动态规划
hot100_198. 打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
解题思路:
动态规划的的四个解题步骤是:
-
定义子问题
-
写出子问题的递推关系
-
确定 DP 数组的计算顺序
-
空间优化(可选)
步骤一:定义子问题
原问题是 "从全部房子中能偷到的最大金额",将问题的规模缩小,子问题就是 "从 k 个房子中能偷到的最大金额 ",用 f(k) 表示。

可以看到,子问题是参数化的,我们定义的子问题中有参数 k。假设一共有 n 个房子的话,就一共有 n 个子问题。动态规划实际上就是通过求这一堆子问题的解,来求出原问题的解。这要求子问题需要具备两个性质:
-
原问题要能由子问题表示。例如这道小偷问题中,k=n 时实际上就是原问题。否则,解了半天子问题还是解不出原问题,那子问题岂不是白解了。
-
一个子问题的解要能通过其他子问题的解求出。例如这道小偷问题中,f(k) 可以由 f(k−1) 和 f(k−2) 求出,具体原理后面会解释。这个性质就是教科书中所说的"最优子结构"。如果定义不出这样的子问题,那么这道题实际上没法用动态规划解。
步骤二:写出子问题的递推关系
分析一下这道小偷问题的递推关系:
假设一共有 n 个房子,每个房子的金额分别是 H 0 , H 1 , . . . , H n − 1 H_0, H_1,...,H_{n-1} H0,H1,...,Hn−1,子问题 f(k) 表示从前 k 个房子(即 H 0 , H 1 , . . . , H n − 1 H_0, H_1,...,H_{n-1} H0,H1,...,Hn−1)中能偷到的最大金额。那么,偷 k 个房子有两种偷法:

k 个房子中最后一个房子是 H k − 1 H_{k-1} Hk−1 。如果不偷这个房子,那么问题就变成在前 k−1 个房子中偷到最大的金额,也就是子问题 f(k−1)。如果偷这个房子,那么前一个房子 H k − 2 H_{k-2} Hk−2显然不能偷,其他房子不受影响。那么问题就变成在前 k−2 个房子中偷到的最大的金额。两种情况中,选择金额较大的一种结果。
f ( k ) = m a x f ( k − 1 ) , H k − 1 + f ( k − 2 ) f(k)=max{f(k−1),H_{k−1}+f(k−2)} f(k)=maxf(k−1),Hk−1+f(k−2)
在写递推关系的时候,要注意写上 k=0 和 k=1 的基本情况:
-
当 k=0 时,没有房子,所以 f(0)=0。
-
当 k=1 时,只有一个房子,偷这个房子即可,所以 f(1)=H_0 。
这样才能构成完整的递推关系,后面写代码也不容易在边界条件上出错。
步骤三:确定 DP 数组的计算顺序
在确定了子问题的递推关系之后,下一步就是依次计算出这些子问题了。在很多教程中都会写,动态规划有两种计算顺序,一种是自顶向下的、使用备忘录的递归方法,一种是自底向上的、使用 dp 数组的循环方法。不过在普通的动态规划题目中,99% 的情况我们都不需要用到备忘录方法,所以我们最好坚持用自底向上的 dp 数组。
DP 数组也可以叫"子问题数组",因为 DP 数组中的每一个元素都对应一个子问题。如下图所示,dp[k] 对应子问题 f(k),即偷前 k 间房子的最大金额。

那么,只要搞清楚了子问题的计算顺序,就可以确定 DP 数组的计算顺序。对于小偷问题,我们分析子问题的依赖关系,发现每个 f(k) 依赖 f(k−1) 和 f(k−2)。也就是说,dp[k] 依赖 dp[k-1] 和 dp[k-2],如下图所示。

那么,既然 DP 数组中的依赖关系都是向右指的,DP 数组的计算顺序就是从左向右。这样我们可以保证,计算一个子问题的时候,它所依赖的那些子问题已经计算出来了。
确定了 DP 数组的计算顺序之后,我们就可以写出题解代码了:
c++
int rob(vector<int>& nums) {
if (nums.size() == 0) {
return 0;
}
// 子问题:
// f(k) = 偷 [0..k) 房间中的最大金额
// f(0) = 0
// f(1) = nums[0]
// f(k) = max{ rob(k-1), nums[k-1] + rob(k-2) }
int N = nums.size();
vector<int> dp(N+1, 0);
dp[0] = 0;
dp[1] = nums[0];
for (int k = 2; k <= N; k++) {
dp[k] = max(dp[k-1], nums[k-1] + dp[k-2]);
}
return dp[N];
}
步骤四:空间优化
空间优化的基本原理是,很多时候我们并不需要始终持有全部的 DP 数组。对于小偷问题,我们发现,最后一步计算 f(n) 的时候,实际上只用到了 f(n−1) 和 f(n−2) 的结果。n−3 之前的子问题,实际上早就已经用不到了。那么,我们可以只用两个变量保存两个子问题的结果,就可以依次计算出所有的子问题。下面的动图比较了空间优化前和优化后的对比关系:

这样一来,空间复杂度也从 O(n) 降到了 O(1)。优化后的代码如下所示:
c++
int rob(vector<int>& nums) {
int prev = 0;
int curr = 0;
// 每次循环,计算"偷到当前房子为止的最大金额"
for (int i : nums) {
// 循环开始时,curr 表示 dp[k-1],prev 表示 dp[k-2]
// dp[k] = max{ dp[k-1], dp[k-2] + i }
int temp = max(curr, prev + i);
prev = curr;
curr = temp;
// 循环结束时,curr 表示 dp[k],prev 表示 dp[k-1]
}
return curr;
}
hot100_ .打家劫舍2
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
示例 1:
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
示例 2:
输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 3:
输入:nums = [1,2,3]
输出:3
c++
class Solution {
public:
int robRange(vector<int>& nums, int start, int end) {
int pre = nums[start], curr = max(nums[start+1], pre);
cout << pre <<" " << curr << endl;
for (int i = start + 2; i <= end; i++) {
int temp = max(curr, pre + nums[i]);
pre = curr;
curr = temp;
}
cout << curr << endl;
return curr;
}
int rob(vector<int>& nums) {
int length = nums.size();
if (length == 1) {
return nums[0];
} else if (length == 2) {
return max(nums[0], nums[1]);
}
return max(robRange(nums, 0, length - 2), robRange(nums, 1, length - 1));
}
};
hot100_53.最大子数组和
给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组是数组中的一个连续部分。
示例 1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
示例 2:
输入:nums = [1]
输出:1
示例 3:
输入:nums = [5,4,-1,7,8]
输出:23
c++
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int result = nums[0];
vector<int> dp(nums.size());//d[i]标识以 nums[i]结尾的连续子序列的最大和
dp[0] = nums[0];
for(int i = 1;i<nums.size();i++){
dp[i] = max(dp[i -1] + nums[i],nums[i]); //求子序列的最大和
result = max(result,dp[i]); //d[i]当前子序列最大和,和之前子序列最大和比较
}
return result;
}
};
//空间优化:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int result = nums[0], sumPre = nums[0];
for(int i = 1; i<nums.size(); i++){
sumPre = max(sumPre + nums[i],nums[i]);
result = max(result,sumPre);
}
return result;
}
};
hot100_152.乘积最大子数组
给你一个整数数组 nums
,请你找出数组中乘积最大的非空连续 子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
测试用例的答案是一个 32-位 整数。
示例 1:
输入: nums = [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
示例 2:
输入: nums = [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
解题思路
根据正负性进行分类讨论
考虑当前位置如果是一个负数的话,那么我们希望以它前一个位置结尾的某个段的积也是个负数,这样就可以负负得正,并且我们希望这个积尽可能「负得更多」,即尽可能小。如果当前位置是一个正数的话,我们更希望以它前一个位置结尾的某个段的积也是个正数,并且希望它尽可能地大。于是这里我们可以再维护一个 f m i n ( i ) f_{min}(i) fmin(i),它表示以第 i 个元素结尾的乘积最小子数组的乘积,那么我们可以得到这样的动态规划转移方程:
f m a x ( i ) = m a x { f m a x ( i − 1 ) × a i , f m i n ( i − 1 ) × a i , a i } f_{max}(i) = max\{f_{max}(i-1) \times a_i, f_{min}(i-1) \times a_i , a_i\} fmax(i)=max{fmax(i−1)×ai,fmin(i−1)×ai,ai}
f m i n ( i ) = m i n { f m a x ( i − 1 ) × a i , f m i n ( i − 1 ) × a i , a i } f_{min}(i) = min\{f_{max}(i-1) \times a_i, f_{min}(i-1) \times a_i , a_i\} fmin(i)=min{fmax(i−1)×ai,fmin(i−1)×ai,ai}
它代表第 i 个元素结尾的乘积最大子数组的乘积 f m a x ( i ) f_{max}(i) fmax(i),可以考虑把 a i a_i ai加入第 i−1 个元素结尾的乘积最大或最小的子数组的乘积中,二者加上 a i a_i ai,三者取大,就是第 i 个元素结尾的乘积最大子数组的乘积。第 i 个元素结尾的乘积最小子数组的乘积 f m i n ( i ) f_{min}(i) fmin(i) 同理。
代码:
c++
class Solution {
public:
int maxProduct(vector<int>& nums) {
vector <long> maxF(nums.begin(),nums.end()), minF(nums.begin(), nums.end());
for (int i = 1; i < nums.size(); ++i) {
maxF[i] = max(maxF[i - 1] * nums[i], max((long)nums[i], minF[i - 1] * nums[i]));
minF[i] = min(minF[i - 1] * nums[i], min((long)nums[i], maxF[i - 1] * nums[i]));
if(minF[i]<INT_MIN) {
minF[i]=nums[i];
}
}
return *max_element(maxF.begin(), maxF.end());
}
};
空间优化:
c++
class Solution {
public:
int maxProduct(vector<int>& nums) {
long long maxPre = nums[0], minPre = nums[0];
long long maxCur, minCur;
long long ans = nums[0];
for (int i = 1; i < nums.size(); ++i) {
maxCur = max(maxPre * nums[i], max((long long)nums[i], minPre * nums[i]));
minCur = min(minPre * nums[i], min((long long )nums[i], maxPre * nums[i]));
maxPre = maxCur;
minPre = minCur;
if(ans < maxPre) ans = maxPre;
}
return ans;
}
};
hot100_279.完全平方数
给你一个整数 n
,返回 和为 n
的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1
、4
、9
和 16
都是完全平方数,而 3
和 11
不是。
示例 1:
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4
示例 2:
输入:n = 13
输出:2
解释:13 = 4 + 9
解题思路:
依据题目的要求写出状态表达式:dp[i] 表示最少需要多少个数的平方来表示整数 i。这些数必然落在区间 [ 1 , i ] [1,\sqrt{i}] [1,i ]。我们可以枚举这些数,假设当前枚举到 j,那么我们还需要取若干数的平方,构成 i − j 2 i-j^2 i−j2 。此时我们发现该子问题和原问题类似,只是规模变小了。这符合了动态规划的要求,于是我们可以写出状态转移方程。
d p [ i ] = m i n j = 1 i { d p [ i − j 2 ] + 1 , d p [ i ] } dp[i] = min_{j=1}^{\sqrt{i}}\{dp[i-j^2] + 1, dp[i]\} dp[i]=minj=1i {dp[i−j2]+1,dp[i]}
dp[0]=0 为边界条件,表示刚好有一个完全平方数等于i,同时因为计算dp[i] 时所需要用到的状态仅有 f [ i − j 2 ] f[i-j^2] f[i−j2],必然小于 i,因此我们只需要从小到大地枚举 i 来计算 dp[i] 即可。
c++
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 - j*j] + 1, dp[i]);
}
}
return dp[n];
}
};
hot100_322.零钱兑换
给你一个整数数组 coins
,表示不同面额的硬币;以及一个整数 amount
,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1
。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
示例 2:
输入:coins = [2], amount = 3
输出:-1
示例 3:
输入:coins = [1], amount = 0
输出:0
c++
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount+2, amount+1);
dp[0] = 0;
for(int i = 1; i <= amount; i++){
for(int k = 0; k < coins.size(); k++){
if(i-coins[k]>=0) dp[i] = min(dp[i-coins[k]]+1, dp[i]);
}
}
return dp[amount] < amount+1 ? dp[amount] : -1;
}
};
hot100_139.单词拆分
给你一个字符串 s
和一个字符串列表 wordDict
作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s
则返回 true
。
**注意:**不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
示例 1:
输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以由 "leet" 和 "code" 拼接成。
示例 2:
输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以由 "apple" "pen" "apple" 拼接成。
注意,你可以重复使用字典中的单词。
示例 3:
输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false
c++
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet;
for(string word:wordDict){ //放在集合便于查找
wordSet.insert(word);
}
vector<bool> dp(s.length() + 1, false); //dp[i]表示前i个字母是否能够被单词表示
dp[0] = true;
for(int i = 1; i <= s.length(); i++){
for(int j = 0; j < i; j++){ //检查dp[j]和子串[j:i]能否被单词表示
if(dp[j] && wordSet.find(s.substr(j, i-j)) != wordSet.end()){
dp[i] = true;
break;
}
}
}
return dp[s.length()];
}
};
hot100_300.最长递增子序列
给你一个整数数组 nums
,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7]
是数组 [0,3,1,6,2,2,7]
的子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4
示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1
c++
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size(), ans = 1;
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);
ans = max(ans, dp[i]);
}
}
return ans;
}
};
hot100_152.乘积最大子数组
给你一个整数数组 nums
,请你找出数组中乘积最大的非空连续 子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
测试用例的答案是一个 32-位 整数。
示例 1:
输入: nums = [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
示例 2:
输入: nums = [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
c++
class Solution {
public:
int maxProduct(vector<int>& nums) {
long long maxPre = nums[0], minPre = nums[0];
long long maxCur, minCur;
long long ans = nums[0];
for (int i = 1; i < nums.size(); ++i) {
maxCur = max(maxPre * nums[i], max((long long)nums[i], minPre * nums[i]));
minCur = min(minPre * nums[i], min((long long )nums[i], maxPre * nums[i]));
maxPre = maxCur;
minPre = minCur;
if(ans < maxPre) ans = maxPre;
}
return ans;
}
};
hot100_416.分割等和子集
给你一个 只包含正整数 的 非空 数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。
c++
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for(int num:nums) sum += num;
if(sum & 1) return false; //如果 sum 是奇数,那么无法将数组分成两个和相等的子集,直接返回 false
sum /= 2;
vector<int> dp(sum+1, 0);
dp[0] = 0;
for (int i = 0; i < nums.size(); i++) {
int num = nums[i];
for (int j = sum; j >= num; j--) { //(防止同一个元素被多次加入子集)。
//如果dp[j - nums[i]] 是可达的(即可以找到一个子集和为 j - nums[i]),则加上当前元素 nums[i],更新 dp[j]。
dp[j] = max(dp[j], dp[j-nums[i]] + nums[i]);
}
}
return dp[sum] == sum;
}
};