动态规划专题
5.最长回文子串
题目描述
给你一个字符串 s,找到 s 中最长的回文子串。
如果字符串的反序与原始字符串相同,则该字符串称为回文字符串。
-
示例 1:
- 输入:s = “babad”
- 输出:“bab”
- 解释:“aba” 同样是符合题意的答案。 示例 2:
- 输入:s = “cbbd”
- 输出:“bb”
解析
马拉车算法(Manacher’s Algorithm)是一种用于寻找最长回文子串的线性时间算法,核心思想是通过利用已知回文子串的对称性质,避免重复计算,从而在线性时间内找到最大回文子串。
-
算法的主要步骤:
-
预处理字符串: 在字符串的每个字符之间和两端都插入特殊字符(通常是不在字符串中的字符,比如#),以统一处理奇数和偶数长度的回文子串。这样处理后,字符串的长度将变为奇数。
-
中心扩展: 从左到右遍历字符串,以每个字符为中心进行扩展,寻找以该字符为中心的最长回文子串。由于插入了特殊字符,回文子串的长度可能是奇数或偶数,这需要分别处理。
-
维护回文子串的信息: 在扩展的同时,维护一个关于已知回文子串的一些信息,如最右边界(回文串的最右字符的位置)、最右边界对应的中心、已知的最长回文子串的长度等。
-
利用对称性质: 利用已知回文子串的对称性,避免重复计算。当一个位置的字符对应的另一侧存在对称位置的回文子串时,可以直接利用已知信息进行快速扩展。
-
寻找最长回文子串: 在整个遍历过程中,维护最长回文子串的信息,得到最长回文子串的起始位置和长度。
代码
class Solution {
public:
int expand( string s, int left, int right) {
while (left >= 0 && right < s.size() && s[left] == s[right]) {
--left;
++right;
}
return (right - left - 2) / 2;
}
string longestPalindrome(string s) {
int start = 0, end = -1;
string t = "#";
for (char c: s) {
t += c;
t += '#';
}
t += '#';
s = t;
vector<int> arm_len;
int right = -1, j = -1;
for (int i = 0; i < s.size(); ++i) {
int cur_arm_len;
if (right >= i) {
int i_sym = j * 2 - i;
int min_arm_len = min(arm_len[i_sym], right - i);
cur_arm_len = expand(s, i - min_arm_len, i + min_arm_len);
} else {
cur_arm_len = expand(s, i, i);
}
arm_len.push_back(cur_arm_len);
if (i + cur_arm_len > right) {
j = i;
right = i + cur_arm_len;
}
if (cur_arm_len * 2 + 1 > end - start) {
start = i - cur_arm_len;
end = i + cur_arm_len;
}
}
string ans;
for (int i = start; i <= end; ++i) {
if (s[i] != '#') {
ans += s[i];
}
}
return ans;
}
};
22.括号生成
题目描述
数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
-
示例 1:
- 输入:n = 3
- 输出:[“((()))”,“(()())”,“(())()”,“()(())”,“()()()”] 示例 2:
- 输入:n = 1
- 输出:[“()”] 提示:
- 1 <= n <= 8
解析
任何一个括号序列都一定是由 ‘(’ 开头,并且第一个 ‘(’ 一定有一个唯一与之对应的 ‘)’。这样一来,每一个括号序列可以用(a)b来表示,其中a与 bbb 分别是一个合法的括号序列(可以为空)。
那么,要生成所有长度为2n的括号序列,我们定义一个函数generate(n)来返回所有可能的括号序列。
-
在函数generate(n)的过程中:
- 我们需要枚举与第一个 ‘(’ 对应的‘)’ 的位置2i+1;
- 递归调用generate(i) 即可计算a的所有可能性;
- 递归调用generate(n−i−1)即可计算b的所有可能性;
- 遍历a与b的所有可能性并拼接,即可得到所有长度为2n的括号序列。
为了节省计算时间,我们在每次generate(i)函数返回之前,把返回值存储起来,下次再调用generate(i)时可以直接返回,不需要再递归计算。
代码
class Solution {
shared_ptr<vector<string>> cache[100] = {nullptr};
public:
shared_ptr<vector<string>> generate(int n) {
if (cache[n] != nullptr)
return cache[n];
if (n == 0) {
cache[0] = shared_ptr<vector<string>>(new vector<string>{""});
} else {
auto result = shared_ptr<vector<string>>(new vector<string>);
for (int i = 0; i != n; ++i) {
auto lefts = generate(i);
auto rights = generate(n - i - 1);
for (const string& left : *lefts)
for (const string& right : *rights)
result -> push_back("(" + left + ")" + right);
}
cache[n] = result;
}
return cache[n];
}
vector<string> generateParenthesis(int n) {
return *generate(n);
}
};
64.最小路径和
题目描述
给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
-
示例 1:
- 输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
- 输出:7
- 解释:因为路径 1→3→1→1→1 的总和最小。 示例 2:
- 输入:grid = [[1,2,3],[4,5,6]]
- 输出:12
提示:
m == grid.length
n == grid[i].length
1 <= m, n <= 200
0 <= grid[i][j] <= 200
解析
由于路径的方向只能是向下或向右,因此网格的第一行的每个元素只能从左上角元素开始向右移动到达,网格的第一列的每个元素只能从左上角元素开始向下移动到达,此时的路径是唯一的,因此每个元素对应的最小路径和即为对应的路径上的数字总和。
对于不在第一行和第一列的元素,可以从其上方相邻元素向下移动一步到达,或者从其左方相邻元素向右移动一步到达,元素对应的最小路径和等于其上方相邻元素与其左方相邻元素两者对应的最小路径和中的最小值加上当前元素的值。由于每个元素对应的最小路径和与其相邻元素对应的最小路径和有关,因此可以使用动态规划求解。
创建二维数组dp,与原始网格的大小相同,dp[i][j] 表示从左上角出发到(i,j)位置的最小路径和。显然,dp[0][0]=grid[0][0]。对于dp中的其余元素,通过以下状态转移方程计算元素值。
当 i>0 且 j=0 时,dp[i][0]=dp[i−1][0]+grid[i][0]。
当 i=0 且 j>0 时,dp[0][j]=dp[0][j−1]+grid[0][j]。
当 i>0 且 j>0 时,dp[i][j]=min(dp[i−1][j],dp[i][j−1])+grid[i][j]。
最后得到dp[m−1][n−1] 的值即为从网格左上角到网格右下角的最小路径和。
代码
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
if (grid.size() == 0 || grid[0].size() == 0) {
return 0;
}
int rows = grid.size(), columns = grid[0].size();
auto dp = vector < vector <int> > (rows, vector <int> (columns));
dp[0][0] = grid[0][0];
for (int i = 1; i < rows; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
for (int j = 1; j < columns; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
for (int i = 1; i < rows; i++) {
for (int j = 1; j < columns; j++) {
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[rows - 1][columns - 1];
}
};
95.不同的二叉搜索树 II
题目描述
给你一个整数 n ,请你生成并返回所有由 n 个节点组成且节点值从 1 到 n 互不相同的不同 二叉搜索树 。可以按 任意顺序 返回答案。
-
示例 1:
- 输入:n = 3
- 输出:[[1,null,2,null,3],[1,null,3,2],[2,1,3],[3,1,null,null,2],[3,2,null,1]] 示例 2:
- 输入:n = 1
- 输出:[[1]]
提示:
1 <= n <= 8
解析
二叉搜索树关键的性质是根节点的值大于左子树所有节点的值,小于右子树所有节点的值,且左子树和右子树也同样为二叉搜索树。因此在生成所有可行的二叉搜索树的时候,假设当前序列长度为 n,如果我们枚举根节点的值为 i,那么根据二叉搜索树的性质我们可以知道左子树的节点值的集合为 [1…i−1],右子树的节点值的集合为 [i+1…n]。而左子树和右子树的生成相较于原问题是一个序列长度缩小的子问题,因此我们可以想到用回溯的方法来解决这道题目。
我们定义 generateTrees(start, end) 函数表示当前值的集合为 [start,end],返回序列 [start,end] 生成的所有可行的二叉搜索树。按照上文的思路,我们考虑枚举 [start,end] 中的值 i 为当前二叉搜索树的根,那么序列划分为了 [start,i−1] 和 [i+1,end] 两部分。我们递归调用这两部分,即 generateTrees(start, i - 1) 和 generateTrees(i + 1, end),获得所有可行的左子树和可行的右子树,那么最后一步我们只要从可行左子树集合中选一棵,再从可行右子树集合中选一棵拼接到根节点上,并将生成的二叉搜索树放入答案数组即可。
递归的入口即为 generateTrees(1, n),出口为当 start>end 的时候,当前二叉搜索树为空,返回空节点即可。
代码
class Solution {
public:
vector<TreeNode*> generateTrees(int start, int end) {
if (start > end) {
return { nullptr };
}
vector<TreeNode*> allTrees;
// 枚举可行根节点
for (int i = start; i <= end; i++) {
// 获得所有可行的左子树集合
vector<TreeNode*> leftTrees = generateTrees(start, i - 1);
// 获得所有可行的右子树集合
vector<TreeNode*> rightTrees = generateTrees(i + 1, end);
// 从左子树集合中选出一棵左子树,从右子树集合中选出一棵右子树,拼接到根节点上
for (auto& left : leftTrees) {
for (auto& right : rightTrees) {
TreeNode* currTree = new TreeNode(i);
currTree->left = left;
currTree->right = right;
allTrees.emplace_back(currTree);
}
}
}
return allTrees;
}
vector<TreeNode*> generateTrees(int n) {
if (!n) {
return {};
}
return generateTrees(1, n);
}
};
96.不同的二叉搜索树 I
题目描述
给你一个整数 n ,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
-
示例 1:
- 输入:n = 3
- 输出:5 示例 2:
- 输入:n = 1
- 输出:1
提示:
1 <= n <= 19
解析
给定一个有序序列1⋯n,为了构建出一棵二叉搜索树,我们可以遍历每个数字i,将该数字作为树根,将 1⋯(i−1) 序列作为左子树,将 (i+1)⋯n 序列作为右子树。接着我们可以按照同样的方式递归构建左子树和右子树。
在上述构建的过程中,由于根的值不同,因此我们能保证每棵二叉搜索树是唯一的。
由此可见,原问题可以分解成规模较小的两个子问题,且子问题的解可以复用。因此,我们可以想到使用动态规划来求解本题。
代码
class Solution {
public:
int numTrees(int n) {
vector<int> G(n + 1, 0);
G[0] = 1;
G[1] = 1;
for (int i = 2; i <= n; ++i) {
for (int j = 1; j <= i; ++j) {
G[i] += G[j - 1] * G[i - j];
}
}
return G[n];
}
};
97.交错字符串
题目描述
给定三个字符串 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 连接。
-
示例 1:
- 输入:s1 = “aabcc”, s2 = “dbbca”, s3 = “aadbbcbcac”
- 输出:true 示例 2:
- 输入:s1 = “aabcc”, s2 = “dbbca”, s3 = “aadbbbaccc”
- 输出:false 示例 3:
- 输入:s1 = “”, s2 = “”, s3 = “”
- 输出:true
提示:
0 <= s1.length, s2.length <= 100
0 <= s3.length <= 200
s1、s2、和 s3 都由小写英文字母组成
解析
首先如果 ∣s1∣+∣s2∣≠∣s3∣,那 s3 必然不可能由s1 和 s2 交错组成。在 ∣s1∣+∣s2∣=∣s3∣ 时,我们可以用动态规划来求解。我们定义 f(i,j) 表示 s1 的前 i 个元素和 s2 的前 j 个元素是否能交错组成s3 的前 i+j 个元素。如果 s1 的第 i 个元素和 s3 的第 i+j 个元素相等,那么 s1 的前 i 个元素和 s2 的前 j 个元素是否能交错组成 s3 的前 i+j 个元素取决于 s1 的前 i−1 个元素和 s2 的前 j 个元素是否能交错组成 s3 的前 i+j−1 个元素,即此时 f(i,j) 取决于 f(i−1,j),在此情况下如果 f(i−1,j) 为真,则 、f(i,j) 也为真。同样的,如果 、s2 的第 j 个元素和 s3 的第、i+j 个元素相等并且 f(i,j−1) 为真,则 f(i,j) 也为真。于是我们可以推导出这样的动态规划转移方程:
f(i,j)=[f(i−1,j)ands1(i−1)=s3]or[f(i,j−1)ands2(j−1)=s3]
其中 p=i+j−1。边界条件为f(0,0)=True。
代码
class Solution {
public:
bool isInterleave(string s1, string s2, string s3) {
auto f = vector < vector <int> > (s1.size() + 1, vector <int> (s2.size() + 1, false));
int n = s1.size(), m = s2.size(), t = s3.size();
if (n + m != t) {
return false;
}
f[0][0] = true;
for (int i = 0; i <= n; ++i) {
for (int j = 0; j <= m; ++j) {
int p = i + j - 1;
if (i > 0) {
f[i][j] |= (f[i - 1][j] && s1[i - 1] == s3[p]);
}
if (j > 0) {
f[i][j] |= (f[i][j - 1] && s2[j - 1] == s3[p]);
}
}
}
return f[n][m];
}
};
120.三角形最小路径和
题目描述
给定一个三角形 triangle ,找出自顶向下的最小路径和。
每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到下一行的下标 i 或 i + 1 。
-
示例 1:
- 输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
- 输出:11
- 解释:如下面简图所示:
- 2
- 3 4
- 6 5 7
- 4 1 8 3
- 自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。 示例 2:
- 输入:triangle = [[-10]]
- 输出:-10
提示:
1 <= triangle.length <= 200
triangle[0].length == 1
triangle[i].length == triangle[i - 1].length + 1
-104 <= triangle[i][j] <= 104
解析
在本题中,给定的三角形的行数为 n,并且第 i 行(从 0 开始编号)包含了 i+1 个数。如果将每一行的左端对齐,那么会形成一个等腰直角三角形
-
如下所示:
- [2]
- [3,4]
- [6,5,7]
- [4,1,8,3]
我们用 f[i][j] 表示从三角形顶部走到位置 (i,j) 的最小路径和。这里的位置 (i,j) 指的是三角形中第 i 行第 j 列(均从 0 开始编号)的位置。
由于每一步只能移动到下一行「相邻的节点」上,因此要想走到位置 (i,j),上一步就只能在位置 (i−1,j−1) 或者位置 (i−1,j)。我们在这两个位置中选择一个路径和较小的来进行转移,状态转移方程为:
f[i][j]=min(f[i−1][j−1],f[i−1][j])+c[i][j]
其中 c[i][j] 表示位置 (i,j) 对应的元素值。
注意第 i 行有 i+1 个元素,它们对应的 jjj 的范围为 [0,i]。当 j=0 或 j=i 时,上述状态转移方程中有一些项是没有意义的。例如当 j=0 时,f[i−1][j−1] 没有意义,因此状态转移方程为:
f[i][0]=f[i−1][0]+c[i][0]
即当我们在第 i 行的最左侧时,我们只能从第 i−1 行的最左侧移动过来。当 j=i 时,f[i−1][j] 没有意义,因此状态转移方程为:
f[i][i]=f[i−1][i−1]+c[i][i]
即当我们在第 i 行的最右侧时,我们只能从第 i−1 行的最右侧移动过来。
最终的答案即为 f[n−1][0] 到 f[n−1][n−1] 中的最小值,其中 n 是三角形的行数。
状态转移方程的边界条件是什么?由于我们已经去除了所有「没有意义」的状态,因此边界条件可以定为:
f[0][0]=c[0][0]
即在三角形的顶部时,最小路径和就等于对应位置的元素值。这样一来,我们从1开始递增地枚举i,并在 [0,i] 的范围内递增地枚举 j,就可以完成所有状态的计算。
代码
class Solution {
public:
int minimumTotal(vector<vector<int>>& triangle) {
int n = triangle.size();
vector<vector<int>> f(n, vector<int>(n));
f[0][0] = triangle[0][0];
for (int i = 1; i < n; ++i) {
f[i][0] = f[i - 1][0] + triangle[i][0];
for (int j = 1; j < i; ++j) {
f[i][j] = min(f[i - 1][j - 1], f[i - 1][j]) + triangle[i][j];
}
f[i][i] = f[i - 1][i - 1] + triangle[i][i];
}
return *min_element(f[n - 1].begin(), f[n - 1].end());
}
};
121.买卖股票的最佳时机 I
题目描述
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
-
示例 1:
- 输入:[7,1,5,3,6,4]
- 输出:5
- 解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
- 注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。 示例 2:
- 输入:prices = [7,6,4,3,1]
- 输出:0
- 解释:在这种情况下, 没有交易完成, 所以最大利润为 0。 提示:
- 1 <= prices.length <= 105
- 0 <= prices[i] <= 104
解析
我们来假设自己来购买股票。随着时间的推移,每天我们都可以选择出售股票与否。那么,假设在第 i 天,如果我们要在今天卖股票,那么我们能赚多少钱呢?
显然,如果我们真的在买卖股票,我们肯定会想:如果我是在历史最低点买的股票就好了!太好了,在题目中,我们只要用一个变量记录一个历史最低价格 minprice,我们就可以假设自己的股票是在那天买的。那么我们在第 i 天卖出股票能得到的利润就是 prices[i] - minprice。
因此,我们只需要遍历价格数组一遍,记录历史最低点,然后在每一天考虑这么一个问题:如果我是在历史最低点买进的,那么我今天卖出能赚多少钱?当考虑完所有天数之时,我们就得到了最好的答案。
代码
class Solution {
public:
int maxProfit(vector<int>& prices) {
int inf = 1e9;
int minprice = inf, maxprofit = 0;
for (int price: prices) {
maxprofit = max(maxprofit, price - minprice);
minprice = min(price, minprice);
}
return maxprofit;
}
};
122.买卖股票的最佳时机 II
题目描述
给你一个整数数组prices,其中prices[i]表示某支股票第 i 天的价格。
在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。
返回你能获得的 最大 利润 。
-
示例 1:
- 输入:prices = [7,1,5,3,6,4]
- 输出:7
- 解释:在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5 - 1 = 4 。
- 随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6 - 3 = 3 。
- 总利润为 4 + 3 = 7 。 示例 2:
- 输入:prices = [1,2,3,4,5]
- 输出:4
- 解释:在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5 - 1 = 4 。
- 总利润为 4 。 示例 3:
- 输入:prices = [7,6,4,3,1]
- 输出:0
- 解释:在这种情况下, 交易无法获得正利润,所以不参与交易可以获得最大利润,最大利润为 0 。 提示:
- 1 <= prices.length <= 3 * 104
- 0 <= prices[i] <= 104
解析
考虑到「不能同时参与多笔交易」,因此每天交易结束后只可能存在手里有一支股票或者没有股票的状态。
定义状态dp[i][0]表示第 i天交易完后手里没有股票的最大利润,dp[i][1]表示第i天交易完后手里持有一支股票的最大利润(i从0开始)。
考虑dp[i][0] 的转移方程,如果这一天交易完后手里没有股票,那么可能的转移状态为前一天已经没有股票,即dp[i−1][0],或者前一天结束的时候手里持有一支股票,即dp[i−1][1],这时候我们要将其卖出,并获得prices[i]的收益。
我们列出如下的转移方程:
dp[i][0]=max{dp[i−1][0],dp[i−1][1]+prices[i]}
再来考虑dp[i][1],按照同样的方式考虑转移状态,那么可能的转移状态为前一天已经持有一支股票,即dp[i−1][1],或者前一天结束时还没有股票,即dp[i−1][0],这时候我们要将其买入,并减少prices[i]的收益。可以列出如下的转移方程:
dp[i][1]=max{dp[i−1][1],dp[i−1][0]−prices[i]}
对于初始状态,根据状态定义我们可以知道第0天交易结束的时候dp[0][0]=0,dp[0][1]=−prices[0]。
因此,我们只要从前往后依次计算状态即可。由于全部交易结束后,持有股票的收益一定低于不持有股票的收益,因此这时候dp[n−1][0] 的收益必然是大于dp[n−1][1]的,最后的答案即为dp[n−1][0]。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
int dp[n][2];
dp[0][0] = 0, dp[0][1] = -prices[0];
for (int i = 1; i < n; ++i) {
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
}
return dp[n - 1][0];
}
};
每一天的状态只与前一天的状态有关,而与更早的状态都无关,因此我们不必存储这些无关的状态,只需要将dp[i−1][0] 和dp[i−1][1] 存放在两个变量中,通过它们计算出dp[i][0]和dp[i][1] 并存回对应的变量,以便于第i+1天的状态转移即可。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
int dp0 = 0, dp1 = -prices[0];
for (int i = 1; i < n; ++i) {
int newDp0 = max(dp0, dp1 + prices[i]);
int newDp1 = max(dp1, dp0 - prices[i]);
dp0 = newDp0;
dp1 = newDp1;
}
return dp0;
}
};
代码
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
int dp0 = 0, dp1 = -prices[0];
for (int i = 1; i < n; ++i) {
int newDp0 = max(dp0, dp1 + prices[i]);
int newDp1 = max(dp1, dp0 - prices[i]);
dp0 = newDp0;
dp1 = newDp1;
}
return dp0;
}
};
152.乘积最大子数组
题目描述
给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
测试用例的答案是一个 32位整数。
子数组 是数组的连续子序列。
示例 1:
输入: nums = [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
示例 2:
输入: nums = [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
提示:
1 <= nums.length <= 2 * 104
-10 <= nums[i] <= 10
nums 的任何前缀或后缀的乘积都 保证 是一个 32-位 整数
解析
如果我们用 fmax(i) 来表示以第 i 个元素结尾的乘积最大子数组的乘积,a 表示输入参数 nums,那么很容易推导出这样的状态转移方程:
fmax(i)=maxi=1n{f(i−1)×ai,ai}
它表示以第 i 个元素结尾的乘积最大子数组的乘积可以考虑 ai 加入前面的 fmax(i−1) 对应的一段,或者单独成为一段,这里两种情况下取最大值。求出所有的 fmax(i) 之后选取最大的一个作为答案。
可是在这里,这样做是错误的。为什么呢?
因为这里的定义并不满足「最优子结构」。具体地讲,如果 a={5,6,−3,4,−3},那么此时 fmax 对应的序列是 {5,30,−3,4,−3},按照前面的算法我们可以得到答案为 30,即前两个数的乘积,而实际上答案应该是全体数字的乘积。我们来想一想问题出在哪里呢?问题出在最后一个 −3 所对应的 fmax 的值既不是 −3,也不是 4×(−3),而是 5×6×(−3)×4×(−3)。所以我们得到了一个结论:当前位置的最优解未必是由前一个位置的最优解转移得到的。
我们可以根据正负性进行分类讨论。
考虑当前位置如果是一个负数的话,那么我们希望以它前一个位置结尾的某个段的积也是个负数,这样就可以负负得正,并且我们希望这个积尽可能「负得更多」,即尽可能小。如果当前位置是一个正数的话,我们更希望以它前一个位置结尾的某个段的积也是个正数,并且希望它尽可能地大。于是这里我们可以再维护一个 fmin(i),它表示以第 i 个元素结尾的乘积最小子数组的乘积,那么我们可以得到这样的动态规划转移方程:
fmax(i)=maxi=1n{fmax(i−1)×ai,fmin(i−1)×ai,ai}
fmin(i)=mini=1n{fmax(i−1)×ai,fmin(i−1)×ai,ai}
它代表第 i 个元素结尾的乘积最大子数组的乘积 fmax(i),可以考虑把 ai 加入第 i−1 个元素结尾的乘积最大或最小的子数组的乘积中,二者加上 ai,三者取大,就是第 i 个元素结尾的乘积最大子数组的乘积。第 i 个元素结尾的乘积最小子数组的乘积 fmin(i) 同理。
代码
class Solution {
public:
int maxProduct(vector<int>& nums) {
vector <int> maxF(nums), minF(nums);
for (int i = 1; i < nums.size(); ++i) {
maxF[i] = max(maxF[i - 1] * nums[i], max(nums[i], minF[i - 1] * nums[i]));
minF[i] = min(minF[i - 1] * nums[i], min(nums[i], maxF[i - 1] * nums[i]));
}
return *max_element(maxF.begin(), maxF.end());
}
};
337.打家劫舍 III
题目描述
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root 。
除了 root 之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。
给定二叉树的 root 。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。
-
示例 1:
- 输入: root = [3,2,3,null,3,null,1]
- 输出: 7
- 解释: 小偷一晚能够盗取的最高金额 3 + 3 + 1 = 7 示例 2:
- 输入: root = [3,4,5,1,3,null,1]
- 输出: 9
- 解释: 小偷一晚能够盗取的最高金额 4 + 5 = 9
提示:
树的节点数在 [1, 104] 范围内
0 <= Node.val <= 104
解析
简化一下这个问题:一棵二叉树,树上的每个点都有对应的权值,每个点有两种状态(选中和不选中),问在不能同时选中有父子关系的点的情况下,能选中的点的最大权值和是多少。
我们可以用 f(o) 表示选择 o 节点的情况下,o 节点的子树上被选择的节点的最大权值和;g(o) 表示不选择 o 节点的情况下,o 节点的子树上被选择的节点的最大权值和;l 和 r 代表 o 的左右孩子。
当 o 被选中时,o 的左右孩子都不能被选中,故 o 被选中情况下子树上被选中点的最大权值和为 l 和 r 不被选中的最大权值和相加,即 f(o)=g(l)+g(r)。
当 o 不被选中时,o 的左右孩子可以被选中,也可以不被选中。对于 o 的某个具体的孩子 x,它对 o 的贡献是 x 被选中和不被选中情况下权值和的较大值。故 g(o)=max{f(l),g(l)}+max{f(r),g(r)}。
至此,我们可以用哈希表来存 f 和 g 的函数值,用深度优先搜索的办法后序遍历这棵二叉树,我们就可以得到每一个节点的 f 和 g。根节点的 f 和 g 的最大值就是我们要找的答案。
代码
class Solution {
public:
unordered_map <TreeNode*, int> f, g;
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]);
}
int rob(TreeNode* root) {
dfs(root);
return max(f[root], g[root]);
}
};
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。
提示:
2 <= n <= 58
解析
对于正整数 n,当 n≥2 时,可以拆分成至少两个正整数的和。令 x 是拆分出的第一个正整数,则剩下的部分是 n−x 可以不继续拆分,或者继续拆分成至少两个正整数的和。由于每个正整数对应的最大乘积取决于比它小的正整数对应的最大乘积,因此可以使用动态规划求解。
创建数组 dp[i] 表示将正整数 i 拆分成至少两个正整数的和之后,这些正整数的最大乘积。特别地,0 不是正整数,1 是最小的正整数,0 和 1 都不能拆分,因此 dp[0]=dp[1]=0。
当 i≥2 时,假设对正整数 i 拆分出的第一个正整数是 j(1≤j<i),则有以下两种方案:
将 i 拆分成 j 和i−j 的和,且 i−j 不再拆分成多个正整数,此时的乘积是j×(i−j);
将 i 拆分成 j 和 i−j 的和,且 i−j 继续拆分成多个正整数,此时的乘积是j×dp[i−j]。
因此,当 j 固定时,有 dp[i]=max(j×(i−j),j×dp[i−j])。由于 j 的取值范围是 1 到 i−1,需要遍历所有的 j 得到 dp[i] 的最大值,因此可以得到状态转移方程如下:
dp[i]=1≤j<imax{max(j×(i−j),j×dp[i−j])}
最终得到 dp[n] 的值即为将正整数 n 拆分成至少两个正整数的和之后,这些正整数的最大乘积。
代码
class Solution {
public:
int integerBreak(int n) {
vector <int> dp(n + 1);
for (int i = 2; i <= n; i++) {
int curMax = 0;
for (int j = 1; j < i; j++) {
curMax = max(curMax, max(j * (i - j), j * dp[i - j]));
}
dp[i] = curMax;
}
return dp[n];
}
};
1105.填充书架
题目描述
给定一个数组 books ,其中 books[i] = [thicknessi, heighti] 表示第 i 本书的厚度和高度。你也会得到一个整数 shelfWidth 。
按顺序 将这些书摆放到总宽度为 shelfWidth 的书架上。
先选几本书放在书架上(它们的厚度之和小于等于书架的宽度 shelfWidth ),然后再建一层书架。重复这个过程,直到把所有的书都放在书架上。
需要注意的是,在上述过程的每个步骤中,摆放书的顺序与给定图书数组 books 顺序相同。
例如,如果这里有 5 本书,那么可能的一种摆放情况是:第一和第二本书放在第一层书架上,第三本书放在第二层书架上,第四和第五本书放在最后一层书架上。
每一层所摆放的书的最大高度就是这一层书架的层高,书架整体的高度为各层高之和。
以这种方式布置书架,返回书架整体可能的最小高度。
-
示例 1:
-
输入:books = [[1,1],[2,3],[2,3],[1,1],[1,1],[1,1],[1,2]], shelfWidth = 4
输出:6 - 解释:3 层书架的高度和为 1 + 3 + 2 = 6 。
- 第 2 本书不必放在第一层书架上。 示例 2:
- 输入: books = [[1,3],[2,4],[3,2]], shelfWidth = 6
- 输出: 4
提示:
1 <= books.length <= 1000
1 <= thicknessi <= shelfWidth <= 1000
1 <= heighti <= 1000
解析
根据题意,按顺序将这些书摆放到总宽度为shelfWidth的书架上。先选几本书放在书架上,然后再建一层书架。重复这个过程,直到把所有的书都放在书架上。
考虑用「动态规划」来解决这个问题,dp[i]来表示放下前 iii 本书所用的最小高度。 因为最多1000本书, 每本书高度最大1000,我们可以把dp[i] 初始化为1000000,初始化dp[0]为零,表示没有书是高度为零。
当我们要放置前i本书时候,假定前j本书放在上面的书架上,其中j<i, 前 jjj 本书放好后剩余的书放在最后一层书架上, 这一层书架的高度是这部分书的高度最大值,由此得到如此递推公式:
dp[i]=min(dp[j]+max(books[k]))
其中满足
0≤j≤k<i≤n,∑books[k]≤shelfWidth
我们循环遍历i, 求出dp[i]的值,最后返回dp[n]为最终答案。
代码
class Solution {
public:
int minHeightShelves(vector<vector<int>>& books, int shelfWidth) {
int n = books.size();
vector<int> dp(n + 1, 1000000);
dp[0] = 0;
for (int i = 0; i < n; ++i) {
int maxHeight = 0, curWidth = 0;
for (int j = i; j >= 0; --j) {
curWidth += books[j][0];
if (curWidth > shelfWidth) {
break;
}
maxHeight = max(maxHeight, books[j][1]);
dp[i + 1] = min(dp[i + 1], dp[j] + maxHeight);
}
}
return dp[n];
}
};
1395.统计作战单位数
题目描述
n 名士兵站成一排。每个士兵都有一个 独一无二 的评分 rating 。
每 3 个士兵可以组成一个作战单位,分组规则如下:
从队伍中选出下标分别为 i、j、k 的 3 名士兵,他们的评分分别为 rating[i]、rating[j]、rating[k]
作战单位需满足: rating[i] < rating[j] < rating[k] 或者 rating[i] > rating[j] > rating[k] ,其中 0 <= i < j < k < n
请你返回按上述条件可以组建的作战单位数量。每个士兵都可以是多个作战单位的一部分。
-
示例 1:
- 输入:rating = [2,5,3,4,1]
- 输出:3
- 解释:我们可以组建三个作战单位 (2,3,4)、(5,4,1)、(5,3,1) 。 示例 2:
- 输入:rating = [2,1,3]
- 输出:0
- 解释:根据题目条件,我们无法组建作战单位。 示例 3:
- 输入:rating = [1,2,3,4]
- 输出:4
提示:
n == rating.length
3 <= n <= 1000
1 <= rating[i] <= 10^5
rating 中的元素都是唯一的
解析
枚举三元组 (i,j,k)(i, j, k)(i,j,k) 中的 jjj,它是三元组的中间点。在这之后,我们统计:
出现在位置j左侧且比 jjj 评分低的士兵数量iless;
出现在位置j左侧且比 jjj 评分高的士兵数量imore;
出现在位置j右侧且比 jjj 评分低的士兵数量kless;
出现在位置j右侧且比 jjj 评分高的士兵数量kmore。
这样以来,任何一个出现在iless中的士兵i,以及出现在kmore中的士兵k,都可以和j组成一个严格单调递增的三元组;同理,任何一个出现在imore中的士兵i,以及出现在 klessk_{\textit{less}}kless 中的士兵k,都可以和j组成一个严格单调递减的三元组。因此,以j为中间点的三元组的数量为:
iless∗kmore+imore∗kless
我们将所有的值进行累加即可得到答案。
代码
class Solution {
public:
int numTeams(vector<int>& rating) {
int n = rating.size();
int ans = 0;
// 枚举三元组中的 j
for (int j = 1; j < n - 1; ++j) {
int iless = 0, imore = 0;
int kless = 0, kmore = 0;
for (int i = 0; i < j; ++i) {
if (rating[i] < rating[j]) {
++iless;
}
// 注意这里不能直接写成 else
// 因为可能有评分相同的情况
else if (rating[i] > rating[j]) {
++imore;
}
}
for (int k = j + 1; k < n; ++k) {
if (rating[k] < rating[j]) {
++kless;
}
else if (rating[k] > rating[j]) {
++kmore;
}
}
ans += iless * kmore + imore * kless;
}
return ans;
}
};