识别 DP:重叠子问题
识别暴力递归反复求解同一子问题的情况,画出斐波那契的递归树,并观察指数级增长。
识别 DP:重叠子问题 是 CoddyKit 上的免费 DSA Interview Prep 课时。 这是第 1 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 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)')识别重叠子问题
要识别重叠子问题,可以先写出暴力递归,然后问自己:“是否有多个递归调用使用 SAME(相同的)参数?”如果有,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 经过 B 到达 C 的最短路径是最优的,当且仅当子路径 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):每次走 1 级或 2 级,爬上 n 级楼梯有多少种不同的方法?令 dp[i] 表示到达第 i 级楼梯的方法数。到达第 i 级可以从第 i-1 级走 1 步,也可以从第 i-2 级走 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] 之前,已经计算出 dp[i-1](以及其他依赖项)。基本情况用于初始化边界。这个框架会将模糊的 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')何时 NOT 使用 DP
DP 不总是答案。当单个局部最优选择总能导向全局最优解时,请使用贪心方法(活动选择、跳跃游戏 I)。当子问题不重叠时,请使用分治方法(归并排序、二分查找)。当问题是在无权图中寻找最短路径时,请使用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 的时间和空间复杂度。对于规模为 n 的输入上的一维 DP,有 O(n) 个子问题。对于两个输入规模分别为 m 和 n 的二维 DP,有 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')打家劫舍:重叠选择
打家劫舍(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}')快速检查
请测试您对本课程中“数据结构与算法——编程面试准备”概念的理解。
课程回顾
本课您学到了:DP 的两个要素(重叠子问题和最优子结构)、如何将递归树可视化以识别重复调用、DP 三步框架(定义状态、递推关系、填表顺序),以及最初的几个示例,包括斐波那契、爬楼梯和打家劫舍。接下来我们将使用记忆化实现自顶向下 DP。
常见问题解答
「识别 DP:重叠子问题」课时是免费的吗?
是的 — 「识别 DP:重叠子问题」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 DSA Interview Prep 课程的其余内容,请升级到 CoddyKit PRO。 DSA Interview Prep 课程共包含 4 节课。
「识别 DP:重叠子问题」这节课中我会学到什么?
识别暴力递归反复求解同一子问题的情况,画出斐波那契的递归树,并观察指数级增长。 你通过在浏览器中直接运行的动手代码来练习 DSA Interview Prep,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 DSA Interview Prep 需要有经验吗?
无需任何先前经验。CoddyKit 上的 DSA Interview Prep 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 1 节课,共 4 节。
「识别 DP:重叠子问题」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 DSA Interview Prep 课中编写并运行代码吗?
能。每节 DSA Interview Prep 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- 识别 DP:重叠子问题
- 自顶向下的 DP 与记忆化
- 自底向上的 DP 与制表法
- 零钱兑换与最低成本爬楼梯