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

ডায়নামিক প্রোগ্রামিং — একই হিসাব দুবার নয়

Dynamic Programming — Memoization, Tabulation, Coin Change, Knapsack & Edit Distance

Taught in Bangla, this lesson introduces dynamic programming: overlapping subproblems and optimal substructure, memoization and tabulation, a five-question DP recipe, and classic problems including coin change, 0/1 knapsack, LCS and edit distance.

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

  • কোন সমস্যা DP দিয়ে সমাধান করা যায়, আর Divide & Conquer থেকে এর আসল পার্থক্য কোথায়
  • Fibonacci-কে তিন উপায়ে — naive, memoization, tabulation — আর শেষে মাত্র দুটি ভ্যারিয়েবলে
  • যেকোনো DP সমস্যার জন্য পাঁচ ধাপের একটি রেসিপি
  • তিনটি ক্লাসিক সমস্যা: সবচেয়ে কম কয়েন, ব্যাগ গোছানো (knapsack) আর দুটি শব্দের দূরত্ব (edit distance)
  • AI-এর লেখা DP কোডের সাধারণ ভুল, আর সেগুলো ধরার পরীক্ষা

১ · একই হিসাব দুবার কেন? — DP-র মূল ভাবনা

একটি পরিচিত দৃশ্য দিয়ে শুরু করি। ক্লাসে স্যার বোর্ডে লিখলেন: ১ + ১ + ১ + ১ + ১ + ১ + ১ + ১ = ? আপনি গুনে বললেন ৮. এবার স্যার বাঁ দিকে আরও একটি “১ +” যোগ করলেন। আপনি কি আবার শুরু থেকে গুনবেন? না — আপনি বলবেন “আগে ছিল ৮, আরেকটা যোগ হলো, তাহলে ৯.” আপনি আগের উত্তরটি মনে রেখেছিলেন, তাই নতুন করে গুনতে হয়নি।

এই সাধারণ বুদ্ধিটুকুই dynamic programming (সংক্ষেপে DP)-এর প্রাণ। নামটা ভারী শোনালেও ভেতরের কথাটি সহজ: যে হিসাব একবার করেছেন, সেটি আবার করবেন না — লিখে রাখুন।

DP = ছোট উপ-সমস্যায় ভাঙা + প্রতিটির উত্তর একবার হিসাব করে মনে রাখা

পর্ব ১৫-এ recursion শেখার সময় আপনি দেখেছিলেন, fib(5)-এর মতো ছোট হিসাবেও একই fib(2) বারবার হিসাব হয়, আর memo টেবিল রাখলে ডাকের সংখ্যা অনেক কমে যায়। সেটি ছিল DP-র প্রথম ঝলক। আজ আমরা সেই ঝলককে একটি পূর্ণ কৌশলে পরিণত করব — যেটি দিয়ে টাকার হিসাব, ব্যাগ গোছানো, বানান শোধরানো, এমনকি GPS-এর পথ খোঁজার মতো সমস্যাও সমাধান হয়।

DP চেনার দুটি লক্ষণ

🔁 একই উপ-সমস্যা বারবার আসে

বড় সমস্যা ভাঙলে যে ছোট সমস্যাগুলো পাওয়া যায়, সেগুলোর অনেকগুলোই একই। fib(5) আর fib(4) দুজনেরই fib(3) লাগে। একে বলে overlapping subproblems. এটি না থাকলে মনে রাখার কিছু নেই, DP-র লাভও নেই।

লক্ষণ ১ · মনে রাখার মতো কিছু আছে

🧩 ছোট সেরা দিয়ে বড় সেরা

বড় সমস্যার সেরা উত্তরের ভেতরে ছোট সমস্যার সেরা উত্তর লুকিয়ে থাকে। ঢাকা থেকে চট্টগ্রামের সবচেয়ে ছোট পথ যদি কুমিল্লা হয়ে যায়, তবে তার ঢাকা–কুমিল্লা অংশটুকুও অবশ্যই সবচেয়ে ছোট। একে বলে optimal substructure.

লক্ষণ ২ · ছোট উত্তর জুড়ে বড় উত্তর

Divide & Conquer-এর সাথে পার্থক্য

পর্ব ৪০ আর ৪১-এ আপনি divide and conquer দেখেছেন — merge sort আর binary search-এ সমস্যাকে ভাগ করা হয়, ভাগগুলো আলাদাভাবে সমাধান হয়। DP-ও সমস্যা ভাগ করে। তাহলে পার্থক্য কোথায়? পার্থক্য হলো ভাগগুলো একে অপরের সাথে মেলে কি না।

বিষয়Divide & ConquerDynamic Programming
উপ-সমস্যাএকে অপর থেকে স্বাধীন — বাঁ অর্ধেক আর ডান অর্ধেকের মধ্যে কোনো মিল নেইবারবার একই উপ-সমস্যা ফিরে আসে
মনে রাখা লাগে?না — প্রতিটি টুকরো একবারই আসেহ্যাঁ — এটিই পুরো কৌশলের প্রাণ
উদাহরণMerge sort, binary searchFibonacci, coin change, knapsack, edit distance
মেমরি খরচসাধারণত কমটেবিলের জন্য বাড়তি মেমরি লাগে

“Dynamic programming” নামটি দেন মার্কিন গণিতবিদ Richard Bellman, ১৯৫০-এর দশকে, যখন তিনি RAND Corporation-এ কাজ করতেন। মজার ব্যাপার হলো, এখানে “programming” মানে কম্পিউটারের কোড লেখা নয় — তখনকার ভাষায় এর মানে ছিল পরিকল্পনা বা সময়সূচি তৈরি (যেমন “linear programming”). Bellman নিজে তাঁর আত্মজীবনীতে লিখেছেন, তখনকার একজন উচ্চপদস্থ সরকারি কর্মকর্তা “research” শব্দটিই পছন্দ করতেন না। তাই তিনি এমন একটি নাম বেছেছিলেন যা শুনতে প্রভাবশালী, আর যাকে কেউ খারাপ অর্থে ব্যবহার করতে পারবে না — “dynamic”. অর্থাৎ নামটির পেছনে গণিতের চেয়ে অফিসের রাজনীতিই বেশি ছিল!

২ · Fibonacci তিন উপায়ে — অপচয় থেকে দুটি ভ্যারিয়েবল

Fibonacci ধারা মনে করুন: 0, 1, 1, 2, 3, 5, 8, 13 … প্রতিটি সংখ্যা আগের দুটির যোগফল। সংজ্ঞাটি সরাসরি রিকার্শনে লেখা যায়: fib(n) = fib(n − 1) + fib(n − 2), আর base case fib(0) = 0, fib(1) = 1. সমস্যা হলো, এভাবে লিখলে একই কাজ অবিশ্বাস্য রকম বারবার হয়।

নিচের ডায়াগ্রামে একই প্রশ্ন — fib(5) — তিনভাবে সমাধান হচ্ছে। প্রথমে সরল রিকার্শন, তারপর memo টেবিলসহ রিকার্শন, তারপর নিচ থেকে টেবিল ভরা। প্রতিটি ধাপে ডান দিকের “হিসাব” অংশে কাজের পরিমাণ খেয়াল করুন:

fib(5)
fib(4)
fib(3)
fib(3)
fib(2)
fib(2)
fib(1)
fib(2)
fib(1)
fib(1)
fib(0)
fib(1)
fib(0)
fib(1)
fib(0)
fib(5)
fib(4)
fib(3)memo থেকে
fib(3)
fib(2)memo থেকে
fib(2)
fib(1)
fib(1)
fib(0)
dp[0]
dp[1]
dp[2]
dp[3]
dp[4]
dp[5]
হিসাব
পদ্ধতি
মোট কাজ
মেমরি
  1. সরল রিকার্শন। fib(5) নিজের উত্তর জানে না, তাই fib(4) আর fib(3)-কে ডাকে; ওরা আবার দুজন করে ডাকে। সবুজগুলো base case. পুরো call tree-তে মোট ১৫টি ডাক।
  2. অপচয় চিনুন। লাল দাগের ঘরগুলো দেখুন: fib(3) হিসাব হয়েছে ২ বার, fib(2) ৩ বার। অথচ আলাদা প্রশ্ন ছিল মাত্র ৬টি — fib(0) থেকে fib(5). n বাড়লে গাছটি প্রায় দ্বিগুণ হারে চওড়া হয়; তাই এই পদ্ধতির সময় exponential.
  3. Memo টেবিলসহ রিকার্শন। এবার প্রতিটি উত্তর প্রথমবার বের হলেই টেবিলে লিখে রাখা হয়। ওপর থেকে প্রশ্ন শুরু হয়ে নিচে নামে, তাই একে বলে top-down. গাছটি প্রায় একটি সরলরেখা হয়ে গেছে — মোট ৯টি ডাক।
  4. টেবিল থেকে সরাসরি। হলুদ-লাল দুটি ঘর নতুন করে হিসাব হয়নি — fib(4)-এর দরকার হলো fib(2), কিন্তু fib(3) হিসাবের সময়ই সেটি টেবিলে উঠে গেছে। প্রতিটি fib(k) ঠিক একবার হিসাব হলো। এটিই memoization.
  5. নিচ থেকে ওপরে — tabulation. এবার রিকার্শন বাদ। একটি array dp[] নিলাম, যেখানে dp[i] মানে fib(i). প্রথমে দুটি base case বসালাম: dp[0] = 0, dp[1] = 1.
  6. dp[2] = dp[1] + dp[0] = 1. বাঁ থেকে ডানে এগোচ্ছি। প্রতিটি ঘর ভরার সময় তার দরকারি দুটি ঘর আগেই ভরা আছে — এটিই নিচ থেকে ওপরে যাওয়ার সুবিধা।
  7. dp[3] = dp[2] + dp[1] = 2. কোনো function call নেই, কোনো stack নেই — শুধু একটি সাধারণ for লুপ।
  8. dp[4] = dp[3] + dp[2] = 3.
  9. dp[5] = dp[4] + dp[3] = 5. উত্তর পাওয়া গেল শেষ ঘরে। মোট ৪ বার লুপ — n বাড়লে কাজ বাড়ে ঠিক n-এর সমান হারে।
  10. দুটি ভ্যারিয়েবলই যথেষ্ট। খেয়াল করুন, প্রতিটি ঘর শুধু ঠিক আগের দুটি ঘর দেখে। তাহলে পুরো টেবিল রাখার দরকার কী? শুধু শেষ দুটি সংখ্যা a আর b রাখলেই চলে — বাকিগুলো ভুলে যাওয়া যায়। মেমরি নেমে এলো মাত্র দুটি সংখ্যায়।

চারটি পদ্ধতির তুলনা এক টেবিলে:

পদ্ধতিসময়বাড়তি মেমরিমন্তব্য
সরল রিকার্শনexponential (মোটামুটি 1.6ⁿ)stack-এ n তলাn = 50-এই প্রায় ৪,০০০ কোটি ডাক
Memoization (top-down)n-এর সমানুপাতিকটেবিল + stackসংজ্ঞা থেকে প্রায় হুবহু লেখা যায়
Tabulation (bottom-up)n-এর সমানুপাতিকটেবিলরিকার্শন নেই, stack overflow-এর ভয় নেই
দুটি ভ্যারিয়েবলn-এর সমানুপাতিকধ্রুব — মাত্র ২টি সংখ্যাযখন শুধু শেষ কয়েকটি ঘর লাগে

এবার নিজে মেপে দেখুন। নিচের কোডে সরল আর memo — দুই ভার্সনেই একটি counter আছে, যা গোনে ফাংশনটি মোট কতবার ডাকা হলো। শেষ লাইনে দুটি ভ্যারিয়েবল দিয়ে fib(90) বের করা হয়েছে:

#include <stdio.h>
long long calls = 0, memo[91];        /* memo[n] == 0 : not computed yet */
long long fib_naive(int n) {
    calls++; if (n <= 1) return n;
    return fib_naive(n - 1) + fib_naive(n - 2);
}
long long fib_memo(int n) {
    calls++; if (n <= 1) return n;
    if (memo[n] != 0) return memo[n];          /* answer already written */
    long long x = fib_memo(n - 1);
    return memo[n] = x + fib_memo(n - 2);
}
int main(void) {
    long long r = fib_naive(32);
    printf("naive : fib(32) = %lld, calls = %lld\n", r, calls);
    calls = 0; r = fib_memo(32);
    printf("memo  : fib(32) = %lld, calls = %lld\n", r, calls);
    long long a = 0, b = 1;                    /* fib(0), fib(1) */
    for (int i = 0; i < 90; i++) { long long c = a + b; a = b; b = c; }
    printf("2 vars: fib(90) = %lld\n", a);
    return 0;
}

আউটপুটে দেখবেন, fib(32) = 2178309 বের করতে সরল রিকার্শন ফাংশনটি ডেকেছে ৭০ লাখেরও বেশি বার (7049155), আর memo ভার্সন মাত্র ৬৩ বার। দুটির উত্তর হুবহু এক — বদলেছে শুধু কাজের পরিমাণ। কয়েকটি খুঁটিনাটি খেয়াল করুন:

  • memo[] একটি global array, তাই শুরুতে সব ঘর 0. যেহেতু n ≥ 1 হলে fib(n) কখনো 0 হয় না, এখানে “0 মানে এখনো হিসাব হয়নি” ধরে নেওয়া নিরাপদ। অন্য সমস্যায় 0 আসল উত্তর হতে পারলে আলাদা চিহ্ন (যেমন −1) লাগবে।
  • x-এ আগে fib(n − 1) রেখে তারপর fib(n − 2) ডাকা হয়েছে। C-তে f() + g()-এর মধ্যে কোনটি আগে চলবে, নিয়মে নির্দিষ্ট নেই। উত্তর একই থাকত, কিন্তু counter-এর সংখ্যা compiler-ভেদে বদলে যেতে পারত।
  • fib(90) প্রায় ২.৮৮ × ১০১৮ — int-এ ধরবে না, তাই long long. fib(92)-এর পরে long long-ও overflow করবে।

Memoization-এ রিকার্শন থেকেই যায়। fib_memo(100000) ডাকলে একটির ভেতর আরেকটি — এক লাখ তলা গভীর call stack তৈরি হবে, আর প্রোগ্রাম stack overflow-এ ক্র্যাশ করতে পারে। Bottom-up লুপে এই ঝুঁকি নেই। এটিই বাস্তবে tabulation বেশি পছন্দ করার বড় কারণ।

৩ · DP-র রেসিপি — পাঁচটি প্রশ্নের উত্তর লিখুন

Fibonacci সহজ, কারণ সূত্রটা আগেই দেওয়া। আসল সমস্যায় সূত্রটি আপনাকেই খুঁজে বের করতে হয়। অভিজ্ঞ প্রোগ্রামাররা কোড লেখার আগে খাতায় পাঁচটি প্রশ্নের উত্তর লেখেন। এই পাঁচটি পরিষ্কার হলে কোড প্রায় নিজে থেকেই লেখা হয়ে যায়।

প্রশ্নকী লিখবেনFibonacci-তে
১. State — একটি ঘরের অর্থ কী?বাংলায় পুরো এক বাক্যে: “dp[i] মানে …”dp[i] = i-তম Fibonacci সংখ্যা
২. Transition — একটি ঘর অন্য ঘর থেকে কীভাবে আসে?একটি সূত্র, আর কেন সেটি ঠিকdp[i] = dp[i − 1] + dp[i − 2]
৩. Base case — কোন ঘরগুলো সরাসরি জানা?যেখানে সূত্র খাটে নাdp[0] = 0, dp[1] = 1
৪. ক্রম — কোন ঘর আগে ভরবেন?যাতে দরকারি ঘর সবসময় আগে ভরা থাকেi = 2, 3, 4 … ছোট থেকে বড়
৫. উত্তর — কোন ঘরে?কখনো শেষ ঘর, কখনো সব ঘরের সর্বোচ্চdp[n]
মূল ধারণা

সবচেয়ে কঠিন, আর সবচেয়ে গুরুত্বপূর্ণ ধাপ হলো প্রথমটি — state-এর অর্থ এক বাক্যে লেখা। “dp[x] মানে x টাকা মেলাতে সবচেয়ে কম কয়টি কয়েন লাগে” — এমন একটি পরিষ্কার বাক্য লিখতে না পারলে transition কখনোই ঠিক হবে না। DP-র বেশিরভাগ ভুল আসলে এখানেই শুরু হয়: ঘরটির অর্থ মাথায় ঝাপসা থাকে।

Top-down নাকি bottom-up?

একই রেসিপি দুইভাবে কোডে বসানো যায়। দুটিই সমান “DP” — পার্থক্য শুধু কোন দিক থেকে হাঁটছেন।

⬇️ Top-down (memoization)

বড় প্রশ্ন দিয়ে শুরু, রিকার্শনে নিচে নামা, উত্তর পেলেই memo-তে লেখা। সূত্র থেকে সরাসরি লেখা যায়, আর শুধু যে ঘরগুলো সত্যিই দরকার সেগুলোই হিসাব হয়। কিন্তু রিকার্শনের stack-খরচ আছে।

যখন অনেক ঘর আসলে লাগেই না

⬆️ Bottom-up (tabulation)

সবচেয়ে ছোট ঘর থেকে শুরু করে লুপ দিয়ে টেবিল ভরা। দ্রুত, stack overflow নেই, আর মেমরি কমানোর কৌশল (শুধু শেষ সারি রাখা) সহজে খাটে। কিন্তু সঠিক ক্রম আপনাকেই ঠিক করতে হয়।

বাস্তবের বেশিরভাগ DP কোড

পরীক্ষার আগের রাতে দুই বন্ধুর পড়ার ধরন ভাবুন। রাফি প্রশ্নপত্রের শেষ অঙ্কটি ধরল; দেখল এটির জন্য একটি সূত্র লাগবে, সেটির জন্য আরেকটি — এভাবে পিছিয়ে গিয়ে যা লাগল শুধু তা-ই পড়ল, আর প্রতিটি সূত্র খাতার কোণে লিখে রাখল। এটি top-down. মিতু প্রথম অধ্যায় থেকে শুরু করে ক্রমানুসারে প্রতিটি সূত্র শিখে শিখে শেষ অধ্যায়ে পৌঁছাল। এটি bottom-up. দুজনেই শেষ অঙ্কটি পারবে — একজন কম পড়ে, অন্যজন বেশি গোছানোভাবে।

৪ · Coin change — সবচেয়ে কম কয়েনে টাকা মেলানো

চায়ের দোকানে বসে আছেন। দোকানদারকে ১৮ টাকা ভাংতি ফেরত দিতে হবে। হাতে আছে ১, ২, ৫ টাকার কয়েন আর ১০ টাকার নোট — প্রতিটি যত খুশি। সবচেয়ে কম কয়টি কয়েন-নোটে ফেরত দেওয়া যায়? স্বাভাবিক বুদ্ধি বলে: সবচেয়ে বড়টা আগে নিন — ১০, তারপর ৫, তারপর ২, তারপর ১. মোট ৪টি। এই “এই মুহূর্তে সবচেয়ে বড়টা নিন” কৌশলকে বলে greedy, যা পরের পর্বে বিস্তারিত দেখবেন। বাংলাদেশের টাকার এই সেটে greedy সবসময় সঠিক উত্তর দেয়।

কিন্তু ধরুন কোনো এক দেশে কয়েন আছে শুধু ১, ৩ আর ৪ টাকার। ৬ টাকা মেলাতে হবে। Greedy নেবে প্রথমে ৪, বাকি ২ টাকা — ৩ নেওয়া যাবে না, তাই ১ + ১. মোট ৩টি কয়েন (৪ + ১ + ১)। অথচ ৩ + ৩ = ৬ — মাত্র ২টি কয়েনেই হয়! “এখনকার সেরা” বেছে নেওয়া সবসময় “সব মিলিয়ে সেরা” দেয় না। DP সব সম্ভাবনা গুছিয়ে দেখে, তাই কখনো ভুল করে না।

রেসিপি অনুযায়ী

  • State: dp[x] = ঠিক x টাকা মেলাতে সবচেয়ে কম কয়টি কয়েন লাগে।
  • Transition: x টাকা মেলানোর শেষ কয়েনটি কোনো একটি c টাকার কয়েন হবেই। তাহলে বাকি x − c টাকা আগেই সবচেয়ে কম কয়েনে মেলানো থাকতে হবে। তাই dp[x] = সব c-এর জন্য (dp[x − c] + 1)-এর মধ্যে সবচেয়ে ছোটটি।
  • Base case: dp[0] = 0 — শূন্য টাকা মেলাতে কোনো কয়েন লাগে না।
  • ক্রম: x = 1, 2, 3 … ছোট থেকে বড়, কারণ dp[x] সবসময় ছোট ঘর দেখে।
  • উত্তর: dp[amount]; কোনোভাবেই মেলানো না গেলে −1.

এবার {১, ৩, ৪} কয়েন দিয়ে ৬ টাকার টেবিল ঘরে ঘরে ভরে দেখুন। প্রতিটি ঘরে তিনটি সম্ভাবনা যাচাই হচ্ছে — শেষ কয়েন ১, ৩ না ৪:

0
1
2
3
4
5
6
dp[x]
শেষ কয়েন
এই ঘরের তিনটি সম্ভাবনা
এখন যে ঘর
শেষ কয়েন 1
শেষ কয়েন 3
শেষ কয়েন 4
সবচেয়ে কম
  1. Base case. dp[0] = 0. বাকি সব ঘর এখনো অজানা (?)। এই একটি ঘর থেকেই পুরো টেবিল গড়ে উঠবে।
  2. ১ টাকা। শুধু ১ টাকার কয়েন খাটে (৩ বা ৪ তো ১-এর চেয়ে বড়)। dp[1] = dp[0] + 1 = 1.
  3. ২ টাকা। আবার শুধু ১ টাকার কয়েন: dp[2] = dp[1] + 1 = 2 (অর্থাৎ ১ + ১)। খেয়াল করুন — dp[1]-এর উত্তর আবার হিসাব করিনি, টেবিল থেকে নিয়েছি।
  4. ৩ টাকা — প্রথম আসল প্রতিযোগিতা। শেষ কয়েন ১ হলে dp[2] + 1 = 3; শেষ কয়েন ৩ হলে dp[0] + 1 = 1. ছোটটি জিতল: dp[3] = 1.
  5. ৪ টাকা। তিনটি সম্ভাবনা: 2, 2, আর ৪ টাকার কয়েন সরাসরি — 1. dp[4] = 1.
  6. ৫ টাকা। শেষ কয়েন ১ → dp[4] + 1 = 2; ৩ → dp[2] + 1 = 3; ৪ → dp[1] + 1 = 2. সবচেয়ে কম 2 (৪ + ১)। সমান হলে আগে পাওয়াটি রেখেছি।
  7. ৬ টাকা — greedy যেখানে হারে। শেষ কয়েন ১ → 3; শেষ কয়েন ৩ → dp[3] + 1 = 2; শেষ কয়েন ৪ → dp[2] + 1 = 3. Greedy শুধু “৪” রাস্তাটিই দেখত, আর পেত 3. DP তিনটি রাস্তাই দেখে, তাই পায় 2.
  8. কোন কোন কয়েন? সংখ্যা জানা গেল, কিন্তু কয়েনগুলো কী? “শেষ কয়েন” সারি ধরে পিছিয়ে যান: ৬-এর শেষ কয়েন ৩, বাকি ৩; ৩-এর শেষ কয়েন ৩, বাকি ০. উত্তর: ৩ + ৩. প্রতিটি ঘরে “কোন পছন্দ জিতেছিল” লিখে রাখলে এভাবে পুরো সমাধান ফিরে পাওয়া যায় — একে বলে traceback.

এই সমস্যার নাম coin change. নিচের কোডে ঠিক এই টেবিলটাই লুপ দিয়ে ভরা হয়েছে, আর তিনটি পরীক্ষা চালানো হয়েছে — বাংলাদেশের টাকা, {১, ৩, ৪}, আর এমন একটি সেট যেখানে মেলানোই অসম্ভব:

#include <stdio.h>
#define INF 1000000                       /* "not possible" marker */

int min_coins(const int coins[], int k, int amount) {
    int dp[amount + 1];                   /* dp[x] = fewest coins for x */
    dp[0] = 0;                            /* base case */
    for (int x = 1; x <= amount; x++) {
        dp[x] = INF;
        for (int j = 0; j < k; j++)
            if (coins[j] <= x && dp[x - coins[j]] + 1 < dp[x])
                dp[x] = dp[x - coins[j]] + 1;
    }
    return dp[amount] >= INF ? -1 : dp[amount];
}
int main(void) {
    int bd[] = {1, 2, 5, 10}, odd[] = {1, 3, 4}, big[] = {5, 10};
    printf("BD money, 18 : %d\n", min_coins(bd, 4, 18));
    printf("{1,3,4},   6 : %d\n", min_coins(odd, 3, 6));
    printf("{5,10},    7 : %d\n", min_coins(big, 2, 7));
    return 0;
}

উত্তর আসবে 4, 2 আর −1. শুধু ৫ আর ১০ দিয়ে ৭ টাকা কখনোই মেলানো যায় না — তাই dp[7] শেষ পর্যন্ত INF-ই থেকে যায়, আর ফাংশন −1 ফেরত দেয়। দুটি সূক্ষ্ম বিষয়:

  • INF হিসেবে ইচ্ছে করে মাঝারি একটি সংখ্যা (১০ লাখ) নেওয়া হয়েছে। INT_MAX নিলে dp[...] + 1 করার সময় overflow হয়ে ঋণাত্মক সংখ্যা হয়ে যেত, আর “অসম্ভব” ঘরটিকেই সবচেয়ে ভালো মনে হতো! AI-এর কোডে এটি খুব সাধারণ একটি ভুল।
  • দুটি লুপ মিলে কাজ হয় amount × কয়েনের সংখ্যা — ১৮ × ৪ = ৭২টি তুলনা। সব সম্ভাব্য কয়েন-সমন্বয় আলাদা করে গুনতে গেলে এর চেয়ে বহু গুণ বেশি কাজ লাগত।

যে কয়েন-সেটে greedy সবসময় সঠিক উত্তর দেয়, তাকে বলে “canonical” সেট। পৃথিবীর বেশিরভাগ দেশের মুদ্রা এভাবেই নকশা করা, যাতে দোকানদার মাথা না খাটিয়েই সবচেয়ে কম কয়েন ফেরত দিতে পারেন। কিন্তু প্রোগ্রামে কয়েনের সেট যখন ব্যবহারকারী দেন — যেমন একটি গেমের পয়েন্ট, বা কোনো কুরিয়ারের প্যাকেটের সাইজ — তখন greedy-র ওপর ভরসা করা যায় না। পর্ব ৪৩-এ দেখবেন কখন greedy নিরাপদ, আর কীভাবে তা প্রমাণ করতে হয়।

৫ · 0/1 Knapsack — ঈদের বাসে ব্যাগ গোছানো

ঈদের আগে বাসে করে গ্রামের বাড়ি যাচ্ছেন। বাসের ছাদে জায়গা নেই, হাতে রাখা ব্যাগে বড়জোর ৬ কেজি নেওয়া যাবে। বাড়ির জন্য তিনটি জিনিস কেনা আছে, প্রতিটির ওজন আর পরিবারের কাছে তার “মূল্য-পয়েন্ট” আলাদা:

জিনিসওজনমূল্য-পয়েন্টপ্রতি কেজিতে মূল্য
মায়ের জন্য রাইস কুকার৪ কেজি৭১.৭৫
শাড়ি আর পাঞ্জাবি৩ কেজি৫প্রায় ১.৬৭
ভাইবোনের জামা৩ কেজি৪প্রায় ১.৩৩

প্রতিটি জিনিস হয় পুরো নেবেন, নয়তো নেবেনই না — অর্ধেক রাইস কুকার নেওয়া যায় না। তাই নাম “0/1”. “প্রতি কেজিতে সবচেয়ে দামি আগে” — এই greedy নিয়মে প্রথমে রাইস কুকার (৪ কেজি) উঠবে, বাকি ২ কেজিতে আর কিছুই ধরবে না: মোট ৭. অথচ শাড়ি-পাঞ্জাবি আর জামা মিলে ৬ কেজি, মূল্য ৯! এই সমস্যার নাম knapsack (knapsack মানে পিঠের ঝোলা)।

  • State: K[i][c] = প্রথম i টি জিনিস থেকে বেছে, c কেজির ব্যাগে সর্বোচ্চ কত মূল্য নেওয়া যায়।
  • Transition: i-তম জিনিসটির জন্য মাত্র দুটি পথ। না নিলে: K[i − 1][c]. নিলে (যদি ওজন w ≤ c হয়): K[i − 1][c − w] + মূল্য। দুটির মধ্যে বড়টি।
  • Base case: কোনো জিনিস না থাকলে (i = 0) সব ঘর 0.
  • ক্রম: সারি ধরে ওপর থেকে নিচে; প্রতিটি সারি শুধু ঠিক ওপরের সারি দেখে।
  • উত্তর: সবচেয়ে নিচের সারির শেষ ঘর, K[3][6].
0kg
1kg
2kg
3kg
4kg
5kg
6kg
কিছু না
+ কুকার
+ শাড়ি
+ জামা
এই ধাপের সিদ্ধান্ত
সারি
মূল প্রশ্ন
  1. প্রথম সারি — কিছুই নেই। কোনো জিনিস না থাকলে ব্যাগ যত বড়ই হোক, মূল্য 0. কলামগুলো ব্যাগের ধারণক্ষমতা: ০ থেকে ৬ কেজি। ছোট ব্যাগের উত্তরও আমরা রাখছি, কারণ পরে এগুলোই কাজে লাগবে।
  2. রাইস কুকার যোগ হলো। ৪ কেজির কম ব্যাগে এটি ধরেই না, তাই ওপরের মান 0-ই থাকে। c = 4 থেকে নেওয়া যায়: না নিলে 0, নিলে 0 + 7 = 7. তাই ৪, ৫, ৬ কেজিতে 7.
  3. শাড়ি-পাঞ্জাবি যোগ হলো। c = 3-এ নতুন সুযোগ: নিলে K[1][0] + 5 = 5. c = 4, 5, 6-এ শাড়ি নিলে বাকি জায়গায় (১-৩ কেজি) কুকার ধরে না, মূল্য 5 — না নিলে কুকারসহ 7. তাই 7-ই থাকে।
  4. জামা যোগ হলো — প্রথম অংশ। c = 0 থেকে ৫ পর্যন্ত জামা নিলে কোনো লাভ হয় না, ওপরের মানই বসে। শুধু শেষ ঘরটি (c = 6) বাকি — সেখানেই আসল নাটক।
  5. K[3][6] — সিদ্ধান্তের মুহূর্ত। জামা না নিলে: ওপরের ঘর K[2][6] = 7. জামা নিলে: বাকি ৩ কেজির সেরা উত্তর K[2][3] = 5, যোগ জামার 4 = 9. 9 বড়, তাই K[3][6] = 9. খেয়াল করুন, K[2][3] ঘরটি আমরা আগেই হিসাব করে রেখেছিলাম — নতুন করে কিছু ভাবতে হয়নি।
  6. কোন জিনিসগুলো? নিচ থেকে পিছিয়ে যান। K[3][6] = 9 আর ওপরের K[2][6] = 7 আলাদা — মানে জামা নেওয়া হয়েছে; বাকি ৩ কেজি। K[2][3] = 5 আর ওপরের K[1][3] = 0 আলাদা — শাড়ি-পাঞ্জাবিও নেওয়া হয়েছে; বাকি ০ কেজি। K[1][0] = 0 — কুকার নেওয়া হয়নি। উত্তর: শাড়ি-পাঞ্জাবি + জামা, ৬ কেজি, মূল্য ৯.

এক সারির array — লুপের দিক কেন গুরুত্বপূর্ণ

প্রতিটি সারি শুধু ঠিক ওপরের সারি দেখে। তাহলে পুরো 2D টেবিল না রেখে একটিই 1D array best[c] রেখে সেটিকেই বারবার হালনাগাদ করা যায়। কিন্তু এখানে একটি ফাঁদ আছে, যেটি AI-এর কোডেও প্রায়ই দেখা যায়:

for (int i = 0; i < n; i++)
    for (int c = W; c >= w[i]; c--)          /* right to left */
        if (best[c - w[i]] + v[i] > best[c])
            best[c] = best[c - w[i]] + v[i];

ডান থেকে বাঁয়ে চললে best[c - w[i]] পড়ার সময় সেটি এখনো পুরোনো সারির মান — অর্থাৎ এই জিনিসটি তাতে এখনো যোগ হয়নি। তাই প্রতিটি জিনিস বড়জোর একবার নেওয়া হয়।

for (int i = 0; i < n; i++)
    for (int c = w[i]; c <= W; c++)          /* left to right */
        if (best[c - w[i]] + v[i] > best[c])
            best[c] = best[c - w[i]] + v[i];

বাঁ থেকে ডানে চললে best[c - w[i]] ইতিমধ্যে এই সারিতেই হালনাগাদ হয়ে গেছে — তাতে এই জিনিসটি হয়তো আগেই নেওয়া। ফলে একই জিনিস বারবার নেওয়া যায়। একে বলে unbounded knapsack — আর coin change ঠিক এটাই (একই কয়েন যতবার খুশি)।

দুটি কোডে পার্থক্য শুধু একটি লুপের দিক — c-- বনাম c++. কিন্তু উত্তর একদম আলাদা। আমাদের উদাহরণে ভুল দিকে চালালে শাড়ি-পাঞ্জাবি দুবার নেওয়া হবে (৩ + ৩ = ৬ কেজি, মূল্য ১০) — যা বাস্তবে অসম্ভব, কারণ শাড়ি একটাই। Compiler এই ভুল ধরবে না, প্রোগ্রাম কোনো সতর্কতা ছাড়াই চলবে আর একটি “বেশি ভালো” উত্তর দেবে। ছোট উদাহরণ হাতে মিলিয়ে দেখাই একমাত্র রক্ষা।

একটি গভীর কথা

Knapsack টেবিলের ঘর n × (W + 1)টি, তাই কাজ মোটামুটি n × W. শুনতে দ্রুত। কিন্তু W হলো একটি সংখ্যার মান, ইনপুটের দৈর্ঘ্য নয়। ব্যাগের সীমা ৬ কেজি না হয়ে ৬০০ কোটি গ্রাম হলে টেবিলও ৬০০ কোটি কলামের! এমন অ্যালগরিদমকে বলে pseudo-polynomial. আসলে knapsack একটি NP-hard সমস্যা — সব ইনপুটে দ্রুত চলে এমন কোনো অ্যালগরিদম আজও কেউ জানেন না। DP কাজ করে কারণ বাস্তবের অনেক ক্ষেত্রে W ছোট।

৬ · দুটি লেখার তুলনা — LCS ও edit distance

ফোনে কাউকে লিখছেন “আমি sylhet যাচ্ছি”, কিন্তু তাড়াহুড়োয় টাইপ হলো “silet”. Keyboard সঙ্গে সঙ্গে নিচে পরামর্শ দেখায়: “Sylhet?” সে কীভাবে বুঝল আপনি আসলে কোন শব্দটি চেয়েছিলেন? এর একটি মূল উপাদান হলো দুটি শব্দের মধ্যে “দূরত্ব” মাপা: একটি থেকে আরেকটিতে যেতে সবচেয়ে কম কয়টি পরিবর্তন লাগে।

পরিবর্তন তিন রকম: একটি অক্ষর ঢোকানো (insert), মুছে ফেলা (delete), অথবা বদলে দেওয়া (substitute). সবচেয়ে কম পরিবর্তনের সংখ্যাকে বলে edit distance — একে Levenshtein distance-ও বলে, সোভিয়েত গণিতবিদ Vladimir Levenshtein-এর নামে, যিনি ১৯৬০-এর দশকে এটি নিয়ে কাজ করেন।

  • State: D[i][j] = প্রথম শব্দের প্রথম i টি অক্ষরকে দ্বিতীয় শব্দের প্রথম j টি অক্ষরে বদলাতে সবচেয়ে কম কয়টি পরিবর্তন লাগে।
  • Transition: শেষ দুটি অক্ষর মিললে কিছুই করতে হয় না: D[i][j] = D[i − 1][j − 1]. না মিললে তিন রাস্তার সবচেয়ে সস্তাটি + 1 — মুছুন (ওপরের ঘর), ঢোকান (বাঁয়ের ঘর), বা বদলান (কোনাকুনি ঘর)।
  • Base case: খালি লেখা থেকে j অক্ষর বানাতে j বার ঢোকাতে হয়: D[0][j] = j. একইভাবে D[i][0] = i.
  • উত্তর: একদম নিচের ডান কোণের ঘর।
""
s
i
l
e
t
""
s
y
l
h
e
t
  1. প্রথম সারি আর কলাম। সারিতে sylhet-এর অক্ষর, কলামে silet-এর। "" মানে খালি লেখা। প্রথম সারি 0, 1, 2 … — খালি থেকে “s”, “si”, “sil” বানাতে যতগুলো অক্ষর, ততবার ঢোকানো। প্রথম কলামও তেমনি, মুছে ফেলার হিসাব।
  2. সারি “s”. কলাম “s”-এ দুটি অক্ষর মিলেছে — কোনাকুনি ঘরের 0-ই বসল (লাল দাগ)। বাকি ঘরগুলো বাঁয়ের ঘর থেকে একটি করে বেশি: “s” থেকে “si”, “sil” … বানাতে বাকি অক্ষরগুলো ঢোকাতে হবে।
  3. সারি “y” — প্রথম বদল। লাল ঘরে “y” আর “i” মেলে না। তিন প্রতিবেশী: ওপরে 1, বাঁয়ে 1, কোনাকুনি 0. সবচেয়ে কম 0, যোগ 1 = 1. মানে “sy” থেকে “si” — শুধু y-কে i দিয়ে বদলে দিলেই হলো।
  4. সারি “l”. “l” আর “l” মিলেছে, তাই কোনাকুনি ঘরের 1-ই বসল। “syl” থেকে “sil” — এখনো মাত্র ১টি পরিবর্তন।
  5. সারি “h” — মুছে ফেলা। silet-এ কোনো “h” নেই। লাল ঘরে (h, l) ওপরের ঘর 1 সবচেয়ে কম, তাই 1 + 1 = 2. ওপর থেকে নামা মানে প্রথম শব্দের অক্ষরটি মুছে ফেলা — “sylh” থেকে h মুছলে “syl”.
  6. সারি “e”. “e” আর “e” মিলেছে — কোনাকুনি ঘরের 2 নেমে এলো।
  7. সারি “t” — উত্তর। “t” আর “t” মিলেছে, কোনাকুনি 2. নিচের ডান কোণে 2 — sylhet থেকে silet-এ যেতে সবচেয়ে কম ২টি পরিবর্তন লাগে। মোট ৪২টি ঘর, প্রতিটি একবার — কাজ দুই শব্দের দৈর্ঘ্যের গুণফলের সমানুপাতিক।
  8. পথ ফিরে দেখা। উত্তরের ঘর থেকে পিছিয়ে যে পথে এসেছি তা লাল দাগে: s মিল, y → i বদল, l মিল, h মুছে ফেলা, e মিল, t মিল। Keyboard ঠিক এই রকম হিসাব অনেক শব্দের সাথে করে, আর সবচেয়ে কাছের শব্দগুলো পরামর্শ হিসেবে দেখায় (সাথে কোন শব্দ বেশি প্রচলিত, সেটিও দেখে)।

LCS — দুটি লেখায় সবচেয়ে লম্বা মিল

Edit distance-এর খুব কাছের আত্মীয় হলো longest common subsequence বা LCS: দুটি লেখা থেকে কিছু অক্ষর বাদ দিয়ে (ক্রম না বদলে) সবচেয়ে লম্বা কোন অংশটি দুটিতেই পাওয়া যায়? sylhet আর silet-এর LCS হলো “slet” — ৪টি অক্ষর। এর টেবিলও একই রকম গ্রিড: অক্ষর মিললে কোনাকুনি ঘর + 1, না মিললে ওপর আর বাঁয়ের মধ্যে বড়টি।

LCS জানলে অনেক কিছু জানা যায়। যেমন, শুধু ঢোকানো আর মোছার অনুমতি থাকলে পরিবর্তন লাগে (৬ + ৫) − ২ × ৪ = ৩টি: y আর h মুছুন, i ঢোকান। আরও বড় ব্যবহার হলো ফাইলের তুলনা। দুটি ফাইলের লাইনগুলোকে অক্ষর ধরে LCS বের করলে যে লাইনগুলো LCS-এ নেই, সেগুলোই “বদলেছে”। ১৯৭০-এর দশকে Unix-এর diff প্রোগ্রাম এই প্রশ্নের ওপর দাঁড়িয়েই তৈরি হয়েছিল, আর আজ আপনি যখন git diff-এ লাল-সবুজ লাইন দেখেন, তার পেছনেও একই প্রশ্ন — যদিও git আরও দ্রুত, বিশেষ অ্যালগরিদম ব্যবহার করে।

জীববিজ্ঞানেও একই গ্রিড। DNA হলো A, C, G, T — চারটি অক্ষরের লম্বা লেখা। দুটি প্রাণীর একই জিনের DNA পাশাপাশি সাজিয়ে কোথায় মিল, কোথায় একটি অক্ষর বদলেছে বা হারিয়ে গেছে — সেটি বের করা হয় ঠিক এই ধরনের DP টেবিল দিয়ে। ১৯৭০ সালে প্রকাশিত Needleman–Wunsch অ্যালগরিদম এই কাজের ভিত্তি।

৭ · বাস্তবে DP কোথায় কাজ করে

DP কোনো প্রতিযোগিতার কৌশল মাত্র নয়। আপনার ফোন, ইন্টারনেট আর AI-এর ভেতরে প্রতিদিন কোটি কোটি বার এটি চলছে। কয়েকটি উদাহরণ:

রাস্তা আর নেটওয়ার্ক

পর্ব ৩৮-এ দেখা shortest path সমস্যা — “k টির বেশি রাস্তা না নিয়ে সবচেয়ে ছোট পথ কত?” — এটিই Bellman-Ford অ্যালগরিদমের DP state. ইন্টারনেটের কিছু পুরোনো routing protocol এই চিন্তা থেকেই তৈরি।

Routing, GPS

লেখা আর কোডের তুলনা

Spell-check, diff, git-এর পরিবর্তন দেখানো, plagiarism ধরা, DNA মেলানো — সবগুলোর মূলে edit distance বা LCS-এর মতো গ্রিড।

Text, Git, Bioinformatics

কথা থেকে লেখা

১৯৬০-এর দশকে Andrew Viterbi একটি DP অ্যালগরিদম দেন, যা শব্দের গোলমাল-ভরা সংকেত থেকে “সবচেয়ে সম্ভাব্য” বার্তাটি খুঁজে বের করে। Viterbi algorithm মোবাইল ফোনের সংকেত বোঝা আর পুরোনো speech recognition-এর ভিত্তি ছিল।

Mobile, Speech

AI-এর সিদ্ধান্ত

Reinforcement learning-এ একটি AI এজেন্ট শেখে কোন অবস্থায় কোন কাজ করলে শেষ পর্যন্ত সবচেয়ে বেশি পুরস্কার মিলবে। এর কেন্দ্রে আছে Bellman equation — একটি DP transition.

Game AI, Robotics

Bellman equation-এর ভাবটি সহজ বাংলায় বলা যায়: “এই অবস্থার মূল্য = এখনই পাওয়া পুরস্কার + পরের অবস্থার মূল্য (একটু কমিয়ে ধরা)।” ঠিক coin change-এর মতো — dp[x]-কে প্রকাশ করা হয়েছিল ছোট একটি ঘর দিয়ে। পার্থক্য হলো, এখানে “ঘর” মানে দাবার একটি অবস্থান বা রোবটের একটি অবস্থান, আর ঘরের সংখ্যা এত বেশি যে পুরো টেবিল লেখা অসম্ভব। তখন টেবিলের বদলে একটি neural network সেই মান অনুমান করে। কিন্তু ভেতরের চিন্তা Bellman-এর ১৯৫০-এর দশকের DP-ই।

DP-র আসল শিক্ষা: বড় সিদ্ধান্তকে “এখনকার একটি পছন্দ + বাকি ছোট সমস্যার সেরা উত্তর” হিসেবে দেখা

৮ · AI-এর যুগে DP — সুন্দর কোড, লুকানো ভুল

AI-কে “coin change-এর C কোড দাও” বললে কয়েক সেকেন্ডে ঝকঝকে কোড পাবেন। DP কোড ছোট আর দেখতে নিরীহ, তাই চোখ বুলিয়ে মনে হয় ঠিকই আছে। কিন্তু DP-র ভুল সাধারণত crash করে না — চুপচাপ একটি ভুল সংখ্যা দেয়। আর সেই সংখ্যা প্রায়ই “বিশ্বাসযোগ্য” দেখায়।

AI-এর DP কোডে যেসব ভুল বেশি দেখা যায়

ভুলকী হয়কীভাবে ধরবেন
Base case ভুল (যেমন dp[0] = 1 বা বাদই পড়েছে)সব ঘরের মান সরে যায়amount = 0 আর 1 হাতে হিসাব করুন
Knapsack-এ লুপের দিক উল্টোএকই জিনিস একাধিকবার নেওয়া হয়একটিমাত্র জিনিস দিয়ে পরীক্ষা: তার দ্বিগুণ ক্ষমতার ব্যাগ
টেবিলের আকার dp[amount], হওয়ার কথা dp[amount + 1]শেষ ঘর লেখা হয় array-র বাইরে — off-by-oneAddressSanitizer দিয়ে চালান
“অসম্ভব” বোঝাতে INT_MAX, তারপর + 1overflow হয়ে ঋণাত্মক, ভুল ঘর জিতে যায়অসম্ভব একটি ইনপুট দিন — −1 আসে কি না
উত্তর বড় হয়, কিন্তু int ব্যবহারপথ গোনার মতো সমস্যায় overflowসবচেয়ে বড় ইনপুটে উত্তর ঋণাত্মক কি না দেখুন
Memo array একাধিক পরীক্ষার মধ্যে reset হয়নিদ্বিতীয় পরীক্ষায় আগের কয়েন-সেটের উত্তর চলে আসেএকই প্রোগ্রামে দুটি ভিন্ন ইনপুট পরপর চালান

এসব ভুল ধরার সবচেয়ে শক্তিশালী উপায় একটি পুরোনো কৌশল: brute force-এর সাথে মেলানো। ছোট ইনপুটে (যেমন amount ≤ 20) সব সম্ভাব্য সমন্বয় একে একে চেষ্টা করে এমন একটি ধীর কিন্তু নিশ্চিতভাবে সঠিক ফাংশন লিখুন। তারপর কয়েকশো এলোমেলো ছোট ইনপুটে দুটির উত্তর মিলিয়ে দেখুন। একটিও না মিললে DP-তে ভুল আছে — আর কোন ইনপুটে মেলেনি, সেটিই আপনার test case.

AI-কে DP কোড চাইলে শুধু কোড নয়, রেসিপির পাঁচটি উত্তরও চেয়ে নিন। তাহলে কোড পড়ার আগেই ভুল চিন্তা ধরা পড়বে:

আমি C-তে একটি dynamic programming সমাধান চাই: [সমস্যাটি এখানে লিখুন]. কোড লেখার আগে এই পাঁচটি জিনিস সহজ বাংলায় লেখো: ১. dp[...] এর একটি ঘরের অর্থ কী — পুরো এক বাক্যে ২. transition সূত্র, আর কেন সেটি সঠিক ৩. base case কোনগুলো ৪. কোন ক্রমে ঘর ভরবে, আর লুপের দিক কেন এমন ৫. উত্তর কোন ঘরে তারপর কোড দাও, যেখানে: - টেবিলের আকার ঠিক কত, তা কমেন্টে লেখা থাকবে - "অসম্ভব" মানের জন্য INT_MAX + 1 এর মতো overflow হবে না - ছোট ইনপুটের জন্য একটি brute force ফাংশন থাকবে, আর main() ১০০টি এলোমেলো ছোট ইনপুটে দুটির উত্তর মিলিয়ে দেখাবে

AI-এর DP কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১একটি নতুন সমস্যা দেখে কীভাবে বুঝবেন যে এটি DP দিয়ে সমাধান করা যাবে?

কোনো জাদুর সূত্র নেই, কিন্তু কয়েকটি লক্ষণ খুব নির্ভরযোগ্য।

  • প্রশ্নের ভাষা: “সবচেয়ে কম কত”, “সর্বোচ্চ কত”, “মোট কত উপায়ে”, “সম্ভব কি না” — এমন প্রশ্নে সাধারণত অনেকগুলো পছন্দের সমন্বয় থেকে একটি সেরা বা মোট সংখ্যা চাওয়া হয়। সব সমন্বয় আলাদা করে দেখলে exponential কাজ — এটাই DP-র জায়গা।
  • “শেষ পদক্ষেপ” কৌশল: নিজেকে জিজ্ঞেস করুন, “সেরা সমাধানের একদম শেষ সিদ্ধান্তটি কী হতে পারে?” coin change-এ শেষ কয়েন, knapsack-এ শেষ জিনিস নেব কি না, edit distance-এ শেষ অক্ষর নিয়ে কী করব। শেষ সিদ্ধান্তটি ঠিক করলে যদি বাকিটা একই ধরনের ছোট সমস্যা হয়ে যায় — আপনি transition পেয়ে গেছেন।
  • Brute force রিকার্শন লিখে দেখুন: সমস্যাটির একটি সরল রিকার্শন লিখুন, তারপর call tree আঁকুন। একই আর্গুমেন্ট নিয়ে একই ফাংশন বারবার ডাকা হচ্ছে? তাহলে memo বসালেই DP. ডাকগুলো সব আলাদা হলে (যেমন merge sort-এ) এটি divide and conquer, memo-র লাভ নেই।
  • State কত বড়: ফাংশনের আর্গুমেন্টগুলো কত রকম মান নিতে পারে? amount ≤ ১০,০০০ হলে ১০,০০০টি ঘর — সহজ। কিন্তু state-এ যদি “এ পর্যন্ত কোন কোন জিনিস নেওয়া হয়েছে” পুরো তালিকাটাই রাখতে হয়, তাহলে ঘর ২ⁿ টি — DP আর সাহায্য করবে না।

আরেকটি ব্যবহারিক কথা: অনেক সমস্যা আপাতদৃষ্টিতে greedy মনে হয়। আগে greedy-কে একটি ছোট উল্টো-উদাহরণ দিয়ে ভাঙার চেষ্টা করুন — যেমন {১, ৩, ৪}-এ ৬ টাকা। ভেঙে গেলে DP-র দিকে যান।

মূল উপলব্ধি: DP চেনার আসল প্রশ্ন একটাই — “শেষ সিদ্ধান্তটি নিলে বাকিটা কি একই রকম ছোট সমস্যা, আর সেই ছোট সমস্যাগুলো কি বারবার ফিরে আসে?” দুটির উত্তর হ্যাঁ হলে DP.

প্র ০২Memoization আর tabulation — দুটিই যদি একই উত্তর দেয়, পেশাদার কোডে কোনটি বেছে নেবেন?

দুটির খরচের ধরন আলাদা, তাই উত্তর নির্ভর করে পরিস্থিতির ওপর।

  • Stack-এর গভীরতা: memoization রিকার্শন ব্যবহার করে। state-এর শিকল যদি এক লাখ ধাপ লম্বা হয় (যেমন fib(100000) বা খুব লম্বা দুটি লেখার edit distance), তাহলে stack-এ এক লাখ frame জমবে। সাধারণত stack-এর আকার কয়েক মেগাবাইট — তাই crash হতে পারে। Tabulation সাধারণ লুপ, এই সমস্যা নেই।
  • শুধু দরকারি ঘর: top-down শুধু সেই ঘরগুলো হিসাব করে যেগুলো উত্তরের জন্য সত্যিই লাগে। ধরুন coin change-এ কয়েন {৫০০, ১০০০} আর amount ১০ লাখ। bottom-up ১০ লাখটি ঘরই ভরবে, অথচ top-down শুধু ৫০০-এর গুণিতকগুলো ছোঁবে। state-এর জায়গা বিশাল কিন্তু সত্যিকারের দরকারি ঘর কম হলে memo-ই জেতে — তখন array-র বদলে hash table-এ memo রাখা হয় (পর্ব ৩৭)।
  • গতি: সমান সংখ্যক ঘর হলে bottom-up সাধারণত দ্রুত — function call-এর খরচ নেই, আর array ক্রমানুসারে পড়া হয় বলে CPU cache ভালো কাজ করে।
  • মেমরি কমানো: bottom-up-এ সহজেই শুধু শেষ সারিটুকু রাখা যায় (প্র ০৫ দেখুন)। top-down-এ কোন ঘর আর লাগবে না, তা বলা কঠিন।
  • লেখার সহজতা: top-down সংজ্ঞা থেকে প্রায় হুবহু লেখা যায় — ক্রম নিয়ে ভাবতে হয় না। জটিল state-এ (যেমন গাছের ওপর DP) এটি ভুল কমায়।

অনেক অভিজ্ঞ প্রোগ্রামার তাই প্রথমে top-down লিখে সঠিকতা নিশ্চিত করেন, তারপর দরকার হলে bottom-up-এ রূপান্তর করেন — আর দুটির উত্তর মিলিয়ে নেন।

মূল উপলব্ধি: “কোনটা ভালো” নয় — প্রশ্ন হলো stack-এর গভীরতা, দরকারি ঘরের সংখ্যা, আর মেমরির সীমা কেমন। একই চিন্তা, দুটি বাস্তবায়ন; পরিস্থিতি দেখে বেছে নিন।

প্র ০৩Knapsack NP-hard — অথচ DP দিয়ে তো দ্রুতই সমাধান হলো! এই আপাত-বিরোধের ব্যাখ্যা কী?

বিরোধটি আসে “দ্রুত” শব্দের মাপকাঠি থেকে। কম্পিউটার বিজ্ঞানে অ্যালগরিদমের গতি মাপা হয় ইনপুটের আকার দিয়ে — অর্থাৎ ইনপুট লিখতে কতগুলো bit লাগে।

  • Knapsack DP-র কাজ n × W. এখানে n হলো জিনিসের সংখ্যা — এটি ইনপুটের দৈর্ঘ্যের সাথে মেলে। কিন্তু W হলো ব্যাগের সীমার মান. W = ১,০০০,০০০,০০০ লিখতে মাত্র ৩০টির মতো bit লাগে, অথচ টেবিলে কলাম লাগে একশো কোটি।
  • ইনপুটে একটি অঙ্ক বাড়ালে (W-কে ১০ গুণ করলে) কাজ ১০ গুণ হয়। bit-এর হিসাবে কাজ বাড়ে exponential হারে। তাই এটি সত্যিকারের polynomial নয় — “দেখতে polynomial”, অর্থাৎ pseudo-polynomial.
  • NP-hard কথাটির মানে: সব ইনপুটে — W যত বড়ই হোক — polynomial সময়ে চলে এমন কোনো অ্যালগরিদম কেউ জানেন না, আর পাওয়া গেলে হাজারো কঠিন সমস্যা একসাথে সহজ হয়ে যাবে (বিখ্যাত P বনাম NP প্রশ্ন)।

বাস্তবে কী করা হয়? বেশিরভাগ ব্যবসায়িক সমস্যায় W ছোট — ট্রাকে কত কেজি, বাজেটে কত হাজার টাকা। তখন DP চমৎকার। W বিশাল হলে ওজনগুলোকে গোল করে ছোট এককে নেওয়া হয় (একটু ছাড় দিয়ে প্রায়-সেরা উত্তর), অথবা branch and bound-এর মতো কৌশল ব্যবহার হয়। ক্লাউড কোম্পানির সার্ভারে কোন কাজ কোথায় বসবে, কুরিয়ারের ট্রাকে কোন পার্সেল উঠবে, বিনিয়োগের বাজেট — সবখানেই knapsack-এর রূপ।

মূল উপলব্ধি: “এই সমস্যা কঠিন” মানে “সব ইনপুটে কঠিন।” ইঞ্জিনিয়ারের কাজ হলো নিজের ইনপুটের আসল আকার চেনা — অনেক সময় তত্ত্বের কঠিন সমস্যা আপনার ডেটায় সহজ।

প্র ০৪AI-এর জগতে DP কোথায় লুকিয়ে আছে? Bellman equation আর Viterbi আসলে কী করে?

AI-এর দুটি বড় ধারার ভিত্তিতেই DP-র চিন্তা।

  • Reinforcement learning ও Bellman equation: একটি এজেন্ট (গেমের খেলোয়াড়, রোবট) প্রতিটি অবস্থায় একটি কাজ বেছে নেয়, পুরস্কার পায়, নতুন অবস্থায় যায়। প্রশ্ন: কোন অবস্থা কতটা “ভালো”? Bellman equation বলে, একটি অবস্থার মূল্য = সেরা কাজটি করে এখনই পাওয়া পুরস্কার + পরের অবস্থার মূল্য (ভবিষ্যতের পুরস্কার একটু কমিয়ে ধরা)। এটি হুবহু DP-র transition — বড় প্রশ্নকে “এখনকার পছন্দ + ছোট প্রশ্নের সেরা উত্তর” দিয়ে প্রকাশ। ছোট গ্রিড-গেমে সব অবস্থার মান একটি টেবিলে রেখে বারবার হালনাগাদ করা যায় (value iteration). দাবা বা Go-তে অবস্থার সংখ্যা মহাবিশ্বের পরমাণুর চেয়েও বেশি; তখন টেবিলের বদলে neural network সেই মূল্য অনুমান করে। DeepMind-এর AlphaGo-র মতো ব্যবস্থাতেও “অবস্থার মূল্য” অনুমানের এই চিন্তা কেন্দ্রীয় ভূমিকা রাখে।
  • Viterbi: ধরুন একটি কথার রেকর্ডিং ছোট ছোট টুকরোয় ভাগ করা হলো। প্রতিটি টুকরো কোন ধ্বনি হতে পারে, তার কয়েকটি সম্ভাবনা আছে। সব সম্ভাব্য ধ্বনি-ক্রম আলাদা করে দেখলে exponential. Viterbi প্রতিটি সময়ে, প্রতিটি সম্ভাব্য ধ্বনির জন্য “এখানে শেষ হওয়া সবচেয়ে সম্ভাব্য পথ” টেবিলে রাখে — ঠিক edit distance গ্রিডের মতো, সারি ধরে এগোয়, শেষে traceback করে পুরো বাক্য বের করে। আধুনিক speech recognition এখন মূলত neural network-নির্ভর, কিন্তু অনেক ব্যবস্থায় এখনো এমন DP-ভিত্তিক decoding ব্যবহার হয়।

মূল উপলব্ধি: AI-এর অনেক “শেখা” আসলে একটি বিশাল DP টেবিলকে অনুমানে ভরা। আজ যে ছোট coin change টেবিল ভরলেন, তার চিন্তাই একটু বড় পরিসরে রোবটকে হাঁটতে শেখায়।

প্র ০৫DP টেবিল যদি মেমরিতে না ধরে? শুধু দুটি সারি রেখে কীভাবে কাজ চালানো যায়, আর তার দাম কী?

ধরুন দুটি লম্বা DNA লেখার edit distance চাই — প্রতিটি ১ লাখ অক্ষরের। পুরো টেবিলে ঘর ১ লাখ × ১ লাখ = ১,০০০ কোটি। প্রতিটি ঘর ৪ byte হলে ৪০ গিগাবাইট — সাধারণ ল্যাপটপে অসম্ভব।

  • পর্যবেক্ষণ: edit distance-এর প্রতিটি সারি শুধু ঠিক ওপরের সারি দেখে। তাহলে পুরোনো সারিগুলো রাখার দরকার নেই — দুটি সারি (আগের আর এখনকার) রেখে পালা করে ব্যবহার করলেই চলে। মেমরি নেমে আসে ২ × ১ লাখ ঘরে — এক মেগাবাইটেরও কম। একে বলে rolling array. Fibonacci-র দুটি ভ্যারিয়েবল ঠিক এর সবচেয়ে ছোট রূপ, আর knapsack-এর 1D array-ও তা-ই।
  • দাম: শুধু দূরত্বের সংখ্যাটি পাবেন, কিন্তু traceback করে কোন কোন পরিবর্তন — সেই পথ আর পাবেন না, কারণ পুরোনো সারিগুলো মুছে গেছে। Knapsack-এ কোন জিনিস নেওয়া হলো, সেটিও হারায়।
  • চতুর সমাধান: ১৯৭৫ সালে Dan Hirschberg একটি কৌশল দেখান — divide and conquer আর দুই-সারির DP মিলিয়ে, অল্প মেমরিতেও পুরো পথ ফিরে পাওয়া যায়, বিনিময়ে মোটামুটি দ্বিগুণ সময়। অর্থাৎ DP আর D&C প্রতিদ্বন্দ্বী নয়, একসাথে কাজ করতে পারে।
  • লুপের দিক: এক সারিতেই কাজ সারতে চাইলে (knapsack-এর মতো) ক্রম খুব সাবধানে বাছতে হয়, যাতে এখনো-দরকারি পুরোনো মান মুছে না যায় — এই পর্বে ঠিক সেটিই দেখেছেন।

মূল উপলব্ধি: DP-তে সময়, মেমরি আর “পুরো উত্তর ফিরে পাওয়া” — এই তিনটির মধ্যে দর-কষাকষি চলে। কোনটি আপনার সত্যিই দরকার, সেটি আগে ঠিক করুন; তারপর টেবিলের যতটুকু না রাখলেই নয়, শুধু ততটুকু রাখুন।

অনুশীলন

এগুলোর বেশিরভাগে কোড লিখতে হবে না — খাতা-কলমে টেবিল ভরাই আসল কাজ।

  1. সিঁড়ি ভাঙা: ৫ ধাপের একটি সিঁড়ি। প্রতিবার ১ ধাপ বা ২ ধাপ উঠতে পারেন। মোট কত উপায়ে ওপরে পৌঁছানো যায়? রেসিপির পাঁচটি প্রশ্নের উত্তর লিখে টেবিল ভরুন।

    State: ways[i] = ঠিক i নম্বর ধাপে পৌঁছানোর মোট উপায়। Transition: i-তে পৌঁছানোর শেষ পা হয় i − 1 থেকে, নয়তো i − 2 থেকে — তাই ways[i] = ways[i − 1] + ways[i − 2]. Base: ways[0] = 1 (কিছু না করাও একটি উপায়), ways[1] = 1. টেবিল: 1, 1, 2, 3, 5, 8. উত্তর ৮ — আর এটি আসলে Fibonacci! অনেক নতুন সমস্যার ভেতরে পুরোনো চেনা transition লুকিয়ে থাকে।

  2. Greedy ভাঙুন: কয়েন {১, ৫, ৬, ৯} দিয়ে ১১ টাকা মেলাতে greedy কী দেবে, আর DP কী দেবে? dp[0] থেকে dp[11] পর্যন্ত টেবিল ভরে দেখুন।

    Greedy: ৯ নেয়, বাকি ২ → ১ + ১. মোট ৩টি (৯ + ১ + ১)। DP: dp = 0, 1, 2, 3, 4, 1, 1, 2, 3, 1, 2, 2. dp[11] = 2, কারণ শেষ কয়েন ৬ হলে বাকি ৫ — dp[5] = 1, মোট 2 (৬ + ৫)। আবারও দেখা গেল, সবচেয়ে বড় কয়েন আগে নেওয়া সবসময় সেরা নয়।

  3. লুপের দিক পরীক্ষা: একটিমাত্র জিনিস — ওজন ২ কেজি, মূল্য ৫ — আর ৪ কেজির ব্যাগ। 1D array best[0..4]-কে (ক) ডান থেকে বাঁয়ে, (খ) বাঁ থেকে ডানে হালনাগাদ করলে শেষে কী মান পাবেন? কোনটি 0/1 knapsack-এর সঠিক উত্তর?

    (ক) ডান থেকে বাঁয়ে: c = 4: best[2] + 5 = 0 + 5 = 5; c = 3: best[1] + 5 = 5; c = 2: best[0] + 5 = 5. ফল: 0, 0, 5, 5, 5 — জিনিসটি একবার, সঠিক। (খ) বাঁ থেকে ডানে: c = 2: 5; c = 3: 5; c = 4: best[2] + 5 = 5 + 5 = 10 — best[2] এইমাত্র হালনাগাদ হয়েছিল, তাই একই জিনিস দুবার গোনা হলো। একটিমাত্র জিনিসের এই ছোট পরীক্ষাটি যেকোনো knapsack কোডের লুপের দিক সঙ্গে সঙ্গে ধরে ফেলে।

  4. AI অডিট: যেকোনো AI-কে বলুন “C-তে coin change-এর minimum coins কোড দাও।” তারপর তিনটি ইনপুটে চালান: amount = 0, কয়েন {২} দিয়ে amount = 3, আর কয়েন {১, ৩, ৪} দিয়ে amount = 6. সব ঠিক এলো?

    সঠিক উত্তর হওয়া উচিত 0, −1 আর 2. সাধারণত যা দেখা যায়:

    • “অসম্ভব” বোঝাতে INT_MAX নিয়ে + 1 করা — {২}-এ ৩ টাকায় অদ্ভুত ঋণাত্মক সংখ্যা আসে।
    • কিছু কোড greedy লিখে দেয় (কয়েন sort করে বড়টা আগে) — {১, ৩, ৪}-এ 2-এর বদলে 3 দেয়।
    • amount = 0-তে টেবিলের আকার বা base case নিয়ে গোলমাল।

    তিনটি ছোট, বেছে নেওয়া ইনপুট — আর AI-এর “সুন্দর” কোডের অর্ধেক ভুল ধরা পড়ে। এটাই যাচাইয়ের দক্ষতা।

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

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

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

  1. 📝 dp-র একটি ঘরের অর্থ এক বাক্যে লেখা (state)
  2. 🔗 ঘরটি ছোট ঘর থেকে কীভাবে আসে, তার সূত্র (transition)
  3. 🧱 সরাসরি জানা ঘরগুলো বসানো (base case)
  4. ➡️ কোন ক্রমে ঘর ভরবেন, তা ঠিক করে লুপ লেখা
  5. 🎯 উত্তরের ঘর পড়া, দরকার হলে traceback

কোন বৈশিষ্ট্যটি না থাকলে DP-র “মনে রাখা” থেকে কোনো লাভ হয় না?

  • সমস্যায় array থাকতে হবে
  • একই উপ-সমস্যা বারবার ফিরে আসা (overlapping subproblems)
  • ইনপুট খুব বড় হওয়া
  • সমাধানটি recursion দিয়ে লেখা থাকা

কয়েন {১, ৩, ৪} দিয়ে ৬ টাকা মেলাতে সবচেয়ে কম কয়টি কয়েন লাগে?

  • ১টি
  • ৩টি
  • ২টি
  • ৬টি

Memoization (top-down)-এর তুলনায় tabulation (bottom-up)-এর একটি বড় সুবিধা কোনটি?

  • শুধু tabulation-ই সঠিক উত্তর দেয়
  • সবসময় কম ঘর হিসাব করে
  • কোনো টেবিল লাগে না
  • রিকার্শন না থাকায় stack overflow-এর ভয় নেই

1D array দিয়ে 0/1 knapsack-এ ধারণক্ষমতার লুপ ডান থেকে বাঁয়ে (c--) চালানো হয় কেন?

  • যাতে প্রতিটি জিনিস বড়জোর একবার নেওয়া হয়
  • যাতে প্রোগ্রাম দ্রুত চলে
  • যাতে array-র বাইরে না যায়
  • যাতে একই জিনিস যতবার খুশি নেওয়া যায়

Edit distance টেবিলে D[i][j] হিসাবের সময় দুটি অক্ষর মিলে গেলে কী বসে?

  • কোনাকুনি ঘর + 1
  • কোনাকুনি ঘর D[i − 1][j − 1], কোনো যোগ ছাড়া
  • ওপরের ঘর + 1
  • সবসময় 0

AI একটি coin change কোড দিল। সেটি যাচাইয়ের সবচেয়ে নির্ভরযোগ্য উপায় কোনটি?

  • AI-কেই জিজ্ঞেস করা কোডটি ঠিক কি না
  • শুধু বাংলাদেশের টাকা দিয়ে একবার চালানো
  • ছোট এলোমেলো ইনপুটে brute force-এর উত্তরের সাথে মিলিয়ে দেখা, সাথে অসম্ভব ইনপুট পরীক্ষা
  • -Wall দিয়ে compile করে warning না পেলেই যথেষ্ট

শব্দকোষ

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

মনে রাখুন

  1. DP = উপ-সমস্যার উত্তর একবার হিসাব করে মনে রাখা; কাজে লাগে যখন উপ-সমস্যা বারবার ফিরে আসে আর ছোট সেরা জুড়ে বড় সেরা হয়।
  2. দুই রূপ: top-down (রিকার্শন + memo) আর bottom-up (লুপ দিয়ে টেবিল); শুধু শেষ সারি লাগলে মেমরি অনেক কমানো যায়।
  3. রেসিপি: state-এর অর্থ → transition → base case → ক্রম → উত্তরের ঘর. প্রথমটি এক বাক্যে লিখতে না পারলে থামুন।
  4. Coin change-এ greedy ভুল করতে পারে ({১, ৩, ৪}-এ ৬); knapsack-এ লুপের দিক ঠিক করে দেয় — প্রতিটি জিনিস একবার, নাকি যতবার খুশি; edit distance আর LCS — দুটি লেখার গ্রিড।
  5. AI-এর DP কোড ছোট ইনপুটে brute force-এর সাথে মিলিয়ে, অসম্ভব আর সবচেয়ে বড় ইনপুটে চালিয়ে তবেই বিশ্বাস করুন।

পরের পর্বে: আজ দেখলেন greedy কোথায় হারে। পরের পর্বে দেখব greedy কোথায় জেতে — আর কীভাবে প্রমাণ করবেন যে “এখনকার সেরা” পছন্দ শেষ পর্যন্তও সেরা। সাথে থাকবে নাম্বার থিওরি: GCD, মৌলিক সংখ্যা আর modular arithmetic.

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

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