完全背包系列
完全背包与01背包的区别就是完全背包的物品的数量无限并且可以重复选择,定义 d p dp dp 数组时与背包中的物品无关。
剑指 Offer II 103. 最少的硬币数目 Medium 完全背包 2023/2/4
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。
示例:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
类型:每种硬币可以使用无限次,为完全背包问题, d p [ i ] dp[i] dp[i] 必与要凑的钱相关,与硬币无关。
本题为经典动态规划问题,详细思路。
本题
d
p
[
i
]
dp[i]
dp[i] 定义为要凑出
i
i
i 块钱需要的最小硬币数,并将一开始所有数组成员初始化为INT_MAX - 1
。
状态转移方程:
d
p
[
i
]
=
m
i
n
(
d
p
[
i
]
,
d
p
[
i
−
c
o
i
n
]
+
1
)
;
dp[i] = min(dp[i], dp[i - coin] + 1);
dp[i]=min(dp[i],dp[i−coin]+1);
对于子问题,如果硬币不够分,显然跳过此硬币,否则判断是否要更新当前最小硬币数。
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
// dp[i] 表示要凑出i块钱需要的最小硬币数
vector<int> dp(amount + 1, INT_MAX - 1); // INT_MAX - 1表示凑不出来
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (i - coin < 0) continue; // 子问题无解
dp[i] = min(dp[i], dp[i - coin] + 1);
}
}
return dp[amount] == INT_MAX - 1 ? -1 : dp[amount];
}
};
剑指 Offer II 104.排列的数目 Medium 完全背包 2023/2/11
给定一个由 不同 正整数组成的数组 nums ,和一个目标整数 target 。请从 nums 中找出并返回总和为 target 的元素组合的个数。数组中的数字可以在一次排列中出现任意次,但是顺序不同的序列被视作不同的组合。
题目数据保证答案符合 32 位整数范围。
示例:
输入:nums = [1,2,3], target = 4
输出:7
解释:
所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不同的序列被视作不同的组合。
本题看似复杂,考虑的组合又有可能有重复数,且顺序不同都算,很自然想到常规回溯。
class Solution {
public:
int res = 0;
int combinationSum4(vector<int>& nums, int target) {
sort(nums.begin(), nums.end(), [](int x, int y) {return x > y;});
dfs(nums, target);
return res;
}
void dfs(vector<int>& nums, int target) {
if (target == 0) {
res++;
return;
}
if (target < 0) return;
for (auto i: nums) {
dfs(nums, target - i);
}
}
};
但在回溯的过程中,出现很多重复枝,且很难去除,考虑使用dp数组消除重复子问题。
类型:每个数可用无限次,为完全背包问题, d p [ i ] dp[i] dp[i] 必与要组合的数相关,与数组无关。
本题
d
p
[
i
]
dp[i]
dp[i] 定义为总和为
i
i
i 的组合个数,写写就会发现,例中
[
1
,
2
,
3
]
[1,2,3]
[1,2,3]
t
a
r
g
e
t
=
4
target = 4
target=4 的时候,
d
p
[
4
]
dp[4]
dp[4] 恰好等于
d
p
[
1
]
+
d
p
[
2
]
+
d
p
[
3
]
dp[1] + dp[2] + dp[3]
dp[1]+dp[2]+dp[3] ,即等于在末尾加上了一个元素构成了该组合。
状态转移方程:
d
p
[
i
]
=
d
p
[
i
−
n
u
m
[
0
]
]
+
d
p
[
i
−
n
u
m
[
1
]
]
+
.
.
.
+
d
p
[
i
−
n
u
m
[
n
u
m
.
s
i
z
e
(
)
−
1
]
]
dp[i] = dp[i - num[0]] + dp[i - num[1]] + ... + dp[i - num[num.size() - 1]]
dp[i]=dp[i−num[0]]+dp[i−num[1]]+...+dp[i−num[num.size()−1]]。
class Solution {
public:
int res = 0;
int combinationSum4(vector<int>& nums, int target) {
// 总和为i的组合个数定义为dp[i]
vector<unsigned int> dp(target + 1, 0);
dp[0] = 1; // 构成空的组合数设为1
for (int i = 1; i <= target; i++) {
for (int num: nums) {
if (i - num < 0) continue;
dp[i] += dp[i - num];
}
}
return dp[target];
}
};
01背包系列
在01背包问题中,因为每种物品只有一个(或者最多只能选取一个),对于第 i i i 个物品只需要考虑选与不选两种情况。那么怎么判断选不选这个物品呢,就是看选了这个物品和不选这个物品背包的价值哪个大。
剑指 Offer II 101.分割等和子集 Easy 问题转化 01背包 2023/3/31
给定一个非空的正整数数组 nums ,请判断能否将这些数字分成元素和相等的两部分。
示例:
输入:nums = [1,5,11,5]
输出:true
解释:nums 可以分割成 [1, 5, 5] 和 [11] 。
问题转化:将数字分成元素和相等的两部分⇒找出部分和为总和一半的元素。
进行前提条件判断:数组元素长度>=2,数组和必须为偶数,最大数不能超过数组和一半。
类型:每个数只能用一次,为01背包问题, d p [ i ] [ j ] dp[i][j] dp[i][j] 既与要凑出的和相关,又与数组元素相关。
本题
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j] 定义为从nums中选取
i
i
i 个数能否组成
j
j
j。
要么不选:
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
]
dp[i][j] = dp[i - 1][j]
dp[i][j]=dp[i−1][j] ,要么选:
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
−
n
u
m
s
[
i
−
1
]
]
dp[i][j] = dp[i - 1][j - nums[i - 1]]
dp[i][j]=dp[i−1][j−nums[i−1]] 。
class Solution {
public:
bool canPartition(vector<int>& nums) {
int len = nums.size();
if (len == 1) return false; // 数组元素必须>=2
int sum = accumulate(nums.begin(), nums.end(), 0);
if (sum & 1) return false; // 数组和必须为偶数
int target = sum / 2;
int maxNum = *max_element(nums.begin(), nums.end());
if (maxNum > target) return false; // 最大的数不能超过数组和的一半
// 从数组中选一部分数等于target--01背包
// dp[i][j]表示从nums中选取i个数能否组成j (dp[len + 1][target + 1])
vector<vector<int>> dp(len + 1, vector<int>(target + 1));
// 初始化,不选数只能构成0
dp[0][0] = true;
for (int i = 1; i <= len; i++)
for (int j = 0; j <= target; j++) {
// 两种情况:不选nums[i - 1]
dp[i][j] = dp[i - 1][j];
// 选nums[i - 1],前提是不能超过j
if (j >= nums[i - 1]) dp[i][j] |= dp[i - 1][j - nums[i - 1]];
}
return dp[len][target];
}
};
剑指 Offer II 102.加减的目标值 Medium 问题转化 01背包 2023/3/31
给定一个正整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个 表达式 :
例如,nums = [2, 1] ,可以在 2 之前添加 ‘+’ ,在 1 之前添加 ‘-’ ,然后串联起来得到表达式 “+2-1” 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
示例:
输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3
回溯解法很基础,但很难剪枝,在此不赘述。
问题转化:首先目标值为
t
a
r
g
e
t
target
target 和
−
t
a
r
g
e
t
-target
−target 的表达式数目必定相同,将问题转化为目标值
a
b
s
(
t
a
r
g
e
t
)
abs(target)
abs(target) 。
将所有数之和记为
s
u
m
sum
sum,正数和1-正数和2
=
t
a
r
g
e
t
=target
=target,所以有
2
∗
2*
2∗ 正数和1
=
t
a
r
g
e
t
+
s
u
m
=target+sum
=target+sum。问题转化为找出部分元素等于
(
t
a
r
g
e
t
+
s
u
m
)
/
2
(target+sum)/2
(target+sum)/2 的数量。
如
[
1
,
2
,
3
,
5
]
[1,2,3,5]
[1,2,3,5]
t
a
r
g
e
t
=
5
target = 5
target=5
s
u
m
=
11
sum = 11
sum=11,找出部分元素等于
8
8
8 即可。至此,本题与上题几乎一模一样。
进行前提条件判断:数组和必须大于等于 t a r g e t target target, t a r g e t + s u m target+sum target+sum 必须为偶数。
类型:每个数只能用一次,为01背包问题, d p [ i ] [ j ] dp[i][j] dp[i][j] 既与要凑出的和相关,又与数组元素相关。
本题
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j] 定义为从nums的前
i
i
i 个元素中选出和为
j
j
j 的个数。
要么不选:
d
p
[
i
]
[
j
]
+
=
d
p
[
i
−
1
]
[
j
]
dp[i][j] += dp[i - 1][j]
dp[i][j]+=dp[i−1][j] ,要么选:
d
p
[
i
]
[
j
]
+
=
d
p
[
i
−
1
]
[
j
−
n
u
m
s
[
i
−
1
]
]
dp[i][j] += dp[i - 1][j - nums[i - 1]]
dp[i][j]+=dp[i−1][j−nums[i−1]] 。
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int len = nums.size();
int sum = accumulate(nums.begin(), nums.end(), 0);
if (sum < abs(target) || (sum + target) % 2 != 0) return 0;
sum = (sum + abs(target)) / 2;
// dp[i][j]表示从nums的前i个元素中选出和为j的个数
vector<vector<int>> dp(len + 1, vector<int>(sum + 1, 0)); // dp[len + 1][sum + 1]
dp[0][0] = 1; // 前0个元素只能选出0,有一种选法
for (int i = 1; i <= len; i++) {
for (int j = 0; j <= sum; j++) {
// 两种情况:不选nums[i - 1]
dp[i][j] += dp[i - 1][j];
// 选nums[i - 1],前提是不能超过j
if (j - nums[i - 1] >= 0) dp[i][j] += dp[i - 1][j - nums[i - 1]];
}
}
return dp[len][sum];
}
};
字符串系列
剑指 Offer II 20.回文子字符串的个数 Medium 子字符串 逆向遍历 2023/2/25
给定一个字符串 s ,请计算这个字符串中有多少个回文子字符串。
具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
题目数据保证答案符合 32 位整数范围。
示例:
输入:s = “abc”
输出:3
解释:三个回文子串: “a”, “b”, “c”
本题对于每一个字符从中间向两边扩展找回文字符串的方法很容易想到,这里介绍的是使用二维数组进行动态规划。
d p [ i ] [ j ] dp[i][j] dp[i][j] 表示s的第 i i i 个字符到第 j j j 个字符能否组成回文串。
遍历的顺序也很重要:
i
i
i 的遍历需要从末尾开始,递减到0。
j
j
j 的遍历需要从
i
i
i 开始,递增到末尾。
(如果
i
i
i 也从0开始递增会出现
d
p
[
i
+
1
]
[
j
−
1
]
dp[i + 1][j - 1]
dp[i+1][j−1] 索引无效的情况)
只有当 i i i 的字符等于 j j j 的字符 且 i i i 和 j j j 之间相差小于2或 d p [ i + 1 ] [ j − 1 ] dp[i + 1][j - 1] dp[i+1][j−1] 为true时,当前子字符串为回文串。
class Solution {
public:
int countSubstrings(string s) {
int len = s.size();
// dp[i][j]表示s的第i个字符到第j个字符能否组成回文串
vector<vector<bool>> dp(len, vector<bool>(len, false));
int res = 0;
// i从末尾递减到0
for (int i = len - 1; i >= 0; i--)
// j从i递增到末尾
for (int j = i; j < len; j++)
// dp[i][j]为true的条件
// 1. i字符与j字符相等
// 2. i和j之间相差小于2或dp[i + 1][j - 1]为true
if (s[i] == s[j] && (j - i <= 1 || dp[i + 1][j - 1])) {
dp[i][j] = true;
res++;
}
return res;
}
};
剑指 Offer II 095.最长公共子序列 Medium 公共子序列 双字符串二维 d p dp dp 2023/3/26
给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,“ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
示例:
输入:text1 = “abcde”, text2 = “ace”
输出:3
解释:最长公共子序列是 “ace” ,它的长度为 3 。
公共子序列⇒存在递推关系⇒动态规划
非常难的动态规划问题,假设字符串 text1和 text2的长度分别为m和n,创建m+1行n+1列的二维数组dp,其中 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示 t e x t 1 text1 text1 前 i i i 个字符和 t e x t 2 text2 text2 前 j j j 个字符 的最长公共子序列的长度。从1遍历到 m m m 和 n n n 。递推关系为:如果这两个字符相同,则比 d p [ i − 1 ] [ j − 1 ] dp[i - 1][j - 1] dp[i−1][j−1] 多1,否则比较 d p [ i − 1 ] [ j ] dp[i - 1][j] dp[i−1][j] 和 d p [ i ] [ j − 1 ] dp[i][j - 1] dp[i][j−1] 的大小,需要反复推敲理解!
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.length(), n = text2.length();
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
for (int i = 1; i <= m; i++) {
char c1 = text1[i - 1];
for (int j = 1; j <= n; j++) {
char c2 = text2[j - 1];
if (c1 == c2) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[m][n];
}
};
剑指 Offer II 096.字符串交织 Medium 字符串组合 双字符串二维 d p dp dp 2023/3/31
给定三个字符串 s1、s2、s3,请判断 s3 能不能由 s1 和 s2 交织(交错) 组成。
两个字符串 s 和 t 交织 的定义与过程如下,其中每个字符串都会被分割成若干 非空 子字符串:
s = s1 + s2 + … + sn
t = t1 + t2 + … + tm
|n - m| <= 1
交织 是 s1 + t1 + s2 + t2 + s3 + t3 + … 或者 t1 + s1 + t2 + s2 + t3 + s3 + …
提示:a + b 意味着字符串 a 和 b 连接。
示例:
输入:s1 = “aabcc”, s2 = “dbbca”, s3 = “aadbbcbcac”
输出:true
与上题类似, d p [ i ] [ j ] dp[i][j] dp[i][j] 数组定义为 s 1 s1 s1 的前 i i i 个字符和 s 2 s2 s2 的前 j j j 个字符是否能够交织成 s 3 s3 s3 的前 i + j i+j i+j 个字符,递推关系较为简单,画表可推。
class Solution {
public:
bool isInterleave(string s1, string s2, string s3) {
int len1 = s1.size(), len2 = s2.size(), len3 = s3.size();
if (s1.size() + s2.size() != s3.size()) return false;
// dp[i][j]含义是s1的前i个字符和s2的前j个字符是否能够交织成s3的前i+j个字符
bool dp[len1 + 1][len2 + 1];
dp[0][0] = true;
// 初始化
for (int i = 1; i <= len1; i++) dp[i][0] = (s1.substr(0, i) == s3.substr(0, i));
for (int j = 1; j <= len2; j++) dp[0][j] = (s2.substr(0, j) == s3.substr(0, j));
// 递推关系
for (int i = 1; i <= len1; i++)
for (int j = 1; j <= len2; j++)
dp[i][j] = (s1[i - 1] == s3[i + j - 1] && dp[i - 1][j]) ||
(s2[j - 1] == s3[i + j - 1] && dp[i][j - 1]);
return dp[len1][len2];
}
};
剑指 Offer II 094.最少回文分割 Medium 单字符串 回文 2023/3/31
给定一个字符串 s,请将 s 分割成一些子串,使每个子串都是回文串。
返回符合要求的 最少分割次数 。
示例:
输入:s = “aab”
输出:1
解释:只需一次分割就可将 s 分割成 [“aa”,“b”] 这样两个回文子串。
剑指 Offer II 086. 分割回文子字符串讲的是要列举出分割的所有情况,用回溯算法,这题是要找所有情况中分割次数最少的,难就难在看出是个动态规划问题,看出是动态规划后,定义 d p [ i ] dp[i] dp[i] 为 [ s [ 0 ] , s [ 1 ] , . . . s [ i ] ] [s[0], s[1], ... s[i]] [s[0],s[1],...s[i]] 最少分割次数,并在每次遍历 j j j 从 0 0 0 到 i − 1 i-1 i−1,看看是否有更短的分割次数。
本题还有一个难点是要预先计算是否是回文串,否则在计算 d p dp dp 数组时再算时间复杂度会超!
class Solution {
public:
int minCut(string s) {
int len = s.size();
vector<vector<int>> g(len, vector<int>(len, true));
for (int i = len - 1; i >= 0; --i)
for (int j = i + 1; j < len; ++j)
g[i][j] = (s[i] == s[j]) && g[i + 1][j - 1];
// dp[i]表示[s[0], s[1], ... s[i]]最少分割次数
int dp[len];
dp[0] = 0; // 一个字符本身就是
for (int i = 1; i < len; i++) {
dp[i] = dp[i - 1] + 1;
for (int j = 0; j < i; j++) {
// 是回文
if (g[j][i]) {
if (j == 0) dp[i] = 0;
else dp[i] = min(dp[i], dp[j - 1] + 1); // 更新
}
}
}
return dp[len - 1];
}
};
其他问题
剑指 Offer II 093.最长斐波那契数列 Medium 二维动态规划 2023/3/26
如果序列 X_1, X_2, …, X_n 满足下列条件,就说它是 斐波那契式 的:
n >= 3
对于所有 i + 2 <= n,都有 X_i + X_{i+1} = X_{i+2}
给定一个严格递增的正整数数组形成序列 arr ,找到 arr 中最长的斐波那契式的子序列的长度。如果一个不存在,返回 0 。
(回想一下,子序列是从原序列 arr 中派生出来的,它从 arr 中删掉任意数量的元素(也可以不删),而不改变其余元素的顺序。例如, [3, 5, 8] 是 [3, 4, 5, 6, 7, 8] 的一个子序列)
示例:
输入: arr = [1,3,7,11,12,14,18]
输出: 3
解释: 最长的斐波那契式子序列有 [1,11,12]、[3,11,14] 以及 [7,11,18] 。
本题动态规划dp数组的含义非常难想,肯定是二维, d p [ i ] [ j ] dp[i][j] dp[i][j] 定义为以 a r r [ i ] arr[i] arr[i] 和 a r r [ j ] arr[j] arr[j] 作为斐波那契式子序列最后两个数的子序列长度,为什么不是以 a r r [ i ] arr[i] arr[i] 和 a r r [ j ] arr[j] arr[j] 为前两个数的长度呢?因为要从前到后递推。配合哈希表可以构建状态转移方程。
class Solution {
public:
int lenLongestFibSubseq(vector<int>& arr) {
int len = arr.size();
int maxLen = 0;
unordered_map<int, int> mp;
for (int i = 0; i < arr.size(); i++)
mp[arr[i]] = i;
int dp[len][len];
for (int j = 1; j < arr.size(); j++) dp[0][j] = 0;
for (int i = 1; i < arr.size(); i++) {
for (int j = i + 1; j < arr.size(); j++) {
int diff = arr[j] - arr[i];
if (diff < arr[i] && mp.count(diff)) {
dp[i][j] = dp[mp[diff]][i] + 1;
maxLen = max(maxLen, dp[i][j]);
}
else dp[i][j] = 0;
}
}
return maxLen == 0 ? 0 : maxLen + 2;
}
};
剑指 Offer II 091.粉刷房子 Medium 必选型 2023/3/26
假如有一排房子,共 n 个,每个房子可以被粉刷成红色、蓝色或者绿色这三种颜色中的一种,你需要粉刷所有的房子并且使其相邻的两个房子颜色不能相同。
当然,因为市场上不同颜色油漆的价格不同,所以房子粉刷成不同颜色的花费成本也是不同的。每个房子粉刷成不同颜色的花费是以一个 n x 3 的正整数矩阵 costs 来表示的。
例如,costs[0][0] 表示第 0 号房子粉刷成红色的成本花费;costs[1][2] 表示第 1 号房子粉刷成绿色的花费,以此类推。
请计算出粉刷完所有房子最少的花费成本。
示例:
输入: costs = [[17,2,17],[16,16,5],[14,3,19]]
输出: 10
解释: 将 0 号房子粉刷成蓝色,1 号房子粉刷成绿色,2 号房子粉刷成蓝色。
最少花费: 2 + 5 + 3 = 10。
本题对比打家劫舍,每个元素都是必选,在记录dp数组的同时需要记录上一个元素的选择(如果上一个房子粉刷成红色,则下一个房子不能刷成红色),则建立一个n*3的dp数组,分别表示粉刷i排j房需要的成本,递推关系很简单。
class Solution {
public:
int minCost(vector<vector<int>>& costs) {
int num = costs.size();
int dp[num][3]; // 粉刷i排j房需要的成本
for (int j = 0; j < 3; j++) dp[0][j] = costs[0][j];
for (int i = 1; i < num; i++) {
dp[i][0] = min(dp[i - 1][1], dp[i - 1][2]) + costs[i][0];
dp[i][1] = min(dp[i - 1][0], dp[i - 1][2]) + costs[i][1];
dp[i][2] = min(dp[i - 1][0], dp[i - 1][1]) + costs[i][2];
}
return min(min(dp[num - 1][0], dp[num - 1][1]), dp[num - 1][2]);
}
};
剑指 Offer II 092.翻转字符 Medium 必选型 2023/3/26
如果一个由 ‘0’ 和 ‘1’ 组成的字符串,是以一些 ‘0’(可能没有 ‘0’)后面跟着一些 ‘1’(也可能没有 ‘1’)的形式组成的,那么该字符串是 单调递增 的。
我们给出一个由字符 ‘0’ 和 ‘1’ 组成的字符串 s,我们可以将任何 ‘0’ 翻转为 ‘1’ 或者将 ‘1’ 翻转为 ‘0’。
返回使 s 单调递增 的最小翻转次数。
示例:
输入:s = “010110”
输出:2
解释:我们翻转得到 011111,或者是 000111。
类似上题,定义dp数组为"翻转后第i项为0所需要的翻转次数”和“翻转后第i项为1所需要的翻转次数”,列出状态转移方程即可。
class Solution {
public:
int minFlipsMonoIncr(string s) {
int len = s.size();
int dp[len][2];
dp[0][0] = s[0] - '0';
dp[0][1] = 1 - dp[0][0];
for (int i = 1; i < len; i++) {
dp[i][0] = dp[i - 1][0] + s[i] - '0';
dp[i][1] = min(dp[i - 1][0], dp[i - 1][1]) + 1 - (s[i] - '0');
}
return min(dp[len - 1][0], dp[len - 1][1]);
}
};
打家劫舍系列
198. 打家劫舍 Medium 经典动态规划 2023/2/6
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
示例:
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
类型:逐步扩大数组窗口类
定义dp数组, d p [ i ] dp[i] dp[i]含义是抢第i家的最大金额,第i家要么抢(i-2家也抢),要么不抢(抢i-1家)。
class Solution {
public:
int rob(vector<int>& nums) {
int len = nums.size();
if (len == 1) return nums[0];
// dp[i]含义是抢第i家的最大金额
int dp[len];
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
for (int i = 2; i < len; i++)
// 第i家要么抢(i-2家也抢),要么不抢(抢i-1家)
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
return dp[len - 1];
}
};
可以优化空间复杂度到 O ( 1 ) O(1) O(1),即滚动式dp。
class Solution {
public:
int rob(vector<int>& nums) {
int len = nums.size();
if (len == 1) return nums[0];
// dp[i]含义是抢第i家的最大金额
int dp0 = nums[0];
int dp1 = max(nums[0], nums[1]);
int dp;
for (int i = 2; i < len; i++){
// 第i家要么抢(i-2家也抢),要么不抢(抢i-1家)
dp = max(dp0 + nums[i], dp1);
dp0 = dp1;
dp1 = dp;
}
return dp1;
}
};
213. 打家劫舍 II Medium 经典动态规划 2023/2/6
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
示例:
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
类型:逐步扩大数组窗口类+分解问题
本题与上题唯一的区别在于头尾相连,因此可以分别讨论:第1家要么不抢,从2遍历到n-1家,第1家要么抢,从1遍历到n-2家,并带上下标进行遍历。
class Solution {
public:
int rob(vector<int>& nums) {
int len = nums.size();
if (len == 1) return nums[0];
// 第1家要么不抢,从2遍历到n-1家,第1家要么抢,从1遍历到n-2家
return max(robRange(nums, 0, len - 2), robRange(nums, 1, len - 1));
}
// 抢start, start+1, ..., end家
int robRange(vector<int>& nums, int start, int end){
int len = end - start + 1;
if (len == 1) return nums[start];
// 压缩空间到O(1)
int dp0 = nums[start];
int dp1 = max(nums[start], nums[start + 1]);
int dp;
for (int i = 2; i < len; i++){
dp = max(dp0 + nums[start + i], dp1);
dp0 = dp1;
dp1 = dp;
}
return dp1;
}
};
337. 打家劫舍 III Medium 树型动态规划 2023/2/6
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root 。
除了 root 之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。
给定二叉树的 root 。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。
示例:
输入: root = [3,2,3,null,3,null,1]
输出: 7
解释: 小偷一晚能够盗取的最高金额 3 + 3 + 1 = 7
类型:树型动态规划
本题难点在于定义合适dp数组。
我们可以用
f
(
o
)
f(o)
f(o) 表示选择
o
o
o 节点的情况下,
o
o
o 节点的子树上被选择的节点的最大权值和;
g
(
o
)
g(o)
g(o) 表示不选择
o
o
o 节点的情况下,
o
o
o 节点的子树上被选择的节点的最大权值和;
l
l
l 和
r
r
r 代表
o
o
o 的左右孩子。
- 当 o o o 被选中时, o o o 的左右孩子都不能被选中,故 o o o 被选中情况下子树上被选中点的最大权值和为 l l l 和 r r r 不被选中的最大权值和相加,即 f ( o ) = g ( l ) + g ( r ) f(o) = g(l) + g(r) f(o)=g(l)+g(r)。
- 当 o o o 不被选中时, o o o 的左右孩子可以被选中,也可以不被选中。对于 o o o 的某个具体的孩子,它对 o o o 的贡献是 被选中和不被选中情况下权值和的较大值。故 g ( o ) = m a x ( f ( l ) , g ( l ) ) + m a x ( f ( r ) , g ( r ) ) g(o) = max(f(l), g(l)) + max(f(r), g(r)) g(o)=max(f(l),g(l))+max(f(r),g(r))。
class Solution {
public:
// f表示该结点被选中时的最大和,g表示该结点不被选中的最大和
unordered_map<TreeNode*, int> f, g;
int rob(TreeNode* root) {
dfs(root);
return max(f[root], g[root]);
}
void dfs(TreeNode* node) {
if (!node) return;
dfs(node->left);
dfs(node->right);
// 后序遍历位置
// 该结点被选中,子节点都不能选
f[node] = node->val + g[node->left] + g[node->right];
// 该结点不被选中,子结点都可以选
g[node] = max(f[node->left], g[node->left]) + max(f[node->right], g[node->right]);
}
};
另一种思路是分解问题+备忘录消除重叠子问题。
递归时,分别计算该结点被选中和不被选中时的和,并进行比较。但其递归较乱,容易出错。
class Solution {
public:
// 记录已访问结点的信息
unordered_map<TreeNode *, int> sums;
int tryrob(TreeNode* root) {
if (root == nullptr) return 0;
// 备忘录里有就用备忘录的
if (sums.count(root)) return sums[root];
int cur_price = root->val;
int next_price = 0;
// 有左结点
if (root->left != nullptr) {
cur_price += (rob(root->left->left) + rob(root->left->right));
next_price += rob(root->left);
}
// 有右节点
if (root->right != nullptr) {
cur_price += (rob(root->right->left) + rob(root->right->right));
next_price += rob(root->right);
}
sums[root] = max(cur_price, next_price);
return sums[root];
}
int rob(TreeNode* root) {
return tryrob(root);
}
};