التكرار الذاتي
درس 1 من 2
1التكرار الذاتي (C - Recursion)
أسلوب حل مختلف تمامًا عن الحلقات (loops): دالة تستدعي نفسها لحل نسخة أصغر من نفس المسألة، حتى تصل لحالة بسيطة جدًا تُحل مباشرة.
ماذا تشاهد في الرسم: كل استدعاء يضيف إطارًا فوق المكدّس حتى حالة الأساس، ثم تعود القيم واحدة واحدة وتُزال الإطارات.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
التكرار الذاتي مثل دمى «ماتريوشكا» الروسية: تفتح الدمية فتجد دمية أصغر، فتفتحها فتجد أصغر، حتى تصل إلى أصغر دمية لا تُفتح. الدالة التي تستدعي نفسها تحل المسألة بحل نسخة أصغر منها، حتى تصل إلى حالة سهلة جوابها معروف.
كل دالة تكرارية تحتاج حالة أساس base case تتوقف عندها، وخطوة تكرارية تقرّب المدخل منها. كل استدعاء يأخذ إطارًا على مكدّس الاستدعاءات call stack، ولذلك قد يفيض المكدّس مع العمق الكبير. ومن أمثلته المضروب وفيبوناتشي والبحث في الأشجار.
قبل أن تبدأ
📞استدعاء الدوال
تحتاج فهمًا أساسيًا لكيفية استدعاء دالة لدالة أخرى في C، ومفهوم أن كل استدعاء له متغيراته المحلية الخاصة، لأن جوهر التكرار هو أن الدالة تستدعي نفسها، فتصبح الدالة المُستدعِيَة والمُستدعاة كيانًا واحدًا بنسختين مختلفتين من المتغيرات.
🔀جمل التحكم الشرطية
كل دالة تكرارية تعتمد على if لتحديد: هل وصلنا لحالة الأساس فنتوقف، أم نستدعي الدالة مرة أخرى؟ بدون فهم ثابت لكيف يعمل if/else ومتى يتحقق الشرط، ستجد صعوبة في تتبع سلسلة الاستدعاءات المتداخلة.
🧮المؤشرات (Pointers): أساسيات
عدة أنماط تكرارية في C (مثل المرور على مصفوفة أو سلسلة نصية تكراريًا) تعتمد على تحريك مؤشر خطوة للأمام في كل استدعاء، فإن لم تكن مرتاحًا مع مفهوم أن arr + 1 يُحرّك المؤشر بمقدار عنصر واحد، راجع أساسيات حسابيات المؤشرات (pointer arithmetic) أولًا.
الفكرة الجوهرية: حالة أساس + استدعاء ذاتي
لفهم التكرار فهمًا حقيقيًا، يجب أن تراه كأسلوب تعريف لحل المسألة لا كأسلوب حل خطوة بخطوة: الدالة التكرارية لا "تشرح" كيف تُحل المسألة، بل تُعرّف حلّها بالاستناد إلى حلّ نسخة أصغر منها، تمامًا كما يُعرَّف العدد الطبيعي رياضيًا بالاستقراء (induction): حالة أساس واحدة (عادة الصفر) زائد قاعدة تبني كل عدد من العدد الذي قبله. وبالضبط كما يحتاج البرهان بالاستقراء ركيزتين لا يكتمل بدونهما، أي دالة تكرارية (recursive) صحيحة تحتاج ركيزتين لا غنى عنهما:
1حالة الأساس (Base Case)
هي الحالة الأبسط التي تُحل مباشرة دون أي استدعاء إضافي، نظير حالة "n = 0" في برهان الاستقراء. وظيفتها الحقيقية مزدوجة: فهي أولًا نقطة التوقف الفعلية التي تمنع الاستدعاءات من الاستمرار إلى ما لا نهاية؛ وهي ثانيًا المصدر الوحيد لقيمة "جاهزة" يمكن البناء عليها، كل الاستدعاءات الأخرى لا تملك قيمة جاهزة، بل تنتظر قيمة استدعاء أصغر منها. لو كانت حالة الأساس غائبة تمامًا، أو مكتوبة بشرط لا يتحقق أبدًا لمدخل معيّن (مثل استدعاء factorial(-3) على دالة تتحقق فقط من n <= 1 بينما المُدخل سالب ويتباعد عن الصفر بدل أن يقترب منه)، فستستمر الاستدعاءات بلا توقف: كل استدعاء ينتظر نتيجة استدعاء لم يحدث بعد، وتتكدّس الإطارات في الذاكرة حتى تستهلك المساحة المحجوزة لمكدس الاستدعاءات بالكامل، غالبًا 8 ميجابايت لكل خيط تنفيذ (thread) بشكل افتراضي على أنظمة Linux، فينهار البرنامج بخطأ Stack Overflow.
2الخطوة التكرارية (Recursive Step)
هي الاستدعاء الذي تقوم به الدالة لنفسها، لكن مُدخله ليس "أصغر" بمعنى حدسي غامض فقط، يجب أن يكون أصغر بمقياس محدد يضمن الوصول لحالة الأساس بعد عدد خطوات منتهٍ. في factorial هذا المقياس هو قيمة n نفسها: كل استدعاء يُمرّر n - 1، فتقل القيمة بمقدار واحد بالضبط في كل مرة، ولأنه لا توجد سلسلة لا نهائية من الأعداد الصحيحة بين أي عدد موجب والصفر، فالوصول لـ n <= 1 مضمون رياضيًا. الخطأ الدقيق الذي يسقط فيه كثيرون هو كتابة استدعاء "يبدو" تكراريًا شكليًا لكنه لا يُقرّب المُدخل فعليًا من حالة الأساس، كاستدعاء الدالة بنفس القيمة n بدل n - 1 (خطأ طباعي سهل الحدوث)، أو بقيمة تتباعد عن حالة الأساس بدل الاقتراب منها؛ في كل هذه الحالات الكود "تكراري" في الشكل فقط، لكنه عمليًا حلقة لا نهائية لا تختلف جوهريًا عن غياب حالة الأساس تمامًا.
شكل 1، كل استدعاء ينتظر نتيجة الاستدعاء الذي بعده قبل أن يكمل حسابه الخاص؛ هذا هو سبب وجود "مرحلتين": النزول (كل استدعاء يستدعي التالي) ثم الصعود (كل استدعاء يُرجع قيمته للي استدعاه).
الرسمان السابقان يوضحان لماذا تحتاج الركيزتين معًا لا كل واحدة بمفردها: حالة الأساس وحدها بلا خطوة تكرارية لا تحل إلا الحالة البسيطة فقط؛ والخطوة التكرارية وحدها بلا حالة أساس لا تتوقف أبدًا. والضمان الحقيقي للصحة هنا يشبه تمامًا برهان الاستقراء الرياضي: إذا صحّت حالة الأساس، وإذا كانت كل خطوة تكرارية تبني النتيجة الصحيحة بافتراض أن الاستدعاء الأصغر منها صحيح (وهذا بالضبط ما يسمى "الافتراض الاستقرائي")، فالنتيجة صحيحة لكل مُدخل ممكن، وهذا ما يحدث حرفيًا في factorial(n): بافتراض أن factorial(n - 1) يُرجع القيمة الصحيحة، فإن n * factorial(n - 1) هي بالضرورة القيمة الصحيحة لـ factorial(n).
أنماط التكرار المختلفة
3التكرار الذيلي (Tail Recursion)
الاستدعاء التكراري هو آخر عملية في الدالة، لا يوجد شيء ينتظر بعد عودته. الفرق الحاسم: بما أنه لا توجد عملية حسابية معلقة بعد الاستدعاء، فبعض المترجمات (compilers) تستطيع تحويله لحلقة عادية تلقائيًا (tail call optimization)، فتتجنب تكدّس الإطارات تمامًا. في C، مترجم GCC مع -O2 يفعل ذلك غالبًا.
4التكرار الشجري (Tree Recursion)
الدالة تستدعي نفسها أكثر من مرة في كل استدعاء، كحساب فيبوناتشي: fib(n-1) + fib(n-2). النتيجة شجرة استدعاءات تتفرع بسرعة هائلة، وعدد الاستدعاءات ينمو أسيًّا (exponentially) مع حجم المُدخل، هذا النمط يحتاج تقنيات مثل التذكّر (memoization) لتجنب إعادة حساب نفس القيم آلاف المرات.
مقارنة: التكرار الذاتي مقابل الحلقات التكرارية
| المعيار | التكرار الذاتي (Recursion) | الحلقات (Iteration) |
|---|---|---|
| الذاكرة | كل استدعاء يستهلك إطارًا في المكدس، خطر Stack Overflow مع العمق الكبير | متغير واحد أو عدة متغيرات فقط، لا تكدّس إضافي |
| الأداء | أبطأ غالبًا بسبب كلفة الاستدعاء والعودة (overhead)، لكن التحسين الذيلي يُلغي الفرق | أسرع عادة لأنها تتجنب كلفة الاستدعاءات المتكررة |
| الوضوح | أوضح بكثير للمسائل التي تنقسم طبيعيًا لنسخ أصغر (أشجار، رسومات بيانية، divide & conquer) | أوضح للمسائل الخطية التي تتقدم خطوة بخطوة (عد، تجميع) |
كيف يعمل، خطوة بخطوة: مكدس الاستدعاءات (Call Stack)
كل استدعاء دالة، تكراري أو غير تكراري، يحصل على "إطار" (stack frame) خاص به في الذاكرة، يُنشأ تلقائيًا عند الدخول للدالة ويُزال تلقائيًا عند الخروج منها. هذا الإطار ليس صندوقًا مجردًا: فعليًا يحتوي عنوان العودة (return address)، أي مكان الكود الذي يجب أن ينتقل له المعالج بعد انتهاء هذا الاستدعاء تحديدًا، بالإضافة إلى نسخة هذا الاستدعاء الخاصة من مُدخلاته (parameters) ومتغيراته المحلية (local variables)، وغالبًا مؤشرًا محفوظًا لإطار الاستدعاء الذي قبله (saved base/frame pointer) يُستخدم لإيجاد ذلك الإطار السابق بسرعة عند العودة. مكدس الاستدعاءات بأكمله مساحة ذاكرة محجوزة سلفًا بحجم ثابت لكل خيط تنفيذ، غالبًا 8 ميجابايت افتراضيًا على أنظمة Linux، وكل استدعاء جديد "يدفع" (push) إطاره فوق الإطار السابق، فيتحرك مؤشر المكدس (stack pointer) تلقائيًا ليحجز المساحة اللازمة. في التكرار، هذه الإطارات تتكدّس فوق بعضها بلا أي استثناء لكون الدالة نفسها، لأن كل استدعاء لـ factorial(n)، حتى لو حمل نفس اسم الدالة بالضبط، هو كيان مستقل تمامًا بعنوان عودة ونسخة n خاصة به لا يشاركها أي استدعاء آخر:
📥1. الاستدعاء
كل استدعاء لـfactorial(n) يضيف إطارًا جديدًا فوق المكدس، يحمل نسخته الخاصة من n وعنوان العودة لموضع الاستدعاء الذي أنشأه بالضبط. حتى لو كانت كل الاستدعاءات تنفّذ نفس أسطر الكود تمامًا، فكل واحد منها معزول كليًا عن الآخر؛ تعديل n داخل استدعاء واحد لا يُغيّر أبدًا نسخة n في استدعاء آخر، وهذا بالضبط ما يسمح لكل استدعاء أن "يرى" قيمة مختلفة من المتغير نفسه بالاسم.
🧱2. التكدّس
الإطارات تتراكم تصاعديًا حتى الوصول لحالة الأساس، وعمق المكدس في أي لحظة يساوي عدد الاستدعاءات المتداخلة النشطة فعليًا في تلك اللحظة. وبما أن كل إطار يستهلك مساحة حقيقية من الذاكرة المحجوزة للمكدس (تتناسب مع عدد وحجم المتغيرات المحلية في الدالة)، فإن عمق التكرار الأقصى الممكن محدود عمليًا بتلك المساحة المحجوزة مقسومة على حجم الإطار الواحد، وهذا تحديدًا الرابط المباشر بين حالة أساس بطيئة الوصول (أو غائبة) وخطأ Stack Overflow الذي ذكرناه في الجزء السابق.
📤3. الانحلال (Unwinding)
بمجرد الوصول لحالة الأساس، تسير العملية بالاتجاه المعكوس تمامًا: الإطار الأعلى في المكدس يحسب قيمته ويُرجعها عبر عنوان العودة المحفوظ فيه إلى الإطار الذي استدعاه بالضبط، ثم يُزال هذا الإطار من المكدس فتتقلص المساحة المحجوزة فورًا. هذا الترتيب العكسي الصارم، آخر إطار يُضاف هو أول إطار يُزال، يُعرف بانضباط LIFO (Last In, First Out)، وهو بالضبط ما يضمن أن كل استدعاء يستقبل نتيجة الاستدعاء الذي أنشأه هو بعينه، لا نتيجة أي استدعاء آخر، حتى مع وجود عدة استدعاءات متداخلة تحمل نفس اسم الدالة نشطة في آن واحد.
شكل 2، كل مستوى في هذا الرسم إطار مستقل تمامًا في الذاكرة: يحمل نسخته الخاصة من n وعنوان العودة الذي سيُرجع له قيمته بالضبط؛ الإطارات الأربعة موجودة معًا في الذاكرة في آنٍ واحد فقط عند الوصول لحالة الأساس: وهذا العمق المتزامن هو تحديدًا ما قد يستهلك المساحة المحدودة المحجوزة للمكدس إن تكرر آلاف المرات.
int factorial(int n)
{
if (n <= 1) /* حالة الأساس */
return (1);
return (n * factorial(n - 1)); /* الخطوة التكرارية */
}
شكل 3، لاحظ أن الضرب n * factorial(n - 1) لا يمكن حسابه إلا بعد أن يُرجع الاستدعاء الداخلي قيمته: وهذا بالضبط ما يجبر المكدس على الانتظار قبل الانحلال.
مثال تطبيقي: مجموع عناصر مصفوفة تكراريًا
نفس النمط (حالة أساس + خطوة تكرارية) ينطبق على أي مسألة قابلة للتفكيك لنسخة أصغر من نفسها:
int sum_array(int *arr, int size)
{
if (size == 0) /* حالة الأساس: مصفوفة فارغة */
return (0);
return (arr[0] + sum_array(arr + 1, size - 1));
}
كل استدعاء يأخذ "بقية" المصفوفة (بتحريك المؤشر خطوة وتقليل الحجم)، حتى تصل لحجم صفر فتتوقف. المصفوفة نفسها لا تُنسخ أبدًا: arr + 1 هو تعبير حسابي على المؤشر (pointer arithmetic) يُحرّك المؤشر بمقدار عنصر واحد من نوع int (أي 4 بايتات غالبًا)، فيُعيد إشارة جديدة تبدأ عند العنصر الثاني من نفس المصفوفة في الذاكرة، وليس مصفوفة منفصلة. في الوقت نفسه، size - 1 يُخبر الاستدعاء الجديد أن "عالمه" الآن أصغر بعنصر واحد. تحريك المؤشر للأمام وتقليل الحجم معًا هما ما يضمنان أن كل استدعاء يرى جزءًا أصغر فعليًا من نفس المصفوفة، فتصل السلسلة لحجم صفر (حالة الأساس) بعد عدد خطوات محدود يساوي طول المصفوفة تمامًا.
شكل 4، كل استدعاء يستقبل مؤشرًا متحركًا خطوة واحدة للأمام (arr+1، arr+2...) وحجمًا أصغر بواحد في كل مرة؛ بما أن الحجم يقل باستمرار بخطوات ثابتة، فالوصول لحجم صفر (حالة الأساس) مضمون بعد عدد محدود من الاستدعاءات، بالضبط كما حدث مع factorial في الرسم السابق.
خرافة مقابل حقيقة
| الخرافة | الحقيقة |
|---|---|
| التكرار الذاتي يستخدم فقط في الأكاديميا ولا فائدة عملية منه | التكرار هو الأسلوب الطبيعي لتمثيل هياكل البيانات المتداخلة (أشجار، JSON، نظام ملفات، DOM)، أغلب الخوارزميات الفعّالة للترتيب (merge sort, quicksort) والبحث (binary search tree) مبنية عليه أساسًا. |
| أي حل تكراري يمكن تحويله بسهولة لحلقة | التكرار الخطي (مثل factorial) يمكن تحويله مباشرة. لكن التكرار الشجري (مثل المرور على شجرة بعدة فروع) يحتاج محاكاة مكدس يدوية (explicit stack) لتحويله، وغالبًا يكون الكود الناتج أعقد وأصعب قراءة من النسخة التكرارية. |
| كل دالة تكرارية تستهلك ذاكرة أكثر | صحيح للتكرار غير الذيلي فقط. التكرار الذيلي (tail recursion) عند تحسينه بواسطة المترجم يُعاد تدويره لاستخدام نفس الإطار، فلا تتكدّس إطارات إضافية أبدًا، يعمل تمامًا كحلقة من حيث استهلاك الذاكرة. |
تطبيقات عملية
🌲المرور على الأشجار
هياكل البيانات الشجرية (أشجار البحث الثنائية، DOM في صفحات الويب، نظام الملفات) مبنية تكراريًا بطبيعتها: كل عقدة قد تحوي عقدًا فرعية من نفس النوع. المرور عليها (traversal) يُعبَّر عنه طبيعيًا بدالة تكرارية تزور العقدة ثم تستدعي نفسها على كل فرع.
🔀خوارزميات فرّق تسُد
Merge Sort و Quick Sort يقسّمان المصفوفة لنصفين، يرتّبان كل نصف تكراريًا، ثم يدمجانهما، نفس نمط "حالة أساس + خطوة تكرارية" بالضبط، لكن مع استدعاءين بدل واحد (تكرار شجري).
📂تصفح المجلدات
أي أداة تستعرض مجلدًا وكل مجلداته الفرعية (مثل find أو ls -R) تعمل تكراريًا: ادخل المجلد، عالج ملفاته، ثم استدعِ نفسك على كل مجلد فرعي، المجلد الفارغ هو حالة الأساس.
🧩تحليل التعبيرات الرياضية
المترجمات (compilers) والآلات الحاسبة تحلل التعبيرات مثل (3 + (2 * 5)) تكراريًا: كل تعبير إما رقم بسيط (حالة أساس) أو عملية على تعبيرين فرعيين، بنية تكرارية طبيعية.
تعمّق: تصميم التكرار الذاتي وتكلفته
فهمنا آلية مكدس الاستدعاءات؛ هنا نحوّل الفهم إلى منهج تصميم: كيف تقرر أن مسألة تُحلّ بالتكرار الذاتي، وكيف تصمّمه، وكيف تعرف أنه تكلفة لا تستحق.
وصفة تصميم: أربعة أسئلة
شكل 5، وصفة تصميم التكرار الذاتي: الثقة في الاستدعاء الأصغر هي سر البساطة.
السؤال الثالث هو "قفزة الإيمان": لا تحاول تتبع كل الاستدعاءات في رأسك دفعة واحدة؛ افترض أن الاستدعاء على المسألة الأصغر يعمل، وركّز فقط على كيف تضيف خطوتك الخاصة. أما السؤال الرابع فهو ضمانك ضد الحلقة اللانهائية: إن لم يقترب المُدخل من الحالة الأساس في كل استدعاء، فلن تنتهي العملية أبدًا حتى يمتلئ المكدس.
أنماط شائعة
| النمط | كيف تصغّر المسألة | مثال على الفكرة |
|---|---|---|
| نص أو مصفوفة | تعالج عنصرًا ثم تستدعي على الباقي | طباعة نص حرفًا حرفًا |
| الأعداد | تنقص العدد أو تحذف رقمًا | مجموع أرقام عدد |
| القسمة والحكم | تقسم المسألة إلى نصفين | بحث ثنائي |
| التجميع في الوسائط | تمرر النتيجة الجزئية كوسيط إضافي | عندما يُمنع المتغير الساكن والحلقات |
قيدا "لا حلقات ولا متغيرات ساكنة" يفرضان فكرة جميلة: كل الحالة يجب أن تنتقل عبر الوسائط وقيم الإرجاع. فإن احتجت لعدّاد، فاجعله وسيطًا إضافيًا، وغالبًا تغلّفه دالة أخرى تبدأ به من الصفر.
التكلفة: متى يصبح التكرار الذاتي سيئًا؟
لكل استدعاء إطار على المكدس يحمل الوسائط والمتغيرات المحلية وعنوان العودة. عمق N يعني ذاكرة تتناسب مع N، والمكدس محدود (غالبًا بضعة ميغابايتات على Linux)، فعمق كبير جدًا يسبب انهيارًا بفيض المكدس. وهناك تكلفة أخفى: التكرار الساذج قد يعيد حساب المسألة الفرعية نفسها مرارًا. مثال كلاسيكي هو متتالية فيبوناتشي المكتوبة بنداءين متفرعين: لحساب fib(5) يُحسب fib(3) مرتين و fib(2) ثلاث مرات، ويتضاعف العمل تقريبًا مع كل زيادة في المُدخل (نمو أسّي). الحل بحفظ النتائج السابقة (memoization) أو بالتحويل إلى حلقة.
شكل 6، شجرة استدعاءات فيبوناتشي الساذجة: العقد المكررة (fib(3) و fib(2)) هي العمل المهدور.
| يناسب التكرار الذاتي | يناسب الحلقة |
|---|---|
| بنى متفرعة: أشجار، مجلدات، تعبيرات متداخلة | عدّ أو جمع بسيط على نطاق معروف |
| القسمة والحكم والتراجع (Backtracking) | عمق كبير جدًا مع مكدس محدود |
| الحل أوضح بكثير بالتعريف التكراري | أداء حرج لا يحتمل تكلفة الاستدعاءات |
ارسم جدول تتبع: عمود لكل استدعاء، ووسائطه، وقراره (حالة أساس أم خطوة). حين ترى الجدول ينتهي كما تتوقع، تكون صممت بشكل صحيح.
جرّب بنفسك
ماذا يحدث لدالة تكرارية حالة أساسها لا تتحقق أبدًا لمدخل معيّن؟
كل استدعاء ينتظر نتيجة استدعاء لم يحدث بعد. الإطارات تستهلك المكدس المحدود (غالبًا 8 ميجابايت) فينهار البرنامج.
استدعاءان لـ factorial(n) في المكدس نفسه. ماذا عن المتغير n فيهما؟
كل إطار يحمل عنوان العودة ونسخته من المعاملات والمتغيرات المحلية، فالاستدعاءات معزولة رغم أن الكود واحد.
متى لا تستهلك الدالة التكرارية ذاكرة إضافية بتكدّس الإطارات؟
التكرار الذيلي المحسَّن يعمل كحلقة من حيث الذاكرة. أما غير الذيلي فيكدّس إطارًا لكل مستوى.
بطاقات مراجعة سريعة
fib(n-1) + fib(n-2).تحقق من فهمك
لماذا لا يمكن حساب factorial(4) فورًا، ويجب أن ننتظر factorial(3) يُرجع قيمته؟
لأن حساب factorial(4) يعتمد رياضيًا على نتيجة factorial(3) (هو 4 × factorial(3))؛ لا يمكن ضرب رقم في نتيجة لم تُحسب بعد، فالاستدعاء ينتظر إجباريًا حتى تكتمل السلسلة وصولًا لحالة الأساس.
لو كتبت return factorial(n) بدل return factorial(n - 1)، ماذا سيحدث؟
المُدخل لن يتغيّر أبدًا بين الاستدعاءات، كل استدعاء يُمرّر نفس القيمة n، فلا يقترب من حالة الأساس أبدًا. النتيجة هي نفسها تمامًا كنسيان حالة الأساس: استدعاءات لا نهائية وانهيار بخطأ Stack Overflow.
كم إطارًا يكون في المكدس في آنٍ واحد عند حساب factorial(100)؟
100 إطار بالضبط، واحد لكل قيمة من 100 نزولًا حتى 1 (حالة الأساس). لن يُزال أي إطار حتى يصل الاستدعاء لحالة الأساس ويبدأ الانحلال، فالعمق الأقصى يساوي قيمة المُدخل الأصلي.
لماذا لا يكفي أن تضع حالة أساس؟
لأنه يجب أن يقترب كل استدعاء منها؛ وإلا فقد لا تُبلغ أبدًا فيمتلئ المكدس.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
مقالات2
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.