算法笔记之动态规划
概述
动态规划(Dynamic Programming,简称DP)是一种将问题分解成子问题并仅仅解决一次,将解保存起来的优化技术。DP主要适用于有重叠子问题和最优子结构性质的问题。
通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
适用情况
- 最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。
- 无后效性。即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响。
- 子问题重叠性质。子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率,降低了时间复杂度。
动态规划的一般步骤:
-
定义状态:明确定义问题的状态,找出问题中变化的量,这些变化的量即为状态。
-
找到状态转移方程:确定状态之间的关系,即状态转移方程。这是动态规划的核心,描述了问题的最优子结构。
-
初始化:确定初始状态,即问题中最小规模的子问题的解。
-
计算顺序:按照一定的计算顺序,一步步计算状态的值,直到计算出问题的解。
-
解的表示:根据问题的要求,确定最终的解是哪个状态,即哪些状态是我们最终关心的。
举例:
斐波那契数列。斐波那契数列的状态定义为 f(n) 表示第 n 个斐波那契数,状态转移方程为 f(n) = f(n-1) + f(n-2),初始状态为 f(0) = 0 和 f(1) = 1。
- 定义状态:f(n) 表示第 n 个斐波那契数。
- 状态转移方程:f(n) = f(n-1) + f(n-2)。
- 初始化:f(0) = 0,f(1) = 1。
- 计算顺序:从小到大计算 f(2), f(3), ..., f(n)。
- 解的表示:最终解是 f(n)。
力扣实战
杨辉三角
给定一个非负整数 numRows,生成「杨辉三角」的前 numRows 行。
动态规划实现:
class Solution {
public:
std::vector<std::vector<int>> generate(int numRows) {
std::vector<std::vector<int>> triangle;
for (int i = 0; i < numRows; ++i) {
std::vector<int> row(i + 1, 1); // 初始化当前行并将元素都置为1
for (int j = 1; j < i; ++j) {
row[j] = triangle[i - 1][j - 1] + triangle[i - 1][j]; // 计算当前元素的值
}
triangle.push_back(row); // 将当前行加入杨辉三角
}
return triangle;
}
};
打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
动态规划实现:
动态规划的关键是找到状态转移方程。在这个问题中,我们可以定义一个数组 dp,其中 dp[i] 表示在第 i 个房屋结束时,小偷能够偷窃到的最大金额。状态转移方程为:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
nums[i] 表示第 i 个房屋内存放的金额。在第 i 个房屋结束时,小偷可以选择偷窃这个房屋或者不偷窃。如果偷窃,那么最大金额为前两个房屋的最大金额加上当前房屋的金额;如果不偷窃,那么最大金额为前一个房屋的最大金额。
class Solution {
public:
int rob(vector<int>& nums) {
int n = nums.size();
if (n == 0) {
return 0;
} else if (n == 1) {
return nums[0];
}
vector<int> dp(n, 0);
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
for (int i = 2; i < n; ++i) {
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[n - 1];
}
};
dp[i] 记录了在第 i 个房屋结束时能够偷窃到的最大金额。通过遍历数组,计算出最后一个房屋的最大金额,即为整个问题的解。
完全平方数
给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
使用动态规划数组 dp,其中 dp[i] 表示和为 i 的完全平方数的最少数量。
状态转移方程可以定义为:
dp[i]=min(dp[i],dp[i−j^2]+1)
其中 j 的取值范围是 1 到 根号i。
动态规划实现:
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j * j <= i; ++j) {
dp[i] = min(dp[i], dp[i - j * j] + 1);
}
}
return dp[n];
}
};
dp[i] 记录了和为 i 的完全平方数的最少数量。通过双重循环,遍历所有可能的平方数,更新 dp[i]。
零钱兑换
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
动态规划实现:
令 dp[i] 表示凑成金额 i 所需的最少硬币个数。对于每个金额 i,我们可以考虑使用所有硬币中的任何一种硬币,假设选择硬币的面值为 coin,那么状态转移方程可以定义为:
dp[i]=min(dp[i],dp[i−coin]+1)
这表示,凑成金额 i 的最少硬币个数可以通过凑成金额 i - coin 的最少硬币个数加上 1 来得到。我们在所有可能的硬币中选择最小的数量。
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0; // 初始状态,凑成金额为 0 的硬币个数为 0
for (int i = 1; i <= amount; ++i) {
for (int coin : coins) {
if (i - coin >= 0 && dp[i - coin] != INT_MAX) {
dp[i] = min(dp[i], dp[i - coin] + 1);
}
}
}
return (dp[amount] == INT_MAX) ? -1 : dp[amount];
}
};
dp[i] 记录了凑成金额 i 所需的最少硬币个数。通过两层循环,遍历金额和硬币,更新 dp[i] 的值。
单词拆分
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
动态规划实现:
令 dp[i] 表示字符串 s 的前 i 个字符是否可以被字典中的单词拼接而成。对于每个位置 i,我们考虑前面的字符,假设 j 是一个位置,0 <= j < i,如果 dp[j] 为 true(表示前 j 个字符可以被拼接),且从 j 到 i 的子串也在字典中,那么 dp[i] 也为 true。即:
dp[i]=dp[j] and s[j:i] in wordDict
其中,s[j:i] 表示字符串 s 从位置 j 到 i 的子串。
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
int n = s.length();
vector<bool> dp(n + 1, false);
dp[0] = true; // 空字符串可以被拼接
for (int i = 1; i <= n; ++i) {
for (int j = 0; j < i; ++j) {
if (dp[j] && wordSet.count(s.substr(j, i - j))) {
dp[i] = true;
break;
}
}
}
return dp[n];
}
};
dp[i] 记录了字符串 s 的前 i 个字符是否可以被拼接。通过两层循环,遍历所有的位置 i 和 j,更新 dp[i]。
最长递增子序列
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
动态规划实现:
令 dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。对于每个位置 i,我们考虑前面的位置 j,0 <= j < i,如果 nums[i] > nums[j],那么 dp[i] 就可以通过 dp[j] 的值加上 1 来更新。
dp[i]=max(dp[i],dp[j]+1)
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
if (n == 0) {
return 0;
}
vector<int> dp(n, 1);
for (int i = 1; i < n; ++i) {
for (int j = 0; j < i; ++j) {
if (nums[i] > nums[j]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
}
return *max_element(dp.begin(), dp.end());
}
};
dp[i] 记录了以 nums[i] 结尾的最长递增子序列的长度。通过两层循环,遍历所有的位置 i 和 j,更新 dp[i]。
乘积最大子数组
给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
测试用例的答案是一个 32-位 整数。
动态规划实现:
令 maxProd[i] 表示以 nums[i] 结尾的最大乘积子数组的乘积,minProd[i] 表示以 nums[i] 结尾的最小乘积子数组的乘积。对于 maxProd[i],可以选择继续乘以 nums[i] 或者重新开始以 nums[i] 为起点。
maxProd[i] = max(nums[i], maxProd[i-1] * nums[i])
minProd[i] = min(nums[i], minProd[i-1] * nums[i])
class Solution {
public:
int maxProduct(vector<int>& nums) {
int n = nums.size();
if (n == 0) {
return 0;
}
int maxProd = nums[0];
int minProd = nums[0];
int result = nums[0];
for (int i = 1; i < n; ++i) {
int tempMax = maxProd;
maxProd = max({nums[i], maxProd * nums[i], minProd * nums[i]});
minProd = min({nums[i], tempMax * nums[i], minProd * nums[i]});
result = max(result, maxProd);
}
return result;
}
};
maxProd 和 minProd 分别记录了以当前位置 nums[i] 结尾的子数组的最大乘积和最小乘积。通过一重循环,遍历所有的位置 i,更新这两个值,最终得到最大乘积。
数组最大值
有一个长为n的数组A,求满足0≤a≤b<n的A[b]-A[a]的最大值
动态规划实现:
令 dp[i] 表示以 A[i] 结尾的子数组中的最大差值。对于每个位置 i,我们考虑前面的位置 j,0 <= j < i,如果 A[i] - A[j] 大于 dp[i],那么更新 dp[i]。
dp[i]=max(dp[i],A[i]−A[j])
class Solution {
public:
int maxProfit(vector<int>& A) {
int n = A.size();
if (n <= 1) {
return 0;
}
int maxDiff = 0;
int minVal = A[0];
for (int i = 1; i < n; ++i) {
maxDiff = max(maxDiff, A[i] - minVal);
minVal = min(minVal, A[i]);
}
return maxDiff;
}
};
maxDiff 记录了以当前位置 A[i] 结尾的子数组中的最大差值,minVal 记录了遍历过的最小值。通过一重循环,遍历所有的位置 i,更新 maxDiff 和 minVal,最终得到满足条件的最大差值。
背包问题
给定一组物品,每个物品有两个属性:重量 w[i] 和价值 v[i],以及一个背包的容量 C。现在要求从这组物品中选择若干个放入背包中,使得这些物品的总重量不超过背包容量,同时总价值最大。
动态规划实现:
- 状态定义:
定义一个二维数组 dp[i][j],表示在前 i 个物品中,背包容量为 j 时的最大总价值。
- 状态转移方程:
dp[i][j]=max(dp[i−1][j],dp[i−1][j−w[i]]+v[i])
其中,dp[i-1][j] 表示不选择第 i 个物品时的最大总价值,dp[i-1][j-w[i]] + v[i] 表示选择第 i 个物品时的最大总价值。
C++中的伪代码实现:
int knapsack(int N, int C, vector<int>& w, vector<int>& v) {
vector<vector<int>> dp(N + 1, vector<int>(C + 1, 0));
for (int i = 1; i <= N; ++i) {
for (int j = 1; j <= C; ++j) {
if (j >= w[i]) {
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]);
} else {
dp[i][j] = dp[i-1][j];
}
}
}
return dp[N][C];
}
分割等和子集
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
动态规划实现:
令 dp[i][j] 表示前 i 个元素是否可以构成和为 j 的子集。对于每个元素 nums[i],我们有两个选择:
不选择 nums[i],即
dp[i][j] = dp[i-1][j];
选择 nums[i],即
dp[i][j] = dp[i-1][j-nums[i]]。
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
if (n == 0) {
return false;
}
int sum = 0;
for (int num : nums) {
sum += num;
}
// 如果总和为奇数,不可能划分成两个和相等的子集
if (sum % 2 != 0) {
return false;
}
int target = sum / 2;
vector<vector<bool>> dp(n + 1, vector<bool>(target + 1, false));
// 初始化
dp[0][0] = true;
// 遍历计算
for (int i = 1; i <= n; ++i) {
for (int j = 0; j <= target; ++j) {
dp[i][j] = dp[i-1][j];
if (j >= nums[i-1]) {
dp[i][j] = dp[i][j] || dp[i-1][j-nums[i-1]];
}
}
}
return dp[n][target];
}
};
最长有效括号
给你一个只包含 '(' 和 ')' 的字符串,找出最长有效(格式正确且连续)括号子串的长度。
动态规划实现:
令 dp[i] 表示以字符串的第 i 个字符结尾的最长有效括号子串的长度。如果字符串第 i 个字符是 (,那么 dp[i] 必定为0,因为以 ( 结尾的子串无法形成有效括号子串。如果字符串第 i 个字符是 ),那么我们需要考虑它前面的字符。
如果 s[i-1] 是 (,那么
dp[i] = dp[i-2] + 2
如果 s[i-1] 是 ) 并且 i - dp[i-1] - 1 大于等于0,并且 s[i - dp[i-1] - 1] 是 (,那么
dp[i] = dp[i-1] + dp[i - dp[i-1] - 2] + 2
tip:
如果 s[i-1] 是 ) 并且 i - dp[i-1] - 1 大于等于0,并且 s[i - dp[i-1] - 1] 是 (,那么我们可以形成一对匹配的括号,即 ...(...)... 的形式。
- dp[i-1] 表示以 s[i-1] 结尾的最长有效括号子串的长度。
- dp[i - dp[i-1] - 2] 表示在 s[i-1] 之前,与 s[i-1] 形成一对有效括号的前一个字符之前的最长有效括号子串的长度。
- +2 表示当前形成的一对有效括号。
实际上是在原有的最长有效括号子串的基础上,加上了新形成的一对括号。这样就确保了正确地计算了以 s[i] 结尾的最长有效括号子串的长度。
class Solution {
public:
int longestValidParentheses(string s) {
int n = s.size();
if (n <= 1) {
return 0;
}
vector<int> dp(n, 0);
int maxLen = 0;
for (int i = 1; i < n; ++i) {
if (s[i] == ')') {
if (s[i - 1] == '(') {
dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
} else if (i - dp[i - 1] > 0 && s[i - dp[i - 1] - 1] == '(') {
dp[i] = dp[i - 1] + ((i - dp[i - 1] >= 2) ? dp[i - dp[i - 1] - 2] : 0) + 2;
}
maxLen = max(maxLen, dp[i]);
}
}
return maxLen;
}
};
总结
在动态规划中,选择是基于问题的性质而定的。通常情况下:
-
选择min: 当问题要求最小值、最小花费、最少次数等时,我们选择min。例如,在找零钱的问题中,我们要求凑成总金额所需的最少硬币个数,因此使用 dp[i] = min(dp[i], dp[i - coin] + 1)。
-
选择max: 当问题要求最大值、最大收益、最长长度等时,我们选择max。例如,在打家劫舍的问题中,我们要求一夜之内能够偷窃到的最高金额,因此使用 dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具