Recursion ও Divide and Conquer — ছোট নিজেকে ডাকা
Recursion and Divide and Conquer
Recursion আসলে induction-এরই কোড-রূপ — কিন্তু প্রতিটা call একটা stack frame-এর দাম দেয়, আর divide-and-conquer-এর recurrence মেপে দেয় সেই দাম কত।
আগে এটা বুঝি
Sorting module-এ আপনি ইতিমধ্যে দুইবার recursion ব্যবহার করেছেন — quicksort নিজেকে ডেকেছে দুই partition-এর ওপর, mergesort নিজেকে ডেকেছে দুই half-এর ওপর। কিন্তু আমরা কখনো থেমে জিজ্ঞেস করিনি: call নিজেকে ডাকলে ঠিক কী ঘটে? Memory-তে কী হয়? কতটা খরচ? কোথায় গিয়ে ভাঙে?
আজ সেই প্রশ্নগুলোর হিসাব-নিকাশ। আর শুরুটা হঠাৎ করে গণিত থেকে — mathematics module-এর induction লেসনে আপনি যে প্রমাণ-কৌশল দেখেছেন, recursion তারই যমজ। Induction বলে:
- Base: ছোটতম input-এর জন্য কথাটা সত্য।
- Inductive step: কোনো input-এর জন্য সত্য হলে, তার এক ধাপ বড় input-এর জন্যও সত্য।
Recursion ঠিক তা-ই করে:
- Base case: ছোটতম input-এ সরাসরি উত্তর দাও।
- Recursive case: বড় input-কে ছোট করে নিজেকে ডাকো, ফল জোড়া লাগাও।
দুই ক্ষেত্রেই একই চুক্তি: ছোটটা ঠিক থাকলে বড়টাও ঠিক — induction প্রমাণ করে সেটা অসীম পর্যন্ত, recursion চালিয়ে দেয় সেটা বাস্তবে। আজ আমরা এই সম্পর্কটা কংক্রিট করব, call stack-এর হার্ডওয়্যার-খরচ মাপব, divide-and-conquer-এর recurrence সমাধান করব, আর দেখব একটা ভুল লেখা recursion কীভাবে exponential সময়ে প্রোগ্রাম জিম্মা করে ফেলে।
মূল ধারণা
Induction, কিন্তু executable
Factorial দিয়ে শুরু — সবচেয়ে ছোট পূর্ণ উদাহরণ:
def fact(n: int) -> int:
if n <= 1: # base case ← induction-এর base
return 1
return n * fact(n - 1) # recursive case ← inductive stepপাশে পাশে রাখুন:
| Mathematics (induction) | Code (recursion) |
|---|---|
| P(1) সত্য | fact(1) = 1 — base case |
| P(k) সত্য হলে P(k+1) সত্য | fact(n) ঠিক হলে fact(n+1) ঠিক — একটা multiply |
| সুচিন্দ্রিত ভিত্তি + ধাপ | base case + progress toward base |
Induction-এর প্রমাণে সবচেয়ে বিপজ্জনক ভুল হলো base case ভুলে যাওয়া — তাহলে গোটা সিঁড়ি শূন্যে ঝুলে থাকে। Recursion-এ সেই একই ভুল infinite recursion — আর এখানে গণিতের চেয়ে নির্মম শাস্তি আছে: প্রোগ্রাম crash করে।
Call stack — প্রতিটা call-এর ভাড়া টিকিট
fact(4) চালালে memory-তে কী হয়? Operating-systems module-এর
stack-and-heap লেসনে আপনি দেখেছেন — প্রতিটা function call-এ runtime একটা stack frame বানায়। Frame-এ থাকে:
fact(4)-এর সময় call stack (উপরে সবচেয়ে নতুন):
│ fact(1): n=1 │ ← base case, ফেরে সঙ্গে সঙ্গে
│ fact(2): n=2, 2×? │ ← fact(1)-এর উত্তরের অপেক্ষায়
│ fact(3): n=3, 3×? │
│ fact(4): n=4, 4×? │ ← সবার আগে call হয়েছে, সবার পরে শেষ
└───────────────────────┘প্রতিটা frame বহন করে: return address (ফিরে কোথায় চলব), parameter (n), local variable, আর saved register। মানে একটা call-এর খরচ শুধু সময় না — memory। গভীর recursion-এর মোট মেমরি ব্যবহার = depth × frame-size।
এই হিসাবটা কংক্রিট করি। সাধারণত OS একটা thread-কে fixed-size stack দেয় — Linux main thread-এ সাধারণত ৮ MB, Python-এর নিজের interpreter stack-এর ওপরে C-stack-ও থাকে। fact-জাতীয় সাধারণ function-এর frame ধরুন ~১০০ byte (parameter + locals + alignment):
৮ MB ÷ ১০০ byte/frame ≈ ৮০,০০০ frame — recursion-এর practical ceilingPython এখানে আরও কড়া: default sys.getrecursionlimit() = 1000 — interpreter-level guard, যাতে C-stack overflow করে process-টা segfault না করে পরিষ্কার exception দেয়।
Divide and conquer — recurrence হলো খরচের চুক্তিপত্র
Divide-and-conquer হলো recursion-এর সবচেয়ে শৃঙ্খলিত রূপ — তিন ধাপে contract:
- Divide: সমস্যা
b-টা সমান টুকরোয় ভাঙো। - Conquer: প্রতিটা টুকরো recursively solve করো — মোট
a-টা call (সাধারণতa ≤ b)। - Combine: টুকরোগুলো জোড়া লাগাও — এই ধাপের খরচ
f(n)।
মোট খরচের সমীকরণ — recurrence relation:
এই একটা সমীকরণই পুরো খরচের গল্প বলে। Mergesort-এর জন্য: divide free, combine merge O(n), দুই half — T(n) = 2T(n/2) + O(n)। Binary search-এর জন্য: combine free, একটাই call — T(n) = T(n/2) + O(1)।
Master theorem — recurrence-এর তিনটা verdict
Recurrence বারবার expand করলে একটা recursion tree তৈরি হয় — গভীরতা log_b(n), আর প্রতি level-এ কাজের পরিমাণ হয় বাড়ে, কমে, বা সমান থাকে। Master theorem-এর তিনটা case আসলে একটাই প্রশ্নের তিন উত্তর: root-এর কাজ জিতবে, leaf-দের কাজ জিতবে, নাকি draw?
| Case | শর্ত (roughly) | বিজয়ী | Verdict | উদাহরণ |
|---|---|---|---|---|
| ১ | leaf-heavy: প্রতি level-এ কাজ বাড়ে | leaf (নিচ) | T(n) = Θ(n^log_b a) | T(n)=2T(n/2)+O(1) → Θ(n) |
| ২ | balanced: প্রতি level-এ সমান কাজ | কেউ না | T(n) = Θ(f(n) · log n) | T(n)=2T(n/2)+O(n) → Θ(n log n) |
| ৩ | root-heavy: প্রতি level-এ কাজ কমে | root (উপর) | T(n) = Θ(f(n)) | T(n)=T(n/2)+O(n) → Θ(n) |
দুইটা worked example আপনার চেনা:
- Mergesort:
a=2, b=2, f(n)=n। Level-wise কাজ: level 0-তেn, level 1-এ2·(n/2)=n, level 2-তে4·(n/4)=n— প্রতি level সমান! Level সংখ্যা log₂ n ⇒ মোট Θ(n log n) — case ২। - Binary search:
a=1, b=2, f(n)=1। প্রতি level-এ ঠিক ১ unit কাজ, level সংখ্যা log₂ n ⇒ মোট Θ(log n) — আবার case ২-এর চেতনা (এখানে f constant বলে Θ(log n))।
ভেতরে কী ঘটছে
Recursion থেকে iteration — compiler-ও করে, আপনিও পারবেন
Compiler theory module-এ আপনি দেখব কোনো compiler tail-call optimization করে। কিন্তু সাধারণ recursion-এর সার্বজনীন truth হলো: যেকোনো recursion explicit stack দিয়ে iteration-এ ভাঙা যায় — কারণ recursion নিজেই compiler-এর implicit stack দিয়ে implement করা। Stack টা আপনি নিজে চালালে interpreter-এর depth-limit আর frame overhead দুটোই নিয়ন্ত্রণে।
fact-এর iterative রূপ trivial। আসল খেলা tree-shaped recursion-এ —
যেমন directory traversal বা tree sum:
def tree_sum_recursive(node):
if node is None:
return 0
return node.val + tree_sum_recursive(node.left) \
+ tree_sum_recursive(node.right)
def tree_sum_iterative(root):
"""একই হিসাব, explicit stack — depth limit নেই"""
total = 0
stack = [root]
while stack:
node = stack.pop()
if node is None:
continue
total += node.val
stack.append(node.left)
stack.append(node.right)
return totalনিয়মটা সর্বজনীন: recursive case-এ যা যা call করতাম, explicit stack-এ push করো; base case-এ যা ফেরত আসত, সেটাই accumulate করো। Order নিয়ন্ত্রণ করতে চাইলে push-এর ক্রম উল্টাও (stack LIFO)।
Naive fibonacci — exponential খরচের বিচার
def fib_naive(n: int) -> int:
if n <= 1:
return n
return fib_naive(n - 1) + fib_naive(n - 2)এক নজরে নিরীহ। কিন্তু call-tree আঁকলে ভয় ধরে — fib(5)-এর গাছটা example section-এ পুরো আঁকব। এখন হিসাব: T(n) = T(n-1) + T(n-2) + O(1) — প্রতিটা node দুইটা child জন্ম দেয়, tree-র size প্রায় golden-ratio-powered: মোট call ≈ φⁿ যেখানে φ ≈ 1.618। মানে fib_naive(50) ≈ ২৫ বিলিয়ন call — আর fib(5)-এর গাছে fib(1)-এর মান ৫ বার আলাদা আলাদা হিসাব হয়েছে।
Subproblem বারবার একই — overlapping subproblems। পরের লেসনের dynamic programming-এর পুরো বিষয় এই একটা observation-কে ঘিরে: একবার হিসাব করো, জমা রাখো (memoization), আর কখনো দ্বিতীয়বার হিসাব কোরো না — খরচ exponential থেকে linear:
def fib_memo(n: int, memo=None) -> int:
if memo is None:
memo = {}
if n <= 1:
return n
if n in memo:
return memo[n]
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
return memo[n]Depth তবু O(n) — তাই fib_memo(20000)-ও Python default limit-এ আটকাবে, যদিও খরচ linear। Memoization সময় বাঁচায়, stack বাঁচায় না।
উদাহরণ
হাতে-কলমে দুইটা trace — একটা সুস্থ, একটা অসুস্থ
Trace ১: recursive binary search — master theorem-এর বাস্তব রূপ
Sorted array A = [2, 5, 9, 13, 17, 21, 28, 36, 41, 47], target 21:
search(lo=0, hi=9) mid=4, A[4]=17 < 21 → ডানে
└─ search(lo=5, hi=9) mid=7, A[7]=36 > 21 → বামে
└─ search(lo=5, hi=6) mid=5, A[5]=21 == 21 → FOUNDতিনটা call — ঠিক ⌈log₂ 11⌉ = ৪-এর মধ্যে। প্রতিটা call-এ সমস্যা অর্ধেক, combine শূন্য — searching-binary-search লেসনের iterative version-এর সাথে হুবহু একই খরচ, শুধু এখানে প্রতি call-এ একটা extra frame-এর memory খরচ। এই খরচটাই recursion-এর ভাড়া।
Trace ২: fib_naive(5) — একই কাজ বারবার
f(5)
/ \
f(4) f(3)
/ \ / \
f(3) f(2) f(2) f(1)
/ \ / \ / \
f(2) f(1) f(0)f(1)f(0)f(1)
/ \
f(0) f(1)
মোট node = 15। f(3) হিসাব হয়েছে ২ বার, f(2) — ৩ বার,
f(1) — ৫ বার। আর বড় n-এ এই অপচয় exponential হয়ে ফুলে ওঠে।Node-counting দিয়ে প্রমাণ: N(n) = call সংখ্যা। Base: N(0)=N(1)=1 (tree-এ leaf গণনা)। Recursive: N(n) = 1 + N(n-1) + N(n-2)। এই recurrence-এর solution N(n) = 2·F(n+1) − 1, আর F(n) ≈ φⁿ/√5 — তাই Θ(φⁿ) ≈ Θ(1.618ⁿ)। n=40-এ প্রায় ৩৩ কোটি call; n=50-এ ২৫ বিলিয়ন। Exponential মানে প্রতি n-এ খরচ ৬২% বাড়ে — কোনো computer এটাকে brute-force-এ বাঁচাতে পারে না।
f(5) ← 1×
┌─────────┴────────┐
f(4) f(3) ← 2×
┌────┴────┐ ┌───┴───┐
f(3) f(2) f(2) f(1) ← f(3): 2×, f(2): 3×
┌──┴──┐ ┌──┴──┐ ┌──┴──┐
f(2) f(1) f(0) f(1) f(0) f(1) ← f(1): 5×, f(0): 3×
┌─┴─┐
f(0) f(1)
unique subproblems: f(0..5) = 6টা মাত্র — বাকি সব duplicate!
memoized খরচ: 6টা হিসাব + 10টা lookup = O(n)ছবিতে
Recursion-এর তিন মুখ — এক ছবিতে
- Linear recursionএক call-এ একটাই recursive call — fact, binary search; সহজে loop-এ রূপান্তর
- Tree recursionএক call-এ একাধিক recursive call — fib naive, tree traversal; overlap হলে DP-র শিকার
- Divide and conquerTree recursion-এর শৃঙ্খলিত রূপ — সমান ভাগ + combine; recurrence দিয়ে খরচ মাপা যায়
implicit stack (recursion): explicit stack (loop): accumulator (loop):
sum([a,b,c,d]) stack=[a,b,c,d] acc=0
└ a + sum([b,c,d]) while stack: for x in lst:
└ b + sum([c,d]) acc += stack.pop(); acc += x
└ c + sum([d]) push children...
└ d + sum([])
depth frames: 5 heap stack: ≤ width memory: 1 var
memory: O(n) frames memory: O(width) memory: O(1)
risk: RecursionError risk: নেই risk: নেইPython-এ প্রথম column-টা n=1000-তে মৃত্যুঞ্জলি (default limit), দ্বিতীয় আর তৃতীয় কোটি element-এও অটুট। এই কারণেই production Python-এ গভীর structure traversal (JSON parse tree, filesystem, DOM) প্রায় সবসময় explicit-stack বা queue-based — তৃতীয় লেসনের BFS-এর চেতনাই।
নিজে চালিয়ে দেখুন
fibonacci-র তিন রূপে stopwatch — exponential বনাম linear, নিজের চোখে
import sys
import time
# ---------- তিন implementation ----------
def fib_naive(n: int) -> int:
if n <= 1:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
def fib_memo(n: int, _memo={}) -> int:
if n <= 1:
return n
if n in _memo:
return _memo[n]
r = fib_memo(n - 1) + fib_memo(n - 2)
_memo[n] = r
return r
def fib_iter(n: int) -> int:
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
# ---------- timing ----------
def timed(fn, arg, reps=1):
t0 = time.perf_counter()
result = fn(arg)
dt = time.perf_counter() - t0
return result, dt * 1000 / reps
print(f'{"n":>4} {"naive ms":>12} {"memo ms":>10} {"iter ms":>9} speedup')
for n in (25, 30, 33, 35):
_, t_n = timed(fib_naive, n)
r_m, t_m = timed(fib_memo, n)
r_i, t_i = timed(fib_iter, n)
assert r_m == r_i
print(f'{n:>4} {t_n:>12.1f} {t_m:>10.3f} {t_i:>9.3f} {t_n/t_i:>7.0f}x')
# growth ratio of naive — exponential-এর স্বাক্ষর
_, a = timed(fib_naive, 31)
_, b = timed(fib_naive, 32)
print(f'\nnaive growth ratio T(32)/T(31) = {b/a:.2f} (φ≈1.62 হলে exponential confirmed)')
sys.setrecursionlimit(120) # danger demo — ছোট করে দেখাই
try:
fib_naive(150)
except RecursionError:
print('depth test n=150: RecursionError — limit ছুঁয়েছে (crash না, exception)')
finally:
sys.setrecursionlimit(3000) # ফিরিয়ে আনুন — বড় limit-এর লোভ করবেন নাসাধারণ ফলাফল (laptop, Python 3.12 — সংখ্যা বদলাবে, pattern মিলবে):
n naive ms memo ms iter ms speedup
25 26.5 0.042 0.004 6865x
30 298.6 0.005 0.004 72641x
33 1167.1 0.003 0.004 282600x
35 3018.1 0.003 0.004 703851x
naive growth ratio T(32)/T(31) = 1.62 (φ≈1.62 হলে exponential confirmed)
depth test n=150: RecursionError — limit ছুঁয়েছে (crash না, exception)টেবিল পড়ার তিনটা চোখ:
১. Naive-এর সময় প্রতি n-এ প্রায় ১.৬২ গুণ বাড়ছে — golden ratio φ-এর হাতে-কলমে সাক্ষাৎ। n=35-এ পাঁচ সেকেন্ড; n=50 extrapolate করলে ঘণ্টা-স্কেল।
২. Memo আর iter-এর মধ্যে constant-factor gap (~২×) — dict lookup বনাম bare arithmetic। Asymptotic-notation লেসনের সেই শিক্ষা: big-O এক হলেও constant বাস্তব। (Depth measurement build section-এর Counter-এ — max_depth ঠিক n+1, linear recursion-এর depth = input size।)
৩. RecursionError graceful — কিন্তু limit ১০⁶ বাড়িয়ে দিলে একই প্রোগ্রাম segfault করতে পারত, কারণ আসল C-stack এখনো সীমিত। Guard বাড়ানো সমস্যা সমাধান না।
Naive tree recursion-এর খরচ প্রতি n-এ ~১.৬২ গুণ বাড়ে (exponential), memoization আর iteration একই উত্তর linear সময়ে দেয় — আর recursion-depth instrumentation দেখায় কেন গভীর input-এ Python-এর limit আঘাত হানে।
নিজে বানান
power(x, n) তিনভাবে — একই সমস্যায় recursion-এর তিন মাত্রা
- Naive সংস্করণ লিখুন: x-কে n বার গুণ — O(n) সময়
- Divide-and-conquer সংস্করণ লিখুন: x^64 = (x^32)^2 — প্রতি step-এ n অর্ধেক, O(log n)
- Memoized সংস্করণ লিখুন এবং দেখান এটা divide-version-এর চেয়ে ধীর কেন (overhead, লাভ নেই)
- প্রতিটা সংস্করণে call-count ও max-depth counter বসান
- Test suite দিয়ে তিনটারই সঠিকতা যাচাই করুন — edge case: n=0, n=1, negative n, x=0
লক্ষ্য: একই সূত্রের তিন implementation-এর call-count আর depth পাশে পাশে দাঁড় করানো — recursion-এর খরচ-মডেল হাতে-কলমে অনুভব।
def power_naive(x: float, n: int, ctr=None) -> float:
"""O(n) multiplications, depth O(n) — সোজাসাপ্টা"""
if ctr: ctr.enter()
try:
if n == 0:
return 1.0
if n < 0:
return 1.0 / power_naive(x, -n, ctr)
return x * power_naive(x, n - 1, ctr)
finally:
if ctr: ctr.exit()
def power_divide(x: float, n: int, ctr=None) -> float:
"""O(log n) multiplications, depth O(log n) — fast exponentiation"""
if ctr: ctr.enter()
try:
if n == 0:
return 1.0
if n < 0:
return 1.0 / power_divide(x, -n, ctr)
half = power_divide(x, n // 2, ctr)
if n % 2 == 0:
return half * half # x^(2k) = (x^k)^2
return x * half * half # x^(2k+1) = x·(x^k)^2
finally:
if ctr: ctr.exit()
def power_memo(x: float, n: int, ctr=None, _m={}) -> float:
"""divide-এর memo মেখানো রূপ — এখানে subproblem unique, তাই লাভ শূন্য"""
if ctr: ctr.enter()
try:
if n == 0:
return 1.0
if n < 0:
return 1.0 / power_memo(x, -n, ctr, _m)
key = (x, n) # base-ভিত্তিক key — ভিন্ন x ভিন্ন ঘর!
if key in _m:
return _m[key]
half = power_memo(x, n // 2, ctr, _m)
r = half * half if n % 2 == 0 else x * half * half
_m[key] = r
return r
finally:
if ctr: ctr.exit()
class Counter:
def __init__(self):
self.calls = 0
self.depth = 0
self.cur = 0
def enter(self):
self.calls += 1
self.cur += 1
self.depth = max(self.depth, self.cur)
def exit(self):
self.cur -= 1
if __name__ == '__main__':
# ---------- correctness ----------
tests = [(2.0, 10), (3.0, 0), (5.0, 1), (2.0, 63), (-2.0, 7),
(2.0, -3), (0.0, 5)]
for x, n in tests:
results = {name: fn(x, n) for name, fn in
[('naive', power_naive), ('divide', power_divide),
('memo', power_memo)]}
assert len({round(v, 9) for v in results.values()}) == 1, (x, n)
print(f'{x}^{n:<3} = {results["divide"]:>12.4f} (তিন সংস্করণ একমত)')
# ---------- cost comparison ----------
import sys
sys.setrecursionlimit(2000) # naive-এর depth 1025 — default 1000 ছাড়িয়ে যেত!
print(f'\n{"version":<8} {"calls":>8} {"max_depth":>10} n=1024')
for name, fn in [('naive', power_naive), ('divide', power_divide),
('memo', power_memo)]:
c = Counter()
fn(2.0, 1024, c)
print(f'{name:<8} {c.calls:>8} {c.depth:>10}')প্রত্যাশিত output:
2.0^10 = 1024.0000 (তিন সংস্করণ একমত)
3.0^0 = 1.0000 (তিন সংস্করণ একমত)
5.0^1 = 5.0000 (তিন সংস্করণ একমত)
2.0^63 = 9223372036854775808.0000 (তিন সংস্করণ একমত)
-2.0^7 = -128.0000 (তিন সংস্করণ একমত)
2.0^-3 = 0.1250 (তিন সংস্করণ একমত)
0.0^5 = 0.0000 (তিন সংস্করণ একমত)
version calls max_depth n=1024
naive 1025 1025
divide 12 12
memo 10 10পড়ার বিষয়: naive-এর depth 1025 — Python default limit-এর ভয়ের জায়গা; divide-এর 11 — log₂(1024)+1। Memo এখানে কোনো লাভ দেখায় না, কারণ divide-chain-এ প্রতিটা subproblem একবারই আসে (unique chain: 1024 → 512 → 256 …) — memoization-এর শর্ত overlapping subproblems, যা এখানে নেই। এই negative result-টাই পরের লেসনের DP-র প্রবেশদ্বার।
নিজে বাড়ান:
১. Modular exponentiation (x^n) mod m-এ extend করুন — RSA-র মূল
primitive, প্রতিটা গুণের পরে mod নিলে number ফুলে ওঠে না।
২. Iterative bitwise সংস্করণ (binary exponentiation, loop-এ) যোগ করুন —
depth O(1), n-এর binary bits scan করে।
৩. Matrix exponentiation-এ generalize করে O(log n)-এ fibonacci বের
করুন — পরের লেসনের DP-র সাথে তুলনার জন্য।
বাস্তব সিস্টেমে
Recursion বাস্তবে কোথায়
Parser আর compiler। Expression grammar স্বাভাবিকভাবে recursive —
expr := expr + term | term; তাই recursive descent parser (compilers
module-এ আপনি নিজেই লিখবেন) grammar-এর প্রতিটা rule-এ একটা mutually recursive function। Expression nesting depth-ই recursion depth — browser-এর CSS selector parser পর্যন্ত একই pattern।
Filesystem আর DOM। Directory tree traversal-এর natural formulation recursion — প্রতিটা folder ছোট নিজের সমস্যা। কিন্তু adversarial filesystem (হাজার-স্তরের nested symlink) real threat, তাই production tool (find, ripgrep) explicit stack/queue ব্যবহার করে। Browser DOM-এও event bubbling হলো আসলে tree-উপরের recursion।
Quicksort আর mergesort। Sorting লেসনের দুই champion — দুটোই divide-and-conquer, দুটোরই recurrence master theorem-এ সমাধান। Quicksort-এর tail-recursion trick (ছোট half recurse, বড় half loop) depth-কে O(log n)-এ lock করে — এই লেসনের তত্ত্বের production application।
Graphics আর mathematical software। Ray tracing-এ reflection ray recursively trace (depth limit ছাড়া infinite — দুই mirror মুখোমুখি), quadtree/octree subdivision, k-d tree nearest neighbor — সব divide-and-conquer। Karatsuba multiplication (O(n^1.585)), FFT, Strassen — fast-algorithm revolution divide-and-conquer recurrence ভাঙার ফসল; master theorem-এর case ১-ই তাদের গল্প: combine সস্তা, leaf-দের বিশাল বাহিনী।
যে ভুলগুলো সবাই করে
“Recursion সবসময় iteration-এর চেয়ে ধীর।”
Function-call overhead (~ns scale) আছে ঠিক, কিন্তু বাস্তব পার্থক্য algorithm-এর structure-এ: tree traversal-এ recursion প্রায়ই explicit stack-এর চেয়ে দ্রুত (CPU branch predictor আর register allocation frame-reuse করে), আর divide-and-conquer-এ (mergesort/quicksort) তো recursion-ই natural form। সঠিক বাক্য: Python-এ অগভীর recursion-এর constant overhead উপেক্ষাযোগ্য; গভীর recursion-এর আসল ঝুঁকি speed না — stack limit।
“Stack overflow মানে algorithm-এ logic bug।”
Algorithm নিখুঁত হয়েও stack overflow হতে পারে — linked-list traversal recursion-এ লিখলে n=100,000-এ depth 100,000, কোনো bug ছাড়াই limit ভাঙে। Overflow-এর কারণ input size বনাম stack budget, logic না। Diagnosis প্রশ্ন একটাই: worst-case depth কত, আর budget কত? Depth O(log n) হলে নিরাপদ, O(n) হলে বড় input-এ মৃত্যু-নিশ্চিত।
“Memoization সব recursion-কে দ্রুত করে।”
Memoization-এর লাভ তখনই যখন overlapping subproblems — একই subproblem বারবার আসে। Binary search-এ প্রতিটা subproblem unique; power-এর divide-chain-এও unique — memo শুধু dict-overhead যোগ করে, কিছু বাঁচায় না (build section-এর measurement দেখুন)। Overlap আছে কি না আগে দেখুন, তারপর memo লিখুন — পরের লেসনের DP এই শর্তটাকেই প্রথম axiom করবে।
“sys.setrecursionlimit() বাড়িয়ে দিলেই deep recursion safe।”
Limit-টা interpreter-এর guard মাত্র; আসল সীমা OS-এর C-stack (সাধারণত ৮ MB)। Guard সরালে Python আর আটকাতে পারে না — C-stack ছাপিয়ে গেলে process segfault করে, exception-ও নেই, traceback-ও নেই। Production rule: depth যদি input-dependent হয় এবং unbounded হতে পারে — explicit stack বা queue লিখুন, limit ধরে ব্যবসা নয়।
বুঝেছেন কি না দেখুন
1Recursion-এর কোন দুইটা উপাদান induction-এর কোন দুইটা উপাদানের প্রতিরূপ? এক লাইনে প্রতিটা মেলান।
স্মরণ
Base case ↔ induction-এর base (P(1) সত্য) — ছোটতম input-এ সরাসরি উত্তর। Recursive case ↔ inductive step (P(k) ⇒ P(k+1)) — বড় input-কে ছোট-তে reduce করে নিজেকে trust করা। Progress condition ↔ well-founded ordering — প্রতিটা step-এ base-এর দিকে এগোনো।
2Frame size 200 byte, thread stack 8 MB। Depth 50,000-এর recursion safe কি না হিসাব করুন। Python default recursionlimit=1000 এই scenario-তে কী করবে?
প্রয়োগ
মোট দরকার: 50,000 × 200 B = 10 MB > 8 MB budget — native stack-এ overflow, মানে segfault risk। Python-এ তবে আগেই interpreter guard: 1000-তেই RecursionError — 50,000 পৌঁছানোর আগেই থেমে যায়, তাই crash না, exception। Guard-টা আসলে রক্ষাকবচ: আসল stack-এর আগে সে হাত দেয়।
3T(n) = 3T(n/2) + O(n) — master theorem-এর কোন case? উত্তর কত, আর কেন (level-wise কাজের হিসাব দিয়ে)?
যুক্তি
a=3, b=2 ⇒ প্রতি level-এ node-work বাহক 3/2 = 1.5 — কাজ নিচের দিকে বাড়ছে, leaf-heavy ⇒ case ১। উত্তর: Θ(n^log₂3) ≈ Θ(n^1.585)। Level হিসাব: level j-তে মোট কাজ 3^j · O(n/2^j) = O(n·(3/2)^j) — geometrically increasing, শেষ level (j=log₂n) dominate করে। এটাই Karatsuba multiplication-এর recurrence — CLRS chapter 4-এর প্রথম বড় উদাহরণ।
4আপনার দলের JSON pretty-printer recursive-এ লেখা; production-এ ব্যবহারকারী 20,000-স্তরের nested array upload করে server crash করাল। Fix design করুন: তিনটা বিকল্প আলোচনা করে একটা বাছুন।
ডিজাইন
বিকল্প ১: recursionlimit বাড়ানো — অস্বীকৃত: attacker আরও গভীর পাঠাতে পারে, C-stack segfault-এর ঝুঁকি থেকেই যায়। বিকল্প ২: depth cap + error response — সহজ, কিন্তু legitimate deep document বাতিল। বিকল্প ৩: explicit-stack rewrite — stack-এ (node, indent) tuple push, pop-এ emit; depth unbounded-safe, memory heap-এ (সীমা বড়), code সামান্য জটিল। সঠিক বাছাই: বিকল্প ৩, সাথে বিকল্প ২-এর একটা sane cap defense-in-depth হিসেবে (যেমন 1e6)। এটাই production JSON parsers-এর আসল নকশা।
5fib_naive-এর মোট call সংখ্যার exact formula কী, আর asymptotically সেটা
কোন শ্রেণিতে? Tail-recursive count(n): if n==0: return 0; return count(n-1) — C compiler -O2-তে n=10⁷ safe কেন, Python-এ n=2000 fail?
যুক্তি
count(n): if n==0: return 0; return count(n-1) — C compiler -O2-তে n=10⁷ safe কেন, Python-এ n=2000 fail?Formula: N(n) = 2·F(n+1) − 1 (n=0,1 base-এ N=1) — Θ(φⁿ), φ ≈ 1.618; fibonacci হিসাব করতে গিয়ে fibonacci-সংখ্যক call। Tail call-এ caller-এর কাজ শেষ — frame reuse করে depth O(1) (goto-রূপান্তর); Python কখনো tail-call optimize করে না (traceback ধরে রাখার সিদ্ধান্ত): প্রতিটা call আসল frame খায়, 1000-তেই RecursionError। Language semantics performance ঠিক করে।
এরপর কী
এরপর কী
আজ আমরা দেখলাম recursion-এর সবচেয়ে বিখ্যাত ব্যর্থতা — fib_naive-এর exponential অপচয় — এবং তার এক লাইনের প্রতিষেধক memoization। পরের লেসন সেই ভাবনাকে পূর্ণ প্রকৌশলে রূপ দেব: Dynamic Programming — কোন কোন সমস্যায় overlapping subproblems আর optimal substructure আছে, state কীভাবে ডিজাইন করতে হয়, top-down বনাম bottom-up কখন কোনটা, আর space কীভাবে চাপা যায়। Coin change থেকে edit distance পর্যন্ত পাঁচটা classic পুরো matrix-সহ হাতে-কলমে — আর দেখব greedy-র সুন্দর মুখের পেছনে লুকানো ফাঁদটাও।
আরও পড়ুন
- Introduction to Algorithms (CLRS), Chapter 2.3 ও Chapter 4 — Divide-and-Conquer — Thomas H. Cormen et al. · Master theorem-এর প্রামাণ্য উৎস — three cases আর recurrence solving-এর পূর্ণ পদ্ধতি
- Concrete Mathematics, Chapter 1 ও 7 — Ronald Graham, Donald Knuth, Oren Patashnik · Recurrence relation আর generating function-এর গণিত — এই লেসনের recurrence intuition-এর ভিত্তি
- Structure and Interpretation of Computer Programs, Section 1.2 — Harold Abelson, Gerald Jay Sussman · Recursive process বনাম recursive procedure-এর ক্লাসিক আলাদা করা — tree recursion আর linear recursion-এর চেতনা