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

লংগেস্ট কমন সাবসিকোয়েন্স

Longest common subsequence
৮ মিনিট পড়া উচ্চ · Advanced Python কোডসহ সম্পূর্ণ বাংলায়

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

  • LCS-এর ক্লাসিক ২D DP রিকারেন্স ও প্রতিটি কেসের যুক্তি
  • শেষ ক্যারেক্টার মিলে গেলে কেন সেটি সবসময় LCS-এর অংশ হতে বাধ্য — একটি সুস্পষ্ট প্রমাণ
  • নেইভ এক্সপোনেনশিয়াল রিকার্সন বনাম DP — কোড ও বাস্তব যাচাই
  • সাবসিকোয়েন্স ও সাবস্ট্রিং-এর মধ্যে পার্থক্য স্পষ্টভাবে বোঝা

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

একটি সাবসিকোয়েন্সSubsequenceমূল স্ট্রিং থেকে কিছু ক্যারেক্টার (শূন্য বা তার বেশি) মুছে ফেলে গঠিত স্ট্রিং, কিন্তু অবশিষ্ট ক্যারেক্টারগুলোর আপেক্ষিক ক্রম অপরিবর্তিত থাকে। যেমন "ACE" হলো "ABCDE"-এর একটি সাবসিকোয়েন্স। সাবস্ট্রিং থেকে ভিন্ন — সাবস্ট্রিংয়ে ক্যারেক্টারগুলো পাশাপাশি (কনটিগুয়াস) থাকতে হয়, সাবসিকোয়েন্সে তা লাগে না। দুটি স্ট্রিং $X$ (দৈর্ঘ্য $m$) ও $Y$ (দৈর্ঘ্য $n$) দেওয়া থাকলে, লক্ষ্য এমন একটি স্ট্রিং খুঁজে বের করা যা $X$ ও $Y$ উভয়েরই সাবসিকোয়েন্স এবং যতটা সম্ভব দীর্ঘ — আমাদের শুধু তার দৈর্ঘ্য বের করলেই যথেষ্ট (এটাই এই পাঠের কোডের লক্ষ্য; পুরো সাবসিকোয়েন্সটি পুনর্গঠনের কৌশল DSA কোর্সে দেখানো হয়েছে)।

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

$c[i][j]$ সংজ্ঞায়িত করি $X$-এর প্রথম $i$ ক্যারেক্টার ($x_1 \ldots x_i$) ও $Y$-এর প্রথম $j$ ক্যারেক্টার ($y_1 \ldots y_j$)-এর LCS দৈর্ঘ্য হিসেবে।

$$ c[i][j] = \begin{cases} 0 & i = 0 \text{ অথবা } j = 0 \\ c[i-1][j-1] + 1 & i, j > 0 \text{ এবং } x_i = y_j \\ \max\big(c[i-1][j],\, c[i][j-1]\big) & i, j > 0 \text{ এবং } x_i \ne y_j \end{cases} $$

অপটিমাল সাবস্ট্রাকচার প্রমাণ (কেস-বাই-কেস):

কেস ১ — $x_i = y_j$
দাবি: এই ক্যারেক্টারটি অবশ্যই কোনো না কোনো LCS-এর শেষ ক্যারেক্টার হতে হবে। যদি কোনো অপটিমাল LCS এই মিলটি ব্যবহার না করত, তাহলে সেই LCS-এর শেষে $x_i (= y_j)$ যোগ করে একটি লম্বা কমন সাবসিকোয়েন্স বানানো যেত (কারণ $x_i$ ও $y_j$ উভয় স্ট্রিং-এর ঐ অবস্থানের পরে আর কোনো ক্যারেক্টার ব্যবহৃত হয়নি এই সাবসিকোয়েন্সে) — যা অপটিমালিটির সাথে সাংঘর্ষিক। তাই $c[i][j] = c[i-1][j-1] + 1$।
কেস ২ — $x_i \ne y_j$
দুটোই একসাথে LCS-এর অংশ হতে পারে না (শেষে থাকলে মিলতে হতো)। তাই অপটিমাল LCS হয় $x_i$ ব্যবহার করবে না (তখন এটি $X$-এর প্রথম $i-1$ ও $Y$-এর প্রথম $j$-এর LCS-এর সমান, $c[i-1][j]$), নয়তো $y_j$ ব্যবহার করবে না ($c[i][j-1]$)। দুটোর মধ্যে যেটি বড় সেটিই $c[i][j]$।

দুই ক্ষেত্রেই বড় সমস্যার অপটিমাল উত্তর ছোট সাবপ্রবলেমের অপটিমাল উত্তর থেকে সরাসরি গঠিত হচ্ছে — এটাই অপটিমাল সাবস্ট্রাকচার। আর $c[i-1][j]$-এর মতো একই অবস্থা $c[i][j]$ ও $c[i-1][j+1]$ উভয়ের গণনায় আসতে পারে — এটাই ওভারল্যাপিং সাবপ্রবলেম, যেমনটি L24-এ সাধারণভাবে ব্যাখ্যা করা হয়েছে।

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

উপরের রিকারেন্সটি cache ছাড়া সরাসরি রিকার্সিভভাবে লিখলে এটি $O(2^{m+n})$ সময়ে চলে (প্রতিটি অমিলে দুটি শাখা তৈরি হয়) — একটি ধীর কিন্তু নিঃসন্দেহে সঠিক গ্রাউন্ড ট্রুথ। নিচের কোডে এটি এবং ট্যাবুলেটেড DP পাশাপাশি চালিয়ে একাধিক ছোট র‍্যান্ডম স্ট্রিং জোড়ায় (দৈর্ঘ্য $\le 9$, শুধু 'a'/'b' অক্ষর ব্যবহার করে যাতে মিল ঘন ঘন হয়) তুলনা করা হয়েছে।

Python
import random

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

def lcs_dp(X, Y):
    # ট্যাবুলেটেড DP -- ঠিক একই রিকারেন্স, ইটারেটিভভাবে
    m, n = len(X), len(Y)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    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] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
    return dp[m][n]

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

print("\nক্লাসিক টেক্সটবুক উদাহরণ:")
X, Y = "ABCBDAB", "BDCABA"
print(X, Y, "-> LCS দৈর্ঘ্য:", lcs_dp(X, Y), "(brute:", lcs_brute(X, Y, len(X), len(Y)), ")")

    
বাস্তবে চালালে ১০টি র‍্যান্ডম টেস্ট কেসের প্রতিটিতে brute ও dp কলাম অভিন্ন থাকে (যেমন X="bbbbbbaa", Y="aabbbbbaa" জোড়ায় উভয়ই ৭ দেয়)। ক্লাসিক উদাহরণ $X{=}\text{"ABCBDAB"}$, $Y{=}\text{"BDCABA"}$-এ উভয় পদ্ধতিই ৪ দেয় (যেমন "BCBA" বা "BDAB" — দৈর্ঘ্য ৪-এর একাধিক বৈধ LCS থাকতে পারে, কিন্তু দৈর্ঘ্যটি সবসময় একই)।
মূল কথা · Key takeaway

LCS দেখায় কীভাবে DP-এর রাজ্য দুটি ভিন্ন ইনপুটের (দুটি স্ট্রিং) প্রিফিক্স জোড়া দিয়ে সংজ্ঞায়িত হতে পারে — 0/1 ন্যাপস্যাকের মতো একক ইনপুটের প্রিফিক্স নয়। এই "দুই-মাত্রার প্রিফিক্স" প্যাটার্নটি এডিট ডিস্ট্যান্সেও (L29) আবার আসবে, এবং স্ট্রিং সম্পর্কিত অনেক DP সমস্যার একটি সাধারণ কাঠামো।

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

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

প্র ০১ যদি $X$ ও $Y$-এর মধ্যে কোনো কমন ক্যারেক্টারই না থাকে, তাহলে $c[m][n]$ কত হবে, এবং রিকারেন্স কীভাবে সেটি নিশ্চিত করে?

$c[m][n] = 0$ হবে। যেহেতু কোনো $x_i = y_j$ কখনো সত্য হবে না, তাই প্রতিটি ধাপে "কেস ২" প্রযোজ্য হবে ($\max(c[i-1][j], c[i][j-1])$), যা কখনো $0$-এর বেশি হতে পারে না যদি বেস কেস (যেকোনো ইনডেক্স $0$ হলে) সবসময় $0$ থাকে — এবং ইনডাকশন দিয়ে দেখানো যায় পুরো টেবিলই $0$ থাকবে।

প্র ০২ উপরের প্রমাণে "কেস ১"-এ বলা হয়েছে $x_i = y_j$ হলে এই ক্যারেক্টারটি "কোনো না কোনো" অপটিমাল LCS-এর অংশ। এর মানে কি সব অপটিমাল LCS-এই এটি থাকবে?

না — এর মানে অন্তত একটি অপটিমাল LCS আছে যেখানে এটি ব্যবহৃত হয়, কিন্তু একাধিক ভিন্ন অপটিমাল LCS থাকতে পারে যাদের মধ্যে কিছু এই নির্দিষ্ট মিলটি ব্যবহার নাও করতে পারে (যদি অন্য কোনো মিল দিয়ে একই দৈর্ঘ্য পাওয়া যায়)। DP শুধু দৈর্ঘ্য নিয়ে নিশ্চিত (সেটি একক, স্বতন্ত্র সংখ্যা) — কোন নির্দিষ্ট LCS স্ট্রিংটি বেছে নেওয়া হবে তা একাধিক বৈধ উত্তরের যেকোনো একটি হতে পারে।

প্র ০৩ ব্রুট-ফোর্স ফাংশনটির সময় জটিলতা $O(2^{m+n})$ কেন — LCS-এ তো প্রতিটি ধাপে "নেওয়া/না নেওয়া" দুটি সিদ্ধান্ত নেই মনে হয়?

যদিও উপরিতলে মনে হতে পারে প্রতিটি কলে একটিই "দিক" (হয় $i$ কমে, নয়তো $j$ কমে, নয়তো দুটোই), আসলে "কেস ২"-তে দুটি ভিন্ন রিকার্সিভ কল হয় ($c[i-1][j]$ এবং $c[i][j-1]$) — অর্থাৎ প্রতিটি অমিলে রিকার্সন গাছ দুই ভাগে ভাগ হয়। সবচেয়ে খারাপ ক্ষেত্রে (কোনো মিল না থাকলে) এই শাখাবিভাজন $m+n$ ধাপ পর্যন্ত চলতে পারে, যা $O(2^{m+n})$ কল তৈরি করে — ঠিক যেমন cache ছাড়া ফিবোনাচি $O(2^n)$ কল তৈরি করে।

অনুশীলন

  1. চিন্তা করুন: $X = \text{"AGGTAB"}$ এবং $Y = \text{"GXTXAYB"}$-এর LCS দৈর্ঘ্য হাতে-কলমে বের করার চেষ্টা করুন (হিন্ট: "GTAB" একটি কমন সাবসিকোয়েন্স)।

    LCS দৈর্ঘ্য ৪ — "GTAB" উভয় স্ট্রিং-এরই একটি সাবসিকোয়েন্স ($X$-এ: G-G-T-A-B এর মধ্যে G(২য়)-T-A-B; $Y$-এ: G-X-T-X-A-Y-B এর মধ্যে G-T-A-B), এবং এর চেয়ে দীর্ঘ কোনো কমন সাবসিকোয়েন্স নেই।

  2. পরীক্ষা করুন: উপরের কোড সেলে X, Y = "AGGTAB", "GXTXAYB" বসিয়ে Run চাপুন এবং নিশ্চিত হোন lcs_dp ও lcs_brute উভয়েই আপনার হাতে-গোনা উত্তরের সাথে মেলে।

    উভয় ফাংশনই ৪ ফেরত দেবে, যা উপরের অনুশীলনের হাতে-গোনা উত্তরের সাথে মিলে যায় — এটি DP রিকারেন্স ও তার ব্রুট-ফোর্স ভিত্তির মধ্যে সামঞ্জস্যের আরেকটি নিশ্চিতকরণ।

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

  • কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৭টি পাঠ পরবর্তী পাঠে (L27) আমরা লংগেস্ট ইনক্রিজিং সাবসিকোয়েন্স কভার করব — একক স্ট্রিং/অ্যারের উপর একটি ভিন্ন ধরনের ১D DP রাজ্য।
  • Data Structures & Algorithms কোর্স সহোদর কোর্স LCS-এর ইমপ্লিমেন্টেশন এবং প্রকৃত সাবসিকোয়েন্স স্ট্রিং পুনর্গঠনের কৌশল (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 — সব এক জায়গায়।
আগের পাঠ
0/1 ন্যাপস্যাক প্রবলেম