0Pricing
DSA Interview Prep · 课时

识别 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 反馈 — 无需本地设置。

此课程中的所有课时

  1. 识别 DP:重叠子问题
  2. 自顶向下的 DP 与记忆化
  3. 自底向上的 DP 与制表法
  4. 零钱兑换与最低成本爬楼梯
← 返回 DSA Interview Prep