طَبِّقْهاتعلّم المبدأ، وطبّقه بيدكمقرر 101 · تركيب البيانات والخوارزميات
الدرس 21 · الوحدة 9: تطبيق تحسينات على الحلول

التخزين المؤقت والبرمجة الديناميكية

من ساعات إلى أجزاء من الثانية: احفظ ما حسبته ولا تحسبه مرتين. ⏱ نحو 40 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تكتشف الحساب المكرر في الحلول العودية.
  • تطبق التخزين المؤقت بقاموس وبـ lru_cache.
  • تبني حلًّا بالبرمجة الديناميكية بالجدولة من الأسفل.
  • تتعرف على شرطي البرمجة الديناميكية وتحل بها مسائل الدرج والقطع النقدية.

١المشكلة: نحسب الشيء نفسه مرارًا

متتالية فيبوناتشي: كل عدد مجموع العددين قبله: 0، 1، 1، 2، 3، 5، 8، 13… الحل العودي المباشر جميل، لكنه يحسب القيم نفسها مرات ومرات:

f(5)f(4)f(3)f(2)f(1)f(0)f(1)f(2)f(1)f(0)f(3)f(2)f(1)f(0)f(1)
استدعاءات fib(5): الأحمر حساب تكرر من قبل. 9 استدعاءً مكررًا من 15.
main.py
calls = 0def fib(n):    global calls    calls += 1    if n < 2:        return n    return fib(n - 1) + fib(n - 2) for n in [10, 20, 25]:    calls = 0    print(n, fib(n), "| عدد الاستدعاءات:", calls)
Shell×
>>> %Run main.py
10 55 | عدد الاستدعاءات: 177
20 6765 | عدد الاستدعاءات: 21891
25 75025 | عدد الاستدعاءات: 242785
>>>

الاستدعاءات تتضاعف تقريبًا مع كل زيادة في n: تعقيد أُسّي O(2ⁿ). fib(50) بهذه الطريقة تحتاج ساعات.

٢الحل الأول: التخزين المؤقت Memoization

الفكرة: احفظ كل نتيجة تحسبها في قاموس، وقبل أي حساب اسأل: هل حسبته من قبل؟

main.py
memo = {}calls = 0def fib(n):    global calls    calls += 1    if n in memo:            # حُسب من قبل: O(1)        return memo[n]    if n < 2:        result = n    else:        result = fib(n - 1) + fib(n - 2)    memo[n] = result         # احفظ للمرة القادمة    return result print(fib(50), "| الاستدعاءات:", calls)
Shell×
>>> %Run main.py
12586269025 | الاستدعاءات: 99
>>>

وفي بايثون يفعل ذلك سطر واحد: المزخرف @lru_cache أو @cache:

main.py
from functools import lru_cache @lru_cache(maxsize=None)def fib(n):    if n < 2:        return n    return fib(n - 1) + fib(n - 2) print(fib(90))
Shell×
>>> %Run main.py
2880067194370816120
>>>

صار كل fib(k) يُحسب مرة واحدة: O(n) زمنًا، مقابل O(n) ذاكرة للقاموس. هذه مقايضة الذاكرة بالزمن التي عرفتها في الدرس 11.

٣الحل الثاني: البرمجة الديناميكية من الأسفل

بدل العودية من الأعلى، نبني الجدول من الأصغر إلى الأكبر بحلقة. وتسمى هذه الطريقة الجدولة Tabulation، وهي قلب البرمجة الديناميكية Dynamic Programming:

main.py
def fib_table(n):    table = [0, 1]    for i in range(2, n + 1):        table.append(table[i - 1] + table[i - 2])    return table[n] def fib_two(n):                  # نحتاج آخر قيمتين فقط: ذاكرة O(1)    a, b = 0, 1    for _ in range(n):        a, b = b, a + b    return a print(fib_table(90), fib_two(90))
Shell×
>>> %Run main.py
2880067194370816120 2880067194370816120
>>>

٤متى تنفع البرمجة الديناميكية؟

حين تحقق المسألة شرطين:

الشرطمعناهفي فيبوناتشي
مسائل فرعية متداخلةالمسائل الصغيرة نفسها تتكرر.fib(3) تُطلب من fib(4) ومن fib(5)
البنية المثلىالحل الأمثل للكبيرة يُبنى من حلول الصغيرة.fib(n) = fib(n−1) + fib(n−2)

مثال عملي: عدد طرق صعود الدرج إن كنت تصعد درجة أو درجتين كل مرة:

main.py
def ways(n):    w = [1, 1]                       # 0 درجات: طريقة واحدة، 1 درجة: طريقة واحدة    for i in range(2, n + 1):        w.append(w[i - 1] + w[i - 2])  # آخر خطوة: درجة واحدة أو درجتان    return w[n] for n in [3, 5, 10, 30]:    print(n, "درجات ← عدد الطرق:", ways(n))
Shell×
>>> %Run main.py
3 درجات ← عدد الطرق: 3
5 درجات ← عدد الطرق: 8
10 درجات ← عدد الطرق: 89
30 درجات ← عدد الطرق: 1346269
>>>

٥مثال: أقل عدد قطع نقدية

كم أقل عدد من القطع لتكوين مبلغ معين من فئات محددة؟ نحسب لكل مبلغ من 0 إلى الهدف: أفضل حل = قطعة واحدة + أفضل حل للمبلغ الباقي:

main.py
def min_coins(coins, amount):    INF = float("inf")    best = [0] + [INF] * amount    for x in range(1, amount + 1):        for c in coins:            if c <= x and best[x - c] + 1 < best[x]:                best[x] = best[x - c] + 1    return best[amount] if best[amount] != INF else -1 print(min_coins([1, 5, 10, 50], 67))print(min_coins([1, 3, 4], 6))       # الجواب 2 (3 + 3)، لا 3 (4 + 1 + 1)
Shell×
>>> %Run main.py
5
2
>>>

التعقيد O(المبلغ × عدد الفئات). وسترى في الدرس 24 أن الطريقة «الجشعة» (خذ الأكبر دائمًا) تخطئ في المثال الثاني.

توقّع وتحقّق

تعقيد فيبوناتشي العودي بلا تخزين:

يتضاعف.

حفظ نتائج الدالة لإعادة استخدامها:

التخزين المؤقت.

بناء الجدول من الأصغر إلى الأكبر بحلقة:

من الأسفل.

شرطا البرمجة الديناميكية:

Overlapping + Optimal.

الخلاصة

تحدٍّ برمجي: مسارات الشبكة

متقدم

روبوت في الزاوية العليا اليسرى من شبكة 4 × 6 يتحرك يمينًا أو أسفل فقط. كم طريقًا إلى الزاوية السفلى اليمنى؟ حلها بجدول البرمجة الديناميكية.

المطلوب:
  • جدول dp بحجم الصفوف × الأعمدة.
  • الصف الأول والعمود الأول: 1 (طريق واحد).
  • dp[r][c] = dp[r-1][c] + dp[r][c-1]
  • اطبع الجواب: 56

ابدأ من هذا الكود، واضغط ▶ شغّل هنا لتكتب حلّك وتجرّبه مباشرة:

main.py
rows, cols = 4, 6
المطلوب×
56
💡 تلميح 1
ابدأ الجدول كله بـ 1، ثم احسب من r = 1 و c = 1.
💡 تلميح 2
كل خانة: من فوقها + من يسارها.

حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.

main.py
rows, cols = 4, 6dp = [[1] * cols for _ in range(rows)]for r in range(1, rows):    for c in range(1, cols):        dp[r][c] = dp[r - 1][c] + dp[r][c - 1]print(dp[rows - 1][cols - 1])
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

📝 معلم؟ أرسل ملاحظة للمؤلف عن هذا الدرس