সরাসরি পর্বে যান
পর্ব ৫২ · ৭২-এর মধ্যে · মডিউল ৭

Dynamic programming-এর ভিত্তি — memoization আর tabulation

Foundations of Dynamic Programming — Overlapping Subproblems, Optimal Substructure, Memoization vs Tabulation, the DP Recipe, Rolling Arrays, Reconstruction & DP as Shortest Paths on a DAG

Taught in Bangla, this lesson builds dynamic programming from the exploding Fibonacci recursion. It explains overlapping subproblems and optimal substructure, top-down memoization and bottom-up tabulation with rolling arrays, a six-step DP recipe with a complexity formula, grid path counting and minimum-cost paths, reconstructing answers, DP as shortest paths on a DAG of states, and how DP differs from divide and conquer and greedy, with C++ and Python code.

এই পর্বে যা শিখবেন

  • সরল Fibonacci রিকার্শনের কল গুনে প্রমাণ করা কেন সেটি সূচকীয়, আর memo দিয়ে কীভাবে রৈখিক হয়
  • overlapping subproblems আর optimal substructure চেনা, আর cut-and-paste যুক্তিতে প্রমাণ করা
  • একই সমস্যা memoization আর tabulation — দুইভাবে লেখা, আর rolling array দিয়ে জায়গা বাঁচানো
  • ছয় ধাপের রেসিপিতে সিঁড়ি, ঢাকার রাস্তার গ্রিডে পথ গোনা ও সবচেয়ে কম জ্যামের পথ, house robber আর Kadane সমাধান
  • DP-কে states-এর DAG-এ পথ হিসেবে দেখা, DP কখন ব্যর্থ, আর DP, divide and conquer ও greedy-র পার্থক্য

১ · একই হিসাব বারবার — Fibonacci-র বিস্ফোরণ

পর্ব ০৭-এ Fibonacci সংখ্যার রিকার্সিভ সংজ্ঞা দেখেছেন: fib(0) = 0, fib(1) = 1, আর fib(n) = fib(n − 1) + fib(n − 2). কোডে এটি তিন লাইনের, আর দেখতে নির্ভুল। কিন্তু fib(5) ডাকলে কী কী কল হয়, তার পুরো recursion tree লিখে দেখুন:

fib(5)
├─ fib(4)
│  ├─ fib(3)
│  │  ├─ fib(2)
│  │  │  ├─ fib(1)
│  │  │  └─ fib(0)
│  │  └─ fib(1)
│  └─ fib(2)          <- again
│     ├─ fib(1)
│     └─ fib(0)
└─ fib(3)             <- again: the whole branch
   ├─ fib(2)          <- again
   │  ├─ fib(1)
   │  └─ fib(0)
   └─ fib(1)

১৫টি কল, কিন্তু আলাদা প্রশ্ন মাত্র ৬টি — fib(0) থেকে fib(5). fib(3) দুবার, fib(2) তিনবার, fib(1) পাঁচবার হিসাব হয়েছে। n বাড়লে অবস্থা ভয়াবহ হয়।

কল গোনার প্রমাণ. C(n) = fib(n)-এর মোট কল। C(0) = C(1) = 1, আর n ≥ 2 হলে C(n) = 1 + C(n − 1) + C(n − 2). দাবি: C(n) = 2F(n+1) − 1, যেখানে F হলো Fibonacci সংখ্যা। Induction: C(0) = 2·1 − 1 = 1, C(1) = 2·1 − 1 = 1. ধরে নিন n − 1 আর n − 2-এর জন্য সত্য; তাহলে C(n) = 1 + (2F(n) − 1) + (2F(n−1) − 1) = 2(F(n) + F(n−1)) − 1 = 2F(n+1) − 1. ∎ যেহেতু F(n) ≈ φn/√5, যেখানে φ ≈ 1.618 হলো golden ratio, সময় Θ(φn) — সূচকীয়। জায়গা অবশ্য শুধু call stack-এর গভীরতা, Θ(n).

nfib(n)সরল রিকার্শনে কল = 2F(n+1) − 1মনে রাখলে কল = 2n − 1
১০৫৫১৭৭১৯
২০৬,৭৬৫২১,৮৯১৩৯
৩০৮,৩২,০৪০২৬,৯২,৫৩৭৫৯
৪০১০,২৩,৩৪,১৫৫৩৩,১১,৬০,২৮১৭৯
৫০প্রায় ১.২৬ × ১০১০প্রায় ৪.০৭ × ১০১০৯৯

n = 50-এ চার হাজার কোটির বেশি কল — দ্রুত C++-এও সেটি অনেক সেকেন্ডের কাজ, আর Python-এ ঘণ্টার হিসাবে। অথচ ডানের কলামে মাত্র ৯৯টি কল। পার্থক্য একটাই: একবার হিসাব করা উত্তর মনে রাখা।

উপমা — মুদি দোকানের বাকির খাতা। পাড়ার দোকানদারকে কেউ জিজ্ঞেস করল, “রহিম সাহেবের মোট বাকি কত?” প্রথমবার তিনি পুরো মাসের রসিদ যোগ করেন — সময় লাগে। কিন্তু উত্তরটি খাতায় লিখে রাখেন। দশ মিনিট পর একই প্রশ্ন এলে আর যোগ করেন না, খাতা দেখে বলে দেন। আর নতুন একটি কেনাকাটা হলে পুরোনো মোটের সাথে শুধু নতুনটি যোগ করেন। Dynamic programming মানে ঠিক এই খাতা — প্রতিটি ছোট প্রশ্নের উত্তর একবার হিসাব, তারপর শুধু দেখে নেওয়া।

২ · DP-র দুই শর্ত — overlapping subproblems আর optimal substructure

Dynamic programming (সংক্ষেপে DP) একটি নকশার কৌশল: বড় সমস্যাকে ছোট উপসমস্যায় ভাঙা, প্রতিটি উপসমস্যার উত্তর একবারই হিসাব করে রাখা, আর বড় উত্তর সেগুলো জুড়ে বানানো। কৌশলটি কাজ করে দুটি শর্ত পূরণ হলে।

শর্ত ১ — overlapping subproblems: রিকার্শন চলার সময় একই উপসমস্যা বারবার আসে, আর মোট আলাদা উপসমস্যার সংখ্যা ছোট (সাধারণত polynomial). fib-এ আলাদা প্রশ্ন n + 1 টি, অথচ কল সূচকীয়। তুলনা করুন merge sort-এর সাথে (পর্ব ২১): সেখানে প্রতিটি অর্ধেক আলাদা টুকরো, কোনো টুকরো দুবার সাজানো হয় না। তাই merge sort-এ খাতা রেখে কোনো লাভ নেই — ওটি খাঁটি divide and conquer.

শর্ত ২ — optimal substructure: পুরো সমস্যার সেরা উত্তরের ভেতরের অংশগুলোও নিজ নিজ উপসমস্যার সেরা উত্তর। উদাহরণ: মিরপুর থেকে মতিঝিলের সবচেয়ে ছোট পথ যদি ফার্মগেট হয়ে যায়, তাহলে সেই পথের “মিরপুর → ফার্মগেট” অংশটিও মিরপুর থেকে ফার্মগেটের সবচেয়ে ছোট পথ।

প্রমাণ — cut-and-paste যুক্তি. ধরুন P একটি সংক্ষিপ্ততম পথ s ⇝ t, যা v দিয়ে যায়: P = P1 (s ⇝ v) + P2 (v ⇝ t). যদি P1 s থেকে v-এর সংক্ষিপ্ততম না হতো, তাহলে আরও ছোট একটি Q থাকত। P1 কেটে Q বসালে (cut and paste) Q + P2 হতো P-এর চেয়ে ছোট একটি s ⇝ t walk — কিন্তু P সবচেয়ে ছোট, বিরোধ। (ঋণাত্মক cycle না থাকলে walk থেকে লুপ কেটে simple path পাওয়া যায়, খরচ বাড়ে না।) ∎ ঠিক এই যুক্তিই Bellman-Ford আর Floyd-Warshall-এর (পর্ব ৪৪) ভিত্তি ছিল।

মূল ধারণা

Optimal substructure বলে “ছোট সেরা উত্তর জুড়ে বড় সেরা উত্তর বানানো যায়”; overlapping subproblems বলে “খাতা রাখলে লাভ আছে”। প্রথমটি না থাকলে DP ভুল উত্তর দেবে; দ্বিতীয়টি না থাকলে DP ঠিক উত্তর দেবে, কিন্তু সাধারণ রিকার্শনের চেয়ে দ্রুত হবে না।

Richard Bellman ১৯৫৭ সালে দ্বিতীয় শর্তটিকে (optimal substructure) বলেছিলেন principle of optimality: একটি সেরা নীতির বৈশিষ্ট্য হলো, শুরুর অবস্থা আর প্রথম সিদ্ধান্ত যা-ই হোক, বাকি সিদ্ধান্তগুলো প্রথম সিদ্ধান্তের ফলে তৈরি অবস্থার জন্য নিজেরাই একটি সেরা নীতি।

৩ · Memoization — ওপর থেকে নিচে, রিকার্শন আর খাতা

সবচেয়ে সহজ উপায়: সরল রিকার্সিভ ফাংশনটি অপরিবর্তিত রাখুন, শুধু একটি খাতা যোগ করুন। হিসাব শুরুর আগে দেখুন উত্তর খাতায় আছে কি না; থাকলে সেটিই ফেরত দিন, না থাকলে হিসাব করে লিখে রাখুন। এর নাম memoization — শব্দটি ১৯৬৮ সালে Donald Michie-র দেওয়া, “memo” (স্মারকলিপি) থেকে। এটি top-down DP: বড় প্রশ্ন থেকে শুরু, প্রয়োজনমতো ছোট প্রশ্নে নামা।

Pseudocode — memoized Fibonacci (0-based)

memo = empty table                      // "not computed yet" for every n
fib(n):
    if n < 2:  return n                 // base cases
    if memo[n] is filled:  return memo[n]   // seen before: just read the notebook
    memo[n] = fib(n - 1) + fib(n - 2)   // first time: compute once, write down
    return memo[n]
কলের গাছ◀ = এইমাত্র ঘটল

                
খাতা আর গণনা
memo[2..5]
মোট কল
খাতা থেকে পড়া
  1. শুরু. খাতা খালি। fib(5) দেখল memo[5] নেই, তাই সে fib(4) ডাকবে। কল ১টি।
  2. বাঁ দিকে নামা. fib(4) → fib(3) → fib(2): কারও উত্তর খাতায় নেই, তাই প্রত্যেকে আরও নিচে নামে। কল ৪টি।
  3. প্রথম লেখা. fib(2) ডাকল fib(1) = 1 আর fib(0) = 0 (base case, খাতা লাগে না)। memo[2] = 1 লেখা হলো। কল ৬টি।
  4. fib(3) শেষ. fib(3)-এর ডান দিকের fib(1) একটি base case. memo[3] = 1 + 1 = 2. কল ৭টি।
  5. প্রথম লাভ. fib(4) এবার fib(2) চায় — খাতায় আছে! সরল রিকার্শনে এখানে পুরো একটি ডাল আবার হিসাব হতো; এখন এক ধাপে 1. memo[4] = 2 + 1 = 3. কল ৮টি।
  6. উত্তর. fib(5)-এর ডানের fib(3)-ও খাতা থেকে: memo[5] = 3 + 2 = 5. মোট কল ৯টি = 2·5 − 1, যেখানে সরল রিকার্শনে ১৫টি লাগত। n বাড়লে ব্যবধান সূচকীয়।

জটিলতা: প্রতিটি n ≥ 2 একবারই পুরো হিসাব হয় (প্রথমবার), আর তখন ঠিক দুটি কল করে; পরের যেকোনো ডাক O(1)-এ খাতা থেকে ফেরে। মোট কল 2n − 1, প্রতিটি O(1) কাজ — Θ(n) সময়. খাতা Θ(n), call stack-এর গভীরতাও Θ(n) — মোট Θ(n) জায়গা. খাতা হিসেবে array (n ছোট আর পূর্ণসংখ্যা হলে) বা hash table (অবস্থা জটিল হলে, পর্ব ৩২) — দুটোই চলে।

Memoization-এর দুই ফাঁদ। (১) ফাংশনটি অবশ্যই বিশুদ্ধ হতে হবে — একই ইনপুটে সবসময় একই ফল, কোনো global অবস্থার ওপর নির্ভর নয়; নইলে খাতার পুরোনো উত্তর ভুল হয়ে যায়। (২) গভীরতা n পর্যন্ত যায়: Python-এ fib(5000) memo দিয়েও RecursionError দেয়, কারণ recursion-এর সীমা সাধারণত ১০০০. তখন পরের অংশের লুপ-ভিত্তিক পদ্ধতিই নিরাপদ।

৪ · Tabulation — নিচ থেকে ওপরে, আর rolling array

উল্টো দিক থেকেও ভাবা যায়: বড় প্রশ্নের অপেক্ষা না করে সবচেয়ে ছোট উপসমস্যা থেকে শুরু করে একটি টেবিল ক্রমানুসারে ভরে ফেলুন, যাতে প্রতিটি ঘর ভরার সময় তার দরকারি ঘরগুলো আগেই ভরা থাকে। এর নাম tabulation, বা bottom-up DP. কোনো রিকার্শন নেই — শুধু একটি লুপ।

একটি পরিচিত সমস্যায় দেখুন। সিঁড়ি ওঠা (climbing stairs): ফুটওভার ব্রিজের সিঁড়িতে n টি ধাপ। আপনি একবারে ১ ধাপ বা ২ ধাপ উঠতে পারেন। মোট কতভাবে ওপরে পৌঁছানো যায়? শেষ চালটি ভাবুন: n-এ পৌঁছানোর ঠিক আগে আপনি ছিলেন n − 1-এ (তারপর ১ ধাপ) অথবা n − 2-এ (তারপর ২ ধাপ) — দুই দল আলাদা, কেউ দুই দলে পড়ে না। তাই ways[n] = ways[n − 1] + ways[n − 2], base case ways[0] = 1 (কিছু না করাও একটি উপায়) আর ways[1] = 1. এটি আসলে Fibonacci: ways[n] = F(n + 1).

টেবিল ways[ ]· = এখনো ভরা হয়নি

                
এই ধাপ
হিসাব
(prev2, prev1)
  1. Base case. ways[0] = 1 আর ways[1] = 1 আগে বসানো হলো — recurrence এদের হিসাব করতে পারে না, কারণ তাদের “আগের দুটি” নেই।
  2. i = 2. ২ নম্বর ধাপে আসা যায় ১ থেকে (১ উপায়) বা ০ থেকে (১ উপায়): মোট ২ — “১+১” আর “২”.
  3. i = 3. ways[2] + ways[1] = ৩: “১+১+১”, “২+১”, “১+২”. দুটি ঘরই আগে ভরা, তাই কোনো অপেক্ষা নেই।
  4. i = 4. ৩ + ২ = ৫. লক্ষ করুন, লুপের ক্রম (বাঁ থেকে ডানে) এমন যে প্রতিটি ঘরের নির্ভর ঘর তার বাঁয়ে।
  5. i = 5. ৫ + ৩ = ৮.
  6. i = 6. ৮ + ৫ = ১৩ — উত্তর টেবিলের শেষ ঘরে। ৭টি ঘর, প্রতিটি O(1): Θ(n) সময়, Θ(n) জায়গা।
  7. Rolling. ঘর i ভরতে শুধু i − 1 আর i − 2 লাগে; তার আগের ঘরগুলো আর কখনো পড়া হয় না। তাই পুরো টেবিলের বদলে দুটি চলক রাখলেই চলে — জায়গা Θ(1).

শেষ ধাপের কৌশলটির নাম rolling array: যখন recurrence শুধু শেষ k টি সারি বা ঘরের ওপর নির্ভর করে, তখন শুধু সেগুলোই রাখুন। গ্রিডের DP-তে (অংশ ৬) পুরো R × C টেবিলের বদলে একটি সারি, অর্থাৎ Θ(C) জায়গা। তবে একটি দাম আছে: পুরোনো ঘর মুছে গেলে কোন পথে উত্তর এসেছিল তা আর বের করা যায় না — শুধু সংখ্যাটি থাকে। সেজন্য অংশ ৬-এ আলাদা একটি পছন্দের টেবিল রাখব।

নিচের প্রোগ্রাম তিনটি পদ্ধতি পাশাপাশি চালায়, আর সরল ও memo পদ্ধতির কল গোনে:

#include <cstdio>
#include <initializer_list>
#include <vector>
using namespace std;
long long calls = 0;
long long fib_naive(int n) {                         // plain recursion: no memory at all
    ++calls;
    return n < 2 ? n : fib_naive(n - 1) + fib_naive(n - 2);
}
long long fib_memo(int n, vector<long long>& memo) {   // top-down: same recursion + a notebook
    ++calls;
    if (n < 2) return n;
    if (memo[n] == -1) memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo);
    return memo[n];
}
long long fib_table(int n) {                         // bottom-up: fill dp[0..n] left to right
    vector<long long> dp(n + 1, 0);
    if (n >= 1) dp[1] = 1;
    for (int i = 2; i <= n; ++i) dp[i] = dp[i - 1] + dp[i - 2];
    return dp[n];
}
long long fib_rolling(int n) {                       // only the last two values are ever needed
    long long a = 0, b = 1;
    for (int i = 0; i < n; ++i) { long long c = a + b; a = b; b = c; }
    return a;
}
int main() {
    for (int n : {10, 20, 25}) {
        calls = 0;
        long long x = fib_naive(n), naive_calls = calls;
        vector<long long> memo(n + 1, -1);
        calls = 0;
        long long y = fib_memo(n, memo);
        printf("n=%2d naive=%lld calls=%lld | memo=%lld calls=%lld | table=%lld | rolling=%lld\n",
               n, x, naive_calls, y, calls, fib_table(n), fib_rolling(n));
    }
    printf("fib(90) = %lld = %lld\n", fib_table(90), fib_rolling(90));
}

আউটপুট:

n=10 naive=55 calls=177 | memo=55 calls=19 | table=55 | rolling=55
n=20 naive=6765 calls=21891 | memo=6765 calls=39 | table=6765 | rolling=6765
n=25 naive=75025 calls=242785 | memo=75025 calls=49 | table=75025 | rolling=75025
fib(90) = 2880067194370816120 = 2880067194370816120

চারটি পদ্ধতিই একই উত্তর দেয়, কিন্তু খরচ আলাদা। সরল পদ্ধতির কল ঠিক 2F(n+1) − 1: n = 25-এ F(26) = 121393, তাই 2 × 121393 − 1 = 242785. memo-র কল 2n − 1: ১৯, ৩৯, ৪৯. fib(90)-এ সরল রিকার্শন কয়েক শতাব্দী চলত; টেবিল আর rolling সংস্করণ মুহূর্তে শেষ। long long-এ সবচেয়ে বড় যে Fibonacci ধরে, তা F(92); তার পরে overflow. সময়: সরল Θ(φn), বাকি তিনটি Θ(n). জায়গা: memo আর table Θ(n), rolling Θ(1).

calls = 0

def fib_naive(n):                      # plain recursion: no memory at all
    global calls
    calls += 1
    return n if n < 2 else fib_naive(n - 1) + fib_naive(n - 2)

def fib_memo(n, memo):                 # top-down: same recursion + a notebook
    global calls
    calls += 1
    if n < 2:
        return n
    if n not in memo:
        memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
    return memo[n]

def fib_table(n):                      # bottom-up: fill dp[0..n] left to right
    dp = [0] * (n + 1)
    if n >= 1:
        dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

def fib_rolling(n):                    # only the last two values are ever needed
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

for n in (10, 20, 25):
    calls = 0
    x = fib_naive(n)
    naive_calls = calls
    calls = 0
    y = fib_memo(n, {})
    print(f"n={n:2} naive={x} calls={naive_calls} | memo={y} calls={calls}"
          f" | table={fib_table(n)} | rolling={fib_rolling(n)}")
print("fib(90) =", fib_table(90), "=", fib_rolling(90))

আউটপুট C++-এর সাথে হুবহু এক:

n=10 naive=55 calls=177 | memo=55 calls=19 | table=55 | rolling=55
n=20 naive=6765 calls=21891 | memo=6765 calls=39 | table=6765 | rolling=6765
n=25 naive=75025 calls=242785 | memo=75025 calls=49 | table=75025 | rolling=75025
fib(90) = 2880067194370816120 = 2880067194370816120

এখানে খাতা একটি dict. বাস্তবে Python-এ নিজে খাতা লিখতে হয় না: ফাংশনের ওপরে @functools.cache (Python ৩.৯ থেকে) বা @functools.lru_cache(maxsize=None) লিখলেই memoization, ভেতরে একটি dict. a, b = b, a + b হলো rolling array-র এক লাইনের রূপ। Python-এর int-এর সীমা নেই, তাই fib(100)-ও ঠিক আসে — তবে খুব বড় n-এ সংখ্যাগুলো নিজেই Θ(n) bit লম্বা, তাই প্রতিটি যোগ আর O(1) থাকে না।

দিকMemoization (top-down)Tabulation (bottom-up)
লেখাrecurrence হুবহু রিকার্শনে, সাথে খাতালুপ; হিসাবের ক্রম নিজে ঠিক করতে হয়
কোন অবস্থা হিসাব হয়শুধু যেগুলো সত্যিই দরকারটেবিলের সব ঘর
বাড়তি খরচফাংশন কল, stack-এর গভীরতাপ্রায় নেই; cache-বান্ধব
জায়গা বাঁচানোকঠিনrolling array দিয়ে সহজ
কখন ভালোঅবস্থা ছড়ানো বা অল্প অংশ লাগে; প্রথম খসড়াবড় n, recursion-এর সীমা, জায়গার টানাটানি

৫ · DP-র রেসিপি — ছয়টি ধাপ, আর জটিলতার এক সূত্র

নতুন DP সমস্যায় সবচেয়ে কঠিন কাজ কোড লেখা নয় — সঠিক উপসমস্যা খুঁজে পাওয়া। প্রায় সব DP এই ছয়টি ধাপে লেখা যায়:

  1. অবস্থা কথায় লিখুন. “dp[i] = …” একটি পূর্ণ বাক্যে, যেমন “প্রথম i দিনে সর্বোচ্চ কত আয় সম্ভব”. বাক্যটি স্পষ্ট না হলে recurrence-ও ভুল হবে। এই সংজ্ঞাই DP-র অবস্থা.
  2. Recurrence. শেষ সিদ্ধান্তটি নিয়ে ভাবুন (“শেষ দিনে কাজ করব কি না”, “শেষ চাল ১ ধাপ না ২ ধাপ”), আর প্রতিটি সম্ভাব্য পছন্দকে ছোট অবস্থায় নামান। এক অবস্থা থেকে অন্যটিতে যাওয়াই transition.
  3. Base case. যে অবস্থাগুলো recurrence ছাড়াই জানা — খালি ইনপুট, প্রথম ঘর।
  4. হিসাবের ক্রম. প্রতিটি অবস্থার আগে তার নির্ভর অবস্থাগুলো — সাধারণত i বাড়িয়ে, বা সারি ধরে। Memoization-এ রিকার্শন নিজেই ক্রম ঠিক করে।
  5. উত্তর কোথায়. dp[n]? dp-র সর্বোচ্চ মান? শেষ সারির কোনো ঘর? অনেক ভুল শুধু এখানেই।
  6. সমাধান পুনর্গঠন. শুধু সংখ্যা নয়, আসল পছন্দগুলো চাইলে — কোন দিনগুলো, কোন পথ — প্রতিটি অবস্থায় কোন পছন্দ জিতেছিল তা রাখুন, আর উত্তর থেকে পেছনে হাঁটুন।
DP-র সময় = অবস্থার সংখ্যা × প্রতিটি অবস্থার transition খরচ

fib-এ অবস্থা n + 1 টি, প্রতিটিতে ২টি পুরোনো মান পড়া — Θ(n). R × C গ্রিডে অবস্থা RC টি, প্রতিটিতে ২টি পছন্দ — Θ(RC). পর্ব ৫৩-এর knapsack-এ অবস্থা n × W, প্রতিটিতে ২টি পছন্দ — Θ(nW). আর জায়গা = যত অবস্থা একসাথে মনে রাখতে হয় (rolling করলে কম)। কোনো DP-র সময় বলতে বললে আগে এই দুটি সংখ্যা আলাদা করে লিখুন।

রেসিপিতে একটি সমস্যা — ওভারটাইমের সেরা দিন (house robber)

একটি গার্মেন্টস কারখানায় ঈদের আগে ৫ দিন ওভারটাইমের সুযোগ, প্রতিদিনের মজুরি a = [5, 3, 4, 11, 2] (শত টাকায়)। নিয়ম: পরপর দুই দিন ওভারটাইম করা যাবে না। সর্বোচ্চ কত আয়? ইন্টারভিউতে এটিই “house robber” নামে পরিচিত — পাশাপাশি দুটি বাড়ি একসাথে নেওয়া যায় না।

অবস্থা: best[i] = প্রথম i দিনের মধ্যে, নিয়ম মেনে, সর্বোচ্চ আয়। Recurrence: i-তম দিনে কাজ না করলে best[i − 1]; করলে (i − 1)-তম দিন বাদ, তাই best[i − 2] + a[i]. সুতরাং best[i] = max(best[i − 1], best[i − 2] + a[i]). Base: best[0] = 0, best[1] = a[1]. ক্রম: i = 2, 3, …, n. উত্তর: best[n]. Optimal substructure কেন: একটি সেরা পরিকল্পনা যদি শেষ দিন বাদ দেয়, তার বাকিটা প্রথম n − 1 দিনের একটি বৈধ পরিকল্পনা — সেটি সেরা না হলে বদলে দিয়ে মোট বাড়ানো যেত (cut-and-paste); শেষ দিন নিলে একই যুক্তি প্রথম n − 2 দিনে।

টেবিলT = এই দিন নিলাম, S = বাদ

                
এই ধাপের তুলনা
বাদ: best[i−1]
নিই: best[i−2] + a[i]
উত্তর এ পর্যন্ত
  1. Base. best[0] = 0 (কোনো দিন নেই), best[1] = 5 (একমাত্র দিনটি নিলেই সেরা)।
  2. i = 2. দিন ২ বাদ দিলে ৫; নিলে দিন ১ যায়, 0 + 3 = ৩. বাদ দেওয়াই ভালো: best[2] = ৫, choice S.
  3. i = 3. বাদ: ৫; নিই: best[1] + 4 = ৯. নেওয়া ভালো — দিন ১ আর ৩ একসাথে চলে।
  4. i = 4. বাদ: ৯; নিই: best[2] + 11 = ১৬. দিন ৪-এর বড় মজুরির জন্য দিন ৩ ছাড়তে হলো — best[2] = ৫ মানে দিন ১-ও রাখা যায়।
  5. i = 5. বাদ: ১৬; নিই: best[3] + 2 = ১১. বাদ। উত্তর best[5] = ১৬. সময় Θ(n), আর শুধু সংখ্যাটি চাইলে rolling করে Θ(1) জায়গা।
  6. পুনর্গঠন. choice ধরে পেছনে: S হলে এক ঘর পেছনে, T হলে সেই দিনটি নিয়ে দুই ঘর পেছনে। দিন ১ আর ৪ — ৫ + ১১ = ১৬. বিকল্প দেখুন: বিজোড় দিনগুলো ৫ + ৪ + ২ = ১১, জোড় দিনগুলো ৩ + ১১ = ১৪ — সহজ নিয়ম কোনোটাই সেরা নয়।

Kadane-কে DP-র চোখে

পর্ব ১০-এ maximum subarray-এর Kadane সমাধান দেখেছিলেন। এবার রেসিপিতে লিখুন। অবস্থা: end[i] = ঠিক i-তে শেষ হওয়া subarray-গুলোর মধ্যে সর্বোচ্চ যোগফল। “i পর্যন্ত সেরা” নয় — শেষ হওয়ার শর্তটিই recurrence-কে সহজ করে। Recurrence: i-তে শেষ হওয়া সেরা অংশটি হয় শুধু a[i], নয়তো i − 1-এ শেষ হওয়া সেরা অংশের সাথে a[i]: end[i] = max(a[i], end[i − 1] + a[i]). Base: end[0] = a[0]. উত্তর: শেষ ঘরে নয় — সব end[i]-এর সর্বোচ্চ। a = [−2, 1, −3, 4, −1, 2, 1, −5, 4]-এ:

i      :  0   1   2   3   4   5   6   7   8
a[i]   : -2   1  -3   4  -1   2   1  -5   4
end[i] : -2   1  -2   4   3   5   6   1   5      answer = max = 6  (i = 3..6)

অবস্থা n টি, transition O(1): Θ(n) সময়, আর end[i − 1] ছাড়া কিছু লাগে না বলে Θ(1) জায়গা — Kadane আসলে rolling array-সহ একটি DP. এখান থেকে একটি সাধারণ শিক্ষা: “উত্তর কোথায়” ধাপটি সবসময় dp[n] নয়।

৬ · ঢাকার রাস্তার গ্রিড — পথ গোনা আর সবচেয়ে কম জ্যামের পথ

ধরুন শহরের একটি অংশ ৪ সারি × ৫ কলামের মোড়ের গ্রিড। শুরু ওপরের বাঁ কোণ (0,0), ধরুন মিরপুরের দিকের একটি মোড়; গন্তব্য নিচের ডান কোণ (3,4), মতিঝিলের দিকে। প্রতিটি চালে আপনি শুধু ডানে (পূর্বে, R) বা নিচে (দক্ষিণে, D) যান — পেছনে যাওয়ার কারণ নেই। কিছু মোড় বন্ধ (#): রাস্তা খোঁড়া বা জলাবদ্ধ। প্রতিটি খোলা মোড়ের সংখ্যা সেখানে জ্যামে আটকে থাকার মিনিট।

        0  1  2  3  4
   0 │  3  2  4  1  2      start = (0,0)
   1 │  1  #  1  3  1
   2 │  2  1  2  #  2
   3 │  #  3  1  2  1      goal  = (3,4)

প্রশ্ন ১ — কয়টি পথ? অবস্থা: ways[i][j] = (0,0) থেকে (i,j)-তে পৌঁছানোর পথের সংখ্যা। শেষ চালটি এসেছে ওপর থেকে বা বাঁ থেকে — দুই দল আলাদা: ways[i][j] = ways[i − 1][j] + ways[i][j − 1]; বন্ধ মোড়ে ০; base ways[0][0] = 1; গ্রিডের বাইরের ঘর ০ ধরা হয়। সারি ধরে, বাঁ থেকে ডানে ভরলে প্রতিটি ঘরের ওপরের আর বাঁয়ের ঘর আগেই ভরা।

ways[i][j]# = বন্ধ, · = বাকি

                
এই ধাপ
ভরা হলো
একটি উদাহরণ
  1. শুরু. বন্ধ মোড় তিনটি চিহ্নিত। শুধু ways[0][0] = 1 জানা — সেখানে দাঁড়িয়ে থাকাই একটি পথ।
  2. সারি 0. ওপরে কিছু নেই, তাই প্রতিটি ঘর শুধু বাঁ থেকে: সবাই ১ — সোজা পূর্বে হাঁটা।
  3. সারি 1. (1,0) = ১ (শুধু ওপর থেকে)। (1,1) বন্ধ — ০. (1,2) = ওপরের ১ + বাঁয়ের ০ = ১: বন্ধ মোড় বাঁ দিক থেকে আসা সব পথ কেটে দিয়েছে। (1,3) = ১ + ১ = ২, (1,4) = ১ + ২ = ৩.
  4. সারি 2. (2,1) = ০ + ১ = ১, (2,2) = ১ + ১ = ২, (2,3) বন্ধ, আর (2,4) = ওপরের ৩ + বাঁয়ের ০ = ৩.
  5. সারি 3. (3,0) বন্ধ। (3,1) = ১, (3,2) = ২ + ১ = ৩, (3,3) = ০ + ৩ = ৩, আর গন্তব্য (3,4) = ৩ + ৩ = ৬টি পথ. ২০টি অবস্থা, প্রতিটি O(1): Θ(RC).
  6. তুলনা. কোনো মোড় বন্ধ না থাকলে টেবিলটি Pascal-এর ত্রিভুজ: ৭টি চালের মধ্যে কোন ৩টি D, তা বাছাই — C(7, 3) = ৩৫. তিনটি বন্ধ মোড় ২৯টি পথ মুছে দিয়েছে। বন্ধ মোড় থাকলে এমন সরল সূত্র আর থাকে না, কিন্তু DP-র টেবিল ঠিকই কাজ করে।

প্রশ্ন ২ — সবচেয়ে কম জ্যামের পথ (minimum cost path). অবস্থা: cost[i][j] = (0,0) থেকে (i,j) পর্যন্ত কোনো পথে মোট জ্যামের সর্বনিম্ন মিনিট (দুই প্রান্তের মোড়সহ)। Recurrence: cost[i][j] = w[i][j] + min(cost[i − 1][j], cost[i][j − 1]); বন্ধ মোড় বা গ্রিডের বাইরে ∞. পুনর্গঠনের জন্য একটি পছন্দের টেবিল came[i][j]: D (ওপর থেকে এসেছি) বা R (বাঁ থেকে)। সমান হলে D নেওয়া হবে — এই নিয়ম আগে ঠিক না করলে দুটি সঠিক প্রোগ্রাম দুটি আলাদা (সমান খরচের) পথ ছাপতে পারে।

cost[i][j] আর came[i][j]S = শুরু

                
এই ধাপ
একটি উদাহরণ
  1. শুরু. cost[0][0] = শুরুর মোড়ের জ্যাম ৩. বন্ধ মোড়ের খরচ ∞ — সেখানে কোনো পথ ঢোকে না।
  2. সারি 0. শুধু বাঁ থেকে আসা যায়: ৩, ৩ + ২ = ৫, ৫ + ৪ = ৯, ১০, ১২. সবার came = R.
  3. সারি 1. (1,0) = ৩ + ১ = ৪, D. (1,2) = ১ + min(ওপরে ৯, বাঁয়ে ∞) = ১০. (1,4) = ১ + min(১২, ১৩) = ১৩, ওপর থেকে — D.
  4. সারি 2. (2,1) = ১ + min(∞, ৬) = ৭, বাঁ থেকে। (2,2) = ২ + min(১০, ৭) = ৯ — বাঁ দিক জিতল, R. (2,4) = ২ + ১৩ = ১৫.
  5. সারি 3. (3,2) = ১ + min(৯, ১০) = ১০, D. (3,3) = ২ + min(∞, ১০) = ১২, R. গন্তব্য (3,4) = ১ + min(১৫, ১২) = ১৩ মিনিট.
  6. পেছনে হাঁটা. উত্তরের ঘর থেকে came ধরে: R মানে বাঁয়ে সরো, D মানে ওপরে। ৭টি চাল পরেই (0,0) — পেছনে হাঁটার খরচ পথের দৈর্ঘ্যের সমান, R + C − 2 টি ধাপ, অর্থাৎ O(R + C).
  7. পথ. উল্টো ক্রমে পড়লে DDRRDRR: দুই মোড় দক্ষিণে, দুই পূর্বে, এক দক্ষিণে, দুই পূর্বে। ওপরের সারি দিয়ে সোজা গেলে লাগত ১৬ মিনিট — প্রথম সারির কম সংখ্যাগুলো ফাঁদ।

দুটি প্রশ্ন একই লুপে, একই প্রোগ্রামে:

#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
using namespace std;
int main() {
    vector<string> grid = {"32412",              // digit = minutes of jam at that crossing
                           "1#131",              // '#' = road closed (dug up / flooded)
                           "212#2",
                           "#3121"};
    const int R = grid.size(), C = grid[0].size(), INF = 1e9;
    vector<vector<long long>> ways(R, vector<long long>(C, 0));
    vector<vector<int>> cost(R, vector<int>(C, INF));
    vector<string> came(R, string(C, '.'));      // choice table: 'D' = from above, 'R' = from the left
    for (int i = 0; i < R; ++i)                  // row by row = a topological order of the cells
        for (int j = 0; j < C; ++j) {
            if (grid[i][j] == '#') continue;     // closed crossing: 0 ways, infinite cost
            int w = grid[i][j] - '0';
            if (i == 0 && j == 0) { ways[0][0] = 1; cost[0][0] = w; continue; }
            int up = i > 0 ? cost[i - 1][j] : INF, left = j > 0 ? cost[i][j - 1] : INF;
            ways[i][j] = (i > 0 ? ways[i - 1][j] : 0) + (j > 0 ? ways[i][j - 1] : 0);
            if (min(up, left) < INF) { cost[i][j] = w + min(up, left); came[i][j] = up <= left ? 'D' : 'R'; }
        }
    for (int i = 0; i < R; ++i) {
        for (int j = 0; j < C; ++j) printf("%s%2lld", j ? " " : "", ways[i][j]);
        for (int j = 0; j < C; ++j) { const char* sep = j ? " " : "     ";
            if (cost[i][j] < INF) printf("%s%2d", sep, cost[i][j]); else printf("%s #", sep); }
        for (int j = 0; j < C; ++j) printf("%s%c", j ? " " : "     ", came[i][j]);
        printf("\n");
    }
    printf("paths: %lld  min cost: %d\n", ways[R - 1][C - 1], cost[R - 1][C - 1]);
    int i = R - 1, j = C - 1;
    string moves, path = "(" + to_string(i) + "," + to_string(j) + ")";
    while (i || j) {                             // walk the choice table backwards
        moves = came[i][j] + moves;
        if (came[i][j] == 'D') --i; else --j;
        path = "(" + to_string(i) + "," + to_string(j) + ") " + path;
    }
    printf("moves: %s  path: %s\n", moves.c_str(), path.c_str());
}

আউটপুট (বাঁয়ে ways, মাঝে cost, ডানে came; came-এ . মানে শুরু বা বন্ধ মোড়):

 1  1  1  1  1      3  5  9 10 12     . R R R R
 1  0  1  2  3      4  # 10 13 13     D . D D D
 1  1  2  0  3      6  7  9  # 15     D R R . D
 0  1  3  3  6      # 10 10 12 13     . D D R R
paths: 6  min cost: 13
moves: DDRRDRR  path: (0,0) (1,0) (2,0) (2,1) (2,2) (3,2) (3,3) (3,4)

তিনটি টেবিলই ওপরের দুই ডায়াগ্রামের শেষ অবস্থা। লুপটি সারি ধরে চলে — এটি ঘরগুলোর একটি topological order, তাই প্রতিটি ঘরের সময় ওপরের আর বাঁয়ের ঘর চূড়ান্ত। INF দিয়ে বন্ধ মোড় আর গ্রিডের বাইরের ঘর একসাথে সামলানো হয়েছে; min(up, left) < INF যাচাই না করলে ∞ + w যোগ করে overflow হতে পারত। পথ গোনায় long long, কারণ বড় গ্রিডে পথের সংখ্যা দ্রুত বিশাল হয় (৩০ × ৩০ খালি গ্রিডেই C(58, 29) ≈ ৩ × ১০১৬)। সময় Θ(RC), জায়গা Θ(RC); শুধু সংখ্যা চাইলে একটি সারি রেখে Θ(C), কিন্তু পথ ছাপতে came টেবিল পুরোটা লাগে।

grid = ["32412",                       # digit = minutes of jam at that crossing
        "1#131",                       # '#' = road closed (dug up / flooded)
        "212#2",
        "#3121"]
R, C, INF = len(grid), len(grid[0]), 10**9
ways = [[0] * C for _ in range(R)]
cost = [[INF] * C for _ in range(R)]
came = [["."] * C for _ in range(R)]   # choice table: 'D' = from above, 'R' = from the left
for i in range(R):                     # row by row = a topological order of the cells
    for j in range(C):
        if grid[i][j] == "#":
            continue                   # closed crossing: 0 ways, infinite cost
        w = int(grid[i][j])
        if i == 0 and j == 0:
            ways[0][0], cost[0][0] = 1, w
            continue
        up = cost[i - 1][j] if i > 0 else INF
        left = cost[i][j - 1] if j > 0 else INF
        ways[i][j] = (ways[i - 1][j] if i > 0 else 0) + (ways[i][j - 1] if j > 0 else 0)
        if min(up, left) < INF:
            cost[i][j] = w + min(up, left)
            came[i][j] = "D" if up <= left else "R"    # tie: prefer coming from above
for i in range(R):
    a = " ".join(f"{x:2}" for x in ways[i])
    b = " ".join(f"{x:2}" if x < INF else " #" for x in cost[i])
    print(f"{a}     {b}     {' '.join(came[i])}")
print(f"paths: {ways[R - 1][C - 1]}  min cost: {cost[R - 1][C - 1]}")
i, j, moves, path = R - 1, C - 1, "", f"({R - 1},{C - 1})"
while i or j:                          # walk the choice table backwards
    moves = came[i][j] + moves
    if came[i][j] == "D":
        i -= 1
    else:
        j -= 1
    path = f"({i},{j}) " + path
print(f"moves: {moves}  path: {path}")

আউটপুট C++-এর সাথে হুবহু এক:

 1  1  1  1  1      3  5  9 10 12     . R R R R
 1  0  1  2  3      4  # 10 13 13     D . D D D
 1  1  2  0  3      6  7  9  # 15     D R R . D
 0  1  3  3  6      # 10 10 12 13     . D D R R
paths: 6  min cost: 13
moves: DDRRDRR  path: (0,0) (1,0) (2,0) (2,1) (2,2) (3,2) (3,3) (3,4)

[[0] * C for _ in range(R)] প্রতিটি সারির আলাদা list বানায়; [[0] * C] * R লিখলে সব সারি একই list হতো, আর একটি ঘর বদলালে পুরো কলাম বদলে যেত — Python-এ DP টেবিলের সবচেয়ে পরিচিত ভুল। Python-এর int বড় হতে পারে, তাই পথ গোনায় overflow-এর চিন্তা নেই; C++-এ কনটেস্টে সাধারণত 109 + 7 দিয়ে ভাগশেষ চাওয়া হয়। শুধু খরচ চাইলে rolling-এর রূপ: row[j] = w + min(row[j], row[j - 1]) — এখানে row[j]-এ তখনো ওপরের সারির মান, আর row[j - 1]-এ এই সারির।

৭ · DP মানে states-এর DAG-এ পথ — আর যেখানে DP ব্যর্থ

প্রতিটি DP-কে একটি গ্রাফ হিসেবে আঁকা যায়: প্রতিটি অবস্থা একটি vertex, আর “অবস্থা u-এর উত্তর অবস্থা v-এর হিসাবে লাগে” হলে একটি edge u → v. এটি অবস্থাগুলোর একটি DAG — cycle থাকলে কোনো অবস্থা নিজের ওপরই নির্ভর করত, আর হিসাব কখনো শুরু হতো না। গ্রিডে প্রতিটি মোড় একটি vertex, চালগুলো edge, ওজন মোড়ের জ্যাম; fib-এ k → k + 1 আর k → k + 2:

fib's state DAG: every k feeds k+1 and k+2

  edges k -> k+1 :  0 -> 1 -> 2 -> 3 -> 4 -> 5
  edges k -> k+2 :  0 -> 2,  1 -> 3,  2 -> 4,  3 -> 5

  6 vertices, 9 edges; the loop i = 2, 3, 4, 5 is a topological order

এভাবে দেখলে পর্ব ৪২-এর সাথে সম্পর্কটি পরিষ্কার: tabulation-এর লুপের ক্রম আসলে এই DAG-এর একটি topological order, আর memoization হলো DFS — DFS যেমন একটি vertex শেষ করার আগে তার সব প্রতিবেশী শেষ করে। Min-cost path হলো DAG-এ সংক্ষিপ্ততম পথ, পথ গোনা DAG-এ পথ গোনা, আর house robber-এ সর্বোচ্চ আয় একটি দীর্ঘতম পথ। DAG-এ এগুলো সব Θ(V + E) — আর DP-র “অবস্থা × transition” সূত্রটি ঠিক এই V + E.

মূল ধারণা

নতুন DP সমস্যা দেখলে জিজ্ঞেস করুন: অবস্থাগুলো কী, আর তাদের DAG-এ আমি কী খুঁজছি — সংক্ষিপ্ততম পথ, দীর্ঘতম পথ, নাকি পথের সংখ্যা? এই প্রশ্নের উত্তরই recurrence-এর min, max বা + ঠিক করে দেয়।

যখন optimal substructure নেই — দীর্ঘতম simple path

এবার উল্টো উদাহরণ। চারটি শহর q, r, s, t, আর দুই দিকে চলা চারটি রাস্তা: q–r, r–t, t–s, s–q (একটি চতুর্ভুজ)। প্রশ্ন: q থেকে t পর্যন্ত সবচেয়ে লম্বা simple path (কোনো শহর দুবার নয়)। উত্তর ২টি রাস্তা — q → r → t বা q → s → t. এখন উপসমস্যা দেখুন। q থেকে r-এর দীর্ঘতম simple path হলো q → s → t → r (৩টি রাস্তা), আর r থেকে t-এর দীর্ঘতম r → q → s → t (৩টি)। দুটি জোড়া দিলে q → s → t → r → q → s → t — simple-ই নয়, শহরগুলো বারবার এসেছে।

সমস্যাটি হলো: উপসমস্যাগুলো স্বাধীন নয়। প্রথম অংশ যে শহরগুলো ব্যবহার করেছে, দ্বিতীয় অংশ সেগুলো আর ব্যবহার করতে পারে না — তাই “দুই অংশের সেরা” জুড়ে পুরো সেরা হয় না। সংক্ষিপ্ততম পথে এই সমস্যা হয় না, কারণ দুটি সংক্ষিপ্ততম অংশে কোনো শহর মিলে গেলে মাঝের লুপ কেটে আরও ছোট পথ পাওয়া যায় (cut-and-paste). দীর্ঘতম পথে লুপ কাটলে পথ ছোট হয় — যুক্তিটি উল্টো দিকে খাটে না। সাধারণ গ্রাফে দীর্ঘতম simple path আসলে NP-hard (পর্ব ৬৪), কারণ “n − 1 টি রাস্তার simple path আছে কি?” প্রশ্নটিই Hamiltonian path. DAG-এ (পর্ব ৪২) সমস্যা থাকে না, কারণ সেখানে প্রতিটি পথ নিজে থেকেই simple.

অবস্থায় “যথেষ্ট ইতিহাস” রাখুন। অনেক সময় DP ব্যর্থ নয়, অবস্থাটি ছোট। দীর্ঘতম simple path-এর অবস্থায় “এ পর্যন্ত কোন শহরগুলো ঘোরা হয়েছে” যোগ করলে উপসমস্যা আবার স্বাধীন হয় — কিন্তু অবস্থা তখন 2n × n টি (bitmask DP, পর্ব ৫৫)। ছোট n-এ চলে, বড় n-এ নয়। অবস্থার আকারই DP-র দাম।

৮ · নামের গল্প, আর DP বনাম divide and conquer বনাম greedy

“Dynamic programming” নামটি শুনে মনে হয় এটি প্রোগ্রাম লেখার কোনো গতিশীল পদ্ধতি — আসলে তা নয়। ১৯৫০-এর দশকের শুরুতে মার্কিন গবেষণা প্রতিষ্ঠান RAND Corporation-এ গণিতবিদ Richard Bellman বহু ধাপের সিদ্ধান্তের সমস্যা নিয়ে কাজ করছিলেন — প্রতিটি ধাপে একটি সিদ্ধান্ত, যা পরের অবস্থা বদলে দেয়। তখন “programming” শব্দের মানে ছিল পরিকল্পনা বা সূচি তৈরি, যেমন linear programming. Bellman-এর নিজের ভাষ্যে (তাঁর আত্মজীবনী Eye of the Hurricane, ১৯৮৪): RAND-এর অর্থ আসত বিমানবাহিনী থেকে, আর তখনকার মার্কিন প্রতিরক্ষামন্ত্রী Charles Wilson “research” শব্দটিই সহ্য করতে পারতেন না। তাই Bellman এমন একটি নাম খুঁজছিলেন, যা তাঁর গাণিতিক কাজকে আড়াল করবে আর কেউ আপত্তি করতে পারবে না। “Dynamic” বেছে নিলেন, কারণ এতে সময়ের সাথে বদলানোর ভাব আছে, আর তাঁর মতে শব্দটিকে খারাপ অর্থে ব্যবহার করা প্রায় অসম্ভব। গল্পটির সময়-ক্রম নিয়ে পরে কেউ কেউ প্রশ্ন তুলেছেন, তবে এটিই Bellman-এর নিজের বলা ইতিহাস। ১৯৫৭ সালে তাঁর বই Dynamic Programming প্রকাশিত হয়, যেখানে principle of optimality আর “Bellman equation” আসে। পর্ব ৪৪-এর Bellman-Ford-ও তাঁর নামে।

এই কোর্সের মডিউল ৭-এর তিনটি বড় কৌশল একই প্রশ্নের তিন উত্তর: “ছোট সমস্যা থেকে বড় সমাধান কীভাবে বানাব?”

দিকDivide and conquer (পর্ব ৪৯)Dynamic programmingGreedy (পর্ব ৫০)
উপসমস্যাআলাদা, একে অন্যকে ছোঁয় নাবারবার ফিরে আসে (overlap)প্রতি ধাপে একটিই বাকি থাকে
সিদ্ধান্তভাগ নির্দিষ্ট, পছন্দ নেইসব পছন্দ চেষ্টা, সেরাটি রাখাএখনকার সেরা পছন্দ, আর ফেরা নেই
সঠিকতার শর্তcombine ধাপ ঠিকoptimal substructureoptimal substructure + greedy-choice ধর্ম
প্রমাণের ধরনinduction, recurrence (পর্ব ০৮, ০৯)অবস্থার ওপর induction, cut-and-pasteexchange argument
উদাহরণmerge sort, binary search, Karatsubafib, গ্রিডের পথ, knapsack, LCSKruskal, Dijkstra, Huffman
সাধারণ খরচপ্রায়ই O(n log n)অবস্থা × transition, প্রায়ই O(n²) বা O(nW)প্রায়ই O(n log n) — সর্ট বা heap

একটি সহজ পরীক্ষা: ওভারটাইমের সমস্যায় greedy চেষ্টা করুন — “সবচেয়ে বেশি মজুরির দিনটি নাও, তার পাশের দুই দিন বাদ দাও, আবার করো”. a = [4, 5, 4]-এ greedy নেবে ৫ আর থামবে, অথচ সেরা উত্তর ৪ + ৪ = ৮. Greedy-র একটি পছন্দ ভবিষ্যতের সব পছন্দ বদলে দেয়, আর সে ফিরে দেখে না; DP দুটি পথই চেষ্টা করে সেরাটি রাখে। আবার যেখানে greedy প্রমাণসহ খাটে (পর্ব ৫০), সেখানে DP অপ্রয়োজনীয়ভাবে ধীর।

আরও গভীরে — DP-কে আরও দ্রুত করা। “অবস্থা × transition” সূত্রের দুই দিকেই কাজ করা যায়। রৈখিক recurrence (যেমন fib) matrix আকারে লিখে fast power দিয়ে n-তম পদ O(k³ log n)-এ পাওয়া যায়, যেখানে k হলো recurrence-এর ক্রম — fib-এ 2 × 2 matrix, O(log n) গুণ। Transition-এর খরচ কমানোর জন্য আছে monotonic deque, convex hull trick আর Knuth-এর কৌশল (১৯৭১ সালে optimal BST-কে O(n³) থেকে O(n²)-এ নামানো, পরে F. Frances Yao-র সাধারণীকরণ)। আর জায়গার দিকে, Hirschberg-এর (১৯৭৫) কৌশল LCS-এর মতো DP-তে rolling array রেখেও পুরো সমাধান পুনর্গঠন করতে পারে, divide and conquer জুড়ে দিয়ে।

পরীক্ষা ও ইন্টারভিউতে:

  • DP প্রয়োগের দুটি শর্ত কী? — overlapping subproblems আর optimal substructure; প্রথমটি লাভ দেয়, দ্বিতীয়টি সঠিকতা।
  • সরল রিকার্সিভ fib(n)-এ কয়টি কল? — 2F(n+1) − 1, অর্থাৎ Θ(φn); memo দিয়ে 2n − 1, Θ(n).
  • Memoization আর tabulation-এর পার্থক্য? — top-down রিকার্শন + খাতা, শুধু দরকারি অবস্থা; বনাম bottom-up লুপ, সব অবস্থা, জায়গা বাঁচানো সহজ।
  • একটি DP-র time complexity কীভাবে বলবেন? — অবস্থার সংখ্যা × প্রতিটি অবস্থার transition খরচ; জায়গা = একসাথে রাখা অবস্থা।
  • দীর্ঘতম simple path-এ DP কেন খাটে না? — উপসমস্যাগুলো একই vertex ভাগ করতে পারে, তাই optimal substructure ভাঙে; সাধারণ গ্রাফে সমস্যাটি NP-hard.

৯ · AI-এর যুগে DP — recurrence ঠিক, উত্তর ভুল

DP-র কোড AI সাধারণত ঝটপট লিখে দেয়, আর পরিচিত সমস্যায় (climbing stairs, house robber) প্রায়ই ঠিকও হয়। বিপদ আসে সামান্য বদলানো সমস্যায়: AI চেনা recurrence বসিয়ে দেয়, যদিও শর্ত বদলে গেছে; base case-এ একটি off-by-one; উত্তর dp[n]-এ খোঁজে, যখন আসলে সর্বোচ্চ মানটি দরকার; বা complexity বলে O(n), অথচ প্রতিটি অবস্থায় O(n) কাজ।

DP-তে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
অবস্থার সংজ্ঞা কোথাও লেখা নেই“dp[i] মানে কী, এক বাক্যে বলো” — উত্তর অস্পষ্ট হলে কোডও সন্দেহজনক
Kadane-এ শুরু 0 দিয়েসব সংখ্যা ঋণাত্মক দিন: [−3, −1, −2]-এ উত্তর −1, 0 নয়
বন্ধ ঘর থাকা গ্রিডে প্রথম সারি/কলাম সবসময় ১(0,1) বন্ধ করুন — তার ডানের ঘরগুলো ০ হওয়া উচিত
“greedy-ই যথেষ্ট” — প্রমাণ ছাড়া[4, 5, 4]-এর মতো ছোট প্রতি-উদাহরণে brute force-এর সাথে মেলান
Python-এ [[0] * C] * Rএকটি ঘর বদলে পুরো টেবিল ছাপুন
সময় “O(n)” বলা, অথচ transition O(n)অবস্থার সংখ্যা আর প্রতিটির কাজ আলাদা করে গুনুন
rolling করে তারপর পথ ছাপার চেষ্টাপুরোনো সারি মুছে গেছে — পছন্দের টেবিল আছে কি না দেখুন

AI-কে সমাধান লিখতে না বলে আপনার অবস্থা, recurrence আর trace যাচাই করতে বলুন:

আমি DSA শিখছি, এখন dynamic programming. নিচে একটি সমস্যা, আমার DP-র অবস্থার সংজ্ঞা, recurrence, base case আর একটি ছোট ইনপুটে হাতে ভরা টেবিল দিলাম: [এখানে সমস্যা, সংজ্ঞা আর টেবিল পেস্ট করুন] পুরো কোড লিখে দিও না। শুধু এগুলো করো: ১. আমার অবস্থার সংজ্ঞা কি স্পষ্ট আর যথেষ্ট? কোন তথ্য বাদ পড়লে recurrence ভুল হবে? ২. recurrence-এর প্রতিটি পছন্দ কেন সব সম্ভাবনা ঢাকে আর কোনোটি দুবার গোনে না — ব্যাখ্যা করো, আর optimal substructure-এর cut-and-paste প্রমাণ লেখো। ৩. আমার টেবিলের প্রথম যে ঘরটি ভুল, সেটি দেখাও। একটি ছোট প্রতি-উদাহরণ দাও যেখানে আমার base case ভুল উত্তর দেয়। ৪. অবস্থার সংখ্যা আর প্রতিটি অবস্থার transition খরচ আলাদা করে লিখে time আর space complexity বলো।

DP-র কোড যাচাইয়ের চেকলিস্ট

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

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

প্র ০১Bellman যে সমস্যাগুলোর জন্য DP তৈরি করেছিলেন, সেগুলো প্রোগ্রামিং নয় — বহু ধাপের সিদ্ধান্ত। “Principle of optimality” কেন এত শক্তিশালী একটি ধারণা, আর আজ কোথায় কোথায় তা কাজ করে?

১৯৫০-এর দশকে RAND Corporation-এ Bellman-এর সমস্যাগুলো ছিল এমন: একটি প্রক্রিয়া ধাপে ধাপে চলে, প্রতিটি ধাপে একটি সিদ্ধান্ত নিতে হয় — কত মজুত রাখা হবে, কোন যন্ত্র কখন বদলানো হবে, একটি রকেট কোন পথে উঠবে — আর প্রতিটি সিদ্ধান্ত পরের অবস্থা বদলে দেয়। সব সম্ভাব্য সিদ্ধান্তের শিকল চেষ্টা করা অসম্ভব: প্রতি ধাপে মাত্র ১০টি পছন্দ আর ২০টি ধাপ হলেই ১০২০ শিকল। Bellman-এর অন্তর্দৃষ্টি ছিল, অতীতের পুরো ইতিহাস মনে রাখার দরকার নেই — শুধু “এখন কোন অবস্থায় আছি” জানলেই বাকি পথের সেরা সিদ্ধান্ত ঠিক করা যায়। এটিই principle of optimality: একটি সেরা নীতির বাকি অংশও, নতুন অবস্থা থেকে, একটি সেরা নীতি।

এর ফলে সমস্যাটি “সব শিকল” থেকে নেমে আসে “সব অবস্থা × প্রতি অবস্থায় পছন্দ”-এ — ঠিক এই পর্বের জটিলতার সূত্র। Bellman এটিকে একটি সমীকরণ হিসেবে লেখেন: একটি অবস্থার মূল্য = সেরা পছন্দের তাৎক্ষণিক লাভ + সেই পছন্দে পৌঁছানো অবস্থার মূল্য। এর নাম এখন Bellman equation.

আজ এই সমীকরণ নানা জায়গায় কাজ করে। নিয়ন্ত্রণ প্রকৌশলে (optimal control) রকেট বা রোবটের গতিপথ ঠিক করতে। অর্থনীতিতে সঞ্চয় আর ভোগের মধ্যে সময়ের সাথে ভারসাম্য বের করতে। আর সবচেয়ে আলোচিত ক্ষেত্র reinforcement learning: একটি এজেন্ট (যেমন দাবা বা Go খেলার প্রোগ্রাম) প্রতিটি অবস্থার “মূল্য” শেখে, আর value iteration বা Q-learning-এর হালনাগাদের নিয়ম আসলে Bellman equation-এর একটি রূপ। পার্থক্য একটাই — সেখানে অবস্থা এত বেশি যে টেবিল রাখা যায় না, তাই neural network দিয়ে মূল্য আনুমানিক করা হয়।

Bellman নিজেই এর সীমাও চিহ্নিত করেছিলেন: অবস্থার মাত্রা বাড়লে অবস্থার সংখ্যা সূচকীয় হারে বাড়ে। তিনি এর নাম দেন “curse of dimensionality” — শব্দটি আজ machine learning-এও খুব প্রচলিত।

মূল উপলব্ধি: DP-র মূল কথা হলো “অবস্থা”-র ধারণা — অতীত যতই জটিল হোক, ভবিষ্যতের জন্য যতটুকু জানা দরকার শুধু ততটুকু। ঠিক অবস্থা বাছতে পারলে সূচকীয় অনুসন্ধান polynomial হয়ে যায়।

প্র ০২“১ ধাপ বা ২ ধাপ” সিঁড়ির সমস্যাটি Fibonacci-র ইতালীয় খরগোশের চেয়েও পুরোনো — প্রাচীন ভারতের ছন্দশাস্ত্রে। এই ইতিহাস DP সম্পর্কে কী শেখায়?

সংস্কৃত আর প্রাকৃত ছন্দে অক্ষর দুই রকম: লঘু (ছোট, এক মাত্রা) আর গুরু (দীর্ঘ, দুই মাত্রা)। কবি আর ছন্দশাস্ত্রবিদদের একটি স্বাভাবিক প্রশ্ন ছিল: মোট n মাত্রার একটি লাইনে লঘু-গুরুর কতগুলো ভিন্ন বিন্যাস সম্ভব? এটি হুবহু সিঁড়ির সমস্যা — ১ ধাপ = লঘু, ২ ধাপ = গুরু, আর মোট n মাত্রা = n ধাপের সিঁড়ি।

উত্তর যুক্তিটিও একই। শেষ অক্ষরটি লঘু হলে বাকি n − 1 মাত্রা যেকোনোভাবে সাজানো যায়; গুরু হলে বাকি n − 2 মাত্রা। তাই বিন্যাসের সংখ্যা = আগের দুটির যোগফল। প্রাচীন পণ্ডিত Pingala-র ছন্দসূত্রে এ ধরনের গণনার ইঙ্গিত আছে বলে মনে করা হয়; প্রায় সপ্তম-অষ্টম শতকে Virahanka স্পষ্টভাবে এই নিয়ম দেন, আর পরে Gopala (প্রায় ১১৩৫ সালের আগে) ও Hemachandra (প্রায় ১১৫০ সাল) তা আবার লেখেন। ইতালির Leonardo of Pisa, যিনি Fibonacci নামে পরিচিত, ১২০২ সালে Liber Abaci-তে খরগোশের বংশবৃদ্ধির ধাঁধায় একই সংখ্যার সারি দেন — ভারতীয় গণনার কয়েক শতাব্দী পরে। এজন্য কিছু গণিতবিদ এদের “Virahanka–Fibonacci সংখ্যা” বা “Hemachandra সংখ্যা” বলতেও পছন্দ করেন।

DP-র দিক থেকে এখানে দুটি শিক্ষা আছে। প্রথমত, recurrence আবিষ্কারের কৌশল একই — “শেষ সিদ্ধান্তটি কী ছিল?” এই প্রশ্নই সব সম্ভাবনাকে আলাদা দলে ভাগ করে, যাতে কোনো বিন্যাস দুবার গোনা না হয় আর কোনোটি বাদ না পড়ে। দ্বিতীয়ত, ছন্দশাস্ত্রবিদরা হাতে একটি তালিকা বানাতেন — ১, ২, ৩, ৫, ৮, … — প্রতিটি সংখ্যা আগের দুটি থেকে। এটি আসলে tabulation: ছোট থেকে বড়, প্রতিটি উত্তর একবার। গণনার কোনো যন্ত্র ছাড়াই তাঁরা বুঝেছিলেন যে সব বিন্যাস এক এক করে লিখে গোনা অসম্ভব, কিন্তু ছোট উত্তর থেকে বড় উত্তর গড়া সহজ।

মূল উপলব্ধি: DP একটি আধুনিক প্রোগ্রামিং কৌশলের নাম হলেও এর মূল যুক্তি — শেষ পছন্দ ধরে ভাগ করা আর ছোট উত্তর জমিয়ে রাখা — মানুষের গণনার খুব পুরোনো অভ্যাস।

প্র ০৩Donald Michie ১৯৬৮ সালে “memo function” ধারণা দেন। আজ memoization-এর রূপ ওয়েব সার্ভারের cache থেকে React পর্যন্ত — কিন্তু কখন এটি ক্ষতিকর?

ব্রিটিশ কম্পিউটার বিজ্ঞানী Donald Michie — যিনি দ্বিতীয় বিশ্বযুদ্ধে Bletchley Park-এ জার্মান সংকেত ভাঙার দলে কাজ করেছিলেন, আর পরে এডিনবরায় কৃত্রিম বুদ্ধিমত্তার গবেষণা গড়ে তোলেন — ১৯৬৮ সালে Nature পত্রিকায় “Memo functions and machine learning” প্রবন্ধে প্রস্তাব করেন: একটি ফাংশন নিজের আগের ফলগুলো মনে রাখবে, যাতে একই প্রশ্নের উত্তর আবার হিসাব না করে “স্মৃতি থেকে” দিতে পারে। তাঁর চোখে এটি একধরনের শেখা — অভিজ্ঞতা থেকে দ্রুত হওয়া।

আজ এই ধারণা সবখানে, শুধু নাম আলাদা। ওয়েব সার্ভার একটি পাতার ফল cache-এ রাখে; ডেটাবেস একটি query-র পরিকল্পনা মনে রাখে; build system আগের compile-এর ফল রাখে (পর্ব ৪২-এর ভাবনার প্রশ্ন দেখুন); React-এর useMemo একটি হিসাব আবার করে না যদি ইনপুট না বদলায়; Python-এর functools.cache এক লাইনে যেকোনো ফাংশনকে memoized বানায়। CPU-র ভেতরের cache-ও একই নীতিতে — আবার লাগবে এমন ডেটা কাছে রাখা।

কিন্তু memoization বিনা মূল্যে নয়। প্রথমত জায়গা: প্রতিটি আলাদা ইনপুটের ফল জমা থাকে, আর অবস্থা অনেক হলে মেমরি ফুরিয়ে যেতে পারে — এজন্য বাস্তবে LRU-র মতো নিয়মে পুরোনো ফল মুছে ফেলা হয় (পর্ব ১২, ৩২)। দ্বিতীয়ত সঠিকতা: ফাংশনটি বিশুদ্ধ না হলে, অর্থাৎ ফল যদি সময়, ফাইল, বা কোনো global চলকের ওপর নির্ভর করে, তাহলে খাতার উত্তর পুরোনো হয়ে যায়। কম্পিউটার বিজ্ঞানে একটি পুরোনো রসিকতা আছে — সবচেয়ে কঠিন দুটি কাজের একটি হলো cache invalidation, অর্থাৎ কখন খাতার উত্তর আর বিশ্বাস করা যাবে না। তৃতীয়ত অবস্থা পুনরাবৃত্ত না হলে খাতা শুধু খরচ বাড়ায়: merge sort-এ memo যোগ করলে কোনো লাভ নেই।

DP-র ক্ষেত্রে এই তিন সমস্যা সাধারণত সহজে সামলানো যায়: অবস্থার সংখ্যা আগে থেকেই জানা, ফাংশন গাণিতিক আর বিশুদ্ধ, আর overlap থাকাটাই DP-র শর্ত।

মূল উপলব্ধি: memoization হলো সময়ের বিনিময়ে জায়গা খরচ — এটি তখনই লাভজনক, যখন একই প্রশ্ন সত্যিই বারবার আসে আর উত্তর কখনো বদলায় না।

প্র ০৪মোবাইল ফোনের সংকেত থেকে DNA-র তুলনা আর বইয়ের লাইন ভাঙা — DP বাস্তব প্রযুক্তির কোথায় কোথায় লুকিয়ে আছে?

১৯৬৭ সালে Andrew Viterbi গোলমেলে চ্যানেলে পাঠানো convolutional code-এর সংকেত উদ্ধারের একটি অ্যালগরিদম দেন। এটি আসলে একটি DP: প্রতিটি সময়ে প্রতিটি সম্ভাব্য অবস্থার জন্য “এখানে পৌঁছানোর সবচেয়ে সম্ভাব্য পথ” মনে রাখা, আর শেষে পছন্দের টেবিল ধরে পেছনে হেঁটে পুরো বার্তা পুনর্গঠন — এই পর্বের গ্রিডের পথের মতোই। Viterbi অ্যালগরিদম ডিজিটাল মোবাইল যোগাযোগ, স্যাটেলাইট লিংক আর কথা চেনার (speech recognition) পুরোনো ব্যবস্থার একটি ভিত্তি হয়ে ওঠে; Viterbi পরে Qualcomm কোম্পানির সহপ্রতিষ্ঠাতা হন।

জীববিজ্ঞানে, দুটি DNA বা প্রোটিন ক্রম কতটা মেলে তা মাপতে ১৯৭০ সালে Needleman আর Wunsch একটি DP প্রকাশ করেন, যেখানে অবস্থা হলো “প্রথম ক্রমের i অক্ষর আর দ্বিতীয়টির j অক্ষর মেলানোর সেরা স্কোর”; ১৯৮১ সালে Smith আর Waterman আংশিক মিলের জন্য রূপটি দেন। নতুন জিন পেলে বিজ্ঞানীরা প্রথমে এই ধরনের তুলনাই করেন। পর্ব ৫৪-এর LCS আর edit distance একই পরিবারের।

সফটওয়্যারে, diff আর git-এর মতো টুল দুটি ফাইলের পার্থক্য দেখাতে longest common subsequence-ভিত্তিক ধারণা ব্যবহার করে; বানান-সংশোধক edit distance দিয়ে কাছাকাছি শব্দ খোঁজে। ১৯৮১ সালে Donald Knuth আর Michael Plass TeX-এর জন্য একটি DP প্রকাশ করেন, যা একটি অনুচ্ছেদের সব লাইন-ভাঙার সম্ভাবনা একসাথে বিবেচনা করে সবচেয়ে সুন্দর বিন্যাস বাছে — প্রতিটি লাইন আলাদাভাবে greedy-ভাবে ভরার চেয়ে ফাঁকগুলো অনেক সমান হয়। আর রাউটিং-এ, Bellman-Ford-ভিত্তিক distance-vector প্রোটোকল (পর্ব ৪৪) নেটওয়ার্কের রাউটারগুলোর মধ্যে ছড়ানো একটি DP.

এই সব উদাহরণে একই তিনটি উপাদান: অবস্থার স্পষ্ট সংজ্ঞা, শেষ সিদ্ধান্ত ধরে recurrence, আর পছন্দের টেবিল ধরে পুনর্গঠন। পার্থক্য শুধু অবস্থা কী আর “সেরা” মানে কী — সর্বনিম্ন খরচ, সর্বোচ্চ সম্ভাবনা, নাকি সবচেয়ে সমান ফাঁক।

মূল উপলব্ধি: DP কনটেস্টের কৌশল হিসেবে বেশি পরিচিত, কিন্তু আপনার ফোনের সংকেত, ওষুধ গবেষণার জিন-তুলনা আর আপনার পড়া বইয়ের ছাপা — সবখানে একই ছয় ধাপের রেসিপি কাজ করছে।

প্র ০৫Traveling salesman সমস্যা NP-hard, তবু ১৯৬২ সালে এর একটি DP সমাধান প্রকাশিত হয় যা আজও ব্যবহৃত হয়। DP দিয়ে কঠিন সমস্যা “সমাধান” করা মানে কী, আর pseudo-polynomial কথাটির অর্থ কী?

Traveling salesman সমস্যায় n টি শহর একবার করে ঘুরে শুরুতে ফিরতে সবচেয়ে কম দূরত্ব খুঁজতে হয়। সব ক্রম চেষ্টা করলে (n − 1)! টি সফর — ২০টি শহরেই প্রায় ১.২ × ১০১৭. ১৯৬২ সালে Michael Held আর Richard Karp, আর প্রায় একই সময়ে আলাদাভাবে Bellman নিজে, একটি DP দেন। অবস্থা: “শহর ১ থেকে শুরু করে S সেটের শহরগুলো ঘুরে এখন v-তে আছি — সর্বনিম্ন দূরত্ব”. এই পর্বের দীর্ঘতম simple path-এর মতোই, “কোন শহরগুলো ঘোরা হয়েছে” অবস্থায় রাখায় উপসমস্যাগুলো স্বাধীন হয়, আর optimal substructure ফিরে আসে।

দাম? অবস্থা 2n × n টি, প্রতিটিতে n টি পছন্দ — O(n² 2n). ২০টি শহরে প্রায় ৪ × ১০৮ — একটি আধুনিক কম্পিউটারে কয়েক সেকেন্ড, যেখানে (n − 1)! অসম্ভব। কিন্তু ৬০টি শহরে আবার অসম্ভব। অর্থাৎ DP সমস্যাটিকে সহজ করেনি — শুধু সূচকীয় অংশটিকে ছোট করেছে। NP-hard সমস্যায় এটিই সাধারণ চিত্র (পর্ব ৫৭, ৬৫)।

দ্বিতীয় একটি সূক্ষ্ম ঘটনা দেখা যায় knapsack-এ (পর্ব ৫৩)। n টি জিনিস, ব্যাগের ধারণক্ষমতা W: DP-র সময় O(nW). এটি দেখতে polynomial, কিন্তু W একটি সংখ্যা, আর ইনপুটে তা লেখা হয় মাত্র log W টি bit-এ। W = ১০১৮ হলে টেবিলের আকার অসম্ভব বড়, যদিও ইনপুট ছোট। এমন সময়কে বলে pseudo-polynomial: সংখ্যার মানের polynomial, কিন্তু তার bit-সংখ্যার নয়। Knapsack NP-hard থাকে, কিন্তু W ছোট হলে DP দিয়ে বাস্তবে দ্রুত সমাধান হয়। এজন্য কনটেস্টের সমস্যায় সীমাগুলো (W ≤ ১০৫, n ≤ ২০) মন দিয়ে পড়া জরুরি — সীমাই প্রায়ই বলে দেয় অবস্থা কী হবে।

মূল উপলব্ধি: DP-র খরচ সবসময় অবস্থার সংখ্যা। কঠিন সমস্যায় DP অবস্থাকে “সব ক্রম” থেকে “সব সেট” বা “সব সংখ্যার মান”-এ নামিয়ে আনে — ছোট ইনপুটে এটিই প্রায়ই সেরা নির্ভুল পদ্ধতি।

অনুশীলন

খাতা-কলম নিন। প্রথম দুটি হাতে trace করার, তৃতীয়টি ভাবার, শেষটিতে কোড লিখতে হবে।

  1. হাতে trace করুন — তিন রকম চাল: সিঁড়িতে একবারে ১, ২ বা ৩ ধাপ ওঠা যায়। recurrence আর base case লিখুন, আর n = 5-এর জন্য টেবিলটি ভরুন। জায়গা কত লাগে, rolling করলে কত?

    শেষ চাল ১, ২ বা ৩: ways[n] = ways[n − 1] + ways[n − 2] + ways[n − 3], base ways[0] = 1, আর ঋণাত্মক সূচক ০ ধরুন (তাই ways[1] = 1, ways[2] = 1 + 1 = 2). টেবিল: ways[3] = 2 + 1 + 1 = ৪, ways[4] = 4 + 2 + 1 = ৭, ways[5] = 7 + 4 + 2 = ১৩. যাচাই: n = 3-এর চারটি — ১+১+১, ১+২, ২+১, ৩.

    পুরো টেবিলে Θ(n) জায়গা; প্রতিটি ঘর শুধু শেষ তিনটির ওপর নির্ভর করে, তাই তিনটি চলক রেখে rolling করলে Θ(1). সময় Θ(n) — অবস্থা n + 1 টি, প্রতিটিতে ৩টি যোগ।

  2. হাতে trace করুন — house robber আর পুনর্গঠন: a = [2, 7, 9, 3, 1]. best[ ] আর choice টেবিল ভরুন, সর্বোচ্চ আয় বের করুন, আর পেছনে হেঁটে কোন দিনগুলো নেওয়া হলো তা বলুন।

    best[0] = 0, best[1] = 2 (T). i = 2: max(2, 0 + 7) = ৭, T. i = 3: max(7, 2 + 9) = ১১, T. i = 4: max(11, 7 + 3) = ১১, S. i = 5: max(11, 11 + 1) = ১২, T. উত্তর ১২.

    পেছনে: i = 5 T → দিন ৫ নিলাম, i = 3-এ লাফ; i = 3 T → দিন ৩, i = 1-এ লাফ; i = 1 T → দিন ১. দিন ১, ৩, ৫: ২ + ৯ + ১ = ১২ ✓. লক্ষ করুন, সবচেয়ে বড় দুটি মজুরি (৭ আর ৯) পাশাপাশি, তাই একসাথে নেওয়া যায় না — ৭ নিলে সর্বোচ্চ ৭ + ৩ = ১০.

  3. ভেবে বলুন: ঢাকার গ্রিডে যদি চার দিকেই (উত্তর, দক্ষিণ, পূর্ব, পশ্চিম) যাওয়া যেত, তাহলে cost[i][j] = w + min(চার প্রতিবেশী) দিয়ে সারি ধরে টেবিল ভরলে কী সমস্যা হতো? কোন অ্যালগরিদম দরকার, আর “পথের সংখ্যা” প্রশ্নটির কী হয়?

    চার দিকে চলা গেলে অবস্থার গ্রাফে cycle আসে: (0,0) → (0,1) → (0,0). তখন এমন কোনো ক্রম নেই যাতে প্রতিটি ঘরের আগে তার সব প্রতিবেশী চূড়ান্ত — সারি ধরে ভরলে নিচের প্রতিবেশীর মান তখনো ∞, তাই নিচ থেকে ঘুরে আসা ভালো পথ বাদ পড়ে। অবস্থার গ্রাফ আর DAG নয়, তাই সরল DP খাটে না।

    সমাধান: ঋণাত্মক নয় এমন ওজনে Dijkstra (পর্ব ৪৩), যা “সবচেয়ে ছোট দূরত্বের ঘর আগে চূড়ান্ত” নিয়মে নিজেই একটি ক্রম বানায় — একে একধরনের DP-ও বলা যায়। আর পথের সংখ্যা: cycle থাকলে বারবার ঘুরে অসীম সংখ্যক walk, তাই প্রশ্নটি “simple path কয়টি” হয়ে যায় — যা সাধারণভাবে কঠিন (#P-complete).

  4. কোড লিখুন: Kadane-এর DP-তে শুধু সর্বোচ্চ যোগফল নয়, subarray-এর শুরু আর শেষ সূচকও ছাপুন। a = [−2, 1, −3, 4, −1, 2, 1, −5, 4] দিয়ে হাতে মেলান, আর সব সংখ্যা ঋণাত্মক হলে কী আসে বলুন।

    end[i]-এর সাথে “এই সেরা অংশটি কোথা থেকে শুরু” রাখুন: নতুন শুরু করলে (a[i] বড়) শুরু = i, নইলে আগের শুরুই থাকে। Rolling করে দুটি চলকই যথেষ্ট।

    long long cur = a[0], best = a[0];          // cur = best sum ending exactly at i
    int start = 0, bestL = 0, bestR = 0;
    for (int i = 1; i < (int)a.size(); ++i) {
        if (cur + a[i] < a[i]) { cur = a[i]; start = i; }   // starting fresh at i is better
        else cur += a[i];                                    // extend the run ending at i-1
        if (cur > best) { best = cur; bestL = start; bestR = i; }
    }
    // print best, bestL, bestR

    হাতে: cur = −2; i = 1: −2 + 1 < 1, নতুন শুরু, cur = 1, start = 1, best = 1. i = 2: cur = −2. i = 3: −2 + 4 < 4, নতুন শুরু, cur = 4, start = 3, best = 4 (৩..৩)। i = 4: 3; i = 5: 5, best = 5 (৩..৫); i = 6: 6, best = 6 (৩..৬); i = 7: 1; i = 8: 5. উত্তর ৬, সূচক ৩ থেকে ৬ — [4, −1, 2, 1]. সব ঋণাত্মক হলে, যেমন [−3, −1, −2], উত্তর −1 (সূচক ১..১), কারণ শুরু a[0] দিয়ে, 0 দিয়ে নয়। সময় Θ(n), জায়গা Θ(1).

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি DP সমাধান তৈরির ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. অবস্থা (dp[i]-এর মানে) এক বাক্যে সংজ্ঞায়িত করা
  2. শেষ সিদ্ধান্ত ধরে recurrence লেখা
  3. base case ঠিক করা
  4. এমন হিসাবের ক্রম বাছা, যাতে প্রতিটি অবস্থার আগে তার নির্ভর অবস্থাগুলো আসে
  5. টেবিলের কোথায় উত্তর আছে তা ঠিক করা
  6. পছন্দের টেবিল ধরে পেছনে হেঁটে আসল সমাধান পুনর্গঠন করা

fib(0) = 0, fib(1) = 1 আর fib(n) = fib(n−1) + fib(n−2) — এই সংজ্ঞা হুবহু রিকার্শনে (কোনো memo ছাড়া) লিখে fib(6) ডাকলে মোট কয়টি ফাংশন কল হয়?

  • ৭
  • ১১
  • ২৫
  • ৬৪

একটি সমস্যার রিকার্সিভ সমাধানে উপসমস্যাগুলো কখনো পুনরাবৃত্ত হয় না (প্রতিটি একবারই আসে), যদিও optimal substructure আছে। এতে memoization যোগ করলে কী হবে?

  • উত্তর ভুল হবে, কারণ খাতা পুরোনো মান দেবে
  • উত্তর ঠিক থাকবে, কিন্তু সময় প্রায় একই থাকবে, বাড়তি জায়গা লাগবে
  • সময় সূচকীয় থেকে polynomial হয়ে যাবে
  • প্রোগ্রাম অসীম লুপে পড়বে

একটি DP-তে অবস্থা dp[i][j], যেখানে 0 ≤ i < n আর 0 ≤ j < n, আর প্রতিটি অবস্থার মান বের করতে O(n) টি পুরোনো অবস্থার সর্বনিম্ন নিতে হয়। মোট সময় কত?

  • Θ(n²)
  • Θ(n log n)
  • Θ(2ⁿ)
  • Θ(n³)

৩ × ৩ মোড়ের একটি গ্রিডে (কোনো মোড় বন্ধ নয়) ওপরের বাঁ কোণ থেকে নিচের ডান কোণে শুধু ডানে বা নিচে গিয়ে মোট কয়টি পথ আছে?

  • ৬
  • ৪
  • ৯
  • ১৬

পাশাপাশি দুটি নেওয়া যাবে না এমন শর্তে [3, 10, 3, 1, 2] থেকে সর্বোচ্চ যোগফল কত?

  • ১০
  • ৮
  • ১২
  • ১৩

একটি সাধারণ undirected গ্রাফে দুটি vertex-এর মধ্যে দীর্ঘতম simple path খুঁজতে সরল DP (“s থেকে v-এর দীর্ঘতম + v থেকে t-এর দীর্ঘতম”) কেন ব্যর্থ হয়?

  • উপসমস্যাগুলো বারবার আসে
  • দুই অংশের সেরা উত্তর একই vertex ব্যবহার করতে পারে, তাই optimal substructure নেই
  • edge-এর ওজন ঋণাত্মক হতে পারে
  • অবস্থার সংখ্যা n² হয়ে যায়

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. সরল fib(n)-এ কল 2F(n+1) − 1 = Θ(φn); খাতা রাখলে 2n − 1. DP মানে প্রতিটি উপসমস্যা একবার.
  2. DP-র দুই শর্ত: overlapping subproblems (লাভ) আর optimal substructure (সঠিকতা, cut-and-paste দিয়ে প্রমাণ)।
  3. Memoization = রিকার্শন + খাতা, ওপর থেকে নিচে; tabulation = লুপে টেবিল, নিচ থেকে ওপরে; rolling array জায়গা বাঁচায়, কিন্তু পুনর্গঠনে পছন্দের টেবিল লাগে।
  4. রেসিপি: অবস্থা → recurrence → base → ক্রম → উত্তর কোথায় → পুনর্গঠন। সময় = অবস্থা × transition.
  5. DP = অবস্থার DAG-এ পথ; tabulation-এর ক্রম একটি topological order. উপসমস্যা স্বাধীন না হলে (দীর্ঘতম simple path) DP খাটে না।

পরের পর্বে: এই রেসিপি দিয়ে তিনটি ক্লাসিক — ব্যাগে কোন জিনিস নেবেন (0/1 knapsack), সবচেয়ে কম কয়েনে টাকা ভাঙানো (coin change), আর সবচেয়ে লম্বা বাড়ন্ত উপক্রম (LIS), O(n log n) কৌশলসহ।

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

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান