C语言动规学习
文章目录
- 一、动态规划的基本概念
- 1. 最优子结构
- 2. 重叠子问题
- 二、动态规划的求解步骤
- 三、动态规划与递归的比较
- 四、例题(只讲思维,空间时间复杂度大小不与题目比较)
- 1、斐波那契数列
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 2、爬楼梯
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 3、使用最小花费爬楼梯
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 4、跳跃游戏
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 5、不同路径
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 6、不同路径2
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 5. 定义状态
- 6. 找出状态转移方程
- 7. 初始化边界条件
- 8. 确定计算顺序
- 7、整数拆分
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 8、最长回文子串
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 9、不同的二叉搜索树
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 五、进阶背包理论
- 1、01背包(dp二维数组)
- 1. 问题描述
- 2. 定义状态
- 3. 状态转移方程
- 4. 初始化边界条件
- 5. 计算顺序
- 6. 最终结果
- 7.代码解释
- 8.复杂度分析
- 2、01背包(dp滚动数组)
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 5.代码解释
- 6.复杂度分析
- 3.滚动数组!!!
- 1.基本概念
- 2.使用场景
- 3.原理
- 4.优缺点
- 优点
- 缺点
- 4.分割等和子集
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 5.最后一块石头的重量 II
- 1. 定义状态
- 2. 找出状态转移方程
- 3. 初始化边界条件
- 4. 确定计算顺序
- 六、总结
动归五部曲!!!:
一、动态规划的基本概念
动态规划(Dynamic Programming,简称 DP)是一种通过把原问题分解为相对简单的子问题,并保存子问题的解来避免重复计算,从而解决复杂问题的算法策略。它通常用于求解具有最优子结构和重叠子问题性质的问题。
1. 最优子结构
最优子结构性质指的是一个问题的最优解包含其子问题的最优解。也就是说,大问题的最优解可以由小问题的最优解组合而成。例如,在求解最短路径问题时,如果从 A 到 C 的最短路径经过 B,那么从 A 到 B 的路径和从 B 到 C 的路径也分别是 A 到 B 和 B 到 C 的最短路径。
2. 重叠子问题
重叠子问题是指在求解过程中,很多子问题会被重复计算。动态规划通过保存子问题的解,避免了这些重复计算,从而提高了算法的效率。比如在计算斐波那契数列时,递归方法会多次重复计算相同的斐波那契数,而动态规划可以通过保存已经计算过的数,避免这些重复计算。
二、动态规划的求解步骤
一般来说,使用动态规划解决问题可以分为以下几个步骤:
- 定义状态:确定问题的状态表示,也就是用什么变量来描述问题的子问题。状态通常是原问题的一个子集,通过状态的定义可以将原问题分解为多个子问题。
- 找出状态转移方程:根据问题的最优子结构性质,找出状态之间的转移关系。状态转移方程描述了如何从已知的子问题的解推导出当前问题的解。
- 初始化边界条件:确定问题的边界情况,也就是最小子问题的解。边界条件是状态转移的起点,没有边界条件,状态转移方程就无法开始递推。
- 计算顺序:根据状态转移方程,确定计算的顺序。通常是从小的子问题开始,逐步计算出大的子问题,直到得到原问题的解。
三、动态规划与递归的比较
递归方法也可以解决斐波那契数列问题,但递归方法会存在大量的重复计算,时间复杂度为 (O(2^n))。而动态规划通过保存子问题的解,避免了重复计算,时间复杂度为 (O(n))。因此,对于具有重叠子问题的问题,动态规划通常比递归方法更高效。
四、例题(只讲思维,空间时间复杂度大小不与题目比较)
1、斐波那契数列
509. 斐波那契数
int fib(int n) {if (n == 0 || n == 1) {return n;}int dp[2];dp[0] = 0;dp[1] = 1;int i;for (i = 2; i <= n; i++) {int sum = dp[0] + dp[1];dp[0] = dp[1];dp[1] = sum;}return dp[1];
}
1. 定义状态
在这个斐波那契数列的实现中,状态定义为存储斐波那契数列中相邻两项的值。代码里使用长度为 2 的数组 dp
来表示状态,具体而言:
dp[0]
代表斐波那契数列中当前正在计算的某一项的前一项的值。dp[1]
代表斐波那契数列中当前正在计算的某一项的值。
通过不断更新 dp
数组,我们可以逐步计算出斐波那契数列的每一项。
其实最基础的就是可以构建一个dp数组,大小为测试用例上线,或者可以malloc一个动态内存。然后根据状态进行计算。
但其实我们可以发现:对于每个要计算的数,我们只需要维护他的前两个数就行,因此只需要维护两个数据就行。(70.爬楼梯同理)
2. 找出状态转移方程
斐波那契数列的定义是 (F(n)=F(n - 1)+F(n - 2))((n<=2)),(F(0) = 0),(F(1)=1)
。在代码中对应的状态转移方程如下:
int sum = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = sum;
- 首先计算
sum = dp[0] + dp[1]
,这里的sum
就相当于 (F(n)),dp[0]
相当于 (F(n - 2)),dp[1]
相当于 (F(n - 1))。 - 接着更新
dp[0]
为dp[1]
,也就是让dp[0]
变为下一次计算时的 (F(n - 2))。 - 最后将
dp[1]
更新为sum
,即下一次计算时的 (F(n - 1))。
3. 初始化边界条件
根据斐波那契数列的定义,(F(0) = 0),(F(1)=1)。代码中对边界条件的初始化操作如下:
if (n == 0 || n == 1) {return n;
}
int dp[2];
dp[0] = 0;
dp[1] = 1;
- 当
n
为 0 或者 1 时,直接返回n
,这是因为 (F(0) = 0),(F(1)=1)。 - 初始化
dp[0]
为 0,dp[1]
为 1,这是进行后续状态转移计算的起始值。
4. 确定计算顺序
代码中使用 for
循环来确定计算顺序:
for (i = 2; i <= n; i++) {int sum = dp[0] + dp[1];dp[0] = dp[1];dp[1] = sum;
}
从 (n = 2) 开始,按照顺序依次计算斐波那契数列的每一项。每一次循环都会根据当前的 dp[0]
和 dp[1]
计算出下一项的值,并更新 dp
数组,直到计算出第 n
项的值,最终返回 dp[1]
作为结果。
2、爬楼梯
70. 爬楼梯
假设你正在爬楼梯。需要
n
阶你才能到达楼顶。每次你可以爬
1
或2
个台阶。你有多少种不同的方法可以爬到楼顶呢?示例 1:
输入:n = 2 输出:2 解释:有两种方法可以爬到楼顶。 1. 1 阶 + 1 阶 2. 2 阶
示例 2:
输入:n = 3 输出:3 解释:有三种方法可以爬到楼顶。 1. 1 阶 + 1 阶 + 1 阶 2. 1 阶 + 2 阶 3. 2 阶 + 1 阶
int climbStairs(int n) {if (n == 1 || n == 2 || n == 3) {return n;}int dp[2];int i;dp[0] = 2;dp[1] = 3;for (i = 4; i <= n; i++) {int sum = dp[0] + dp[1];dp[0] = dp[1];dp[1] = sum;}return dp[1];
}
1. 定义状态
在爬楼梯问题中,我们定义状态 dp[i]
为爬到第 i
阶楼梯的不同方法数。不过在代码里,由于我们发现计算第 i
阶楼梯的方法数只依赖于第 i - 1
阶和第 i - 2
阶楼梯的方法数,所以仅使用长度为 2 的数组 dp
来存储状态:
dp[0]
表示爬到第i - 1
阶楼梯的不同方法数。dp[1]
表示爬到第i
阶楼梯的不同方法数。
2. 找出状态转移方程
对于爬楼梯问题,因为每次只能爬 1 个或 2 个台阶,所以要到达第 i
阶楼梯,只能从第 i - 1
阶楼梯爬 1 个台阶上来,或者从第 i - 2
阶楼梯爬 2 个台阶上来。那么到达第 i
阶楼梯的方法数就等于到达第 i - 1
阶楼梯的方法数加上到达第 i - 2
阶楼梯的方法数,即状态转移方程为: (dp[i]=dp[i - 1]+dp[i - 2])
。
int sum = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = sum;
这里 sum
就相当于 (dp[i]),dp[0]
相当于 (dp[i - 2]),dp[1]
相当于 (dp[i - 1])。
3. 初始化边界条件
在代码中,当 n
为 1、2、3 时直接返回 n
:
if (n == 1 || n == 2 || n == 3) {return n;
}
并且初始化 dp
数组:
dp[0] = 2;
dp[1] = 3;
这里 dp[0]
初始化为 2,对应 (dp[2]) 的值;dp[1]
初始化为 3,对应 (dp[3]) 的值,为后续从 n = 4
开始的状态转移做准备。
4. 确定计算顺序
由于状态转移方程 (dp[i]=dp[i - 1]+dp[i - 2]) 表明当前状态依赖于前两个状态,所以计算顺序应该是从较小的 i
值逐步计算到较大的 i
值。在代码中,使用 for
循环从 i = 4
开始计算,直到 i = n
:
for (i = 4; i <= n; i++) {int sum = dp[0] + dp[1];dp[0] = dp[1];dp[1] = sum;
}
每次循环都会根据当前的 dp[0]
和 dp[1]
计算出下一个状态的值,并更新 dp
数组,保证在计算 dp[i]
时,dp[i - 1]
和 dp[i - 2]
已经被正确计算出来。最终,当循环结束时,dp[1]
中存储的就是爬到第 n
阶楼梯的不同方法数。
通过使用长度为 2 的数组将空间复杂度优化到了 (O(1))。
3、使用最小花费爬楼梯
746. 使用最小花费爬楼梯
给你一个整数数组
cost
,其中cost[i]
是从楼梯第i
个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。你可以选择从下标为
0
或下标为1
的台阶开始爬楼梯。请你计算并返回达到楼梯顶部的最低花费。
示例 1:
输入:cost = [10,15,20] 输出:15 解释:你将从下标为 1 的台阶开始。 - 支付 15 ,向上爬两个台阶,到达楼梯顶部。 总花费为 15 。
示例 2:
输入:cost = [1,100,1,1,1,100,1,1,100,1] 输出:6 解释:你将从下标为 0 的台阶开始。 - 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。 - 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。 - 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。 - 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。 - 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。 - 支付 1 ,向上爬一个台阶,到达楼梯顶部。 总花费为 6 。
int min(int a, int b) { return a < b ? a : b; }int minCostClimbingStairs(int* cost, int costSize) {int dp[costSize + 1];dp[0] = 0;dp[1] = 0;int i;for (i = 2; i <= costSize; i++) {dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);}return dp[costSize];
}
1. 定义状态
在这个爬楼梯花费最小成本的问题中,我们定义状态 dp[i]
表示到达第 i
级楼梯所需要的最小花费。这里的第 i
级楼梯是基于 0 索引的,并且最终要到达的楼梯是第 costSize
级(因为 cost
数组长度为 costSize
,表示从第 0 级到第 costSize - 1
级楼梯的花费)。
2. 找出状态转移方程
要到达第 i
级楼梯,有两种方式:
- 从第
i - 1
级楼梯爬 1 步到达,此时到达第i
级楼梯的总花费是到达第i - 1
级楼梯的最小花费dp[i - 1]
加上从第i - 1
级楼梯爬上来的花费cost[i - 1]
。 - 从第
i - 2
级楼梯爬 2 步到达,此时到达第i
级楼梯的总花费是到达第i - 2
级楼梯的最小花费dp[i - 2]
加上从第i - 2
级楼梯爬上来的花费cost[i - 2]
。
我们需要取这两种方式中花费较小的那个, 在代码中对应的实现是:
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
3. 初始化边界条件
对于这个问题,我们可以从第 0 级或者第 1 级楼梯开始爬,且站在第 0 级和第 1 级楼梯上还没有产生额外的向上爬的花费,所以到达第 0 级和第 1 级楼梯的最小花费为 0,即: (dp[0]=0) (dp[1]=0), 在代码中的体现为:
dp[0] = 0;
dp[1] = 0;
4. 确定计算顺序
由于状态转移方程表明,计算 dp[i]
时依赖于 dp[i - 1]
和 dp[i - 2]
的值,所以我们需要按照从低阶楼梯到高阶楼梯的顺序依次计算。
在代码中,使用 for
循环从 i = 2
开始计算,因为 dp[0]
和 dp[1]
已经初始化好了:
for (i = 2; i <= costSize; i++) {dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
}
这样能保证在计算 dp[i]
时,所依赖的 dp[i - 1]
和 dp[i - 2]
已经计算完成。最终,当循环结束时,dp[costSize]
就存储了到达第 costSize
级楼梯(也就是楼顶)的最小花费,所以函数返回 dp[costSize]
。
cost | 1 | 100 | 1 | 1 | 1 | 100 | 1 | 1 | 100 | 1 | 楼顶 |
---|---|---|---|---|---|---|---|---|---|---|---|
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
dp | 0 | 0 | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 5 | 6 |
如图,dp[2]
:min(dp[2-1]+cost[2-1],dp[2-2]+cost[2-2])
,即求从比它低两阶的楼梯上来的花费和比它低一阶的楼梯上来的花费的较小值。
4、跳跃游戏
55. 跳跃游戏
给你一个非负整数数组
nums
,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标,如果可以,返回
true
;否则,返回false
。示例 1:
输入:nums = [2,3,1,1,4] 输出:true 解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
示例 2:
输入:nums = [3,2,1,0,4] 输出:false 解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。
bool canJump(int* nums, int numsSize) {if (numsSize == 1)return true;bool dp[numsSize];dp[0] = true;for (int i = 1; i < numsSize; i++) {dp[i] = false;for (int j = 0; j < i; j++) {if (dp[j] == true && nums[j] + j >= i) {dp[i] = true;break;}}}return dp[numsSize - 1];
}
1. 定义状态
在这个跳跃游戏问题里,我们定义状态 dp[i]
为一个布尔类型的值,其含义是能否从数组的起始位置(即下标为 0 的位置)跳跃到下标为 i
的位置。若 dp[i]
为 true
,表示可以从起始位置跳跃到下标 i
;若 dp[i]
为 false
,则表示无法从起始位置跳跃到下标 i
。
2. 找出状态转移方程
要判断是否能到达下标 i
,我们需要考察其前面的所有位置 j
(0 <= j < i
)。若满足以下两个条件:
- 能够到达位置
j
,即dp[j]
为true
。 - 从位置
j
借助该位置元素nums[j]
所代表的最大跳跃长度,能够到达位置i
,也就是nums[j] + j >= i
。
只要存在这样一个位置 j
满足上述两个条件,那么就可以到达位置 i
,即 dp[i]
为 true
。所以状态转移方程可表示为:
if (dp[j] == true && nums[j] + j >= i) {dp[i] = true;break;
}
3. 初始化边界条件
由于我们最初就位于数组的第一个下标,也就是起始位置,所以肯定可以到达起始位置,因此 dp[0]
初始化为 true
。在代码里的体现是:
dp[0] = true;
同时,函数中对数组长度为 1 的情况进行了特殊处理,当 numsSize == 1
时,直接返回 true
,因为只有一个元素,自然是可以到达的。
4. 确定计算顺序
根据状态转移方程可知,dp[i]
的值依赖于 dp[j]
(0 <= j < i
),所以我们要按照从左到右的顺序,依次计算 dp[1]
到 dp[numsSize - 1]
的值。在代码中,使用外层的 for
循环来控制 i
从 1 到 numsSize - 1
进行遍历,对于每个 i
,再使用内层的 for
循环遍历其前面的所有位置 j
来确定 dp[i]
的值。
nums | 2 | 3 | 1 | 1 | 4 |
---|---|---|---|---|---|
下标 | 0 | 1 | 2 | 3 | 4 |
dp | true | true | true | true | true |
但是,时间复杂度是O(n2),空间复杂度是O(n)。最后有一个测试用例没过,显示超时了(作者也不知道有没有更好的dp方法)
这里讲的主要是思路和想法!!!
根据标题,还可以用贪心的方法(作者也顺便附上)
#define MAX(a, b) ((b) > (a) ? (b) : (a))bool canJump(int* nums, int numsSize) {int mx = 0;for (int i = 0; i < numsSize; i++) {if (i > mx) {return false;}mx = MAX(mx, i + nums[i]);}return true;
}
时间复杂度是O(n),空间复杂度是O(1)
5、不同路径
62. 不同路径
一个机器人位于一个
m x n
网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
示例 1:
输入:m = 3, n = 7 输出:28
示例 2:
输入:m = 3, n = 2 输出:3 解释: 从左上角开始,总共有 3 条路径可以到达右下角。 1. 向右 -> 向下 -> 向下 2. 向下 -> 向下 -> 向右 3. 向下 -> 向右 -> 向下
示例 3:
输入:m = 7, n = 3 输出:28
示例 4:
输入:m = 3, n = 3 输出:6
int uniquePaths(int m, int n) {int dp[m][n];for (int i = 0; i < m; i++) {dp[i][0] = 1;}for (int i = 0; i < n; i++) {dp[0][i] = 1;}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];
}
1. 定义状态
本题是在一个 m
行 n
列的网格中,从左上角(坐标 (0, 0)
)移动到右下角(坐标 (m - 1, n - 1)
),每次只能向下或向右移动一步,要求计算不同的路径数量。
我们定义状态 dp[i][j]
表示从网格的左上角 (0, 0)
位置移动到坐标为 (i, j)
位置的不同路径数量。其中 i
表示行索引(范围是 0
到 m - 1
),j
表示列索引(范围是 0
到 n - 1
)。
2. 找出状态转移方程
由于每次只能向下或向右移动一步,那么要到达坐标 (i, j)
位置,只能从它的上方位置 (i - 1, j)
向下移动一步到达,或者从它的左方位置 (i, j - 1)
向右移动一步到达。
所以到达位置 (i, j)
的不同路径数量等于到达其上方位置 (i - 1, j)
的路径数量加上到达其左方位置 (i, j - 1)
的路径数量,即状态转移方程为:
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
3. 初始化边界条件
- 第一行:对于网格的第一行(
i = 0
),从左上角(0, 0)
到第一行的任意位置(0, j)
只有一种路径,即一直向右移动。所以dp[0][j]
都初始化为1
,代码实现为:
for (int i = 0; i < n; i++) {dp[0][i] = 1;
}
- 第一列:对于网格的第一列(
j = 0
),从左上角(0, 0)
到第一列的任意位置(i, 0)
只有一种路径,即一直向下移动。所以dp[i][0]
都初始化为1
,代码实现为:
for (int i = 0; i < m; i++) {dp[i][0] = 1;
}
4. 确定计算顺序
根据状态转移方程 dp[i][j]=dp[i - 1][j]+dp[i][j - 1]
可知,计算 dp[i][j]
时依赖于 dp[i - 1][j]
和 dp[i][j - 1]
,也就是需要先计算出上方和左方位置的路径数量。
按照从左到右、从上到下的顺序依次计算每个位置的 dp
值。代码中通过两层嵌套的 for
循环来实现:
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];}
}
从第 1
行第 1
列开始,逐行逐列地计算每个位置的 dp
值,直到计算出右下角位置 (m - 1, n - 1)
的 dp
值。
时间复杂度为 O(n*m)
,空间复杂度也为 O(n*m)
。
这个也可以优化空间复杂度,利用滚动数组的方法,详见6
6、不同路径2
63. 不同路径 II
给定一个
m x n
的整数数组grid
。一个机器人初始位于 左上角(即grid[0][0]
)。机器人尝试移动到 右下角(即grid[m - 1][n - 1]
)。机器人每次只能向下或者向右移动一步。网格中的障碍物和空位置分别用
1
和0
来表示。机器人的移动路径中不能包含 任何 有障碍物的方格。返回机器人能够到达右下角的不同路径数量。
测试用例保证答案小于等于
2 * 109
。示例 1:
输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]] 输出:2 解释:3x3 网格的正中间有一个障碍物。 从左上角到右下角一共有 2 条不同的路径: 1. 向右 -> 向右 -> 向下 -> 向下 2. 向下 -> 向下 -> 向右 -> 向右
int uniquePathsWithObstacles(int** obstacleGrid, int obstacleGridSize,int* obstacleGridColSize) {int m = obstacleGridSize, n = obstacleGridColSize[0];if (obstacleGrid[0][0] == 1) {return 0;}int dp[m][n];memset(dp, 0, sizeof(dp));for (int i = 0; i < m; i++) {if (obstacleGrid[i][0] == 1) {break;}dp[i][0] = 1;}for (int i = 0; i < n; i++) {if (obstacleGrid[0][i] == 1) {break;}dp[0][i] = 1;}for (int i = 1; i < m; i++) {for (int j = 1; j < n; j++) {if (obstacleGrid[i][j] == 1) {dp[i][j] = 0;continue;}dp[i][j] = dp[i - 1][j] + dp[i][j - 1];}}return dp[m - 1][n - 1];
}
1. 定义状态
设dp[i][j]
表示机器人从网格的左上角(0, 0)
出发,到达网格中坐标为(i, j)
位置时不同的有效路径数量。
2. 找出状态转移方程
由于机器人每次只能向下或者向右移动一步,所以要到达位置(i, j)
,它要么是从上方位置(i - 1, j)
向下移动一步到达,要么是从左方位置(i, j - 1)
向右移动一步到达。 同时,需要考虑网格中是否存在障碍物。如果(i, j)
位置有障碍物(即obstacleGrid[i][j] == 1
),那么机器人无法到达该位置,dp[i][j]
的值为0
;如果没有障碍物,则到达该位置的路径数量是到达上方位置的路径数量与到达左方位置的路径数量之和。
if (obstacleGrid[i][j] == 1) {dp[i][j] = 0;continue;
}
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
3. 初始化边界条件
- 起点情况:如果起点
(0, 0)
位置就有障碍物(即obstacleGrid[0][0] == 1
),那么机器人无法出发,此时直接返回0
。代码体现为:
if (obstacleGrid[0][0] == 1) {return 0;
}
- 第一行:如果在这一行中某个位置
(0, j)
之前都没有障碍物,那么到达这些位置的路径数量都为1
;一旦遇到障碍物,后续位置的路径数量都为0
。代码实现如下:
for (int i = 0; i < n; i++) {if (obstacleGrid[0][i] == 1) {break;}dp[0][i] = 1;
}
- 第一列:同理第一行。
for (int i = 0; i < m; i++) {if (obstacleGrid[i][0] == 1) {break;}dp[i][0] = 1;
}
4. 确定计算顺序
为了确保在计算dp[i][j]
时,其依赖的状态已经计算完成,需要按照从左到右、从上到下的顺序依次计算每个位置的dp
值。
for (int i = 1; i < m; i++) {for (int j = 1; j < n; j++) {// 根据状态转移方程更新 dp[i][j] 的值if (obstacleGrid[i][j] == 1) {dp[i][j] = 0;continue;}dp[i][j] = dp[i - 1][j] + dp[i][j - 1];}
}
时间空间复杂度为O(n*m) O(n*m)
。
但我们还可以使用滚动数组的方法:
int uniquePathsWithObstacles(int** obstacleGrid, int obstacleGridSize,int* obstacleGridColSize) {int m = obstacleGridSize, n = obstacleGridColSize[0];if (obstacleGrid[0][0] == 1) {return 0;}int dp[n];memset(dp, 0, sizeof(dp));dp[0] = 1;for (int i = 0; i < m; i++) {for (int j = 0; j < n; j++) {if (obstacleGrid[i][j] == 1) {dp[j] = 0;continue;}if (j - 1 >= 0) {dp[j] += dp[j - 1];}}}return dp[n - 1];
}
5. 定义状态
这里通过复用 dp
数组,在每次处理新的一行时,dp
数组的值会不断更新,最终 dp[n - 1]
存储的是到达最后一行最后一列(即右下角)位置的不同路径数量。
6. 找出状态转移方程
因为机器人每次只能向下或者向右移动一步,对于位置 (i, j)
(i
表示行,j
表示列):
- 如果当前位置
obstacleGrid[i][j]
是障碍物(即obstacleGrid[i][j] == 1
),那么机器人无法到达该位置,此时dp[j] = 0
。 - 如果当前位置不是障碍物(即
obstacleGrid[i][j] == 0
),到达该位置的路径数量等于从左边位置(i, j - 1)
移动过来的路径数量(前提是j - 1 >= 0
,因为不能超出边界)加上当前位置原来的路径数量(在处理新的一行时,当前位置原来的路径数量其实就是上一行对应位置的路径数量)。
if (obstacleGrid[i][j] == 1) {dp[j] = 0;continue;
}
if (j - 1 >= 0) {dp[j] += dp[j - 1];
}
7. 初始化边界条件
- 起点
(0, 0)
位置的处理:由于已经判断了obstacleGrid[0][0] != 1
(if (obstacleGrid[0][0] == 1) { return 0; }
),所以可以从起点出发,将dp[0]
初始化为1
,表示到达起点的路径数量为1
。代码为:dp[0] = 1;
。 - 初始化
dp
数组的其他元素为0
:使用memset(dp, 0, sizeof(dp));
将dp
数组所有元素初始化为0
.
8. 确定计算顺序
外层循环 for (int i = 0; i < m; i++)
遍历每一行,内层循环 for (int j = 0; j < n; j++)
遍历当前行的每一列。从左上角开始,逐行逐列地更新 dp
数组的值,这样可以保证在计算 dp[j]
时,依赖的 dp[j - 1]
已经是正确的(因为是从左到右计算的),并且在处理新的一行时,dp
数组的值会根据上一行的结果和当前行的障碍物情况进行更新。
最后,函数返回 dp[n - 1]
,即机器人从左上角 (0, 0)
到达右下角 (m - 1, n - 1)
位置的不同路径数量。
这种方法通过一维数组优化了空间复杂度O(n)
,相比使用二维数组的动态规划方法,减少了空间的占用,时间复杂度仍然是 O(n*m)
,因为需要遍历整个网格。
7、整数拆分
343. 整数拆分
给定一个正整数
n
,将其拆分为k
个 正整数 的和(k >= 2
),并使这些整数的乘积最大化。返回 你可以获得的最大乘积 。
示例 1:
输入: n = 2 输出: 1 解释: 2 = 1 + 1, 1 × 1 = 1。
示例 2:
输入: n = 10 输出: 36 解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
int integerBreak(int n) {if (n == 2 || n == 3) {return n - 1;}if (n == 4)return n;long long int dp[60];dp[0] = 6;dp[1] = 9;dp[2] = 12;for (int i = 3; i <= n - 5; i++) {dp[i] = dp[i - 3] * 3;}return dp[n-5];
}
1. 定义状态
在整数拆分问题中,定义状态 dp[i]
表示将整数 i + 5
拆分成至少两个正整数的和后,这些正整数的最大乘积。
2. 找出状态转移方程
为了得到将一个整数拆分成若干正整数和后这些正整数的最大乘积,我们可以利用数学性质:尽量多拆出 3
,这样得到的乘积最大。
对于大于等于 5
的整数 n
,把它拆分成 3
和另一个数的组合能使乘积最大。当 n >= 5
时,我们发现 dp[i]
(对应整数 i + 5
)可以由 dp[i - 3]
(对应整数 (i - 3)+ 5
)乘以 3
得到,因为在拆分过程中多拆分出一个 3
。所以状态转移方程为:
dp[i] = dp[i - 3] * 3;
3. 初始化边界条件
对于较小的 n
值,我们需要单独处理并作为边界条件进行初始化。
- 当
n = 2
时,拆分成1 + 1
,乘积为1
,即2 - 1
,所以if (n == 2 || n == 3) { return n - 1; }
。 - 当
n = 4
时,拆分成2 + 2
,乘积为4
,所以if (n == 4) return n;
。
对于 dp
数组的初始化,对应 n
从 5
开始:
- 当
n = 5
时,拆分为2 + 3
,乘积为6
,所以dp[0] = 6
。 - 当
n = 6
时,拆分为3 + 3
,乘积为9
,所以dp[1] = 9
。 - 当
n = 7
时,拆分为3 + 4
,乘积为12
,所以dp[2] = 12
。
4. 确定计算顺序
根据状态转移方程 dp[i]=dp[i - 3]\times3
可知,计算 dp[i]
依赖于 dp[i - 3]
的值,所以需要按照从小到大的顺序依次计算 dp
数组的值。
在代码中,使用 for
循环从 i = 3
开始计算,直到 i = n - 5
:
for (int i = 3; i <= n - 5; i++) {dp[i] = dp[i - 3] * 3;
}
这样可以保证在计算 dp[i]
时,dp[i - 3]
已经计算完成。
时间复杂度为O(n),空间复杂度也为 O(n)。
我觉得这个其实不是严格意义上的动态规划,更偏向数学规律和贪心算法。力扣的答案有另一种解法,大家可以去看看
本体其实也可以优化空间复杂度,因为只需要维护三个数值即可。
附上代码:
int integerBreak(int n) {if (n == 2 || n == 3) {return n - 1;}if (n == 4)return n;long long int dp[3];dp[0] = 6;dp[1] = 9;dp[2] = 12;int index = 0, count = 0;for (int i = 1; i <= n - 7; i++) {if (index == 0)count++;dp[index++] *= 3;if (index == 3)index = 0;}return dp[n - count * 3 - 5]; }
时间复杂度为O(n),空间复杂度也为 O(1)。
不多解释,如上1、2题。
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
dp | 6 | 9 | 12 | 18 | 27 | 36 | 54 | 81 | 108 |
分解数 | 2+3 | 3+3 | 3+4 | 2+3+3 | 3+3+3 | 3+3+4 | 2+3+3+3 | 3+3+3+3 | 3+3+3+4 |
数字 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
8、最长回文子串
5. 最长回文子串
给你一个字符串
s
,找到s
中最长的 回文 子串。示例 1:
输入:s = "babad" 输出:"bab" 解释:"aba" 同样是符合题意的答案。
示例 2:
输入:s = "cbbd" 输出:"bb"
char* longestPalindrome(char* s) {int n = strlen(s);if (n == 1)return s;bool dp[n][n];int start = 0, maxLen = 1;memset(dp, false, sizeof(dp));for (int i = 0; i < n; i++)dp[i][i] = true;for (int i = 0; i < n - 1; i++) {if (s[i] == s[i + 1]) {dp[i][i + 1] = true;start = i;maxLen = 2;}}for (int l = 3; l <= n; l++) {for (int i = 0; i <= n - l; i++) {int j = i + l - 1;if (dp[i + 1][j - 1] && s[i] == s[j]) {dp[i][j] = true;if (l > maxLen) {start = i;maxLen = l;}}}}s[start + maxLen] = '\0';return s + start;
}
1. 定义状态
设 dp[i][j]
表示字符串 s
中从索引 i
到索引 j
的子串(即 s[i...j]
)是否为回文子串,其中 i
和 j
分别是子串的起始和结束索引,dp[i][j]
为 true
时表示该子串是回文子串,为 false
时则不是。
2. 找出状态转移方程
一个长度大于 2 的子串 s[i...j]
如果是回文子串,需要满足两个条件:
- 子串的首尾字符相等,即
s[i] == s[j]
。 - 去掉首尾字符后的子串
s[i + 1...j - 1]
也是回文子串,即dp[i + 1][j - 1]
为true
。
if (dp[i + 1][j - 1] && s[i] == s[j]) {dp[i][j] = true;if (l > maxLen) {start = i;maxLen = l;}
}
3. 初始化边界条件
- 单个字符的子串:对于单个字符的子串
s[i...i]
,它本身就是回文子串,所以dp[i][i]
初始化为true
。
for (int i = 0; i < n; i++)dp[i][i] = true;
- 长度为 2 的子串:对于长度为 2 的子串
s[i...i + 1]
,如果两个字符相等,那么它是回文子串,此时更新dp[i][i + 1]
为true
,同时记录当前最长回文子串的起始位置和长度。
for (int i = 0; i < n - 1; i++) {if (s[i] == s[i + 1]) {dp[i][i + 1] = true;start = i;maxLen = 2;}
}
4. 确定计算顺序
由于状态转移方程中 dp[i][j]
依赖于 dp[i + 1][j - 1]
,即当前状态依赖于左下角的状态,所以需要按照子串长度从小到大的顺序进行计算。
先计算长度为 3 的子串,再计算长度为 4 的子串,以此类推,直到计算长度为 n
的子串。对于每个长度 l
,遍历所有可能的起始索引 i
,计算对应的结束索引 j = i + l - 1
,然后根据状态转移方程更新 dp[i][j]
。
for (int l = 3; l <= n; l++) {for (int i = 0; i <= n - l; i++) {int j = i + l - 1;if (dp[i + 1][j - 1] && s[i] == s[j]) {dp[i][j] = true;if (l > maxLen) {start = i;maxLen = l;}}}
}
最后,通过 s[start + maxLen] = '\0'
将最长回文子串后面的字符截断,返回 s + start
指向的最长回文子串。
**时间空间复杂度是O(n2),O(n2) **。
/ | b | a | b | a | d |
---|---|---|---|---|---|
dp | 0 | 1 | 2 | 3 | 4 |
0 | T | / | / | / | / |
1 | F | T | / | / | / |
2 | T | F | T | / | / |
3 | F | T | F | T | / |
4 | F | F | F | F | T |
/ | c | b | b | d |
---|---|---|---|---|
dp | 0 | 1 | 2 | 3 |
0 | T | / | / | / |
1 | F | T | / | / |
2 | F | T | T | / |
3 | F | F | F | T |
这样虽然能通过,但耗时耗地,所以作者最开始还是用双指针的方法写的。
char* longestPalindrome(char* s) {int n = strlen(s);int i, left, right;int curLen = 1, maxLen, pL, pR;for (i = 0; i < n; i++) {left = i - 1;right = i + 1;while (left >= 0 && right < n && s[left] == s[right]) {left--;right++;}curLen = right - left + 1 - 2;if (curLen > maxLen) {maxLen = curLen;pL = left + 1;pR = right - 1;}}for (int j = 0; j < n; j++) {left = j;right = j + 1;while (left >= 0 && right < n && s[left] == s[right]) {left--;right++;}curLen = right - left + 1 - 2;if (curLen > maxLen) {maxLen = curLen;pL = left + 1;pR = right - 1;}}s[pL + maxLen] = '\0';return s + pL; }
9、不同的二叉搜索树
96. 不同的二叉搜索树
给你一个整数
n
,求恰由n
个节点组成且节点值从1
到n
互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。示例 1:
输入:n = 3 输出:5
示例 2:
输入:n = 1 输出:1
int numTrees(int n) {if (n == 1) {return 1;}if (n == 2) {return 2;}int dp[n + 1];for (int i = 0; i <= n; i++) {dp[i] = 0;}dp[0] = 1;dp[1] = 1;dp[2] = 2;for (int i = 3; i <= n; i++) {for (int j = 1; j <= i; j++) {dp[i] += dp[j - 1] * dp[i - j];}}return dp[n];
}
1. 定义状态
设 dp[i]
表示节点个数为 i
时,能构成的不同二叉搜索树(Binary Search Tree, BST)的数量。
2. 找出状态转移方程
对于节点个数为 i
的二叉搜索树,我们可以考虑以 1
到 i
中的任意一个数 j
作为根节点。
- 当选择
j
作为根节点时,其左子树的节点个数为j - 1
,右子树的节点个数为i - j
。 - 根据二叉搜索树的性质,左子树的所有节点值小于根节点值,右子树的所有节点值大于根节点值。因此,左子树的不同形态数量为
dp[j - 1]
,右子树的不同形态数量为dp[i - j]
。 - 那么以
j
为根节点的二叉搜索树的数量就是左子树的不同形态数量乘以右子树的不同形态数量,即dp[j - 1] * dp[i - j]
。 - 由于
j
可以从1
取到i
,所以节点个数为i
的二叉搜索树的总数量dp[i]
就是以1
到i
中每个数为根节点的二叉搜索树数量之和。
3. 初始化边界条件
dp[0] = 1
:表示空树也是一种二叉搜索树,所以节点个数为0
时,不同二叉搜索树的数量为1
。dp[1] = 1
:当只有一个节点时,显然只能构成一种二叉搜索树,即这个节点本身,所以dp[1]
为1
。dp[2] = 2
:当有两个节点时,有两种不同的二叉搜索树结构,一种是根节点的左子树有一个节点,另一种是根节点的右子树有一个节点,所以dp[2]
为2
。
4. 确定计算顺序
根据状态转移方程,计算 dp[i]
时需要用到 dp[0]
到 dp[i - 1]
的值,所以我们应该按照从小到大的顺序依次计算 dp[3]
到 dp[n]
。
五、进阶背包理论
参考代码随想录:
1、01背包(dp二维数组)
1. 问题描述
有 n
个物品,每个物品有重量 weight[i]
和价值 value[i]
(i = 0, 1, 2, ..., n - 1
),以及一个容量为 W
的背包。每个物品只有一件,要么放入背包,要么不放入背包**(这就是 “01” 的含义,0 表示不选,1 表示选)**。目标是在不超过背包容量的前提下,选择一些物品放入背包,使得放入背包的物品总价值最大。
max=4Kg weight value item0 1 15 item1 2 20 item2 4 30
2. 定义状态
设 dp[i][j]
表示考虑前 i
个物品(i
从 0 到 n - 1
),背包容量为 j
(j
从 0 到 W
)时,能获得的最大价值。
item0放入
dp
数组:
/ 0 1 2 3 4 item0 0 15 15 15 15 item1 item2 item1放入
dp
数组:
/ 0 1 2 3 4 item0 0 15 15 15 15 item1 0 15 15 20 35 item2
- 背包容量为 0,放不下物品0 或者物品1,此时背包里的价值为0。
- 背包容量为 1,只能放下物品0,背包里的价值为15。
- 背包容量为 2,只能放下物品0,背包里的价值为15。
- 背包容量为 3,上一行同一状态,背包只能放物品0,这次也可以选择物品1了,背包可以放物品1 或者 物品0,物品1价值更大,背包里的价值为20。
- 背包容量为 4,上一行同一状态,背包只能放物品0,这次也可以选择物品1了,背包可以放下物品0 和 物品1,背包价值为35。
3. 状态转移方程
对于每个物品 i
和背包容量 j
,有两种情况:
- 不选择物品
i
:此时dp[i][j] = dp[i - 1][j]
,即不选当前物品时,最大价值与考虑前i - 1
个物品且背包容量为j
时相同。 - 选择物品
i
:前提是j >= weight[i]
(背包容量能装下物品i
),此时dp[i][j] = dp[i - 1][j - weight[i]] + value[i]
,即选了当前物品后,最大价值是考虑前i - 1
个物品且背包容量为j - weight[i]
时的最大价值加上物品i
的价值。
4. 初始化边界条件
- 当
i = 0
时,即只考虑第一个物品,若j < weight[0]
,则dp[0][j] = 0
(背包容量装不下第一个物品,价值为 0);若j >= weight[0]
,则dp[0][j] = value[0]
(能装下第一个物品,价值就是第一个物品的价值)。 - 当
j = 0
时,无论考虑多少个物品,背包容量为 0,能获得的价值都是 0,即dp[i][0] = 0
(i = 0, 1, 2, ..., n - 1
)。
5. 计算顺序
根据状态转移方程,计算 dp[i][j]
时依赖于 dp[i - 1][j]
和 dp[i - 1][j - weight[i]]
,所以应该按照 i
从 0 到 n - 1
,j
从 0 到 W
的顺序依次计算 dp[i][j]
的值。
6. 最终结果
计算完所有的 dp[i][j]
后,dp[n - 1][W]
就是考虑所有 n
个物品且背包容量为 W
时能获得的最大价值,即 01 背包问题的解。
#include <stdio.h>
#include <stdlib.h>// 函数用于解决 01 背包问题
int knapsack_01(int *weight, int *value, int n, int W) {// 创建二维数组 dp 用于存储中间结果int **dp = (int **)malloc(n * sizeof(int *));for (int i = 0; i < n; i++) {dp[i] = (int *)malloc((W + 1) * sizeof(int));}// 初始化边界条件for(int i = 0; i < n; i++){dp[i][0] = 0;}for (int j = 0; j <= W; j++) {if (j < weight[0]) {dp[0][j] = 0;} else {dp[0][j] = value[0];}}// 动态规划计算for (int i = 1; i < n; i++) {for (int j = 0; j <= W; j++) {if (j < weight[i]) {dp[i][j] = dp[i - 1][j];} else {dp[i][j] = (dp[i - 1][j] > dp[i - 1][j - weight[i]] + value[i]) ? dp[i - 1][j] : dp[i - 1][j - weight[i]] + value[i];}}}// 保存最终结果int result = dp[n - 1][W];// 释放动态分配的内存for (int i = 0; i < n; i++) {free(dp[i]);}free(dp);return result;
}int main() {int weight[] = {2, 3, 4, 5};int value[] = {3, 4, 5, 6};int n = sizeof(weight) / sizeof(weight[0]);int W = 8;int max_value = knapsack_01(weight, value, n, W);printf("能获得的最大价值为: %d\n", max_value);return 0;
}
7.代码解释
- 内存分配:在
knapsack_01
函数中,使用malloc
函数动态分配一个二维数组dp
,用于存储中间结果。 - 边界条件初始化:对于第一个物品(
i = 0
),根据背包容量j
和物品重量weight[0]
的关系初始化dp[0][j]
。 - 动态规划计算:使用两层嵌套循环,外层循环遍历物品(
i
从 1 到n - 1
),内层循环遍历背包容量(j
从 0 到W
),根据状态转移方程更新dp[i][j]
的值。 - 结果保存与内存释放:将最终结果保存到
result
变量中,然后使用free
函数释放动态分配的内存,避免内存泄漏。 - 主函数:定义物品的重量数组
weight
、价值数组value
、物品数量n
和背包容量W
,调用knapsack_01
函数计算最大价值并输出结果。
8.复杂度分析
- 时间复杂度:
O(n*W)
,其中n
是物品的数量,W
是背包的容量,因为使用了两层嵌套循环。 - 空间复杂度:
O(n*W)
,主要用于存储二维数组dp
。
2、01背包(dp滚动数组)
在 01 背包问题中,我们前面使用二维数组 dp[i][j]
来记录状态,不过可以利用滚动数组把空间复杂度从 O(nW)
优化到 O(W)
。这是因为状态转移方程 dp[i][j]
只依赖于 dp[i - 1][j]
和 dp[i - 1][j - weight[i]]
,也就是当前状态只和上一行的状态有关,所以可以用一维数组来滚动更新状态。
1. 定义状态
在使用滚动数组优化的 01 背包问题中,我们将状态定义为一维数组 dp[j]
,它表示背包容量为 j
时,能获得的最大价值。
这里的状态其实是对原本二维状态 dp[i][j]
(表示考虑前 i
个物品,背包容量为 j
时的最大价值)的简化,通过滚动数组的方式,我们在同一数组空间中不断更新状态,使得当前的 dp[j]
实际上记录的是考虑到当前物品时背包容量为 j
的最大价值。
2. 找出状态转移方程
对于每个物品 i
和背包容量 j
,同样有两种情况需要考虑:
- 不选择物品
i
:此时背包的最大价值不变,即dp[j]
保持上一轮(也就是考虑前i - 1
个物品时)的值。 - 选择物品
i
:前提是背包容量j
要大于等于物品i
的重量weight[i]
。选择该物品后,背包剩余容量为j - weight[i]
,此时的最大价值为考虑前i - 1
个物品且背包容量为j - weight[i]
时的最大价值加上物品i
的价值value[i]
。
3. 初始化边界条件
在代码中,我们使用 calloc
函数创建了一维数组 dp
,并将其所有元素初始化为 0,即 dp[j] = 0
(j
从 0 到 W
)。这是因为当没有考虑任何物品(即初始状态)或者背包容量为 0 时,能获得的最大价值自然为 0。
4. 确定计算顺序
根据状态转移方程,计算 dp[j]
时依赖于 dp[j - weight[i]]
,而 dp[j - weight[i]]
是上一轮(考虑前 i - 1
个物品时)的状态。为了保证在更新 dp[j]
时,dp[j - weight[i]]
没有被当前轮的更新所覆盖,我们需要从后往前更新 dp
数组。具体的计算顺序如下:
- 外层循环遍历物品,从第一个物品(
i = 0
)到第n - 1
个物品。 - 对于每个物品
i
,内层循环从背包的最大容量W
开始,递减到该物品的重量weight[i]
,依次更新dp[j]
的值。
#include <stdio.h>
#include <stdlib.h>
#define max(a, b) (((a) > (b)) ? (a) : (b))// 函数用于解决 01 背包问题,使用滚动数组优化空间
int knapsack_01(int *weight, int *value, int n, int W) {// 创建一维数组 dp 用于滚动更新状态int *dp = (int *)calloc(W + 1, sizeof(int));// 动态规划计算for (int i = 0; i < n; i++) {// 从后往前更新 dp 数组,避免覆盖需要使用的上一轮状态for (int j = W; j >= weight[i]; j--) {// 状态转移方程//代码表示为 `not_choose = dp[j]`//int choose = dp[j - weight[i]] + value[i];//用代码表示为 `choose = dp[j - weight[i]] + value[i]`//int not_choose = dp[j];//dp[j] = (choose > not_choose) ? choose : not_choose;dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);}}// 保存最终结果int result = dp[W];// 释放动态分配的内存free(dp);return result;
}int main() {int weight[] = {2, 3, 4, 5};int value[] = {3, 4, 5, 6};int n = sizeof(weight) / sizeof(weight[0]);int W = 8;int max_value = knapsack_01(weight, value, n, W);printf("能获得的最大价值为: %d\n", max_value);return 0;
}
5.代码解释
- 初始化
dp
数组:借助calloc
函数创建一个长度为W + 1
的一维数组dp
,并且把所有元素初始化为 0。此数组用来滚动更新状态。 - 动态规划计算:
- 外层循环遍历每一个物品,从
i = 0
到i = n - 1
。 - 内层循环从
W
到weight[i]
倒序更新dp
数组。之所以要倒序,是为了保证在更新dp[j]
时,dp[j - weight[i]]
依旧是上一轮的状态,避免被当前轮的更新覆盖。 - 针对每个
j
,计算选择当前物品和不选择当前物品的价值,取二者中的较大值更新dp[j]
。
- 外层循环遍历每一个物品,从
- 结果保存与内存释放:把最终结果存于
result
变量,接着用free
函数释放动态分配的内存,防止内存泄漏。 - 主函数:定义物品的重量数组
weight
、价值数组value
、物品数量n
和背包容量W
,调用knapsack_01
函数计算最大价值并输出结果。
6.复杂度分析
- 时间复杂度:
O(n*W)
,这里的n
是物品的数量,W
是背包的容量,因为使用了两层嵌套循环。 - 空间复杂度:
O(W)
,主要用于存储一维数组dp
。
3.滚动数组!!!
滚动数组是一种在动态规划中常用的优化技巧,主要用于减少空间复杂度。下面从基本概念、使用场景、原理、示例(以 0 - 1 背包问题为例)以及优缺点几个方面详细介绍滚动数组。
1.基本概念
滚动数组本质上是对动态规划中二维或多维状态数组的一种优化手段。在动态规划里,我们通常会定义一个二维或者多维的数组来保存中间状态,然而在某些情况下,当前状态的计算只依赖于前面若干行或者若干层的状态,此时就可以用一个一维数组或者更小维度的数组来滚动更新状态,从而达到节省空间的目的。
2.使用场景
滚动数组适用于满足以下条件的动态规划问题:
- 状态转移依赖局部:当前状态的计算只依赖于前面固定数量的状态。例如在 0 - 1 背包问题中,
dp[i][j]
只依赖于dp[i - 1][j]
和dp[i - 1][j - weight[i]]
,即只和上一行的状态有关。 - 状态更新顺序可控制:可以通过合理安排状态更新的顺序,避免新状态覆盖掉后续计算还需要使用的旧状态。
3.原理
滚动数组的核心原理是复用数组空间。以二维数组 dp[i][j]
为例,如果当前状态 dp[i][j]
只依赖于 dp[i - 1][k]
(k
为某个与 j
相关的值),那么我们可以用一个一维数组 dp[j]
来代替二维数组。在更新 dp[j]
时,我们通过控制更新顺序,使得在更新 dp[j]
时,dp[j - weight[i]]
仍然保存着上一轮(即考虑前 i - 1
个物品时)的状态,这样就可以实现状态的滚动更新。
4.优缺点
优点
- 节省空间:能够显著减少动态规划所需的空间复杂度,尤其在状态数组维度较高或者数据规模较大时,优势更加明显。
- 代码简洁:使用滚动数组后,代码中的数组维度降低,使得代码更加简洁易读。
缺点
- 可读性降低:对于不熟悉滚动数组技巧的人来说,代码的理解难度会增加,因为状态的更新和依赖关系不像二维数组那样直观。
- 适用范围受限:并非所有的动态规划问题都适合使用滚动数组,只有满足状态转移依赖局部和状态更新顺序可控制这两个条件的问题才能使用。
4.分割等和子集
416. 分割等和子集
给你一个 只包含正整数 的 非空 数组
nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。示例 1:
输入:nums = [1,5,11,5] 输出:true 解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入:nums = [1,2,3,5] 输出:false 解释:数组不能分割成两个元素和相等的子集。
#define max(a, b) (((a) > (b)) ? (a) : (b))int getSum(int* nums, int t) {int sum = 0;for (int i = 0; i < t; i++) {sum += nums[i];}return sum;
}bool canPartition(int* nums, int numsSize) {int sum = getSum(nums, numsSize);if (sum % 2 != 0) {return false;}int tar = sum / 2;int dp[tar + 1];for (int i = 0; i <= tar; i++) {dp[i] = 0;}for (int i = 0; i < numsSize; i++) {for (int j = tar; j >= nums[i]; j--) {dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);}}return dp[tar] == tar;
}
1. 定义状态
设 dp[j]
表示在当前考虑的数组元素范围内,能否组成和为 j
的子集。这里 j
的范围是从 0
到 sum / 2
,其中 sum
是数组 nums
中所有元素的总和。
2. 找出状态转移方程
对于每个元素 nums[i]
和每个可能的和 j
(j
从 sum / 2
到 nums[i]
),有两种情况需要考虑:
- 不选择当前元素
nums[i]
:此时dp[j]
保持不变,即dp[j]
等于之前考虑过的元素能组成和不超过j
的最大子集和,也就是dp[j]
本身。 - 选择当前元素
nums[i]
:前提是j >= nums[i]
。选择该元素后,能组成和不超过j
的最大子集和为dp[j - nums[i]] + nums[i]
,其中dp[j - nums[i]]
是之前考虑过的元素能组成和不超过j - nums[i]
的最大子集和。
3. 初始化边界条件
由于初始时没有考虑任何元素,所以对于任意的 j
(从 0
到 sum / 2
),能组成和不超过 j
的最大子集和都为 0
,即 dp[j] = 0
。
4. 确定计算顺序
根据状态转移方程,计算 dp[j]
时依赖于 dp[j - nums[i]]
,而 dp[j - nums[i]]
是之前计算得到的状态。为了保证在更新 dp[j]
时,dp[j - nums[i]]
没有被当前轮的更新所覆盖,我们需要从后往前更新 dp
数组。
- 外层循环遍历数组中的每个元素,从
nums[0]
到nums[numsSize - 1]
。 - 对于每个元素
nums[i]
,内层循环从sum / 2
开始,递减到nums[i]
,依次更新dp[j]
的值。
**时间空间复杂度是O(n*tar),O(tar)
**。
5.最后一块石头的重量 II
最后一块石头的重量 II
有一堆石头,用整数数组
stones
表示。其中stones[i]
表示第i
块石头的重量。每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为
x
和y
,且x <= y
。那么粉碎的可能结果如下:
- 如果
x == y
,那么两块石头都会被完全粉碎;- 如果
x != y
,那么重量为x
的石头将会完全粉碎,而重量为y
的石头新重量为y-x
。最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回
0
。示例 1:
输入:stones = [2,7,4,1,8,1] 输出:1 解释: 组合 2 和 4,得到 2,所以数组转化为 [2,7,1,8,1], 组合 7 和 8,得到 1,所以数组转化为 [2,1,1,1], 组合 2 和 1,得到 1,所以数组转化为 [1,1,1], 组合 1 和 1,得到 0,所以数组转化为 [1],这就是最优值。
示例 2:
输入:stones = [31,26,33,21,40] 输出:5
#define max(a, b) (((a) > (b)) ? (a) : (b))int getSum(int* nums, int t) {int sum = 0;for (int i = 0; i < t; i++) {sum += nums[i];}return sum;
}int lastStoneWeightII(int* stones, int stonesSize) {int sum = getSum(stones, stonesSize);int tar = sum / 2;int dp[tar + 1];for (int i = 0; i <= tar; i++) {dp[i] = 0;}for (int i = 0; i < stonesSize; i++) {for (int j = tar; j >= stones[i]; j--) {dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);}}return sum - dp[tar] - dp[tar];
}
1. 定义状态
本题的核心目标是将一堆石头分成两堆,使得这两堆石头重量之差尽可能小。可以将其转化为一个 0 - 1 背包问题,即从所有石头中选取部分石头,使得这部分石头的重量和尽可能接近总重量的一半。
设 dp[j]
表示在考虑前 i
个石头的情况下,能组成的不超过 j
的最大石头重量和。其中 j
的取值范围是从 0
到 sum / 2
,sum
是所有石头的总重量。
2. 找出状态转移方程
对于每个石头 stones[i]
和每个可能的重量 j
(j
从 sum / 2
到 stones[i]
),有两种选择:
- 不选择当前石头
stones[i]
:此时能组成的不超过j
的最大石头重量和保持不变,即dp[j]
维持上一轮的结果。 - 选择当前石头
stones[i]
:前提是j >= stones[i]
。若选择该石头,那么能组成的不超过j
的最大石头重量和为dp[j - stones[i]] + stones[i]
,其中dp[j - stones[i]]
是考虑前i - 1
个石头时能组成的不超过j - stones[i]
的最大石头重量和。
3. 初始化边界条件
在开始考虑任何石头之前,也就是没有选取任何石头时,对于任意的 j
(从 0
到 sum / 2
),能组成的不超过 j
的最大石头重量和都为 0
。所以需要将 dp
数组初始化为 0
。
4. 确定计算顺序
根据状态转移方程,计算 dp[j]
时依赖于 dp[j - stones[i]]
,而 dp[j - stones[i]]
是之前计算得到的状态。为了保证在更新 dp[j]
时,dp[j - stones[i]]
没有被当前轮的更新所覆盖,需要从后往前更新 dp
数组。具体的计算顺序如下:
- 外层循环:遍历所有石头,从
stones[0]
到stones[stonesSize - 1]
,依次考虑每个石头对dp
数组的影响。 - 内层循环:对于每个石头
stones[i]
,从sum / 2
开始递减到stones[i]
,根据状态转移方程更新dp[j]
的值。
**时间空间复杂度是O(n*tar),O(tar)
**。
六、总结
动态规划是一种强大的算法策略,适用于解决具有最优子结构和重叠子问题性质的问题。通过合理定义状态、找出状态转移方程、初始化边界条件和确定计算顺序,可以高效地解决复杂问题。