পাঠ ২৯ · ৫৭-এর মধ্যে · মডিউল ৬
Home / Courses / Design and Analysis of Algorithms / ডাইনামিক প্রোগ্রামিং

এডিট ডিস্ট্যান্স ও অপটিমাল সাবস্ট্রাকচার

Edit distance & optimal substructure
৮ মিনিট পড়া উচ্চ · Advanced Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • এডিট ডিস্ট্যান্সের ২D DP রিকারেন্স এবং তিনটি অপারেশনের প্রতিটির যুক্তি
  • ইনসার্ট/ডিলিট/সাবস্টিটিউট-ভিত্তিক অপটিমাল সাবস্ট্রাকচার প্রমাণ
  • নেইভ এক্সপোনেনশিয়াল রিকার্সন বনাম DP-এর বাস্তব যাচাই
  • M6-এর সব ক'টি DP সমস্যায় (L24-L29) অপটিমাল সাবস্ট্রাকচার ও ওভারল্যাপিং সাবপ্রবলেম কীভাবে একই সাধারণ নীতির ভিন্ন প্রয়োগ — একটি একত্রিত দৃষ্টিভঙ্গি

১ · সমস্যার সংজ্ঞা

দুটি স্ট্রিং $X$ (দৈর্ঘ্য $m$) ও $Y$ (দৈর্ঘ্য $n$) দেওয়া থাকলে, $X$-কে $Y$-তে রূপান্তর করতে ন্যূনতম কতগুলো নিচের অপারেশন লাগবে তা বের করতে হবে — প্রতিটির খরচ $1$:

ইনসার্ট (Insert)
একটি ক্যারেক্টার যোগ করা।
ডিলিট (Delete)
একটি ক্যারেক্টার মুছে ফেলা।
সাবস্টিটিউট (Substitute)
একটি ক্যারেক্টারকে অন্য একটি ক্যারেক্টার দিয়ে প্রতিস্থাপন করা।

এটি বানান-সংশোধক (spell checker), DNA সিকোয়েন্স অ্যালাইনমেন্ট, এবং diff-জাতীয় টুলের একটি মৌলিক উপাদান। L26-এর LCS-এর সাথে গঠনগত মিল আছে (দুটি স্ট্রিং-এর প্রিফিক্স জোড়া দিয়ে রাজ্য), কিন্তু এখানে "মেলে কি না" ছাড়াও তিনটি ভিন্ন অপারেশনের খরচ বিবেচনা করতে হয়।

২ · DP রিকারেন্স

$dp[i][j]$ সংজ্ঞায়িত করি $X$-এর প্রথম $i$ ক্যারেক্টার-কে $Y$-এর প্রথম $j$ ক্যারেক্টারে রূপান্তর করার ন্যূনতম খরচ হিসেবে। বেস কেস: $dp[i][0] = i$ (পুরো $X$-এর প্রিফিক্স মুছে ফেলতে হবে) এবং $dp[0][j] = j$ (পুরো $Y$-এর প্রিফিক্স ইনসার্ট করতে হবে)।

$$ dp[i][j] = \begin{cases} dp[i-1][j-1] & x_i = y_j \\[4pt] 1 + \min\big(\, dp[i-1][j],\;\; dp[i][j-1],\;\; dp[i-1][j-1] \,\big) & x_i \ne y_j \end{cases} $$

যখন $x_i = y_j$, কোনো অপারেশনেরই দরকার নেই এই অবস্থানে — সমস্যাটি সরাসরি $dp[i-1][j-1]$-এ নেমে আসে। যখন $x_i \ne y_j$, তিনটি বিকল্প থেকে সবচেয়ে সস্তাটি বেছে নেওয়া হয় (প্রতিটির নিজস্ব খরচ $1$ যোগ করে):

$dp[i-1][j]$ — ডিলিট
$x_i$-কে $X$ থেকে মুছে ফেলা (এখন $X$-এর প্রথম $i-1$ ক্যারেক্টারকে $Y$-এর প্রথম $j$-এ রূপান্তর করতে হবে)।
$dp[i][j-1]$ — ইনসার্ট
$y_j$-কে $X$-এর শেষে যোগ করা (এখন বাকি থাকল $X$-এর প্রথম $i$-কে $Y$-এর প্রথম $j-1$-এ রূপান্তর করা)।
$dp[i-1][j-1]$ — সাবস্টিটিউট
$x_i$-কে $y_j$ দিয়ে প্রতিস্থাপন করা (এখন বাকি থাকল প্রথম $i-1$ ও প্রথম $j-1$ প্রিফিক্স মেলানো)।

অপটিমাল সাবস্ট্রাকচার প্রমাণ: $X[1..i]$-কে $Y[1..j]$-এ রূপান্তরকারী যেকোনো অপটিমাল অপারেশন-ক্রম বিবেচনা করুন এবং এর শেষ অপারেশনটি দেখুন (যেটি শেষ পর্যন্ত $x_i$ বা $y_j$-এর সাথে সংশ্লিষ্ট অবস্থানকে প্রভাবিত করে)। এটি অবশ্যই তিনটির একটি — ডিলিট, ইনসার্ট, সাবস্টিটিউট, অথবা (মিলে গেলে) কোনো অপারেশনই না। প্রতিটি ক্ষেত্রে, শেষ অপারেশনটি বাদ দিলে যা থাকে তা অবশ্যই সংশ্লিষ্ট ছোট সাবপ্রবলেমের (যথাক্রমে $dp[i-1][j]$, $dp[i][j-1]$, বা $dp[i-1][j-1]$) জন্য একটি অপটিমাল রূপান্তর-ক্রম হতে হবে — কারণ যদি সেই সাবপ্রবলেমের জন্য এর চেয়ে সস্তা কোনো ক্রম থাকত, সেটি ব্যবহার করে (একই শেষ অপারেশন যোগ করে) মূল সমস্যার জন্য একটি সস্তা সমাধান পাওয়া যেত — যা মূল সমাধানের অপটিমাল হওয়ার সাথে সাংঘর্ষিক। যেহেতু আমরা জানি না প্রকৃতপক্ষে কোন অপারেশনটি অপটিমাল সমাধানে ব্যবহৃত হয়েছিল, তাই সবগুলো বিবেচনা করে সবচেয়ে সস্তাটি বেছে নিই — এটাই $\min$ টার্মটির উৎস।

৩ · যাচাই — নেইভ এক্সপোনেনশিয়াল রিকার্সন বনাম DP

Python
import random

def edit_brute(X, Y, i, j):
    # নেইভ এক্সপোনেনশিয়াল রিকার্সন -- কোনো cache নেই
    if i == 0:
        return j
    if j == 0:
        return i
    if X[i - 1] == Y[j - 1]:
        return edit_brute(X, Y, i - 1, j - 1)
    return 1 + min(
        edit_brute(X, Y, i - 1, j),      # ডিলিট
        edit_brute(X, Y, i, j - 1),      # ইনসার্ট
        edit_brute(X, Y, i - 1, j - 1),  # সাবস্টিটিউট
    )

def edit_dp(X, Y):
    # ট্যাবুলেটেড DP -- ঠিক একই রিকারেন্স, ইটারেটিভভাবে
    m, n = len(X), len(Y)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(m + 1):
        dp[i][0] = i
    for j in range(n + 1):
        dp[0][j] = j
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i - 1] == Y[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]
            else:
                dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
    return dp[m][n]

rng = random.Random(5)
alphabet = "abc"
print(f"{'X':>10} | {'Y':>10} | {'brute':>5} | {'dp':>3}")
for trial in range(10):
    lx = rng.randint(1, 8)
    ly = rng.randint(1, 8)
    X = "".join(rng.choice(alphabet) for _ in range(lx))
    Y = "".join(rng.choice(alphabet) for _ in range(ly))
    b = edit_brute(X, Y, len(X), len(Y))
    d = edit_dp(X, Y)
    assert b == d, "মিসম্যাচ!"
    print(f"{X:>10} | {Y:>10} | {b:>5} | {d:>3}")

print("\nক্লাসিক উদাহরণ:")
print("kitten -> sitting:", edit_dp("kitten", "sitting"),
      "(brute:", edit_brute("kitten", "sitting", 6, 7), ")")

    
বাস্তবে চালালে যেমন X="a", Y="bbbbcb" জোড়ায় উভয় পদ্ধতিই ৬ দেয় (৫টি ইনসার্ট + সম্ভবত একটি সাবস্টিটিউট, মোট ৬টি অপারেশন), এবং X="a", Y="a"-তে উভয়েই ০ দেয় (কোনো পরিবর্তনই লাগে না)। ক্লাসিক "kitten" → "sitting" উদাহরণে উভয় পদ্ধতিই ৩ দেয় — যা সুপরিচিত ফলাফলের সাথে মিলে যায় (k→s সাবস্টিটিউট, e→i সাবস্টিটিউট, শেষে g ইনসার্ট)। সব ১০টি র‍্যান্ডম টেস্ট কেসেও brute ও dp কলাম সম্পূর্ণ অভিন্ন।
মডিউল সারসংক্ষেপ · অপটিমাল সাবস্ট্রাকচার ও ওভারল্যাপিং সাবপ্রবলেম — M6 জুড়ে (L24-L29)

এই মডিউলের ছয়টি পাঠ ভিন্ন ভিন্ন সমস্যা কভার করেছে, কিন্তু প্রতিটিই একই দুটি নীতির একটি ভিন্ন প্রয়োগ:

  • L24 (কয়েন কম্বিনেশন): রাজ্য $(i, a)$ — "কতগুলো কয়েন-ধরন বাকি, কত পরিমাণ বাকি"। সাবস্ট্রাকচার: একটি কয়েন ব্যবহার করা/না করার সিদ্ধান্ত থেকে সরাসরি ছোট সাবপ্রবলেম তৈরি হয়।
  • L25 (0/1 ন্যাপস্যাক): রাজ্য $(i, w)$ — "কতগুলো আইটেম বিবেচিত, কত ধারণক্ষমতা বাকি"। সাবস্ট্রাকচার: একটি আইটেম নেওয়া/না নেওয়ার সিদ্ধান্ত।
  • L26 (LCS): রাজ্য $(i, j)$ — দুটি স্ট্রিং-এর প্রিফিক্স জোড়া। সাবস্ট্রাকচার: শেষ ক্যারেক্টার মেলে কি না তার উপর ভিত্তি করে কেস-বিভাজন।
  • L27 (LIS): রাজ্য $i$ — "কোন ইনডেক্সে সাবসিকোয়েন্স শেষ হয়"। সাবস্ট্রাকচার: শেষের ঠিক আগের এলিমেন্ট বাদ দিলে ছোট সাবপ্রবলেম।
  • L28 (ম্যাট্রিক্স চেইন): রাজ্য $(i, j)$ — একটি ইন্টারভাল। সাবস্ট্রাকচার: একটি স্প্লিট পয়েন্টে ভাগ করলে দুটি স্বাধীন সাবচেইন।
  • L29 (এডিট ডিস্ট্যান্স): রাজ্য $(i, j)$ — দুটি স্ট্রিং-এর প্রিফিক্স জোড়া। সাবস্ট্রাকচার: শেষ অপারেশন (ইনসার্ট/ডিলিট/সাবস্টিটিউট/কিছু না) অনুযায়ী কেস-বিভাজন।

প্রতিটি ক্ষেত্রেই একই দুটি প্রশ্ন জিজ্ঞাসা করা হয়েছে — (১) অপটিমাল সমাধান কি ছোট সাবপ্রবলেমের অপটিমাল সমাধান থেকে গঠিত? (হ্যাঁ হলে অপটিমাল সাবস্ট্রাকচার আছে) এবং (২) একই সাবপ্রবলেম কি বহু ভিন্ন পথ থেকে বারবার আসে? (হ্যাঁ হলে ওভারল্যাপিং সাবপ্রবলেম আছে, cache/টেবিল লাভজনক)। আর প্রতিটি পাঠেই যাচাইয়ের পদ্ধতি একই ছিল — একটি নেইভ, cache-বিহীন রিকার্সিভ বাস্তবায়ন (যা সংজ্ঞা থেকে সরাসরি সঠিক, কিন্তু ধীর) বনাম DP বাস্তবায়ন (যা একই রিকারেন্স সমাধান করে, কিন্তু দ্রুত) — দুটি স্বাধীনভাবে লেখা কোড একমত হওয়াই সবচেয়ে জোরালো প্রমাণ যে রিকারেন্সটি সঠিকভাবে বাস্তবায়িত হয়েছে। M7 থেকে আমরা গ্রাফ অ্যালগরিদমে যাব, যেখানে সঠিকতার প্রমাণ-পদ্ধতি ভিন্ন হবে (কাট প্রপার্টি, ইনভেরিয়েন্ট) কিন্তু "বাস্তবে যাচাই করা" নীতিটি একই থাকবে।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।

প্র ০১ যদি সাবস্টিটিউট অপারেশনের খরচ $2$ করা হয় (ইনসার্ট/ডিলিট এখনও $1$), তাহলে রিকারেন্সে কী পরিবর্তন লাগবে?

শুধু $x_i \ne y_j$ কেসে সাবস্টিটিউট টার্মের খরচ বদলাতে হবে: $dp[i][j] = \min\big(1 + dp[i-1][j],\ 1 + dp[i][j-1],\ 2 + dp[i-1][j-1]\big)$। বাকি কাঠামো (রাজ্য সংজ্ঞা, বেস কেস) অপরিবর্তিত থাকবে — এটি দেখায় কীভাবে DP রিকারেন্স সহজেই বিভিন্ন খরচ-মডেলের জন্য মানিয়ে নেওয়া যায়, যতক্ষণ অপটিমাল সাবস্ট্রাকচার বজায় থাকে।

প্র ০২ $X$ ও $Y$ সম্পূর্ণ অভিন্ন হলে ($X = Y$) $dp[m][n]$ কত হবে, এবং কেন?

$dp[m][n] = 0$। যেহেতু প্রতিটি অবস্থানে $x_i = y_i$, তাই রিকারেন্সের প্রথম শাখা ($dp[i-1][j-1]$, কোনো অতিরিক্ত খরচ ছাড়া) সবসময় প্রযোজ্য হবে কর্ণ বরাবর, এবং শেষ পর্যন্ত $dp[0][0] = 0$-এ পৌঁছাবে — কোনো অপারেশনই দরকার নেই যেহেতু স্ট্রিং দুটি ইতিমধ্যে অভিন্ন।

প্র ০৩ এডিট ডিস্ট্যান্স ও LCS (L26)-এর DP রাজ্য দুটোই $(i, j)$ — প্রিফিক্স জোড়া। এই দুই সমস্যার রিকারেন্সের মূল পার্থক্য কী?

LCS-এ অমিলের ক্ষেত্রে শুধু দুটি বিকল্প থাকে ($dp[i-1][j]$ অথবা $dp[i][j-1]$ — কারণ LCS-এ শুধু ক্যারেক্টার "বাদ দেওয়া" যায়, "প্রতিস্থাপন" নয়), এবং কোনো খরচ যোগ হয় না (শুধু $\max$ নেওয়া হয়, লক্ষ্য দৈর্ঘ্য সর্বোচ্চ করা)। এডিট ডিস্ট্যান্সে অমিলের ক্ষেত্রে তিনটি বিকল্প থাকে (সাবস্টিটিউট অতিরিক্ত), এবং প্রতিটি বিকল্পে $+1$ খরচ যোগ হয় (লক্ষ্য খরচ সর্বনিম্ন করা)। দুটি সমস্যাই একই "দুই-মাত্রার প্রিফিক্স" কাঠামো ব্যবহার করে, কিন্তু অপটিমাইজেশনের দিক (সর্বোচ্চ বনাম সর্বনিম্ন) ও সম্ভাব্য অপারেশনের সংখ্যা ভিন্ন।

অনুশীলন

  1. চিন্তা করুন: $X = \text{"horse"}$, $Y = \text{"ros"}$-এর এডিট ডিস্ট্যান্স হাতে-কলমে বের করার চেষ্টা করুন।

    এডিট ডিস্ট্যান্স ৩ — একটি সম্ভাব্য ক্রম: "horse" → "rorse" (h→r সাবস্টিটিউট) → "rose" (r ডিলিট, ২য় অবস্থানের) → "ros" (e ডিলিট)। মোট ৩টি অপারেশন, এবং এর চেয়ে কম কোনো ক্রমে সম্ভব নয়।

  2. পরীক্ষা করুন: উপরের কোড সেলে edit_dp("horse", "ros") ও edit_brute("horse", "ros", 5, 3) কল করে Run চাপুন, ফলাফল আপনার হাতে-গোনা উত্তরের সাথে মিলছে কি না দেখুন।

    দুটো ফাংশনই ৩ ফেরত দেবে — উপরের হাতে-গোনা উত্তরের সাথে হুবহু মিলে যায়, এবং এটি LeetCode-এর সুপরিচিত "Edit Distance" সমস্যার একটি ক্লাসিক টেস্ট কেসও বটে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

  • কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৭টি পাঠ M6 (ডাইনামিক প্রোগ্রামিং) এখানেই শেষ। পরবর্তী মডিউলে (M7, L30 থেকে) আমরা গ্রাফ অ্যালগরিদমে যাব — BFS/DFS-এর কমপ্লেক্সিটি ও সঠিকতা প্রমাণ দিয়ে শুরু হবে।
  • Data Structures & Algorithms কোর্স সহোদর কোর্স এডিট ডিস্ট্যান্সের ইমপ্লিমেন্টেশন এবং প্রকৃত অপারেশন-ক্রম পুনর্গঠন (backtracking through the DP table) সেই কোর্সে দেখানো হয়েছে।
  • সব Courses দেখুন ABCL TECH C, C++, Python, Java, JavaScript, DSA, DBMS, Discrete Mathematics, System Design, Cybersecurity, Cloud Computing & DevOps, Computer Networks, Operating Systems, Computer Architecture, Programming Languages & Compiler Design, Software Engineering & Git, Theory of Computation, Engineering Economics, Full-Stack Web Frameworks, Mobile App Development, Ethics in Computing & AI Safety, Software Testing & Quality Assurance ও Design and Analysis of Algorithms — সব এক জায়গায়।
আগের পাঠ
ম্যাট্রিক্স চেইন মাল্টিপ্লিকেশন