التعرّف إلى DP: المسائل الفرعية المتداخلة
حدّد متى يعيد الاستدعاء الذاتي بالقوة الغاشمة حل المسألة الفرعية نفسها، وارسم شجرة الاستدعاء الذاتي لـ Fibonacci، ولاحظ التضخم الأسي
التعرّف إلى DP: المسائل الفرعية المتداخلة درس مجاني في DSA Interview Prep على CoddyKit. هذا هو الدرس 1 من أصل 4. يمكنك قراءة الدرس كاملاً أدناه مجاناً — ثم تمرن عليه مباشرة في المتصفح باستخدام محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7. هذا الدرس جزء من مسار التعلم في DSA Interview Prep، وتقدمك يتزامن عبر الويب وتطبيق CoddyKit. تتضمن دورة DSA Interview Prep 4 دروس في المجموع.
ما البرمجة الديناميكية؟
تحل البرمجة الديناميكية (DP) المسائل المعقدة بتقسيمها إلى مسائل فرعية أبسط ومتداخلة، وحل كل مسألة فرعية مرة واحدة، وتخزين النتيجة لتجنّب الحسابات المتكررة. تنطبق DP عندما تحتوي المسألة على عنصرين: مسائل فرعية متداخلة (تُحل المسألة الفرعية نفسها عدة مرات في الاستدعاء التكراري الساذج) وبنية مثلى (يمكن بناء الحل الأمثل من حلول مثلى للمسائل الفرعية). من دون هذين العنصرين معًا، لا تفيد DP.
# Two ingredients of DP:
# 1. Overlapping sub-problems:
# fib(5) -> fib(4) + fib(3)
# fib(4) -> fib(3) + fib(2) <- fib(3) computed twice!
# Without caching: O(2^n) calls for Fibonacci
# 2. Optimal substructure:
# Shortest path from A to C through B:
# shortest(A,C) = shortest(A,B) + shortest(B,C)
# The sub-path A->B must itself be the shortest
# Contrast with greedy: greedy makes one locally optimal
# choice; DP tries all choices and picks the best.
print('DP = overlapping sub-problems + optimal substructure')فيبوناتشي: المدخل الكلاسيكي إلى DP
تُعد متتالية فيبوناتشي (fib(n) = fib(n-1) + fib(n-2)) المثال الأساسي على المسائل الفرعية المتداخلة. يستغرق الاستدعاء التكراري الساذج زمنًا أُسّيًا O(2^n)، لأنه يعيد حساب القيم نفسها مرارًا. وتُظهر شجرة الاستدعاءات لـ fib(6) أن fib(3) حُسبت 3 مرات، وأن fib(2) حُسبت 5 مرات، وهكذا. وهذا التضخم الأُسّي هو بالضبط ما تلغيه DP من خلال تخزين النتائج المحسوبة.
import time
def fib_naive(n):
if n <= 1:
return n
return fib_naive(n-1) + fib_naive(n-2)
# Count the calls:
call_count = [0]
def fib_count(n):
call_count[0] += 1
if n <= 1: return n
return fib_count(n-1) + fib_count(n-2)
fib_count(10)
print(f'Calls for fib(10): {call_count[0]}') # 177 calls for n=10!
call_count[0] = 0
fib_count(20)
print(f'Calls for fib(20): {call_count[0]}') # 21891 calls
# n=30 -> ~2.7 million calls: exponential growthتصوّر شجرة الاستدعاء التكراري
يكشف رسم شجرة الاستدعاء التكراري لـ fib(5) مقدار الهدر: إذ تنشئ كل عقدة ابنتين، وتتكرر الأشجار الفرعية المتطابقة مرات عديدة. ويبلغ العدد الإجمالي لعقد الشجرة O(2^n). عندما ترى هذا النمط — استدعاءات دالة متطابقة بالوسائط نفسها تتكرر في الشجرة — فهذا يشير إلى أن DP يمكن أن تساعد من خلال تخزين النتائج مؤقتًا. وتُعد مهارة تصوّر ذلك أمرًا بالغ الأهمية: فإذا استطعت تحديد الأشجار الفرعية المتكررة، عرفت أن DP قابلة للتطبيق.
# fib(5) recursion tree (simplified):
# fib(5)
# / \
# fib(4) fib(3)
# / \ / \
# fib(3) fib(2) fib(2) fib(1)
# / \ \
# fib(2) fib(1) fib(1)
# / \
# fib(1) fib(0)
# fib(3) appears TWICE
# fib(2) appears THREE TIMES
# Each redundant call wastes exponential time
# Key insight: fib(n) only has O(n) DISTINCT sub-problems
# (fib(0), fib(1), ..., fib(n))
# DP computes each ONCE -> O(n) total
print('Distinct sub-problems: O(n) but naive calls: O(2^n)')تحديد المسائل الفرعية المتداخلة
للتعرّف على المسائل الفرعية المتداخلة، اكتب الاستدعاء التكراري للقوة الغاشمة، ثم اسأل: «هل توجد استدعاءات تكرارية متعددة بالوسائط نفسها؟» إذا كانت الإجابة نعم، فقد تساعد DP. ومن الإشارات الشائعة في أوصاف المسائل: «العدد الأدنى/الأقصى من X»، و«كم عدد الطرق لتحقيق Y»، و«هل يمكننا تحقيق Z؟». وتشير أنماط الصياغة هذه دائمًا تقريبًا إلى مسألة ذات بنية مثلى، حيث تعتمد الإجابة عند الموضع i على إجابات المواضع السابقة.
# DP signal phrases in problem statements:
# 'minimum number of coins to make amount X'
# 'maximum profit from stock trades'
# 'number of ways to climb n stairs'
# 'can you reach the last index?'
# 'longest common subsequence'
# 'edit distance between two strings'
# All have this shape:
# solve(input) = f(solve(smaller_input_1), solve(smaller_input_2), ...)
# And multiple branches end up calling solve with the same argument.
# If the recursion tree has repeated nodes: DP
# If subproblems are all independent: divide-and-conquer (no DP needed)
print('Repeated arguments in recursion tree -> DP')شرح البنية المثلى
تعني البنية المثلى أنه يمكن بناء الحل الأمثل للمسألة من حلول مثلى لمسائلها الفرعية. فعلى سبيل المثال، يكون أقصر مسار من A إلى C مرورًا بـ B أمثلَ إذا وفقط إذا كان المساران الفرعيان A→B وB→C أمثلين كلٌّ على حدة. إذا تحققت هذه الخاصية، يمكنك بناء الحل الأمثل العام تصاعديًا من الحلول المثلى المحلية. أما المسائل التي تفتقر إلى البنية المثلى (مثل أطول مسار في رسم بياني عام يحتوي على دورات)، فلا يمكن حلها باستخدام DP.
# Optimal substructure examples:
# SHORTEST PATH: shortest(A,C) = min over all B: shortest(A,B) + w(B,C)
# -> Sub-paths must be optimal: YES, has optimal substructure
# LONGEST PATH (no cycles, DAG): can also use DP
# -> Longer path through node B means sub-path A->B must be longest
# LONGEST PATH (with cycles): NO optimal substructure
# -> Best path from A to C might reuse nodes: sub-problems not independent
# COIN CHANGE: min coins for amount n = 1 + min(min coins for n-coin_i)
# -> YES: optimal for n-coin_i is needed for optimal n
print('Optimal substructure: build global optimum from local optima')تسلّق الدرج: أول تطبيق لك في DP
تسلّق الدرج (LeetCode #70): كم عدد الطرق المختلفة لتسلّق n درجة، مع اتخاذ خطوة واحدة أو خطوتين في كل مرة؟ لتكن dp[i] = عدد الطرق للوصول إلى الدرجة i. يمكنك الوصول إلى الدرجة i من الدرجة i-1 (خطوة واحدة) أو من الدرجة i-2 (خطوتان)، ولذلك dp[i] = dp[i-1] + dp[i-2]. هذه هي متتالية فيبوناتشي! الحالات الأساسية: dp[1] = 1 وdp[2] = 2. ويُعد إدراك أن «تسلّق الدرج» يختزل إلى فيبوناتشي من الرؤى الكلاسيكية في المقابلات.
def climb_stairs(n):
if n <= 2:
return n
dp = [0] * (n + 1)
dp[1] = 1 # 1 way to reach step 1
dp[2] = 2 # 2 ways to reach step 2: (1+1) or (2)
for i in range(3, n + 1):
dp[i] = dp[i-1] + dp[i-2] # come from i-1 or i-2
return dp[n]
for n in range(1, 8):
print(f'climb_stairs({n}) = {climb_stairs(n)}')
# 1, 2, 3, 5, 8, 13, 21 -- Fibonacci sequence!إطار DP: تعريف الحالة، والعلاقة التكرارية، وترتيب الملء
إطار موثوق من 3 خطوات لـ DP: 1. تعريف الحالة — ماذا تمثل dp[i] (أو dp[i][j])؟ اكتب ذلك بالإنجليزية. 2. كتابة العلاقة التكرارية — عبّر عن dp[i] بدلالة مسائل فرعية أصغر. أدرج جميع الحالات. 3. تحديد ترتيب الملء — تأكد من حساب dp[i-1] (والتبَعيات الأخرى) قبل dp[i]. تهيّئ الحالات الأساسية الحدود. يحوّل هذا الإطار حدس DP غير الواضح إلى خطة تنفيذ ملموسة.
# Framework applied to climbing stairs:
# Step 1 - Define state:
# dp[i] = number of distinct ways to reach step i
# Step 2 - Recurrence:
# dp[i] = dp[i-1] + dp[i-2] (come from step i-1 or i-2)
# Step 3 - Fill order:
# Compute dp[1], dp[2], dp[3], ..., dp[n] in order
# Because dp[i] depends on dp[i-1] and dp[i-2] (smaller)
# Base cases: dp[1]=1, dp[2]=2
# Framework applied to coin change:
# Step 1: dp[amount] = minimum coins to make that amount
# Step 2: dp[i] = 1 + min(dp[i-coin] for coin in coins if i >= coin)
# Step 3: Fill i from 1 to amount
# Base: dp[0] = 0 (zero coins for zero amount)
print('DP framework: define state -> recurrence -> fill order')متى لا تستخدم DP
ليست DP هي الحل دائماً. استخدم الخوارزمية الجشعة عندما يؤدي اختيار محلي أمثل واحد دائماً إلى الحل الأمثل عالمياً (activity selection، وjump game I). استخدم التقسيم والغزو عندما لا تتداخل المسائل الفرعية (merge sort، والبحث الثنائي). استخدم BFS عندما تكون المسألة متعلقة بأقصر مسار في رسم بياني غير موزون. تكون DP صحيحة، لكنها غالباً مبالغة في الحل عندما يتوفر نهج جشع أو أبسط. في المقابلات، ناقش سبب اختيارك DP بدلاً من البدائل.
# DP vs alternatives:
# Problem: can you jump to the end of the array?
# Greedy: track max reachable index -> O(n) O(1) BETTER than DP
# Problem: shortest path unweighted graph?
# BFS: O(V+E) BETTER than DP on general graph
# Problem: sort an array?
# Comparison sort: O(n log n), no DP needed
# DP IS the right choice when:
# - Greedy fails (choices interact)
# - Need to count/enumerate all possibilities
# - Problem has 'how many ways' or 'minimum/maximum' flavor
# - Recursion tree clearly shows overlapping sub-problems
print('Ask: does greedy fail? If yes, consider DP.')عدّ المسائل الفرعية المختلفة
يحدد عدد المسائل الفرعية المختلفة التعقيدين الزمني والمكاني لـ DP. في DP أحادية البعد على مُدخل حجمه n، توجد O(n) من المسائل الفرعية. وفي DP ثنائية البعد على مُدخلين بحجمَي m وn، توجد O(mn) من المسائل الفرعية. إذا حُلّت كل مسألة فرعية في زمن O(k) (لوجود k اختيارات في كل خطوة)، يصبح الزمن الكلي O(n*k) أو O(mn*k). احسب دائماً عدد المسائل الفرعية المختلفة أولاً — فهذا يعطيك التعقيد الزمني لـ DP قبل أن تكتب أي شيفرة.
# Sub-problem count examples:
# Problem | Sub-problems | Each costs | Total
# Fibonacci | O(n) | O(1) | O(n)
# Coin change | O(amount) | O(coins) | O(amount * coins)
# LCS (m,n chars) | O(m*n) | O(1) | O(m*n)
# Edit distance | O(m*n) | O(1) | O(m*n)
# 0/1 Knapsack | O(n*W) | O(1) | O(n*W)
# Matrix chain | O(n^2) | O(n) | O(n^3)
# Rule: DP time = (# distinct sub-problems) * (time per sub-problem)
print('Time = subproblems * work-per-subproblem')House Robber: الخيارات المتداخلة
تطلب مسألة House Robber (LeetCode #198) إيجاد أكبر مبلغ يمكنك سرقته من منازل متجاورة في صف واحد، من دون سرقة منزلين متجاورين. في كل منزل، تختار: سرقته (إضافة قيمته وتجاوز المنزل السابق) أو تخطيه (أخذ أفضل نتيجة من المنزل السابق). dp[i] = max(dp[i-1], dp[i-2] + nums[i]). يُعد نمط الاختيار في كل خطوة أبسط علاقة تكرارية لـ DP أحادية البعد، ويظهر في عشرات مسائل المقابلات.
def rob(nums):
if not nums: return 0
if len(nums) == 1: return nums[0]
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], # skip house i
dp[i-2] + nums[i]) # rob house i
return dp[-1]
print(rob([1, 2, 3, 1])) # 4: rob house 0 and 2 (1+3)
print(rob([2, 7, 9, 3, 1]))# 12: rob house 0, 2, 4 (2+9+1)
print(rob([2, 1, 1, 2])) # 4: rob house 0 and 3اختبار سلامة: القوة الغاشمة مقابل DP
تحقق دائماً من DP بمقارنتها بحل بالقوة الغاشمة على مُدخلات صغيرة. فالحل بالقوة الغاشمة هو مرجعك الصحيح. بعد أن تتطابق DP مع الحل بالقوة الغاشمة في جميع حالات الاختبار، ستعرف أن العلاقة التكرارية صحيحة. عندها فقط حسّن استخدام المساحة. يُعد هذا النهج المعتمد على الاختبارات — القوة الغاشمة → DP من أعلى إلى أسفل → DP من أسفل إلى أعلى → DP المحسّنة من حيث المساحة — الطريقة الاحترافية لتطوير حلول DP والتحقق منها أثناء المقابلة.
# Brute-force for house robber (exponential)
def rob_brute(nums, i=0):
if i >= len(nums):
return 0
# Option 1: rob house i
rob_it = nums[i] + rob_brute(nums, i + 2)
# Option 2: skip house i
skip_it = rob_brute(nums, i + 1)
return max(rob_it, skip_it)
# Verify on small inputs:
test_cases = [[1,2,3,1], [2,7,9,3,1], [2,1,1,2]]
for tc in test_cases:
bf = rob_brute(tc)
dp = rob(tc)
print(f'{tc}: brute={bf}, dp={dp}, match={bf==dp}')اختبار سريع
اختبر فهمك لمفاهيم Data Structures & Algorithms — Coding Interview Prep الواردة في هذا الدرس.
مراجعة الدرس
تعلمت في هذا الدرس: مكوّني DP (المسائل الفرعية المتداخلة والبنية المثلى)، وكيفية تصوير شجرة الاستدعاء التكراري لتحديد الاستدعاءات المتكررة، وإطار DP ذي الخطوات الثلاث (تعريف الحالة، والعلاقة التكرارية، وترتيب الملء)، إضافةً إلى أمثلة أولى تشمل فيبوناتشي، وصعود السلالم، وHouse Robber. ننتقل بعد ذلك إلى تنفيذ DP من أعلى إلى أسفل باستخدام التخزين المؤقت.
الأسئلة الشائعة
هل درس «التعرّف إلى DP: المسائل الفرعية المتداخلة» مجاني؟
نعم — نص درس «التعرّف إلى DP: المسائل الفرعية المتداخلة» كامل متاح مجاناً هنا على الويب. لتمرينه بشكل تفاعلي (محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7) وفتح باقي دورة DSA Interview Prep، انتقل إلى CoddyKit PRO. تتضمن دورة DSA Interview Prep 4 دروس في المجموع.
ماذا ستتعلم في «التعرّف إلى DP: المسائل الفرعية المتداخلة»؟
حدّد متى يعيد الاستدعاء الذاتي بالقوة الغاشمة حل المسألة الفرعية نفسها، وارسم شجرة الاستدعاء الذاتي لـ Fibonacci، ولاحظ التضخم الأسي تتمرن على DSA Interview Prep مع أكواد عملية تشغلها مباشرة في المتصفح، ومدرس ذكاء اصطناعي متاح 24/7 يجيب على أسئلتك أثناء عملك.
هل أحتاج إلى خبرة سابقة لأبدأ DSA Interview Prep؟
لا تُشترط خبرة سابقة. DSA Interview Prep على CoddyKit منظم للمبتدئين حتى المتقدمين، لذا يمكنك البدء من هنا أو من البداية والتقدم بسرعتك الخاصة. هذا هو الدرس 1 من أصل 4.
كم من الوقت يستغرق درس «التعرّف إلى DP: المسائل الفرعية المتداخلة»؟
معظم دروس CoddyKit تستغرق حوالي 5–10 دقائق. كل منها موجز وتفاعلي، لذا تحرز تقدماً مستمراً وتستأنف من حيث توقفت عبر الويب والتطبيق.
هل يمكنني كتابة وتشغيل أكواد في درس DSA Interview Prep هذا؟
نعم. كل درس في DSA Interview Prep يتضمن محرر أكواد مدمج، لذا تكتب وتشغل أكواداً حقيقية مباشرة في متصفحك وتحصل على تعليقات فورية من الذكاء الاصطناعي — بدون إعداد محلي.
جميع الدروس في هذه الدورة
- التعرّف إلى DP: المسائل الفرعية المتداخلة
- DP من الأعلى إلى الأسفل مع التخزين المؤقت
- DP من الأسفل إلى الأعلى باستخدام الجدولة
- تغيير العملات والسلم ذي التكلفة الدنيا