এই পর্বে যা শিখবেন
- Subsequence আর substring-এর পার্থক্য, আর LCS-এর DP সূত্র প্রমাণসহ লেখা
- LCS আর edit distance-এর টেবিল হাতে ভরা, আর টেবিল থেকে উত্তর (diff বা অপারেশনের তালিকা) ফিরিয়ে আনা
- সময় আর জায়গার হিসাব — দুই সারির কৌশল, আর Hirschberg কেন দরকার
- Longest common substring, palindromic subsequence আর sequence alignment-কে একই ছাঁচে চেনা
- Interval DP: matrix chain-এর সূত্র, কর্ণ ধরে টেবিল ভরা, বন্ধনী পুনর্গঠন আর অন্য interval সমস্যায় একই কৌশল
১ · দুটি sequence মেলানো — diff, DNA আর বানান
পর্ব ৫২-এ (Dynamic programming-এর ভিত্তি) দেখেছেন, dynamic programming কাজ করে যখন দুটি শর্ত থাকে: বড় সমস্যার সেরা উত্তর ছোট সমস্যার সেরা উত্তর দিয়ে গড়া যায় (optimal substructure), আর একই ছোট সমস্যা বারবার ফিরে আসে (overlapping subproblems). পর্ব ৫৩-এ (DP-র ক্লাসিক সমস্যা ১) knapsack, coin change আর LIS-এ সেই ধারণা খাটিয়েছেন — সেখানে ইনপুট ছিল একটি তালিকা। এই পর্বের প্রথম ভাগে ইনপুট দুটি sequence, আর প্রশ্ন: এরা কতটা মেলে? দ্বিতীয় ভাগে আসবে নতুন এক ধাঁচ — interval-এর ওপর DP.
দুটি sequence মেলানোর প্রশ্ন আপনি প্রতিদিনই দেখেন, হয়তো খেয়াল না করে:
- diff আর git: একটি ফাইলের পুরোনো আর নতুন সংস্করণে কোন লাইন বাদ গেছে (−), কোন লাইন নতুন এসেছে (+), আর কোনগুলো একই আছে — git-এর প্রতিটি commit-এর পরিবর্তন এভাবেই দেখানো হয়। “একই আছে” এমন লাইনগুলো দুই ফাইলের একটি সবচেয়ে লম্বা সাধারণ ক্রম।
- DNA তুলনা: DNA হলো A, C, G, T — চারটি অক্ষরের একটি লম্বা স্ট্রিং। দুটি প্রাণীর একই জিন কতটা মেলে, কোথায় অক্ষর বদলেছে, কোথায় বাদ পড়েছে — জীববিজ্ঞানীরা এটিই হিসাব করেন।
- বানান-সংশোধক: ফোনে ইংরেজি অক্ষরে বাংলা লিখছেন (Banglish) — “bhalo” বা “valo” লিখতে গিয়ে ভুলে লিখলেন “bhlo”. কিবোর্ড অ্যাপ বা phonetic টাইপিং সফটওয়্যার অভিধানের কোন শব্দটি আপনার লেখার সবচেয়ে কাছে, তা বের করে পরামর্শ দেয়। “কাছে” মাপার একটি সংখ্যা লাগে।
- নকল ধরা: দুটি অ্যাসাইনমেন্ট বা দুটি প্রোগ্রামের কতটা অংশ একই ক্রমে মেলে, তা দেখে plagiarism-এর সন্দেহ করা হয়।
Subsequence আর substring — একটি জরুরি পার্থক্য
একটি স্ট্রিং থেকে কিছু অক্ষর (শূন্য বা বেশি) বাদ দিয়ে, বাকিগুলোর ক্রম না বদলে যা থাকে, সেটি একটি subsequence. যেমন “GATCA” থেকে A আর C বাদ দিলে “GTA” — এটি subsequence, যদিও G, T, A মূল স্ট্রিংয়ে পাশাপাশি নেই। অন্যদিকে substring মানে পাশাপাশি থাকা একটি টুকরো: “ATC” হলো “GATCA”-র substring, “GTA” নয়। প্রতিটি substring একটি subsequence, কিন্তু উল্টোটা সত্য নয়। n অক্ষরের স্ট্রিংয়ে substring মোটে প্রায় n²/2 টি, কিন্তু subsequence 2n টি — প্রতিটি অক্ষর রাখা বা না রাখা।
উপমা — দুই বন্ধুর বাজারের তালিকা। রহিম আর করিম দুজনেই কাওরান বাজারে যাবেন, আর প্রত্যেকে নিজের তালিকা লিখেছেন বাজারে দোকানগুলো যে ক্রমে পড়ে সেই ক্রমে। দুজনে একসাথে হাঁটতে চান, এক দোকানে একসাথে ঢুকতে চান, কিন্তু কেউ পেছনে ফিরবেন না। সবচেয়ে বেশি কয়টি দোকানে তাঁরা একসাথে কেনাকাটা করতে পারবেন? যে দোকান শুধু একজনের তালিকায়, সেখানে অন্যজন বাইরে দাঁড়ান (বাদ দেওয়া), কিন্তু ক্রম কেউ ভাঙেন না। দুই তালিকার এই সবচেয়ে লম্বা সাধারণ ক্রমই longest common subsequence — সংক্ষেপে LCS.
LCS-এর brute force উপায়: প্রথম স্ট্রিং X-এর (দৈর্ঘ্য n) প্রতিটি subsequence নিন, দেখুন সেটি Y-এরও (দৈর্ঘ্য m) subsequence কি না — প্রতিটি যাচাইয়ে দুই pointer দিয়ে O(m) সময় লাগে। মোট O(m · 2n). n = 50 হলেই 250 ≈ 1015 — পৃথিবীর দ্রুততম কম্পিউটারেও অবাস্তব। অথচ DP দিয়ে n = m = 5000-এর LCS এক সেকেন্ডেরও কম সময়ে বের হয়। পার্থক্যটা exponential থেকে polynomial — ঠিক পর্ব ৫২-এ Fibonacci-তে যেমন দেখেছেন।
২ · LCS — দুই prefix-এর DP আর তার প্রমাণ
DP-র প্রথম কাজ state ঠিক করা: কোন ছোট প্রশ্নগুলোর উত্তর জানলে বড়টির উত্তর আসবে? দুটি sequence-এর সমস্যায় প্রায় সবসময় উত্তর একই — দুই prefix. এই অংশের গণিতে স্ট্রিং 1-based: X = x1x2…xn, Y = y1…ym, আর Xi মানে X-এর প্রথম i টি অক্ষর।
কোনো একটি স্ট্রিং খালি হলে সাধারণ কিছুই নেই: L[0][j] = L[i][0] = 0 — এগুলো base case. এবার শেষ অক্ষর দুটি xi আর yj দেখুন। দুটি ঘটনা:
কেন সূত্রটি ঠিক — প্রমাণ
বিশ্ববিদ্যালয় বা চাকরির লিখিত পরীক্ষায় এই প্রমাণটি প্রায়ই চাওয়া হয়। ধরুন Z = z1…zk হলো Xi আর Yj-এর একটি LCS (k = L[i][j]).
- xi = yj হলে: প্রথমে দাবি, zk = xi. যদি না হতো, তাহলে Z-এর শেষে xi জুড়ে দিলে দৈর্ঘ্য k + 1-এর একটি সাধারণ subsequence পেতাম (দুই স্ট্রিংয়েই xi সবার শেষে, Z-এর সব অক্ষরের পরে) — Z সবচেয়ে লম্বা, এই কথার সাথে স্ববিরোধ. তাহলে z1…zk−1 হলো Xi−1 আর Yj−1-এর একটি সাধারণ subsequence. এটিই সবচেয়ে লম্বা: আরও লম্বা কোনো W থাকলে W-এর শেষে xi জুড়ে দিয়ে Z-এর চেয়ে লম্বা উত্তর পেতাম। তাই L[i][j] = L[i−1][j−1] + 1.
- xi ≠ yj হলে: zk একসাথে xi আর yj দুটোই হতে পারে না। zk ≠ xi হলে xi উত্তরে কাজে লাগেনি, তাই Z আসলে Xi−1 আর Yj-এর সাধারণ subsequence, অর্থাৎ k ≤ L[i−1][j]. একইভাবে zk ≠ yj হলে k ≤ L[i][j−1]. আর উল্টো দিকে, ছোট prefix-এর যেকোনো সাধারণ subsequence বড় prefix-এরও সাধারণ subsequence, তাই k ≥ দুটোর max. দুই দিক মিলে k = max(L[i−1][j], L[i][j−1]).
এই যুক্তির ধরনকে বলে cut-and-paste: “উপ-সমস্যার উত্তর সেরা না হলে, সেটি কেটে সেরাটি বসিয়ে বড় উত্তরকেও ভালো করা যেত” — পর্ব ৫২-এর optimal substructure-এর প্রমাণের সাধারণ কৌশল। একই যুক্তি এই পর্বের প্রতিটি সমস্যায় খাটবে।
কেন memo বা টেবিল লাগে
সূত্রটি সরাসরি recursion হিসেবে লিখলে, অক্ষর না মিললে প্রতিটি ডাক দুটি নতুন ডাক দেয়, আর গভীরতা n + m পর্যন্ত — worst case-এ প্রায় 2n+m ডাক। কিন্তু ভিন্ন ভিন্ন প্রশ্ন (i, j) আছে মাত্র (n + 1)(m + 1) টি; L(i−1, j−1) প্রশ্নটি L(i−1, j) আর L(i, j−1) দুই দিক থেকেই আসে। তাই হয় memoization (প্রতিটি (i, j)-এর উত্তর একবার হিসাব করে রেখে দেওয়া), নয়তো tabulation — নিচ থেকে টেবিল ভরা। এখানে tabulation সহজ: প্রতিটি ঘর নির্ভর করে শুধু তার ওপরের, বাঁয়ের আর ওপর-বাঁয়ের ঘরের ওপর, তাই সারি ধরে বাঁ থেকে ডানে ভরলেই প্রতিটি ঘরের দরকারি মান আগে থেকে তৈরি থাকে।
Pseudocode — LCS length (strings 1-based: x[1..n], y[1..m])
for i = 0..n: L[i][0] = 0
for j = 0..m: L[0][j] = 0
for i = 1..n:
for j = 1..m:
if x[i] == y[j]: L[i][j] = L[i-1][j-1] + 1 // diagonal ↖
else if L[i-1][j] >= L[i][j-1]: L[i][j] = L[i-1][j] // up ↑
else: L[i][j] = L[i][j-1] // left ←
return L[n][m]প্রতিটি ঘরে তীর-চিহ্ন (↖, ↑, ←) মনে রাখার মানে, মানটি কোন দিক থেকে এসেছে। উত্তর ফিরে পেতে এই তীরগুলোই কাজে লাগবে। আর লক্ষ করুন ওপর আর বাঁ সমান হলে আমরা ↑ নিচ্ছি — একটি নির্দিষ্ট নিয়ম, যাতে হাতে করা trace আর কোডের আউটপুট একই হয়।
৩ · LCS-এর টেবিল হাতে ভরা আর উত্তর ফিরে পাওয়া
দুটি ছোট DNA টুকরো নিন: X = GATCA (সারি, i), Y = GCTA (কলাম, j). খালি prefix-কে ε দিয়ে দেখানো হলো। প্রতিটি ঘরে তীর আর মান; “·” মানে এখনো ভরা হয়নি।
| সারি | |
| লক্ষ করার ঘর | |
| এ পর্যন্ত সেরা |
- Base case. সারি ε আর কলাম ε-তে সব 0 — একটি স্ট্রিং খালি হলে সাধারণ কিছু নেই। এবার সারি ধরে, বাঁ থেকে ডানে ভরা হবে।
- সারি G. L[1][1]: G = G, তাই কোণাকুণি 0 + 1 = 1 (↖). বাকি তিনটিতে G মেলে না C, T, A-র সাথে; ওপরে 0, বাঁয়ে 1, তাই বাঁ থেকে 1 (←). অর্থ: “G” আর “GC”, “GCT”, “GCTA”-র LCS = 1.
- সারি A. প্রথম তিন ঘরে A মেলে না; ওপর আর বাঁ সমান হলে (যেমন L[2][2]-এ দুটোই 1) নিয়ম অনুযায়ী ↑. শেষ ঘরে A = A: L[1][3] + 1 = 2 (↖) — “GA” আর “GCTA”-র LCS “GA”.
- সারি T. L[3][3]: T = T, তাই L[2][2] + 1 = 2. L[3][4]: T ≠ A, ওপরে 2, বাঁয়ে 2 — ↑ থেকে 2. “GAT” আর “GCT”-এর LCS এখন “GT”.
- সারি C. L[4][2]: C = C, L[3][1] + 1 = 1 + 1 = 2. পরের দুটি ঘরে ওপর থেকে 2. “GATC” আর “GC”-এর LCS এখন “GC”.
- সারি A. L[5][4]: A = A, L[4][3] + 1 = 2 + 1 = 3. টেবিলের কোণের এই সংখ্যাটিই উত্তর: LCS-এর দৈর্ঘ্য 3. মোট 5 × 4 = 20 টি ঘর ভরা হলো, প্রতিটিতে O(1) কাজ।
- পেছনে হাঁটা. (5,4)-এ ↖: A নিন, যান (4,3). সেখানে ↑ যান (3,3). ↖: T নিন, যান (2,2). ↑ যান (1,2). ← যান (1,1). ↖: G নিন। সংগ্রহ উল্টো ক্রমে A, T, G — উল্টে দিলে LCS = GTA.
পুনর্গঠন — দৈর্ঘ্য থেকে আসল sequence
টেবিল শুধু দৈর্ঘ্য দেয়। আসল LCS পেতে কোণ (n, m) থেকে শুরু করে তীর ধরে পেছনে হাঁটুন: ↖ হলে অক্ষরটি উত্তরে যোগ করে কোণাকুণি সরুন, ↑ হলে ওপরে, ← হলে বাঁয়ে। প্রতি ধাপে i বা j (বা দুটোই) এক কমে, তাই এই reconstruction O(n + m) সময় নেয়। তীর আলাদা করে রাখারও দরকার নেই — প্রতিটি ঘরে দাঁড়িয়ে আবার তুলনা করলেই বোঝা যায় মান কোথা থেকে এসেছে।
একটি সূক্ষ্ম কথা: LCS একটিই হবে, এমন নয়। এখানে “GCA”-ও একটি LCS — G, C, A দুই স্ট্রিংয়েই এই ক্রমে আছে। আমরা সমান হলে ↑ বেছেছি বলে GTA এসেছে; সমান হলে ← বাছলে GCA আসত। দুটোই সঠিক। পরীক্ষায় “একটি LCS লিখুন” বললে যেকোনো একটি চলে, কিন্তু কোড আর হাতের trace মেলাতে চাইলে একই নিয়ম মানতে হবে। সব ভিন্ন LCS তালিকা করতে চাইলে সেগুলোর সংখ্যা নিজেই exponential হতে পারে।
পেছনে হাঁটার সময় যা পাওয়া যায় তা আসলে একটি পূর্ণ diff: ↖ মানে দুই দিকেই আছে (অপরিবর্তিত), ↑ মানে শুধু X-এ আছে (“−”, মুছে ফেলা), ← মানে শুধু Y-এ (“+”, নতুন যোগ)। আমাদের উদাহরণে diff দাঁড়ায় G +C −A T −C A — অর্থাৎ GATCA থেকে A আর C মুছে আর একটি C যোগ করে GCTA. git ঠিক এভাবেই, অক্ষরের বদলে পুরো লাইন ধরে, পরিবর্তন দেখায় (যদিও তার অ্যালগরিদম আরও চতুর — ভাবনার প্রশ্ন ১ দেখুন)।
খরচ, আর দুই সারির কৌশল
(n + 1)(m + 1) টি ঘর, প্রতিটিতে একটি তুলনা আর একটি max — সময় Θ(nm), পুরো টেবিল রাখলে জায়গা Θ(nm). n = m = 104 হলে 108 ঘর: C++-এ সময় এক সেকেন্ডের কাছাকাছি, কিন্তু int-এর টেবিলে প্রায় 400 MB মেমরি — সাধারণ কনটেস্টের 256 MB সীমা পেরিয়ে যায়। অথচ সারি i ভরতে লাগে শুধু সারি i − 1. তাই শুধু দৈর্ঘ্য চাইলে দুটি সারি রাখুন (rolling array), আর ছোট স্ট্রিংটিকে কলাম বানান: জায়গা O(min(n, m)). এটি time–space trade-off নয় — সময় একই থাকে, শুধু জায়গা কমে।
// LCS length in O(min(n, m)) extra space: keep only the previous row
int lcsLength(const string& a, const string& b) { // pass the shorter string as b
vector<int> prev(b.size() + 1, 0), cur(b.size() + 1, 0);
for (char c : a) {
for (size_t j = 1; j <= b.size(); ++j)
cur[j] = (c == b[j-1]) ? prev[j-1] + 1 : max(prev[j], cur[j-1]);
swap(prev, cur); // cur[0] stays 0 for every row
}
return prev[b.size()];
}কিন্তু দুই সারি রাখলে তীরগুলো হারিয়ে যায় — আসল LCS আর ফিরে পাওয়া যায় না। ১৯৭৫ সালে Daniel Hirschberg এর একটি সুন্দর সমাধান দেন: প্রথম স্ট্রিংটিকে মাঝখানে ভাগ করুন, ওপরের অর্ধেকের জন্য সামনে থেকে আর নিচের অর্ধেকের জন্য পেছন থেকে দুই সারির DP চালান, আর দেখুন দ্বিতীয় স্ট্রিংয়ের কোন বিন্দুতে ভাগ করলে দুই অর্ধেকের যোগফল সবচেয়ে বড়। তারপর দুই ভাগে divide and conquer (পর্ব ৪৯) চালান। Hirschberg-এর অ্যালগরিদম মোট O(nm) সময়েই (প্রতি স্তরে কাজ অর্ধেক হয়, তাই মোট প্রায় 2nm) আর O(n + m) জায়গায় পুরো LCS দেয়। লম্বা DNA-র alignment-এ এই কৌশল এখনো ব্যবহৃত হয়।
৪ · Edit distance — insert, delete আর replace
ধরুন একটি Banglish কিবোর্ডের অভিধানে আছে “valo”, আর ব্যবহারকারী লিখলেন “bhalo”. কোন শব্দ পরামর্শ দেবেন, তা ঠিক করতে দরকার একটি দূরত্ব: সবচেয়ে কম কয়টি এক-অক্ষরের পরিবর্তনে একটি শব্দ আরেকটি হয়? পরিবর্তন তিন রকম — একটি অক্ষর যোগ (insert), মুছে ফেলা (delete), বা অন্য অক্ষরে বদলে দেওয়া (replace), প্রতিটির খরচ 1. এই সংখ্যার নাম edit distance, আর সোভিয়েত গণিতবিদ Vladimir Levenshtein-এর নামে একে Levenshtein distance-ও বলে। “bhalo” থেকে “valo”: b মুছে দিন, h-কে v বানান — ২টি ধাপ। একটিতে কি সম্ভব? না: দৈর্ঘ্যই আলাদা (৫ আর ৪), তাই অন্তত একটি delete লাগবে, আর শুধু একটি অক্ষর মুছে “bhalo” থেকে “valo” হয় না।
State আর সূত্র
LCS-এর মতোই দুই prefix: D[i][j] = a-এর প্রথম i টি অক্ষরকে b-এর প্রথম j টি অক্ষরে বদলানোর সবচেয়ে কম খরচ। base case এবার 0 নয়: খালি স্ট্রিং থেকে j অক্ষরের স্ট্রিং বানাতে j টি insert, তাই D[0][j] = j; i অক্ষরকে খালি করতে i টি delete, তাই D[i][0] = i.
এখানে [ai ≠ bj] মানে অক্ষর আলাদা হলে 1, একই হলে 0. যুক্তিটি সবচেয়ে ভালো বোঝা যায় দুই শব্দকে একটির নিচে আরেকটি সাজিয়ে — একটি alignment. সেরা সাজানোর একেবারে শেষ কলামটি তিনটির একটি হতেই হবে: (১) ai-এর নিচে ফাঁকা — ai মুছে ফেলা হয়েছে, বাকি খরচ D[i−1][j]; (২) bj-এর ওপরে ফাঁকা — bj যোগ হয়েছে, বাকি D[i][j−1]; (৩) ai-এর নিচে bj — মিললে খরচ 0, না মিললে একটি replace. শেষ কলাম বাদ দিলে যা থাকে তা ছোট prefix-দুটির একটি alignment, আর cut-and-paste যুক্তিতে সেটিকেও সেরা হতে হবে। তিনটির মধ্যে সবচেয়ে কমটিই উত্তর। ∎
| সারি | |
| লক্ষ করার ঘর | |
| অর্থ |
- Base case. সারি ε: খালি থেকে “v”, “va”, “val”, “valo” বানাতে 1, 2, 3, 4 টি insert. কলাম ε: “b”, “bh”, … খালি করতে 1 থেকে 5 টি delete. LCS-এ বর্ডার ছিল 0 — এখানে নয়, এটিই সবচেয়ে প্রচলিত ভুল।
- সারি b. D[1][1]: b ≠ v, তিন বিকল্প — ওপর থেকে delete 1 + 1 = 2, বাঁ থেকে insert 1 + 1 = 2, কোণাকুণি replace 0 + 1 = 1. সবচেয়ে কম 1. বাকি তিন ঘরে 2, 3, 4 — “b”-কে “va”, “val”, “valo” বানাতে প্রতিবার একটি করে অক্ষর বেশি লাগে।
- সারি h. D[2][1] = min(1 + 1, 2 + 1, 1 + 1) = 2. D[2][2]: h ≠ a, কোণাকুণি D[1][1] + 1 = 2. D[2][3] = 3, D[2][4] = 4. এখনো কোনো অক্ষর মেলেনি।
- সারি a. D[3][2]: a = a — কোণাকুণি D[2][1] + 0 = 2, যা ওপর (2 + 1) বা বাঁ (3 + 1)-এর চেয়ে কম। প্রথম মিল: “bha” → “va” খরচ 2, কারণ “bh” → “v” খরচই 2.
- সারি l. D[4][3]: l = l, কোণাকুণি D[3][2] + 0 = 2. D[4][4]: l ≠ o, বাঁ থেকে 2 + 1 = 3. মিলের ঘরগুলো কোণাকুণি একটি “সস্তা পথ” তৈরি করছে।
- সারি o. D[5][4]: o = o, D[4][3] + 0 = 2. কোণের মান = edit distance = 2. মোট 5 × 4 = 20 ঘর, প্রতিটিতে তিনটি সংখ্যার min — সময় Θ(nm).
- অপারেশন ফিরে পাওয়া. কোণ থেকে পেছনে, প্রতিটি ঘরে দেখুন কোন বিকল্পটি মান দিয়েছে (আগে কোণাকুণি, তারপর ওপর, তারপর বাঁ যাচাই)। o, l, a তিনটি মিল (খরচ 0). (2,1)-এ h বনাম v: কোণাকুণি 1 + 1 = 2 — replace h → v. (1,0)-এ শুধু ওপরে যাওয়া যায় — delete b. সাজিয়ে লিখলে: delete b, replace h → v.
এখানেও একাধিক সমান-ভালো উত্তর আছে: “replace b → v, তারপর delete h”-ও ২টি ধাপ। কোন সমান বিকল্প আগে যাচাই করা হবে, তার ওপর নির্ভর করে কোনটি ছাপা হবে। সময় Θ(nm); শুধু দূরত্ব চাইলে LCS-এর মতোই দুই সারি, জায়গা O(min(n, m)); অপারেশনের তালিকা চাইলে পুরো টেবিল বা Hirschberg-এর কৌশল।
LCS-এর সাথে সম্পর্ক, আর বাস্তবের রূপভেদ
- শুধু insert আর delete চললে: replace নিষেধ হলে দূরত্ব = n + m − 2 · LCS. কারণ LCS-এর অক্ষরগুলো রেখে বাকি সব a থেকে মুছে, b-এর বাকিগুলো যোগ করতে হয়। “bhalo” আর “valo”-র LCS “alo” (3), তাই 5 + 4 − 6 = 3 — replace থাকলে যা 2. diff ঠিক এই মডেলটি ব্যবহার করে: লাইন হয় মোছা হয়, নয়তো যোগ হয়, “বদলানো” লাইন আসলে একটি − আর একটি +.
- Transposition: টাইপ করার সময় পাশাপাশি দুটি অক্ষর উল্টে যাওয়া (“teh” লিখতে চেয়েছিলেন “the”) খুব সাধারণ। Levenshtein-এ এর খরচ 2, অথচ মানুষের চোখে একটিই ভুল। ১৯৬৪ সালে Frederick Damerau দেখান, বানান-ভুলের বেশিরভাগই একটিমাত্র insert, delete, replace বা পাশাপাশি দুটি অক্ষরের অদলবদল। সূত্রে একটি চতুর্থ বিকল্প যোগ করলে — ai = bj−1 আর ai−1 = bj হলে D[i−2][j−2] + 1 — পাওয়া যায় Damerau-Levenshtein-এর একটি প্রচলিত রূপ (একে optimal string alignment distance-ও বলে)। খরচ একই Θ(nm).
- ভিন্ন ভিন্ন খরচ: কিবোর্ডে পাশাপাশি থাকা অক্ষরের replace (যেমন Banglish-এ b আর v, s আর sh-এর গোলমাল) কম খরচের ধরা যায়। সূত্রে “+ 1”-এর জায়গায় শুধু একটি খরচের টেবিল বসে; DP একই থাকে।
দুটি sequence-এর যেকোনো “মেলানো”-র সমস্যায় প্রশ্ন করুন: সেরা alignment-এর শেষ কলামে কী থাকতে পারে? প্রতিটি সম্ভাবনা একটি ছোট prefix-জোড়ায় নিয়ে যায় — সেগুলোর min বা max-ই সূত্র। LCS, edit distance, alignment, এমনকি wildcard দিয়ে স্ট্রিং মেলানোর DP — সব এই এক প্রশ্ন থেকে আসে।
৫ · কোডে — LCS-এর diff আর edit distance-এর অপারেশন
এবার দুটি অ্যালগরিদম একসাথে, পুনর্গঠনসহ। lcsDiff LCS আর diff (− মানে শুধু প্রথমটিতে, + মানে শুধু দ্বিতীয়টিতে) ছাপে; edit দূরত্ব আর অপারেশনের তালিকা। ইনপুট: ওপরের দুই ডায়াগ্রামের স্ট্রিং, পাঠ্যবইয়ের পরিচিত ABCBDAB/BDCABA, আর একটি লম্বা Banglish শব্দ। কোডে স্ট্রিং 0-based, তাই টেবিলের i-তম সারির অক্ষর a[i-1].
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
using namespace std;
using Table = vector<vector<int>>;
void lcsDiff(const string& a, const string& b) {
int n = a.size(), m = b.size();
Table L(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j)
L[i][j] = a[i-1] == b[j-1] ? L[i-1][j-1] + 1 : max(L[i-1][j], L[i][j-1]);
vector<string> out; string common; // walk back from the corner L[n][m]
for (int i = n, j = m; i > 0 || j > 0; )
if (i && j && a[i-1] == b[j-1]) { common += a[i-1]; out.push_back(string(1, a[--i])); --j; }
else if (i && (!j || L[i-1][j] >= L[i][j-1])) out.push_back("-" + string(1, a[--i]));
else out.push_back("+" + string(1, b[--j]));
reverse(common.begin(), common.end());
cout << "LCS(" << a << ", " << b << ") = " << common << ", length " << L[n][m] << "\n diff:";
for (auto it = out.rbegin(); it != out.rend(); ++it) cout << ' ' << *it;
cout << '\n';
}
void edit(const string& a, const string& b) {
int n = a.size(), m = b.size();
Table D(n + 1, vector<int>(m + 1));
for (int i = 0; i <= n; ++i) for (int j = 0; j <= m; ++j)
D[i][j] = (i == 0 || j == 0) ? i + j
: min({D[i-1][j] + 1, D[i][j-1] + 1, D[i-1][j-1] + (a[i-1] != b[j-1])});
vector<string> ops;
for (int i = n, j = m; i > 0 || j > 0; )
if (i && j && D[i][j] == D[i-1][j-1] + (a[i-1] != b[j-1])) {
if (a[i-1] != b[j-1]) ops.push_back(string("replace ") + a[i-1] + " by " + b[j-1]);
--i; --j;
} else if (i && D[i][j] == D[i-1][j] + 1) ops.push_back(string("delete ") + a[--i]);
else ops.push_back(string("insert ") + b[--j]);
cout << "edit(" << a << ", " << b << ") = " << D[n][m] << "\n ops:";
for (size_t k = ops.size(); k-- > 0; ) cout << (k + 1 == ops.size() ? " " : ", ") << ops[k];
cout << '\n';
}
int main() {
lcsDiff("GATCA", "GCTA"); lcsDiff("ABCBDAB", "BDCABA");
edit("bhalo", "valo"); edit("bhalobasa", "valobasha");
}আউটপুট:
LCS(GATCA, GCTA) = GTA, length 3
diff: G +C -A T -C A
LCS(ABCBDAB, BDCABA) = BCBA, length 4
diff: -A B +D C +A B -D A -B
edit(bhalo, valo) = 2
ops: delete b, replace h by v
edit(bhalobasa, valobasha) = 3
ops: delete b, replace h by v, insert h
প্রথম দুই লাইন ডায়াগ্রাম ১-এর সাথে হুবহু মেলে: GTA, আর diff G +C −A T −C A. ABCBDAB আর BDCABA-র LCS-এর দৈর্ঘ্য 4 (এখানে BCBA; BDAB বা BCAB-ও সঠিক উত্তর)। পেছনে হাঁটার লুপ চলে i > 0 || j > 0 পর্যন্ত — একটি স্ট্রিং শেষ হয়ে গেলেও অন্যটির বাকি অক্ষরগুলো − বা + হিসেবে diff-এ আসে; !j শর্তটি j = 0-তে L[i][j-1] পড়া (সীমার বাইরে) আটকায়। edit-এর বর্ডার এক লাইনে: i বা j শূন্য হলে i + j. “bhalobasa” থেকে “valobasha”-য় তিনটি ধাপ — অভিধানের পরামর্শে এর চেয়ে ছোট দূরত্বের শব্দ (যেমন “bhalobasha”, দূরত্ব 1) আগে আসবে। দুই ফাংশনেই সময় Θ(nm), জায়গা Θ(nm).
def lcs_diff(a, b):
n, m = len(a), len(b)
L = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
L[i][j] = L[i-1][j-1] + 1 if a[i-1] == b[j-1] else max(L[i-1][j], L[i][j-1])
out, i, j = [], n, m # walk back from the corner L[n][m]
while i > 0 or j > 0:
if i and j and a[i-1] == b[j-1]:
out.append(a[i-1]); i -= 1; j -= 1 # diagonal: common letter
elif i and (not j or L[i-1][j] >= L[i][j-1]):
out.append("-" + a[i-1]); i -= 1 # up: only in a
else:
out.append("+" + b[j-1]); j -= 1 # left: only in b
out.reverse()
common = "".join(x for x in out if len(x) == 1)
print(f"LCS({a}, {b}) = {common}, length {L[n][m]}\n diff:", *out)
def edit(a, b):
n, m = len(a), len(b)
D = [[i + j if i == 0 or j == 0 else 0 for j in range(m + 1)] for i in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
D[i][j] = min(D[i-1][j] + 1, D[i][j-1] + 1, D[i-1][j-1] + (a[i-1] != b[j-1]))
ops, i, j = [], n, m
while i > 0 or j > 0:
if i and j and D[i][j] == D[i-1][j-1] + (a[i-1] != b[j-1]):
if a[i-1] != b[j-1]:
ops.append(f"replace {a[i-1]} by {b[j-1]}")
i -= 1; j -= 1
elif i and D[i][j] == D[i-1][j] + 1:
ops.append(f"delete {a[i-1]}"); i -= 1
else:
ops.append(f"insert {b[j-1]}"); j -= 1
print(f"edit({a}, {b}) = {D[n][m]}\n ops:", ", ".join(reversed(ops)))
for x, y in [("GATCA", "GCTA"), ("ABCBDAB", "BDCABA")]:
lcs_diff(x, y)
for x, y in [("bhalo", "valo"), ("bhalobasa", "valobasha")]:
edit(x, y)আউটপুট C++-এর সাথে হুবহু এক:
LCS(GATCA, GCTA) = GTA, length 3
diff: G +C -A T -C A
LCS(ABCBDAB, BDCABA) = BCBA, length 4
diff: -A B +D C +A B -D A -B
edit(bhalo, valo) = 2
ops: delete b, replace h by v
edit(bhalobasa, valobasha) = 3
ops: delete b, replace h by v, insert h
Python-এ a[i-1] != b[j-1] একটি bool, আর True + 0 = 1 — তাই replace-এর খরচ সরাসরি যোগ করা যায়, C++-এর মতোই। diff-এর তালিকায় এক-অক্ষরের উপাদানগুলোই LCS-এর অক্ষর (“−A”, “+C”-এর দৈর্ঘ্য 2), তাই len(x) == 1 দিয়ে আলাদা করা হয়েছে। Python-এ দুই স্তরের লুপ ধীর: n = m = 3000 হলে প্রায় 107 ঘর, যা কয়েক সেকেন্ড নিতে পারে। বাস্তবে Python-এ শুধু তুলনা লাগলে stdlib-এর difflib আছে, যদিও সেটি ঠিক LCS নয়, একটি ভিন্ন heuristic.
৬ · একই ছাঁচের আত্মীয় — common substring, palindrome আর alignment
Longest common substring — “না মিললে শূন্য”
এবার শর্ত কড়া: সাধারণ অংশটি দুই স্ট্রিংয়েই পাশাপাশি থাকতে হবে। State একটু বদলান: C[i][j] = এমন সবচেয়ে লম্বা সাধারণ অংশ, যা ঠিক ai আর bj-এ শেষ হয় (অর্থাৎ দুই prefix-এর শেষে থাকা সবচেয়ে লম্বা সাধারণ অংশ)। অক্ষর মিললে C[i][j] = C[i−1][j−1] + 1, না মিললে 0 — কারণ পাশাপাশির শিকল সেখানেই ভেঙে গেল। উত্তর টেবিলের কোণে নয়, সব ঘরের সর্বোচ্চ মানে।
Pseudocode — longest common substring (1-based)
best = 0
for i = 1..n: for j = 1..m:
C[i][j] = (a[i] == b[j]) ? C[i-1][j-1] + 1 : 0
best = max(best, C[i][j]) // the substring ends at a[i]: a[i-best+1 .. i]“bhalobasa” আর “valobasha”: LCS হলো “alobasa” (৭), কিন্তু longest common substring “alobas” (৬) — কারণ দ্বিতীয়টিতে s আর শেষ a-র মাঝে h বসে শিকল ভেঙেছে। আর GATCA বনাম GCTA-য় LCS 3 হলেও সাধারণ substring সবচেয়ে লম্বা মাত্র 1 অক্ষরের। সময় Θ(nm); তবে এই সমস্যার দ্রুততর উপায়ও আছে — suffix array বা suffix automaton দিয়ে প্রায় O(n + m) (পর্ব ৫৯)।
Longest palindromic subsequence = LCS(s, উল্টো s)
Palindrome সামনে-পেছনে একই পড়া যায়, যেমন “madam” বা “BABCBAB”. একটি স্ট্রিংয়ের সবচেয়ে লম্বা palindromic subsequence (LPS) খোঁজার সবচেয়ে সুন্দর কৌশল: s আর তার উল্টোর LCS নিন। কেন? s-এর যেকোনো palindromic subsequence উল্টো s-এও একই ক্রমে আছে, তাই দুটির সাধারণ subsequence — অর্থাৎ LPS ≤ LCS. উল্টো দিকটি একটু সূক্ষ্ম: যেকোনো LCS নিজে palindrome না-ও হতে পারে, কিন্তু প্রমাণ করা যায় যে সবসময় অন্তত একটি LCS palindrome হয় (প্রমাণটি একটু সূক্ষ্ম), তাই LCS ≤ LPS. উদাহরণ: s = BBABCBCAB, উল্টো BACBCBABB — LCS 7, আর একটি LPS হলো BABCBAB.
একই সমস্যা সরাসরি interval-এর ওপরও লেখা যায় — যা এই পর্বের দ্বিতীয় ভাগের সেতু: P[i][j] = si…sj-এর LPS; দুই প্রান্তের অক্ষর সমান হলে P[i+1][j−1] + 2, নইলে max(P[i+1][j], P[i][j−1]); P[i][i] = 1. আর একটি পরিচিত প্রশ্নের উত্তর এখান থেকেই আসে: স্ট্রিংটিকে palindrome বানাতে সবচেয়ে কম কয়টি অক্ষর insert করতে হবে? উত্তর n − LPS.
Subsequence-এর কৌশল substring-এ খাটে না। “Longest palindromic substring = s আর উল্টো s-এর longest common substring” — এটি একটি বিখ্যাত ভুল, যা AI-ও প্রায়ই লেখে। s = abacdfgdcaba নিন: উল্টোটি abacdgfdcaba, আর দুটির longest common substring “abacd” (৫) — যা palindrome-ই নয়! আসল উত্তর “aba” (৩)। Substring-এর জন্য মাঝখান থেকে দুই দিকে বাড়িয়ে দেখা (O(n²)) বা Manacher-এর অ্যালগরিদম (O(n)) লাগে।
Sequence alignment — জীববিজ্ঞানের DP
১৯৭০ সালে Saul Needleman আর Christian Wunsch প্রোটিনের অ্যামিনো অ্যাসিডের ক্রম তুলনা করতে ঠিক এই ধরনের টেবিল ব্যবহার করেন — যা এখন Needleman–Wunsch অ্যালগরিদম. পার্থক্য শুধু খরচে: “কম খরচ” নয়, “বেশি স্কোর” — মিল হলে পুরস্কার, অমিল বা ফাঁকা (gap) হলে শাস্তি। ধরুন মিল +1, অমিল −1, gap −1. তাহলে S[i][j] = max(S[i−1][j−1] ± 1, S[i−1][j] − 1, S[i][j−1] − 1), বর্ডারে S[i][0] = −i, S[0][j] = −j. GATCA আর GCTA-র সেরা alignment-এর স্কোর 1:
G A T C A
G C T - A
+ - + - + score = 3 matches - 1 mismatch - 1 gap = 1
লক্ষ করুন, edit distance আসলে এরই একটি বিশেষ রূপ (মিল 0, অমিল আর gap −1, তারপর চিহ্ন উল্টে নেওয়া)। জীববিজ্ঞানে অমিলের শাস্তি অক্ষরের জোড়ার ওপর নির্ভর করে (কোন অ্যামিনো অ্যাসিড কোনটিতে সহজে বদলায় — PAM বা BLOSUM টেবিল), আর একটি লম্বা gap অনেকগুলো আলাদা ছোট gap-এর চেয়ে কম শাস্তি পায় (affine gap). ১৯৮১ সালে Temple Smith আর Michael Waterman দেখান কীভাবে পুরো স্ট্রিং নয়, সবচেয়ে ভালো মেলা টুকরো খোঁজা যায় (local alignment): সূত্রে চতুর্থ বিকল্প 0 যোগ করুন, আর উত্তর নিন সব ঘরের সর্বোচ্চ থেকে — ঠিক longest common substring-এর মতো।
৭ · Interval DP — matrix chain multiplication
এ পর্যন্ত state ছিল দুটি prefix. এবার নতুন ধাঁচ: একটিই sequence, আর state হলো তার একটি টুকরো — interval [i, j]. এই ধরনের DP-কে বলে interval DP, আর তার সবচেয়ে বিখ্যাত উদাহরণ matrix chain multiplication.
বন্ধনী কেন জরুরি — একটি সংখ্যার উদাহরণ
p × q আকারের একটি matrix-কে q × r আকারের একটি matrix দিয়ে গুণ করলে ফল p × r, আর সাধারণ পদ্ধতিতে লাগে p · q · r টি সংখ্যার গুণ (ফলের p · r টি ঘরের প্রতিটিতে q টি গুণ)। Matrix গুণ associative (সহযোজী): বন্ধনী যেভাবেই বসান, (AB)C = A(BC), ফল একই। কিন্তু খরচ? ধরুন A 10 × 30, B 30 × 5, C 5 × 60:
| ক্রম | প্রথম গুণ | দ্বিতীয় গুণ | মোট |
|---|---|---|---|
| (AB)C | AB: 10 · 30 · 5 = 1,500 → ফল 10 × 5 | (10 × 5)(5 × 60): 10 · 5 · 60 = 3,000 | 4,500 |
| A(BC) | BC: 30 · 5 · 60 = 9,000 → ফল 30 × 60 | (10 × 30)(30 × 60): 10 · 30 · 60 = 18,000 | 27,000 |
একই উত্তর, অথচ একটি ক্রমে ৬ গুণ বেশি কাজ! কারণ প্রথম ক্রমে মাঝের ফল ছোট (10 × 5), দ্বিতীয়টিতে বড় (30 × 60). প্রশ্ন: n টি matrix A1A2…An, যেখানে Ai-এর আকার pi−1 × pi, তাদের কোন বন্ধনীতে গুণ করলে মোট গুণের সংখ্যা সবচেয়ে কম? (আমরা ক্রম বদলাচ্ছি না — matrix গুণ commutative নয় — শুধু বন্ধনী।) সব বন্ধনী চেষ্টা করা অসম্ভব: n টি matrix-এর বন্ধনীর সংখ্যা Catalan number Cn−1, যা প্রায় 4n/n1.5 হারে বাড়ে — n = 20-এ প্রায় ১৭৭ কোটি।
State: শেষ গুণটি কোথায়
Ai…Aj-এর যেকোনো বন্ধনীতে একেবারে শেষ যে গুণটি হয়, সেটি দুটি টুকরোকে জোড়ে: (Ai…Ak)(Ak+1…Aj), কোনো এক k-এর জন্য, i ≤ k < j. বাঁ টুকরোর ফল pi−1 × pk, ডানেরটির pk × pj, তাই শেষ গুণের খরচ pi−1pkpj. আর দুই টুকরোর ভেতরের বন্ধনী একে অন্যের ওপর নির্ভর করে না — তাই দুটিকেই আলাদাভাবে সেরা করতে হবে (cut-and-paste: কোনো টুকরো সেরা না হলে সেটি বদলে মোট খরচ কমানো যেত)।
যে k-তে min পাওয়া গেল সেটি লিখে রাখুন s[i][j]-এ — বন্ধনী ফিরে পেতে লাগবে। এবার ভরার ক্রম: m[i][j] নির্ভর করে m[i][k] আর m[k+1][j]-এর ওপর — দুটোই [i, j]-এর চেয়ে ছোট interval. তাই সারি ধরে ভরলে চলবে না (m[1][4] ভরতে m[2][4] লাগে, যা নিচের সারিতে); ভরতে হবে দৈর্ঘ্য ধরে: আগে সব দৈর্ঘ্য-২-এর interval, তারপর ৩, … — টেবিলে এটি কর্ণ (diagonal) ধরে ধরে এগোনো।
Pseudocode — matrix chain order (matrices 1-based: Ai is p[i-1] x p[i])
for i = 1..n: m[i][i] = 0
for len = 2..n: // interval length = which diagonal
for i = 1..n-len+1:
j = i + len - 1; m[i][j] = INF
for k = i..j-1: // the last multiplication splits after Ak
cost = m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j]
if cost < m[i][j]: m[i][j] = cost; s[i][j] = k
return m[1][n]চারটি matrix নিয়ে হাতে চালানো যাক: p = (10, 30, 5, 60, 10), অর্থাৎ A1 10 × 30, A2 30 × 5, A3 5 × 60, A4 60 × 10. টেবিলের প্রতিটি ঘরে “খরচ/k” — k হলো সেরা ভাঙার বিন্দু s[i][j].
| Interval-এর দৈর্ঘ্য | |
| হিসাব | |
| এ পর্যন্ত ঘর |
- দৈর্ঘ্য ১ — মূল কর্ণ. একটিমাত্র matrix-এ কোনো গুণ নেই, তাই m[i][i] = 0. বাকি ঘর এখনো খালি (·); নিচের ত্রিভুজ (j < i) অর্থহীন।
- দৈর্ঘ্য ২. দুটি matrix-এর একটিই উপায়, k = i. m[1][2] = 10 · 30 · 5 = 1500, m[2][3] = 30 · 5 · 60 = 9000, m[3][4] = 5 · 60 · 10 = 3000.
- দৈর্ঘ্য ৩. m[1][3]: k = 1 হলে A1(A2A3) = 0 + 9000 + 10 · 30 · 60 = 27000; k = 2 হলে (A1A2)A3 = 1500 + 0 + 10 · 5 · 60 = 4500. সেরা 4500, k = 2. m[2][4]: k = 2 → 0 + 3000 + 30 · 5 · 10 = 4500; k = 3 → 9000 + 0 + 30 · 60 · 10 = 27000. সেরা 4500, k = 2.
- দৈর্ঘ্য ৪. m[1][4]: k = 1 → 0 + 4500 + 10 · 30 · 10 = 7500; k = 2 → 1500 + 3000 + 10 · 5 · 10 = 5000; k = 3 → 4500 + 0 + 10 · 60 · 10 = 10500. সেরা 5000, k = 2. পাঁচটি সম্ভাব্য বন্ধনীর মধ্যে সবচেয়ে খারাপটি, (A1(A2A3))A4, নেয় 33000 — প্রায় সাত গুণ।
- বন্ধনী ফিরে পাওয়া. s[1][4] = 2: শেষ গুণ (A1A2)(A3A4). তারপর s[1][2] = 1 আর s[3][4] = 3 — দুটি টুকরোই দুই matrix-এর, আর ভাঙার দরকার নেই। উত্তর ((A1A2)(A3A4)), খরচ 1500 + 3000 + 500 = 5000.
খরচ — কেন Θ(n³)
Interval আছে প্রায় n²/2 টি, আর দৈর্ঘ্য L-এর interval-এ k-এর L − 1 টি বিকল্প। মোট ভেতরের লুপ চলে ΣL=2..n (n − L + 1)(L − 1) = (n³ − n)/6 বার — সময় Θ(n³), জায়গা m আর s মিলে Θ(n²). n = 500 হলে প্রায় 2 × 107 ধাপ, চোখের পলকে। পুনর্গঠন recursion-এ, প্রতিটি s[i][j] একবার পড়ে — O(n).
৮ · Matrix chain কোডে, আর interval DP-র অন্য ছাঁচ
কোডটি তিনটি chain চালায়: শুরুর তিন-matrix-এর উদাহরণ, ডায়াগ্রামের চার-matrix, আর পাঠ্যবইয়ে বহুল ব্যবহৃত ছয়-matrix-এর উদাহরণ (p = 30, 35, 15, 5, 10, 20, 25). খরচ বড় হতে পারে বলে C++-এ long long.
#include <climits>
#include <iostream>
#include <string>
#include <vector>
using namespace std;
using Table = vector<vector<long long>>;
string paren(const Table& s, int i, int j) { // rebuild the brackets from the split table
if (i == j) return "A" + to_string(i);
int k = s[i][j];
return "(" + paren(s, i, k) + paren(s, k + 1, j) + ")";
}
void matrixChain(const vector<long long>& p) { // matrix Ai has size p[i-1] x p[i], i = 1..n
int n = p.size() - 1;
Table m(n + 1, vector<long long>(n + 1, 0)), s = m;
for (int len = 2; len <= n; ++len) // shorter chains first
for (int i = 1; i + len - 1 <= n; ++i) {
int j = i + len - 1;
m[i][j] = LLONG_MAX;
for (int k = i; k < j; ++k) { // last multiplication: (Ai..Ak)(Ak+1..Aj)
long long cost = m[i][k] + m[k + 1][j] + p[i - 1] * p[k] * p[j];
if (cost < m[i][j]) { m[i][j] = cost; s[i][j] = k; }
}
}
cout << "dims";
for (long long d : p) cout << ' ' << d;
cout << " -> cost " << m[1][n] << " order " << paren(s, 1, n) << '\n';
}
int main() {
matrixChain({10, 30, 5, 60});
matrixChain({10, 30, 5, 60, 10});
matrixChain({30, 35, 15, 5, 10, 20, 25});
}আউটপুট:
dims 10 30 5 60 -> cost 4500 order ((A1A2)A3)
dims 10 30 5 60 10 -> cost 5000 order ((A1A2)(A3A4))
dims 30 35 15 5 10 20 25 -> cost 15125 order ((A1(A2A3))((A4A5)A6))
প্রথম লাইন অংশ ৭-এর টেবিল: (AB)C, 4500. দ্বিতীয় লাইন ডায়াগ্রামের শেষ ধাপ। তৃতীয়টি ছয়টি matrix-এর জন্য 15125 আর ((A1(A2A3))((A4A5)A6)). len-এর লুপটি সবার বাইরে — এটিই কর্ণ ধরে ভরা; ভুল করে i-কে বাইরে রেখে 1 থেকে n চালালে m[1][4] হিসাবের সময় m[2][4] তখনো তৈরি হয়নি। paren হলো s টেবিল ধরে একটি সরল recursion. s = m লিখে একই আকারের দ্বিতীয় টেবিল বানানো হয়েছে।
def paren(s, i, j): # rebuild the brackets from the split table
if i == j:
return f"A{i}"
k = s[i][j]
return "(" + paren(s, i, k) + paren(s, k + 1, j) + ")"
def matrix_chain(p): # matrix Ai has size p[i-1] x p[i], i = 1..n
n = len(p) - 1
m = [[0] * (n + 1) for _ in range(n + 1)]
s = [[0] * (n + 1) for _ in range(n + 1)]
for length in range(2, n + 1): # shorter chains first
for i in range(1, n - length + 2):
j = i + length - 1
m[i][j] = None
for k in range(i, j): # last multiplication: (Ai..Ak)(Ak+1..Aj)
cost = m[i][k] + m[k + 1][j] + p[i - 1] * p[k] * p[j]
if m[i][j] is None or cost < m[i][j]:
m[i][j], s[i][j] = cost, k
print("dims", *p, "-> cost", m[1][n], " order", paren(s, 1, n))
matrix_chain([10, 30, 5, 60])
matrix_chain([10, 30, 5, 60, 10])
matrix_chain([30, 35, 15, 5, 10, 20, 25])আউটপুট C++-এর সাথে হুবহু এক:
dims 10 30 5 60 -> cost 4500 order ((A1A2)A3)
dims 10 30 5 60 10 -> cost 5000 order ((A1A2)(A3A4))
dims 30 35 15 5 10 20 25 -> cost 15125 order ((A1(A2A3))((A4A5)A6))
Python-এ INF-এর বদলে None দিয়ে “এখনো কিছু পাইনি” বোঝানো হয়েছে; float('inf')-ও চলে, কিন্তু তখন টেবিলে int আর float মেশে। Python-এর int-এ overflow নেই, তাই বড় মাত্রাতেও খরচ নির্ভুল। Θ(n³) লুপ Python-এ ধীর — n = 300-এই প্রায় 4.5 × 106 ভেতরের ধাপ, যা এক বা দুই সেকেন্ড নিতে পারে।
একই ছাঁচ: “interval-এর ভেতরে কোন বিন্দুটি শেষে বা প্রথমে”
Interval DP চেনার লক্ষণ: একটি সারি বা বৃত্ত, পাশাপাশি জিনিস জোড়া লাগানো বা ভাঙা, আর উত্তর নির্ভর করে কোন ক্রমে জোড়া হলো তার ওপর। প্রায় সবসময় state = dp[i][j], আর প্রশ্ন “[i, j]-এর ভেতরে কোন k-কে আলাদা ভূমিকা দেব?”
পাশাপাশি ফাইল বা পাথর জোড়া. সারিতে রাখা কয়েকটি স্তূপ, প্রতিবার পাশাপাশি দুটি জোড়া দিলে খরচ = দুটির মোট ওজন। dp[i][j] = mink dp[i][k] + dp[k+1][j] + sum(i..j). পাশাপাশির শর্ত না থাকলে এটি Huffman-এর greedy (পর্ব ৫১) — শর্তটিই DP দাবি করে।
Palindrome partitioning. একটি স্ট্রিংকে সবচেয়ে কম কয়টি কাটায় এমন টুকরো করা যায়, যার প্রতিটি palindrome? আগে isPal[i][j] interval DP দিয়ে (দুই প্রান্ত সমান আর ভেতরটি palindrome), তারপর prefix ধরে cut[j] = min cut[i−1] + 1 — মোট O(n²).
Burst balloons. একটি পরিচিত ইন্টারভিউ সমস্যা: বেলুন ফাটালে পাশের দুটির সাথে গুণফল পয়েন্ট। চাবি হলো উল্টো ভাবা — [i, j]-এর মধ্যে কোন বেলুনটি সবার শেষে ফাটবে, k. তখন তার দুই পাশের প্রতিবেশী নিশ্চিতভাবে i − 1 আর j + 1, আর দুই দিক স্বাধীন। “প্রথমে কোনটি” ভাবলে দুই দিক জড়িয়ে যায়।
বহুভুজের triangulation. একটি convex বহুভুজকে ত্রিভুজে ভাগ করার সবচেয়ে কম খরচ (যেমন ত্রিভুজের কোণগুলোর ওজনের গুণফল) — এটি matrix chain-এর সাথে হুবহু এক গঠনের সমস্যা; বাহু (i, j)-এর ত্রিভুজের তৃতীয় কোণই k.
আরও গভীরে — optimal BST আর Knuth-এর কৌশল: কিছু key আর প্রতিটির খোঁজার সম্ভাবনা জানা থাকলে, কোন BST-তে গড় খোঁজার খরচ সবচেয়ে কম? এটি optimal BST সমস্যা, আর এটিও interval DP: e[i][j] = minr e[i][r−1] + e[r+1][j] + w(i, j), যেখানে r হলো [i, j]-এর root আর w হলো সম্ভাবনার যোগফল (root বদলালে নিচের সবাই এক ধাপ গভীরে যায়) — সরাসরি Θ(n³). Donald Knuth ১৯৭১ সালে দেখান, সেরা root একমুখীভাবে সরে: root[i][j−1] ≤ root[i][j] ≤ root[i+1][j]. তাই প্রতিটি ঘরে k-কে শুধু এই সীমার মধ্যে খুঁজলেই হয়, আর একই কর্ণের সব ঘরের খোঁজার পরিসর যোগ করলে কেটে কেটে (telescoping) O(n) থাকে — মোট O(n²). এই কৌশলটিই Knuth optimization. ১৯৮০ সালে F. Frances Yao এই কৌশল সাধারণ করেন: খরচের ফাংশন quadrangle inequality মানলেই চলে। matrix chain এই শর্ত মানে না, তবে তার জন্য T. C. Hu আর M. T. Shing ১৯৮০-র দশকের শুরুতে বহুভুজের জ্যামিতি কাজে লাগিয়ে O(n log n) অ্যালগরিদম দেন — জটিল বলে কনটেস্টে প্রায় দেখা যায় না।
“Dynamic programming” নামটি Richard Bellman-এর, ১৯৫০-এর দশকের (পর্ব ৫২)। দুই স্ট্রিংয়ের দূরত্বের ধারণা দেন Vladimir Levenshtein, ১৯৬৫ সালের একটি রুশ প্রবন্ধে (ইংরেজি অনুবাদ ১৯৬৬), ভুল-সংশোধক কোডের প্রসঙ্গে; তার এক বছর আগে Frederick Damerau বানান-ভুলের ধরন নিয়ে লেখেন। ১৯৭০ সালে Needleman আর Wunsch প্রোটিনের ক্রম মেলাতে DP-র টেবিল ব্যবহার করেন — জীববিজ্ঞানে কম্পিউটারের প্রথম বড় অবদানগুলোর একটি। আজকের পরিচিত edit distance-এর DP-টি অনেক গবেষক স্বাধীনভাবে পেয়েছিলেন; ১৯৭৪ সালে Robert Wagner আর Michael Fischer-এর “The String-to-String Correction Problem” প্রবন্ধের পর একে প্রায়ই Wagner–Fischer অ্যালগরিদম বলা হয়। ১৯৭৫ সালে Daniel Hirschberg LCS-কে linear জায়গায় নামান, আর ১৯৭৬ সালে Bell Labs-এর James Hunt আর Douglas McIlroy Unix-এর diff-এর অ্যালগরিদম প্রকাশ করেন। Interval DP-র দিকে, optimal BST-র O(n²) কৌশলটি Donald Knuth-এর ১৯৭১ সালের প্রবন্ধে।
পরীক্ষা ও ইন্টারভিউতে:
- LCS-এর recurrence লিখুন আর প্রমাণ করুন। — শেষ অক্ষর মিললে L[i−1][j−1] + 1, নইলে max(L[i−1][j], L[i][j−1]); cut-and-paste আর স্ববিরোধ দিয়ে দুই ঘটনা। সময় ও জায়গা Θ(nm).
- Edit distance-এর base case আর তিনটি অপারেশন কী? — D[i][0] = i, D[0][j] = j; delete (ওপর + 1), insert (বাঁ + 1), replace বা মিল (কোণাকুণি + [ai ≠ bj]).
- LCS-এর শুধু দৈর্ঘ্য O(min(n, m)) জায়গায় কীভাবে? — দুটি সারি, ছোট স্ট্রিংটি কলামে; আসল sequence-ও লাগলে Hirschberg.
- Matrix chain-এ টেবিল কোন ক্রমে ভরবেন, আর সময় কত? — interval-এর দৈর্ঘ্য ধরে (কর্ণ), কারণ m[i][j] ছোট interval-এর ওপর নির্ভর করে; Θ(n³) সময়, Θ(n²) জায়গা।
- Longest palindromic subsequence কীভাবে? — LCS(s, উল্টো s), বা P[i][j] interval DP; substring-এর জন্য এই কৌশল চলে না।
৯ · AI-এর যুগে DP-র টেবিল — সূচক, বর্ডার আর সমান মান
“LCS-এর কোড দাও” — AI কয়েক সেকেন্ডে লিখবে, আর পাঠ্যবইয়ের উদাহরণে সেটি ঠিক উত্তরও দেবে। DP-র ভুল লুকিয়ে থাকে সূচকে (i না i − 1), বর্ডারে (0 না i), ভরার ক্রমে, আর সমান মানের সময় কোন দিকে যাওয়া হচ্ছে তাতে। আর complexity-র প্রশ্নে AI প্রায়ই আত্মবিশ্বাসী ভুল দাবি করে — যেমন “edit distance-এর অপারেশনসহ উত্তর দুই সারিতেই সম্ভব” বা “matrix chain O(n²)”.
এই পর্বের সমস্যায় AI সাধারণত যেসব ভুল করে
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
টেবিল n × m (n + 1 নয়), বা a[i] আর dp[i]-এর সূচক এক ধরে নেওয়া | একটি স্ট্রিং খালি, বা একটিমাত্র অক্ষর — এমন edge case চালান; সীমার বাইরে পড়লে ধরা পড়বে |
| Edit distance-এর বর্ডার 0 দিয়ে শুরু | edit("", "abc") চালান — উত্তর 3 হওয়ার কথা |
| Matrix chain-এ i আর j-এর সাধারণ দুই লুপ, দৈর্ঘ্য ধরে নয় | চার বা বেশি matrix দিয়ে brute force-এর সাথে মেলান; ছোট ইনপুটে কাকতালীয়ভাবে ঠিক আসতে পারে |
| Longest palindromic substring-কে s আর উল্টো s-এর common substring ধরে নেওয়া | abacdfgdcaba দিয়ে চালান — “abacd” এলে ভুল |
| LCS-এর greedy: “প্রথম যে অক্ষর মেলে সেটিই নাও” | X = ZAB, Y = ABZ: greedy Z নিয়ে আটকে যায় (1), আসল LCS AB (2) |
পুনর্গঠনে মিল না দেখেই কোণাকুণি যাওয়া, বা লুপ i > 0 && j > 0-তে থেমে বাকি অক্ষর হারানো | আউটপুটের অপারেশনগুলো নিজে প্রয়োগ করে দেখুন সত্যিই দ্বিতীয় স্ট্রিং হয় কি না, আর সংখ্যা দূরত্বের সমান কি না |
AI-কে সমাধান লিখে দিতে না বলে, আপনার state, সূত্র আর trace যাচাই করতে বলুন — আর প্রতিটি দাবির কারণ চান:
DP-র কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১git বা Unix-এর diff কি সত্যিই প্রতিবার Θ(nm) টেবিল ভরে? বড় ফাইলে কীভাবে এত দ্রুত কাজ করে?
না। দুটি ১০ হাজার লাইনের ফাইলে Θ(nm) মানে ১০ কোটি ঘর — প্রতিটি commit দেখাতে এত কাজ করলে git ধীর হয়ে যেত। diff-এর ইতিহাসটি আসলে “LCS-কে বাস্তবে দ্রুত করা”-র ইতিহাস। ১৯৭৬ সালে Bell Labs-এর James Hunt আর Douglas McIlroy Unix-এর diff-এর জন্য যে অ্যালগরিদম দেন, তা প্রথমে প্রতিটি লাইনকে hash করে একটি সংখ্যায় বদলায়, তারপর শুধু সেই জোড়াগুলো দেখে যেখানে দুই ফাইলের লাইন সত্যিই মেলে। বাস্তবের ফাইলে বেশিরভাগ লাইন অনন্য (বা প্রায় অনন্য), তাই মেলা জোড়ার সংখ্যা r অনেক কম, আর সময় প্রায় O((r + n) log n). পরের বছর Hunt আর Thomas Szymanski একই ধারণার আরও পরিষ্কার বিশ্লেষণ দেন। এটি আসলে LCS-কে পর্ব ৫৩-এর LIS-এ রূপান্তর — মেলা জোড়াগুলোর একটি বিশেষ ক্রমে সবচেয়ে লম্বা বর্ধমান ক্রম।
১৯৮৬ সালে Eugene Myers আরও একটি দৃষ্টিভঙ্গি দেন: টেবিলকে একটি গ্রিড-গ্রাফ হিসেবে দেখুন, যেখানে কোণাকুণি (মিল) edge-এর খরচ 0 আর ওপর বা বাঁয়ের edge-এর খরচ 1. তখন সবচেয়ে ছোট diff মানে এক কোণ থেকে আরেক কোণে সবচেয়ে ছোট পথ — পর্ব ৪০-এর BFS-এর মতো স্তরে স্তরে এগোনো যায়। Myers-এর অ্যালগরিদমের সময় O((n + m) · D), যেখানে D হলো diff-এর আকার (কয়টি লাইন যোগ বা মোছা)। বাস্তবে দুই সংস্করণের পার্থক্য ছোট — একটি commit-এ সাধারণত কয়েকটি লাইন বদলায় — তাই D ছোট, আর অ্যালগরিদম প্রায় linear. git-এর ডিফল্ট diff এই Myers-এর অ্যালগরিদম; git আরও কিছু বিকল্প দেয় (যেমন patience আর histogram), যেগুলো প্রায়ই মানুষের চোখে বেশি “স্বাভাবিক” diff দেয় — যেমন একটি ফাংশন সরালে লাইনের এলোমেলো মিলের বদলে পুরো ফাংশনটিকে একসাথে সরানো দেখায়।
এখানে একটি গভীর শিক্ষা আছে: “সবচেয়ে ছোট” diff সবসময় “সবচেয়ে বোধগম্য” নয়। দুটি ফাইলে অনেক ফাঁকা লাইন বা শুধু “}” থাকা লাইন থাকে, আর LCS সেগুলো মিলিয়ে অদ্ভুত diff বানাতে পারে। তাই বাস্তব সফটওয়্যার গাণিতিকভাবে সেরা উত্তরের ওপর কিছু heuristic চাপায়।
মূল উপলব্ধি: পাঠ্যবইয়ের Θ(nm) DP হলো সঠিকতার ভিত্তি; বাস্তবের সিস্টেম ইনপুটের গঠন (কম মিল, ছোট পার্থক্য) কাজে লাগিয়ে সেই একই প্রশ্ন অনেক দ্রুত সমাধান করে। ইনপুটের বৈশিষ্ট্য দিয়ে complexity মাপা — যেমন O(nD) — অ্যালগরিদম ডিজাইনের একটি শক্তিশালী কৌশল।
প্র ০২মানুষের জিনোম তিনশো কোটির বেশি অক্ষরের। Needleman–Wunsch-এর Θ(nm) টেবিল দিয়ে কি জিনোম মেলানো যায়? জীববিজ্ঞানীরা কী করেন?
সরাসরি নয়। দুটি পুরো জিনোম মেলাতে n · m ≈ 1019 ঘর — পৃথিবীর সব কম্পিউটার মিলিয়েও অবাস্তব, আর জায়গার কথা তো বাদই দিন। Needleman–Wunsch (১৯৭০) আর Smith–Waterman (১৯৮১) গাণিতিকভাবে নির্ভুল, তাই এখনো ছোট ক্রম — একটি জিন বা প্রোটিন, কয়েকশো থেকে কয়েক হাজার অক্ষর — মেলাতে আর অন্য পদ্ধতির উত্তর যাচাই করতে ব্যবহৃত হয়। বড় কাজে লাগে শর্টকাট।
সবচেয়ে বিখ্যাত শর্টকাট BLAST (১৯৯০, Stephen Altschul আর সহকর্মীরা)। ধারণাটি সহজ: দুটি সম্পর্কিত ক্রমে প্রায় নিশ্চিতভাবে কিছু ছোট টুকরো (ধরুন ১১ অক্ষরের) হুবহু মেলে। তাই আগে একটি hash table-এ (পর্ব ৩২) সব ছোট টুকরোর অবস্থান রাখুন, মেলা টুকরো খুঁজুন (“seed”), আর শুধু সেই জায়গার আশেপাশে DP চালিয়ে alignment বড় করুন। এতে বিশাল ডেটাবেসে খোঁজা কয়েক সেকেন্ডে হয়, কিন্তু দাম আছে — BLAST একটি heuristic, কখনো কখনো দুর্বল মিল হারিয়ে ফেলে, যা Smith–Waterman পেত।
আধুনিক DNA sequencing-এ যন্ত্র কোটি কোটি ছোট টুকরো (read) পড়ে, আর প্রতিটিকে একটি reference জিনোমের সঠিক জায়গায় বসাতে হয়। সেখানে ব্যবহৃত হয় suffix array আর Burrows–Wheeler transform-ভিত্তিক index (পর্ব ৫৯-এর বিষয়ের আত্মীয়), যা দিয়ে সম্ভাব্য জায়গা প্রায় সঙ্গে সঙ্গে পাওয়া যায়; তারপর সেই ছোট জায়গায় একটি banded DP — টেবিলের শুধু কর্ণের আশেপাশের একটি সরু পট্টি ভরা, কারণ দুটি প্রায়-একই ক্রমের সেরা পথ কর্ণ থেকে বেশি সরে না। পট্টির প্রস্থ w হলে খরচ O(n · w), O(n²) নয়। আধুনিক প্রসেসরের SIMD নির্দেশ দিয়ে একসাথে অনেক ঘর ভরার কৌশলও ব্যাপকভাবে ব্যবহৃত হয়।
আরেকটি বাস্তব দিক স্কোরিং। প্রোটিনে কোন অ্যামিনো অ্যাসিড কোনটিতে সহজে বদলায়, তা বহু প্রজাতির তুলনা থেকে পরিসংখ্যান দিয়ে বের করা হয়েছে (PAM আর BLOSUM টেবিল)। আর gap-এর শাস্তি affine: gap খোলার বড় শাস্তি, বাড়ানোর ছোট — কারণ প্রকৃতিতে একটি বড় অংশ একবারে মুছে যাওয়া অনেকগুলো আলাদা ছোট মোছার চেয়ে বেশি সম্ভাব্য। এর জন্য Osamu Gotoh (১৯৮২) তিনটি টেবিল দিয়ে একই Θ(nm) সময়ের DP দেন।
মূল উপলব্ধি: নির্ভুল DP হলো মানদণ্ড; বিশাল ডেটায় index দিয়ে সম্ভাব্য জায়গা খুঁজে শুধু সেখানে DP চালানো — “filter, then verify” — বাস্তবের সমাধান। গতি বাড়াতে কোথায় নির্ভুলতার সামান্য ছাড় দেওয়া যায়, তা জানা প্রকৌশলের অংশ।
প্র ০৩Edit distance বা LCS কি Θ(n²)-এর চেয়ে অনেক দ্রুত করা সম্ভব? এ নিয়ে গবেষণা কী বলে?
প্রায় পঞ্চাশ বছরের চেষ্টার পরও সাধারণ ইনপুটে n2−ε (যেকোনো ধ্রুবক ε > 0) সময়ের কোনো অ্যালগরিদম কেউ পাননি। সবচেয়ে বড় উন্নতি ১৯৮০ সালে William Masek আর Michael Paterson-এর: “Four Russians” নামের একটি কৌশলে টেবিলকে ছোট ছোট ব্লকে ভাগ করে, প্রতিটি সম্ভাব্য ব্লকের উত্তর আগে থেকে হিসাব করে রাখা — তাতে সময় হয় O(n² / log n) (ছোট বর্ণমালার জন্য), অর্থাৎ শুধু একটি log গুণ কম।
কেন এর চেয়ে ভালো হয় না? ২০১৫ সালে Arturs Backurs আর Piotr Indyk একটি শক্তিশালী শর্তসাপেক্ষ উত্তর দেন: edit distance যদি সত্যিকারের subquadratic (n2−ε) সময়ে সমাধান করা যেত, তাহলে Strong Exponential Time Hypothesis (SETH) মিথ্যা হতো। SETH বলে, n চলকের SAT সমস্যা (পর্ব ৬৪) মূলত 2n-এর কাছাকাছি সময়ের চেয়ে খুব বেশি দ্রুত সমাধান করা যায় না। একই বছর Amir Abboud, Arturs Backurs আর Virginia Vassilevska Williams LCS-এর জন্যও একই রকম ফল দেন। অর্থাৎ Θ(n²) DP সম্ভবত প্রায় সেরা — যদি না জটিলতা-তত্ত্বের একটি বহুল বিশ্বাসিত অনুমান ভেঙে পড়ে। এটি fine-grained complexity নামের ক্ষেত্রের একটি উজ্জ্বল উদাহরণ।
তবে বিশেষ পরিস্থিতিতে দ্রুত উপায় আছে। দূরত্ব d ছোট হলে টেবিলের শুধু কর্ণের ±d পট্টিতে সেরা পথ থাকে — Esko Ukkonen (১৯৮৫) আর Myers-এর কাজ থেকে O(n · d)-এর অ্যালগরিদম আসে, আর আরও উন্নত কৌশলে O(n + d²). বানান-সংশোধকে ঠিক এটিই দরকার: “দূরত্ব 2-এর মধ্যে কোন শব্দ?” — পুরো টেবিল লাগে না, দূরত্ব 2 ছাড়ালেই থামা যায়। Approximation-এর দিকেও বড় অগ্রগতি হয়েছে: ২০১৮ সালের দিকে গবেষকেরা দেখান edit distance-কে একটি ধ্রুবক গুণের মধ্যে subquadratic সময়ে আনুমানিক করা যায়।
আর একাধিক স্ট্রিংয়ে সমস্যা নাটকীয়ভাবে কঠিন হয়: k টি স্ট্রিংয়ের LCS-এর DP-তে k-মাত্রার টেবিল, সময় প্রায় O(nk), আর k ইনপুটের অংশ হলে সমস্যাটি NP-hard (David Maier, ১৯৭৮)। তাই জীববিজ্ঞানে অনেক ক্রমের alignment প্রায় সবসময় heuristic দিয়ে হয়।
মূল উপলব্ধি: কিছু সাধারণ DP-র সময় সম্ভবত প্রায় সর্বোত্তম, আর তার প্রমাণ আসে অন্য একটি কঠিন সমস্যার সাথে সম্পর্ক জুড়ে। ইনপুটের বিশেষ গঠন (ছোট দূরত্ব, ছোট বর্ণমালা) বা আনুমানিক উত্তর মেনে নেওয়াই দ্রুততার পথ।
প্র ০৪Banglish থেকে বাংলায় লেখার সফটওয়্যারের জন্য একটি বানান-পরামর্শ ব্যবস্থা বানাতে চান। শুধু Levenshtein distance কি যথেষ্ট? আর লাখ শব্দের অভিধানে প্রতিবার সবার সাথে দূরত্ব মাপবেন কীভাবে?
Levenshtein একটি ভালো শুরু, কিন্তু Banglish-এর ভুলের ধরন সাধারণ ইংরেজি টাইপিংয়ের মতো নয়। এখানে অনেক “ভুল” আসলে উচ্চারণ লেখার ভিন্ন অভ্যাস: “bhalo” আর “valo”, “sh” আর “s”, “kh” আর “k”, “o” আর “a” (যেমন “kotha”/“katha”) — একই বাংলা শব্দ বোঝাতে মানুষ ভিন্ন ভিন্ন বানান লেখেন। Levenshtein-এ “bh” → “v” হলো ২টি ধাপ, অথচ মানুষের কাছে এটি একটিমাত্র স্বাভাবিক বদল। তাই দুটি উন্নতি দরকার। প্রথমত, খরচের টেবিল: b↔v, s↔sh-এর মতো প্রচলিত বদলের খরচ কম রাখুন, আর এক অক্ষরের সাথে দুই অক্ষরের বদলও (“bh” ↔ “v”) একটি অপারেশন হিসেবে ধরুন — DP একই থাকে, শুধু সূত্রে আরও কিছু বিকল্প আসে। দ্বিতীয়ত, Damerau-এর transposition — দ্রুত টাইপে পাশাপাশি অক্ষর উল্টে যাওয়া।
তারপর কোনটি আগে দেখাবেন: একই দূরত্বের অনেক শব্দ থাকলে বেশি ব্যবহৃত শব্দটি আগে। Peter Norvig তাঁর একটি বিখ্যাত ছোট প্রবন্ধে (২০০৭) এই ধারণাকে সম্ভাবনার ভাষায় লেখেন: সেরা পরামর্শ c হলো যেটি P(c) · P(লেখা শব্দ | c)-কে সর্বোচ্চ করে — P(c) আসে বড় লেখার সংগ্রহে শব্দের ব্যবহার গুনে, আর দ্বিতীয় অংশটি edit distance দিয়ে আনুমানিক। পাশের শব্দও (context) কাজে লাগে: “ami tomake valobasi”-তে শেষ শব্দটি কী হবে তা আগের শব্দগুলো থেকেও আন্দাজ করা যায়। আধুনিক কিবোর্ড অ্যাপ এর সাথে ভাষা-মডেল জুড়ে দেয়।
গতির প্রশ্ন: লাখ শব্দ, প্রতিটির সাথে Θ(nm) — একটি ১০ অক্ষরের শব্দে প্রায় এক কোটি ছোট ধাপ, প্রতিটি অক্ষর টাইপের সাথে সাথে। ফোনে এটি ধীর। কৌশলগুলো: (১) প্রার্থী তৈরি — লেখা শব্দ থেকে দূরত্ব 1 বা 2-এর সব সম্ভাব্য বানান তৈরি করে hash set-এ খুঁজুন (Norvig-এর পদ্ধতি); (২) Trie-র ওপর DP (পর্ব ৩১) — অভিধানের শব্দগুলো trie-তে রাখুন, আর trie-র প্রতিটি node-এ edit distance টেবিলের একটি সারি হিসাব করুন। একই prefix-এর শব্দগুলো সেই সারি ভাগ করে নেয়, আর কোনো সারির সবচেয়ে ছোট মানও সীমা ছাড়ালে পুরো শাখা বাদ; (৩) BK-tree (Walter Burkhard আর Robert Keller, ১৯৭৩) — edit distance একটি metric (triangle inequality মানে), তাই tree-তে অনেক শব্দ একবারেই বাদ দেওয়া যায়।
মূল উপলব্ধি: অ্যালগরিদম (edit distance) সমাধানের একটি অংশ মাত্র; বাস্তব ব্যবস্থায় লাগে ভাষার ভুলের সঠিক মডেল (খরচের টেবিল), ব্যবহারের পরিসংখ্যান, আর অনেক তুলনা এড়ানোর ডেটা স্ট্রাকচার। এই তিনটির মিলনেই একটি ভালো বাংলা কিবোর্ড।
প্র ০৫Matrix chain কি শুধু পাঠ্যবইয়ের ধাঁধা? বাস্তবের কোথায় “গুণের ক্রম” আসলেই খরচ বদলে দেয়?
খুবই বাস্তব, আর জায়গাগুলো অবাক করার মতো। সবচেয়ে সরাসরি উদাহরণ বৈজ্ঞানিক গণনার লাইব্রেরি: Python-এর NumPy-তে numpy.linalg.multi_dot নামে একটি ফাংশন আছে, যা কয়েকটি matrix-এর গুণফল নেওয়ার আগে matrix chain-এর DP চালিয়ে সবচেয়ে সস্তা ক্রম বেছে নেয়। একটি সাধারণ পরিস্থিতি: A একটি বড় matrix (10000 × 10000), x একটি vector (10000 × 1). (A · A) · x লিখলে প্রথমে দুটি বড় matrix-এর গুণ — প্রায় 1012 গুণ; A · (A · x) লিখলে দুবার matrix-vector গুণ — প্রায় 2 × 108. একই গণিত, পাঁচ হাজার গুণ পার্থক্য। NumPy-র einsum-এরও একটি optimize বিকল্প আছে, যা অনেক tensor-এর সংকোচনের ক্রম খোঁজে — একই সমস্যার বড় রূপ।
দ্বিতীয় উদাহরণটি গভীর শিক্ষার (deep learning) একেবারে কেন্দ্রে। একটি neural network হলো অনেকগুলো ফাংশনের শিকল, আর chain rule অনুযায়ী মোট derivative হলো প্রতিটি ধাপের Jacobian matrix-এর গুণফল। এই গুণফল বাঁ থেকে ডানে নেবেন না ডান থেকে বাঁয়ে? যখন শেষে একটিমাত্র সংখ্যা (loss) থাকে, তখন শেষ থেকে শুরু করলে প্রতিটি ধাপে শুধু একটি vector আর matrix-এর গুণ — এটিই reverse-mode automatic differentiation, যার পরিচিত নাম backpropagation. শুরু থেকে গুণ করলে (forward mode) প্রতিটি ধাপে বড় matrix-এর গুণ লাগত। অর্থাৎ backpropagation-এর দক্ষতার একটি অংশ হলো matrix chain-এর বন্ধনী ঠিকভাবে বাছা। আর সাধারণ গ্রাফের জন্য সেরা ক্রম খোঁজা (“optimal Jacobian accumulation”) NP-complete প্রমাণিত হয়েছে।
তৃতীয় উদাহরণ ডেটাবেস। একটি SQL query-তে কয়েকটি টেবিলের join থাকলে ফল একই, কিন্তু কোন ক্রমে join হবে তার ওপর মাঝের ফলের আকার — আর সময় — বহু গুণ বদলায়। ১৯৭৯ সালে IBM-এর System R প্রকল্পে Patricia Selinger আর সহকর্মীরা query optimizer-এ DP দিয়ে join-এর ক্রম বাছার পদ্ধতি দেন, যা আজও প্রায় সব বড় ডেটাবেসের optimizer-এর ভিত্তি। পার্থক্য হলো, সেখানে join যেকোনো দুটি টেবিলের মধ্যে হতে পারে, তাই state শুধু interval নয়, টেবিলের subset — পর্ব ৫৫-এর bitmask DP-র ধাঁচ।
মূল উপলব্ধি: “ফল একই, কিন্তু ক্রমে খরচ বদলায়” — associativity থাকলেই এই প্রশ্ন ওঠে। matrix chain-এর DP সেই প্রশ্নের সবচেয়ে পরিষ্কার মডেল, আর একই চিন্তা লাইব্রেরি, machine learning আর ডেটাবেসের ভেতরে প্রতিদিন কাজ করছে।
অনুশীলন
খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন: X = ABCB (সারি) আর Y = BDCB (কলাম)-এর LCS টেবিল সারি ধরে ভরুন (ওপর আর বাঁ সমান হলে ↑). তারপর কোণ থেকে পেছনে হেঁটে একটি LCS লিখুন।
কলাম ε, B, D, C, B. সারি ε = (0, 0, 0, 0, 0). সারি A = (0, 0, 0, 0, 0) — A কোথাও মেলে না। সারি B = (0, 1, 1, 1, 1): L[2][1] কোণাকুণি 0 + 1; L[2][4]-এও B = B, কিন্তু কোণাকুণি L[1][3] + 1 = 1 — একই। সারি C = (0, 1, 1, 2, 2): L[3][3] = L[2][2] + 1 = 2. সারি B = (0, 1, 1, 2, 3): L[4][4] = L[3][3] + 1 = 3.
পেছনে: (4,4) B = B ↖ → (3,3) C = C ↖ → (2,2) B ≠ D, ওপরে L[1][2] = 0, বাঁয়ে L[2][1] = 1, তাই ← → (2,1) B = B ↖ → (1,0) থামা। সংগ্রহ B, C, B → উল্টে BCB, দৈর্ঘ্য 3. যাচাই: ABCB থেকে A বাদ, BDCB থেকে D বাদ — দুটোতেই BCB.
-
হাতে হিসাব করুন: চারটি matrix-এর মাত্রা p = (6, 2, 10, 3, 8), অর্থাৎ A1 6 × 2, A2 2 × 10, A3 10 × 3, A4 3 × 8. কর্ণ ধরে m টেবিল ভরুন, সবচেয়ে কম খরচ আর বন্ধনী বের করুন।
দৈর্ঘ্য ২: m[1][2] = 6 · 2 · 10 = 120, m[2][3] = 2 · 10 · 3 = 60, m[3][4] = 10 · 3 · 8 = 240. দৈর্ঘ্য ৩: m[1][3]: k = 1 → 0 + 60 + 6 · 2 · 3 = 96; k = 2 → 120 + 0 + 6 · 10 · 3 = 300; তাই 96, k = 1. m[2][4]: k = 2 → 0 + 240 + 2 · 10 · 8 = 400; k = 3 → 60 + 0 + 2 · 3 · 8 = 108; তাই 108, k = 3.
দৈর্ঘ্য ৪: m[1][4]: k = 1 → 0 + 108 + 6 · 2 · 8 = 204; k = 2 → 120 + 240 + 6 · 10 · 8 = 840; k = 3 → 96 + 0 + 6 · 3 · 8 = 240. সেরা 204, k = 1. পুনর্গঠন: s[1][4] = 1 → A1(A2A3A4); s[2][4] = 3 → (A2A3)A4. উত্তর (A1((A2A3)A4)). লক্ষ করুন, 2-এর মতো ছোট মাত্রাটিকে বারবার “বাইরের” দিকে রাখাই সস্তা।
-
ভেবে বলুন: একজন ব্যবহারকারী লিখলেন “bhalobasa”. অভিধানে আছে: valobasa, bhalobasha, valobasha, bhalo. প্রতিটির Levenshtein distance কত, আর কোন ক্রমে পরামর্শ দেখাবেন? শুধু দূরত্ব দেখে সাজানোর একটি দুর্বলতাও বলুন।
bhalobasha: 1 (শেষের দিকে h insert). valobasa: 2 (b মুছে h → v, অথবা b → v আর h মুছে). valobasha: 3 (ওপরের দুটি + একটি h insert). bhalo: 4 (শেষের b, a, s, a মুছে). তাই ক্রম: bhalobasha, valobasa, valobasha, bhalo.
দুর্বলতা: Banglish-এ “bh” আর “v” একই উচ্চারণের দুটি প্রচলিত বানান, তাই মানুষের চোখে valobasha-ও খুব কাছের — অথচ দূরত্ব 3. আর “bhalo” ছোট বলে উপসর্গ হিসেবে মেলে, কিন্তু অর্থে আলাদা শব্দ। সমাধান: b↔v, s↔sh-এর মতো বদলের কম খরচ, আর শব্দের ব্যবহার কত বেশি (frequency) তা দিয়ে সমান দূরত্বের মধ্যে সাজানো — ভাবনার প্রশ্ন ৪ দেখুন।
-
কোড লিখুন: একটি স্ট্রিং s দেওয়া আছে। সবচেয়ে কম কয়টি অক্ষর insert করলে এটি palindrome হয়, তা বের করার ফাংশন লিখুন — O(n) অতিরিক্ত জায়গায়। time আর space complexity বলুন।
যে অক্ষরগুলো একটি সবচেয়ে লম্বা palindromic subsequence-এ নেই, প্রতিটির জন্য উল্টো দিকে একটি জোড়া insert করতে হয় — তাই উত্তর n − LPS(s), আর LPS(s) = LCS(s, উল্টো s). দুই সারির LCS দিয়ে:
int minInsertionsToPalindrome(const string& s) { string r(s.rbegin(), s.rend()); // reverse of s int n = s.size(); vector<int> prev(n + 1, 0), cur(n + 1, 0); for (int i = 1; i <= n; ++i) { for (int j = 1; j <= n; ++j) cur[j] = (s[i-1] == r[j-1]) ? prev[j-1] + 1 : max(prev[j], cur[j-1]); swap(prev, cur); } return n - prev[n]; // n - LPS(s) }সময় Θ(n²), অতিরিক্ত জায়গা O(n) (উল্টো স্ট্রিং আর দুটি সারি)। উদাহরণ: “BBABCBCAB”-এর LPS 7, তাই 2 টি insert. “abc” → LPS 1 → 2 টি insert (যেমন “abcba” বানানো যায়)। ইতিমধ্যে palindrome হলে (যেমন “madam”) উত্তর 0. যাচাইয়ের জন্য ছোট স্ট্রিংয়ে একটি brute force লিখুন — interval DP-র সরাসরি সূত্র: s[i] = s[j] হলে f(i+1, j−1), নইলে 1 + min(f(i+1, j), f(i, j−1)).
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমMatrix chain-এর মতো একটি DP সমাধান গড়ার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- State ঠিক করা — যেমন m[i][j] = Ai…Aj গুণের সবচেয়ে কম খরচ
- শেষ সিদ্ধান্ত (ভাঙার বিন্দু k) ধরে recurrence লেখা আর তার সঠিকতা যুক্তি দিয়ে দেখানো
- Base case বসানো — m[i][i] = 0
- এমন ক্রমে টেবিল ভরা যাতে দরকারি ছোট interval আগেই তৈরি থাকে (দৈর্ঘ্য ধরে)
- উত্তরের ঘর m[1][n] পড়া আর s[i][j] ধরে বন্ধনী পুনর্গঠন
দৈর্ঘ্য n আর m-এর দুটি স্ট্রিংয়ের LCS-এর দৈর্ঘ্য সাধারণ DP টেবিল দিয়ে বের করতে সময় কত লাগে?
- O(n + m)
- Θ(n · m)
- Θ(n · m · min(n, m))
- O(m · 2n)
Levenshtein edit distance-এর DP টেবিলে D[i][0]-এর (প্রথম স্ট্রিংয়ের i অক্ষরের prefix থেকে খালি স্ট্রিং) মান কত?
- 0
- 1
- i
- ∞
A (10 × 100), B (100 × 5) আর C (5 × 50) — তিনটি matrix. (AB)C ক্রমে গুণ করলে মোট কয়টি সংখ্যার গুণ লাগে?
- 7,500
- 75,000
- 5,000
- 2,50,000
নিচের কোন দাবিটি সঠিক?
- Longest palindromic substring = s আর উল্টো s-এর longest common substring
- LCS-এর দৈর্ঘ্য সবসময় longest common substring-এর সমান
- Subsequence-এ অক্ষরের ক্রম বদলানো যায়
- Longest palindromic subsequence-এর দৈর্ঘ্য = LCS(s, উল্টো s)-এর দৈর্ঘ্য
Matrix chain multiplication-এর DP টেবিল m[i][j] কোন ক্রমে ভরতে হয়?
- সারি ধরে, i = 1 থেকে n, প্রতিটি সারিতে j বাড়িয়ে
- Interval-এর দৈর্ঘ্য ধরে — আগে সব ছোট interval, তারপর বড়
- যেকোনো ক্রমে, শেষে সব ঘর একবার ভরা হলেই চলে
- কলাম ধরে, j = n থেকে 1 পর্যন্ত কমিয়ে
দুই সারি রেখে O(min(n, m)) জায়গায় edit distance-এর মান বের করা যায়। কিন্তু আসল অপারেশনের তালিকাও একই রকম কম জায়গায় চাইলে কোন কৌশল লাগে?
- Hirschberg-এর divide and conquer
- Knuth-এর optimization
- Top-down memoization
- প্রতিটি ধাপে greedy-ভাবে সবচেয়ে সস্তা অপারেশন বাছা
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- দুটি sequence-এর DP-তে state = দুই prefix; সেরা alignment-এর শেষ কলাম কী হতে পারে, তা থেকেই সূত্র। subsequence-এ ক্রম থাকে কিন্তু ফাঁক চলে, substring পাশাপাশি।
- LCS: মিললে L[i−1][j−1] + 1, নইলে max(ওপর, বাঁ); বর্ডার 0; Θ(nm) সময়। কোণ থেকে পেছনে হাঁটলে LCS আর diff; শুধু দৈর্ঘ্য হলে দুই সারি, O(min(n, m)) জায়গা; পুরো উত্তর কম জায়গায় চাইলে Hirschberg.
- Edit distance: min(ওপর + 1 delete, বাঁ + 1 insert, কোণাকুণি + [অমিল] replace); বর্ডার D[i][0] = i, D[0][j] = j. শুধু insert/delete হলে n + m − 2 · LCS; transposition যোগ করলে Damerau.
- আত্মীয়: common substring (না মিললে 0, উত্তর সর্বোচ্চ ঘরে), LPS = LCS(s, উল্টো s) (substring-এ নয়!), Needleman–Wunsch (global) আর Smith–Waterman (local) alignment.
- Interval DP: m[i][j] = mink m[i][k] + m[k+1][j] + pi−1pkpj; দৈর্ঘ্য ধরে ভরা; Θ(n³) সময়, Θ(n²) জায়গা; s[i][j] থেকে বন্ধনী। optimal BST-তে Knuth-এর কৌশলে O(n²).
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৫৫ · উন্নত DP — bitmask, digit আর tree DPপরবর্তী পর্বযখন state হয় একটি subset, একটি সংখ্যার অঙ্ক বা একটি subtree.
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- পর্ব ৫৩ · DP-র ক্লাসিক সমস্যা ১ — knapsack, coin change আর LISআগের ভিত্তিএক তালিকার ওপর DP — এই পর্বের দুই-স্ট্রিংয়ের DP-র আগের ধাপ।
- পর্ব ৫২ · Dynamic programming-এর ভিত্তিআগের ভিত্তিOptimal substructure, overlapping subproblems, memoization আর tabulation.
- পর্ব ১১ · String — অক্ষরের সারি আর প্রচলিত সমস্যাসম্পর্কিতস্ট্রিংয়ের মৌলিক কাজ, palindrome আর substring.
- C++ প্রোগ্রামিং · কনটেস্টে C++পাশের ট্র্যাক২D
vector, মেমরির সীমা আর কনটেস্টের কোডের অভ্যাস। - Python প্রোগ্রামিং · অ্যালগরিদম ও কনটেস্টে Pythonপাশের ট্র্যাকPython-এ DP টেবিল,
functools.cacheআর গতির কৌশল।