动态规划核心模型精讲(上篇):斐波那契模型、路径问题与多状态DP
目录
1.前言
插播一条消息~
2.正文
2.1斐波那契数列模型
2.1.1概念讲解
2.1.2第n个泰波那契数列
2.1.3三步问题
2.1.4使用最小花费爬楼梯
2.1.5解码方法
2.2路径问题
2.2.1概念讲解
2.2.2不同路径
2.2.3不同路径II
2.2.4礼物最大的价值
2.2.5下降路径的最小和
2.2.6最小路径和
2.3简单多状态DP
2.3.1按摩师
2.3.2打家劫舍II
2.3.3删除并获得点数
2.3.4粉刷房子
2.3.5买卖股票的最佳时期含冷冻期
3.小结
1.前言
解决复杂问题的核心往往在于拆解艺术——就像规划跨城旅行时,我们不会一次性考虑所有路口的转向,而是先确定途经的关键节点;又如理财储蓄中,通过每月收益的复利累积实现长期目标,这些场景都暗含着动态规划的核心智慧:将大问题分解为可解决的子问题,用子问题的解逐步构建最终答案。
动态规划的魅力在于其对"无后效性"的精妙运用:当前决策只依赖已有的状态信息,未来的选择不会影响过去的最优解。这种特性使其成为处理多阶段决策问题的利器,但初学者常因复杂模型望而却步。本文将聚焦最基础的动态规划模型,通过斐波那契数列的递归优化、网格路径的状态转移等实例,带你掌握状态定义、转移方程构建等核心方法,为后续学习子序列、背包等复杂问题夯实基础。
学习路径提示:从斐波那契数列的"自顶向下递归"到"自底向上迭代",理解重叠子问题的优化思路;通过路径问题掌握"状态转移方程"的推导逻辑;最终在简单多状态DP模型中实践"状态定义四要素"(问题边界、状态变量、决策选择、转移关系)。
后续章节将按"基础模型→经典问题→实战技巧"的递进结构展开:首先解析斐波那契数列模型中的状态压缩技巧,再通过不同约束条件的路径问题强化多阶段决策思维,最后系统梳理简单多状态DP的通用解题框架,帮助你建立动态规划的完整认知体系。
插播一条消息~
🔍十年经验淬炼 · 系统化AI学习平台推荐
系统化学习AI平台https://www.captainbed.cn/scy/
- 📚 完整知识体系:从数学基础 → 工业级项目(人脸识别/自动驾驶/GANs),内容由浅入深
- 💻 实战为王:每小节配套可运行代码案例(提供完整源码)
- 🎯 零基础友好:用生活案例讲解算法,无需担心数学/编程基础
🚀 特别适合
- 想系统补强AI知识的开发者
- 转型人工智能领域的从业者
- 需要项目经验的学生
2.正文
2.1斐波那契数列模型
2.1.1概念讲解
动态规划的核心思想可通过“存钱罐”类比直观理解:若每日存钱数等于前两日之和(今天的钱 = 昨天的钱 + 前天的钱),这种状态对前序状态的依赖关系,正是斐波那契模型的本质。从数学角度看,其递推关系可表示为 F(n) = F(n-1) + F(n-2)(n ≥ 2,F(0)=0,F(1)=1)。
传统递归解法直接按公式计算,会产生大量重复计算(如计算 F(5) 需重复计算 F(3)、F(2) 等),时间复杂度为 O(2ⁿ)。而动态规划通过存储中间结果(如用数组记录已计算的 F(0) 至 F(n-1)),将时间复杂度优化至 O(n),空间复杂度可进一步优化至 O(1)。这种“用空间换时间”的策略,实现了从数学递推到高效算法的关键跨越。
核心洞察:动态规划通过状态定义(明确子问题)和转移方程(描述状态依赖),将复杂问题分解为重叠子问题,利用存储避免重复计算,是处理多阶段决策优化问题的通用框架。
2.1.2第n个泰波那契数列
泰波那契数列可视为“斐波那契的升级版”,其定义为:第n个泰波那契数Tn等于前三个数之和(T0=0, T1=1, T2=1, Tn=Tn-1+Tn-2+Tn-3)。以下通过动态规划方法拆解并优化求解过程。
动态规划四步骤解析
- 状态定义:设dp[i]为第i个泰波那契数;
- 转移方程:dp[i] = dp[i-1] + dp[i-2] + dp[i-3](i≥3);
- 初始化:dp[0]=0, dp[1]=1, dp[2]=1;
- 返回值:dp[n]。
基础实现与空间优化
LeetCode 1137题的基础实现如下,通过数组存储所有状态:
public int tribonacci(int n) { int[] ans = new int[40]; // 创建dp表时大小为40,因题目约束n≤37,确保空间充足ans[0] = 0; // 初始化T0ans[1] = 1; // 初始化T1ans[2] = 1; // 初始化T2if(n > 2){ for(int i = 2; i < n;i++){ // 从i=2开始迭代,计算T3至Tnans[i + 1] = ans[i] + ans[i - 1] + ans[i - 2]; } } return ans[n];
}
该实现时间复杂度O(n),空间复杂度O(n)。由于每个状态仅依赖前三个状态,可通过滚动数组优化,用三个变量替代数组:
优化核心:用a、b、c分别代表Tn-3、Tn-2、Tn-1,每次迭代计算Tn = a+b+c,再更新a=b、b=c、c=Tn,空间复杂度降至O(1)。
时空复杂度对比
实现方式 | 时间复杂度 | 空间复杂度 |
---|---|---|
数组存储 | O(n) | O(n) |
滚动数组 | O(n) | O(1) |
2.1.3三步问题
三步问题可类比“上楼梯的走法计数”:每次可跨 1、2 或 3 阶台阶,求到达第 n 阶的走法总数。其动态规划解法需聚焦状态转移与边界条件:
状态定义:设 dp[i] 为到达第 i 阶的走法数。
转移方程:dp[i] = dp[i-1] + dp[i-2] + dp[i-3],因最后一步可从 i-1 阶跨 1 步、i-2 阶跨 2 步或 i-3 阶跨 3 步到达。
边界条件是关键:n=1 返回 1,n=2 返回 2,n=3 返回 4。生活化案例“3 阶台阶的 4 种走法”可直观理解:
- 1+1+1(每次跨 1 阶)
- 1+2(先 1 阶再 2 阶)
- 2+1(先 2 阶再 1 阶)
- 3(直接跨 3 阶)
public int waysToStep(int n) {// 1. 创建 dp 表int MOD = (int)1e9 + 7;// 处理一下边界情况if(n == 1 || n == 2)return n;if(n == 3)return 4;// 2. 初始化int[] dp = new int[n + 1];dp[1] = 1;dp[2] = 2;dp[3] = 4;// 3. 填表for(int i = 4; i <= n; i++)dp[i] = ((dp[i - 1] + dp[i - 2]) % MOD + dp[i - 3]) % MOD;// 4. 返回值return dp[n];}
代码实现中,初始化 dp[1]=1、dp[2]=2、dp[3]=4,从 i=4 开始迭代计算,结果需对 MOD=1e9+7 取模防溢出。时间复杂度 O(n),空间复杂度 O(n),可通过滚动数组优化空间至 O(1)(同泰波那契数列,仅保留前三个状态)。
2.1.4使用最小花费爬楼梯
以“花钱爬楼梯的最优策略”类比,问题可描述为:给定楼梯每层花费数组,每次可跳1或2层,求到达顶部的最小总花费。
动态规划四要素:
- 状态定义:dp[i]表示到达第i层的最小花费。
- 转移方程:dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]),因到达第i层需从i-1层(花费cost[i-1])或i-2层(花费cost[i-2])跳上,取两者最小值。
- 初始化:dp[0] = 0,dp[1] = 0,因可从第0或1层直接开始,无需额外花费。
- 返回值:dp[n],其中n为cost数组长度,代表到达顶部(第n层)的最小花费。
public int minCostClimbingStairs(int[] cost) {// 1. 创建 dp 表:dp[i] 表示到达第 i 层台阶时的最小花费int n = cost.length; // 台阶总数int[] dp = new int[n + 1]; // dp 数组大小为 n+1,因为要包含第 n 层(索引 n)// 2. 初始化:dp[0] 和 dp[1] 分别表示跳到第 0 层和第 1 层的花费(可以从任意一层出发)// 注意,这里不需要单独初始化,因为后面会在循环中计算得到// 3. 填表:从第 2 层开始,计算每层的最小花费for (int i = 2; i <= n; i++) {// 花费 = min(从第 i-1 层跳过来的花费 + cost[i-1], 从第 i-2 层跳过来的花费 + cost[i-2])dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);}// 4. 返回值:dp[n] 表示跳到第 n 层(即终点)的最小花费return dp[n];}
代码关键解析:dp数组大小为n+1,因需包含第n层(顶部)索引;时间复杂度O(n)(遍历一次),空间复杂度O(n),可优化为O(1)(用a、b变量存储dp[i-2]、dp[i-1],滚动更新)。
问题类型 | 转移方程核心逻辑 | 目标 |
---|---|---|
三步问题 | dp[i] = dp[i-1]+dp[i-2]+dp[i-3] | 计算路径总数 |
最小花费爬楼梯 | dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2]) | 计算最小花费 |
2.1.5解码方法
解码方法问题可类比密码本翻译规则:将数字字符串按1-26对应A-Z解码,求有效解码方式总数。该问题核心在于多条件状态转移,需通过动态规划处理字符组合的不确定性。
状态定义:设dp[i]
为前i
个字符的解码方法数(i
从0开始,对应空字符串到完整字符串)。
转移方程:需同时考虑两种解码可能:
- 若当前字符
s[i-1]≠'0'
(单独解码),则dp[i] += dp[i-1]
; - 若前两位字符
s[i-2..i-1]
组成10-26的数字(组合解码),则dp[i] += dp[i-2]
。
'0'的处理:若当前字符为'0',单独解码路径失效;若前两位为"00"或"30"等超26的组合,组合解码路径亦失效,此时dp[i] = 0
。
复杂度分析:时间复杂度O(n)
(遍历字符串一次),空间复杂度O(n)
(dp
数组),可优化为O(1)
(仅保留前两个状态)。
案例计算过程:
字符串 | dp[0] | dp[1] | dp[2] | dp[3] | 结果 |
---|---|---|---|---|---|
"0" | 1 | 0 | - | - | 0 |
"12" | 1 | 1 | 1+1=2 | - | 2 |
"226" | 1 | 1 | 1+1=2 | 2+1=3 | 3 |
核心代码实现(Java):
public int num;public int numDecodings(String s) {int n = s.length();char[] ss = s.toCharArray();int[] dp = new int[n];//初始化第一位if(ss[0] != '0'){dp[0] = 1;}if(n == 1){return dp[0];}//初始化第二位if(ss[1] != '0' && ss[0] != '0'){dp[1]++;}num = 10 * (ss[0] - '0') + (ss[1] - '0');if(num >= 10 && num <= 26){dp[1]++;}for(int i = 2; i < n; i++){if(ss[i] != '0'){dp[i] = dp[i - 1];}num = 10 * (ss[i - 1] - '0') + (ss[i] - '0');if(num >= 10 && num <= 26){dp[i] += dp[i - 2];}}return dp[n - 1];}
2.2路径问题
2.2.1概念讲解
路径问题可类比“城市地图导航”:从 A 地到 B 地仅能向右或向下移动,需计算路线数量或最短路径。动态规划通过二维 dp 表求解,其中 dp[i][j] 定义为到达网格 (i,j) 位置的状态值(如路线数、路径长度)。状态转移方向由移动规则决定,通常来自上方 (i-1,j) 或左方 (i,j-1),特殊场景可包含对角线方向。边界初始化需注意:第一行只能从左向右到达,第一列只能从上到下到达,故这些位置的 dp 值需单独设定初始状态。
路径问题的核心要素包括:网格结构(二维平面的单元格分布)、移动方向(允许的位移向量集合)、目标函数(路线计数或最值优化)、障碍物处理(需标记不可达位置并阻断状态转移)。
2.2.2不同路径
一个机器人从 m 行 n 列网格左上角出发,每次只能向下或向右移动,求到达右下角的不同路径总数。
基础路径计数模型:
- 状态定义:
dp[i][j]
表示到达网格第 i 行第 j 列的路径总数。 - 转移方程:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
,因机器人只能从上方(i-1,j)或左方(i,j-1)到达当前位置。 - 边界初始化:第一行
dp[i][0] = 1
(仅能从左至右移动),第一列dp[0][j] = 1
(仅能从上至下移动)。 - 返回值:
dp[m-1][n-1]
,即右下角单元格的路径数。
public int uniquePaths(int m, int n) { int[][] dp = new int[m][n]; // 第一行所有单元格只能通过从左向右移动到达,路径数恒为 1for (int i = 0; i < m; ++i) dp[i][0] = 1; // 第一列所有单元格只能通过从上向下移动到达,路径数恒为 1for (int j = 0; j < n; ++j) dp[0][j] = 1; // 填充 dp 表,每个单元格值为上方和左方单元格之和for (int i = 1; i < m; ++i) { for (int j = 1; j < n; ++j) { dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; } } return dp[m - 1][n - 1];
}
复杂度分析:时间复杂度 O(mn)(需遍历 m×n 个单元格),空间复杂度 O(mn)。可优化为 O(min(m,n)),通过一维数组滚动存储(当前行仅依赖上一行数据,用 dp[j] = dp[j] + dp[j-1]
更新)。
路径计算示例(m=3, n=7):
初始化 3 行 7 列网格,第一行 [1,1,1,1,1,1,1]
,第一列 [1,1,1]
。从 (1,1) 开始计算:
- (1,1)=1+1=2,(1,2)=1+2=3,(1,3)=1+3=4,(1,4)=1+4=5,(1,5)=1+5=6,(1,6)=1+6=7;
- (2,1)=1+1=2,(2,2)=2+1=3,(2,3)=3+2=5,(2,4)=4+3=7,(2,5)=5+4=9,(2,6)=6+5=11;
最终右下角 (2,6) 的路径数为 28。
核心逻辑:路径计数问题本质是组合数学中的「步骤选择」问题,动态规划通过子问题分解将复杂计算转化为网格递推,空间优化利用了状态转移的「单向依赖性」。
2.2.3不同路径II
在“不同路径”问题基础上增加障碍物,需计算从网格起点到终点的不同路径数(障碍物位置不可通行)。核心处理逻辑如下:
障碍物处理机制
- 状态定义:
dp[i][j]
表示到达(i,j)
的路径数,若(i,j)
有障碍物则dp[i][j] = 0
。 - 转移方程:与“不同路径”一致,但需先判断当前网格是否有障碍物:
若obstacleGrid[i-1][j-1] == 1
(因dp
表扩展),则dp[i][j] = 0
;否则dp[i][j] = dp[i-1][j] + dp[i][j-1]
。 - 边界初始化:通过将
dp
表扩展为(m+1)×(n+1)
并设置dp[0][1] = 1
,简化第一行/列有障碍物时的初始化逻辑(无需单独处理边界障碍物)。 - 特殊情况:若起点
(0,0)
或终点(m-1,n-1)
有障碍物,直接返回 0。
代码实现与优化
以下是 LeetCode 63 题的参考实现:
public int uniquePathsWithObstacles(int[][] obstacleGrid) { int m = obstacleGrid.length; int n = obstacleGrid[0].length; // 起点或终点有障碍物时直接返回 0if (obstacleGrid[0][0] == 1 || obstacleGrid[m-1][n-1] == 1) return 0;int[][] dp = new int[m + 1][n + 1]; dp[0][1] = 1; // 虚拟初始化,简化边界处理for (int i = 1; i <= m; i++){ for (int j = 1; j <= n; j++){ if (obstacleGrid[i-1][j-1] == 1){ dp[i][j] = 0; }else { dp[i][j] = dp[i-1][j] + dp[i][j-1]; } } } return dp[m][n];
}
dp 表扩展原因:通过增加一行一列(索引 0 行和 0 列),将第一行/列的边界条件转化为通用转移方程,避免单独处理“首行只能从左到右”“首列只能从上到下”的复杂逻辑。
复杂度分析
- 时间复杂度:O(mn),需遍历
m×n
网格。 - 空间复杂度:O(mn),可优化为 O(n)(仅保留当前行数据)。
障碍物影响案例
以 2×2 网格为例,对比有无障碍物的路径数变化:
障碍物位置 | 路径数(无障碍物) | 路径数(有障碍物) |
---|---|---|
无 | 2 | 2 |
(0,1) | 2 | 1 |
(1,1)(终点) | 2 | 0 |
关键结论:障碍物会阻断后续路径传递,首行/列障碍物会导致其右侧/下方所有格子路径数为 0;起点/终点障碍物直接导致无解。
2.2.4礼物最大的价值
"礼物最大的价值"问题是路径规划类动态规划的典型变种,其核心目标从"计算路径数量"转变为"求解最大收益"。以网格捡礼物场景为例,每个单元格对应特定价值的礼物,只能向右或向下移动,需找到从左上角到右下角的最大价值路径。
动态规划解决方案
public int jewelleryValue(int[][] frame) {int m = frame.length;int n = frame[0].length;int[][] dp = new int[m + 1][n + 1];for (int i = 1; i <= m; i++){for (int j = 1; j <= n; j++){dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]) + frame[i-1][j-1];}}return dp[m][n];}
状态定义:设 dp[i][j]
表示到达网格 (i-1, j-1)
位置时的最大礼物价值(dp
表扩展一行一列以简化边界处理)。
转移方程:到达 (i,j)
的最大价值等于从上方或左方过来的最大价值加上当前礼物价值,即:
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + frame[i-1][j-1]
边界初始化:dp[0][j]
与 dp[i][0]
均设为 0,模拟网格外无礼物的边界条件。
返回值:最终结果为 dp[m][n]
,其中 m
和 n
分别是原网格的行数和列数。
关键实现细节
- 索引对应关系:
frame[i-1][j-1]
是由于dp
表比原网格多一行一列,dp
表的(i,j)
对应原网格的(i-1,j-1)
。 - 复杂度分析:时间复杂度为
O(mn)
(需遍历所有单元格),空间复杂度为O(mn)
(可优化至O(n)
,通过一维数组存储上一行状态)。
3×3 网格示例演示
以价值网格 [[1,3,1],[1,5,1],[4,2,1]]
为例,dp
表填充过程如下:
dp[1][1] = max(0,0) + 1 = 1
dp[1][2] = max(0,1) + 3 = 4
dp[2][1] = max(1,0) + 1 = 2
dp[2][2] = max(4,2) + 5 = 9
(关键决策点,选择左方路径)- 最终
dp[3][3] = 12
,对应最优路径1→3→5→2→1
。
该问题展示了动态规划在多阶段决策中的应用,通过状态转移累积最优解,为复杂场景下的资源分配问题提供了基础模型。
2.2.5下降路径的最小和
以“滑雪下山的最短路径”为类比,该问题要求从矩阵顶部任意位置出发,每次可向正下方、左下方或右下方移动,求解到达底部的最小路径和。动态规划是解决此类问题的高效方法,其核心思路如下:
状态定义:设dp[i][j]
表示到达第i
行第j
列的最小下降路径和。由于矩阵采用 0-based 索引,代码中dp
表使用 1-based 索引以便简化边界处理,因此matrix[i-1][j-1]
对应原矩阵的元素。
转移方程:每个位置(i,j)
可由上一行的(i-1,j-1)
(左上)、(i-1,j)
(正上)或(i-1,j+1)
(右上)到达,故dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i-1][j+1]) + matrix[i-1][j-1]
。
边界处理关键:为避免判断左右边界越界,dp
表设计为(n+1)×(n+2)
的大小(n
为矩阵边长)。其中第 0 列和第n+1
列初始化为Integer.MAX_VALUE
,确保边界位置(如j=1
时j-1=0
)的无效路径不影响最小值计算。
结果计算:遍历dp
表最后一行(第n
行)的所有有效列,取最小值即为答案。
复杂度分析:时间复杂度O(n²)
(两层嵌套循环);空间复杂度O(n²)
(二维dp
表),可优化为O(n)
(仅存储上一行状态的一维数组)。
public int minFallingPathSum(int[][] matrix) { int n = matrix.length; int[][] dp = new int[n + 1][n + 2]; // (n+1)行(n+2)列处理边界// 初始化左右边界为最大值for(int i = 1; i <= n; i++){ dp[i][0] = dp[i][n + 1] = Integer.MAX_VALUE; } // 填充dp表for(int i = 1; i <= n; i++){ for(int j = 1; j <= n; j++){ dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i - 1][j + 1])) + matrix[i - 1][j - 1]; } } // 寻找最后一行最小值int ans = Integer.MAX_VALUE; for (int i = 1; i <= n; i++){ ans = Math.min(ans, dp[n][i]); } return ans;
}
2.2.6最小路径和
最小路径和问题是动态规划中路径规划类问题的典型代表,以带权值的网格为场景,目标是寻找从左上角到右下角的路径中权值之和最小的路径。以下从动态规划四要素展开分析:
状态定义:设 dp[i][j]
表示到达网格中第 i
行第 j
列(均从 1 开始计数)的最小路径和。
转移方程:到达 (i,j)
的路径只能来自上方 (i-1,j)
或左方 (i,j-1)
,因此取两者中的较小路径和加上当前格子权值:dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i-1][j-1]
(grid
为原始网格数组,因索引偏移需 i-1
和 j-1
)。
边界初始化关键处理:将 dp 数组的首行(dp[i][0]
)和首列(dp[0][j]
)初始化为 Integer.MAX_VALUE
(无穷大),仅保留 dp[0][1] = 0
和 dp[1][0] = 0
。此设计可避免对第一行(只能从左到右)和第一列(只能从上到下)的单独逻辑判断,因无穷大与有效路径和比较时会自动被忽略,确保转移方程普适性。
返回值:dp[n][m]
(n
和 m
分别为网格的行数和列数)。
复杂度分析:时间复杂度为 O(mn)
(需遍历整个网格),空间复杂度为 O(mn)
(二维 dp 数组),可通过滚动数组优化为 O(m)
或 O(n)
(仅保留当前行或列的状态)。
public int minPathSum(int[][] grid) {int n = grid.length;int m = grid[0].length;int[][] dp = new int[n + 1][m + 1];//初始化for(int i = 0; i <= n; i++){dp[i][0] = Integer.MAX_VALUE;}for(int j = 0; j <= m; j++){dp[0][j] = Integer.MAX_VALUE;}dp[0][1] = dp[1][0] = 0;for(int i = 1; i <= n; i++){for(int j = 1; j <= m; j++){dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i - 1][j - 1];}}return dp[n][m];}
与“礼物最大价值”的对比:两者转移方程结构相似,核心差异在于优化目标(最小 vs 最大),如下表所示:
问题 | 转移方程核心操作 | 网格权值性质 |
---|---|---|
最小路径和 | min(上方, 左方) | 非负整数 |
礼物最大价值 | max(上方, 左方) | 正整数(价值非负) |
上述分析基于 LeetCode 64 题的动态规划解法,通过状态定义、边界处理与转移逻辑的协同设计,实现了问题的高效求解。
2.3简单多状态DP
概念讲解
多状态动态规划(DP)适用于处理存在多种选择或状态的决策问题,可类比“天气预测”场景:明天的天气(晴天、阴天、雨天)概率仅由今天的天气状态决定,每种状态间存在特定转移规律。其核心在于通过多维度DP表定义状态,如用dp[i][0]
、dp[i][1]
、dp[i][2]
分别表示第i
天晴天、阴天、雨天的概率,或用dpYes[i]
/dpNo[i]
表示第i
步“是/否”决策的状态值。状态转移的本质是:当前状态仅依赖前一阶段的特定状态,通过建立状态间的量化关系(转移方程)实现递推。
多状态DP的三大关键步骤:
- 状态划分:确保状态定义不重不漏,覆盖所有可能情况(如天气问题需包含所有天气类型);
- 转移方程:明确各状态间的依赖关系(如明天晴天概率=今天晴天→晴天、今天阴天→晴天、今天雨天→晴天的概率之和);
- 结果合并:根据问题目标整合最终状态值(如求第
n
天的总降水概率需合并阴天和雨天状态)。
2.3.1按摩师
按摩师预约问题是双状态动态规划的典型应用,核心在于通过“接受”或“拒绝”当前预约的二元选择,最大化总服务时长。该问题可通过定义两个状态变量构建动态规划模型:dpYes[i] 表示接受第 i 个预约时的最大时长,dpNo[i] 表示拒绝第 i 个预约时的最大时长。
双状态模型构建
-
状态定义:
- dpYes[i] = 接受第 i 个预约的最大时长
- dpNo[i] = 拒绝第 i 个预约的最大时长
-
转移方程:
- 接受第 i 个预约时,前一个预约必须拒绝(避免连续服务),故 dpYes[i] = dpNo[i-1] + nums[i]。
- 拒绝第 i 个预约时,前一个预约可接受或拒绝,需取两者中的最优值,故 dpNo[i] = max(dpYes[i-1], dpNo[i-1])。
关键逻辑:拒绝当前预约时取 max 的原因在于,前一个预约的“接受”或“拒绝”状态已分别存储了对应场景的最优解,取最大值可确保当前拒绝状态下的总时长最优。
- 初始化:首个预约接受时时长为 nums[0](dpYes[0] = nums[0]),拒绝时为 0(dpNo[0] = 0)。
- 结果:最终最大时长为最后一个预约的接受与拒绝状态的最大值,即 max(dpYes[n-1], dpNo[n-1])。
代码实现与优化
以下为 Java 实现代码,清晰体现双状态转移逻辑:
public int massage(int[] nums) { int n = nums.length; if(n == 0){ return 0; } int[] dpYes = new int[n]; int[] dpNo = new int[n]; dpYes[0] = nums[0]; dpNo[0] = 0; for (int i = 1; i < n; i++){ dpYes[i] = dpNo[i - 1] + nums[i]; // 接受当前需拒绝前一个dpNo[i] = Math.max(dpYes[i - 1], dpNo[i - 1]); // 拒绝当前取前一个最优} return Math.max(dpYes[n - 1], dpNo[n - 1]);
}
- 复杂度分析:时间复杂度 O(n)(遍历一次数组),空间复杂度 O(n)(两个状态数组)。可优化为 O(1),通过两个变量存储前一状态的 dpYes 和 dpNo,替代数组。
2.3.2打家劫舍II
环形街道的抢劫问题因房屋首尾相连形成闭环,导致首屋与尾屋互斥(不可同时抢劫),需通过问题拆分策略解决。核心思路为将环形问题转化为两个单链问题:抢首不抢尾(考虑房屋 0 至 n-2)与抢尾不抢首(考虑房屋 1 至 n-1),分别计算两种情况的最大收益后取最大值。
单链抢劫部分采用双状态动态规划模型(类似按摩师问题):定义 dpYes[i]
为抢劫第 i 间房屋的最大收益,dpNo[i]
为不抢劫第 i 间房屋的最大收益。状态转移方程为 dpYes[i] = dpNo[i-1] + nums[i]
、dpNo[i] = max(dpYes[i-1], dpNo[i-1])
。通过滚动变量优化空间复杂度,用 a1/b1
存储“抢首不抢尾”情况的状态(a1
对应 i-2 收益,b1
对应 i-1 收益),a2/b2
存储“抢尾不抢首”情况的状态,将空间复杂度从 O(n) 降至 O(1)。
环形处理关键:因首尾房屋相邻,同时抢劫会触发警报,故必须拆分场景。两种情况覆盖所有可能的有效抢劫方案,取其最大值即为最优解。
public int rob1(int[] nums) {int n = nums.length;if (n == 0) return 0;if (n == 1) return nums[0];// 情况 1:考虑第一个房屋,但不考虑最后一个房屋int a1 = nums[0];int b1 = Math.max(nums[0], nums[1]);for (int i = 2; i < n - 1; i++) {int temp = b1;b1 = Math.max(b1, a1 + nums[i]);a1 = temp;}int ans1 = Math.max(a1, b1);// 情况 2:考虑第二个房屋,但不考虑最后一个房屋int a2 = nums[1];int b2 = Math.max(nums[1], nums[2]);for (int i = 3; i < n; i++) {int temp = b2;b2 = Math.max(b2, a2 + nums[i]);a2 = temp;}int ans2 = Math.max(a2, b2);// 返回两种情况的最大值return Math.max(ans1, ans2);}
时间复杂度为 O(n)(两次单链遍历,各 O(n)),空间复杂度 O(1)(仅用常数变量存储状态)。
对比维度 | 单链打家劫舍 | 环形打家劫舍 |
---|---|---|
问题结构 | 线性排列,首尾独立 | 环形排列,首尾互斥 |
处理策略 | 单次动态规划遍历 | 拆分两种单链情况,取最大值 |
状态转移方程 | 双状态(抢/不抢当前房屋) | 同单链,需分别应用于两个子数组 |
空间复杂度优化 | 滚动变量 O(1) | 滚动变量 O(1)(两组状态变量) |
2.3.3删除并获得点数
"删除并获得点数"问题(LeetCode 740)可通过动态规划转化为"打家劫舍"模型求解。核心思路是:选择一个数字 x 则不能选择 x-1 和 x+1,需最大化收集的总点数。
首先进行预处理:创建 arr 数组,其中 arr[x] = x × count(x)(count(x) 为 x 在 nums 中出现次数),将原问题转化为"选择 x 则不能选相邻数字,求最大和"。例如 nums = [3,4,2] 时,arr[2] = 2、arr[3] = 3、arr[4] = 4,问题转化为在 arr 中选择不相邻数字的最大和,此时选 2 和 4 得 6 为最优解。
定义双状态 DP 数组:dpYes[i] 表示选数字 i 的最大点数,dpNo[i] 表示不选 i 的最大点数。转移方程为:dpYes[i] = dpNo[i-1] + arr[i](选 i 则 i-1 必不选);dpNo[i] = max(dpYes[i-1], dpNo[i-1])(不选 i 则 i-1 可选或不选,取较大值)。
代码实现中,arr 数组大小设为 10001,因题目约束 nums 中数字 ≤ 10000。初始化 dpYes[0] = arr[0],从 i=1 遍历至 10000 计算状态值,最终结果为 max(dpYes[10000], dpNo[10000])。
核心代码片段:
public int deleteAndEarn(int[] nums) { int n = 10001; // nums 中数字 ≤ 10000,故数组大小为 10001int[] arr = new int[n]; for(int x : nums) { arr[x] += x; } // 预处理:计算每个数字的总贡献int[] dpYes = new int[n]; int[] dpNo = new int[n]; dpYes[0] = arr[0]; for(int i = 1; i < n; i++) { dpYes[i] = dpNo[i - 1] + arr[i]; dpNo[i] = Math.max(dpYes[i - 1], dpNo[i - 1]); } return Math.max(dpYes[n - 1], dpNo[n - 1]);
}
复杂度分析:时间复杂度 O(n + max(nums)),含预处理遍历 nums(O(n))和 DP 遍历 arr(O(max(nums)));空间复杂度 O(max(nums)),因 arr、dpYes、dpNo 数组大小均为 max(nums)+1。
2.3.4粉刷房子
粉刷房子问题是多状态动态规划的典型案例,其核心在于通过维护多个状态的最优解实现全局优化。以"给房子刷漆的最低成本"问题为例,假设有n个房子,每个房子可刷红、蓝、绿三种颜色之一,且相邻房子颜色不同,求最小总成本。
三状态模型定义:
- 状态定义:
dp[i][0/1/2]
表示第i个房子刷红/蓝/绿的最小成本 - 转移方程:
dp[i][0] = min(dp[i-1][1], dp[i-1][2]) + costs[i-1][0]
(当前刷红需取前一个房子蓝/绿的最小值) - 初始化:
dp[0][0/1/2] = 0
(第0个房子为虚拟起点,未实际粉刷) - 结果:
min(dp[n][0], dp[n][1], dp[n][2])
(取最后一个房子三种颜色的最小成本)
实现时需注意costs[i-1][0]
的索引逻辑:由于dp表从1开始计数(对应第1个房子),而costs数组从0开始,故第i个房子的成本需访问costs[i-1]
。以下为Java实现代码:
public int minCost(int[][] costs) { int n = costs.length; int[][] dp = new int[n + 1][3]; for(int i = 1; i <= n; i++){ dp[i][0] = Math.min(dp[i-1][1], dp[i-1][2]) + costs[i-1][0]; dp[i][1] = Math.min(dp[i-1][0], dp[i-1][2]) + costs[i-1][1]; dp[i][2] = Math.min(dp[i-1][0], dp[i-1][1]) + costs[i-1][2]; } return Math.min(dp[n][0], Math.min(dp[n][1], dp[n][2]));
}
该算法时间复杂度O(n)(遍历n个房子),空间复杂度O(n)(dp表存储n+1个状态)。由于每个状态仅依赖前一个状态,可优化为O(1)空间:用三个变量存储dp[i-1][0/1/2]
,迭代更新即可。
2.3.5买卖股票的最佳时期含冷冻期
「买卖股票的最佳时机含冷冻期」(LeetCode 309)是典型的多状态动态规划问题,其核心在于通过状态机模型处理「卖出后次日无法交易」的约束条件。该问题需定义三种互斥状态,通过状态转移方程刻画交易过程,最终求解最大利润。
状态定义与转移方程
采用三状态动态规划模型,定义 dp[i][j]
表示第 i
天结束时处于状态 j
的最大利润,其中:
- 状态 0(持有股票):
dp[i][0]
,可由两种情况转移而来:前一天已持有(继续持有),或前一天处于可交易状态(当日买入),转移方程为dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i])
。 - 状态 1(可交易):
dp[i][1]
,表示未持有股票且非冷冻期,可由前一天可交易状态(继续不操作)或前一天冷冻期结束(冷冻期转为可交易)转移,方程为dp[i][1] = max(dp[i-1][1], dp[i-1][2])
。 - 状态 2(冷冻期):
dp[i][2]
,表示当日刚卖出股票,仅能由前一天持有状态(当日卖出)转移,方程为dp[i][2] = dp[i-1][0] + prices[i]
。
初始化与结果计算
- 初始化:首日(
i=0
)仅能买入或不操作。dp[0][0] = -prices[0]
(首日买入),dp[0][1] = 0
(首日未买),dp[0][2] = 0
(首日无法卖出)。 - 结果:最后一天持有股票无意义(无法卖出获利),故取
max(dp[n-1][1], dp[n-1][2])
。
代码实现与优化
以下为 Java 实现代码,包含状态转移逻辑与边界处理:
public int maxProfit(int[] prices) { int n = prices.length; if (n <= 1) return 0; // 不足两天无法交易int[][] dp = new int[n][3]; // 初始化首日状态dp[0][0] = -prices[0]; // 持有(买入)// dp[0][1] 与 dp[0][2] 默认为 0(未操作/无法卖出)for (int i = 1; i < n; i++) { dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] - prices[i]); // 持有状态转移dp[i][1] = Math.max(dp[i-1][1], dp[i-1][2]); // 可交易状态转移dp[i][2] = dp[i-1][0] + prices[i]; // 冷冻期状态转移} return Math.max(dp[n-1][1], dp[n-1][2]); // 最后一天持有无意义
}
关键优化与复杂度分析
- 空间优化:由于每个状态仅依赖前一天结果,可将二维数组优化为三个变量(
prev0
、prev1
、prev2
),空间复杂度从 O(n) 降至 O(1)。 - 时间复杂度:O(n),仅需遍历一次价格数组。
状态转换关系
三个状态的转换路径如下:
- 持有(0)→ 冷冻期(2):当日卖出股票,进入冷冻期。
- 可交易(1)→ 持有(0):当日买入股票,转为持有状态。
- 冷冻期(2)→ 可交易(1):冷冻期结束,次日恢复可交易状态。
- 持有(0)→ 持有(0):继续持有股票,不操作。
- 可交易(1)→ 可交易(1):继续不操作,保持可交易状态。
通过状态机模型,可清晰描述股票交易过程中的约束条件与利润累积逻辑,为类似带约束的动态规划问题提供通用分析框架。
3.小结
本章围绕动态规划入门阶段的核心模型展开系统梳理,可通过“动态规划入门三板斧”构建知识框架:斐波那契模型、路径模型与多状态模型,三者分别对应不同复杂度的状态设计与转移逻辑,共同构成动态规划问题的基础认知体系。
三类基础模型核心差异对比
模型类型 | 状态维度 | 转移方向特征 | 目标函数典型形式 | 典型问题示例 |
---|---|---|---|---|
斐波那契模型 | 一维单状态 | 依赖前 n 项(线性递推) | 第 n 项状态值(如方案数、数值) | 泰波那契数列、爬楼梯 |
路径模型 | 二维状态 | 方向受限(如右/下移动),需处理边界/障碍物 | 到达终点的路径数/最小代价 | 不同路径、最小路径和 |
多状态模型 | 多 DP 表共存 | 状态间存在明确转换规则(如持有/未持有) | 最优状态组合下的目标值 | 按摩师问题、买卖股票系列 |
动态规划通用解题四步骤(贯穿三类模型的核心方法论):
- 定义状态:明确 DP 表/变量的具体含义(如
dp[i]
代表第 i 步的状态值); - 确定转移方程:建立当前状态与前置状态的数学关系(如
dp[i] = dp[i-1] + dp[i-2]
); - 初始化:设置边界条件(如斐波那契数列的
dp[0] = 0, dp[1] = 1
); - 填表计算:按顺序遍历状态空间,通过递推求解目标值。
上述框架不仅适用于本章模型,更可迁移至复杂问题的分析。通过对状态维度、转移逻辑的拆解,能有效降低动态规划问题的抽象性。后续章节将深入探讨子序列问题(如最长递增子序列)、回文串问题(如最长回文子串)及背包问题(0-1 背包、完全背包等),进一步拓展动态规划的应用边界。建议读者通过针对性练习(如调整模型参数、增设约束条件)巩固基础,逐步建立“状态建模 - 方程推导 - 边界处理”的系统化解题思维。