DP প্যারাডাইম — মেমোয়াইজেশন বনাম ট্যাবুলেশন
এই পাঠে যা শিখবেন
- একটি সমস্যায় DP প্রযোজ্য কি না তা চেনার দুটি শর্ত — অপটিমাল সাবস্ট্রাকচার ও ওভারল্যাপিং সাবপ্রবলেম
- মেমোয়াইজেশন (টপ-ডাউন) ও ট্যাবুলেশন (বটম-আপ) — কোড স্ট্রাকচারে এই দুইয়ের প্রকৃত পার্থক্য
- একটি রিকারেন্স থেকে কীভাবে দুই ধরনের বাস্তবায়ন লেখা যায়, এবং কেন তারা একই উত্তর দিতে বাধ্য
- ওভারল্যাপিং সাবপ্রবলেম থাকার কারণে cache ব্যবহার করলে প্রকৃতপক্ষে কত কম কাজ লাগে — সরাসরি গণনা করে দেখা
১ · DP কখন প্রযোজ্য — দুটি শর্ত
DSA কোর্সে আপনি ইতিমধ্যে কয়েকটি DP সমস্যা (যেমন ফিবোনাচি, কয়েন চেঞ্জ) দেখে থাকতে পারেন। এই কোর্স ধরে নেয় আপনি DP-এর মৌলিক ধারণার সাথে পরিচিত — এখানে আমরা সরাসরি যাচাই করব ঠিক কেন DP কাজ করে এবং মেমোয়াইজেশন বনাম ট্যাবুলেশন বেছে নেওয়ার সিদ্ধান্তে যাব। একটি সমস্যায় DP প্রযোজ্য হওয়ার জন্য দুটি শর্ত পূরণ হতে হয়:
সমস্যার একটি অপটিমাল সমাধান তার সাবপ্রবলেমগুলোর অপটিমাল সমাধান দিয়ে গঠিত হয়। অর্থাৎ, বড় সমস্যার সেরা উত্তর জানতে হলে ছোট সাবপ্রবলেমগুলোর সেরা উত্তর জানাই যথেষ্ট — পুরো সমস্যা নতুন করে না ভেবে।
রিকার্সিভভাবে সমস্যা ভাঙলে একই সাবপ্রবলেম বারবার, ভিন্ন ভিন্ন পথ থেকে আসে। ডিভাইড অ্যান্ড কনকারে (M4) সাবপ্রবলেমগুলো disjoint (যেমন মার্জ সর্টের বাম-ডান অর্ধেক) — তাই সেখানে cache করার কোনো লাভ নেই। DP-তে ঠিক উল্টো — এই পুনরাবৃত্তিই cache-কে কার্যকর করে তোলে।
দুটো শর্তই থাকলে তবেই "প্রতিটি সাবপ্রবলেম একবার সমাধান করে ফলাফল জমা রাখা" একটি লাভজনক কৌশল হয়ে ওঠে — এবং এটাই DP-এর মূল ধারণা। গ্রিডি অ্যালগরিদমের (M5) সাথে পার্থক্য মনে রাখুন — গ্রিডিতে প্রতি ধাপে একটিই স্থানীয়-সেরা পছন্দ করা হয় এবং কখনো ফিরে দেখা হয় না, কিন্তু DP-তে সব সম্ভাব্য সাবপ্রবলেমের উত্তর গণনা করে রাখা হয় (তাই DP অনেক বেশি সমস্যায় প্রযোজ্য, কিন্তু গ্রিডির চেয়ে ধীর)।
২ · উদাহরণ সমস্যা — কয়েন কম্বিনেশন গোনা
ধরুন coins হলো কয়েকটি ভিন্ন মূল্যমানের কয়েন (প্রতিটির যথেষ্ট সরবরাহ আছে ধরে নিন), এবং আমরা জানতে
চাই amount পরিমাণ তৈরি করার কতগুলো ভিন্ন কম্বিনেশন সম্ভব (ক্রম গুরুত্বপূর্ণ নয় —
১+২ ও ২+১ একই কম্বিনেশন)। রাজ্য সংজ্ঞায়িত করি $W(i, a)$ = coins[i:] (অর্থাৎ $i$-তম কয়েন থেকে
শুরু করে বাকি সব কয়েন, অসীম সরবরাহে) ব্যবহার করে $a$ পরিমাণ তৈরি করার কম্বিনেশন সংখ্যা।
$$ W(i, a) = \begin{cases} 1 & \text{যদি } a = 0 \\ 0 & \text{যদি } a < 0 \text{ অথবা } (i = n \text{ এবং } a > 0) \\ W(i+1,\, a) \;+\; W(i,\, a - c_i) & \text{অন্যথায়} \end{cases} $$
প্রথম রিকার্সিভ টার্ম W(i+1, a) মানে "$c_i$ কয়েনটি একেবারেই ব্যবহার না করা" (পরের কয়েনে চলে যাওয়া)।
দ্বিতীয় টার্ম W(i, a - c_i) মানে "$c_i$ কয়েনের অন্তত একটি ব্যবহার করা" (একই $i$-তে থেকে যাওয়া,
কারণ কয়েনটি আবার ব্যবহার করা যাবে)। এই দুই বিকল্প একে অপরের সাথে ওভারল্যাপ করে না, তাই যোগ করলেই মোট কম্বিনেশন
পাওয়া যায় — এটাই এখানকার অপটিমাল সাবস্ট্রাকচার আর্গুমেন্ট (আসলে এটি একটি "গণনা" সমস্যা, "অপটিমাইজেশন"
নয়, কিন্তু একই নীতি প্রযোজ্য: বড় প্রবলেমের উত্তর ছোট সাবপ্রবলেমের উত্তর থেকে সরাসরি গঠন করা যায়)। আর W(i, a)
অবস্থাটি বহু ভিন্ন রিকার্সিভ পথ থেকে বারবার আসতে পারে — এটাই ওভারল্যাপিং সাবপ্রবলেম।
৩ · মেমোয়াইজেশন (টপ-ডাউন)
মেমোয়াইজেশনে আমরা রিকারেন্সটি ঠিক যেমন আছে তেমনই একটি রিকার্সিভ ফাংশন হিসেবে লিখি, শুধু একটি
ডিকশনারিতে (cache) প্রতিটি $(i, a)$-এর উত্তর জমা রাখি যাতে একই অবস্থা দ্বিতীয়বার এলে পুনরায়
গণনা না করে সরাসরি cache থেকে ফেরত দেওয়া যায়।
৪ · ট্যাবুলেশন (বটম-আপ)
ট্যাবুলেশনে কোনো রিকার্সন নেই — বরং একটি টেবিল dp[i][a] তৈরি করে সবচেয়ে ছোট সাবপ্রবলেম
(i = n, অর্থাৎ কোনো কয়েন বাকি নেই) থেকে শুরু করে বড় দিকে ($i = n-1, n-2, \dots, 0$) ইটারেটিভভাবে
পূরণ করা হয়। একই $i$-এর ভেতরে a ছোট থেকে বড় দিকে যেতে হয়, কারণ dp[i][a] নির্ভর করে
dp[i][a - coins[i]]-এর উপর (একই সারিতে, কিন্তু ছোট a)।
def count_ways_naive(coins, i, a, counter):
# নেইভ রিকার্সন -- কোনো cache নেই, একই (i, a) বহুবার গণনা হয়
counter[0] += 1
if a == 0:
return 1
if a < 0 or i == len(coins):
return 0
skip = count_ways_naive(coins, i + 1, a, counter) # coins[i] একদম ব্যবহার না করা
take = count_ways_naive(coins, i, a - coins[i], counter) # coins[i]-এর অন্তত একটি ব্যবহার করা
return skip + take
def count_ways_memo(coins, i, a, cache, counter):
# মেমোয়াইজেশন -- ঠিক একই রিকারেন্স, শুধু cache যোগ করা হয়েছে
counter[0] += 1
if a == 0:
return 1
if a < 0 or i == len(coins):
return 0
key = (i, a)
if key in cache:
return cache[key]
result = (count_ways_memo(coins, i + 1, a, cache, counter)
+ count_ways_memo(coins, i, a - coins[i], cache, counter))
cache[key] = result
return result
def count_ways_tab(coins, amount):
# ট্যাবুলেশন -- কোনো রিকার্সন নেই, ইটারেটিভভাবে টেবিল পূরণ
n = len(coins)
dp = [[0] * (amount + 1) for _ in range(n + 1)]
for a in range(amount + 1):
dp[n][a] = 1 if a == 0 else 0
for i in range(n - 1, -1, -1):
for a in range(amount + 1):
ways = dp[i + 1][a]
if a - coins[i] >= 0:
ways += dp[i][a - coins[i]]
dp[i][a] = ways
return dp[0][amount]
test_cases = [
([1, 2, 5], 5),
([1, 2, 5], 11),
([2, 3, 7], 12),
([1, 5, 10, 25], 30),
]
print(f"{'coins':>18} | {'amount':>6} | {'naive':>6} | {'memo':>6} | {'tab':>6}")
for coins, amount in test_cases:
n_counter = [0]
m_counter = [0]
r_naive = count_ways_naive(coins, 0, amount, n_counter)
r_memo = count_ways_memo(coins, 0, amount, {}, m_counter)
r_tab = count_ways_tab(coins, amount)
assert r_naive == r_memo == r_tab, "মিসম্যাচ!"
print(f"{str(coins):>18} | {amount:>6} | {r_naive:>6} | {r_memo:>6} | {r_tab:>6}")
print("\nতিনটি বাস্তবায়নই সব টেস্ট কেসে অভিন্ন উত্তর দিয়েছে।")
coins = [1, 2, 5], amount = 5-এর উত্তর ৪ — হাতে গুনেও
যাচাই করা যায়: $5$, $2{+}2{+}1$, $2{+}1{+}1{+}1$, $1{+}1{+}1{+}1{+}1$ — ঠিক চারটি ভিন্ন কম্বিনেশন। তিনটি
বাস্তবায়নই (নেইভ, মেমোয়াইজড, ট্যাবুলেটেড) প্রতিটি টেস্ট কেসে সম্পূর্ণ অভিন্ন সংখ্যা দিয়েছে, কারণ তিনটিই
একই $W(i,a)$ রিকারেন্স সমাধান করছে — শুধু হিসাবের ক্রম ভিন্ন।
৫ · ওভারল্যাপিং সাবপ্রবলেম বাস্তবে কতটা প্রভাব ফেলে
"ওভারল্যাপিং সাবপ্রবলেম" শুধু তত্ত্বকথা নয় — নিচের কোড সেলে coins = [1, 5, 10, 25] রেখে
amount বাড়িয়ে বাড়িয়ে নেইভ রিকার্সনের মোট ফাংশন-কল সংখ্যা এবং মেমোয়াইজড ভার্সনের কল সংখ্যা ও
cache-এ জমা হওয়া ইউনিক অবস্থার সংখ্যা গুনে তুলনা করা হয়েছে।
coins = [1, 5, 10, 25]
print(f"{'amount':>6} | {'naive calls':>12} | {'memo calls':>11} | {'unique states':>13}")
for amount in [10, 20, 30, 40, 50]:
n_counter = [0]
m_counter = [0]
cache = {}
count_ways_naive(coins, 0, amount, n_counter)
count_ways_memo(coins, 0, amount, cache, m_counter)
print(f"{amount:>6} | {n_counter[0]:>12} | {m_counter[0]:>11} | {len(cache):>13}")
amount = 10-এ নেইভ রিকার্সন ১১১ বার কল হয়, মেমোয়াইজড
মাত্র ৮১ বার (এবং cache-এ জমা হয় মাত্র ৪০টি ইউনিক অবস্থা)। amount = 50-এ
পৌঁছালে নেইভ কল সংখ্যা ৩,৩৭১-এ পৌঁছায় (প্রায় রৈখিকের চেয়ে অনেক দ্রুত বাড়ছে), অথচ মেমোয়াইজড কল
সংখ্যা মাত্র ৪০১ এবং ইউনিক অবস্থা মাত্র ২০০ — কারণ ইউনিক অবস্থার সংখ্যা সর্বোচ্চ
$(n+1)\times(\text{amount}+1)$-এ সীমাবদ্ধ ($n=4$ কয়েনের জন্য)। এটাই মেমোয়াইজেশনের মূল লাভ — প্রতিটি ইউনিক
সাবপ্রবলেম ঠিক একবার গণনা হয়, বারবার নয়।
মেমোয়াইজেশন লেখা সহজ (স্বাভাবিক রিকার্সিভ চিন্তাভাবনা থেকে সরাসরি আসে) এবং শুধু
প্রয়োজনীয় সাবপ্রবলেমগুলোই গণনা করে (পুরো টেবিল নয়, যদি কিছু অংশ কখনো দরকারই না হয়) — কিন্তু
Python-এ রিকার্সন স্ট্যাক গভীর হলে RecursionError-এর ঝুঁকি থাকে এবং ফাংশন-কল ওভারহেড থাকে।
ট্যাবুলেশন-এ কোনো রিকার্সন স্ট্যাক নেই, এবং প্রায়ই স্পেস অপ্টিমাইজ করা সহজ (যেমন শুধু আগের
সারি রাখা, পুরো টেবিল নয়) — কিন্তু এটি সব সাবপ্রবলেম গণনা করে, এমনকি অপ্রয়োজনীয়গুলোও। L25 থেকে
M6-এর বাকি পাঠে আমরা মূলত ট্যাবুলেশন ব্যবহার করব (স্পেস-অপ্টিমাইজেশন আলোচনার সুবিধার জন্য), কিন্তু প্রতিটি
সমস্যাই সমান দক্ষতায় মেমোয়াইজড ভার্সনেও লেখা যায়।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১ যদি কোনো সমস্যায় শুধু ওভারল্যাপিং সাবপ্রবলেম থাকে কিন্তু অপটিমাল সাবস্ট্রাকচার না থাকে, তাহলে কি মেমোয়াইজেশন কাজে লাগবে?
মেমোয়াইজেশন তখনও ফাংশন-কল কমাবে (কারণ এটি নির্ভর করে শুধু "একই ইনপুটে একই আউটপুট" থাকার উপর — একে বলা হয় pure function), কিন্তু ফলাফলটি একটি সঠিক অপটিমাইজেশন হবে না যদি সাবপ্রবলেমের অপটিমাল উত্তর দিয়ে মূল প্রবলেমের অপটিমাল উত্তর গঠন করা না যায়। উদাহরণস্বরূপ, "দীর্ঘতম সরল পথ" (longest simple path) সমস্যায় অপটিমাল সাবস্ট্রাকচার নেই (একটি সাবপাথের দীর্ঘতম সরল পথ মূল পাথের দীর্ঘতম সরল পথের অংশ নাও হতে পারে, কারণ ভার্টেক্স পুনরাবৃত্তি এড়াতে হয়) — তাই এখানে DP প্রয়োগ করলে ভুল উত্তর আসবে, cache যতই থাকুক।
প্র ০২
উপরের ট্যাবুলেশন কোডে a-এর লুপ কেন ছোট থেকে বড় দিকে যায়, বড় থেকে ছোট দিকে নয়?
কারণ dp[i][a] নির্ভর করে dp[i][a - coins[i]]-এর উপর — একই সারি i-তে,
কিন্তু ছোট ইনডেক্স a - coins[i]-তে (যেহেতু coins[i] >= 1)। তাই যখন আমরা
dp[i][a] গণনা করছি, dp[i][a - coins[i]] ইতিমধ্যে গণনা হয়ে থাকতে হবে — যা তখনই
নিশ্চিত হয় যখন a ছোট থেকে বড় দিকে যায়। যদি উল্টো দিকে যেতাম, তাহলে dp[i][a - coins[i]]-এর
মান তখনও পুরনো (এখনো আপডেট না হওয়া, ভুল) থাকত।
প্র ০৩
যদি coins-এ ডুপ্লিকেট মান থাকে (যেমন [1, 1, 2]), তাহলে কি উপরের কোড ভুল উত্তর
দেবে?
হ্যাঁ, ভুল হতে পারে — কারণ রিকারেন্সটি ধরে নেয় প্রতিটি $c_i$ একটি স্বতন্ত্র কয়েন-ধরন যার অসীম
সরবরাহ আছে। যদি একই মান দুইবার তালিকায় থাকে, তাহলে ফাংশনটি সেটিকে দুটি ভিন্ন "ধরন" হিসেবে গণনা করবে এবং
একই কম্বিনেশন একাধিকবার গোনা হতে পারে। বাস্তব ব্যবহারে প্রথমে coins-কে set() দিয়ে
ডুপ্লিকেট সরিয়ে ফেলা উচিত — এটি একটি ভালো উদাহরণ যে DP-এর সঠিকতা তার অন্তর্নিহিত অনুমানগুলোর (এখানে:
কয়েনের ধরনগুলো স্বতন্ত্র) উপর নির্ভরশীল, শুধু কোড সঠিকভাবে টাইপ করলেই যথেষ্ট নয়।
অনুশীলন
-
চিন্তা করুন:
count_ways_tab-এ প্রতিটি সময়ে শুধু আগের সারি লাগে কি, নাকি সব সারি (সবi) স্মৃতিতে রাখতে হয়? স্পেস কমানো সম্ভব কি?dp[i][a]শুধুdp[i+1][*](পরের সারি) এবংdp[i][*](একই সারির ছোট ইনডেক্স)-এর উপর নির্ভর করে — তাই এক সময়ে সর্বোচ্চ একটি সারি (দৈর্ঘ্যamount+1) যথেষ্ট, পুরো $(n+1) \times (\text{amount}+1)$ টেবিল না রেখেই। এটি স্পেস কমপ্লেক্সিটি $O(n \cdot \text{amount})$ থেকে $O(\text{amount})$-এ নামিয়ে আনে — ট্যাবুলেশনের একটি বড় সুবিধা যা মেমোয়াইজেশনে সরাসরি করা কঠিন (কারণ রিকার্সন কোন অবস্থা পরে লাগবে তা আগে থেকে জানে না)। -
পরীক্ষা করুন: উপরের প্রথম কোড সেলে
test_cases-এ নিজের একটি নতুন(coins, amount)জোড়া যোগ করুন এবং Run চেপে নিশ্চিত হোন তিনটি বাস্তবায়নই এখনও একমত।যেকোনো বৈধ (পজিটিভ, স্বতন্ত্র) কয়েন তালিকা ও পরিমাণ দিলে
assertলাইনটি পাস করা উচিত — কারণ তিনটি ফাংশনই একই $W(i,a)$ রিকারেন্স সমাধান করে, শুধু ভিন্ন কৌশলে। যদি কখনোassertব্যর্থ হয়, সেটি বাস্তবায়নে একটি বাগের প্রমাণ — রিকারেন্সের নয়।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৭টি পাঠ এই DP প্যারাডাইমের ভিত্তির উপর দাঁড়িয়ে পরবর্তী পাঠগুলোতে (L25–L29) আমরা 0/1 ন্যাপস্যাক, LCS, LIS, ম্যাট্রিক্স চেইন মাল্টিপ্লিকেশন ও এডিট ডিস্ট্যান্স — পাঁচটি ক্লাসিক DP সমস্যা কভার করব।
- Data Structures & Algorithms কোর্স সহোদর কোর্স DP-এর মৌলিক পরিচিতি ও আরও বেসিক উদাহরণ (ফিবোনাচি, সিম্পল কয়েন চেঞ্জ) সেই কোর্সে ইমপ্লিমেন্টেশন আকারে দেখানো হয়েছে।
- সব Courses দেখুন ABCL TECH C, C++, Python, Java, JavaScript, DSA, DBMS, Discrete Mathematics, System Design, Cybersecurity, Cloud Computing & DevOps, Computer Networks, Operating Systems, Computer Architecture, Programming Languages & Compiler Design, Software Engineering & Git, Theory of Computation, Engineering Economics, Full-Stack Web Frameworks, Mobile App Development, Ethics in Computing & AI Safety, Software Testing & Quality Assurance ও Design and Analysis of Algorithms — সব এক জায়গায়।