0Pricing
Coding Interview Prep · درس

DP من الأسفل إلى الأعلى باستخدام الجدولة

حوّل الحلول من الأعلى إلى الأسفل إلى جداول DP تكرارية، وقلّل المساحة من O(n) إلى O(1) عندما تكون آخر إدخالات قليلة فقط مطلوبة

DP من الأسفل إلى الأعلى باستخدام الجدولة درس مجاني في Coding Interview Prep على CoddyKit. هذا هو الدرس 3 من أصل 4. يمكنك قراءة الدرس كاملاً أدناه مجاناً — ثم تمرن عليه مباشرة في المتصفح باستخدام محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7. هذا الدرس جزء من مسار التعلم في Coding Interview Prep، وتقدمك يتزامن عبر الويب وتطبيق CoddyKit. تتضمن دورة Coding Interview Prep 4 دروس في المجموع.

DP من أسفل إلى أعلى: نهج إنشاء الجدول

تملأ DP من أسفل إلى أعلى (إنشاء الجدول) جدولاً بإجابات المسائل الفرعية، بدءاً من أصغر المسائل الفرعية ووصولاً تدريجياً إلى الإجابة. فبدلاً من التكرار إلى الأسفل والتخزين المؤقت أثناء العودة إلى الأعلى، تحسب النتائج تكرارياً بدءاً من الأساس. يكون الجدول عادةً مصفوفة أحادية أو ثنائية البعد، حيث تُحسب كل خلية اعتماداً على خلايا مملوءة سابقاً. يلغي ذلك التكرار تماماً — فلا مكدس استدعاءات، ولا حد للتكرار، مع محلية أفضل لذاكرة التخزين المؤقت.

# Converting top-down to bottom-up:
# Top-down: start at fib(n), recurse to smaller, cache
# Bottom-up: start at fib(0), fill table to fib(n)

# Key question for bottom-up:
# 'In what order do I fill the table so that when I compute dp[i],
# all values dp[i] depends on are already filled?'
# For Fibonacci: dp[i] needs dp[i-1] and dp[i-2]
# Fill order: i = 2, 3, 4, ..., n (left to right)
print('Bottom-up: fill small sub-problems first, build to answer')

فيبوناتشي من أسفل إلى أعلى

تملأ خوارزمية فيبوناتشي من أسفل إلى أعلى dp[0..n] من اليسار إلى اليمين. dp[i] = dp[i-1] + dp[i-2] عندما تكون i >= 2. تكون الحالتان الأساسيتان dp[0] = 0 وdp[1] = 1، وتُخزّنان مباشرةً في المصفوفة. الزمن هو O(n)، والمساحة هي O(n) للجدول الكامل. وبمجرد أن تلاحظ أن dp[i] يعتمد على القيمتين الأخيرتين فقط، يمكنك تقليل المساحة إلى O(1) باستخدام متغيرين — وهذه هي خطوة تحسين المساحة.

def fib_bottom_up(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[0] = 0  # base case
    dp[1] = 1  # base case
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

print([fib_bottom_up(i) for i in range(10)])
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

# Space-optimised to O(1):
def fib_optimised(n):
    if n <= 1: return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

print(fib_optimised(50))  # 12586269025

Coin Change من أسفل إلى أعلى

بالنسبة إلى Coin Change، يكون جدول النهج من أسفل إلى أعلى هو dp[0..amount]، حيث dp[i] = الحد الأدنى لعدد العملات اللازمة لتكوين المبلغ i. هيّئ dp[0] = 0 (صفر من العملات للمبلغ صفر) وdp[1..amount] = ما لا نهاية. لكل مبلغ i من 1 إلى الهدف، جرّب كل عملة: إذا كان i >= coin، فحينها dp[i] = min(dp[i], 1 + dp[i - coin]). تكون الإجابة dp[amount]، أو -1 إذا بقيت القيمة ما لا نهاية.

def coin_change(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0  # base case: 0 coins for amount 0
    for i in range(1, amount + 1):
        for coin in coins:
            if i >= coin:  # can use this coin
                dp[i] = min(dp[i], 1 + dp[i - coin])
    return dp[amount] if dp[amount] != float('inf') else -1

print(coin_change([1, 5, 6, 9], 11))  # 2: (5+6)
print(coin_change([2], 3))             # -1: impossible
print(coin_change([1, 2, 5], 11))      # 3: 5+5+1
print(coin_change([186, 419, 83, 408], 6249))  # 20

ترتيب الملء: الفكرة المحورية

يُعد ترتيب الملء جوهر DP من أسفل إلى أعلى. بالنسبة إلى أي حالة dp[i]، يجب حساب جميع الحالات التي تعتمد عليها أولاً. في DP أحادية البعد، عندما تعتمد dp[i] على dp[i-1] وdp[i-2]، املأ من اليسار إلى اليمين. وفي DP ثنائية البعد، عندما تعتمد dp[i][j] على dp[i-1][j] وdp[i][j-1]، املأ صفاً تلو الآخر (من الأعلى إلى الأسفل، ومن اليسار إلى اليمين). ارسم دائماً أسهم التبعيات قبل كتابة الشيفرة للتأكد من ترتيب الملء.

# Fill order examples:

# 1D: dp[i] = f(dp[i-1], dp[i-2])
# Arrows point LEFT: fill LEFT TO RIGHT
# i: 0 -> 1 -> 2 -> ... -> n

# 2D: dp[i][j] = f(dp[i-1][j], dp[i][j-1])
# Arrows point LEFT and UP: fill TOP-LEFT TO BOTTOM-RIGHT
# Fill row 0 first, then row 1, etc.

# 2D reversed: dp[i][j] = f(dp[i+1][j], dp[i][j+1])
# Arrows point RIGHT and DOWN: fill BOTTOM-RIGHT TO TOP-LEFT
# Used in interval DP and some string problems

print('Draw dependencies first, then determine fill order')

LCS من أسفل إلى أعلى: جدول ثنائي الأبعاد

يكون جدول النهج من أسفل إلى أعلى لـ المتتالية الفرعية المشتركة الأطول بحجم (m+1) × (n+1)، حيث dp[i][j] = LCS في s1[:i] وs2[:j]. الحالات الأساسية: dp[0][j] = dp[i][0] = 0 (السلسلة الفارغة لها LCS يساوي 0 مع أي شيء). املأ الصفوف واحداً تلو الآخر: إذا كان s1[i-1] == s2[j-1]، فـ dp[i][j] = 1 + dp[i-1][j-1]؛ وإلا فـ dp[i][j] = max(dp[i-1][j], dp[i][j-1]). الإجابة هي dp[m][n].

def lcs_bottom_up(s1, s2):
    m, n = len(s1), len(s2)
    # (m+1) x (n+1) table, initialised to 0
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:         # characters match
                dp[i][j] = 1 + dp[i-1][j-1]
            else:                            # skip one character
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])

    return dp[m][n]

print(lcs_bottom_up('abcde', 'ace'))   # 3
print(lcs_bottom_up('ABCBDAB', 'BDCAB'))  # 4: 'BCAB' or 'BDAB'

تحسين المساحة: المصفوفة الدوّارة

يمكن تقليل كثير من جداول DP ثنائية البعد إلى بعد واحد (أو صفين) بملاحظة أن dp[i][j] يعتمد فقط على الصف الحالي والصف السابق. احتفظ بمصفوفتين: prev وcurr، أو حدّث مصفوفة واحدة بالترتيب الصحيح. في LCS، تعتمد dp[i][j] على dp[i-1][j] وdp[i][j-1] وdp[i-1][j-1] — ويكفي الاحتفاظ بالصف السابق.

def lcs_space_optimised(s1, s2):
    m, n = len(s1), len(s2)
    # Keep only one row (previous row state)
    prev = [0] * (n + 1)
    for i in range(1, m + 1):
        curr = [0] * (n + 1)
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                curr[j] = 1 + prev[j-1]  # dp[i-1][j-1]
            else:
                curr[j] = max(prev[j], curr[j-1])  # dp[i-1][j] and dp[i][j-1]
        prev = curr
    return prev[n]

print(lcs_space_optimised('abcde', 'ace'))   # 3
# Space: O(n) instead of O(mn)

House Robber من أسفل إلى أعلى

تملأ خوارزمية House Robber من أسفل إلى أعلى dp[0..n-1]، حيث dp[i] = أكبر ربح من سرقة المنازل من 0 حتى i. dp[0] = nums[0]، وdp[1] = max(nums[0], nums[1])، ولـ i >= 2: dp[i] = max(dp[i-1], dp[i-2] + nums[i]). وبما أن dp[i] يعتمد على القيمتين الأخيرتين فقط، يمكن تحسين المساحة فوراً إلى O(1) باستخدام متغيرين — وهو نمط شائع في DP أحادية البعد ذات التبعيات ذات الخطوتين.

def rob_bottom_up(nums):
    if not nums: return 0
    if len(nums) == 1: return nums[0]

    # Full table version: O(n) space
    dp = [0] * len(nums)
    dp[0] = nums[0]
    dp[1] = max(nums[0], nums[1])
    for i in range(2, len(nums)):
        dp[i] = max(dp[i-1], dp[i-2] + nums[i])
    return dp[-1]

def rob_optimised(nums):
    # O(1) space: only need last two values
    if not nums: return 0
    if len(nums) == 1: return nums[0]
    prev2, prev1 = nums[0], max(nums[0], nums[1])
    for i in range(2, len(nums)):
        prev2, prev1 = prev1, max(prev1, prev2 + nums[i])
    return prev1

print(rob_optimised([2, 7, 9, 3, 1]))  # 12

Minimum Path Sum في شبكة

تطلب مسألة Minimum Path Sum (LeetCode #64) إيجاد مسار من أعلى اليسار إلى أسفل اليمين يقلل مجموع القيم (يمكنك التحرك إلى اليمين أو إلى الأسفل فقط). في DP ثنائية الأبعاد: dp[i][j] = أقل مجموع للوصول إلى الخلية (i,j). dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]). املأ من اليسار إلى اليمين، ومن الأعلى إلى الأسفل. الحالة الأساسية: dp[0][0] = grid[0][0]، ويُملأ الصف الأول بالتحرك إلى اليمين فقط، والعمود الأول بالتحرك إلى الأسفل فقط.

def min_path_sum(grid):
    rows, cols = len(grid), len(grid[0])
    dp = [[0] * cols for _ in range(rows)]
    dp[0][0] = grid[0][0]
    # Fill first row (can only come from left)
    for c in range(1, cols):
        dp[0][c] = dp[0][c-1] + grid[0][c]
    # Fill first column (can only come from above)
    for r in range(1, rows):
        dp[r][0] = dp[r-1][0] + grid[r][0]
    # Fill rest of the table
    for r in range(1, rows):
        for c in range(1, cols):
            dp[r][c] = grid[r][c] + min(dp[r-1][c], dp[r][c-1])
    return dp[rows-1][cols-1]

grid = [[1,3,1],[1,5,1],[4,2,1]]
print(min_path_sum(grid))  # 7: 1+3+1+1+1

تعديل جدول DP داخل المكان

عندما تكون المساحة الإضافية ممنوعة، يمكنك أحياناً تعديل شبكة الإدخال نفسها واستخدامها جدولاً لـ DP. في Minimum Path Sum، استبدل قيمة grid[i][j] بأقل تكلفة للوصول إلى تلك الخلية. يستخدم ذلك O(1) من المساحة الإضافية، لكنه يدمّر الإدخال — اذكر هذه المفاضلة دائماً للمحاوِر وتأكد من أنها مقبولة. إذا كان يجب الحفاظ على الإدخال، فاستخدم نهج المصفوفة الدوّارة بدلاً منه.

def min_path_sum_inplace(grid):
    rows, cols = len(grid), len(grid[0])
    # Modify grid in-place (O(1) extra space, destroys input)
    for r in range(rows):
        for c in range(cols):
            if r == 0 and c == 0:
                continue  # starting cell
            elif r == 0:
                grid[r][c] += grid[r][c-1]  # first row
            elif c == 0:
                grid[r][c] += grid[r-1][c]  # first column
            else:
                grid[r][c] += min(grid[r-1][c], grid[r][c-1])
    return grid[rows-1][cols-1]

import copy
grid = [[1,3,1],[1,5,1],[4,2,1]]
print(min_path_sum_inplace(copy.deepcopy(grid)))  # 7

مقارنة النهجين من الأعلى إلى الأسفل ومن الأسفل إلى الأعلى في مسألة تبديل العملات

يحلّ كلا النهجين مسألة تبديل العملات على النحو الأمثل، لكنهما يختلفان عمليًا. النهج من الأعلى إلى الأسفل أسهل في الكتابة، ولا يحسب سوى المسائل الفرعية التي يمكن الوصول إليها فعليًا. أما النهج من الأسفل إلى الأعلى فيحسب جميع المبالغ من 0 إلى المبلغ المستهدف، حتى تلك التي يتعذر الوصول إليها باستخدام العملات المعطاة، والتي تظل قيمتها اللانهاية. يكون النهج من الأعلى إلى الأسفل أكثر كفاءة في المسائل المتناثرة، أي التي تحتوي على عدد قليل من الحالات القابلة للوصول، بينما تكون النفقات الإضافية للنهج من الأسفل إلى الأعلى أقل في المسائل الكثيفة.

import functools

# Top-down: only computes reachable amounts
def coin_change_top(coins, amount):
    @functools.lru_cache(maxsize=None)
    def dp(rem):
        if rem == 0: return 0
        if rem < 0: return float('inf')
        return 1 + min(dp(rem - c) for c in coins)
    r = dp(amount)
    return r if r != float('inf') else -1

# Bottom-up: computes all amounts 0 to target
def coin_change_bottom(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    for i in range(1, amount + 1):
        for c in coins:
            if i >= c: dp[i] = min(dp[i], 1 + dp[i-c])
    return dp[amount] if dp[amount] != float('inf') else -1

print(coin_change_top([1,5,6,9], 11))    # 2
print(coin_change_bottom([1,5,6,9], 11)) # 2

المسارات الفريدة: البرمجة الديناميكية الثنائية الأبعاد

تحسب المسارات الفريدة (LeetCode #62) عدد المسارات من الزاوية العلوية اليسرى إلى الزاوية السفلية اليمنى في شبكة m×n، مع السماح بالحركة إلى اليمين أو إلى الأسفل فقط. العلاقة التكرارية مباشرة: dp[i][j] = dp[i-1][j] + dp[i][j-1] — أي المسارات القادمة من الأعلى مضافًا إليها المسارات القادمة من اليسار. حالات الأساس: يحتوي الصف الأول والعمود الأول بالكامل على مسار واحد بالضبط، إذ لا يوجد سوى اتجاه واحد للحركة. تملأ هذه البرمجة الديناميكية الثنائية الأبعاد الجدول في زمن O(mn)، ويمكن تقليل المساحة إلى O(n) باستخدام صف متحرك.

def unique_paths(m, n):
    # dp[i][j] = number of paths to reach cell (i,j)
    dp = [[1] * n for _ in range(m)]
    # Base: first row and first column are all 1
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = dp[i-1][j] + dp[i][j-1]
    return dp[m-1][n-1]

print(unique_paths(3, 7))   # 28
print(unique_paths(3, 2))   # 3

# O(n) space rolling row:
def unique_paths_opt(m, n):
    row = [1] * n
    for _ in range(1, m):
        for j in range(1, n):
            row[j] += row[j-1]
    return row[n-1]

print(unique_paths_opt(3, 7))  # 28

اختبار سريع

تحقّقوا من فهمكم لمفاهيم Data Structures & Algorithms — Coding Interview Prep الواردة في هذا الدرس.

ملخص الدرس

تعلّمتم في هذا الدرس: البرمجة الديناميكية من الأسفل إلى الأعلى باستخدام الجدولة، وكيفية تحديد ترتيب الملء انطلاقًا من أسهم الاعتماد، وتحسين المساحة باستخدام المصفوفات المتحركة (من O(mn) إلى O(n)) وتتبع متغيرين (من O(n) إلى O(1))، بالإضافة إلى تطبيقات البرمجة الديناميكية من الأسفل إلى الأعلى لمسائل فيبوناتشي، وتبديل العملات، وLCS، وسارق المنازل، وأقل مجموع لمسار. بعد ذلك، سنحل مسألتي تبديل العملات والسلم ذي أقل تكلفة من البداية إلى النهاية.

الأسئلة الشائعة

هل درس «DP من الأسفل إلى الأعلى باستخدام الجدولة» مجاني؟

نعم — نص درس «DP من الأسفل إلى الأعلى باستخدام الجدولة» كامل متاح مجاناً هنا على الويب. لتمرينه بشكل تفاعلي (محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7) وفتح باقي دورة Coding Interview Prep، انتقل إلى CoddyKit PRO. تتضمن دورة Coding Interview Prep 4 دروس في المجموع.

ماذا ستتعلم في «DP من الأسفل إلى الأعلى باستخدام الجدولة»؟

حوّل الحلول من الأعلى إلى الأسفل إلى جداول DP تكرارية، وقلّل المساحة من O(n) إلى O(1) عندما تكون آخر إدخالات قليلة فقط مطلوبة تتمرن على Coding Interview Prep مع أكواد عملية تشغلها مباشرة في المتصفح، ومدرس ذكاء اصطناعي متاح 24/7 يجيب على أسئلتك أثناء عملك.

هل أحتاج إلى خبرة سابقة لأبدأ Coding Interview Prep؟

لا تُشترط خبرة سابقة. Coding Interview Prep على CoddyKit منظم للمبتدئين حتى المتقدمين، لذا يمكنك البدء من هنا أو من البداية والتقدم بسرعتك الخاصة. هذا هو الدرس 3 من أصل 4.

كم من الوقت يستغرق درس «DP من الأسفل إلى الأعلى باستخدام الجدولة»؟

معظم دروس CoddyKit تستغرق حوالي 5–10 دقائق. كل منها موجز وتفاعلي، لذا تحرز تقدماً مستمراً وتستأنف من حيث توقفت عبر الويب والتطبيق.

هل يمكنني كتابة وتشغيل أكواد في درس Coding Interview Prep هذا؟

نعم. كل درس في Coding Interview Prep يتضمن محرر أكواد مدمج، لذا تكتب وتشغل أكواداً حقيقية مباشرة في متصفحك وتحصل على تعليقات فورية من الذكاء الاصطناعي — بدون إعداد محلي.

جميع الدروس في هذه الدورة

  1. التعرّف إلى DP: المسائل الفرعية المتداخلة
  2. DP من الأعلى إلى الأسفل مع التخزين المؤقت
  3. DP من الأسفل إلى الأعلى باستخدام الجدولة
  4. تغيير العملات والسلم ذي التكلفة الدنيا
← العودة إلى Coding Interview Prep