动态规划专题
动态规划理论基础
动态规划问题的特点:
1)具有优化子结构,原问题的优化解包含子问题的优化解。通俗来说就是原问题可以拆分成许多规模比较小的问题,通过小规模子问题的解可以推导出原问题的解。
2)子问题具有重叠性。动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。
动态规划常用于求解子序列、子数组、子集合、矩阵等问题,比较困难的是找状态量(代价表示),状态量常设置成以XXX结尾的**数量/true 等。
常用技巧:
当当前状态量只需要前一两个状态量就可以推导得到时,可以不用一维数组来保存每一个状态量的结果,而是只用几个变量来保存连续两三个状态量的值,进行滚动式运算推导,也就是滚动数组的思想,可以将空间复杂度从O(n) 降低到 O(1)。
动态规划问题解题步骤:
1)设置状态量,也就是确定dp数组以及下标的含义(最难最关键的一步)
2)推导状态转移方程,也就是确定递推公式(第二难的一步)
3)dp数组如何初始化
4)确定遍历顺序
5)举例推导dp数组
几个重要概念:
⼦数组的定义:数组中⼀个或连续多个元素组成⼀个⼦数组(⼦数组最少包含⼀个元素)
⼦串定义:字符串中任意个连续的字符组成的⼦序列称为该串的⼦串(子串可以为空)
若字符串长度为n,则子串的个数就是
n
×
(
n
+
1
)
2
+
1
\frac{n×(n+1)}{2}+1
2n×(n+1)+1 个(从n加到1,最后加个空串)
⼦序列的定义:⼦序列就是在原来序列中找出⼀部分组成的序列(子序列不一定连续)
动态规划的几大经典类型:
1)前缀形式的优化子结构: 最长公共子序列
2)中缀形式的优化子结构:矩阵链乘法、最优二叉搜索树、凸多边形的三角剖分
3)后缀形式的优化子结构:0-1背包问题
矩阵链乘法:
m
[
i
,
i
]
m[i, i]
m[i,i] = 计算
A
i
−
i
A_{i-i}
Ai−i 的最小乘法数= 0
m
[
i
,
j
]
=
m
[
i
,
k
]
+
m
[
k
+
1
,
j
]
+
p
i
−
1
p
k
p
j
m[i, j] = m[i, k] + m[k+1, j] + p_{i-1}p_kp_j
m[i,j]=m[i,k]+m[k+1,j]+pi−1pkpj
其中,
p
i
−
1
p
k
p
j
p_{i-1}p_kp_j
pi−1pkpj 是计算
A
i
−
k
×
A
k
+
1
到
j
A_{i-k}×A_{k+1 到 j}
Ai−k×Ak+1到j 所需乘法数,
A
i
−
k
A_{i-k}
Ai−k 和
A
k
+
1
到
j
A_{k+1 到 j}
Ak+1到j 分别是
p
i
−
1
×
p
k
p_{i-1}×p_k
pi−1×pk 和
p
k
×
p
j
p_k×p_j
pk×pj 矩阵
考虑到所有的k,优化解的代价方程为
m
[
i
,
j
]
=
0
i
f
i
=
j
m[i, j]= 0 \ if \ i=j
m[i,j]=0 if i=j
m
[
i
,
j
]
=
m
i
n
i
≤
k
<
j
m
[
i
,
k
]
+
m
[
k
+
1
,
j
]
+
p
i
−
1
p
k
p
j
i
f
i
<
j
m[i, j]= min \ \ i≤k<j \ \ { m[i, k]+m[k+1, j]+p_{i-1} p_k p_j} \ if \ i<j
m[i,j]=min i≤k<j m[i,k]+m[k+1,j]+pi−1pkpj if i<j
举例:
题目分类与传送门
一、基础题目
(1)基本一维动规
509. 斐波那契数
70. 爬楼梯
746. 使用最小花费爬楼梯
343. 整数拆分
96. 不同的二叉搜索树
32. 最长有效括号
42. 接雨水
871. 最低加油次数
(2)基本二维动规(多为方格二维图)
62. 不同路径
63. 不同路径 II
221. 最大正方形
1277. 统计全为 1 的正方形子矩阵
二、背包问题
(1)0-1背包问题
416. 分割等和子集
1049. 最后一块石头的重量 II
494. 目标和
474. 一和零
(2)完全背包
518. 零钱兑换 II
377. 组合总和 Ⅳ
70. 爬楼梯(完全背包解法)
322. 零钱兑换
279. 完全平方数
139. 单词拆分
三、打家劫舍
198. 打家劫舍
213. 打家劫舍 II
337. 打家劫舍 III
四、股票问题
121. 买卖股票的最佳时机
122. 买卖股票的最佳时机 II
123. 买卖股票的最佳时机 III
188. 买卖股票的最佳时机 IV
309. 最佳买卖股票时机含冷冻期
714. 买卖股票的最佳时机含手续费
五、子序列子串子数组问题
(1)子序列(不连续)
300. 最长递增子序列
1143. 最长公共子序列
1035. 不相交的线
128. 最长连续序列(哈希集合实现)
(2)子序列(连续)
674. 最长连续递增序列
718. 最长重复子数组
53. 最大子数组和
152. 乘积最大子数组
(3)编辑距离
392. 判断子序列
115. 不同的子序列
583. 两个字符串的删除操作
72. 编辑距离
(4)回文
647. 回文子串
5. 最长回文子串
516. 最长回文子序列
730. 统计不同回文子序列
(5)字符串的其他问题
926. 将字符串翻转到单调递增
题目分类讲解
基础问题
基本一维动规
32. 最长有效括号
给你一个只包含 ‘(’ 和 ‘)’ 的字符串,找出最长有效(格式正确且连续)括号子串的长度。
示例 1:
输入:s = “)()())”
输出:4
解释:最长有效括号子串是 “()()”
示例 2:
输入:s = “”
输出:0
【分析】
定义
dp
[
i
]
\textit{dp}[i]
dp[i] 表示以下标
i
i
i 字符结尾的最长有效括号的长度。我们将
dp
\textit{dp}
dp 数组全部初始化为 0 。显然有效的子串一定以
‘)’
\text{‘)’}
‘)’ 结尾,因此我们可以知道以
‘(’
\text{‘(’}
‘(’ 结尾的子串对应的
dp
\textit{dp}
dp 值必定为 0 ,我们只需要求解
‘)’
\text{‘)’}
‘)’ 在
dp
\textit{dp}
dp 数组中对应位置的值。
我们从前往后遍历字符串求解 dp \textit{dp} dp 值,我们每两个字符检查一次(注意不是每两个字符叠加,而是每个字符都要做检查):
-
s
[
i
]
=
‘)’
s[i] = \text{‘)’}
s[i]=‘)’ 且
s
[
i
−
1
]
=
‘(’
s[i - 1] = \text{‘(’}
s[i−1]=‘(’,也就是字符串形如
“ … … ( ) ” “……()” “……()”,我们可以推出:
dp [ i ] = dp [ i − 2 ] + 2 \textit{dp}[i]=\textit{dp}[i-2]+2 dp[i]=dp[i−2]+2
我们可以进行这样的转移,是因为结束部分的 " ( ) " "()" "()" 是一个有效子字符串,并且将之前有效子字符串的长度增加了 2 。
-
s
[
i
]
=
‘)’
s[i] = \text{‘)’}
s[i]=‘)’ 且
s
[
i
−
1
]
=
‘)’
s[i - 1] = \text{‘)’}
s[i−1]=‘)’,也就是字符串形如
“ … … ) ) ” “……))” “……))”,我们可以推出: 如果 s [ i − dp [ i − 1 ] − 1 ] = ‘(’ s[i - \textit{dp}[i - 1] - 1] = \text{‘(’} s[i−dp[i−1]−1]=‘(’,那么
dp [ i ] = dp [ i − 1 ] + dp [ i − dp [ i − 1 ] − 2 ] + 2 \textit{dp}[i]=\textit{dp}[i-1]+\textit{dp}[i-\textit{dp}[i-1]-2]+2 dp[i]=dp[i−1]+dp[i−dp[i−1]−2]+2
我们考虑如果倒数第二个 ‘)’ \text{‘)’} ‘)’ 是一个有效子字符串的一部分(记作 s u b s sub_s subs ),对于最后一个 ‘)’ \text{‘)’} ‘)’ ,如果它是一个更长子字符串的一部分,那么它一定有一个对应的 ‘(’ \text{‘(’} ‘(’,且它的位置在倒数第二个 ‘)’ \text{‘)’} ‘)’ 所在的有效子字符串的前面(也就是 s u b s sub_s subs 的前面)。因此,如果子字符串 s u b s sub_s subs 的前面恰好是 ‘(’ \text{‘(’} ‘(’ ,那么我们就用 2 加上 s u b s sub_s subs 的长度( dp [ i − 1 ] \textit{dp}[i-1] dp[i−1])去更新 dp [ i ] \textit{dp}[i] dp[i]。同时,我们也会把有效子串 “ ( s u b s ) ” “(sub_s)” “(subs)” )” 之前的有效子串的长度也加上,也就是再加上 dp [ i − dp [ i − 1 ] − 2 ] \textit{dp}[i-\textit{dp}[i-1]-2] dp[i−dp[i−1]−2]。
最后的答案即为 dp \textit{dp} dp 数组中的最大值(注意不一定是最后一个值)。
int longestValidParentheses(string s) {
int ans = 0;
int n = s.size();
vector<int> dp(n, 0);
for(int i = 1; i < n; i ++){
if(s[i] == ')'){
if(s[i - 1] == '('){
dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
//注意下面边界的限制 i - dp[i - 1] > 0
}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;
}
ans = max(ans, dp[i]);
}
}
return ans;
}
42. 接雨水
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例 1:
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
解释:上面是由数组[0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
【分析】本题还有一种单调栈解法,详见数据结构专题中的单调栈部分
下面介绍比较容易理解的动态规划解法:
对于下标 i,下雨后水能到达的最大高度等于下标 i 两边的最大高度的最小值,下标 i 处能接的雨水量等于下标 i 处的水能到达的最大高度减去 height[i]。
leftMax[i] 表示下标 i 及其左边的位置中, height 的最大高度;
rightMax[i] 表示下标 i 及其右边的位置中, height 的最大高度。
正向遍历数组 height 得到数组leftMax 的每个元素值,反向遍历数组 height 得到数组 rightMax 的每个元素值。
int trap(vector<int>& height) {
int n = height.size();
vector<int> left(n);
vector<int> right(n);
left[0] = height[0];
for(int i =1; i<n; ++i)
{
left[i] = max(left[i-1], height[i]);
}
right[n-1] = height[n-1];
for(int i=n-2; i>=0; --i)
{
right[i] = max(right[i+1], height[i]);
}
int ans = 0;
for(int i=0; i<n; ++i)
{
ans += min(right[i], left[i])-height[i];
}
return ans;
}
871. 最低加油次数
汽车从起点出发驶向目的地,该目的地位于出发位置东面 target 英里处。
沿途有加油站,每个
s
t
a
t
i
o
n
[
i
]
station[i]
station[i] 代表一个加油站,它位于出发位置东面
s
t
a
t
i
o
n
[
i
]
[
0
]
station[i][0]
station[i][0] 英里处,并且有
s
t
a
t
i
o
n
[
i
]
[
1
]
station[i][1]
station[i][1] 升汽油。
假设汽车油箱的容量是无限的,其中最初有 startFuel 升燃料。它每行驶 1 英里就会用掉 1 升汽油。
当汽车到达加油站时,它可能停下来加油,将所有汽油从加油站转移到汽车中。
为了到达目的地,汽车所必要的最低加油次数是多少?如果无法到达目的地,则返回 -1 。
注意:如果汽车到达加油站时剩余燃料为 0,它仍然可以在那里加油。如果汽车到达目的地时剩余燃料为 0,仍然认为它已经到达目的地。
示例 1:
输入:target = 1, startFuel = 1, stations = []
输出:0
解释:我们可以在不加油的情况下到达目的地。
示例 2:
输入:target = 100, startFuel = 1, stations = [[10,100]]
输出:-1
解释:我们无法抵达目的地,甚至无法到达第一个加油站。
示例 3:
输入:target = 100, startFuel = 10, stations = [[10,60],[20,30],[30,30],[60,40]]
输出:2
解释: 我们出发时有 10 升燃料。 我们开车来到距起点 10 英里处的加油站,消耗 10 升燃料。将汽油从 0 升加到 60 升。 然后,我们从 10 英里处的加油站开到 60 英里处的加油站(消耗 50 升燃料), 并将汽油从 10 升加到 50 升。然后我们开车抵达目的地。 我们沿途在1两个加油站停靠,所以返回 2 。
方法一:动态规划(不推荐)
int minRefuelStops(int target, int startFuel, vector<vector<int>>& stations) {
int n = stations.size();
//dp[i] 表示加油 i 次的最大行驶英里数
//需要计算每个加油次数对应的最大行驶英里数,
//然后得到最大行驶英里数大于等于 target 的最少加油次数。
vector<long> dp(n + 1); //不能用 int ,会signed integer overflow
dp[0] = startFuel;
//当遍历到加油站 stations[i] 时,假设在到达该加油站之前已经加油 j 次
for(int i = 0; i < n; ++i){
for(int j = i; j >= 0; --j){
//当遍历到加油站 stations[i] 时,对于每个符合要求的下标 j,
//计算 dp[j+1] 时都是将加油站 stations[i] 作为最后一次加油的加油站。
//为了确保每个 dp[j+1] 的计算中,加油站 stations[i] 只会被计算一次,
//应该按照从大到小的顺序遍历下标 j。
if(dp[j] >= stations[i][0]){
dp[j + 1] = max(dp[j + 1], dp[j] + stations[i][1]);
}
}
}
for(int i = 0; i <= n; ++i){
if(dp[i] >= target){
return i;
}
}
return -1;
}
方法二:贪心(推荐用这个方法)
用
n
n
n 表示数组
stations
\textit{stations}
stations 的长度,即加油站的个数。行驶的过程中依次到达
n
+
1
n + 1
n+1 个位置,分别是
n
n
n 个加油站和目的地。为了得到最少加油次数,应该在确保每个位置都能到达的前提下,选择最大加油量的加油站加油。
为了得到已经到达过的加油站中的最大加油量,需要使用优先队列记录所有已经到达过的加油站的加油量,优先队列中的最大元素位于队首,即每次从优先队列中取出的元素都是优先队列中的最大元素。
从左到右遍历数组
stations
\textit{stations}
stations,对于每个加油站,首先判断该位置是否可以达到,然后将当前加油站的加油量添加到优先队列中。对于目的地,则只需要判断是否可以达到。
用 oil 来累积加油量,当加油总量大于
stations
\textit{stations}
stations 时,该位置就可以达到,反之就需要不断从优先级队列中拿出头部元素来添加给 oil ,直到可以到达当前位置,若队列为空了还不能到达,则直接返回 -1。当最后得到的 oil 不足以达到 target 时,就需要不断从队列中拿出头部元素来添加给 oil,直到队列为空。
int minRefuelStops(int target, int startFuel, vector<vector<int>>& stations) {
priority_queue<int> q;
int n = stations.size();
int oil = startFuel, ans = 0;
for(int i = 0; i < n;){
if(oil >= stations[i][0]){
q.push(stations[i][1]);
++i;
}else{
if(q.empty()){
return -1;
}
//注意这里是top而不是front
oil += q.top();
q.pop();
++ans;
}
}
if(oil >= target){
return ans;
}else{
while(!q.empty() && oil < target){
++ans;
oil += q.top();
q.pop();
}
if(oil >= target){
return ans;
}else{
return -1;
}
}
}
下面这三道题是同一种解法
通常需要对区间按照右端点从小到大的顺序排序,而且区间问题用贪心方法往往更快更简单。
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 。
【解法】
定义
dp
[
i
]
\textit{dp}[i]
dp[i] 为考虑前
i
i
i 个元素,以第
i
i
i 个数字结尾的最长上升子序列的长度,注意
nums
[
i
]
\textit{nums}[i]
nums[i] 必须被选取。
我们从小到大计算 dp \textit{dp} dp 数组的值,在计算 dp [ i ] \textit{dp}[i] dp[i] 之前,我们已经计算出 dp [ 0 … i − 1 ] \textit{dp}[0 \ldots i-1] dp[0…i−1]的值,则状态转移方程为:
dp [ i ] = max ( dp [ j ] ) + 1 , 其中 0 ≤ j < i 且 num [ j ] < num [ i ] \textit{dp}[i] = \max(\textit{dp}[j]) + 1, \text{其中} \, 0 \leq j < i \, \text{且} \, \textit{num}[j]<\textit{num}[i] dp[i]=max(dp[j])+1,其中0≤j<i且num[j]<num[i]
即考虑往 dp [ 0 … i − 1 ] \textit{dp}[0 \ldots i-1] dp[0…i−1] 中最长的上升子序列后面再加一个 nums [ i ] \textit{nums}[i] nums[i]。由于 dp [ j ] \textit{dp}[j] dp[j] 代表 nums [ 0 … j ] \textit{nums}[0 \ldots j] nums[0…j] 中以 nums [ j ] \textit{nums}[j] nums[j] 结尾的最长上升子序列,所以如果能从 dp [ j ] \textit{dp}[j] dp[j]这个状态转移过来,那么 nums [ i ] \textit{nums}[i] nums[i] 必然要大于 nums [ j ] \textit{nums}[j] nums[j],才能将 nums [ i ] \textit{nums}[i] nums[i] 放在 nums [ j ] \textit{nums}[j] nums[j] 后面以形成更长的上升子序列。
最后,整个数组的最长上升子序列即所有 dp [ i ] \textit{dp}[i] dp[i] 中的最大值。
int lengthOfLIS(vector<int>& nums) {
int n = (int)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());
}
时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)
空间复杂度:
O
(
n
)
O(n)
O(n),需要额外使用长度为
n
n
n 的
d
p
dp
dp 数组
435. 无重叠区间(与上一题同系列)
给定一个区间的集合 intervals ,其中 intervals[i] = [starti, endi] 。返回 需要移除区间的最小数量,使剩余区间互不重叠 。
示例 1:
输入: intervals = [[1,2],[2,3],[3,4],[1,3]]
输出: 1
解释: 移除 [1,3]后,剩下的区间没有重叠。
【分析】
题目的要求等价于「选出最多数量的区间,使得它们互不重叠」,先将所有的
n
n
n 个区间按照左端点(或者右端点)从小到大进行排序,随后使用动态规划的方法求出区间数量的最大值.
令
d
p
[
i
]
dp[i]
dp[i] 表示「以区间
i
i
i 为最后一个区间,可以选出的区间数量的最大值」,状态转移方程同上
dp
[
i
]
=
max
(
dp
[
j
]
)
+
1
,
其中
0
≤
j
<
i
且
num
[
j
]
<
num
[
i
]
\textit{dp}[i] = \max(\textit{dp}[j]) + 1, \text{其中} \, 0 \leq j < i \, \text{且} \, \textit{num}[j]<\textit{num}[i]
dp[i]=max(dp[j])+1,其中0≤j<i且num[j]<num[i]
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
int n = (int)intervals.size();
if(n <= 1){
return 0;
}
sort(intervals.begin(), intervals.end(), [&](vector<int>& a, vector<int>& b){
return a[1] < b[1];
});
vector<int> dp(n, 1);
for(int i = 1; i < n; ++i){
for(int j = 0; j < i; ++j){
if(intervals[i][0] >= intervals[j][1]){
dp[i] = max(dp[i], dp[j] + 1);
}
}
}
return n - *max_element(dp.begin(), dp.end());
}
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2),不能通过所有测试样例
【贪心解法】
将所有区间按照右端点从小到大进行排序,那么排完序之后的首个区间,就是我们选择的首个区间。
接着依次找出与上个区间不重合并且右端点最小的区间即可。
在实际的代码编写中,我们对按照右端点排好序的区间进行遍历,并且实时维护上一个选择区间的右端点
right
\textit{right}
right。如果当前遍历到的区间
[
l
i
,
r
i
]
[l_i, r_i]
[li,ri]与上一个区间不重合,即
l
i
≥
right
l_i \geq \textit{right}
li≥right,那么我们就可以贪心地选择这个区间,并将
right
\textit{right}
right 更新为
r
i
r_i
ri。
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end(), [&](const vector<int>& a, const vector<int>& b){
return a[1] < b[1];
});
int ans = 0, n = intervals.size(), r = INT_MIN;
for(int i = 0; i < n; ++i){
if(intervals[i][0] < r){
++ans;
}else{
r = intervals[i][1];
}
}
return ans;
}
646. 最长数对链(与上一题同系列)
给出 n 个数对。 在每一个数对中,第一个数字总是比第二个数字小。
现在,我们定义一种跟随关系,当且仅当 b < c 时,数对(c, d) 才可以跟在 (a, b) 后面。我们用这种形式来构造一个数对链。
给定一个数对集合,找出能够形成的最长数对链的长度。你不需要用到所有的数对,你可以以任何顺序选择其中的一些数对来构造。
示例:
输入:[[1,2], [2,3], [3,4]]
输出:2
解释:最长的数对链是 [1,2] -> [3,4]
【分析】
定义
dp
[
i
]
\textit{dp}[i]
dp[i] 为以
pairs
[
i
]
\textit{pairs}[i]
pairs[i] 为结尾的最长数对链的长度。
计算
dp
[
i
]
\textit{dp}[i]
dp[i] 时,可以先找出所有的满足
pairs
[
i
]
[
0
]
>
pairs
[
j
]
[
1
]
\textit{pairs}[i][0] > \textit{pairs}[j][1]
pairs[i][0]>pairs[j][1] 的
j
j
j,并求出最大的
dp
[
j
]
\textit{dp}[j]
dp[j],
dp
[
i
]
\textit{dp}[i]
dp[i] 的值即可赋为这个最大值加一。
这种动态规划的思路要求计算
dp
[
i
]
\textit{dp}[i]
dp[i] 时,所有潜在的
dp
[
j
]
\textit{dp}[j]
dp[j] 已经计算完成,可以先将
pairs
\textit{pairs}
pairs 进行排序来满足这一要求。初始化时,
dp
\textit{dp}
dp 需要全部赋值为
1
1
1。
int findLongestChain(vector<vector<int>>& pairs) {
if(pairs.empty()){
return 0;
}
sort(pairs.begin(), pairs.end(), [&](const auto& a, const auto& b){
return a[1] < b[1];
});
int n = (int)pairs.size();
vector<int> dp(n, 1);
for(int i = 1; i < n; ++i){
for(int j = 0; j < i; ++j){
if(pairs[i][0] > pairs[j][1]){
dp[i] = max(dp[i], dp[j] + 1);
}
}
}
return *max_element(dp.begin(), dp.end());
}
时间复杂度: O ( n 2 ) O(n^2) O(n2),其中 n n n 为 pairs \textit{pairs} pairs 的长度。排序的时间复杂度为 O ( n log n ) O(n \log n) O(nlogn),两层 for \texttt{for} for 循环的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
空间复杂度: O ( n ) O(n) O(n),数组 dp \textit{dp} dp 的空间复杂度为 O ( n ) O(n) O(n)。
【贪心解法】
要挑选最长数对链的第一个数对时,最优的选择是挑选第二个数字最小的,这样能给挑选后续的数对留下更多的空间。挑完第一个数对后,要挑第二个数对时,也是按照相同的思路,是在剩下的数对中,第一个数字满足题意的条件下,挑选第二个数字最小的。按照这样的思路,可以先将输入按照第二个数字排序,然后不停地判断第一个数字是否能满足大于前一个数对的第二个数字即可。
int findLongestChain(vector<vector<int>>& pairs) {
sort(pairs.begin(), pairs.end(), [&](const vector<int>& a, const vector<int>& b){
return a[1] < b[1];});
int ans = 0, cur = INT_MIN;
for(auto &p : pairs){
if(p[0] > cur){
cur = p[1];
ans ++;
}
}
return ans;
}
时间复杂度:
O
(
n
log
n
)
O(n \log n)
O(nlogn),其中
n
n
n 为
pairs
\textit{pairs}
pairs 的长度。排序的时间复杂度为
O
(
n
log
n
)
O(n \log n)
O(nlogn)。
空间复杂度:
O
(
log
n
)
O(\log n)
O(logn),为排序的空间复杂度。
基本二维动规
此类动态规划多为二维方格图中的问题,需要用二维向量来实现,因此是二维动规。
221. 最大正方形
在一个由 ‘0’ 和 ‘1’ 组成的二维矩阵内,找到只包含 ‘1’ 的最大正方形,并返回其面积。
示例 1:
输入:matrix =
[
[“1”,“0”,“1”,“0”,“0”],
[“1”,“0”,“1”,“1”,“1”],
[“1”,“1”,“1”,“1”,“1”],
[“1”,“0”,“0”,“1”,“0”]
]
输出:4
【解答】
需要左上角、左边和上边三个提前结果,所以自顶向下推:
①状态量:
用 dp(i,j) 表示以 (i,j) 为右下角,且只包含 1 的正方形的边长最大值。
②状态转移方程:
如果该位置的值是 0,则dp(i,j)=0,因为当前位置不可能在由 1 组成的正方形中;
如果该位置的值是 1,则dp(i,j) 的值由其上方、左方和左上方的三个相邻位置的 dp 值决定。具体而言,当前位置的元素值等于三个相邻位置的元素中的最小值加 1,状态转移方程如下:
dp(i, j)=min(dp(i−1, j), dp(i−1, j−1), dp(i, j−1))+1
dp(i,j)=min(dp(i−1,j),dp(i−1,j−1),dp(i,j−1))+1
代码:
int maximalSquare(vector<vector<char>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
if(n == 0) return 0;
vector<vector<int>> dp(m, vector<int>(n));
int maxside = 0;
for(int i = 0; i < m; ++i){
//dp[i][0] = matrix[i][0]; 这样写赋值为49,赋值是ASCII码
if(matrix[i][0] == '1'){
dp[i][0] = 1;
maxside = 1;
}
}
for(int j = 0; j < n; ++j){
if(matrix[0][j] == '1'){
dp[0][j] = 1;
maxside = 1;
}
}
for(int i = 1; i < m; ++i){
for(int j = 1; j < n; ++j){
if(matrix[i][j] == '1'){
dp[i][j] = min({dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]}) + 1;
maxside = max(maxside, dp[i][j]);
}
}
}
return maxside * maxside;
}
1277. 统计全为 1 的正方形子矩阵
给你一个 m * n 的矩阵,矩阵中的元素不是 0 就是 1,请你统计并返回其中完全由 1 组成的 正方形 子矩阵的个数。
示例 1:
输入:matrix = [
[0,1,1,1],
[1,1,1,1],
[0,1,1,1]
]
输出:15
解释: 边长为 1的正方形有 10 个。
边长为 2 的正方形有 4 个。
边长为 3 的正方形有 1 个。
正方形的总数 = 10 + 4 + 1 = 15.
【分析】
这题和上一题的区别就是需要统计上各种边长的子矩阵,dp[i][j]在这里不仅表示以(i, j)为右下角的矩阵的最大边长,而且还表示以(i, j)为右下角的不同边长的矩阵组合数(边长取不同值)。
因此,dp[i][j]之和就是所有子矩阵的组合数。
代码:
int countSquares(vector<vector<int>>& matrix) {
int ans = 0;
int m = matrix.size();
int n = matrix[0].size();
vector<vector<int>> dp(m, vector<int>(n));
for(int i = 0; i < m; ++i){
for(int j = 0; j < n; ++j){
if(matrix[i][j] == 1){ //注意不是char类型的‘1’,而是int类型
if(i == 0 || j == 0){
dp[i][j] = 1; //不是==
}else{
dp[i][j] = min({dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]}) + 1;
}
ans += dp[i][j];
}
}
}
return ans;
}
背包问题
0-1背包问题
完全背包问题
139. 单词拆分
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
示例 1:
输入: s = “applepenapple”, wordDict = [“apple”, “pen”]
输出: true
解释: 返回 true 因为 “applepenapple” 可以由 “apple” “pen” “apple” 拼接成。
注意,你可以重复使用字典中的单词。
① 状态量的定义
定义 dp[i] 表示字符串 s 前 i 个字符组成的字符串 s[0…i−1] 是否能被空格拆分成若干个字典中出现的单词(布尔类型)。
②推导状态转移方程
按照字符串长度i从1到n,从前往后计算考虑转移方程。对于不同长度i,枚举 s[0…i−1] 中的分割点 j ,看 s[0…j−1] 组成的字符串 s1(默认 j=0 时 s1为空串)和 s[j…i−1] 组成的字符串 s2是否都合法,如果两个字符串均合法,那么按照定义 s1 和 s2 拼接成的字符串也同样合法。
转移方程:
dp[i]=dp[j] && check(s[j..i−1])
其中 check(s[j…i−1]) 表示子串 s[j…i−1] 是否出现在字典中。
③边界初始化及优化
对于边界条件,我们定义 dp[0]=true 表示空串且合法。
优化:对于检查一个字符串是否出现在给定的字符串列表里一般可以考虑哈希集合来快速判断,同时也可以做一些简单的剪枝,枚举分割点j的时候从i – 1到0倒着枚举,如果从分割点 j 到 i 的长度已经大于字典列表里最长的单词的长度,那么就结束枚举。
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> st;
int maxlen = 0;
for(string& word : wordDict){
st.insert(word);
maxlen = max(maxlen, (int)word.size());
//注意word.size()不是int类型,max 函数需要参数类型统一
}
int n = s.size();
vector<bool> dp(n + 1);
//边界条件,定义 dp[0]=true 表示空串且合法。
dp[0] = true;
//按照字符串长度 i 从 1 到 n,从前往后计算考虑转移方程。
for(int i = 1; i <= n; ++i){
//剪枝:枚举分割点j的时候从 i – 1 到 0 倒着枚举,
//如果从分割点 j 到 i 的长度已经大于字典列表里最长的单词的长度,那么就结束枚举,
for(int j = i - 1; j >= 0 && i - j <= maxlen; --j){
if(dp[j] && st.find(s.substr(j, i - j)) != st.end()){
dp[i] = true;
break;
}
}
}
return dp[n];
}
打家劫舍
股票问题
121. 买卖股票的最佳时机
给定一个数组 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。
【分析】
该题与动态规划不同是该题不需要多次买入卖出股票,整体只需要买卖一次。
用一维遍历,维护一个当天及之前的最低价,同时维护一个当天及之前的最大利益(价格差)。最后输出这个利益差就行了。
int maxProfit(vector<int>& prices) {
int n = prices.size();
if(n<2){
return 0;
}
int minprice = prices[0];
int profit = 0;
for(int a : prices){
profit = max(a - minprice, profit);
minprice = min(a, minprice);
}
return profit;
}
子序列子串子数组问题
子序列(不连续)
下面这道题只作为区分,用不到动态规划,哈希集合就可以实现。
128. 最长连续序列(哈希集合实现)
给定一个未排序的整数数组 nums ,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。
请你设计并实现时间复杂度为 O(n) 的算法解决此问题。
示例 1:
输入:nums = [100,4,200,1,3,2]
输出:4
解释:最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。
【分析】
用一个哈希集合存储数组中的数,这样查看一个数是否存在即能优化至 O(1) 的时间复杂度。
枚举哈希集合中的每个不存在前驱数 x-1的数 x,考虑以其为起点,不断尝试匹配 x+1,x+2,⋯ 是否存在,假设最长匹配到了 x+y,那么以 x 为起点的最长连续序列长度为 y+1,我们不断枚举并更新答案即可。
int longestConsecutive(vector<int>& nums) {
unordered_set<int> st;
int n = nums.size();
for(int i = 0; i < n; ++i){
st.insert(nums[i]);
}
int ans = 0; //nums可能为空
for(const int& a : st){
if(st.find(a - 1) == st.end()){
int x = a, y = a;
while(st.find(y + 1) != st.end()){
++y;
}
ans = max(ans, y - x + 1);
}
}
return ans;
}
子序列(连续)
152. 乘积最大子数组
给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
测试用例的答案是一个 32-位 整数。
子数组 是数组的连续子序列。
示例 1:
输入: nums = [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
示例 2:
输入: nums = [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
【分析】
如果用
f
max
(
i
)
f_{\max}(i)
fmax(i) 表示以第
i
i
i 个元素结尾的乘积最大子数组的乘积,
a
a
a 表示输入参数 nums,那么根据「53. 最大子序和」的经验,可以推导出这样的状态转移方程:
f max ( i ) = max i = 1 n { f ( i − 1 ) × a i , a i } f_{\max}(i) = \max_{i = 1}^{n} \{ f(i - 1) \times a_i, a_i \} fmax(i)=maxi=1n{f(i−1)×ai,ai}
它表示以第 i i i 个元素结尾的乘积最大子数组的乘积可以考虑 a i a_i ai 加入前面的 f max ( i − 1 ) f_{\max}(i - 1) fmax(i−1) 对应的一段,或者单独成为一段,这里两种情况下取最大值。求出所有的 f max ( i ) f_{\max}(i) fmax(i) 之后选取最大的一个作为答案。
可是在这里,这样做是错误的。因为这里的定义并不满足「最优子结构」。
具体地讲,如果
a
=
{
5
,
6
,
−
3
,
4
,
−
3
}
a = \{ 5, 6, -3, 4, -3 \}
a={5,6,−3,4,−3},那么此时
f
max
f_{\max}
fmax 对应的序列是
{
5
,
30
,
−
3
,
4
,
−
3
}
\{ 5, 30, -3, 4, -3 \}
{5,30,−3,4,−3},按照前面的算法我们可以得到答案为 30,即前两个数的乘积,而实际上答案应该是全体数字的乘积。
也就是说,当数组中存在负数时,当前位置的最优解未必是由前一个位置的最优解转移得到的。
因此,需要可以根据正负性进行分类讨论。
考虑当前位置如果是一个负数的话,那么我们希望以它前一个位置结尾的某个段的积也是个负数,这样就可以负负得正,并且我们希望这个积尽可能「负得更多」,即尽可能小。如果当前位置是一个正数的话,我们更希望以它前一个位置结尾的某个段的积也是个正数,并且希望它尽可能地大。于是这里我们可以再维护一个 f min ( i ) f_{\min}(i) fmin(i),表示以第 i i i 个元素结尾的乘积最小子数组的乘积,那么我们可以得到这样的动态规划转移方程:
f
max
(
i
)
=
max
i
=
1
n
{
f
max
(
i
−
1
)
×
a
i
,
f
min
(
i
−
1
)
×
a
i
,
a
i
}
f
min
(
i
)
=
min
i
=
1
n
{
f
max
(
i
−
1
)
×
a
i
,
f
min
(
i
−
1
)
×
a
i
,
a
i
}
\begin{aligned} f_{\max}(i) &= \max_{i = 1}^{n} \{ f_{\max}(i - 1) \times a_i, f_{\min}(i - 1) \times a_i, a_i \} \\ f_{\min}(i) &= \min_{i = 1}^{n} \{ f_{\max}(i - 1) \times a_i, f_{\min}(i - 1) \times a_i, a_i \} \end{aligned}
fmax(i)fmin(i)=i=1maxn{fmax(i−1)×ai,fmin(i−1)×ai,ai}=i=1minn{fmax(i−1)×ai,fmin(i−1)×ai,ai}
它代表第
i
i
i 个元素结尾的乘积最大子数组的乘积
f
max
(
i
)
f_{\max}(i)
fmax(i),可以考虑把
a
i
a_i
ai 加入第
i
−
1
i - 1
i−1 个元素结尾的乘积最大或最小的子数组的乘积中,二者加上
a
i
a_i
ai,三者取大,就是第
i
i
i 个元素结尾的乘积最大子数组的乘积。第
i
i
i 个元素结尾的乘积最小子数组的乘积
f
min
(
i
)
f_{\min}(i)
fmin(i) 同理。
int maxProduct(vector<int>& nums) {
int n = nums.size();
vector<vector<int>> dp(2, vector<int>(n));
dp[0][0] = nums[0]; //max
dp[1][0] = nums[0]; //min
for(int i = 1; i < n; ++i){
dp[0][i] = max({dp[0][i - 1] * nums[i], dp[1][i - 1] * nums[i], nums[i]});
dp[1][i] = min({dp[0][i - 1] * nums[i], dp[1][i - 1] * nums[i], nums[i]});
}
return *max_element(dp[0].begin(), dp[0].end());
}
编辑距离
给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符
示例 1:
输入:word1 = “horse”, word2 = “ros”
输出:3
解释: horse -> rorse (将 ‘h’ 替换为 ‘r’) rorse -> rose (删除 ‘r’) rose -> ros (删除 ‘e’)
【分析】
定义
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j] 的含义为:
w
o
r
d
1
word1
word1 的前
i
i
i 个字符和
w
o
r
d
2
word2
word2 的前j个字符的编辑距离。意思就是
w
o
r
d
1
word1
word1 的前
i
i
i 个字符,变成
w
o
r
d
2
word2
word2 的前
j
j
j 个字符,最少需要这么多步。
如果下标为零则表示空串,比如: d p [ 0 ] [ 2 ] dp[0][2] dp[0][2] 就表示空串""和“ro”的编辑距离。如果其中一个字符串是空串,那么编辑距离是另一个字符串的长度。
当 i > 0 , j > 0 i>0,j>0 i>0,j>0 时(即两个串都不空时) d p [ i ] [ j ] = m i n ( d p [ i − 1 ] [ j ] + 1 , d p [ i ] [ j − 1 ] + 1 , d p [ i − 1 ] [ j − 1 ] + i n t ( w o r d 1 [ i ] ! = w o r d 2 [ j ] ) ) dp[i][j]=min(dp[i-1][j]+1,dp[i][j-1]+1,dp[i-1][j-1]+int(word1[i]!=word2[j])) dp[i][j]=min(dp[i−1][j]+1,dp[i][j−1]+1,dp[i−1][j−1]+int(word1[i]!=word2[j]))。
举个例子,word1 = “abcde”, word2 = “fgh”,我们现在算这俩字符串的编辑距离,就是找从word1,最少多少步,能变成word2?那就有三种方式:
-
知道"abcd"变成"fgh"多少步(假设X步),那么从"abcde"到"fgh"就是"abcde"->“abcd”->“fgh”。(一次删除,加X步,总共X+1步)
-
知道"abcde"变成“fg”多少步(假设Y步),那么从"abcde"到"fgh"就是"abcde"->“fg”->“fgh”。(先Y步,再一次添加,加X步,总共Y+1步)
-
知道"abcd"变成“fg”多少步(假设Z步),那么从"abcde"到"fgh"就是"abcde"->“fge”->“fgh”。(先不管最后一个字符,把前面的先变好,用了Z步,然后把最后一个字符给替换了。这里如果最后一个字符碰巧就一样,那就不用替换,省了一步)
以上三种方式算出来选最少的,就是答案。那么我们可以写出如下的状态转移方程:
-
若 A 和 B 的最后一个字母相同:
D [ i ] [ j ] = min ( D [ i ] [ j − 1 ] + 1 , D [ i − 1 ] [ j ] + 1 , D [ i − 1 ] [ j − 1 ] ) = 1 + min ( D [ i ] [ j − 1 ] , D [ i − 1 ] [ j ] , D [ i − 1 ] [ j − 1 ] − 1 ) \begin{aligned} D[i][j] &= \min(D[i][j - 1] + 1, D[i - 1][j]+1, D[i - 1][j - 1])\\ &= 1 + \min(D[i][j - 1], D[i - 1][j], D[i - 1][j - 1] - 1) \end{aligned} D[i][j]=min(D[i][j−1]+1,D[i−1][j]+1,D[i−1][j−1])=1+min(D[i][j−1],D[i−1][j],D[i−1][j−1]−1) -
若 A 和 B 的最后一个字母不同:
D [ i ] [ j ] = 1 + min ( D [ i ] [ j − 1 ] , D [ i − 1 ] [ j ] , D [ i − 1 ] [ j − 1 ] ) D[i][j] = 1 + \min(D[i][j - 1], D[i - 1][j], D[i - 1][j - 1]) D[i][j]=1+min(D[i][j−1],D[i−1][j],D[i−1][j−1])
每一步结果都将基于上一步的计算结果。 -
对于边界情况,一个空串和一个非空串的编辑距离为 D [ i ] [ 0 ] = i D[i][0] = i D[i][0]=i 和 D [ 0 ] [ j ] = j D[0][j] = j D[0][j]=j, D [ i ] [ 0 ] D[i][0] D[i][0] 相当于对 w o r d 1 word1 word1 执行 i i i 次删除操作, D [ 0 ] [ j ] D[0][j] D[0][j] 相当于对 w o r d 1 word1 word1 执行 j j j 次插入操作。
算法流程如下:
int minDistance(string word1, string word2) {
int m = word1.size(), n = word2.size();
//有一个字符串为空串的情况
if(m * n == 0) return m + n;
//二维数组定义的数据类型只写了一维,典型错误
// vector<int> dp(m + 1, vector<int>(n + 1, 0));错误写法
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for(int i = 0; i <= m; ++i){
for(int j = 0; j <= n; ++j){
if(i == 0){
dp[i][j] = j;
}else if(j == 0){
dp[i][j] = i;
}else{
if(word1[i - 1] != word2[j - 1]){
dp[i][j] = 1 + min({dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]});
}else{
dp[i][j] = 1 + min({dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1] - 1});
}
}
}
}
return dp[m][n];
}
简化:转化成一维向量 + 用一个变量存着对角的值
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j] 只和
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
]
[
j
−
1
]
,
d
p
[
i
−
1
]
[
j
−
1
]
dp[i-1][j],dp[i][j-1],dp[i-1][j-1]
dp[i−1][j],dp[i][j−1],dp[i−1][j−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] 是没覆盖前我这里的值,
d
p
[
i
−
1
]
[
j
−
1
]
dp[i-1][j-1]
dp[i−1][j−1] 好像找不见了?那我们就单独用一个变量存着它,我们把它叫
l
u
(
l
e
f
t
u
p
)
lu(left up)
lu(leftup)
回文
字符串的其他问题
926. 将字符串翻转到单调递增
如果一个二进制字符串,是以一些 0(可能没有 0)后面跟着一些 1(也可能没有 1)的形式组成的,那么该字符串是 单调递增 的。
给你一个二进制字符串 s,你可以将任何 0 翻转为 1 或者将 1 翻转为 0 。
返回使 s 单调递增的最小翻转次数。
示例 1:
输入:s = “00110”
输出:1
解释:翻转最后一位得到 00111.
【解答】
如果当前位置之前的子字符串单调递增,当前位置也满足单调递增的规则,则加上当前位置字符的字符串也单调递增,因此以使用动态规划计算使字符串 s 单调递增的最小翻转次数。
由于字符串 s 的每个位置的字符可以是 0 或 1,因此对于每个位置需要分别计算该位置的字符是 0 和该位置的字符是 1 的情况下的最小翻转次数。
①状态量的定义:
用
dp
[
i
]
[
0
]
\textit{dp}[i][0]
dp[i][0] 和
dp
[
i
]
[
1
]
\textit{dp}[i][1]
dp[i][1] 分别表示下标 i 处的字符为 0 和 1 的情况下使得
s
[
0..
i
]
s[0 .. i]
s[0..i] 单调递增的最小翻转次数。
②状态转移方程的推导:
当
1
≤
i
<
n
1 \le i < n
1≤i<n 时,考虑下标 i 处的字符。如果下标 i 处的字符是 0,则只有当下标 i - 1处的字符是 0 时才符合单调递增;如果下标 i 处的字符是 1,则下标 i - 1 处的字符是 0 或 1 都符合单调递增,此时为了将翻转次数最小化,应分别考虑下标 i - 1 处的字符是 0 和 1 的情况下需要的翻转次数,取两者的最小值。
在计算 dp [ i ] [ 0 ] \textit{dp}[i][0] dp[i][0] 和 dp [ i ] [ 1 ] \textit{dp}[i][1] dp[i][1] 时,还需要根据 s [ i ] s[i] s[i] 的值决定下标 i 处的字符是否需要翻转,因此可以得到如下状态转移方程:
d
p
[
i
]
[
0
]
=
d
p
[
i
−
1
]
[
0
]
+
I
(
s
[
i
]
=
‘
1
’
)
dp[i][0] =dp[i−1][0]+\mathbb{I}(s[i]=‘1’)
dp[i][0]=dp[i−1][0]+I(s[i]=‘1’)
d
p
[
i
]
[
1
]
=
m
i
n
(
d
p
[
i
−
1
]
[
0
]
,
d
p
[
i
−
1
]
[
1
]
)
+
I
(
s
[
i
]
=
‘
0
’
)
dp[i][1] =min(dp[i−1][0],dp[i−1][1])+\mathbb{I}(s[i]=‘0’)
dp[i][1]=min(dp[i−1][0],dp[i−1][1])+I(s[i]=‘0’)
其中 I \mathbb{I} I 为示性函数, I ( s [ i ] = ‘ 1 ’ ) \mathbb{I}(s[i]=‘1’) I(s[i]=‘1’)表示若当前 s [ i ] s[i] s[i] 的值为1,则 dp [ i ] [ 0 ] \textit{dp}[i][0] dp[i][0] 的翻转次数需要加1(当前 s [ i ] s[i] s[i] 的值从1翻转为 0)。
遍历字符串 s 计算每个下标处的状态值,遍历结束之后, dp [ n − 1 ] [ 0 ] \textit{dp}[n - 1][0] dp[n−1][0] 和 dp [ n − 1 ] [ 1 ] \textit{dp}[n - 1][1] dp[n−1][1] 中的最小值即为使字符串 s 单调递增的最小翻转次数。
③边界初始化与优化
实现方面有以下两点可以优化:
可以将边界情况定义为
dp
[
−
1
]
[
0
]
=
dp
[
−
1
]
[
1
]
=
0
\textit{dp}[-1][0] = \textit{dp}[-1][1] = 0
dp[−1][0]=dp[−1][1]=0,则可以从下标 0 开始使用状态转移方程计算状态值。
由于 dp [ i ] \textit{dp}[i] dp[i] 的值只和 dp [ i − 1 ] \textit{dp}[i - 1] dp[i−1] 有关,因此在计算状态值的过程中只需要维护前一个下标处的状态值,将空间复杂度降低到 O(1)。类似于滚动数组的思想。
int minFlipsMonoIncr(string s) {
int pre0 = 0, pre1 = 0;
for(char c:s){
int dp0 = pre0;
int dp1 = min(pre0, pre1);
if(c == '0'){
dp1 ++;
}else{
dp0 ++;
}
pre0 = dp0;
pre1 = dp1;
}
return min(dp0, dp1);
}