পাঠ ৫০ · ৫৮-এর মধ্যে · মডিউল ১১

রিকার্শন ইমপ্লিমেন্টেশন

Recursion implementation
৮ মিনিট পড়া উচ্চ · Advanced Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • বেস কেস ও রিকার্সিভ কেসের সুনির্দিষ্ট সংজ্ঞা এবং কেন উভয়ই আবশ্যক
  • স্ট্যাক ডেপথ কী এবং কীভাবে এটি M11/L48-এর কল স্ট্যাক মডেল থেকে সরাসরি আসে
  • Tail recursion কী, এবং Tail-Call Optimization (TCO) কেন কিছু ইমপ্লিমেন্টেশনে সম্ভব
  • স্ট্যাক-ডেপথ ট্র্যাকিং সহ factorial ও তার tail-recursive accumulator সংস্করণ বাস্তবায়ন ও তুলনা

১ · বেস কেস ও রিকার্সিভ কেস

একটি সঠিক রিকার্সিভ ফাংশনে দুটি অংশ থাকতেই হয়। বেস কেসBase Caseএমন একটি শর্ত যার অধীনে ফাংশন আর কোনো রিকার্সিভ কল না করেই সরাসরি রিটার্ন করে — "থামার বিন্দু"। হলো এমন একটি শর্ত, যার অধীনে ফাংশন সরাসরি রিটার্ন করে — আর কোনো রিকার্সিভ কল ছাড়াই ("থামার বিন্দু")। রিকার্সিভ কেসRecursive Caseফাংশন নিজেকে একবার বা একাধিকবার, সাধারণত সমস্যার একটি "ছোট" সংস্করণে, আবার কল করে — এবং সেই কলের ফলাফল দিয়ে নিজের ফলাফল তৈরি করে। ফাংশন নিজেকে একবার বা একাধিকবার — সাধারণত মূল সমস্যার একটি "ছোট"/সরলতর সংস্করণে — আবার কল করে, এবং সেই রিকার্সিভ কলের ফলাফল দিয়ে নিজের ফলাফল তৈরি করে। একটি সঠিকভাবে-পৌঁছানো-যায়-এমন বেস কেস ছাড়া রিকার্শন কখনো থামে না — একটি সত্যিকারের, সাধারণ বাগ: ইনফিনিট রিকার্শন, যার সরাসরি পরিণতি হলো স্ট্যাক ক্রমাগত বেড়ে গিয়ে "স্ট্যাক ওভারফ্লো" — M11/L48-এর কল-স্ট্যাক-বৃদ্ধির মডেল দিয়েই এটি সরাসরি ব্যাখ্যা করা যায়: প্রতিটি রিকার্সিভ কল আরেকটি অ্যাক্টিভেশন রেকর্ড push করে, আর বাস্তব সিস্টেমে স্ট্যাকের সাইজ সবসময় ফিনিট।

২ · স্ট্যাক ডেপথ = পেন্ডিং কলের সংখ্যা

M11/L48-এর কল-স্ট্যাক ভিজুয়ালাইজেশনের সরাসরি প্রয়োগ হিসেবে: স্ট্যাক ডেপথ মানে এই মুহূর্তে কতগুলো কল pending (এখনো রিটার্ন করেনি, নিজের রিকার্সিভ কলের ফলাফলের জন্য অপেক্ষা করছে)। factorial(5) কল করলে, সবচেয়ে গভীর বিন্দুতে একইসাথে ৫টি আলাদা অ্যাক্টিভেশন রেকর্ড স্ট্যাকে থাকবে — factorial(5), factorial(4), factorial(3), factorial(2), factorial(1) — প্রতিটি তার নিজের রিকার্সিভ কল রিটার্ন না করা পর্যন্ত অপেক্ষমাণ।

৩ · Tail Recursion ও Tail-Call Optimization

একটি রিকার্সিভ কলকে tail callTail Callএকটি কল, যদি এটি ফাংশনের একেবারে শেষ অপারেশন হয় — কলের ফলাফল ছাড়া আর কিছুই করার বাকি থাকে না, সরাসরি রিটার্ন করা ছাড়া। বলা হয় যদি এটি ফাংশনের একদম শেষ অপারেশন হয় — কলের ফলাফল নিয়ে সরাসরি রিটার্ন করা ছাড়া আর কিছুই বাকি না থাকে। কিছু ভাষার ইমপ্লিমেন্টেশন tail call-কে অপ্টিমাইজ করতে পারে (Tail-Call Optimization, TCO) — নতুন অ্যাক্টিভেশন রেকর্ড push না করে বর্তমান ফ্রেমটিই পুনর্ব্যবহার করে — tail-recursive ফাংশনের জন্য স্ট্যাক-ডেপথ-বৃদ্ধির সমস্যা সম্পূর্ণ এড়িয়ে যায়। সততার সাথে বলা দরকার — সব ভাষা/ইমপ্লিমেন্টেশন এই অপ্টিমাইজেশন করে না; স্ট্যান্ডার্ড Python নিজেই TCO বাস্তবায়ন করে না, একটি সত্যিকারের, বাস্তব সীমাবদ্ধতা যা স্পষ্টভাবে জানা দরকার।

সাবধানতা · Python-এ TCO নেই

নিচের কোডের tail-recursive সংস্করণটি গঠনগতভাবে tail-recursive — কিন্তু যেহেতু Python নিজে TCO করে না, রানটাইমে এটিও ঠিক ততগুলোই অ্যাক্টিভেশন রেকর্ড push করবে যতগুলো non-tail-recursive সংস্করণ করে। শিক্ষণীয় গুরুত্বপূর্ণ বিষয়টি হলো এই দুই সংস্করণের গঠনগত পার্থক্য — একটি TCO-সমর্থনকারী ইমপ্লিমেন্টেশনে (যেমন Scheme) কেবল tail-recursive সংস্করণটিই অপ্টিমাইজেশনের জন্য যোগ্য হতো।

৪ · কোড: গভীরতা-ট্র্যাকিং factorial ও Tail-Recursive সংস্করণ

নিচে দুটো factorial বাস্তবায়ন আছে। প্রথমটি একটি সাধারণ (non-tail-recursive) রিকার্সিভ সংস্করণ, একটি ছোট CallStack দিয়ে সর্বোচ্চ গভীরতা ট্র্যাক করা হয়েছে। দ্বিতীয়টি একটি accumulator-ভিত্তিক, সত্যিকারের tail-recursive সংস্করণ — যেখানে রিকার্সিভ কলটিই একদম শেষ অপারেশন।

Python
class CallStack:
    """M11/L48-এর মডেলের একটি হালকা সংস্করণ -- শুধু গভীরতা ট্র্যাক করে।"""
    def __init__(self):
        self.depth = 0
        self.max_depth = 0

    def push(self):
        self.depth += 1
        self.max_depth = max(self.max_depth, self.depth)

    def pop(self):
        self.depth -= 1


stack = CallStack()


def factorial(n):
    """সাধারণ, non-tail-recursive সংস্করণ -- রিটার্নের ঠিক আগে গুণটা এখনো বাকি থাকে।"""
    stack.push()
    if n <= 1:
        result = 1
    else:
        result = n * factorial(n - 1)   # গুণ করাটা এই কলের ফলাফল আসার পরে হয় -- tail call নয়
    stack.pop()
    return result


result = factorial(5)
print(f"factorial(5) = {result}")
print(f"রিকার্শনের সর্বোচ্চ স্ট্যাক গভীরতা (max_depth) = {stack.max_depth}")
assert stack.max_depth == 5
assert stack.depth == 0
assert result == 120
print(f"যাচাই: max_depth == 5 -> {stack.max_depth == 5},  factorial(5) == 120 -> {result == 120}")


def factorial_tail(n, accumulator=1):
    """সত্যিকারের tail-recursive সংস্করণ -- রিকার্সিভ কলটিই একেবারে শেষ অপারেশন।"""
    if n <= 1:
        return accumulator
    return factorial_tail(n - 1, n * accumulator)   # এখানে ফেরার আগে আর কিছুই করার বাকি নেই


tail_result = factorial_tail(5)
print(f"\nfactorial_tail(5) = {tail_result}")
assert tail_result == result
print(f"যাচাই: tail_result == result -> {tail_result == result}  (উভয় সংস্করণ অভিন্ন ফলাফল দিয়েছে)")

print("\nদ্রষ্টব্য: স্ট্যান্ডার্ড Python TCO বাস্তবায়ন করে না -- তাই factorial_tail-ও রানটাইমে")
print("factorial-এর মতোই ৫টি ফ্রেম push করবে। পার্থক্যটা শুধুই গঠনে (structural),")
print("রানটাইম আচরণে নয় -- একটি TCO-সমর্থনকারী ভাষায় এই গঠনগত পার্থক্যটিই কাজে লাগত।")

    
লক্ষ্য করুন factorial-এ n * factorial(n - 1) — রিকার্সিভ কল factorial(n - 1) রিটার্ন করার পরেও একটি কাজ বাকি থাকে (n দিয়ে গুণ করা) — এজন্যই এটি tail call নয়। factorial_tail-এ return factorial_tail(n - 1, n * accumulator) — গুণটা রিকার্সিভ কলের আর্গুমেন্ট হিসেবে আগেই হয়ে যাচ্ছে, কলের ফলাফল আসার পরে আর কোনো কাজ বাকি থাকে না — এটিই tail call-এর সংজ্ঞা পূরণ করে।
মূল কথা · Key takeaway

রিকার্শন সঠিকভাবে কাজ করে কারণ M11/L48-এর অ্যাক্টিভেশন-রেকর্ড মডেল প্রতিটি কলকে সম্পূর্ণ স্বাধীন রাখে — স্ট্যাক ডেপথ সরাসরি বলে দেয় কতগুলো কল একইসাথে "অপেক্ষমাণ"। Tail recursion একটি বিশেষ গঠন যা — সমর্থনকারী ইমপ্লিমেন্টেশনে — এই ডেপথ-বৃদ্ধি সম্পূর্ণ এড়াতে পারে, যদিও Python নিজে এই সুবিধা দেয় না।

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

প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।

প্র ০১ উপরের কোডে factorial(5)-এর max_depth কেন ঠিক 5 হলো, 4 বা 6 নয় কেন?

factorial(5) নিজেই প্রথম push, তারপর n=4,3,2,1-এর জন্য আরও ৪টি রিকার্সিভ কল push হয় — মোট ৫টি সক্রিয় ফ্রেম যখন n=1-এ পৌঁছায় (বেস কেস)। বেস কেসে পৌঁছানোর সাথে সাথেই কোনো নতুন push হয় না — সেই ফ্রেমটি সরাসরি রিটার্ন করে, এরপর pop শুরু হয়। তাই সর্বোচ্চ গভীরতা ঠিক n = 5-এর সমান — সাধারণভাবে factorial(n)-এর সর্বোচ্চ গভীরতা সবসময় n।

প্র ০২ factorial ও factorial_tail উভয়ই একই সংখ্যক রিকার্সিভ কল করে, কিন্তু শুধু একটিকে "tail-recursive" বলা হয় কেন?

"Tail-recursive" হওয়া রিকার্সিভ কলের সংখ্যা নিয়ে নয় — এটি প্রতিটি কলের গঠন নিয়ে: রিকার্সিভ কলটি কি ফাংশনের একদম শেষ অপারেশন, নাকি তার ফলাফল দিয়ে আরও কিছু করা বাকি আছে। factorial-এ রিকার্সিভ কলের ফলাফলকে n দিয়ে গুণ করতে হয় (কলের পরেও কাজ বাকি) — tail call নয়। factorial_tail-এ গুণটা কলের আগেই, আর্গুমেন্ট হিসেবে হয়ে যায় — কলের ফলাফলই সরাসরি চূড়ান্ত ফলাফল, কল-পরবর্তী কোনো কাজ নেই — এটাই tail call-এর সংজ্ঞা।

প্র ০৩ যদি কোনো একটি ভাষার ইমপ্লিমেন্টেশন প্রকৃতপক্ষে TCO সমর্থন করত, তাহলে factorial_tail(100000) চালালে স্ট্যাক ওভারফ্লো হতো কি? আর সাধারণ factorial(100000)-এর ক্ষেত্রে?

TCO-সমর্থনকারী ইমপ্লিমেন্টেশনে factorial_tail(100000) স্ট্যাক ওভারফ্লো করত না — প্রতিটি tail call পুরনো ফ্রেম পুনর্ব্যবহার করত বলে গভীরতা কখনো বাড়ত না (কার্যত একটি লুপে রূপান্তরিত হয়ে যেত)। কিন্তু non-tail-recursive factorial(100000) এখনও ওভারফ্লো করত — কারণ প্রতিটি কলের ফলাফল দিয়ে গুণ করার কাজ বাকি থাকায় (tail call নয়), TCO এখানে প্রযোজ্য নয়, গভীরতা সত্যিই 100000 পর্যন্ত বাড়ত। (স্ট্যান্ডার্ড Python-এ যেহেতু TCO নেই, বাস্তবে দুটো সংস্করণই বড় n-এ একইভাবে RecursionError দেবে।)

অনুশীলন

  1. পরীক্ষা করুন: উপরের কোডে factorial(5)-এর বদলে factorial(8) চালিয়ে max_depth ও ফলাফল কী হয় দেখুন, নিজে হাতে 8! = 40320 হিসাব করে মিলিয়ে নিন।

    max_depth হবে 8 (একই নিয়ম — সর্বোচ্চ গভীরতা সবসময় n-এর সমান)। ফলাফল হবে 8! = 8×7×6×5×4×3×2×1 = 40320, এবং factorial_tail(8)-ও একই 40320 দেবে — assertion-গুলো এই প্যাটার্ন যেকোনো n-এর জন্য নিশ্চিত করে।

  2. চিন্তা করুন: factorial_tail-এর ডিফল্ট প্যারামিটার accumulator=1 কেন 0 নয়? যদি ভুলবশত accumulator=0 দিয়ে শুরু করা হতো তাহলে কী হতো?

    accumulator-এ ক্রমান্বয়ে গুণ (multiplication) জমা হয় — গুণের identity element হলো 1 (যেকোনো সংখ্যাকে 1 দিয়ে গুণ করলে সংখ্যাটি অপরিবর্তিত থাকে), যোগফলের identity element হয় 0। যদি accumulator=0 দিয়ে শুরু হতো, তাহলে প্রথম গুণেই (n * accumulator) ফলাফল সবসময় 0 হয়ে যেত, এবং পুরো রিকার্শনের বাকি অংশ ধরে সেই 0-ই থেকে যেত — চূড়ান্ত ফলাফল ভুলভাবে সবসময় 0 আসত, যেই n-ই দেওয়া হোক না কেন।

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

পূর্ববর্তী পাঠ
প্যারামিটার পাসিং মেকানিজম