Foundationপ্রথম নীতি থেকে
LEVEL 6লেসন ২০/২৪রিসার্চ-লেভেল১ ঘণ্টা ১০ মিনিট

Dynamic Programming — একবার হিসাব, বারবার ব্যবহার

Dynamic Programming

DP-র পুরো খেলা দুইটা শর্ত আর একটা শৃঙ্খলায়: একই subproblem বারবার এলে একবারই হিসাব করে জমা রাখো — state-এর অর্থ লিখে, recurrence সাজিয়ে, space চেপে।

এই লেসন শেষে আপনি পারবেন

  • Overlapping subproblems আর optimal substructure — DP-র দুইটা শর্ত — চিনে বলতে পারবেন কোনো নির্দিষ্ট সমস্যায় DP খাটে কি না, প্রমাণসহ
  • State design discipline মেনে dp-array-এর প্রতিটা ঘরের অর্থ বাক্যে লিখে recurrence derive করতে পারবেন — coin change, LIS, knapsack, edit distance-এ
  • Top-down memoization আর bottom-up tabulation-এর সমতা ও trade-off ব্যাখ্যা করে যেকোনো একটাকে অন্যটায় রূপান্তর করতে পারবেন
  • 2D DP-table-কে rolling row দিয়ে 1D-তে space-compress করার pattern প্রয়োগ করে মেমরি সঞ্চয় মেপে দেখাতে পারবেন
  • Edit distance-এর পূর্ণ matrix ভরে alignment traceback করে দুই শব্দের সম্পাদনা-পথ বের করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

গত লেসনে আমরা fib_naive-কে exponential অপচয়ে ধরা খেতে দেখলাম — একই subproblem হাজারবার হিসাব হচ্ছিল, আর একটা dict ধার দিলেই খরচ linear হয়ে গেল। আজ সেই এক-লাইনের trick-টাই পূর্ণ প্রকৌশল: Dynamic Programming।

নামটা grandiose, ভেতরের কথাটা গণিতের চেয়ে হিসাবি লোভ: যদি একই ছোট সমস্যা বারবার ফিরে আসে, তাহলে প্রথমবার হিসাব করে answer-টা জমা রাখো — পরে সব সময় lookup করো। Bellman ১৯৫০-এ এই নাম দিয়েছিলেন সরকারি funding পাওয়ার কৌশলে (“dynamic” শব্দটা তখন impressive শোনাত) — technique-এর সাথে নামের কোনো গভীর সম্পর্ক নেই!

আজকের লেসনের পথ: আগে DP-র দুইটা শর্ত — কখন এই জাদু খাটে, কখন খাটে না। তারপর state design-এর discipline — DP fail-এর ৯০% এখানে। তারপর পাঁচটা classic পূর্ণ walk-through: fibonacci → coin change → LIS → knapsack → edit distance। শেষে greedy-র ফাঁদ আর space compression।

মূল ধারণা

DP-র দুই শর্ত — দুটোই লাগবেই

শর্ত ১: Overlapping subproblems। বড় সমস্যাটা ভাঙলে এমন ছোট সমস্যা আসে যেগুলো বারবার একই রূপে ফিরে আসে। fib(n)-এ fib(k) প্রায় φⁿ বার ফেরে — এই duplicate-ই memo-র লাভের উৎস। Divide-and-conquer-এ (mergesort) subproblem unique — তাই সেখানে memo কিছুই বাঁচায় না, আর DP-ও দরকার নেই।

শর্ত ২: Optimal substructure। পুরো সমস্যার optimal solution-এর ভেতরে থাকা প্রতিটা subproblem-এর solution-ও সেই subproblem-এর optimal হতে হবে। Shortest path-এ এটা স্পষ্ট: A→B→C shortest path-এর মধ্যে A→B অংশটাও shortest। কিন্তু longest simple path-এ শর্তটা ভাঙে — তাই সেখানে DP নেই (NP-hard!)।

State design — dp[i] ঘরের অর্থ আগে লিখুন

DP-র code লেখা সহজ; state-এর অর্থ define করা কঠিন। শৃঙ্খলাটা:

  1. অর্থ লিখুন বাক্যে: “dp[i] = ঠিক i-তম index-এ শেষ হওয়া increasing subsequence-এর সর্বোচ্চ দৈর্ঘ্য” — বাক্যটা না লিখে code শুরু করলে off-by-one আর অর্থ-বিভ্রান্তি অবধারিত।
  2. Recurrence derive করুন অর্থ থেকে: dp[i]-এর মানে জানলে সেটা আগের কোন ঘরগুলো থেকে আসতে পারে — সূত্র নিজেই দাঁড়ায়।
  3. Base case ঠিক করুন — induction-এর base, গত লেসনের চুক্তি।
  4. Traversal order: dp[i] হিসাবের সময় যেসব ঘর দরকার, সেগুলো আগে ready থাকতে হবে — bottom-up-এর একমাত্র বাধ্যবাধকতা।

Top-down বনাম Bottom-up — একই গণিত, দুই execution

# Top-down: recursion + memo — দরকারি state-ই হিসাব হয়
def fib_td(n, memo={}):
    if n <= 1: return n
    if n not in memo:
        memo[n] = fib_td(n - 1) + fib_td(n - 2)
    return memo[n]

# Bottom-up: table ভরা loop-এ — সব state আগে থেকে order-এ
def fib_bu(n):
    dp = [0, 1]
    for i in range(2, n + 1):
        dp.append(dp[i - 1] + dp[i - 2])
    return dp[n]

দুটোর recurrence হুবহু এক: dp[i] = dp[i-1] + dp[i-2]। পার্থক্য execution-এ:

Top-down (memoized)Bottom-up (tabulation)
হিসাব হয়দরকারি state-ই (lazy)সব state (eager)
Stackdepth খায় — RecursionError ঝুঁকিখায় না
Constant factordict/hash overheadarray access — দ্রুত
Transition বোঝাnatural (recursion-এর অনুবাদ)order-ভাবতে হয়

Production rule: ভাবতে top-down-এ শুরু, ship করা বড় input-এ bottom-up — stack-safe আর cache-friendly।

ভেতরে কী ঘটছে

Classic ১ → ২: Fibonacci থেকে Coin Change

Coin change-এর দুইটা আলাদা প্রশ্ন — এদের গুলিয়ে ফেলা DP-র সবচেয়ে পরিচিত bug:

  • Minimum coins: amount 11 বানাতে সর্বনিম্ন কতটা coin লাগে? → মান-minimizing DP।
  • Count ways: amount 11 বানানোর কত রকম উপায়? → গণনা-DP।

Coins {1, 5, 6, 9}, amount = 11। প্রথমটার state: “dp[a] = amount a বানানোর সর্বনিম্ন coin সংখ্যা”; recurrence: dp[a] = 1 + min(dp[a-c]) সব coin c ≤ a-এর ওপর। দ্বিতীয়টার state: “ways[a] = amount a বানানোর উপায় সংখ্যা”; আর এখানে order-of-loops meaning বদলে দেয়:

coins = [1, 5, 6, 9]

def min_coins(amount):                 # প্রশ্ন ১: সর্বনিম্ন সংখ্যা
    INF = float('inf')
    dp = [0] + [INF] * amount          # dp[0]=0: শূন্য amount, শূন্য coin
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a and dp[a - c] + 1 < dp[a]:
                dp[a] = dp[a - c] + 1
    return dp[amount] if dp[amount] != INF else -1

def count_ways(amount):                # প্রশ্ন ২: combination গণনা
    dp = [1] + [0] * amount            # dp[0]=1: একটাই উপায় — কিছুই না নেওয়া
    for c in coins:                    # coin বাইরের loop ⇒ combination
        for a in range(c, amount + 1):
            dp[a] += dp[a - c]
    return dp[amount]                  # {1,5,5} আর {5,1,5} একই গণ্য হয়

print(min_coins(11))                   # 2  → 6+5
print(count_ways(11))                  # 3  → {6,5},{1×6,5},{1×11}

Coin-loop বাইরে থাকলে combination (ক্রম অগণনযোগ্য); amount-loop বাইরে থাকলে permutation ({1,5} আর {5,1} আলাদা গণ্য)। Loop-এর ক্রমই semantics — comment ছাড়া পড়লে ধরাই পড়ে না এই class-এর bug।

Classic ৩: Longest Increasing Subsequence — দুই যুগের গল্প

State: “lis[i] = i-তম element-এ শেষ হওয়া LIS-এর দৈর্ঘ্য।”

import bisect

def lis_quadratic(a):
    """O(n²): প্রতিটা i-এর জন্য সব আগের j scan"""
    if not a: return 0
    lis = [1] * len(a)
    for i in range(len(a)):
        for j in range(i):
            if a[j] < a[i]:
                lis[i] = max(lis[i], lis[j] + 1)
    return max(lis)

def lis_nlogn(a):
    """O(n log n): tails[k] = দৈর্ঘ্য k+1-এর সবসময়-সম্ভাবী LIS-এর
    সবচেয়ে ছোট tail — তালিকাটা সর্বদা sorted থাকে!"""
    tails = []
    for x in a:
        pos = bisect.bisect_left(tails, x)
        if pos == len(tails):
            tails.append(x)             # x সবার বড় — LIS বাড়ল
        else:
            tails[pos] = x              # বড় tail-কে ছোট x-এ replace
    return len(tails)

tails sorted থাকে বলে binary search (bisect) খাটে — এটা DP-র state-space-এর ওপর monotonicity exploit; প্রতিটা DP-তে এমন shortcut থাকে না, তাই O(n²)-ও মৃত জ্ঞান নয়।

উদাহরণ

Classic ৪: 0/1 Knapsack — 2D থেকে 1D-তে compression

সমস্যা: item-দের (weight, value) আছে; capacity W bag-এ সর্বোচ্চ value নিন — প্রতিটা item হয় নেবেন হয় বাদ (0/1)।

State (পূর্ণ সংস্করণ): “dp[i][w] = প্রথম i-টা item-এর মধ্যে, capacity w bag-এ সর্বোচ্চ value।” Recurrence:

dp[i][w] = dp[i-1][w]                          ← item i বাদ
         = max(..., dp[i-1][w-wt_i] + val_i)   ← item i নেওয়া (wt_i ≤ w)

লক্ষ্য করুন: row i শুধু row i−1-এর ওপর নির্ভর — পুরো ইতিহাস না! তাহলে দুইটা row-ই যথেষ্ট। আরও সাহস করে একটাই row, তবে একটা ফাঁদ ডিঙাতে হবে:

def knapsack_1d(items, W):
    """items: list of (weight, value); O(nW) time, O(W) space"""
    dp = [0] * (W + 1)
    for wt, val in items:
        for w in range(W, wt - 1, -1):     # উল্টো দিকে! কেন?
            dp[w] = max(dp[w], dp[w - wt] + val)
    return dp[W]

items = [(2, 3), (3, 4), (4, 5), (5, 8), (9, 10)]
print(knapsack_1d(items, 20))              # 20

উল্টো loop-ই 0/1-র প্রাণ: সোজা চললে dp[w - wt] এই item-এর নিজের already-updated মান পড়ত — অর্থাৎ item-টা একই row-এ দুইবার নেওয়া হতো (unbounded knapsack!)। উল্টো চললে dp[w-wt] সবসময় আগের item-দের জগৎ থেকে পড়ে — 0/1 constraint অক্ষুণ্ণ।

Classic ৫: Edit Distance — পূর্ণ matrix + traceback

দুই word-এর মধ্যে সর্বনিম্ন কতগুলো operation (insert/delete/replace) দিয়ে একটাকে আরেকটা বানানো যায়। State: “d[i][j] = প্রথম word-এর প্রথম i অক্ষর → দ্বিতীয় word-এর প্রথম j অক্ষর-এর distance।”

Build section-এ দুই বাংলা শব্দে এই matrix পুরো ভরে traceback-সহ alignment বের করব। এখন ছোট উদাহরণে হাতে: কথা → কোঠা:

        ""   ক    ঠ    আ
   ""    0   1    2    3
   ক     1   0    1    2
   ঢ     2   1    1    2      ← ক↔ঢ replace না করে নিচে দেখুন
   আ     3   2    2    1

d[3][3]=1: ক মিলে গেছে, থ→ঢ replace, আ মিলে গেছে।

Recurrence: অক্ষর মিললে diagonal copy (d[i-1][j-1]), না মিললে তিন operation-এর min (+1): delete (up), insert (left), replace (diagonal)। Traceback = শেষ ঘর থেকে কোন neighbor নিজের মান দিয়েছে তা জিজ্ঞেস করে হাঁটা — প্রতিটা ধাপে একটা operation উন্মোচিত হয়।

ছবিতে

DP-র সিদ্ধান্ত-মানচিত্র আর space ladder

DP design pipeline — কোন ধাপে কী ভাবতে হয়
  1. ১. শর্ত যাচাইoverlapping subproblems + optimal substructure আছে?
  2. ২. State definedp[...] ঘরের অর্থ এক বাক্যে — index-এর মানে, কী minimize/maximize
  3. ৩. Recurrencestate-এর অর্থ থেকে transitions derive — base case সহ
  4. ৪. Order + implementationtop-down না bottom-up; dependency order মানলেই হয়
  5. ৫. Space compressdependency window দেখে row/variable কমান — traceback দরকার কি না ভেবে
পূর্ণ 2D table        দুই-row rolling       এক-row (ultra)         কয়েক variable
────────────────      ────────────────      ────────────────       ────────────────
dp[n+1][W+1]          prev[], cur[]         dp[W+1]                a, b, c
O(n·W) memory         O(2W)                 O(W)                   O(1)
traceback ✓           value only            value only             value only
knapsack/edit         edit distance         knapsack 0/1           fibonacci
reconstruction        (row রাখলে partial)   (উল্টো loop = 0/1)     সবচেয়ে সস্তা

নিয়ম: dp[i][...] কেবল dp[i-1][...] (বা dp[i-1][j-1])-এ নির্ভর করলেই
window কমানো যায় — dependency-ই memory-র চুক্তিপত্র।
Space ladder — dependency window যত সংকীর্ণ, memory তত কমে। প্রতিটা ধাপে traceback ক্ষমতার ক্ষতি লক্ষ্য করুন।

নিজে চালিয়ে দেখুন

EXPERIMENT

fib-এর stopwatch আর knapsack-এর memory-meter — সময় আর স্থান, দুটোই মাপুন

macOS / Linux (Python 3.10+)· ২০ মিনিট
import sys
import time
import tracemalloc


# ---------- part ১: fib timing ----------

sys.setrecursionlimit(10000)

def fib_naive(n):
    if n <= 1: return n
    return fib_naive(n - 1) + fib_naive(n - 2)

def fib_memo(n, _m={}):
    if n <= 1: return n
    if n not in _m:
        _m[n] = fib_memo(n - 1) + fib_memo(n - 2)
    return _m[n]


N = 35
t0 = time.perf_counter(); r1 = fib_naive(N); t1 = time.perf_counter() - t0
t0 = time.perf_counter(); r2 = fib_memo(N);  t2 = time.perf_counter() - t0
assert r1 == r2
print(f'fib({N}) naive : {r1:>10}  {t1*1000:>10.0f} ms')
print(f'fib({N}) memo  : {r2:>10}  {t2*1000:>10.3f} ms   speedup {t1/t2:>12,.0f}x')


# ---------- part ২: knapsack 2D vs 1D memory ----------

def knapsack_2d(items, W):
    n = len(items)
    dp = [[0] * (W + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        wt, val = items[i - 1]
        for w in range(W + 1):
            dp[i][w] = dp[i - 1][w]
            if wt <= w:
                dp[i][w] = max(dp[i][w], dp[i - 1][w - wt] + val)
    return dp[n][W]

def knapsack_1d(items, W):
    dp = [0] * (W + 1)
    for wt, val in items:
        for w in range(W, wt - 1, -1):      # reverse = 0/1 discipline
            dp[w] = max(dp[w], dp[w - wt] + val)
    return dp[W]


rng_items = [(i % 7 + 1, (i * 13) % 50 + 1) for i in range(200)]
W = 5000
assert knapsack_2d(rng_items, W) == knapsack_1d(rng_items, W)

tracemalloc.start()
v2 = knapsack_2d(rng_items, W)
_, peak2 = tracemalloc.get_traced_memory()      # (current, peak)!
tracemalloc.reset_peak()

v1 = knapsack_1d(rng_items, W)
_, peak1 = tracemalloc.get_traced_memory()
tracemalloc.stop()

print(f'\nknapsack n=200, W=5000 → best value {v2}')
print(f'  2D peak memory: {peak2/1024:>10,.0f} KB')
print(f'  1D peak memory: {peak1/1024:>10,.0f} KB   ratio {peak2/max(peak1,1):>6.1f}x')


# ---------- part ৩: coin change দুই প্রশ্ন ----------

coins = [1, 3, 4]

def min_coins(amount, coins):
    INF = float('inf')
    dp = [0] + [INF] * amount
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a and dp[a - c] + 1 < dp[a]:
                dp[a] = dp[a - c] + 1
    return dp[amount]

def greedy_coins(amount, coins):
    count, rem = 0, amount
    for c in sorted(coins, reverse=True):
        count += rem // c
        rem %= c
    return count if rem == 0 else float('inf')

for amt in (6, 11, 23):
    g, m = greedy_coins(amt, coins), min_coins(amt, coins)
    flag = '✓' if g == m else '✗ GREEDY FAILS'
    print(f'amount {amt:>2}: greedy={g} dp={m}  {flag}')

সাধারণ ফলাফল (laptop, Python 3.12):

fib(35) naive :  9227465     3061 ms
fib(35) memo  :  9227465    0.040 ms   speedup      76,661x

knapsack n=200, W=5000 → best value 5100
  2D peak memory:        8,320 KB
  1D peak memory:           44 KB   ratio  189.1x

amount  6: greedy=3 dp=2  ✗ GREEDY FAILS
amount 11: greedy=3 dp=3  ✓
amount 23: greedy=6 dp=6  ✓

পড়ার বিষয়:

১. fib-এর ~৭৭,০০০× speedup — একটা dict-ই φⁿ-কে n-এ নামিয়েছে। Overlapping subproblems থাকলে memo non-optional।

২. Knapsack-এর ~১৯০× memory saving — theory বলত n·W→W (এখানে 200×), বাস্তবে কাছাকাছি। বড় W-এ এই compression-ই feasible/infeasible-এর সীমানা ঠিক করে।

৩. Greedy amount 6-এ ব্যর্থ, কিন্তু 11 আর 23-এ ঠিক — এটাই সবচেয়ে বিপজ্জনক bug-প্রকৃতি: মাঝে মাঝে সঠিক greedy ভুল confidence শেখায়। একটা counterexample-ই claim ভাঙে; পরের লেসনের মূল থিসিস।

এটা কী প্রমাণ করে

Memoization naive-এর exponential সময়কে linear-এ নামায় (n=35-এ হাজার-গুণ gap), আর rolling-row compression knapsack-এর memory n·W থেকে W-এ নামিয়ে আনে — tracemalloc-এ সংখ্যায় ধরা পড়ে।

নিজে বানান

BUILD IT

Edit distance traceback + coin change দুই variant — দুইটা complete tool

Python · ●●●○○
  1. Full DP matrix ভরুন — insert/delete/replace তিন transition সহ
  2. Matrix print করে হাতে verify করুন ছোট উদাহরণে
  3. Traceback লিখুন: শেষ ঘর থেকে শুরুতে হেঁটে operation sequence বের করুন
  4. Alignment প্রিন্ট করুন — দুই লাইনে অক্ষর-বাই-অক্ষর match/replace/insert/delete
  5. Coin change-এর min-coins আর count-ways variant আলাদা function-এ লিখে test করুন

লক্ষ্য: দুই বাংলা শব্দের সম্পাদনা-পথ চোখে দেখা, আর coin change-এর দুই প্রশ্নের পার্থক্য কোডে বাঁধা।

def edit_distance_matrix(s1: str, s2: str):
    """পূর্ণ (len(s1)+1)x(len(s2)+1) matrix ফেরায় — traceback-এর জন্য"""
    d = [[0] * (len(s2) + 1) for _ in range(len(s1) + 1)]
    for i in range(len(s1) + 1):
        d[i][0] = i                      # s2 খালি: সব delete
    for j in range(len(s2) + 1):
        d[0][j] = j                      # s1 খালি: সব insert
    for i in range(1, len(s1) + 1):
        for j in range(1, len(s2) + 1):
            if s1[i - 1] == s2[j - 1]:
                d[i][j] = d[i - 1][j - 1]           # match — free
            else:
                d[i][j] = 1 + min(d[i - 1][j],      # delete from s1
                                  d[i][j - 1],      # insert into s1
                                  d[i - 1][j - 1])  # replace
    return d


def trace_alignment(s1: str, s2: str):
    """matrix + traceback → (distance, ops list, aligned pair)"""
    d = edit_distance_matrix(s1, s2)
    i, j = len(s1), len(s2)
    ops = []
    top, bot = [], []
    while i > 0 or j > 0:
        if i > 0 and j > 0 and s1[i - 1] == s2[j - 1]:
            ops.append('match'); top.append(s1[i - 1]); bot.append(s2[j - 1])
            i, j = i - 1, j - 1
        elif i > 0 and j > 0 and d[i][j] == d[i - 1][j - 1] + 1:
            ops.append(f'replace {s1[i-1]}→{s2[j-1]}')
            top.append(s1[i - 1]); bot.append(s2[j - 1])
            i, j = i - 1, j - 1
        elif i > 0 and d[i][j] == d[i - 1][j] + 1:
            ops.append(f'delete {s1[i-1]}')
            top.append(s1[i - 1]); bot.append('-')
            i -= 1
        else:
            ops.append(f'insert {s2[j-1]}')
            top.append('-'); bot.append(s2[j - 1])
            j -= 1
    return d[len(s1)][len(s2)], ops[::-1], (''.join(top[::-1]), ''.join(bot[::-1]))


def coin_min(amount: int, coins) -> int:
    dp = [float('inf')] * (amount + 1); dp[0] = 0
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a:
                dp[a] = min(dp[a], dp[a - c] + 1)
    return dp[amount]


def coin_count(amount: int, coins) -> int:
    dp = [1] + [0] * amount
    for c in coins:                       # coin outside = combinations
        for a in range(c, amount + 1):
            dp[a] += dp[a - c]
    return dp[amount]


if __name__ == '__main__':
    # ---------- bangla words ----------
    w1, w2 = 'বইখানা', 'বইটা'
    dist, ops, aligned = trace_alignment(w1, w2)
    print(f'"{w1}" → "{w2}"  distance = {dist}')
    for op in ops:
        print(f'   {op}')
    print(f'   {aligned[0]}')
    print(f'   {aligned[1]}')

    assert trace_alignment('kitten', 'sitting')[0] == 3
    assert trace_alignment('flaw', 'lawn')[0] == 2
    assert trace_alignment('', 'abc')[0] == 3

    # ---------- coin change ----------
    coins = [1, 5, 6, 9]

    def _ways(cs, i, amt):               # combination-counting oracle
        if amt == 0:
            return 1
        if amt < 0 or i == 0:
            return 0
        return _ways(cs, i - 1, amt) + _ways(cs, i, amt - cs[i - 1])

    def brute_min(amount):
        if amount == 0:
            return 0
        best = float('inf')
        for c in coins:
            if c <= amount:
                best = min(best, brute_min(amount - c) + 1)
        return best

    for amt in (0, 10, 11, 17):
        gm, gc = coin_min(amt, coins), coin_count(amt, coins)
        assert gm == brute_min(amt), (amt, gm)
        assert gc == _ways(coins, len(coins), amt), (amt, gc)
        print(f'amount {amt:>2}: min-coins={gm}  ways={gc} (oracle-matched)')

প্রত্যাশিত output:

"বইখানা" → "বইটা"  distance = 3
   match
   match
   delete খ
   delete া
   replace ন→ট
   match
   বইখানা
   বই--টা
amount  0: min-coins=0  ways=1 (oracle-matched)
amount 10: min-coins=2  ways=5 (oracle-matched)
amount 11: min-coins=2  ways=6 (oracle-matched)
amount 17: min-coins=3  ways=12 (oracle-matched)

নিজে বাড়ান:

১. Operation cost ভিন্ন করুন (replace=1.5, insert/delete=1) — weighted edit distance; spell-checker-এ keyboard-distance cost যোগ করুন। ২. LCS (longest common subsequence)-এ adapt করুন — diff tools-এর মূল ইঞ্জিন; alignment-এ match-only প্রিন্ট। ৩. coin_min-এ reconstruction যোগ করুন — কোন কোন coin নেওয়া হলো parent-pointer array দিয়ে।

বাস্তব সিস্টেমে

DP বাস্তবে কোথায়

Bioinformatics। DNA/protein sequence alignment — edit distance-এরই scoring-যুক্ত রূপ (Smith-Waterman local, Needleman-Wunsch global); BLAST-এর heuristic shortcut এসেও exact alignment-এর ভিত্তি এই matrix।

Diff আর resource allocation। git diff-এর ভেতরে Myers algorithm — LCS-variant DP, দুই file-এর ন্যূনতম edit script এই traceback-ই। Knapsack family সরাসরি business-এ: ad-budget across campaigns, cloud instance sizing, cargo loading; 0/1 বনাম unbounded-এর সিদ্ধান্ত বাস্তব rule।

Speech আর NLP। Speech recognition-এর Viterbi algorithm — HMM-এর ওপর DP; spelling correction-এ edit distance; machine translation-এর decoding DP-family।

Finance আর compilers। Option pricing-এর binomial lattice — backward-induction DP; inventory management-এর multi-stage optimization; register allocation (graph coloring-এর DP variants), image seam-carving (energy DP) — Photoshop-এর content-aware resize-এর ভেতরটা একটা সরল vertical-path DP।

যে ভুলগুলো সবাই করে

“DP মানেই দ্রুত — যেকোনো সমস্যায় DP apply করলেই performance আসবে।”

DP শুধু overlapping-subproblem অপচয় দূর করে — মূল state-space-এর size যদি exponential হয় (যেমন TSP-এর 2ⁿ states), DP তবু exponential-ই থাকে (TSP-DP: O(n²2ⁿ) — brute force n!-এর তুলনায় ভালো, কিন্তু polynomial নয়)। সঠিক বাক্য: DP খরচকে state-space-এর size × transition-cost-এ নামায়; state-space polynomial না হলে জাদু নেই।

“Top-down আর bottom-up-এর asymptotic খরচ আলাদা হতে পারে।”

Recurrence এক হলে reachable-state set-ও এক — খরচ একই Θ। পার্থক্য constant-factor আর feasibility-তে: top-down lazy (দরকারি state-ই), কিন্তু Python-এ recursion-limit আর hash overhead; bottom-up eager কিন্তু array-fast আর stack-free। বড় input-এ bottom-up practical, তত্ত্বে সমান।

“Knapsack O(nW) — তাহলে pseudo-polynomial মানে polynomial।”

W একটা number, input-এর length না — W লিখতে লাগে log W bits। Input size N = n + log W-এর হিসেবে O(nW) exponential (W=2^(log W))। এজন্যই নাম pseudo-polynomial: সংখ্যা ছোট হলে বাস্তবে দ্রুত, কিন্তু NP-hardness অটুট — W-এর digit বাড়ালে খরচ বিস্ফোরিত।

“Greedy-ও তো optimal দেয় অনেক সময় — তাহলে DP-র ভারী machinery কেন?”

‘অনেক সময়’-এর মধ্যে কোথায় ব্যর্থ হয় তা আগাম বলা যায় না — coin-set {1,3,4} amount 6-এ greedy ৩ দেয়, সঠিক ২ (আজকের experiment)। Greedy ব্যবহারের অনুমতি তখনই যখন প্রমাণ আছে (পরের লেসনের exchange argument)। প্রমাণ না থাকলে DP-ই correct-by-construction পথ — ধীর হতে পারে, ভুল হয় না।

বুঝেছেন কি না দেখুন

1

DP-র দুইটা পূর্বশর্ত কী কী? প্রতিটার একটা counterexample দিন — কোন সমস্যা কোন শর্ত ভাঙে।

স্মরণ

শর্ত ১ overlapping subproblems — ভাঙে mergesort: subproblem unique, memo শূন্য লাভ (divide-and-conquer যথেষ্ট)। শর্ত ২ optimal substructure — ভাঙে longest simple path: পুরো path-এর ভেতরের segment নিজের দুই endpoint-এর মধ্যে longest নাও হতে পারে, তাই DP state সংরক্ষণ করতে পারে না।

2

coin_min-এ dp[0]=0 কেন? এটা ভুল করে dp[0]=1 লিখলে amount 5, coins {1,5}-এ উত্তর কী হবে?

প্রয়োগ

dp[0]=0 মানে: amount শূন্য বানাতে শূন্যটা coin — induction-এর base। dp[0]=1 দিলে recurrence ধরে নেয় শূন্য amount-এও একটা coin খরচ — প্রতিটা chain-এ একটা phantom coin যোগ হয়: amount 5-এ উত্তর আসবে 2 (সঠিক 1)। Base case-এর off-by-one পুরো table দূষিত করে — এজন্যই state-এর অর্থ আগে লেখা বাধ্যতামূলক।

3

count_ways-এ loop-order swap করলে (amount বাইরে, coin ভেতরে) উত্তর কেন বদলে যায়? amount=3, coins={1,2} দিয়ে দেখান।

যুক্তি

Coin-outside: {1,2}-এর সব subset আগে সেট হয় — combination: 2 উপায় ({1,1,1},{1,2})। Amount-outside: প্রতিটা amount-এ সব coin ক্রমে try করে — permutation গণনা: {1,2} আর {2,1} আলাদা — 3 উপায় ({1,1,1},{1,2},{2,1})। Loop-order ঠিক করে দেয় কোন dimension ‘selected so far’ আর কোনটা ‘next choice’ — semantics-ই structure-এ।

4

Spell-checker-এর জন্য ১০⁵-word dictionary-তে প্রতিটা typo-র জন্য edit distance হিসাব করা ব্যয়বহুল। Design sketch দিন — কোন DP-insight কাজে লাগবে?

ডিজাইন

Insight ১: early termination — distance band: যদি |len(s1)-len(s2)| greater than k হয় skip; banded DP (Ukkonen) শুধু diagonal-band হিসাব করে O(k·min(m,n))। Insight ২: prefix pruning — dictionary trie-তে organize করে common-prefix reuse (একই prefix-এর সব word-এর matrix-row share)। Insight ৩: BK-tree/n-gram indexing দিয়ে candidate shortlist, তারপর exact banded DP শুধু candidate-দের ওপর। Production spell-checker তিনটার hybrid।

5

0/1 knapsack-এর 1D compression-এ inner loop উল্টো চলে কেন? Unbounded knapsack-এ সোজা চললে কী হয়?

স্মরণ

উল্টো চললে dp[w-wt] এখনো current item processing-এর আগের মান (আগের item-দের world) ধরে রাখে — item একবারই নেওয়া যায়। সোজা চললে dp[w-wt] ইতিমধ্যে current item-সহ updated — একই item বারবার নেওয়া হয়: unbounded knapsack-এর সঠিক code হয়ে যায়! Loop-direction-ই constraint encode করে।

6

Edit distance-এর traceback-এ একাধিক valid alignment থাকতে পারে কি? কখন, আর code কোনটা বেছে নেয়?

যুক্তি

হ্যাঁ — tie হলে (delete আর replace দুটোই একই total দেয়) multiple optimal paths থাকে। Example: ‘ab’→‘ba’: delete-a-insert-a বা replace-ab→ba — distance 2-ই। Code deterministic tie-break করে: match first, তারপর replace, delete, insert priority — তাই একটা নির্দিষ্ট alignment আসে। Bioinformatics-এ সব optimal alignments চাইলে backtracking branching করতে হয়।

এরপর কী

এরপর কী

DP সবসময় optimal, কিন্তু মাঝে মাঝে অপ্রয়োজনীয়ভাবে ভারী — যেসব সমস্যায় একটা স্থানীয় সিদ্ধান্তই global optimum-এর guarantee দিতে পারে, সেখানে greedy হাজার গুণ দ্রুত। প্রশ্নটা তখন proof-এর: কখন local সিদ্ধান্তে global জয় নিশ্চিত? পরের লেসনে greedy choice property, exchange argument-এর step-by-step প্রমাণ-কৌশল, Kruskal-এর প্রমাণিত greedy, আর Huffman coding-এর পূর্ণ walkthrough — সাথে greedy কখন approximation মাত্র তার honesty। আজকের coin-set {1,3,4} ফাঁদটা তখন formal counterexample culture হয়ে উঠবে।

আরও পড়ুন

  • Introduction to Algorithms (CLRS), Chapters 14 ও 15 — Dynamic Programming — Thomas H. Cormen et al. · LCS, rod cutting, matrix-chain — state design আর traceback-এর প্রামাণ্য পাঠ
  • Algorithms, Chapter 6 — Dynamic Programming — S. Dasgupta, C. Papadimitriou, U. Vazirani · DP-কে 'recursion + careful brute force' বলা উপস্থাপনা — এই লেসনের চেতনার কাছে