0/1 ন্যাপস্যাক প্রবলেম
এই পাঠে যা শিখবেন
- 0/1 ন্যাপস্যাকের ক্লাসিক ২D DP রিকারেন্স এবং প্রতিটি টার্মের অর্থ
- কেন এই সমস্যায় গ্রিডি পদ্ধতি ব্যর্থ হয়, অথচ DP সবসময় সঠিক উত্তর দেয় — অপটিমাল সাবস্ট্রাকচারের একটি সুস্পষ্ট প্রমাণ
- নেইভ এক্সপোনেনশিয়াল রিকার্সিভ ব্রুট-ফোর্স বনাম DP — কোড ও রানটাইম আচরণ উভয়ের পার্থক্য
- র্যান্ডম ছোট ইনস্ট্যান্সে দুই পদ্ধতির ফলাফল অভিন্ন কি না তা কোড দিয়ে সরাসরি যাচাই করা
১ · সমস্যার সংজ্ঞা
$n$টি আইটেম দেওয়া আছে, প্রতিটির একটি ওজন $w_i$ ও মূল্য $v_i$, এবং একটি ব্যাগের ধারণক্ষমতা $W$। লক্ষ্য: একটি উপসেট বেছে নেওয়া যার মোট ওজন $W$-এর বেশি না হয়ে মোট মূল্য সর্বোচ্চ হয় — কিন্তু প্রতিটি আইটেম হয় সম্পূর্ণ নেওয়া, নয়তো একদমই বাদ দেওয়া (তাই নাম "0/1")। এটি DSA কোর্সে ইতিমধ্যে একটি ক্লাসিক DP উদাহরণ হিসেবে ইমপ্লিমেন্ট করা হয়েছে — এখানে আমরা এর সঠিকতার পেছনের যুক্তি এবং একটি সত্যিকারের ব্রুট-ফোর্স-বনাম-DP যাচাইয়ে মনোযোগ দেব।
L22-তে ফ্র্যাকশনাল ন্যাপস্যাকে আইটেম ভাগ করা যেত বলে মূল্য/ওজন অনুপাত অনুযায়ী গ্রিডিভাবে বেছে নিলেই অপটিমাল উত্তর পাওয়া যেত। কিন্তু 0/1 সংস্করণে একটি উচ্চ-অনুপাতের আইটেম ব্যাগে জায়গা "নষ্ট" করতে পারে যা দুটি নিম্ন-অনুপাতের আইটেম মিলে পূরণ করত আরও বেশি মোট মূল্যে। উদাহরণ: $W=10$; আইটেম A ($w{=}6, v{=}12$, অনুপাত ২), আইটেম B ও C (প্রতিটি $w{=}5, v{=}9$, অনুপাত ১.৮)। গ্রিডি প্রথমে A নেবে (সর্বোচ্চ অনুপাত), তারপর মাত্র $4$ ওজন বাকি থাকায় B বা C কোনোটিই নেওয়া যাবে না — মোট মূল্য $12$। অথচ B ও C দুটোই নিলে ওজন $10$ (ঠিক সীমায়) এবং মূল্য $18$ — গ্রিডির চেয়ে ভালো। তাই এখানে স্থানীয়-সেরা পছন্দ সবসময় বৈশ্বিক-সেরায় নিয়ে যায় না — গ্রিডি-চয়েস প্রপার্টি ভেঙে যায়, এবং আমাদের DP-এর মতো সব সাবপ্রবলেম বিবেচনা করা একটি পদ্ধতি লাগে।
২ · DP রিকারেন্স ও অপটিমাল সাবস্ট্রাকচার
$dp[i][w]$ সংজ্ঞায়িত করি প্রথম $i$টি আইটেম (ইনডেক্স $1$ থেকে $i$) থেকে বেছে, ধারণক্ষমতা $w$-এর মধ্যে থেকে পাওয়া সর্বোচ্চ মূল্য হিসেবে। বেস কেস: $dp[0][w] = 0$ (কোনো আইটেম নেই মানে মূল্যও নেই)।
$$ dp[i][w] = \begin{cases} 0 & i = 0 \\ dp[i-1][w] & w_i > w \\ \max\big(\,dp[i-1][w],\;\; v_i + dp[i-1][w-w_i]\,\big) & \text{অন্যথায়} \end{cases} $$
উত্তর: $dp[n][W]$। রিকারেন্সের যুক্তি সহজ — $i$-তম আইটেম নিয়ে সিদ্ধান্ত নিতে দুটি বিকল্প আছে: (ক) বাদ দেওয়া — তাহলে সমস্যাটি ঠিক প্রথম $i-1$টি আইটেম নিয়ে একই ধারণক্ষমতা $w$-এর সাবপ্রবলেমে পরিণত হয়, অর্থাৎ $dp[i-1][w]$; অথবা (খ) নেওয়া (শুধু $w_i \le w$ হলে সম্ভব) — তাহলে বাকি ধারণক্ষমতা $w - w_i$ দিয়ে প্রথম $i-1$টি আইটেম থেকে সেরা মূল্য বের করতে হবে এবং তার সাথে $v_i$ যোগ করতে হবে, অর্থাৎ $v_i + dp[i-1][w-w_i]$।
অপটিমাল সাবস্ট্রাকচার আর্গুমেন্ট: ধরা যাক $dp[i][w]$-এর জন্য একটি অপটিমাল সমাধান $S$ আছে। যদি $S$-এ আইটেম $i$ না থাকে, তাহলে $S$ অবশ্যই প্রথম $i-1$টি আইটেম ও ধারণক্ষমতা $w$-এর জন্য একটি অপটিমাল সমাধান হতে হবে — কারণ যদি এর চেয়ে ভালো কোনো সমাধান $S'$ থাকত (প্রথম $i-1$টি আইটেম, ধারণক্ষমতা $w$-এ), তাহলে $S'$ নিজেই $S$-এর চেয়ে ভালো একটি সমাধান হতো মূল সমস্যাতেও — যা $S$-এর অপটিমাল হওয়ার সাথে সাংঘর্ষিক। একইভাবে, যদি $S$-এ আইটেম $i$ থাকে, তাহলে $S \setminus \{i\}$ অবশ্যই প্রথম $i-1$টি আইটেম ও ধারণক্ষমতা $w-w_i$-এর জন্য অপটিমাল হতে হবে (একই "কাট-অ্যান্ড-পেস্ট" যুক্তি)। দুই ক্ষেত্রেই অপটিমাল সমাধান ছোট সাবপ্রবলেমের অপটিমাল সমাধান থেকে গঠিত — এটাই অপটিমাল সাবস্ট্রাকচার। আর $dp[i-1][w]$-এর মতো অবস্থা বহু ভিন্ন $(i, w)$ থেকে পুনরায় প্রয়োজন হতে পারে — এটাই ওভারল্যাপিং সাবপ্রবলেম, যা L24-এর সাধারণ ব্যাখ্যার একটি সরাসরি প্রয়োগ।
৩ · যাচাই — নেইভ এক্সপোনেনশিয়াল ব্রুট-ফোর্স বনাম DP
উপরের রিকারেন্সটিকে কোনো cache ছাড়াই সরাসরি একটি রিকার্সিভ ফাংশন হিসেবে লিখলে সেটি একটি সঠিক কিন্তু $O(2^n)$ সময়ের ব্রুট-ফোর্স হয়ে যায় (প্রতিটি আইটেমে দুটি শাখা — নেওয়া অথবা না নেওয়া)। এটিই আমাদের "গ্রাউন্ড ট্রুথ" — এটি ধীর কিন্তু স্পষ্টভাবে সঠিক, কারণ এটি সংজ্ঞা থেকে সরাসরি সব সম্ভাবনা পরীক্ষা করে। নিচের কোডে এটি এবং ট্যাবুলেটেড DP পাশাপাশি চালিয়ে $n \le 15$ ও ছোট ধারণক্ষমতার একাধিক র্যান্ডম ইনস্ট্যান্সে তুলনা করা হয়েছে।
import random
def knapsack_brute(i, cap, weights, values, n):
# নেইভ এক্সপোনেনশিয়াল রিকার্সন -- কোনো cache নেই, প্রতিটি আইটেমে দুটি শাখা
if i == n or cap == 0:
return 0
if weights[i] > cap:
return knapsack_brute(i + 1, cap, weights, values, n)
skip = knapsack_brute(i + 1, cap, weights, values, n)
take = values[i] + knapsack_brute(i + 1, cap - weights[i], weights, values, n)
return max(skip, take)
def knapsack_dp(weights, values, capacity):
# ট্যাবুলেটেড DP -- ঠিক একই রিকারেন্স, ইটারেটিভভাবে
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
wt = weights[i - 1]
val = values[i - 1]
for w in range(capacity + 1):
if wt > w:
dp[i][w] = dp[i - 1][w]
else:
dp[i][w] = max(dp[i - 1][w], val + dp[i - 1][w - wt])
return dp[n][capacity]
rng = random.Random(42)
print(f"{'n':>3} | {'capacity':>8} | {'brute-force':>11} | {'DP':>6}")
for trial in range(8):
n = rng.randint(3, 15)
capacity = rng.randint(5, 30)
weights = [rng.randint(1, 12) for _ in range(n)]
values = [rng.randint(1, 20) for _ in range(n)]
brute = knapsack_brute(0, capacity, weights, values, n)
dp = knapsack_dp(weights, values, capacity)
assert brute == dp, "মিসম্যাচ!"
print(f"{n:>3} | {capacity:>8} | {brute:>11} | {dp:>6}")
print("\nসব ৮টি র্যান্ডম ইনস্ট্যান্সে brute-force == DP")
random.seed-এর বদলে এখানে random.Random(42) একটি স্বতন্ত্র জেনারেটর অবজেক্ট হিসেবে
ব্যবহার করা হয়েছে — ফলাফল পুনরুৎপাদনযোগ্য (reproducible) থাকে। বাস্তবে চালালে ঠিক এই ৮টি ইনস্ট্যান্স
পাওয়া যায় (যেমন $n{=}13, \text{capacity}{=}8 \Rightarrow 60$; $n{=}9, \text{capacity}{=}16 \Rightarrow 81$)
এবং প্রতিটিতে brute-force ও DP কলাম সম্পূর্ণ অভিন্ন — দুটোই একই রিকারেন্স সমাধান
করছে বলে এটাই প্রত্যাশিত। লক্ষ্য করুন $n \le 15$ রাখা হয়েছে ইচ্ছাকৃতভাবে, কারণ $2^{15} \approx 32{,}768$
এখনও দ্রুত চলে, কিন্তু $n$ আরও বড় হলে ব্রুট-ফোর্স ব্যবহারিকভাবে অচল হয়ে যাবে (যেখানে DP $O(nW)$-এ চলবে)।
0/1 ন্যাপস্যাক দেখায় কেন DP গ্রিডির চেয়ে বেশি সাধারণ (general) — এটি প্রতিটি সাবপ্রবলেমের সব সম্ভাব্য উত্তর মনে রাখে, তাই কোনো একক "স্থানীয়-সেরা" পছন্দের উপর নির্ভর করতে হয় না। মূল্য দিতে হয় সময়ে — $O(2^n)$ থেকে $O(nW)$-এ নেমে আসা "সিউডো-পলিনোমিয়াল" (যেহেতু $W$ ইনপুটের বিট-সংখ্যার সূচকীয়ভাবে বড় হতে পারে) — যা M11-এ NP-হার্ডনেস আলোচনায় আবার প্রাসঙ্গিক হবে।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১ উপরের DP টেবিলের স্পেস কমপ্লেক্সিটি $O(nW)$। এটি কি $O(W)$-এ নামানো সম্ভব?
হ্যাঁ — $dp[i][w]$ শুধু $dp[i-1][*]$ (আগের সারি)-এর উপর নির্ভর করে, তাই একটি ১D অ্যারে রেখে প্রতিটি $i$-এর জন্য ধারণক্ষমতা বড় থেকে ছোট দিকে (right to left) আপডেট করলেই যথেষ্ট। বড় থেকে ছোট দিকে যাওয়া জরুরি, কারণ ছোট দিকে গেলে একই সারিতে $dp[w - w_i]$-এর মান ইতিমধ্যে এই ধাপে (একই আইটেম $i$-এর জন্য) আপডেট হয়ে গিয়ে ভুল ফলাফল (আইটেমটি একাধিকবার ব্যবহৃত হওয়ার সমতুল্য) দিতে পারত।
প্র ০২
ব্রুট-ফোর্স ফাংশনে weights[i] > cap চেক না থাকলে কি ভুল উত্তর আসত, নাকি শুধু ধীর হতো?
শুধু ধীর হতো, ভুল হতো না — কারণ take শাখায় cap - weights[i] ঋণাত্মক হয়ে যেত,
এবং পরবর্তী কলে cap == 0 চেকটি কখনো সত্য হতো না (ঋণাত্মক থেকে শূন্যে পৌঁছানো এড়িয়ে যেত)। তাই
এক্ষেত্রে বেস কেসে cap <= 0 চেক করা নিরাপদ হতো তবুও সঠিক থাকত, কিন্তু weights[i] > cap
চেকটি রাখা ভালো অভ্যাস — এটি অপ্রয়োজনীয় রিকার্সিভ কল এড়িয়ে কোডকে দ্রুত করে এবং রিকারেন্সের সংজ্ঞার সাথে
হুবহু মিলে যায়।
প্র ০৩ যদি দুটি আইটেমের ওজন ও মূল্য দুটোই সমান হয়, তাহলে DP টেবিলে কি কোনো সমস্যা হবে?
না — রিকারেন্সটি আইটেমের পরিচয় নিয়ে চিন্তা করে না, শুধু তার ওজন ও মূল্য নিয়ে চিন্তা করে, এবং প্রতিটি আইটেমকে ইনডেক্স অনুযায়ী ঠিক একবারই বিবেচনা করে (লুপে $i = 1$ থেকে $n$)। দুটি আইটেম হুবহু একই ওজন-মূল্যের হলেও তারা ভিন্ন ইনডেক্সে আলাদাভাবে গণনা হবে — কোনো ডাবল-কাউন্টিং হবে না। ফলাফল ঠিক তেমনই হবে যেন আইটেম দুটি ভিন্ন হতো।
অনুশীলন
-
চিন্তা করুন: যদি সব আইটেমের ওজন সমান হয় (যেমন সবগুলোর ওজন $2$), তাহলে 0/1 ন্যাপস্যাক
সমস্যাটি কি সহজ হয়ে যায়? কীভাবে সমাধান করবেন?
হ্যাঁ — যদি সব ওজন সমান হয় (ধরুন $w$), তাহলে ধারণক্ষমতা $W$-এ সর্বোচ্চ $\lfloor W/w \rfloor$-টি আইটেম রাখা যাবে, এবং যেহেতু সব আইটেমের "খরচ" (ওজন) সমান, তাই সেরা কৌশল হলো সর্বোচ্চ মূল্যের $\lfloor W/w \rfloor$-টি আইটেম বেছে নেওয়া (মূল্য অনুযায়ী সাজিয়ে গ্রিডিভাবে সেরাগুলো নেওয়া) — এক্ষেত্রে গ্রিডি সঠিক, কারণ প্রতিটি আইটেমের "দাম" অভিন্ন বলে অনুপাত তুলনার প্রয়োজনই পড়ে না।
-
পরীক্ষা করুন: উপরের কোড সেলে
rng.randint(3, 15)-কেrng.randint(16, 18)-এ পরিবর্তন করে Run চাপুন এবং লক্ষ্য করুন ব্রুট-ফোর্স সেলটি চলতে কতটা বেশি সময় নেয় ($2^{16}$ থেকে $2^{18}$)।উত্তর এখনও DP-এর সাথে মিলবে (সঠিকতা বদলায় না), কিন্তু ব্রুট-ফোর্স সেলটি লক্ষণীয়ভাবে বেশি সময় নেবে, কারণ $n$ প্রতি একক বাড়লে কলের সংখ্যা প্রায় দ্বিগুণ হয় ($O(2^n)$)। এটি এই কোর্সের একটি বারবার আসা থিম — "সঠিকতা" ও "ব্যবহারযোগ্য গতি" দুটো আলাদা প্রশ্ন, এবং $n$ বড় হলে ব্রুট-ফোর্সের সঠিকতা টিকে থাকলেও এর ব্যবহারযোগ্যতা হারিয়ে যায়।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৭টি পাঠ পরবর্তী পাঠে (L26) আমরা লংগেস্ট কমন সাবসিকোয়েন্স কভার করব — একটি ভিন্ন ধরনের ২D DP, যেখানে রাজ্য দুটি স্ট্রিং-এর প্রিফিক্সের উপর ভিত্তি করে সংজ্ঞায়িত।
- Data Structures & Algorithms কোর্স সহোদর কোর্স 0/1 ন্যাপস্যাকের ইমপ্লিমেন্টেশন ও আরও ভ্যারিয়েন্ট (যেমন আইটেম পুনরুদ্ধার) সেই কোর্সে দেখানো হয়েছে।
- সব 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 — সব এক জায়গায়।