动态规划算法详解

动态规划(Dynamic Programming,DP)是算法设计中最重要也最容易卡住的思想之一。它的核心不是"递归",而是用空间换时间,避免重复计算。下面从原理、识别方法、经典模型到代码实例完整梳理。

一、核心思想

动态规划解决的是多阶段决策问题:一个问题可以拆成若干重叠的子问题,每个子问题的解会被多次用到。DP 的做法是:

定义状态:用一个或多个变量描述"当前处于什么局面"。

找状态转移方程:当前状态如何由更小状态的解推导出来。

确定边界:最小规模子问题的解(递推起点)。

按顺序求解:自底向上填表,或自顶向下递归+记忆化。

一句话概括:DP = 递归 + 记忆化,或 = 状态 + 转移 + 边界 + 顺序。

二、两个前提条件(判断能不能用 DP)

条件 含义 反例

最优子结构 大问题的最优解由子问题的最优解构成 最长简单路径问题(子问题最优不保证全局最优)

重叠子问题 不同分支会反复求解同一个子问题 归并排序(子问题独立,用分治而非 DP)

分治 vs DP 的关键区别:分治的子问题相互独立(归并排序左右两半互不影响);DP 的子问题相互重叠(斐波那契、背包问题会反复算同一个值)。如果子问题重叠却用朴素递归,复杂度会指数爆炸。

三、两种实现方式

以斐波那契数列 f(n)=f(n-1)+f(n-2) 为例:

  1. 自顶向下(记忆化递归 / Memoization)

python

from functools import lru_cache

@lru_cache(maxsize=None)

def fib(n):

if n <= 1:

return n

return fib(n-1) + fib(n-2)

思路自然,但递归深度大时可能栈溢出。

  1. 自底向上(递推填表 / Tabulation)

python

def fib(n):

if n <= 1:

return n

dp = 0 * (n + 1)

dp0, dp1 = 0, 1

for i in range(2, n + 1):

dpi = dpi-1 + dpi-2

return dpn

无递归开销,还可以进一步把空间压到 O(1)(只保留前两个值)。面试和工程中优先自底向上。

四、DP 解题四步法

这是刷题时最实用的流程:

确定 dp 数组的含义:dpi 或 dpij 代表什么?(这一步最关键,定义错了后面全错)

推导状态转移方程:dpi = ?,即当前状态由哪些前驱状态得来。

初始化边界:dp0、dp1 或第一行第一列的值。

确定遍历顺序:保证计算 dpi 时它依赖的状态已经算好(一维通常从前向后,二维注意行列方向)。

五、经典模型与代码实例

模型 1:线性 DP------爬楼梯(斐波那契变体)

问题:每次可爬 1 或 2 阶,爬 n 阶有多少种方法?

状态:dpi = 爬到第 i 阶的方法数

转移:dpi = dpi-1 + dpi-2(最后一步要么跨 1 阶,要么跨 2 阶)

边界:dp0=1, dp1=1

python

def climb_stairs(n):

if n <= 1:

return 1

dp = 0 * (n + 1)

dp0, dp1 = 1, 1

for i in range(2, n + 1):

dpi = dpi-1 + dpi-2

return dpn

空间优化版:只需保留前两个状态

def climb_stairs_opt(n):

if n <= 1:

return 1

prev, cur = 1, 1

for _ in range(2, n + 1):

prev, cur = cur, prev + cur

return cur

复杂度:时间 O(n),空间 O(n) → 优化后 O(1)。

模型 2:0-1 背包------最经典的 DP

问题:有 n 件物品,第 i 件重量 wi、价值 vi,背包容量 C。每件物品只能选 0 或 1 次,求最大价值。

状态:dpij = 前 i 件物品、容量 j 时的最大价值

转移:

不选第 i 件:dpij = dpi-1j

选第 i 件(需 j >= wi):dpij = dpi-1j-w\[i] + vi

取两者较大值

边界:dp0j = 0(没有物品时价值为 0)

python

def knapsack_01(weights, values, capacity):

n = len(weights)

dpij: 前 i 件物品、容量 j 的最大价值

dp = \[0 * (capacity + 1) for _ in range(n + 1)]

复制代码
for i in range(1, n + 1):
    for j in range(capacity + 1):
        # 不选第 i 件
        dp[i][j] = dp[i-1][j]
        # 选第 i 件(容量够的话)
        if j >= weights[i-1]:
            dp[i][j] = max(dp[i][j],
                           dp[i-1][j - weights[i-1]] + values[i-1])
return dp[n][capacity]

测试

weights = 2, 3, 4, 5

values = 3, 4, 5, 6

print(knapsack_01(weights, values, 8)) # 10

空间优化(一维滚动数组):注意到 dpi 只依赖 dpi-1,可以压成一维。关键点是容量必须倒序遍历,否则同一件物品会被重复选(变成完全背包)。

python

def knapsack_01_opt(weights, values, capacity):

dp = 0 * (capacity + 1)

for i in range(len(weights)):

for j in range(capacity, weightsi - 1, -1): # 倒序!

dpj = max(dpj, dpj - weights\[i] + valuesi)

return dpcapacity

记忆口诀:0-1 背包倒序(每件最多一次),完全背包正序(每件无限次)。

模型 3:最长递增子序列(LIS)

问题:求数组中最长严格递增子序列的长度(子序列不要求连续)。

状态:dpi = 以 numsi 结尾的最长递增子序列长度

转移:dpi = max(dpj) + 1,对所有 j < i 且 numsj < numsi

边界:每个 dpi 初始为 1(自己单独成序列)

python

def length_of_lis(nums):

if not nums:

return 0

n = len(nums)

dp = 1 * n

for i in range(n):

for j in range(i):

if numsj < numsi:

dpi = max(dpi, dpj + 1)

return max(dp)

测试

print(length_of_lis(10, 9, 2, 5, 3, 7, 101, 18)) # 4 (2,3,7,101)

复杂度 O(n²)。进阶有 O(n log n) 的贪心+二分解法(维护一个"最小结尾数组"),面试常考。

模型 4:最长公共子序列(LCS)

问题:求两个字符串的最长公共子序列长度。

状态:dpij = s1 前 i 个字符与 s2 前 j 个字符的 LCS 长度

转移:

若 s1i-1 == s2j-1:dpij = dpi-1j-1 + 1

否则:dpij = max(dpi-1j, dpij-1)

边界:dp0* = dp* 0 = 0

python

def lcs(s1, s2):

m, n = len(s1), len(s2)

dp = \[0 * (n + 1) for _ in range(m + 1)]

for i in range(1, m + 1):

for j in range(1, n + 1):

if s1i-1 == s2j-1:

dpij = dpi-1j-1 + 1

else:

dpij = max(dpi-1j, dpij-1)

return dpmn

测试

print(lcs("abcde", "ace")) # 3 ("ace")

模型 5:编辑距离(Levenshtein Distance)

问题:将 word1 变成 word2 最少需要多少次插入/删除/替换操作。

状态:dpij = word1 前 i 个字符转成 word2 前 j 个字符的最小操作数

转移:

字符相同:dpij = dpi-1j-1

不同:dpij = 1 + min(dpi-1j(删除), dpij-1(插入), dpi-1j-1(替换))

边界:dpi0 = i(删光),dp0j = j(插入 j 个)

python

def min_distance(word1, word2):

m, n = len(word1), len(word2)

dp = \[0 * (n + 1) for _ in range(m + 1)]

for i in range(m + 1):

dpi0 = i

for j in range(n + 1):

dp0j = j

for i in range(1, m + 1):

for j in range(1, n + 1):

if word1i-1 == word2j-1:

dpij = dpi-1j-1

else:

dpij = 1 + min(dpi-1j, # 删除

dpij-1, # 插入

dpi-1j-1) # 替换

return dpmn

测试

print(min_distance("horse", "ros")) # 3

模型 6:最大子数组和(Kadane 算法)

问题:求连续子数组的最大和。

状态:dpi = 以 numsi 结尾的最大子数组和

转移:dpi = max(numsi, dpi-1 + numsi)(要么接上前面,要么从自己重新开始)

边界:dp0 = nums0

python

def max_subarray(nums):

dp = nums0

ans = nums0

for x in nums1::

dp = max(x, dp + x) # 空间已优化为 O(1)

ans = max(ans, dp)

return ans

测试

print(max_subarray(-2, 1, -3, 4, -1, 2, 1, -5, 4)) # 6 (4,-1,2,1)

六、DP 题型识别速查表

题目特征 对应模型 状态维度

求方案数、路径数 计数型 DP 一维/二维

求最大/最小值 最优化 DP(背包类) 一维/二维

两个字符串比较 LCS / 编辑距离 二维 dpij

连续子数组/子串 Kadane / 滑动窗口 DP 一维(以 i 结尾)

子序列(不连续) LIS / LCS 一维/二维

选或不选、容量限制 背包问题 二维→一维

区间合并、矩阵链乘 区间 DP dpij 区间长度遍历

七、常见易错点

dp 数组含义定义模糊:是"以 i 结尾"还是"前 i 个"?两者转移方程完全不同(LIS 与 LCS 就是典型对比)。

遍历顺序错误:二维 DP 要看依赖方向;一维背包要区分正序/倒序。

边界初始化遗漏:dp0 或第一行第一列没赋初值,导致后续全错。

空间优化后忘记倒序:0-1 背包压成一维后必须倒序遍历容量。

把 DP 和贪心混淆:贪心每步取局部最优不回头,DP 会保留所有可能状态取全局最优(如零钱兑换,贪心可能无解)。

相关推荐
xingyuzhisuan1 小时前
不止文生图:StarverseAI 商业视觉解锁视频、展台全场景创作
人工智能
编程见习者1 小时前
AI应用开发之LangGraph解析
人工智能·知识图谱
老唐7771 小时前
大模型面试高频题:Transformer、MoE、RLHF、DPO 核心原理拆解
人工智能·深度学习·ai·大模型·transformer·智能体
m4Rk_1 小时前
【论文阅读】Agent记忆机制(114):MemoryTax——当 Agent 能自主搜索历史,专门的记忆系统还划算吗?
论文阅读·人工智能·学习·开源·github
用户69371750013841 小时前
同事相信 AI 给出的方案,我却不敢直接照做
人工智能
深圳市益普科技有限公司2 小时前
半导体SPC 控制图怎么选:四类图种与八条判异准则的取舍
人工智能
hiahiahia1232 小时前
Structured Output
前端·人工智能
全栈陈序员2 小时前
【Python】基础语法入门(二十五)—— 网络编程入门:用 socket 与 requests 联网
python
2367.2 小时前
AI视频怎么统一片头、字幕、转场和品牌风格:后编辑包装全流程指南
人工智能