一文吃透动态规划:从原理到实战(含 4 道经典例题 + 代码优化)

一文吃透动态规划:从原理到实战(含 4 道经典例题 + 代码优化)

在算法世界中,动态规划(Dynamic Programming,简称 DP)是一种「化繁为简」的智慧 —— 它将复杂问题拆解为重叠的子问题,通过存储子问题的最优解,避免重复计算,最终高效得到全局最优解。这种思想广泛应用于最优路径、资源分配、序列匹配等场景,也是面试中仅次于贪心的高频考点。

本文将从「核心原理→适用条件→经典例题(含空间优化)→避坑指南」四个维度,帮你彻底打通动态规划的任督二脉,看完直接上手刷题!

一、动态规划是什么?核心思想拆解

1. 定义

动态规划是指在解决问题时,将问题分解为若干个重叠的子问题,通过记录子问题的最优解(存储在「DP 表」中),避免重复计算,最终递推得到原问题的最优解。

2. 核心特点

重叠子问题:原问题的解依赖多个子问题的解,且子问题之间存在重复(比如斐波那契数列中,f (5)=f (4)+f (3),f (4)=f (3)+f (2),f (3) 被重复计算);

最优子结构:原问题的最优解包含子问题的最优解(比如最短路径中,A→C 的最短路径一定包含 A→B 的最短路径);

无后效性:子问题的解一旦确定,就不会被后续决策影响(比如确定 f (3) 后,计算 f (4) 时只需使用 f (3) 的结果,无需关心 f (3) 是如何得到的)。

3. 动态规划 vs 贪心算法(关键区别)

很多人会混淆 DP 和贪心,用一张表明确区分:

特性

动态规划

贪心算法

核心思想

子问题重叠 + 最优子结构,存储子问题解

局部最优→全局最优,不存储子问题解

适用条件

重叠子问题、最优子结构

贪心选择性质、最优子结构

决策方式

自底向上 / 自顶向下递推

一步到位的局部最优决策

典型场景

0-1 背包、最长子序列、最短路径

找零、活动选择、区间覆盖

4. 动态规划的核心三要素

解决任何 DP 问题,都离不开这三个核心步骤:

状态定义:确定 DP 表的含义(比如dp[i]代表什么);

转移方程:如何通过子问题的解推导当前问题的解(比如dp[i] = dp[i-1] + dp[i-2]);

初始条件 + 边界处理:确定 DP 表的起始值(比如dp[0]=0, dp[1]=1)和边界约束(比如数组越界、无效状态)。

二、动态规划的适用条件(别用错场景!)

并非所有问题都适合用 DP,必须满足两个核心条件:

重叠子问题:子问题重复出现,否则存储子问题解毫无意义(比如排序问题无重叠子问题,无需 DP);

最优子结构:原问题的最优解可由子问题的最优解推导而来(比如「最长递增子序列」中,以第 i 个元素结尾的最长子序列,可由前 i-1 个元素的最长子序列推导)。

如何判断一个问题能否用 DP?

答:先看是否有重叠子问题(比如递归解法会重复计算),再验证是否满足最优子结构(举例子看子问题最优是否能推导全局最优)。

三、经典例题实战(含 Python 代码 + 优化技巧)

例题 1:斐波那契数列(入门 DP,理解重叠子问题)

问题描述

求斐波那契数列的第 n 项(n≥0),数列定义:f (0)=0,f (1)=1,f (n)=f (n-1)+f (n-2)(n≥2)。

问题分析

递归解法的问题:会重复计算大量子问题(比如 f (5) 需要 f (4) 和 f (3),f (4) 又需要 f (3) 和 f (2),f (3) 被计算 2 次),时间复杂度 O (2ⁿ);

DP 解法:用数组存储已计算的子问题解,时间复杂度优化到 O (n),空间可进一步优化到 O (1)。

动态规划思路

状态定义:dp[i] 表示斐波那契数列的第 i 项;

转移方程:dp[i] = dp[i-1] + dp[i-2](i≥2);

初始条件:dp[0] = 0,dp[1] = 1;

边界处理:n=0 或 n=1 时直接返回初始值。

代码实现(基础版 + 空间优化版)

# 基础版:空间复杂度O(n)

def fib_base(n):

if n <= 1:

return n

# 初始化DP表

dp = [0] * (n+1)

dp[1] = 1

# 递推计算

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

dp[i] = dp[i-1] + dp[i-2]

return dp[n]

# 优化版:空间复杂度O(1)(只存储前两个状态)

def fib_optimized(n):

if n <= 1:

return n

a, b = 0, 1 # a=dp[i-2], b=dp[i-1]

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

a, b = b, a + b # 滚动更新

return b

# 测试用例

print("斐波那契第10项(基础版):", fib_base(10)) # 输出:55

print("斐波那契第10项(优化版):", fib_optimized(10)) # 输出:55

复杂度分析

基础版:时间 O (n),空间 O (n);

优化版:时间 O (n),空间 O (1)(核心:用变量滚动存储,无需完整 DP 表)。

例题 2:爬楼梯(入门进阶,理解状态转移)

问题描述

假设你正在爬楼梯,需要 n 阶才能到达楼顶。每次可以爬 1 或 2 个台阶,求有多少种不同的爬楼方式?

动态规划思路

状态定义:dp[i] 表示爬到第 i 阶的不同方式数;

转移方程:爬到第 i 阶,只能从第 i-1 阶(爬 1 步)或第 i-2 阶(爬 2 步)过来,因此 dp[i] = dp[i-1] + dp[i-2];

初始条件:dp[0] = 1(0 阶只有 1 种方式:不爬),dp[1] = 1(1 阶只有 1 种方式:爬 1 步);

边界处理:n≥0,无负数阶数。

代码实现(空间优化版)

def climb_stairs(n):

if n <= 1:

return 1

a, b = 1, 1 # a=dp[i-2], b=dp[i-1]

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

a, b = b, a + b

return b

# 测试用例

print("爬5阶楼梯的方式数:", climb_stairs(5)) # 输出:8(1+1+1+1+1、1+1+2、1+2+1、2+1+1、2+2+1、2+1+2、1+2+2、2+2)

关键说明

本题本质是斐波那契数列的变种,核心是找到状态转移的逻辑;

空间优化的核心:当dp[i]只依赖前 k 个状态时,可用 k 个变量滚动存储,无需完整 DP 表。

例题 3:0-1 背包问题(经典 DP,理解二维 DP 表)

问题描述

有 n 件物品,每件物品的重量为weight[i],价值为value[i],背包的最大容量为capacity。每件物品只能选或不选(0-1),求背包能装下的最大价值。

问题分析

贪心算法不适用:比如物品 [3,4]、价值 [5,6]、背包容量 5,贪心选价值 / 重量比高的 4(价值 6),但最优是选 3(价值 5)+ 剩余 2 装不下,总价值 5?不对,正确反例:物品 [2,3,4]、价值 [3,4,5]、背包容量 5,贪心选 4(价值 5),最优是 2+3(价值 7);

DP 解法:用二维 DP 表记录「前 i 件物品 + 容量 j」的最大价值,避免重复计算。

动态规划思路

状态定义:dp[i][j] 表示前 i 件物品(0~i-1)放入容量为 j 的背包,能获得的最大价值;

转移方程:

不选第 i-1 件物品:dp[i][j] = dp[i-1][j](继承前 i-1 件的最大价值);

选第 i-1 件物品(前提:j≥weight [i-1]):dp[i][j] = dp[i-1][j - weight[i-1]] + value[i-1](前 i-1 件物品放入容量 j-weight [i-1] 的背包,再加上当前物品的价值);

最终取两者最大值:dp[i][j] = max(不选, 选);

初始条件:dp[0][j] = 0(0 件物品,价值为 0),dp[i][0] = 0(容量为 0,价值为 0);

边界处理:物品重量不能超过背包容量。

代码实现(基础二维版 + 空间优化一维版)

# 基础版:二维DP表,空间复杂度O(n*capacity)

def knapsack_01_base(weight, value, capacity):

n = len(weight)

# 初始化DP表:(n+1)行(物品数),(capacity+1)列(容量)

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

# 递推计算

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

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

# 第i-1件物品的重量超过当前容量j,无法选择

if weight[i-1] > j:

dp[i][j] = dp[i-1][j]

else:

# 选或不选,取最大值

dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i-1]] + value[i-1])

return dp[n][capacity]

# 优化版:一维DP表,空间复杂度O(capacity)(逆序遍历避免覆盖)

def knapsack_01_optimized(weight, value, capacity):

n = len(weight)

# 初始化一维DP表:容量从0到capacity

dp = [0]*(capacity+1)

# 递推计算(逆序遍历容量)

for i in range(n):

# 逆序遍历:避免重复选择同一物品(0-1背包只能选一次)

for j in range(capacity, weight[i]-1, -1):

dp[j] = max(dp[j], dp[j - weight[i]] + value[i])

return dp[capacity]

# 测试用例

weight = [2,3,4,5]

value = [3,4,5,6]

capacity = 8

print("0-1背包最大价值(二维版):", knapsack_01_base(weight, value, capacity)) # 输出:10(3+7?不,正确是2+3+3?哦计算:weight[0]=2(3)+weight[1]=3(4)+weight[2]=4(5) → 总重9>8;最优是weight[0]=2(3)+weight[3]=5(6) → 总重7,价值9?不对,代码运行结果是10:weight[1]=3(4)+weight[3]=5(6) → 总重8,价值10!)

print("0-1背包最大价值(一维版):", knapsack_01_optimized(weight, value, capacity)) # 输出:10

复杂度分析

基础版:时间 O (ncapacity),空间 O (ncapacity);

优化版:时间 O (n*capacity),空间 O (capacity)(核心:逆序遍历容量,避免覆盖未使用的子问题解)。

例题 4:最长递增子序列(LIS,高频面试题)

问题描述

给定一个整数数组nums,求其中最长严格递增子序列的长度(子序列不要求连续)。

动态规划思路

状态定义:dp[i] 表示以nums[i]结尾的最长递增子序列的长度;

转移方程:遍历前 i 个元素,若nums[j] < nums[i](j < i),则dp[i] = max(dp[i], dp[j] + 1)(以 nums [j] 结尾的子序列加上 nums [i],形成更长的递增子序列);

初始条件:dp[i] = 1(每个元素自身是长度为 1 的子序列);

边界处理:数组为空时返回 0。

代码实现(基础版 + 优化思路)

# 基础版:时间O(n²),空间O(n)

def length_of_lis_base(nums):

if not nums:

return 0

n = len(nums)

dp = [1]*n # 初始化每个元素的最长子序列长度为1

max_len = 1 # 记录全局最大值

for i in range(1, n):

# 遍历前i个元素,寻找比nums[i]小的元素

for j in range(i):

if nums[j] < nums[i]:

dp[i] = max(dp[i], dp[j] + 1)

# 更新全局最大值

if dp[i] > max_len:

max_len = dp[i]

return max_len

# 优化版:时间O(nlogn)(用二分查找优化内层循环,非DP思路,拓展学习)

def length_of_lis_optimized(nums):

tails = [] # tails[i]表示长度为i+1的递增子序列的最小尾部元素

for num in nums:

# 二分查找插入位置

left, right = 0, len(tails)

while left < right:

mid = (left + right) // 2

if tails[mid] < num:

left = mid + 1

else:

right = mid

# 插入当前元素

if left == len(tails):

tails.append(num)

else:

tails[left] = num

return len(tails)

# 测试用例

nums = [10,9,2,5,3,7,101,18]

print("最长递增子序列长度(基础版):", length_of_lis_base(nums)) # 输出:4(2→3→7→101 或 2→5→7→101 等)

print("最长递增子序列长度(优化版):", length_of_lis_optimized(nums)) # 输出:4

关键说明

基础版是标准 DP 解法,适合理解 LIS 的核心逻辑;

优化版用二分查找将时间复杂度从 O (n²) 降到 O (nlogn),是面试中的最优解法(虽非 DP,但属于 LIS 的必学拓展)。

四、动态规划的常见误区与避坑指南

误区 1:状态定义模糊(最致命!)

比如 0-1 背包问题中,若错误定义dp[i][j]为「第 i 件物品放入容量 j 的背包的价值」,会导致转移方程无法推导。

避坑:状态定义要明确「范围 + 约束 + 目标」,比如「前 i 件物品 + 容量 j + 最大价值」。

误区 2:转移方程遗漏边界条件

比如爬楼梯问题中,忘记dp[0] = 1(0 阶的情况),会导致 n=2 时计算错误(dp [2] = dp [1] + dp [0] = 1+1=2,正确)。

避坑:先列出前 3~5 个状态的具体值,验证转移方程是否正确。

误区 3:空间未优化,导致内存超限

比如 0-1 背包问题中,当 capacity=1e4 时,二维 DP 表会占用 1e8 + 的空间,导致内存溢出。

避坑:观察转移方程依赖的前序状态,若只依赖前一行 / 前 k 个状态,可改用滚动数组或变量优化。

误区 4:混淆「子序列」和「子数组」

比如最长递增子序列(子序列不连续)和最长连续递增子数组(子数组连续)的 DP 状态定义不同,前者dp[i]依赖所有 j

避坑:明确问题要求,子序列可跳跃,子数组必须连续。

五、总结与拓展

核心总结

动态规划的本质:重叠子问题 + 最优子结构,核心是「存储子问题解,避免重复计算」;

解题四步法:① 定义状态(明确范围 + 约束 + 目标);② 推导转移方程(子问题如何递推);③ 初始化 DP 表(初始条件);④ 处理边界 + 迭代计算;

优化技巧:空间优化(滚动数组 / 变量)、时间优化(二分查找等,视问题而定)。

拓展学习

进阶例题:最长公共子序列(LCS)、编辑距离、打家劫舍系列、股票买卖系列;

专项训练:线性 DP(爬楼梯、LIS)、二维 DP(0-1 背包、LCS)、区间 DP(石子合并)、状态压缩 DP(旅行商问题);

刷题建议:LeetCode 53(最大子数组和)、LeetCode 62(不同路径)、LeetCode 198(打家劫舍)、LeetCode 300(最长递增子序列)。

如果本文对你有帮助,欢迎点赞、收藏、转发~ 若有疑问或想讨论更多动态规划的问题(比如某道题的 DP 思路),欢迎在评论区留言!

Copyright © 2088 神之射手基地-网游活动专题 All Rights Reserved.
友情链接