التعاود (Recursion)
آخر تحديث
التعاود هو أن تستدعي الدالة نفسها على نسخة أصغر من المسألة نفسها، إلى أن تصل إلى حالة صغيرة بما يكفي للإجابة عنها مباشرة. تلك الحالة القابلة للإجابة مباشرة هي الحالة الأساسية، وكل دالة تعاودية تحتاج إلى واحدة: تستمر fib(n) في الانقسام إلى fib(n - 1) و fib(n - 2) حتى تبلغ fib(1) أو fib(0)، وهما تعيدان قيمتيهما مباشرة. يشغّل التصور أعلاه هذا بالضبط: اضغط على تشغيل وشاهد الاستدعاءات تتفرع في شجرة، وتبلغ الحالات الأساسية عند الأوراق، ثم تعيد قيمها إلى الأعلى مع الدمج في كل مستوى.
الشيء الثاني الذي تعرضه الرسوم المتحركة هو مكدس الاستدعاءات: كل استدعاء بدأ ولم يعد بعد. ينمو المكدس كلما تعمقت الاستدعاءات، ويبلغ ذروته عند عمق التعاود، ثم ينحسر مع عودة النتائج، ولهذا قد يسبب التعاود العميق فيضان المكدس بينما لا تنمّي الحلقة التكرارية المكدس أبدًا. الشكل نفسه من الاستدعاءات يقود البحث بالعمق أولاً و الترتيب بالدمج ومعظم العمليات على الشجرة الثنائية.
تعقيد الوقت والمساحة
لفيبوناتشي التعاودي الساذج المعروض أعلاه، وللإصلاحين المعياريين:
| الأسلوب | الوقت | المساحة | ملاحظات |
|---|---|---|---|
| التعاود الساذج | O(2^n) | O(n) | تتضاعف شجرة الاستدعاءات في كل مستوى، والمساحة هي أعمق مكدس لا الشجرة كلها. |
| مع التحفيظ (memoization) | O(n) | O(n) | تُحسب كل fib(k) مرة واحدة وتُخزَّن، فتتحول الأشجار الفرعية المكررة إلى عمليات بحث. |
| حلقة تكرارية | O(n) | O(1) | متغيران متعاقبان يحلان محل المكدس تمامًا. |
| أي تعاود بشكل عام | عدد الاستدعاءات × العمل في كل استدعاء | O(max depth) | يحمل المكدس إطارًا واحدًا لكل استدعاء بدأ ولم يعد بعد. |
خطوة بخطوة
| الخطوة | ما الذي يحدث |
|---|---|
| 1 | يوضع الاستدعاء الأول fib(n) على مكدس الاستدعاءات. |
| 2 | يحتاج إلى fib(n - 1)، فيوضع ذلك الاستدعاء على المكدس أيضًا، والأب ينتظر. |
| 3 | تستمر الاستدعاءات في التداخل حتى يسأل أحدها عن n <= 1: تجيب الحالة الأساسية فورًا دون أي استدعاء أعمق. |
| 4 | تعود قيمة الحالة الأساسية إلى أبيها، فيصبح بإمكانه بدء استدعائه الثاني fib(n - 2). |
| 5 | عندما يعود كلا الابنين، يجمعهما الأب ويعود هو أيضًا، ويغادر إطاره المكدس. |
| 6 | تتكرر العودة صعودًا في الشجرة حتى يخرج إطار الاستدعاء الأول حاملًا الجواب النهائي ويصبح المكدس فارغًا. |
مثال محلول
حساب fib(4) بترتيب الاستدعاءات الدقيق، كما تعرضه الرسوم المتحركة:
| الاستدعاء | المكدس في تلك اللحظة | ما يعيده |
|---|---|---|
fib(4) | fib(4) | ينتظر الأبناء |
fib(3) | fib(4) > fib(3) | ينتظر الأبناء |
fib(2) | fib(4) > fib(3) > fib(2) | ينتظر الأبناء |
fib(1) | fib(4) > fib(3) > fib(2) > fib(1) | 1 (حالة أساسية) |
fib(0) | fib(4) > fib(3) > fib(2) > fib(0) | 0 (حالة أساسية) |
fib(2) يجمع | fib(4) > fib(3) > fib(2) | 1 + 0 = 1 |
fib(1) | fib(4) > fib(3) > fib(1) | 1 (حالة أساسية) |
fib(3) يجمع | fib(4) > fib(3) | 1 + 1 = 2 |
fib(2) مرة أخرى | fib(4) > fib(2) | 1، أُعيد حسابه من الصفر |
fib(4) يجمع | fib(4) | 2 + 1 = 3 |
متى تستخدم التعاود
| استخدمه عندما | تجنّبه عندما |
|---|---|
| تكون المسألة ذاتية التشابه: الأشجار والبنى المتداخلة وفرّق تَسُد | تعبّر حلقة بسيطة عن الشيء نفسه دون أطر مكدس |
يكون العمق محدودًا ومعقولًا، مثل O(log n) في الترتيب بالدمج | يمكن أن يبلغ العمق حجم المدخل على المدخلات الضخمة، فيهدد بفيضان المكدس |
| يحتاج التراجع (backtracking) إلى المكدس ليتذكر من أين يستأنف | تتكرر المسائل الفرعية نفسها وأنت لا تخزّن نتائجها |
| تكون النسخة التعاودية أوضح للقراءة والتحقق بلا شك | تكون داخل حلقة ساخنة يكون فيها عبء الاستدعاء ملموسًا في القياس |
كود Recursion
تنفيذ نظيف وقابل للتشغيل لخوارزمية Recursion بلغات Python, JavaScript, Java, C++, C. اختر لغة، وانسخ الكود، أو افتحه محمّلًا مسبقًا في ساحة تجربة Coddy.
كود Recursion بلغة Python
1calls = 02
3def fib(n, depth=0):4 global calls5 calls += 16 # Print the call with its depth so the recursion is visible7 print(" " * depth + f"fib({n})")8 if n <= 1:9 return n10 return fib(n - 1, depth + 1) + fib(n - 2, depth + 1)11
12
13print("fib(5) =", fib(5))14print("calls made:", calls)كود Recursion بلغة JavaScript
1let calls = 0;2
3function fib(n, depth = 0) {4 calls += 1;5 // Print the call with its depth so the recursion is visible6 console.log(' '.repeat(depth) + `fib(${n})`);7 if (n <= 1) return n;8 return fib(n - 1, depth + 1) + fib(n - 2, depth + 1);9}10
11console.log('fib(5) =', fib(5));12console.log('calls made:', calls);كود Recursion بلغة Java
1public class Main {2 static int calls = 0;3
4 static int fib(int n, int depth) {5 calls++;6 // Print the call with its depth so the recursion is visible7 System.out.println(" ".repeat(depth) + "fib(" + n + ")");8 if (n <= 1) return n;9 return fib(n - 1, depth + 1) + fib(n - 2, depth + 1);10 }11
12 public static void main(String[] args) {13 System.out.println("fib(5) = " + fib(5, 0));14 System.out.println("calls made: " + calls);15 }16}كود Recursion بلغة C++
1#include <iostream>2#include <string>3
4int calls = 0;5
6int fib(int n, int depth) {7 calls++;8 // Print the call with its depth so the recursion is visible9 std::cout << std::string(depth * 2, ' ') << "fib(" << n << ")\n";10 if (n <= 1) return n;11 return fib(n - 1, depth + 1) + fib(n - 2, depth + 1);12}13
14int main() {15 int result = fib(5, 0);16 std::cout << "fib(5) = " << result << "\n";17 std::cout << "calls made: " << calls << "\n";18 return 0;19}كود Recursion بلغة C
1#include <stdio.h>2
3int calls = 0;4
5int fib(int n, int depth) {6 calls++;7 /* Print the call with its depth so the recursion is visible */8 printf("%*sfib(%d)\n", depth * 2, "", n);9 if (n <= 1) return n;10 return fib(n - 1, depth + 1) + fib(n - 2, depth + 1);11}12
13int main(void) {14 printf("fib(5) = %d\n", fib(5, 0));15 printf("calls made: %d\n", calls);16 return 0;17}الأسئلة الشائعة حول التعاود
ما هي الحالة الأساسية في التعاود؟
fib(n) هي n <= 1، وتعيد n مباشرة. من دون حالة أساسية يمكن بلوغها لا تتوقف الاستدعاءات، ويستمر المكدس في النمو، ويتعطل البرنامج بفيضان المكدس.ما هو مكدس الاستدعاءات ولماذا يهم؟
n مستوى يستهلك ذاكرة O(n) حتى لو كان كل استدعاء لا يفعل شيئًا يُذكر. صف البطاقات أسفل الرسوم المتحركة يعرض هذا المكدس بالضبط وهو ينمو ثم ينحسر.لماذا يستغرق فيبوناتشي التعاودي زمنًا أسيًا؟
fib(2) مرتين داخل fib(4)، ويتضاعف هذا التكرار تقريبًا في كل مستوى، فينتج O(2^n) استدعاء. أما تخزين كل نتيجة عند حسابها أول مرة، وهو ما يسمى التحفيظ، فيطوي الشجرة إلى O(n).