এই পর্বে যা শিখবেন
- 0/1 knapsack-এর state, transition আর base case লেখা, টেবিল হাতে ভরা, আর কোন জিনিস নেওয়া হলো তা ফিরে বের করা — শুদ্ধতার যুক্তিসহ
- এক সারির DP-তে লুপের দিক কেন উল্টো, আর সামনে থেকে চালালে কেন unbounded knapsack হয়ে যায়
- O(nW) কেন pseudo-polynomial, আর বিশাল W-এ meet in the middle বা মানের ওপর DP
- Subset sum, partition, bounded knapsack (binary splitting) আর coin change-এর দুই প্রশ্ন — লুপের ক্রমের অর্থসহ
- LIS-এর O(n²) DP আর O(n log n) patience sorting, subsequence ফেরত আনা, আর Russian doll-এর মতো প্রয়োগ চেনা
১ · সমস্যা — ঈদের ব্যাগ, ৭ কেজির সীমা আর greedy-র ফাঁদ
ঈদে বাড়ি যাচ্ছেন। বাসের কোম্পানি বলে দিয়েছে, যাত্রীপ্রতি মালপত্র সর্বোচ্চ ৭ কেজি। ঘরে চারটি জিনিস প্রস্তুত, প্রতিটির একটি ওজন আর একটি “দরকারের নম্বর” (মান) আছে — যত বেশি নম্বর, বাড়িতে সেটি তত কাজে লাগবে:
| জিনিস | ওজন (কেজি) | মান | প্রতি কেজিতে মান |
|---|---|---|---|
| ১ · চানাচুর-বিস্কুটের প্যাকেট | 1 | 1 | 1.00 |
| ২ · আত্মীয়দের উপহার | 3 | 4 | 1.33 |
| ৩ · ঈদের জামাকাপড় | 4 | 5 | 1.25 |
| ৪ · মিষ্টির বড় বাক্স | 5 | 7 | 1.40 |
জিনিস ভেঙে অর্ধেক নেওয়া যায় না — উপহার হয় পুরোটা যাবে, নয়তো যাবে না। প্রতিটি জিনিসের জন্য সিদ্ধান্ত মাত্র দুটি: নেব (১) বা নেব না (০). তাই এর নাম 0/1 knapsack (knapsack মানে পিঠের ব্যাগ)। লক্ষ্য: মোট ওজন ৭ কেজির মধ্যে রেখে মোট মান সবচেয়ে বেশি করা।
প্রথম চিন্তা আসে greedy (পর্ব ৫০): প্রতি কেজিতে যার মান সবচেয়ে বেশি, তাকে আগে নিন। মিষ্টির বাক্স (১.৪০) নিলাম — ৫ কেজি, বাকি ২ কেজি। উপহার (৩ কেজি) বা জামা (৪ কেজি) আর ধরে না, শুধু চানাচুর ঢোকে। মোট মান ৭ + ১ = ৮. কিন্তু উপহার আর জামা একসাথে নিলে ওজন ঠিক ৭, মান ৪ + ৫ = ৯ — greedy-র চেয়ে ভালো। এটি একটি counterexample: greedy এখানে ভুল। জিনিস ভাঙা গেলে (চাল, তেল — fractional knapsack) সেই greedy-ই সেরা, পর্ব ৫০-এ তার প্রমাণ আছে; ভাঙা না গেলে বাক্সের ফাঁকা জায়গা নষ্ট হয়, আর সেই হিসাব greedy করতে পারে না।
আরেকটি উপায় brute force: প্রতিটি জিনিস নেওয়া-না-নেওয়ার সব 2n টি সমন্বয় পরীক্ষা। n = 4-এ ১৬টি — সহজ। কিন্তু n = 100-এ 2100 — exponential time, মহাবিশ্বের বয়সেও শেষ হবে না। পর্ব ৫২-এ (Dynamic programming-এর ভিত্তি) দেখেছেন, dynamic programming কাজ করে যখন সমস্যার optimal substructure আছে আর সরল recursion-এ একই উপসমস্যা বারবার আসে (overlapping subproblems). এই পর্বে সেই কাঠামো চারটি ক্লাসিক সমস্যায় প্রয়োগ করব — প্রতিটিতে একই চারটি প্রশ্ন: state কী, transition কী, base case কী, আর কোন ক্রমে টেবিল ভরব।
২ · 0/1 knapsack-এর state আর transition — dp[i][w]
State. একটি উপসমস্যাকে চেনাতে দুটি সংখ্যা যথেষ্ট: কতগুলো জিনিস নিয়ে ভাবছি আর ব্যাগে কত জায়গা আছে। তাই state:
Transition. জিনিস i-কে নিয়ে মাত্র দুটি সম্ভাবনা। না নিলে প্রথম i − 1 টি জিনিস দিয়ে ক্ষমতা w-এর সেরা উত্তর: dp[i−1][w]. নিলে (যদি wᵢ ≤ w) তার মান vᵢ, আর বাকি w − wᵢ জায়গায় প্রথম i − 1 টির সেরা উত্তর। দুটির বড়টিই উত্তর — এটিই transition:
Base case. কোনো জিনিস না থাকলে মান 0: dp[0][w] = 0 সব w-এ। ক্রম. সারি i ভরতে শুধু সারি i − 1 লাগে, তাই সারি ধরে ওপর থেকে নিচে। উত্তর dp[n][W].
কেন ঠিক. i-এর ওপর induction. ধরুন সারি i − 1-এর সব ঘর সঠিক। প্রথম i টি জিনিস থেকে ক্ষমতা w-এর একটি সেরা ব্যাগ S নিন। S-এ জিনিস i না থাকলে S আসলে প্রথম i − 1 টির একটি বৈধ ব্যাগ, তাই মান(S) ≤ dp[i−1][w]. থাকলে S থেকে i সরালে প্রথম i − 1 টির একটি ব্যাগ পাওয়া যায় যার ওজন ≤ w − wᵢ, তাই মান(S) − vᵢ ≤ dp[i−1][w − wᵢ]. অর্থাৎ max-এর দুটি পদের একটি অন্তত মান(S)-এর সমান। উল্টো দিকে, max-এর প্রতিটি পদ একটি বাস্তব বৈধ ব্যাগের মান, তাই সেরার চেয়ে বেশি হতে পারে না। দুটি মিলে dp[i][w] = মান(S). ∎ লক্ষ করুন, “i একবারই নেওয়া যায়” শর্তটি এসেছে dp[i−1][…]-এর i − 1 থেকে — নেওয়ার পরও আমরা আগের সারিতে যাই।
Pseudocode — 0/1 knapsack (items 1..n, capacities 0..W)
for w = 0 to W: dp[0][w] = 0
for i = 1 to n:
for w = 0 to W:
dp[i][w] = dp[i-1][w] // skip item i
if wt[i] <= w:
dp[i][w] = max(dp[i][w], dp[i-1][w - wt[i]] + val[i]) // take item i once
return dp[n][W]ঈদের ব্যাগে টেবিলটি সারি ধরে ভরা দেখুন। কলাম w = 0 থেকে 7, সারি i = 0 থেকে 4:
| এই ধাপের কাজ | |
| হিসাব |
- Base case. কোনো জিনিস ছাড়া যেকোনো ক্ষমতায় মান 0 — পুরো সারি 0 শূন্য। নিচের সারিগুলো এখনো ফাঁকা।
- শুধু চানাচুর. ১ কেজি জায়গা থাকলেই চানাচুর ঢোকে, মান 1. w = 0-তে কিছুই ঢোকে না। প্রতিটি ঘর আগের সারির দুটি ঘর দেখে O(1)-এ ভরছে।
- উপহার যোগ হলো. w < 3-এ উপহার ঢোকে না, তাই ওপরের ঘরই নেমে আসে। w = 3-এ: না নিলে 1, নিলে dp[1][0] + 4 = 4 — উপহার জেতে। w = 4-এ উপহার + চানাচুর = 5. সারি 2: 0 1 1 4 5 5 5 5.
- জামা যোগ হলো — আসল লড়াই. dp[3][7]: জামা না নিলে ওপরের ঘর dp[2][7] = 5. নিলে ৪ কেজি যায়, বাকি ৩ কেজিতে প্রথম দুটির সেরা dp[2][3] = 4 (উপহার), মোট 9. নিচ্ছি — 9.
- মিষ্টি যোগ হলো. dp[4][7]: না নিলে 9, নিলে ৫ কেজি যায়, বাকি ২ কেজিতে dp[3][2] = 1, মোট 8 — greedy-র উত্তর ঠিক এটিই। DP দুটিকে পাশাপাশি তুলনা করে 9 রাখে। সারি 4: 0 1 1 4 5 7 8 9; উত্তর dp[4][7] = 9.
- ফিরে দেখা শুরু. কোন জিনিস নেওয়া হলো? শেষ ঘর dp[4][7] = 9, ঠিক ওপরের dp[3][7]-ও 9 — মানে মিষ্টি ছাড়াই 9 পাওয়া যায়; মিষ্টি নেওয়া হয়নি, ক্ষমতা w = 7 থাকল।
- বাকিটা. dp[3][7] = 9 কিন্তু dp[2][7] = 5 — জামা নিতেই হয়েছে; w = 7 − 4 = 3. dp[2][3] = 4 ≠ dp[1][3] = 1 — উপহার নেওয়া; w = 0. dp[1][0] = dp[0][0] = 0 — চানাচুর নয়। উত্তর: উপহার + জামা, ৭ কেজি, মান 9.
খরচ. টেবিলে (n + 1)(W + 1) টি ঘর, প্রতিটি O(1)-এ — সময় ও জায়গা Θ(nW).
৩ · কোন জিনিস নিলাম — reconstruction, আর এক সারির DP-তে উল্টো লুপ
উত্তরের সংখ্যাটি (9) জানাই যথেষ্ট নয়; ব্যাগে কী ঢোকাব, সেটিও চাই। ডায়াগ্রামের শেষ দুই ধাপে যা করলেন, সেটিই নিয়ম: i = n, w = W থেকে শুরু করে প্রতিটি i-তে dp[i][w] ≠ dp[i−1][w] হলে জিনিস i নেওয়া হয়েছে (তখন w থেকে wᵢ বাদ), নইলে নেওয়া হয়নি। কারণ dp[i][w] হয় dp[i−1][w], নয়তো dp[i−1][w − wᵢ] + vᵢ — মান বদলালে দ্বিতীয়টিই জিতেছে। এটি গ্রাফে parent ধরে পথ ফেরত আনার (path reconstruction, পর্ব ৪৩) মতোই; খরচ মাত্র O(n), তবে পুরো দুই-মাত্রার টেবিল রেখে দিতে হয়।
এক সারির DP — জায়গা Θ(W)
শুধু সর্বোচ্চ মান চাইলে পুরো টেবিল রাখার দরকার নেই: সারি i বানাতে শুধু সারি i − 1 লাগে। আরও এক ধাপ এগিয়ে একটিমাত্র অ্যারে one[0..W] রাখা যায়, যেখানে নতুন মান পুরোনোর জায়গায় লেখা হয়। কিন্তু একটি সূক্ষ্ম বিষয় আছে। one[w] হিসাব করতে লাগে পুরোনো সারির one[w − wᵢ]. w − wᵢ < w, তাই w-কে বড় থেকে ছোট দিকে চালালে one[w − wᵢ] তখনো ছোঁয়া হয়নি — পুরোনো মানই আছে। ছোট থেকে বড় দিকে চালালে one[w − wᵢ] এই সারিতেই একবার বদলে গেছে, মানে তাতে জিনিস i হয়তো আগেই ঢুকেছে — আর তার ওপর আবার vᵢ যোগ করলে একই জিনিস দুবার:
| জিনিস, লুপ | |
| নমুনা হিসাব | |
| দুই-মাত্রার টেবিলের সাথে |
- শুরু. একটিমাত্র অ্যারে, সব 0 — এটি সারি 0.
- চানাচুর. w = 7 থেকে 1 পর্যন্ত নামতে নামতে one[w] = max(one[w], one[w − 1] + 1) = 1. অ্যারেটি এখন সারি 1.
- উপহার, উল্টো লুপ. w = 7, 6, 5, 4, 3 — বড় থেকে ছোট। one[6] হিসাবের সময় one[3] এখনো পুরোনো 1, তাই 1 + 4 = 5. শেষে 0 1 1 4 5 5 5 5 — হুবহু সারি 2.
- ভুল পথ — সামনে থেকে লুপ. একই অবস্থা (সারি 1) থেকে উপহার w = 3, 4, 5, 6, 7 ক্রমে চালালে: one[3] আগেই 4 হয়ে গেছে, তাই one[6] = 4 + 4 = 8 — অর্থাৎ ৩ + ৩ কেজির দুটি উপহার! আমাদের তো উপহার একটিই। এই ভুল ফলটি ফেলে দিয়ে সঠিক অবস্থা (ধাপ ৩) থেকে এগোই।
- জামা. সঠিক অবস্থা থেকে উল্টো লুপ: one[7] = max(5, one[3] + 5) = 9 — one[3] = 4 মানে উপহার, সাথে জামা। অ্যারে = সারি 3.
- মিষ্টি. one[7] = max(9, one[2] + 7) = 9, one[6] = 8, one[5] = 7. শেষ অ্যারে 0 1 1 4 5 7 8 9 — দুই-মাত্রার টেবিলের শেষ সারির সাথে হুবহু এক। উত্তর one[7] = 9.
ভুল পথের ধাপটি কিন্তু অন্য এক সমস্যার সঠিক সমাধান: যদি প্রতিটি জিনিস যতবার খুশি নেওয়া যেত (unbounded knapsack), তাহলে one[w − wᵢ]-এ জিনিস i আগেই থাকাটাই আমরা চাইতাম। উল্টো লুপ = প্রতিটি জিনিস একবার, সামনের লুপ = যতবার খুশি — এক লাইনের পার্থক্য, সম্পূর্ণ ভিন্ন সমস্যা। এক সারির DP-তে জায়গা Θ(W), সময় তবুও Θ(nW); কিন্তু জিনিসের তালিকা ফেরত পেতে আলাদা ব্যবস্থা লাগে (যেমন প্রতিটি সারির জন্য একটি bit রাখা: “এই w-এ জিনিস i নিয়েছি কি না”).
নিচের প্রোগ্রাম দুই-মাত্রার টেবিল ভরে, পেছনে হেঁটে জিনিসের তালিকা বের করে, তারপর এক সারির উল্টো-লুপ সংস্করণ চালিয়ে মিলিয়ে দেখে — এক সারির অ্যারে কি প্রতিটি ক্ষমতায় দুই-মাত্রার শেষ সারির সমান?
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
using namespace std;
int main() {
vector<string> names{"snacks", "gifts", "clothes", "sweets"};
vector<int> wt{1, 3, 4, 5}; // weight in kg
vector<int> val{1, 4, 5, 7}; // how much we need it
const int n = 4, W = 7;
vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0)); // dp[i][w]: first i items, capacity w
for (int i = 1; i <= n; ++i)
for (int w = 0; w <= W; ++w) {
dp[i][w] = dp[i - 1][w]; // skip item i
if (wt[i - 1] <= w) // or take it (once)
dp[i][w] = max(dp[i][w], dp[i - 1][w - wt[i - 1]] + val[i - 1]);
}
vector<int> taken; // walk back from dp[n][W]
for (int i = n, w = W; i >= 1; --i)
if (dp[i][w] != dp[i - 1][w]) { taken.push_back(i); w -= wt[i - 1]; }
reverse(taken.begin(), taken.end());
vector<int> one(W + 1, 0); // the same DP in one row
for (int i = 0; i < n; ++i)
for (int w = W; w >= wt[i]; --w) // reverse: each item used at most once
one[w] = max(one[w], one[w - wt[i]] + val[i]);
cout << "best value: " << dp[n][W] << '\n' << "take:";
int total = 0;
for (int i : taken) { cout << ' ' << i << ':' << names[i - 1]; total += wt[i - 1]; }
cout << " weight " << total << '\n' << "2D last row:";
for (int x : dp[n]) cout << ' ' << x;
cout << '\n' << "1D row :";
for (int x : one) cout << ' ' << x;
cout << '\n' << "1D == 2D for every capacity: " << (one == dp[n] ? "yes" : "no") << '\n';
}আউটপুট:
best value: 9
take: 2:gifts 3:clothes weight 7
2D last row: 0 1 1 4 5 7 8 9
1D row : 0 1 1 4 5 7 8 9
1D == 2D for every capacity: yes
সর্বোচ্চ মান 9, জিনিস 2 (উপহার) আর 3 (জামা), মোট ৭ কেজি — ডায়াগ্রামের সাথে মেলে। শেষ তিন লাইন দেখায়, এক সারির উল্টো-লুপ DP প্রতিটি ক্ষমতা w = 0…7-এ দুই-মাত্রার টেবিলের শেষ সারির সমান উত্তর দেয় — অর্থাৎ শুধু W = 7 নয়, যেকোনো ছোট ব্যাগের উত্তরও একই অ্যারেতে। for (int w = W; w >= wt[i]; --w) লাইনে দুটি কাজ একসাথে: দিক উল্টো, আর w < wt[i] হলে লুপ ঢোকেই না, তাই আলাদা if লাগে না। one == dp[n] দুটি vector উপাদান ধরে তুলনা করে। সময় Θ(nW), দুই-মাত্রার টেবিলে জায়গা Θ(nW), এক সারিতে Θ(W). মান বড় হলে (যেমন টাকার অঙ্ক) long long নিন — integer overflow এখানে নীরবে ভুল উত্তর দেয়।
names = ["snacks", "gifts", "clothes", "sweets"]
wt = [1, 3, 4, 5] # weight in kg
val = [1, 4, 5, 7] # how much we need it
n, W = len(wt), 7
dp = [[0] * (W + 1) for _ in range(n + 1)] # dp[i][w]: best value, first i items, capacity w
for i in range(1, n + 1):
for w in range(W + 1):
dp[i][w] = dp[i - 1][w] # skip item i
if wt[i - 1] <= w: # or take it (once)
dp[i][w] = max(dp[i][w], dp[i - 1][w - wt[i - 1]] + val[i - 1])
taken, w = [], W # walk back from dp[n][W]
for i in range(n, 0, -1):
if dp[i][w] != dp[i - 1][w]: # value changed: item i was taken
taken.append(i); w -= wt[i - 1]
taken.reverse()
one = [0] * (W + 1) # the same DP in one row
for i in range(n):
for w in range(W, wt[i] - 1, -1): # reverse: each item used at most once
one[w] = max(one[w], one[w - wt[i]] + val[i])
print("best value:", dp[n][W])
print("take:", " ".join(f"{i}:{names[i - 1]}" for i in taken),
"weight", sum(wt[i - 1] for i in taken))
print("2D last row:", *dp[n])
print("1D row :", *one)
print("1D == 2D for every capacity:", "yes" if one == dp[n] else "no")আউটপুট C++-এর সাথে হুবহু এক:
best value: 9
take: 2:gifts 3:clothes weight 7
2D last row: 0 1 1 4 5 7 8 9
1D row : 0 1 1 4 5 7 8 9
1D == 2D for every capacity: yes
[[0] * (W + 1) for _ in range(n + 1)] প্রতিটি সারির জন্য আলাদা list বানায়। [[0] * (W + 1)] * (n + 1) লিখলে সব সারি একই list হতো — একটি ঘর বদলালে সব সারি বদলায়, DP-তে খুব প্রচলিত বাগ। range(W, wt[i] - 1, -1) দেয় W, W − 1, …, wt[i] — উল্টো লুপ। one == dp[n] দুটি list উপাদান ধরে তুলনা করে। Python-এ n·W ≈ 107 পর্যন্ত কয়েক সেকেন্ডে চলে; আরও বড় হলে C++ বা numpy-র মতো vectorized পদ্ধতি দরকার।
৪ · O(nW) কেন “প্রায়” polynomial — pseudo-polynomial সময়
Θ(nW) দেখে মনে হয় এটি একটি সাধারণ polynomial অ্যালগরিদম — n আর W-এর গুণফল। অথচ পর্ব ৬৪-এ দেখবেন, knapsack-এর সিদ্ধান্ত-রূপ (“মান ≥ K করা যায় কি?”) NP-complete — Richard Karp ১৯৭২ সালে যে ২১টি সমস্যাকে NP-complete দেখান, তার একটি knapsack. তাহলে কি আমরা P = NP প্রমাণ করে ফেললাম? না। কারণটি লুকিয়ে আছে “ইনপুটের আকার” (input size) কথাটির মধ্যে।
Complexity মাপা হয় ইনপুট লিখতে কত bit লাগে তার সাপেক্ষে। n টি জিনিস লিখতে n টি সংখ্যা লাগে, কিন্তু W সংখ্যাটি লিখতে লাগে মাত্র b = ⌈log₂(W + 1)⌉ টি bit. W = 1018 লিখতে ৬০ bit — ইনপুটে একটি ছোট সংখ্যা মাত্র। অথচ টেবিলে W + 1 টি কলাম, অর্থাৎ প্রায় 2b. তাই সময় O(n · 2b) — ইনপুটের দৈর্ঘ্যে exponential. W-এ একটি অঙ্ক (bit) বাড়ালে সময় দ্বিগুণ হয়। এমন সময়কে বলে pseudo-polynomial: সংখ্যাগুলোর মানে polynomial, কিন্তু তাদের দৈর্ঘ্যে নয়।
| ক্ষমতা W | W লিখতে bit | n = 100-এ টেবিলের ঘর | বাস্তবে |
|---|---|---|---|
| 1,000 | 10 | 105 | মুহূর্তে |
| 106 | 20 | 108 | C++-এ এক সেকেন্ডের কাছাকাছি |
| 109 | 30 | 1011 | অসম্ভব — সময় ও মেমরি দুটোই |
| 1018 | 60 | 1020 | চিন্তারও বাইরে; অন্য পদ্ধতি লাগবে (অংশ ৯) |
ব্যবহারিক সিদ্ধান্ত সহজ: কনটেস্ট বা বাস্তব সমস্যায় আগে সীমাগুলো দেখুন। n·W মোটামুটি 108-এর মধ্যে হলে এই DP. W বিশাল কিন্তু মানগুলো ছোট হলে উল্টো state নিন — dp[v] = মান ঠিক v পেতে সবচেয়ে কম ওজন — সময় O(n·V), V = মোট মান; উত্তর হলো সবচেয়ে বড় v, যার dp[v] ≤ W. দুটোই বিশাল কিন্তু n ছোট (≤ ৪০) হলে meet in the middle (অংশ ৯-এর “আরও গভীরে”). Pseudo-polynomial সময় তাই NP-complete সমস্যার একটি “দুর্বল জায়গা”: সংখ্যাগুলো ছোট থাকলে সমস্যাটি আসলে সহজ। এমন সমস্যাকে বলা হয় weakly NP-hard; যেগুলো ছোট সংখ্যাতেও কঠিন থাকে (যেমন traveling salesman, পর্ব ৫৭), সেগুলো strongly NP-hard.
৫ · একই ছাঁচের আরও সমস্যা — unbounded, subset sum, partition আর bounded knapsack
Unbounded knapsack — সামনের লুপ
একটি পাইকারি দোকানে বস্তা ভরছেন: চিনির ১ কেজির প্যাকেট, ডালের ৩ কেজির প্যাকেট — প্রতিটি যতগুলো খুশি। এখানে state হতে পারে একটিই সংখ্যা: dp[w] = ক্ষমতা w-এ সর্বোচ্চ মান, আর dp[w] = max over i ( dp[w − wᵢ] + vᵢ ). অংশ ৩-এর ভাষায়, এক সারির DP-তে w-এর লুপ সামনে থেকে:
Pseudocode — unbounded knapsack (each item any number of times)
dp[0..W] = 0
for each item i:
for w = wt[i] to W: // forward: dp[w - wt[i]] may already contain item i
dp[w] = max(dp[w], dp[w - wt[i]] + val[i])
return dp[W]সময় Θ(nW), জায়গা Θ(W). এখানে লুপ দুটির ক্রম উল্টে দিলেও (w বাইরে, i ভেতরে) সর্বোচ্চ মান একই আসে, কারণ max-এ ক্রম গুরুত্বহীন — কিন্তু অংশ ৬-এ দেখবেন, “কত উপায়ে” গুনতে গেলে ক্রমই সব।
Subset sum আর partition — হ্যাঁ/না-এর DP
কয়েকটি সংখ্যা দেওয়া আছে; এদের কোনো উপসেটের যোগফল ঠিক S হতে পারে কি? এটি subset sum — knapsack-এর বিশেষ রূপ, যেখানে মান = ওজন, আর আমরা শুধু জানতে চাই একটি নির্দিষ্ট যোগফল “পৌঁছানো যায় কি না”. state: can[s] = এখন পর্যন্ত দেখা সংখ্যা দিয়ে যোগফল s বানানো যায় কি না। শুরুতে শুধু can[0] = সত্য। প্রতিটি সংখ্যা x-এর জন্য s = S থেকে x পর্যন্ত (উল্টো লুপ — প্রতিটি সংখ্যা একবার): can[s] = can[s] OR can[s − x]. সময় O(n·S).
উদাহরণ {2, 3, 7, 8}: পৌঁছানো যায় এমন যোগফলের সেট ধাপে ধাপে বাড়ে — শুরুতে {0}; 2 নিয়ে {0, 2}; 3 নিয়ে {0, 2, 3, 5}; 7 নিয়ে {0, 2, 3, 5, 7, 9, 10, 12}; 8 নিয়ে আরও বাড়ে। মোট যোগফল 20, আর 10 পৌঁছানো যায় — তাই সংখ্যাগুলোকে সমান যোগফলের দুই দলে ভাগ করা যায়: {3, 7} আর {2, 8}. এই প্রশ্নটিই partition problem: মোট যোগফল T জোড় হলে T/2-এর subset sum; বিজোড় হলে সরাসরি “না”. দুই ভাইয়ের মধ্যে জমি বা দুই ট্রাকে মাল সমান ভাগের প্রশ্ন এভাবেই আসে।
লক্ষ করুন, প্রতিটি ধাপ আসলে “পুরোনো সেট OR (পুরোনো সেট x ঘর সরানো)”. সেটটিকে একটি bitset-এ রাখলে পুরো ধাপটি এক লাইনে: bits |= bits << x;. প্রসেসর একসাথে ৬৪টি bit সরায়, তাই সময় O(n·S / 64) — কনটেস্টে S ≈ 106-এর subset sum এভাবেই পার হয়।
Bounded knapsack — binary splitting
মাঝামাঝি রূপ: জিনিস i সর্বোচ্চ cᵢ বার নেওয়া যায় (যেমন দোকানে মিষ্টির প্যাকেট আছে মাত্র ১৩টি) — bounded knapsack. সরল উপায়ে ১৩টিকে ১৩টি আলাদা 0/1 জিনিস ধরলে সময় O(W · Σcᵢ). চতুর উপায় binary splitting: ১৩টি প্যাকেটকে 1, 2, 4 আর বাকি 6 আকারের চারটি বান্ডিলে ভাগ করুন, প্রতিটি বান্ডিল একটি 0/1 জিনিস (ওজন ও মান বান্ডিলের আকার দিয়ে গুণ)। 0 থেকে 13-এর যেকোনো সংখ্যা এই বান্ডিলগুলোর কিছুর যোগফল (যেমন 11 = 1 + 4 + 6, 13 = 1 + 2 + 4 + 6), আর 13-এর বেশি কখনো হয় না। তাই উত্তর একই থাকে, কিন্তু জিনিস মাত্র প্রায় log₂ cᵢ টি — সময় O(W · Σ log cᵢ). (monotonic deque দিয়ে O(nW)-ও সম্ভব, পর্ব ১৬-এর sliding window-এর ধারণায়; কনটেস্টে binary splitting-ই বেশি লেখা হয়।)
৬ · Coin change — সবচেয়ে কম কয়েন আর কত উপায়ে
ধরুন একটি দেশে কয়েন শুধু 1, 3 আর 4 টাকার। দোকানিকে ৬ টাকা ফেরত দিতে হবে। এটি coin change, আর এর দুটি প্রশ্ন আছে: (১) সবচেয়ে কম কয়টি কয়েন লাগবে? (২) মোট কত উপায়ে ৬ টাকা বানানো যায়? প্রতিটি কয়েন যতবার খুশি — তাই দুটিই unbounded knapsack-এর আত্মীয়।
প্রশ্ন ১ — সবচেয়ে কম কয়েন
Greedy: সবচেয়ে বড় যে কয়েন চলে, সেটি দিন — 4, তারপর 1, 1: তিনটি কয়েন। কিন্তু 3 + 3 = দুটি কয়েন। আবার greedy ভুল। DP: best[a] = অঙ্ক a বানাতে সবচেয়ে কম কয়েন, best[0] = 0, আর best[a] = 1 + min over কয়েন c ≤ a ( best[a − c] ) — শেষ কয়েনটি কোনটি, তা সব রকম চেষ্টা। a = 0 থেকে বাড়তি ক্রমে:
amount a : 0 1 2 3 4 5 6
best[a] : 0 1 2 1 1 2 2 best[6] = 1 + min(best[5], best[3], best[2]) = 1 + 1 = 2 (3 + 3)
যে অঙ্ক বানানোই যায় না (যেমন শুধু 5 আর 7-এর কয়েনে 3), সেখানে best = ∞ রাখুন, আর শেষে ∞ হলে “অসম্ভব” বলুন। সময় O(k·A), k = কয়েনের ধরন, A = অঙ্ক — এটিও pseudo-polynomial. বাস্তবের মুদ্রা (1, 2, 5, 10-এর ধাঁচ) এমনভাবে বানানো যে সেখানে greedy সব সময় সেরা — এমন ব্যবস্থাকে বলে canonical coin system. কিন্তু কোনো ব্যবস্থা canonical কি না, না জেনে greedy চালানো বিপদ।
প্রশ্ন ২ — কত উপায়ে, আর লুপের ক্রম
“কত উপায়ে” প্রশ্নে প্রথমে ঠিক করুন, 1 + 1 + 4 আর 4 + 1 + 1 একই উপায় কি না। দোকানে ফেরত দেওয়ার হিসাবে একই — শুধু কোন কয়েন কয়টি, সেটিই গুরুত্বপূর্ণ (combination). কিন্তু সিঁড়িতে ১, ৩ বা ৪ ধাপে লাফ দিয়ে ৬ ধাপ ওঠার প্রশ্নে লাফের ক্রম আলাদা হলে পথও আলাদা (ক্রমসহ গোনা). দুটির কোড প্রায় একই — শুধু দুই লুপের কোনটি বাইরে:
Pseudocode — number of ways (ways[0] = 1, all others 0)
// (A) order does NOT matter: coins outside
for each coin c:
for a = c to A: ways[a] += ways[a - c]
// (B) order matters: amounts outside
for a = 1 to A:
for each coin c <= a: ways[a] += ways[a - c](A)-তে কয়েনগুলো একটি একটি করে “চালু” হয়: প্রথম কয়েনের ধাপ শেষে ways[a] = শুধু সেই কয়েন দিয়ে উপায়, দ্বিতীয়টির পর = প্রথম দুটি দিয়ে, ইত্যাদি। প্রতিটি সমন্বয় ঠিক একবার গোনা হয় — কয়েনগুলো সব সময় একই নির্দিষ্ট ক্রমে যোগ হয় বলে (আগে সব 1, তারপর সব 3, তারপর সব 4). (B)-তে প্রতিটি অঙ্কে “শেষ কয়েনটি কোনটি” সব রকম চেষ্টা হয়, তাই 1 + 1 + 4 আর 4 + 1 + 1 আলাদা শেষ কয়েনে দুবার গোনা হয়:
| লুপ | |
| নমুনা হিসাব | |
| ways[6]-এর উপায়গুলো |
- শুরু. ways[0] = 1 — শূন্য টাকা বানানোর একটিই উপায়: কোনো কয়েন না দেওয়া। এই 1-ই পরের সব গোনার বীজ।
- শুধু 1 টাকার কয়েন. প্রতিটি অঙ্ক ঠিক এক উপায়ে: সব 1. ways[a] += ways[a − 1] সামনে থেকে চলে, তাই একই কয়েন বারবার নেওয়া যায় — যেমন unbounded knapsack.
- 3 টাকার কয়েন চালু. ways[3] = 1 + ways[0] = 2 (1+1+1, 3). ways[6] = 1 + ways[3] = 3; লক্ষ করুন, ways[3] এই ধাপেই 2 হয়েছে, তাই তাতে “একটি 3” আছে — ফলে 3 + 3-ও গোনা হলো।
- 4 টাকার কয়েন চালু. ways[4] = 2 + 1 = 3, ways[6] = 3 + ways[2] = 4. ক্রম ছাড়া ৬ টাকার ঠিক ৪টি উপায়: ছয়টি 1; একটি 3 আর তিনটি 1; দুটি 3; একটি 4 আর দুটি 1.
- লুপ উল্টালে. অঙ্কের লুপ বাইরে রাখলে ways[a] = ways[a − 1] + ways[a − 3] + ways[a − 4]: 1 1 1 2 4 6 9. ৯টি ক্রম — যেমন 1+1+4, 1+4+1, 4+1+1 তিনটি আলাদা। সিঁড়ির প্রশ্নে এটিই ঠিক, দোকানের প্রশ্নে ভুল। কোনটি চাই, প্রথমে সেটি ঠিক করুন।
দুই সংস্করণই O(k·A) সময় আর O(A) জায়গা। উপায়ের সংখ্যা খুব দ্রুত বড় হয় (A = 1000-এ প্রায়ই 64-bit-এরও বাইরে), তাই প্রশ্নে সাধারণত “উত্তর mod 109 + 7” চাওয়া হয় — প্রতিটি যোগের পর mod নিন। এখানে আরেকটি গভীর মিল আছে: (B)-র ways[a] আসলে একটি DAG-এ 0 থেকে a পর্যন্ত পথের সংখ্যা (প্রতিটি অঙ্ক একটি node, প্রতিটি কয়েন একটি edge) — পর্ব ৪২-এর topological order-এ পথ গোনা। প্রায় প্রতিটি DP-কে এভাবে state-এর একটি DAG হিসেবে দেখা যায়।
৭ · LIS — সবচেয়ে লম্বা বাড়তে থাকা subsequence, O(n²) DP
একজন ব্যাটারের পরপর আটটি ম্যাচের রান (দশকে): 3 1 4 1 5 9 2 6. আমরা খুঁজছি সবচেয়ে লম্বা এমন ম্যাচ-তালিকা (ক্রম বজায় রেখে, মাঝে কিছু বাদ দেওয়া চলবে), যেখানে প্রতিটি ম্যাচে রান আগেরটির চেয়ে কঠোরভাবে বেশি — “উন্নতির সবচেয়ে লম্বা ধারা”. ক্রম থেকে কিছু উপাদান বাদ দিয়ে, বাকিদের ক্রম না বদলে যা থাকে, তাকে বলে subsequence (পাশাপাশি থাকার দরকার নেই — পাশাপাশি হলে বলে subarray). সমস্যাটির নাম longest increasing subsequence (LIS). এখানে উত্তর 4 — যেমন 1 4 5 6 বা 3 4 5 9. পাঁচটির কোনো বাড়তি ধারা নেই।
State. dp[i] = ঠিক a[i]-তে শেষ হওয়া সবচেয়ে লম্বা বাড়তি subsequence-এর দৈর্ঘ্য। “শেষ হওয়া” শর্তটিই চাবি: এতে জানা থাকে শেষ উপাদানটি কত, তাই নতুন উপাদান জোড়া যায় কি না বলা যায়। Transition: dp[i] = 1 + max{ dp[j] : j < i এবং a[j] < a[i] } (এমন j না থাকলে dp[i] = 1). উত্তর সব dp[i]-এর সর্বোচ্চ — কারণ LIS যেকোনো জায়গায় শেষ হতে পারে।
i : 0 1 2 3 4 5 6 7
a[i] : 3 1 4 1 5 9 2 6
dp[i] : 1 1 2 1 3 4 2 4 max = 4
from j : - - 0 - 2 4 1 4 (one best j for each i; dp[7] = dp[4] + 1)
কেন ঠিক. a[i]-তে শেষ হওয়া যেকোনো বাড়তি subsequence-এর শেষের আগের উপাদানটি কোনো a[j], j < i, a[j] < a[i]; সেটি বাদ দিলে a[j]-তে শেষ হওয়া একটি বাড়তি subsequence থাকে, যার দৈর্ঘ্য ≤ dp[j] (induction). উল্টো দিকে dp[j]-এর ধারার শেষে a[i] জোড়া যায়। তাই সূত্রটি ঠিক। খরচ. প্রতিটি i-এর জন্য আগের সব j দেখা — মোট n(n − 1)/2 তুলনা, Θ(n²) সময়, Θ(n) জায়গা। “from j” কলাম (parent) রাখলে শেষ থেকে পেছনে হেঁটে subsequence পাওয়া যায়: 7 → 4 → 2 → 0 — মান 3 4 5 6. n = 5000-এ ১.২৫ কোটি তুলনা — ঠিক আছে; n = 2·105-এ ২০০০ কোটি — অনেক বেশি। দরকার আরও চতুর state.
৮ · LIS O(n log n)-এ — patience sorting, tails আর binary search
উপমা — তাসের স্তূপ. এক হাতে একটি একটি করে তাস নিচ্ছেন। নিয়ম: প্রতিটি তাস বাঁ দিক থেকে প্রথম সেই স্তূপে রাখুন, যার ওপরের তাস এই তাসের সমান বা বড়; এমন স্তূপ না থাকলে ডানে নতুন স্তূপ খুলুন। একাকী খেলার তাসের খেলা (patience বা solitaire) থেকে আসা এই পদ্ধতির নাম patience sorting. খেলা শেষে স্তূপের সংখ্যাই LIS-এর দৈর্ঘ্য।
স্তূপগুলোর ওপরের তাসগুলোকে একটি অ্যারেতে রাখুন — tails. এর অর্থ:
ছোট শেষ উপাদান ভালো, কারণ তাতে পরে বেশি সংখ্যা জোড়া যায়। নতুন সংখ্যা x এলে: tails-এ প্রথম যে ঘরের মান ≥ x, সেটি খুঁজুন (lower_bound, পর্ব ১৮-এর binary search). এমন ঘর না থাকলে x সবার চেয়ে বড় — tails-এর শেষে জুড়ুন (LIS এক বাড়ল)। থাকলে সেই ঘরে x বসান (সেই দৈর্ঘ্যের একটি আরও ভালো, ছোট শেষ পাওয়া গেল)। subsequence ফেরত পেতে প্রতিটি tails ঘরের পাশে সেই উপাদানের index (tailIdx) রাখুন, আর x বসানোর সময় parent[x-এর index] = বাঁ পাশের ঘরের index:
| x, lower_bound | |
| কাজ, parent | |
| tailIdx |
- প্রথম তাস. 3 এলো, tails খালি — প্রথম স্তূপ: tails = [3]. দৈর্ঘ্য ১-এর ধারার সবচেয়ে ছোট শেষ এখন 3.
- ছোট শেষ ভালো. 1 এলো। প্রথম ঘর 3 ≥ 1, তাই 3-এর জায়গায় 1. দৈর্ঘ্য ১-এর ধারা “1”, যা “3”-এর চেয়ে ভালো — পরে 2-ও জোড়া যাবে।
- লম্বা হলো. 4 সবার চেয়ে বড় — নতুন স্তূপ, tails = [1, 4]. parent[2] = tailIdx[0] = 1: এই 4-এর আগে আছে index 1-এর 1.
- সমান মান. আবার 1. lower_bound প্রথম “≥ 1” ঘর দেয় — tails[0] = 1 নিজেই, তাই একই মানে বদল (index এখন 3). সমানকে বড় না ধরায় 1 1 কখনো “বাড়তি” গোনা হয় না — কঠোরভাবে বাড়তি LIS-এর জন্য এটিই ঠিক।
- 5. সবার চেয়ে বড়: tails = [1, 4, 5], parent[4] = tailIdx[1] = 2 (মান 4).
- 9. আবার শেষে: tails = [1, 4, 5, 9] — দৈর্ঘ্য ৪-এর একটি ধারা আছে, শেষ 9.
- 2 — ভেতরে বসানো. প্রথম ≥ 2 হলো 4 (ঘর 1). tails = [1, 2, 5, 9]. লক্ষ করুন, “1 2 5 9” কোনো আসল subsequence নয় (2 এসেছে 5 আর 9-এর পরে)! tails প্রতিটি দৈর্ঘ্যের সেরা শেষ রাখে, একটি একক ধারা নয়।
- 6. প্রথম ≥ 6 হলো 9 (ঘর 3): tails = [1, 2, 5, 6], parent[7] = tailIdx[2] = 4. দৈর্ঘ্য ৪-এর ধারার শেষ এখন 6 — 9-এর চেয়ে ভালো।
- ফিরে আসা. LIS-এর দৈর্ঘ্য tails-এর আকার = 4. শেষ ঘরের index 7 থেকে parent ধরে: 7 → 4 → 2 → 1, মান 6, 5, 4, 1; উল্টে দিলে 1 4 5 6. আসল উত্তর পেতে tails ছাপা যাবে না — parent ধরতে হবে।
কেন ঠিক — invariant. দুটি কথা প্রতিটি ধাপের পর সত্য: (১) tails[k] সত্যিই দৈর্ঘ্য k + 1-এর বাড়তি subsequence-গুলোর সবচেয়ে ছোট শেষ; (২) tails কঠোরভাবে বাড়তি। (২) আসে (১) থেকে: দৈর্ঘ্য k + 2-এর একটি ধারা থেকে শেষ উপাদান বাদ দিলে দৈর্ঘ্য k + 1-এর একটি ধারা পাই, যার শেষ তার চেয়ে কঠোরভাবে ছোট, তাই tails[k] < tails[k + 1]. সাজানো বলেই binary search চলে। এখন x এলো, p = lower_bound. তখন tails[p − 1] < x, তাই দৈর্ঘ্য p-এর সেরা ধারার শেষে x জুড়ে দৈর্ঘ্য p + 1-এর একটি ধারা পাওয়া যায় যার শেষ x ≤ tails[p] — নতুন সবচেয়ে ছোট শেষ। k < p-এর ঘরগুলোতে x কোনো উন্নতি আনে না (সেগুলো আগেই x-এর চেয়ে ছোট), আর x দিয়ে দৈর্ঘ্য p + 2 বা বেশির ধারা শেষ করা যায় না, কারণ তার জন্য দৈর্ঘ্য p + 1-এর এমন ধারা লাগত যার শেষ < x, অথচ tails[p] ≥ x. তাই শুধু ঘর p বদলায়। ∎
খরচ. প্রতিটি উপাদানে একটি binary search — O(n log L), L = LIS-এর দৈর্ঘ্য, তাই O(n log n); জায়গা O(n). Michael Fredman ১৯৭৫ সালে দেখান, তুলনা-ভিত্তিক মডেলে এই হার মূলত সেরা। কঠোরভাবে নয়, শুধু “কমে না” (non-decreasing) ধারা চাইলে lower_bound-এর বদলে upper_bound (প্রথম > x) — সমান মান তখন ধারাকে লম্বা করে।
নিচের প্রোগ্রাম tails আর parent দিয়ে LIS বের করে, তারপর ৫০০টি এলোমেলো ছোট অ্যারেতে (০–১২ দৈর্ঘ্য, মান ০–৯, অনেক পুনরাবৃত্তিসহ) মিলিয়ে দেখে: দ্রুত পদ্ধতির দৈর্ঘ্য O(n²) DP-র সমান কি না, আর ফেরত আনা ধারাটি সত্যিই মূল অ্যারের একটি কঠোরভাবে বাড়তি subsequence কি না — ছোট আকারের stress testing.
#include <algorithm>
#include <iostream>
#include <utility>
#include <vector>
using namespace std;
pair<vector<int>, vector<int>> lisFast(const vector<int>& a) { // O(n log n): patience sorting
vector<int> tails, tailIdx, parent(a.size(), -1);
for (int i = 0; i < (int)a.size(); ++i) {
int p = lower_bound(tails.begin(), tails.end(), a[i]) - tails.begin(); // first tail >= a[i]
if (p > 0) parent[i] = tailIdx[p - 1];
if (p == (int)tails.size()) { tails.push_back(a[i]); tailIdx.push_back(i); }
else { tails[p] = a[i]; tailIdx[p] = i; }
}
vector<int> seq; // walk back through parent[]
for (int i = tailIdx.empty() ? -1 : tailIdx.back(); i != -1; i = parent[i]) seq.push_back(a[i]);
return {vector<int>(seq.rbegin(), seq.rend()), tails};
}
int lisSlow(const vector<int>& a) { // O(n^2): dp[i] = longest ending at i
vector<int> dp(a.size(), 1);
for (size_t i = 0; i < a.size(); ++i)
for (size_t j = 0; j < i; ++j) if (a[j] < a[i]) dp[i] = max(dp[i], dp[j] + 1);
return a.empty() ? 0 : *max_element(dp.begin(), dp.end());
}
bool isIncSubseq(const vector<int>& s, const vector<int>& a) { // strictly increasing subsequence of a?
size_t k = 0;
for (int x : a) if (k < s.size() && x == s[k]) ++k;
return k == s.size() && is_sorted(s.begin(), s.end(), [](int x, int y) { return x <= y; });
}
int main() {
vector<int> a{3, 1, 4, 1, 5, 9, 2, 6};
auto [seq, tails] = lisFast(a);
cout << "tails at the end:"; for (int x : tails) cout << ' ' << x;
cout << "\nLIS length " << seq.size() << " :"; for (int x : seq) cout << ' ' << x;
long long seed = 12345; int bad = 0;
for (int t = 0; t < 500; ++t) { // small random arrays, fixed LCG seed
vector<int> arr;
for (int k = 0; k < t % 13; ++k) { seed = (seed * 1103515245 + 12345) % (1LL << 31); arr.push_back((int)(seed % 10)); }
vector<int> s = lisFast(arr).first;
bad += (int)s.size() != lisSlow(arr) || !isIncSubseq(s, arr);
}
cout << "\n500 random arrays checked, mismatches: " << bad << '\n';
}আউটপুট:
tails at the end: 1 2 5 6
LIS length 4 : 1 4 5 6
500 random arrays checked, mismatches: 0
প্রথম লাইন সাবধান করে: শেষের tails = 1 2 5 6 কোনো আসল উত্তর নয়। দ্বিতীয় লাইনে parent ধরে পাওয়া আসল LIS 1 4 5 6 — ডায়াগ্রামের সাথে মেলে। ৫০০টি এলোমেলো অ্যারের (খালি অ্যারেসহ) একটিতেও দৈর্ঘ্য বা ধারার বৈধতায় অমিল নেই। lower_bound(...) - tails.begin() iterator থেকে index দেয়। is_sorted-এর তুলনা x <= y দেওয়ায় দুটি পাশাপাশি সমান মান থাকলেও “সাজানো নয়” ধরা পড়ে — অর্থাৎ কঠোর বৃদ্ধি যাচাই হয়। এলোমেলো সংখ্যা আসে একটি নির্দিষ্ট seed-এর linear congruential generator থেকে, তাই প্রতিবার একই অ্যারে আর C++ ও Python-এ একই আউটপুট। সময় O(n log n), জায়গা O(n).
from bisect import bisect_left
def lis_fast(a): # O(n log n): patience sorting
tails, tail_idx, parent = [], [], [-1] * len(a)
for i, x in enumerate(a):
p = bisect_left(tails, x) # first tail >= x (strictly increasing)
if p > 0: parent[i] = tail_idx[p - 1]
if p == len(tails): tails.append(x); tail_idx.append(i)
else: tails[p] = x; tail_idx[p] = i
seq, i = [], tail_idx[-1] if tail_idx else -1
while i != -1: seq.append(a[i]); i = parent[i]
return seq[::-1], tails
def lis_slow(a): # O(n^2): dp[i] = longest ending at i
dp = [1] * len(a)
for i in range(len(a)):
for j in range(i):
if a[j] < a[i]: dp[i] = max(dp[i], dp[j] + 1)
return max(dp, default=0)
def is_inc_subseq(s, a): # strictly increasing and a subsequence of a?
it = iter(a)
return all(x < y for x, y in zip(s, s[1:])) and all(x in it for x in s)
a = [3, 1, 4, 1, 5, 9, 2, 6]
seq, tails = lis_fast(a)
print("tails at the end:", *tails)
print("LIS length", len(seq), ":", *seq)
seed, bad = 12345, 0
for t in range(500): # small random arrays, fixed LCG seed
arr = []
for _ in range(t % 13):
seed = (seed * 1103515245 + 12345) % 2**31; arr.append(seed % 10)
s, _ = lis_fast(arr)
bad += len(s) != lis_slow(arr) or not is_inc_subseq(s, arr)
print("500 random arrays checked, mismatches:", bad)আউটপুট C++-এর সাথে হুবহু এক:
tails at the end: 1 2 5 6
LIS length 4 : 1 4 5 6
500 random arrays checked, mismatches: 0
bisect_left হলো Python-এর lower_bound — প্রথম যে অবস্থানে মান ≥ x; non-decreasing ধারার জন্য bisect_right. is_inc_subseq-এর all(x in it for x in s) একটি iterator-কে সামনে এগিয়ে খোঁজে, তাই প্রতিটি পরের উপাদান আগেরটির পরে খোঁজা হয় — subsequence-এর পরীক্ষা O(n)-এ। max(dp, default=0) খালি অ্যারেতেও কাজ করে। seq[::-1] ধারাটি উল্টায়। n = 2·105-এ এই Python কোডও এক সেকেন্ডের কাছাকাছি চলে, কারণ bisect C-তে লেখা।
৯ · LIS-এর প্রয়োগ আর বিশাল W-এর knapsack
LIS-এর শক্তি হলো, অনেক সমস্যা একটু সাজিয়ে নিলেই LIS হয়ে যায়:
Russian doll — খামের ভেতরে খাম
প্রতিটি খামের (প্রস্থ, উচ্চতা); একটির ভেতরে আরেকটি ঢোকে যদি দুটিই কঠোরভাবে ছোট। প্রস্থ বাড়তি ক্রমে সাজান, সমান প্রস্থে উচ্চতা কমতি ক্রমে, তারপর উচ্চতার ওপর কঠোর LIS — O(n log n). সমান প্রস্থে উল্টো সাজানোয় একই প্রস্থের দুটি খাম একই ধারায় আসতে পারে না।
Box stacking
বাক্সের ওপর বাক্স, ওপরেরটির ভূমি দুই দিকেই ছোট; লক্ষ্য সর্বোচ্চ উচ্চতা. ভূমি অনুযায়ী সাজিয়ে O(n²) DP: dp[i] = hᵢ + max dp[j]. এটি “ওজনসহ” LIS, তাই tails-এর কৌশল সরাসরি খাটে না।
সবচেয়ে কম কমতি সারি
ক্রমটিকে সবচেয়ে কম কয়টি non-increasing subsequence-এ ঢাকা যায়? উত্তর = LIS-এর দৈর্ঘ্য — patience sorting-এর স্তূপগুলোই সেই সারি। যেমন ট্রেনের বগি বা রানওয়ের বিমান সাজানোর ধাঁধা।
দুই ফাইলের diff
Git-এর git diff --patience দুই ফাইলে একবার করে থাকা লাইনগুলো মিলিয়ে তাদের ক্রমের LIS নেয়, যাতে কোডের ব্লকগুলো আরও স্বাভাবিকভাবে মেলে।
LIS-এর পরের ধাপ দুটি ক্রমের সবচেয়ে লম্বা সাধারণ subsequence (LCS) — পর্ব ৫৪-এর বিষয়। আরেকটি মজার সংযোগ: দুটি ক্রমের একটি যদি অন্যটির permutation হয় (সব উপাদান আলাদা), তাহলে LCS-কে LIS-এ নামিয়ে O(n log n)-এ করা যায়।
আরও গভীরে — W বিশাল হলে meet in the middle: n ≤ ৪০ টি জিনিস, কিন্তু W আর মান দুটোই 1018 পর্যন্ত — কোনো টেবিল চলবে না, আর 240 ≈ 1012 সমন্বয়ও বেশি। Meet in the middle: জিনিসগুলোকে ২০ + ২০ দুই ভাগে ভাঙুন। প্রতিটি ভাগের সব 220 ≈ ১০ লাখ উপসেটের (ওজন, মান) বের করুন। দ্বিতীয় ভাগের তালিকা ওজন অনুযায়ী সাজিয়ে, “এই ওজন বা কমে সর্বোচ্চ মান” (prefix max) রাখুন। তারপর প্রথম ভাগের প্রতিটি উপসেটের জন্য binary search করে দেখুন, বাকি W − ওজন জায়গায় দ্বিতীয় ভাগের সেরা কত। মোট O(2n/2 · n) — প্রায় ২ কোটির কাজ, এক সেকেন্ডের মধ্যে। Ellis Horowitz আর Sartaj Sahni ১৯৭৪ সালে knapsack-এর জন্য এই পদ্ধতি দেন; পরে Richard Schroeppel আর Adi Shamir (১৯৮১) মেমরি কমিয়ে O(2n/4) করেন।
পরীক্ষা ও ইন্টারভিউতে:
- 0/1 knapsack-এর recurrence আর complexity লিখুন। — dp[i][w] = max(dp[i−1][w], dp[i−1][w − wᵢ] + vᵢ); সময় Θ(nW), জায়গা Θ(nW) বা এক সারিতে Θ(W).
- O(nW) থাকলে knapsack কেন NP-complete? — W-এর মান তার bit-সংখ্যায় exponential; সময়টি pseudo-polynomial, ইনপুটের দৈর্ঘ্যে polynomial নয়।
- এক সারির 0/1 knapsack-এ লুপ উল্টো কেন? — যাতে one[w − wᵢ] পুরোনো সারির মান থাকে; সামনে থেকে চালালে একই জিনিস একাধিকবার, অর্থাৎ unbounded knapsack.
- Coin change-এ উপায় গুনতে কোন লুপ বাইরে? — ক্রম ছাড়া (combination) গুনতে কয়েন বাইরে; ক্রমসহ গুনতে অঙ্ক বাইরে।
- O(n log n) LIS-এ tails কি LIS নিজেই? — না; tails[k] দৈর্ঘ্য k + 1-এর সবচেয়ে ছোট শেষ। দৈর্ঘ্য = tails-এর আকার, ধারা পেতে parent লাগে।
১০ · AI-এর যুগে DP — সঠিক দেখায়, এক লাইনে ভুল
Knapsack, coin change আর LIS ইন্টারনেটে লাখো বার লেখা হয়েছে, তাই AI এগুলোর কোড প্রায় সব সময় পরিচিত চেহারায় দেয়। কিন্তু এই পর্ব জুড়ে দেখেছেন, এই সমস্যাগুলোর আসল পার্থক্য প্রায়ই এক লাইনে — লুপের দিক, লুপের ক্রম, lower_bound না upper_bound. AI প্রায়ই দুটি কাছাকাছি সমস্যার সমাধান গুলিয়ে ফেলে, আর complexity লেখে “O(nW) — polynomial”.
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
| 0/1 knapsack-এর এক সারির DP-তে সামনের লুপ | একটি জিনিস দিন যার দুটি কপি ব্যাগে ধরে: (৩ কেজি, মান ৪), W = 6 — উত্তর 4 হওয়া উচিত, ভুল কোড দেবে 8 |
| Coin change-এর উপায় গুনতে লুপের ক্রম উল্টো | {1, 2}, অঙ্ক 3 হাতে গুনুন: ক্রম ছাড়া 2 (1+1+1, 1+2), ক্রমসহ 3 |
| “O(nW), তাই polynomial” | জিজ্ঞাসা করুন W = 1018 হলে কী হবে, আর W লিখতে কয় bit লাগে |
| Greedy coin change বা ratio-greedy knapsack-কে “সেরা” বলা | {1, 3, 4} আর অঙ্ক 6; বা অংশ ১-এর ঈদের ব্যাগ — brute force-এর সাথে মেলান |
| LIS হিসেবে tails ছাপানো, বা কঠোর LIS-এ upper_bound | 3 1 4 1 5 9 2 6 দিয়ে দেখুন; আর 2 2 2-এর কঠোর LIS 1 কি না |
| উপায়ের সংখ্যায় overflow, বা mod ভুল জায়গায় | বড় অঙ্কে Python-এর অসীম পূর্ণসংখ্যা দিয়ে উত্তর বানিয়ে mod নিয়ে তুলনা করুন |
AI-কে সমাধান লিখতে বলার চেয়ে আপনার DP-র state আর লুপ যাচাই করতে বলুন:
DP-র কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Knapsack NP-complete, অথচ বাস্তবে বিশাল knapsack-ও প্রায়ই দ্রুত সমাধান হয় — “তাত্ত্বিকভাবে কঠিন” আর “বাস্তবে কঠিন”-এর মধ্যে এই ফাঁক কোথা থেকে আসে?
Richard Karp ১৯৭২ সালের বিখ্যাত গবেষণাপত্র “Reducibility Among Combinatorial Problems”-এ ২১টি সমস্যাকে NP-complete দেখান, আর knapsack তাদের একটি। অর্থাৎ knapsack-এর জন্য সব ক্ষেত্রে দ্রুত (ইনপুটের দৈর্ঘ্যে polynomial) অ্যালগরিদম পেলে P = NP হয়ে যেত। তবু অপারেশনস রিসার্চে knapsack প্রতিদিন সমাধান হয় — জাহাজে কনটেইনার তোলা, বিজ্ঞাপনের বাজেট ভাগ, বিনিয়োগের প্রকল্প বাছাই। এর পেছনে তিনটি কারণ।
প্রথমত, অংশ ৪-এর pseudo-polynomial DP: বাস্তবের ওজন আর ক্ষমতা প্রায়ই ছোট পূর্ণসংখ্যা (কেজি, টন, ঘণ্টা), তাই nW সামলানো যায়। কঠিনতা জন্মায় শুধু তখন, যখন সংখ্যাগুলো বিশাল আর সূক্ষ্মভাবে বাছাই করা — এজন্য knapsack-কে বলা হয় weakly NP-hard. দ্বিতীয়ত, approximation: Oscar Ibarra আর Chul Kim ১৯৭৫ সালে দেখান, মানগুলোকে গোল করে ছোট করে মানের ওপর DP চালালে যেকোনো ε > 0-এর জন্য সেরা উত্তরের (1 − ε) গুণের মধ্যে একটি উত্তর পাওয়া যায়, n আর 1/ε-এ polynomial সময়ে — একে বলে FPTAS (পর্ব ৬৬-এর approximation-এর আলোচনা). অর্থাৎ ৯৯% ভালো উত্তর সস্তা, শুধু শেষ ১% কঠিন। তৃতীয়ত, branch and bound (পর্ব ৫৭): fractional knapsack-এর greedy উত্তর একটি দ্রুত ঊর্ধ্বসীমা দেয়, আর তা দিয়ে খোঁজার গাছের বিশাল অংশ কেটে ফেলা যায়। বাস্তব ইনপুটে এই সীমা এতই কাছাকাছি থাকে যে হাজার হাজার জিনিসের knapsack-ও সাধারণত দ্রুত মেলে।
এই কারণেই কাটিং-স্টক সমস্যায় (বড় কাপড় বা কাগজের রোল থেকে বিভিন্ন মাপের টুকরো কাটা, যাতে অপচয় সবচেয়ে কম) Paul Gilmore আর Ralph Gomory ১৯৬০-এর দশকের শুরুতে যে পদ্ধতি দেন, তার ভেতরে বারবার একটি knapsack সমাধান করতে হয় — আর পুরো পদ্ধতিটি বাস্তবে দারুণ চলে। পোশাক কারখানায় কাপড় কাটার পরিকল্পনার মতো কাজে আজও এই ধরনের পদ্ধতির উত্তরসূরিরা ব্যবহৃত হয়।
মূল উপলব্ধি: NP-complete মানে “সবচেয়ে খারাপ ইনপুটে, নির্ভুল উত্তর, দৈর্ঘ্যে polynomial সময়ে — সম্ভবত অসম্ভব”. এই তিন শর্তের যেকোনো একটি শিথিল করলে (ছোট সংখ্যা, প্রায়-সেরা উত্তর, বাস্তব ইনপুট) সমস্যা প্রায়ই সহজ হয়ে যায়।
প্র ০২একসময় knapsack-এর কঠিনতার ওপর একটি public-key cryptosystem বানানো হয়েছিল, আর কয়েক বছরেই তা ভেঙে যায় — এই ঘটনা কঠিনতার কোন সূক্ষ্ম দিক দেখায়?
১৯৭৬ সালে Whitfield Diffie আর Martin Hellman public-key cryptography-র ধারণা দেওয়ার পর গবেষকেরা এমন গাণিতিক সমস্যা খুঁজছিলেন, যা এক দিকে সহজ আর উল্টো দিকে কঠিন। ১৯৭৮ সালে Ralph Merkle আর Martin Hellman subset sum-এর ওপর একটি ব্যবস্থা প্রস্তাব করেন। গোপন চাবি ছিল একটি “superincreasing” ক্রম — প্রতিটি সংখ্যা তার আগের সবগুলোর যোগফলের চেয়ে বড়, যেমন 2, 3, 7, 14, 30. এমন ক্রমে subset sum greedy দিয়েই সমাধান হয়: বড় থেকে দেখুন, ধরলে নিন। এরপর একটি গোপন গুণক আর modulus দিয়ে ক্রমটিকে এলোমেলো দেখতে একটি ক্রমে বদলে প্রকাশ করা হতো। বার্তার bit-গুলো দিয়ে প্রকাশ্য ক্রমের একটি উপসেটের যোগফল হতো সংকেত। আক্রমণকারীর সামনে “সাধারণ” subset sum — NP-complete; গোপন চাবিওয়ালার সামনে সহজ greedy.
কিন্তু ১৯৮২ সালে Adi Shamir দেখান, মূল Merkle–Hellman ব্যবস্থা polynomial সময়ে ভাঙা যায়। কারণ, প্রকাশ্য ক্রমটি দেখতে এলোমেলো হলেও তার ভেতরে superincreasing কাঠামোর ছাপ থেকে যায়, আর lattice-এর গণিত (Lenstra-র integer programming-এর কৌশল, পরে LLL অ্যালগরিদম) সেই ছাপ খুঁজে বের করতে পারে। পরে আরও কিছু “উন্নত” knapsack-ব্যবস্থাও একইভাবে ভেঙে যায়।
শিক্ষাটি গভীর: NP-completeness হলো সবচেয়ে খারাপ ইনপুটের কঠিনতা। Cryptography-তে দরকার গড়ে কঠিনতা — এলোমেলোভাবে বানানো প্রায় সব ইনপুট কঠিন হওয়া চাই, আর বিশেষ কাঠামোর ইনপুটে (যেমন লুকানো superincreasing ক্রম) সমস্যা সহজ হয়ে যেতে পারে। মজার ব্যাপার, যে lattice দিয়ে knapsack-ব্যবস্থা ভেঙেছিল, আজ সেই lattice-এর অন্য সমস্যাই নতুন cryptography-র ভিত্তি: ২০২৪ সালে মার্কিন NIST যে post-quantum মান (ML-KEM, আগের নাম Kyber) প্রকাশ করে, তা lattice-এর একটি গড়-কঠিন সমস্যার ওপর দাঁড়িয়ে।
মূল উপলব্ধি: একটি সমস্যা “NP-complete” হওয়া মানে এই নয় যে তার প্রতিটি ইনপুট কঠিন। নিরাপত্তা বা কঠিনতার দাবি করার আগে জিজ্ঞাসা করুন — কোন ইনপুটের ওপর, আর কোন অর্থে কঠিন?
প্র ০৩কেন বেশির ভাগ দেশের মুদ্রায় greedy ফেরত দেওয়া ঠিক কাজ করে, আর কোন মুদ্রায় করত না? একটি কয়েন-ব্যবস্থা canonical কি না, কীভাবে যাচাই করা যায়?
বাংলাদেশসহ আজকের প্রায় সব দেশের নোট-কয়েন 1-2-5-এর ধাঁচে সাজানো: 1, 2, 5, 10, 20, 50, 100… এমন ব্যবস্থায় “সবচেয়ে বড় যে নোট চলে, সেটি দাও” নিয়মে সব সময় সবচেয়ে কম নোট লাগে। দোকানির জন্য এটি দারুণ সুবিধা — মাথায় DP চালাতে হয় না। কিন্তু এটি মুদ্রার নকশার গুণ, greedy-র নয়।
একটি বিখ্যাত উল্টো উদাহরণ ১৯৭১ সালের আগের ব্রিটিশ মুদ্রা, যখন পেনিকে দশমিকে ভাগ করা হয়নি। তখন কয়েন ছিল 3 পেনি, 6 পেনি, 12 পেনি (shilling), 24 পেনি (florin), 30 পেনি (half crown) ইত্যাদি। 48 পেনি greedy-তে: 30 + 12 + 6 — তিনটি কয়েন; অথচ 24 + 24 — দুটি। এই পর্বের {1, 3, 4} ঠিক একই ধাঁচের ফাঁদ।
তাহলে একটি ব্যবস্থা canonical কি না, কীভাবে বুঝবেন? সরল উপায়: বড় একটি সীমা পর্যন্ত প্রতিটি অঙ্কে greedy আর অংশ ৬-এর DP মিলিয়ে দেখা। গবেষকেরা দেখিয়েছেন, এর জন্য সীমাহীন পরীক্ষা লাগে না — Dexter Kozen আর Shmuel Zaks ১৯৯৪ সালে প্রমাণ করেন, যদি কোনো counterexample থাকে, তাহলে সবচেয়ে ছোটটি সবচেয়ে বড় দুই কয়েনের যোগফলের চেয়ে ছোট। ফলে একটি সসীম পরীক্ষাই যথেষ্ট। পরে David Pearson একটি polynomial সময়ের পরীক্ষা দেন, যা কয়েনের ধরনের সংখ্যার ঘনে (O(k³)) চলে — অঙ্কের মানের ওপর নির্ভর না করে। অন্যদিকে সাধারণ কয়েন-ব্যবস্থায় সবচেয়ে কম কয়েনের প্রশ্নটি নিজেই NP-hard, যখন কয়েনের মানগুলো ইনপুটের অংশ আর বিশাল।
মূল উপলব্ধি: Greedy-র সাফল্য প্রায়ই ইনপুটের বিশেষ কাঠামোর উপহার। কাঠামো জানা না থাকলে DP চালান, বা অন্তত greedy-কে DP-র সাথে ছোট সীমা পর্যন্ত মিলিয়ে নিন — এটিই পর্ব ৫০-এর exchange argument-এর ব্যবহারিক বিকল্প।
প্র ০৪এলোমেলো একটি permutation-এর LIS সাধারণত কত লম্বা হয়? এই প্রশ্ন থেকে গণিতের কোন বিস্ময়কর ফল বেরিয়েছে?
শুরু ১৯৩৫ সালে। Paul Erdős আর George Szekeres প্রমাণ করেন: যেকোনো n² + 1 টি ভিন্ন সংখ্যার ক্রমে দৈর্ঘ্য n + 1-এর একটি বাড়তি অথবা একটি কমতি subsequence থাকবেই। প্রমাণটি এই পর্বের ধারণাতেই: প্রতিটি উপাদানে (তাতে শেষ হওয়া LIS, তাতে শেষ হওয়া সবচেয়ে লম্বা কমতি ধারা) জোড়টি আলাদা হয়, আর দুটিই ≤ n হলে মাত্র n² টি জোড় সম্ভব — pigeonhole নীতিতে বিরোধ। অর্থাৎ দশ লাখ ভিন্ন সংখ্যার যেকোনো ক্রমে অন্তত ১০০০ দৈর্ঘ্যের একমুখী (বাড়তি বা কমতি) ধারা আছেই।
১৯৬০-এর দশকের শুরুতে Stanislaw Ulam প্রশ্ন তোলেন: এলোমেলো permutation-এর LIS-এর গড় দৈর্ঘ্য কত? Monte Carlo পরীক্ষায় মনে হলো প্রায় c√n. ১৯৭৭ সালে দুটি দল আলাদাভাবে — Anatoly Vershik ও Sergei Kerov, আর Benjamin Logan ও Larry Shepp — দেখান c = 2: গড় LIS ≈ 2√n. দশ লাখ উপাদানে প্রায় ২০০০. এর বিশ্লেষণে মূল যন্ত্র ছিল Craige Schensted-এর ১৯৬১ সালের একটি পদ্ধতি (পরে Robinson–Schensted–Knuth নামে পরিচিত), যা প্রতিটি permutation-কে একজোড়া Young tableau-তে বদলায়; tableau-র প্রথম সারির দৈর্ঘ্যই LIS — আর সেই সারি বানানোর নিয়ম হুবহু এই পর্বের tails-এর আপডেট।
সবচেয়ে বিস্ময়কর ধাপ ১৯৯৯ সালে: Jinho Baik, Percy Deift আর Kurt Johansson দেখান, 2√n-এর চারপাশে LIS-এর ওঠানামা মোটামুটি n1/6 মাপের, আর তার বণ্টন Tracy–Widom বণ্টন — যা আগে পাওয়া গিয়েছিল random matrix-এর সবচেয়ে বড় eigenvalue-এর বণ্টন হিসেবে, পদার্থবিজ্ঞানে। একটি তাস সাজানোর খেলা আর পারমাণবিক নিউক্লিয়াসের শক্তিস্তরের গণিত একই বণ্টনে মেলে। David Aldous আর Persi Diaconis একই বছর “from patience sorting to the Baik–Deift–Johansson theorem” শিরোনামে এই গল্পের একটি বিখ্যাত পর্যালোচনা লেখেন।
মূল উপলব্ধি: কনটেস্টের একটি “সাধারণ” DP-র ভেতরে গভীর গণিত লুকিয়ে থাকতে পারে। tails অ্যারে শুধু একটি কৌশল নয় — এটি সম্ভাবনাতত্ত্ব আর combinatorics-এর একটি কেন্দ্রীয় কাঠামো।
প্র ০৫একই knapsack-এর জন্য brute force, ওজনের DP, মানের DP, meet in the middle, branch and bound — সমস্যার সীমা (n, W, মান) দেখে কীভাবে ঠিক পদ্ধতি বাছবেন?
কনটেস্ট আর ইন্টারভিউতে সমস্যার সীমাগুলোই প্রায়ই পদ্ধতির ইঙ্গিত দেয়। মোটামুটি নিয়ম হলো, এক সেকেন্ডে C++ প্রায় 108 সরল কাজ করে (Python-এ তার কয়েক ভাগের এক ভাগ)। সেই বাজেটে প্রতিটি পদ্ধতির খরচ বসান।
n ≤ ২০: সব 2n ≈ ১০ লাখ উপসেট — brute force বা পর্ব ৫৬-এর backtracking, W যত বড়ই হোক। n ≤ ৪০, W ও মান বিশাল: meet in the middle, O(2n/2 · n). n·W ≤ প্রায় 108: অংশ ২–৩-এর ওজনের DP, এক সারিতে মেমরি Θ(W). W বিশাল কিন্তু মোট মান V ছোট: dp[v] = মান v পেতে সবচেয়ে কম ওজন, O(n·V). সব বিশাল আর নির্ভুল উত্তর চাই: branch and bound বা integer programming solver (পর্ব ৫৭) — খারাপ ইনপুটে exponential, কিন্তু বাস্তবে প্রায়ই দ্রুত। প্রায়-সেরা হলেই চলে: FPTAS বা greedy-ভিত্তিক approximation (পর্ব ৬৬).
একই চিন্তা অন্য সমস্যাতেও। LIS-এ n ≤ 5000 হলে O(n²) DP সহজ ও নিরাপদ, n ≈ 2·105 হলে O(n log n) লাগবেই। Coin change-এ অঙ্ক 106 আর কয়েন ১০০ ধরন — 108, সীমায়; অঙ্ক 1012 হলে DP অসম্ভব, তখন কয়েন-ব্যবস্থার কাঠামো (যেমন canonical কি না) বা গণিত খুঁজতে হয়। জায়গার হিসাবও ভুলবেন না: n = 1000, W = 106-এর দুই-মাত্রার int টেবিল প্রায় ৪ GB — অচল; এক সারিতে ৪ MB — সহজ। কিন্তু তখন reconstruction-এর জন্য প্রতি জিনিস প্রতি ক্ষমতায় একটি bit রাখতে হয় (প্রায় ১২৫ MB), বা Hirschberg-ধাঁচের divide and conquer কৌশল লাগে।
এই হিসাব শুধু কনটেস্টের খেলা নয়। বাস্তবে একটি ডেলিভারি কোম্পানি ট্রাকে মাল তোলার পরিকল্পনা করলে ওজন কেজিতে না গ্রামে মাপবে, সেই সিদ্ধান্তই ঠিক করে DP চলবে কি না — W হাজার গুণ বাড়লে সময়ও হাজার গুণ।
মূল উপলব্ধি: একটি সমস্যার একটিমাত্র “সঠিক” অ্যালগরিদম নেই; আছে সীমার সাথে মানানসই অ্যালগরিদম। কোড লেখার আগে প্রতিটি পদ্ধতির খরচে সীমাগুলো বসিয়ে দেখুন — এই এক মিনিটের হিসাবই সবচেয়ে বেশি সময় বাঁচায়।
অনুশীলন
খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন: তিনটি জিনিস (ওজন, মান): (2, 3), (3, 4), (4, 5); ক্ষমতা W = 5. dp[i][w]-এর পুরো টেবিল (i = 0…3, w = 0…5) ভরুন, সর্বোচ্চ মান বলুন, আর পেছনে হেঁটে কোন জিনিস নেওয়া হলো তা বের করুন।
সারি 0: 0 0 0 0 0 0. সারি 1 (2, 3): 0 0 3 3 3 3. সারি 2 (3, 4): w = 3-এ max(3, dp[1][0] + 4) = 4, w = 4-এ max(3, dp[1][1] + 4) = 4, w = 5-এ max(3, dp[1][2] + 4) = 7 → 0 0 3 4 4 7. সারি 3 (4, 5): w = 4-এ max(4, dp[2][0] + 5) = 5, w = 5-এ max(7, dp[2][1] + 5) = 7 → 0 0 3 4 5 7. উত্তর 7.
ফেরা: dp[3][5] = 7 = dp[2][5] → জিনিস 3 নয়। dp[2][5] = 7 ≠ dp[1][5] = 3 → জিনিস 2, w = 2. dp[1][2] = 3 ≠ dp[0][2] = 0 → জিনিস 1, w = 0. উত্তর জিনিস 1 আর 2: ওজন 5, মান 7.
-
tails trace করুন: ক্রম 2 5 3 7 11 8 10 13 6-এর জন্য প্রতিটি উপাদানের পর tails লিখুন (কঠোরভাবে বাড়তি LIS, lower_bound). LIS-এর দৈর্ঘ্য কত? শেষের tails কি একটি বৈধ LIS? একটি আসল LIS লিখুন।
[2] → [2, 5] → [2, 3] → [2, 3, 7] → [2, 3, 7, 11] → [2, 3, 7, 8] → [2, 3, 7, 8, 10] → [2, 3, 7, 8, 10, 13] → 6 এলে প্রথম ≥ 6 হলো 7: [2, 3, 6, 8, 10, 13]. দৈর্ঘ্য 6.
শেষের tails বৈধ নয়: 6 এসেছে সবার শেষে, তাই 6-এর পরে 8, 10, 13 আসতে পারে না। একটি আসল LIS: 2 3 7 8 10 13 (parent ধরে 13 → 10 → 8 → 7 → 3 → 2).
-
Coin change হাতে: কয়েন {1, 5, 6, 9}, অঙ্ক 11. (ক) greedy কয়টি কয়েন দেয়, আর সবচেয়ে কম কয়টি লাগে? best[0..11] লিখুন। (খ) ক্রম ছাড়া কত উপায়ে 11 বানানো যায়? কয়েন-বাইরের লুপ দিয়ে ways অ্যারে ভরুন, আর উপায়গুলো তালিকা করে মিলিয়ে নিন।
(ক) Greedy: 9 + 1 + 1 = তিনটি। DP: best = 0 1 2 3 4 1 1 2 3 1 2 2 (a = 0…11). best[11] = 2 (5 + 6).
(খ) শুরু ways[0] = 1. কয়েন 1-এর পর সব 1. কয়েন 5-এর পর: 1 1 1 1 1 2 2 2 2 2 3 3. কয়েন 6-এর পর: 1 1 1 1 1 2 3 3 3 3 4 5. কয়েন 9-এর পর: ways[9] = 4, ways[10] = 5, ways[11] = 5 + ways[2] = 6. উপায় ৬টি: এগারোটি 1; 5 আর ছয়টি 1; 5 + 5 + 1; 6 আর পাঁচটি 1; 6 + 5; 9 + 1 + 1.
-
কোড লিখুন: একটি ফাংশন
canPartition(a)লিখুন, যা বলে ধনাত্মক পূর্ণসংখ্যার অ্যারে a-কে সমান যোগফলের দুই দলে ভাগ করা যায় কি না। প্রথমে boolean DP দিয়ে, তারপর C++-এরstd::bitsetদিয়ে। দুটির time ও space complexity বলুন।মোট T বিজোড় হলে না। নইলে লক্ষ্য S = T/2-এর subset sum, প্রতিটি সংখ্যা একবার — তাই উল্টো লুপ:
bool canPartition(const vector<int>& a) { int total = accumulate(a.begin(), a.end(), 0); if (total % 2) return false; int S = total / 2; vector<char> can(S + 1, 0); can[0] = 1; for (int x : a) for (int s = S; s >= x; --s) // reverse: each number used once if (can[s - x]) can[s] = 1; return can[S]; } // bitset version (sum of all numbers at most 200000): bool canPartitionBits(const vector<int>& a) { int total = accumulate(a.begin(), a.end(), 0); bitset<200001> bits; bits[0] = 1; for (int x : a) bits |= bits << x; // every reachable sum s also gives s + x return total % 2 == 0 && bits[total / 2]; }প্রথমটি O(n·S) সময়, O(S) জায়গা; দ্বিতীয়টি O(n·T / 64) সময়, O(T) bit জায়গা — shift আর OR একসাথে ৬৪টি bit-এ কাজ করে। bitset-এ shift নতুন একটি কপি থেকে হয়, তাই “একই সংখ্যা দুবার” সমস্যা আপনিই এড়ানো যায়। যাচাই: {2, 3, 7, 8} → true, {1, 2, 5} → false (মোট 8, কিন্তু 4 বানানো যায় না)।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি নতুন DP সমস্যা (যেমন 0/1 knapsack) সমাধানের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- State ঠিক করা — কোন কয়েকটি সংখ্যা একটি উপসমস্যাকে চেনায়, আর টেবিলের ঘরে কী থাকবে
- Transition লেখা — একটি ঘরের উত্তর ছোট ঘরগুলো থেকে কীভাবে আসে
- Base case বসানো — সবচেয়ে ছোট উপসমস্যার উত্তর
- এমন ক্রমে টেবিল ভরা, যাতে প্রতিটি ঘরের দরকারি ঘর আগেই ভরা থাকে
- উত্তরের ঘর পড়া, আর দরকার হলে পেছনে হেঁটে সিদ্ধান্তগুলো ফেরত আনা
0/1 knapsack-এর এক সারির DP-তে (one[w] = max(one[w], one[w − wᵢ] + vᵢ)) ক্ষমতা w-এর লুপ W থেকে wᵢ পর্যন্ত উল্টো দিকে চালানো হয় কেন?
- উল্টো লুপ দ্রুততর, তাই সময় Θ(nW) থেকে কমে
- যাতে one[w − wᵢ] তখনো পুরোনো সারির মান থাকে আর প্রতিটি জিনিস একবারই নেওয়া হয়
- সামনে থেকে চালালে অ্যারের বাইরে পড়া হয়
- সামনে থেকে চালালে উত্তর সব সময় কম আসে
0/1 knapsack-এর DP সময় O(nW), তবু এটিকে polynomial-time অ্যালগরিদম বলা হয় না। মূল কারণ কী?
- n টি জিনিসে সময় exponential হারে বাড়ে
- টেবিল অনেক মেমরি নেয়
- W সংখ্যাটির মান তার bit-সংখ্যায় exponential হতে পারে
- max নেওয়ার কাজটি নিজেই ধীর
কয়েন শুধু 1, 3 আর 4 টাকার, প্রতিটি যতবার খুশি। 6 টাকা বানাতে সবচেয়ে কম কয়টি কয়েন লাগে?
- 2
- 3
- 1
- 4
ways[0] = 1 ধরে ways[a] += ways[a − c] দিয়ে কয়েন-বদলের উপায় গোনা হচ্ছে। কোন লুপ-ব্যবস্থায় 1 + 2 আর 2 + 1 একই উপায় হিসেবে একবারই গোনা হয়?
- বাইরে অঙ্ক a, ভেতরে কয়েন c
- বাইরে কয়েন c, ভেতরে অঙ্ক a বড় থেকে ছোট দিকে
- লুপের ক্রমে কিছু আসে যায় না
- বাইরে কয়েন c, ভেতরে অঙ্ক a ছোট থেকে বড় দিকে
কঠোরভাবে বাড়তি LIS-এর O(n log n) পদ্ধতিতে (প্রতিটি x-এর জন্য tails-এ প্রথম ≥ x ঘরে বসানো, না থাকলে শেষে জোড়া) ক্রম 2 5 3 7 1 শেষ হলে tails কী হবে?
- 2 3 7
- 1 3 7
- 1 2 3 7
- 2 5 7
n = 40 টি জিনিসের knapsack, যেখানে ক্ষমতা আর মান 1018 পর্যন্ত। meet in the middle পদ্ধতির সময় মোটামুটি কত?
- O(nW)
- O(2n)
- O(2n/2 · n)
- O(n²)
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- 0/1 knapsack: dp[i][w] = max(dp[i−1][w], dp[i−1][w − wᵢ] + vᵢ), Θ(nW); dp[i][w] ≠ dp[i−1][w] হলে জিনিস i নেওয়া — শেষ ঘর থেকে পেছনে হেঁটে তালিকা।
- এক সারির DP: উল্টো লুপ = প্রতিটি জিনিস একবার, সামনের লুপ = যতবার খুশি (unbounded). Subset sum আর partition একই ছাঁচে, bitset-এ ৬৪ গুণ দ্রুত; bounded-এ binary splitting.
- O(nW) pseudo-polynomial — W-এর মান তার bit-সংখ্যায় exponential; বিশাল W-এ মানের ওপর DP বা meet in the middle, O(2n/2 · n).
- Coin change: কম কয়েন = best[a] = 1 + min best[a − c]; greedy শুধু canonical ব্যবস্থায় ঠিক। উপায় গুনতে কয়েন বাইরে = ক্রম ছাড়া, অঙ্ক বাইরে = ক্রমসহ।
- LIS: O(n²)-এ dp[i] = 1 + max dp[j]; O(n log n)-এ সাজানো tails আর lower_bound (non-decreasing হলে upper_bound). tails উত্তর নয় — parent ধরে ধারা ফেরত আনুন।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৫৪ · DP-র ক্লাসিক সমস্যা ২ — LCS, edit distance আর matrix chainপরবর্তী পর্বদুই ক্রমের DP আর interval DP.
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- পর্ব ৫২ · Dynamic programming-এর ভিত্তি — memoization আর tabulationআগের ভিত্তিstate, transition, memoization আর tabulation-এর মূল কথা।
- পর্ব ৫০ · Greedy অ্যালগরিদম আর exchange argumentআগের ভিত্তিfractional knapsack কেন greedy-তে মেলে, আর greedy কখন ভাঙে।
- পর্ব ১৮ · Linear আর binary searchআগের ভিত্তিlower_bound আর upper_bound — LIS-এর O(n log n)-এর যন্ত্র।
- C++ প্রোগ্রামিং · STL অ্যালগরিদমপাশের ট্র্যাকlower_bound, is_sorted আর অন্যান্য তৈরি অ্যালগরিদম।
- Python প্রোগ্রামিং · Python-এ অ্যালগরিদমপাশের ট্র্যাকbisect, list আর Python-এ দ্রুত কোডের অভ্যাস।