leetcode 经典题分类
- 链表
- 数组
- 字符串
- 哈希表
- 二分法
- 双指针
- 滑动窗口
- 递归/回溯
- 动态规划
- 二叉树
- 辅助栈
本系列专栏:点击进入 leetcode题目分类 关注走一波
前言 :本系列文章初衷是为了按类别整理
出力扣(leetcode)最经典 题目,一共100多道题,每道题都给出了非常详细的解题思路 、算法步骤 、代码实现 。很多同学刚开始刷题都是按照力扣顺序刷题,其实这样对新手不太适用,刷题效果也很不好。因为力扣题目顺序是随机的,并没有按照算法分类,导致同一类型的算法强化训练不够,最后刷完也是迷迷糊糊的。所以本系列文章就是来帮你完成算法分类,针对每种算法做强化训练,保证让你以后遇到题目直接秒杀!
文章目录
- [leetcode 经典题分类](#leetcode 经典题分类)
最长回文子串
【题目描述】
给你一个字符串 s,找到 s 中最长的回文子串。(回文子串:从正序读取子串和倒序的结果一样)
【输入输出实例】
示例 1:
输入:s = "babad"
输出:"bab" ("aba" 同样是符合题意的答案)
示例 2:
输入:s = "cbbd"
输出:"bb"
【算法思路】
对于一个子串而言,如果它是回文串,并且长度大于2,那么将它首尾的两个字母去除之后,它仍然是个回文串。例如对于字符串 "ababa",如果我们已经知道"bab"是回文串,那么"ababa"一定是回文串,这是因为它的首尾两个字母都是"a"。
根据上述思路,我们就可以用动态规划的方法解决本题。我们用二维数组P(i, j)表示字符串s的第i到j个字母组成的串是否为回文串:
其它情况包含两种可能性:
(1)s[i, j]本身不是一个回文串;
(2)i > j,此时s[i, j]本身不合法;
那么我们就可以写出动态规划的状态转移方程:
P(i, j) = P(i+1, j−1) ∧ (Si == Sj)
也就是说,只有s[i+1, j−1]是回文串,并且s的第i和j个字母相同时,s[i, j]才会是回文串。
上文的所有讨论是建立在子串长度大于2的前提之上的,我们还需要考虑动态规划中的边界条件,即子串的长度为1或2。对于长度为1的子串,它显然是个回文串;对于长度为2的子串,只要它的两个字母相同,它就是一个回文串。
因此我们就可以写出动态规划的边界条件:
根据这个思路,我们就可以完成动态规划,最终的答案即为所有P(i, j) == true中j−i+1(即子串长度)的最大值。
注意:在状态转移方程中,我们是从长度较短的字符串向长度较长的字符串进行转移的(即每次循环按照 i 和 j 相差k-1个字符进行,k起始为2,逐渐增加至s.size()),因此一定要注意动态规划的循环顺序。
【算法描述】
cpp
/**************** 动态规划 ****************/
string longestPalindrome(string s)
{
int length = s.size();
int maxlength = 1; //记录最长回文子串的长度
int begin = 0; //记录最长回文子串的起始位置
vector<vector<bool>> n(length, vector<bool>(length)); //容器里放容器,相当于二维数组
for(int i = 0; i < length; i++)
{
n[i][i] = true; //一个字符即为最短的回文子串
}
for(int k = 2; k <= length; k++)
{
for(int i = 0; i < length; i++)
{
int j = i + k - 1; //每次按照i和j相差k-1个字符进行循环,k逐渐增加,直到k为length
if(j >= length) //超出右边界,退出循环
{
break;
}
if(s[i] != s[j]) //表示从第i位置到第j位置的子串不是回文子串
{
n[i][j] = false;
}
else
{
if(i+1 <= j-1) //表示第i位置到第j位置的字符中间至少有一个字符
{
n[i][j] = n[i+1][j-1];
}
else //表示第i位置字符和第j位置字符为相邻字符
{
n[i][j] = true;
}
}
//找最长回文子串的起始下标和子串长度
if(n[i][j] && maxlength < j - i + 1)
{
maxlength = j - i + 1;
begin = i;
}
}
}
return s.substr(begin, maxlength); //返回由第begin位置开始的maxlength个字符组成的字符串
}
正则表达式匹配
【题目描述】
给你一个字符串 s
和一个字符规律 p
,请你来实现一个支持 '.'
和 '*'
的正则表达式匹配。
'.'
匹配任意单个字符'*'
匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s
的,而不是部分字符串。
【输入输出实例】
示例 1:
输入:s = "aa", p = "a"
输出:false
解释:"a" 无法匹配 "aa" 整个字符串。
示例 2:
输入:s = "aa", p = "a*"
输出:true
解释:因为 '*' 代表可以匹配零个或多个前面的那一个元素, 在这里前面的元素就是 'a'。因此,字符串 "aa" 可被视为 'a' 重复了一次。
示例 3:
输入:s = "ab", p = "."
输出:true
解释:"." 表示可匹配零个或多个('*')任意字符('.')。
【算法思路】
设 s 的长度为 n , p的长度为 m ;将 s 的第 i 个字符记为 si,p 的第 j 个字符记为 pj ,将 s 的前 i 个字符组成的子字符串记为 s[:i] , 同理将 p 的前 j 个字符组成的子字符串记为 p[:j]。本题可转化为求 s[:n] 是否能和 p[:m] 匹配。
总体思路是从 s[:1] 和 p[:1] 开始判断是否能匹配,每轮添加一个字符并判断是否能匹配,直至添加完整个字符串 s 和 p 。展开来看,假设 s[:i] 与 p[:j] 可以匹配,那么下一状态有两种:
- 添加一个字符 si+1 后是否能匹配?
- 添加一个字符 pj+1 后是否能匹配?
因此,本题的状态共有 m×n 种,定义状态矩阵 dp ,dp[i][j]
代表 s[:i] 与 p[:j] 是否可以匹配。
- 状态定义: 设动态规划矩阵 dp ,
dp[i][j]
代表字符串 s 的前 i 个字符和 p 的前 j 个字符能否匹配。 - 转移方程: 需要注意,由于
dp[0][0]
代表的是空字符的状态, 因此dp[i][j]
对应的添加字符是 s[i - 1] 和 p[j - 1] 。- 当
p[j-1] == '*'
时,dp[i][j]
在当以下任一情况成立时都等于 true:dp[i][j-2]
:即将字符组合p[j-2] *
看作是 p[j-2] 出现 0 次时,能否匹配;dp[i-1][j] 且 s[i-1] == p[j-2]
:即让字符 p[j - 2] 多出现 1 次去匹配 s[i - 1] 时,能否匹配;dp[i-1][j] 且 p[j-2] == '.'
:即让字符 '.' 多出现 1 次去匹配 s[i - 1] 时,能否匹配。
- 当
p[j-1] != '*'
时,dp[i][j]
在当以下任一情况成立时都等于 true:dp[i-1][j-1] 且 s[i-1] == p[j-1]
:即在 s[:i-1] 和 p[:j-1] 字符匹配的情况下,新加入的字符 s[i - 1] 和 p[j - 1] 相等时,会匹配。dp[i-1][j-1] 且 p[j-1] == '.'
:即在 s[:i-1] 和 p[:j-1] 字符匹配的情况下,新加入的字符 p[i - 1] 为 '.' 时,会匹配。
- 当
- 初始化: 需要先初始化 dp 矩阵首行,以避免状态转移时索引越界。
dp[0][0] = true
: 代表两个空字符串能够匹配;dp[0][j] = dp[0][j-2] 且 p[j-1] == '*'
:首行 s 为空字符串,因此当 p 的偶数位为 '*' 时才能够匹配(即让 p 的奇数位出现 0 次,也就是 p 中 * 前一位出现 0 次,保持 p 是空字符串)。因此循环遍历字符串 p,步长为 2(即只看偶数位,奇数位默认为false,因为不可能匹配)。
- 返回值: dp 矩阵最右下角值,代表字符串 s 和 p 能否匹配。
如下图举例说明:
【算法描述】
cpp
bool isMatch(string s, string p) {
int sLen = s.size();
int pLen = p.size();
std::vector<std::vector<bool>> dp(sLen+1, std::vector<bool>(pLen+1, false));
// 初始化
dp[0][0] = true;
for(int i = 2; i <= pLen; i += 2) {
dp[0][i] = dp[0][i-2] && (p[i-1] == '*');
}
// 动态规划
for(int i = 1; i <= sLen; ++i) {
for(int j = 1; j <= pLen; ++j) {
if(p[j-1] == '*') {
dp[i][j] = dp[i][j-2]
|| (dp[i-1][j] && (s[i-1] == p[j-2]))
|| (dp[i-1][j] && (p[j-2] == '.'));
// 简化
// dp[i][j] = dp[i][j-2] || (dp[i-1][j] && (s[i-1] == p[j-2] || p[j-2] == '.'));
}
else {
dp[i][j] = (dp[i-1][j-1] && (s[i-1] == p[j-1]))
|| (dp[i-1][j-1] && (p[j-1] == '.'));
// 简化
// dp[i][j] = (dp[i-1][j-1] && (s[i-1] == p[j-1] || p[j-1] == '.'));
}
}
}
return dp[sLen][pLen];
}
括号生成
【题目描述】
数字n代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且有效的括号组合。
【输入输出实例】
示例 1:
输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]
示例 2:
输入:n = 1
输出:["()"]
【算法思路】
当我们知道所有 i < n 时括号的有效排列后,对于 i = n 的情况,必定是由一组完整的括号 "( )" 和 i = n - 1 时的有效排列组成。那么,剩下 n - 1 组括号有可能在哪呢?
剩下的括号要么在这一组新增的括号内部,要么在这一组新增括号的外部(右侧)。既然知道了 i < n 的情况,那我们就可以对所有情况进行遍历:
"(" +【i=p时所有括号的排列组合】+ ")" +【i=q时所有括号的排列组合】
其中 p + q = n - 1,且 p 和 q 均为非负整数。
事实上,当上述 p 从 0 取到 n - 1,q 从 n - 1取到 0 后,所有情况就遍历完了。
【算法描述】
cpp
//动态规划
vector<string> generateParenthesis(int n) {
vector<vector<string>> dp(n+1); //dp[i]表示i组括号的所有组合
dp[0] = {""}; //初始化
dp[1] = {"()"};
for(int i = 2; i <= n; i++)
{
for(int j = 0; j < i; j++)
{
for(string p : dp[j]) //循环时依次将dp[j]中的每一项赋给p,即遍历dp[j]的所有有效组合
{
for(string q : dp[i-j-1]) //遍历dp[i-j-1]的所有有效组合
{
dp[i].push_back("(" + p + ")" + q); //动态规划方程
}
}
}
}
return dp[n]; //返回n组括号的所有有效组合
}
外观数列
【题目描述】
给定一个正整数 n ,输出外观数列的第 n 项。「外观数列」是一个整数序列,从数字 1 开始,序列中的每一项都是对前一项的描述。你可以将其视作是由递归公式定义的数字字符串序列:
countAndSay(1) = "1"
countAndSay(n)
是对countAndSay(n-1)
的描述,然后转换成另一个数字字符串。
前五项如下:
1 第一项是数字 1
11 描述前一项,前一项是 1 ,即 " 一 个 1 ",记作 "11"
21 描述前一项,前一项是 11 ,即 " 二 个 1 " ,记作 "21"
1211 描述前一项,前一项是 21 ,即 " 一 个 2 + 一 个 1 " ,记作 "1211"
111221 描述前一项,前一项是 1211 ,即 " 一 个 1 + 一 个 2 + 二 个 1 " ,记作 "111221"
要描述一个数字字符串,首先要将字符串分割为最小数量的组,每个组都由连续的最多相同字符组成。然后对于每个组,先描述字符的数量,然后描述字符,形成一个描述组。要将描述转换为数字字符串,先将每组中的字符数量用数字替换,再将所有描述组连接起来。
例如,数字字符串 "3322251" 的描述如下图:
【输入输出实例】
示例 1:
输入:n = 1
输出:"1"
示例 2:
输入:n = 4
输出:"1211"
解释:
countAndSay(1) = "1"
countAndSay(2) = 读 "1" = 一 个 1 = "11"
countAndSay(3) = 读 "11" = 二 个 1 = "21"
countAndSay(4) = 读 "21" = 一 个 2 + 一 个 1 = "12" + "11" = "1211"
【算法思路】
使用动态规划:
(1)找子问题:dp[i]表示第 i+1 项外观数列。
(2)动态方程思想:每次求 dp[i] 时,通过遍历前一项数列 dp[i-1] ,记录前一项数列中每个数的连续出现次数。将描述字符的数量和描述字符不断地加到 dp[i] 中,即可得到第 i+1 项外观数列 dp[i]。
(3)找边界(初始化):第 1 项外观数列 dp[0] 就是 "1"。
(4)找输出:返回题目中所求的第 n 项外观数列,即为 dp[n-1]。
(5)空间优化:根据动态方程,求 dp[i] 只和 dp[i-1] 有关,则可以使用「滚动变量」将代码进行优化。
【算法描述】
cpp
//动态规划
string countAndSay(int n) {
vector<string> dp(n); //dp[i]表示第 i+1 项外观数列
dp[0] = "1"; //初始化
for(int i = 1; i < n; i++) //算出每一项外观数列dp[i]
{
int count = 1; //记录前一项数列中每个数的连续出现次数
int len = dp[i-1].size();
for(int j = 1; j < len; j++) //遍历前一项数列
{
if(dp[i-1][j] != dp[i-1][j-1])
{
dp[i] += count + '0'; //描述字符的数量
dp[i] += dp[i-1][j-1]; //描述字符
count = 1;
continue;
}
count++;
}
dp[i] += count + '0'; //记录最后一个字符数字
dp[i] += dp[i-1][len-1];
}
return dp[n-1]; //返回第 n 项外观数列
}
//动态规划------空间优化
string countAndSay(int n) {
string prestr = "1"; //表示dp[i-1],第 i 项外观数列
string currstr; //表示dp[i],第 i+1 项外观数列
for(int i = 1; i < n; i++) //算出每一项外观数列
{
int count = 1; //记录前一项数列中每个数的连续出现次数
int len = prestr.size();
for(int j = 1; j < len; j++) //遍历前一项数列
{
if(prestr[j] != prestr[j-1])
{
currstr += count + '0'; //描述字符的数量
currstr += prestr[j-1]; //描述字符
count = 1;
continue;
}
count++;
}
currstr += count + '0'; //记录最后一个字符数字
currstr += prestr[len-1];
prestr = currstr; //滚动变量更新
currstr.clear();
}
return prestr; //返回第 n 项外观数列
}
接雨水
【题目描述】
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
【输入输出实例】
示例 1:
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
示例 2:
输入:height = [4,2,0,3,2,5]
输出:9
【算法思路】
一、双指针法
按照列来计算雨水的体积,那么每一列雨水的宽度一定是1,我们再把每一列的雨水的高度求出来就可以了。
可以看出每一列雨水的高度,取决于该列左侧最高的柱子和右侧最高的柱子中最矮的那个柱子的高度,同时再减去该列柱子的高度。
例如:求下图中第4列雨水的体积
列4左侧最高的柱子是列3,高度为2(以下用lHeight表示)。
列4右侧最高的柱子是列7,高度为3(以下用rHeight表示)。
列4 柱子的高度为1(以下用height表示)
那么列4的雨水高度为 列3和列7的高度最小值 减 列4高度,即:min(lHeight, rHeight) - height
。列4的雨水高度求出来了,宽度为1,相乘就是列4的雨水体积。
按照上面的方法,只要从头遍历一遍所有的列,然后求出每一列雨水的体积,相加之后就是总雨水的体积了。(注意第一个柱子和最后一个柱子不接雨水)
二、动态规划
本质类似于双指针法,只是用动态规划来提前求出每个柱子的最大左边柱子和最大右边柱子。因为在上述双指针法中,每遍历一列,都要重新求该列的最大左边柱子和最大右边柱子,重复了很多不必要的工作。
所以用动态规划来求maxLeft[i] = max(maxLeft[i-1], height[i])
,只需要将前一根柱子的maxLeft[i-1]和当前柱子高度进行比较,若当前柱子高度较大,说明当前柱子的最大左边柱子就是自己本身。
得出所有柱子的最大左边柱子和最大右边柱子后,再通过遍历每根柱子求出每一列雨水的体积,相加之后就是总雨水的体积了。
【算法描述】
cpp
//双指针法(超时)
int trap(vector<int>& height)
{
int len = height.size();
int sum = 0; //记录接的雨水
for(int i = 1; i < len - 1; i++) //遍历每列(除第一列和最后一列,因为它们不会接雨水)
{
int maxLeft = 0; //左边最高柱子的高度
int maxRight = 0; //右边最高柱子的高度
for(int left = i - 1; left >= 0; left--) //找左边最高柱子
{
maxLeft = max(maxLeft, height[left]);
}
for(int right = i + 1; right < len; right++) //找右边最高柱子
{
maxRight = max(maxRight, height[right]);
if(maxRight > maxLeft) //因为最后要取两者最小,所以只要超过左边最高柱子,直接退出
{
break;
}
}
int rainHeight = min(maxLeft, maxRight) - height[i]; //计算雨水高度
sum += (rainHeight > 0) ? rainHeight : 0;
}
return sum;
}
//动态规划------相当于双指针法,只是用动态规划来提前求出每个柱子的最大左边柱子和最大右边柱子
int trap(vector<int>& height)
{
int len = height.size();
int sum = 0; //记录接的雨水
vector<int> maxLeft(len); //记录第i列左边柱子最大高度
vector<int> maxRight(len); //记录第i列右边柱子最大高度
maxLeft[0] = height[0]; //初始化
maxRight[len-1] = height[len-1];
//记录每个柱子左边柱子最大高度
for(int i = 1; i < len-1; i++)
{
maxLeft[i] = max(maxLeft[i-1], height[i]);
}
//记录每个柱子右边柱子最大高度
for(int i = len-2; i > 0; i--)
{
maxRight[i] = max(maxRight[i+1], height[i]);
}
//遍历每列算雨水量再求和
for(int i = 1; i < len - 1; i++)
{
int rainHeight = min(maxLeft[i-1], maxRight[i+1]) - height[i];
sum += (rainHeight > 0) ? rainHeight : 0;
}
return sum;
}
通配符匹配
【题目描述】
给定一个字符串 (s) 和一个字符模式 § ,实现一个支持 '?'
和 '*'
的通配符匹配。
'?'
可以匹配任何单个字符(不包括空字符串)。'*'
可以匹配任意字符串(包括空字符串)。
两个字符串完全匹配才算匹配成功。
说明:
- s 可能为空,且只包含从 a-z 的小写字母。
- p 可能为空,且只包含从 a-z 的小写字母,以及字符
'?'
和'*'
。
【输入输出实例】
示例 1:
输入: s = "aa",p = "a"
输出: false ("a" 无法匹配 "aa" 整个字符串)
示例 2:
输入: s = "aa",p = "*"
输出: true ( '*'
可以匹配任意字符串)
示例 3:
输入: s = "cb",p = "?a"
输出: false ('?'
可以匹配 'c', 但第二个 'a' 无法匹配 'b')
示例 4:
输入: s = "adceb",p = "*a*b"
输出: true (第一个 '*'
可以匹配空字符串, 第二个 '*'
可以匹配字符串 "dce")
示例 5:
输入: s = "acdcb",p = "a*c?b"
输出: false
【算法思路】
我们来看这样一个表:
其中,横轴为string s,纵轴为pattern p,这个表第(m, n)个格子的意义是:【p从0位置到m位置】这一整段,是否能与【s从0位置到n位置】这一整段匹配。
也就是说,如果表格的下面这一个位置储存的是T(True),说明 "adcb" 和 "a*b" 这一段是可以匹配的,你不用管前面发生了什么,你只要知道,这两段是匹配的,这就是为什么要用动态规划的原因。
在给定的模式串 p 中,只会有三种类型的字符出现:
- 小写字母 a~z,可以匹配对应的一个小写字母;
- 问号 '?',可以匹配任意一个小写字母;
- 星号 '*',可以匹配任意字符串,可以为空,也就是匹配零或任意多个小写字母。
其中「小写字母」和「问号」的匹配是确定的 ,而「星号」的匹配是不确定的,因此我们需要枚举所有的匹配情况。
(1)找子问题:我们用 dp[i][j]
表示字符串 s 的前 i个字符和模式串 p 的前 j 个字符是否能匹配。
(2)动态方程思想:在进行状态转移时,我们可以考虑模式串 p 的第 j 个字符p[j],与之对应的是字符串 s 中的第 i 个字符 s[i]:
- 如果 p[j] 是小写字母 a~z,那么 s[i] 必须也为相同的小写字母,状态转移方程为:
dp[i][j] = (s[i] == p[j]) && dp[i-1][j-1]
- 如果 p[j] 是问号 '?',那么对 s[i] 没有任何要求,状态转移方程为:
dp[i][j] = dp[i-1][j-1]
- 如果 p[j] 是星号 '*',那么同样对 s[i] 没有任何要求,但是星号可以匹配零或任意多个小写字母,因此状态转移方程分为两种情况,即:使用或不使用这个星号。如果我们不使用这个星号(
'*'
代表是空字符),那么就会从dp[i][j--1]
转移而来;如果我们使用这个星号('*'
代表非空任何字符),那么就会从dp[i-1][j]
转移而来。因此状态转移方程为:
dp[i][j] = dp[i-1][j] || dp[i][j-1]
我们也可以将前两种转移进行归纳,最终的状态转移方程如下:
dp[i][j] = (s[i] == p[j] || p[j] == '?') && dp[i-1][j-1]
dp[i][j] = (p[j] == '*') && (dp[i-1][j] || dp[i][j-1])
(3)找边界(初始化):
-
dp[0][0]
:表示 s 的第一个空字符和 p 的第一个空字符匹配,所以为true
; -
第一行
dp[0][j]
,s 为空字符,与 p 匹配,所以只要 p 开始为*
才为true
-
第一列
dp[i][0]
,当然全部为False
(4)找输出:要判定是否成功匹配,只看最右下角的格子,即为dp[m][n]
,其中 m 和 n 分别是字符串 s 和模式 p 的长度。
【算法描述】
cpp
bool isMatch(string s, string p)
{
int m = s.size(); //行为字符串s
int n = p.size(); //列为字符串p
vector<vector<bool>> dp(m+1, vector<bool>(n+1)); //dp[i][j]表示 字符串s到i位置 和 字符串p到j位置 是否匹配
dp[0][0] = true; //初始化dp[0][0],表示s的第一个空字符和p的第一个空字符匹配
//初始化第一列dp[i][0],当然全部为false
//初始化第一行dp[0][j],s为空字符,与p匹配,所以只要p开始为'*'才为true
for(int j = 1; j <= n; j++)
{
dp[0][j] = (p[j-1] == '*') ? dp[0][j-1] : false;
}
//填充二维数组,判断s[0:i]和p[0:j]是否可匹配
for(int i = 1; i <= m; i++)
{
for(int j = 1; j <= n; j++)
{
//因为dp[0][0]初始化为s的第一个空字符和p的第一个空字符匹配,所以下面s和p字符下标都要-1
//动态规划方程:单个字符匹配,'?'可以匹配任何单个字符
if(dp[i-1][j-1] && (s[i-1] == p[j-1] || p[j-1] == '?'))
{
dp[i][j] = true;
}
//动态规划方程:遇到字符串p为'*'时,
//dp[i-1][j]是从使用了'*'转移过来,'*'代表非空任何字符,例如abcd, ab*
//dp[i][j-1]是从不使用'*'转移过来,'*'代表是空字符,例如ab, ab*
if(p[j-1] == '*' && (dp[i-1][j] || dp[i][j-1]))
{
dp[i][j] = true;
}
// 简化
// dp[i][j] = (dp[i-1][j-1] && (s[i-1] == p[j-1] || p[j-1] == '?'))
// || (p[j-1] == '*' && (dp[i][j-1] || dp[i-1][j]));
}
}
return dp[m][n]; //要判定是否成功匹配,只看最右下角的格子
}
跳跃游戏II
【题目描述】
给定一个长度为 n 的整数数组 nums。初始位置为 nums[0]。每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:
- 0 <= j <= nums[i]
- i + j < n
返回到达 nums[n - 1] 的最小跳跃次数。生成的测试用例均可以到达 nums[n - 1]。
【输入输出实例】
示例 1:
输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。
示例 2:
输入: nums = [2,3,0,1,4]
输出: 2
【算法思路】
方法一:贪心算法
-
如果某一个作为起跳点 的格子可以跳跃的距离是 3,那么表示后面 3 个格子都可以作为下一次起跳点 。在当前跳跃区间内,可以对每一个能作为起跳点 的格子都尝试跳一次,不断更新能跳到的最远距离 ,这个能跳到的最远距离就是下一次跳跃区间的终点。
-
所以,当一次跳跃结束时,从下一个格子开始,到现在能跳到最远的距离 ,就是下一次的跳跃区间。
- 对每一次跳跃用 for 循环来模拟。
- 跳完一次之后,更新下一次的跳跃区间 和跳跃次数。
- 在新的范围内跳,继续更新能跳到最远的距离。
- 记录跳跃次数,如果跳到了终点,就得到了结果。
贪心算法优化:
- 从上面代码观察发现,其实被 while 包含的 for 循环中,i 是从头跑到尾的。
- 其实只需要在一次跳跃完成时,更新下一次能跳到最远的距离 ,并以此刻作为时机来更新跳跃次数。
- 则可以在一次 for 循环中完成。
方法二:动态规划
(1)找子问题:dp[i] 表示到达 nums[i] 的最小跳跃次数。
(2)动态方程思想:每次求 dp[i] 时,需要遍历 nums[i] 之前的所有项,找出能到达 nums[i] 的项,再在这些能到达 nums[i] 的项里面找出最小跳跃次数,即为 dp[i]。
cpp
for(int j = 0; j < i; j++) //遍历nums[i]之前的所有项
{
if(nums[j] + j >= i) //找能到达nums[i]的最小跳跃次数
{
minJump = min(minJump, dp[j]+1);
}
}
(3)找边界(初始化):到达 nums[0] 不需要跳跃,则最小跳跃次数为 0,即dp[0] = 0;
(4)找输出:返回到达 nums[n-1] 的最小跳跃次数,即为 dp[n-1]。
【算法描述】
cpp
//方法一:贪心算法
int jump(vector<int>& nums) {
int minJump = 0; //记录到达nums[n-1]的最小跳跃次数
int start = 0; //记录当前能跳跃到的起始位置(闭区间)
int end = 1; //记录当前能跳跃到的终点位置(开区间)
while(end < nums.size())
{
int maxStep = 0; //记录在当前区间内能跳到的最远距离
for(int i = start; i < end; i++)
{
maxStep = max(maxStep, nums[i]+i);
}
start = end; //下一次起跳点范围开始位置
end = maxStep + 1; //下一次起跳点范围结束位置
minJump++; // 跳跃次数
}
return minJump;
}
//贪心算法------优化
int jump(vector<int>& nums) {
int minJump = 0; //记录到达nums[n-1]的最小跳跃次数
int end = 0; //记录当前能跳跃到的终点位置(闭区间)
int maxStep = 0; //记录在当前区间内能跳到的最远距离
for(int i = 0; i < nums.size() - 1; i++)
{
maxStep = max(maxStep, nums[i]+i);
if(i == end) //达到当前区间末尾
{
end = maxStep; //下一次起跳点范围结束位置
minJump++; // 跳跃次数
}
}
return minJump;
}
//方法二:动态规划(时间消耗比贪心算法大得多)
int jump(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n); //dp[i]表示到达nums[i]的最小跳跃次数
dp[0] = 0; //初始化
for(int i = 1; i < n; i++) //遍历nums
{
int minJump = i; //记录到nums[i]的最小跳跃次数
for(int j = 0; j < i; j++) //遍历i项之前
{
if(nums[j] + j >= i) //找到nums[i]的最小跳跃次数
{
minJump = min(minJump, dp[j]+1);
}
}
dp[i] = minJump;
}
return dp[n-1]; //返回到达nums[n-1]的最小跳跃次数
}
最大子数组和
【题目描述】
给定一个整数数组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
【算法思路】
题目只要求返回结果,不要求得到最大的连续子数组是哪一个。这样的问题通常可以使用「动态规划」解决。
动态规划算法:
关键:如何定义子问题(如何定义状态)?
设计状态思路:把不确定的因素确定下来,进而把子问题定义清楚,把子问题定义得简单。动态规划的思想通过解决了一个一个简单的问题,进而把简单的问题的解组成了复杂的问题的解。
我们不知道和最大的连续子数组一定会选哪一个数,那么我们可以求出所有经过输入数组的某一个数的连续子数组的最大和:
例如,输入数组是[-2,1,-3,4,-1,2,1,-5,4],可以求出以下子问题:
-
子问题 1:经过 -2 的连续子数组的最大和是多少;
-
子问题 2:经过 1 的连续子数组的最大和是多少;
-
子问题 3:经过 -3 的连续子数组的最大和是多少;
-
子问题 4:经过 4 的连续子数组的最大和是多少;
-
子问题 5:经过 -1 的连续子数组的最大和是多少;
-
子问题 6:经过 2 的连续子数组的最大和是多少;
-
子问题 7:经过 1 的连续子数组的最大和是多少;
-
子问题 8:经过 -5 的连续子数组的最大和是多少;
-
子问题 9:经过 4 的连续子数组的最大和是多少。
一共9个子问题,这些子问题之间的联系并没有那么好看出来,这是因为子问题的描述还有不确定的地方(有后效性)。
例如「子问题 3」:经过 -3 的连续子数组的最大和是多少。
「经过 -3 的连续子数组」我们任意举出几个:
-
[-2,1,-3,4] ,-3 是这个连续子数组的第 3 个元素;
-
[1,-3,4,-1] ,-3 是这个连续子数组的第 2 个元素;
-
......
我们不确定的是:-3 是连续子数组的第几个元素。那么我们就把 -3 定义成连续子数组的最后一个元素。在新的定义下,我们列出子问题如下:
-
子问题 1:以 -2 结尾的连续子数组的最大和是多少;
-
子问题 2:以 1 结尾的连续子数组的最大和是多少;
-
子问题 3:以 -3 结尾的连续子数组的最大和是多少;
-
子问题 4:以 4 结尾的连续子数组的最大和是多少;
-
子问题 5:以 -1 结尾的连续子数组的最大和是多少;
-
子问题 6:以 2 结尾的连续子数组的最大和是多少;
-
子问题 7:以 1 结尾的连续子数组的最大和是多少;
-
子问题 8:以 -5 结尾的连续子数组的最大和是多少;
-
子问题 9:以 4 结尾的连续子数组的最大和是多少。
我们加上了「结尾的」,这些子问题之间就有了联系。我们单独看子问题 1 和子问题 2:
子问题 1:以 -2 结尾的连续子数组的最大和是多少;
以 -2 结尾的连续子数组是 [-2],因此最大和就是 -2。
子问题 2:以 1 结尾的连续子数组的最大和是多少;
以 1 结尾的连续子数组有 [-2,1] 和 [1] ,其中 [-2,1] 就是在「子问题 1」的后面加上1得到。-2 + 1 = -1 < 1−2+1=−1<1 ,因此「子问题 2」的答案是 1。
根据子问题得到:如果编号为i的子问题的结果是负数或者0,那么编号为 i + 1 的子问题就可以把编号为 i 的子问题的结果舍弃掉。否则编号为 i + 1 的子问题为编号为 i 的子问题 + nums[i]。
根据上面对子问题的分析,编写动态规划题解的步骤如下:
(1)定义状态(定义子问题)
dp[i]:表示以 nums[i] 结尾的连续子数组的最大和。
说明:「结尾」和「连续」是关键字。
(2)状态转移方程(描述子问题之间的联系)
-
如果 dp[i - 1] > 0,那么可以把nums[i]直接接在dp[i - 1]表示的那个数组的后面,得到和更大的连续子数组;
-
如果 dp[i - 1] <= 0,那么nums[i]加上前面的数dp[i - 1]以后值不会变大。于是dp[i]"另起炉灶",此时单独的一个nums[i]的值,就是dp[i]。
根据以上两种情况,写出如下状态转移方程:
因为以上两种情况的最大值就是 dp[i] 的值,则状态转移方程也可以写成:
(3)初始化(思考初始值)
dp[0] 根据定义,只有1个数,以nums[0]结尾,则dp[0] = nums[0]。
(4)思考输出
这里状态的定义不是题目中的问题的定义,不能直接将最后一个状态返回回去;
这个问题的输出是把所有的dp[0]、dp[1]、......、dp[n - 1] 都遍历一遍,取最大值。
(5)是否可优化空间
根据「状态转移方程」,dp[i]的值只和i以前的值(dp[i-1]、nums[i])有关,因此可以使用「滚动变量」的方式将代码进行优化。
【算法描述】
cpp
/********************* 动态规划 *********************/
int maxSubArray(vector<int>& nums)
{
int len = nums.size();
vector<int> dq(len); //dp[i]表示:以nums[i]结尾的连续子数组的最大和
dq[0] = nums[0]; //初始化
int Max = dq[0]; //记录连续子数组的最大和
for(int i = 1; i < len; i++) //根据状态方程更新所有的dp[i]
{
//如果以nums[i-1]结尾的连续子数组的最大和 <= 0,以nums[i]结尾的连续子数组直接为当前元素nums[i]
//否则为当前元素 + 以nums[i-1]为结尾的连续子数组的最大和
if(dq[i - 1] <= 0)
{
dq[i] = nums[i];
}
else
{
dq[i] = dq[i - 1] + nums[i];
}
}
for(int i = 1; i < len; i++) //遍历dq[i],找最大值
{
Max = max(Max, dq[i]);
}
return Max;
}
/***************** 动态规划------空间优化*****************/
int maxSubArray(vector<int>& nums)
{
int dq = nums[0]; //表示以当前元素为结尾的连续子数组的最大和
int Max = dq; //记录连续子数组的最大和
for(int i = 1; i < nums.size(); i++)
{
dq = max(nums[i], dq + nums[i]); //更新dq
Max = max(Max, dq); //每更新一次dq,则找一次最大值
}
return Max;
}
/***************** 动态规划------空间优化*****************/
int maxSubArray(vector<int>& nums)
{
int Max = nums[0]; //存放最大和
for(int i = 1; i < nums.size(); i++)
{
//若nums数组第i个元素之前的连续元素和 <= 0,则舍去之前的元素,重新开始
//若nums数组第i个元素之前的连续元素和 > 0,则将和加到当前元素nums[i]上
if(nums[i-1] > 0)
{
nums[i] += nums[i-1];
}
Max = max(Max, nums[i]); //每次更新nums[i]后,要找最大值
}
return Max;
}
不同路径
【题目描述】
一个机器人位于一个 m × n 网格的左上角(起始点在下图中标记为Start)。机器人每次只能向下或者向右移动一步,机器人试图达到网格的右下角(在下图中标记为Finish)。
求总共有多少条不同的路径?
【输入输出实例】
示例 1:
输入:m = 3, n = 7
输出:28
示例 2:
输入:m = 3, n = 2
输出:3
从左上角开始,总共有3条路径可以到达右下角。
(1)向右 -> 向下 -> 向下
(2)向下 -> 向下 -> 向右
(3)向下 -> 向右 -> 向下
【算法思路】
经典的动态规划解法:
(1)找子问题:设dp[i][j]
存放到达(i, j)的所有路径数。
(2)动态方程思想:到达右下角的路径数 = 到达右下角左边一格的路径数 + 到达右下角上面一格的路径数。即可列出动态方程:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
(3)找边界(初始化):对于第一行dp[0][j]
,或者第一列dp[i][0]
,由于都是在边界,只有一条路径,所以路径数只能为1。
(4)空间优化:根据动态方程,dp[i][j]
的值只和dp[i-1][j]
、dp[i][j-1]
有关,因此可以使用「滚动变量」的方式将代码进行优化。可以只使用一行的空间来完成,甚至可以只使用3个变量。
【算法描述】
cpp
//动态规划
int uniquePaths(int m, int n) {
//边界:第一行和第一列的路径数都为1,则dp全部初始化为1
vector<vector<int>> dp(m, vector<int>(n, 1)); //存放(i,j)的路径数
for(int i = 1; i < m; i++)
{
for(int j = 1; j < n; j++)
{
//动态方程:到达右下角的路径数 = 到达右下角左边一格的路径数 + 到达右下角上面一格的路径数
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m-1][n-1]; //返回最右下角的路径数
}
//动态规划------------空间优化
int uniquePaths(int m, int n) {
vector<int> dp(n, 1); //初始化边界条件
for(int i = 1; i < m; i++)
{
for(int j = 1; j < n; j++)
{
dp[j] = dp[j] + dp[j-1]; //右边dp[j]表示上一格路径数,dp[j-1]表示左一格路径数
}
}
return dp[n-1]; //返回最右下角的路径数
}
不同路径II
【题目描述】
一个机器人位于一个 m × n 网格的左上角(起始点在下图中标记为Start)。机器人每次只能向下或者向右移动一步,机器人试图达到网格的右下角(在下图中标记为Finish)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?(网格中的障碍物和空位置分别用1和0来表示)
【输入输出实例】
示例 1:
输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
从左上角到右下角一共有2条不同的路径:
(1)向右 -> 向右 -> 向下 -> 向下
(2)向下 -> 向下 -> 向右 -> 向右
示例 2:
输入:obstacleGrid = [[0,1],[0,0]]
输出:1
【算法思路】
经典的动态规划解法:
(1)找子问题:设dp[i][j]
存放到达(i, j)的所有路径数。
(2)动态方程思想:若当前格(i, j)无障碍物,则到达(i, j)的路径数 = 到达(i-1, j)的路径数 + 到达(i, j-1)的路径数。若当前格(i, j)有障碍物,则到达(i, j)的路径数为0。即可列出动态方程:
(3)找边界(初始化):对于第一行dp[0][j]
,或者第一列dp[i][0]
,由于都是在边界,只要没有障碍物则只有一条路径,所以路径数只能为1。若边界上出现障碍,则边界上后面的路都不通,路径数为0。
(4)空间优化:根据动态方程,dp[i][j]
的值只和dp[i-1][j]
、dp[i][j-1]
有关,因此可以使用「滚动变量」的方式将代码进行优化。
【算法描述】
cpp
//动态规划
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size(); //行数
int n = obstacleGrid[0].size(); //列数
vector<vector<int>> dp(m, vector<int>(n, 0)); //存放(i,j)的路径数
for(int i = 0; i < m; i++) //边界:初始化第一行
{
if(obstacleGrid[i][0]) //若出现障碍,则后面路都不通
{
break;
}
dp[i][0] = 1; //边界点上路径为1
}
for(int j = 0; j < n; j++) //边界:初始化第一列
{
if(obstacleGrid[0][j]) //若出现障碍,则后面路都不通
{
break;
}
dp[0][j] = 1; //边界点上路径为1
}
for(int i = 1; i < m; i++)
{
for(int j = 1; j < n; j++)
{
//若当前位置无障碍物,则将该位置的上一位置和左一位置的路径数相加,即为该位置路径数
if(!obstacleGrid[i][j])
{
dp[i][j] = dp[i-1][j] + dp[i][j-1]; //动态方程
}
else //若当前位置有障碍物,路径数为0
{
dp[i][j] = 0;
}
}
}
return dp[m-1][n-1]; //返回最右下角的路径数
}
//动态规划------------空间优化
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size(); //行数
int n = obstacleGrid[0].size(); //列数
vector<int> dp(n); //全部初始化为0
dp[0] = (obstacleGrid[0][0] == 0);
for(int i = 0; i < m; i++)
{
for(int j = 0; j < n; j++)
{
//若当前位置有障碍物,路径数为0
if(obstacleGrid[i][j])
{
dp[j] = 0;
continue;
}
//若当前位置无障碍物且j > 0,路径数为上一位置和左一位置的路径数之和
if(j > 0)
{
dp[j] = dp[j] + dp[j-1]; //右边dp[j]表示上一格路径数,dp[j-1]表示左一格路径数
}
}
}
return dp[n-1]; //返回最右下角的路径数
}
【知识点】
问题:怎么想到用动态规划来解决这个问题呢?我们需要从问题本身出发,寻找一些有用的信息,例如本题中:
-
(i, j)位置只能从(i-1, j)和(i, j-1)走到,这样的条件就是在告诉我们这里转移是「无后效性」的,即 f(i, j) 和任何的 f(i', j') (i' > i, j' > j) 无关。
-
动态规划的题目分为两大类:一种是求最优解类 ,典型问题是背包问题;另一种就是计数类,比如这里的统计方案数的问题,它们都存在一定的递推性质。前者的递推性质还有一个名字,叫做「最优子结构」------即当前问题的最优解取决于子问题的最优解;后者类似,当前问题的方案数取决于子问题的方案数。所以在遇到求方案数的问题时,我们可以往动态规划的方向考虑。
有了这两点要素,这个问题八成可以用动态规划来解决。
最小路径和
【题目描述】
给定一个包含非负整数的 m×n 网格grid,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。说明:每次只能向下或者向右移动一步。
【输入输出实例】
示例 1:
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
示例 2:
输入:grid = [[1,2,3],[4,5,6]]
输出:12
【算法思路】
经典的动态规划解法:
(1)找子问题:设dp[i][j]
存放到达(i, j)的最小路径数字总和。
(2)动态方程思想:到达右下角的最小路径数字总和 = 右下角的路径数字 + 到达右下角左边一格和到达右下角上面一格中的较小路径数字和。即可列出动态方程:
dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1])
(3)找边界(初始化):对于第一行dp[0][j]
,或者第一列dp[i][0]
,由于都是在边界,只有一条路径,所以路径数只能为1。
(4)空间优化:根据动态方程,dp[i][j]
的值只和dp[i-1][j]
、dp[i][j-1]
有关,因此可以使用「滚动变量」的方式将代码进行优化。
【算法描述】
cpp
//动态规划
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size(); //行数
int n = grid[0].size(); //列数
vector<vector<int>> dp(m, vector<int>(n)); //存放到(i,j)的最小路径
dp[0][0] = grid[0][0];
for(int i = 1; i < m; i++) //边界:初始化第一列
{
dp[i][0] = grid[i][0] + dp[i-1][0]; //边界上只有一条路径
}
for(int j = 1; j < n; j++) //边界:初始化第一行
{
dp[0][j] = grid[0][j] + dp[0][j-1]; //边界上只有一条路径
}
for(int i = 1; i < m; i++)
{
for(int j = 1; j < n; j++)
{
//动态方程:到达右下角的最小路径 = 右下角的路径数字 + 到达右下角左边一格和到达右下角上面一格中的较小路径
dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]);
}
}
return dp[m-1][n-1]; //返回右下角最小路径
}
//动态规划------空间优化
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size(); //行数
int n = grid[0].size(); //列数
vector<int> dp(n);
dp[0] = grid[0][0];
for(int j = 1; j < n; j++) //边界:初始化第一行
{
dp[j] = dp[j-1] + grid[0][j]; //第一行只有一条路径
}
for(int i = 1; i < m; i++)
{
for(int j = 0; j < n; j++)
{
if(j > 0)
{
dp[j] = grid[i][j] + min(dp[j], dp[j-1]); //动态方程
continue;
}
dp[j] += grid[i][j]; //j==0时,相当于第一列的边界
}
}
return dp[n-1]; //返回右下角最小路径
}
爬楼梯
【题目描述】
假设你正在爬楼梯。需要 n 阶才能到达楼顶。每次你可以爬1或2个台阶。你有多少种不同的方法可以爬到楼顶呢?
【输入输出实例】
示例 1:
输入:n = 2
输出:2 (1阶+1阶、2阶)
示例 2:
输入:n = 3
输出:3 (1阶+1阶+1阶、1阶+2阶、2阶+1阶)
【算法思路】
动态规划:
(1)常规解法将本问题划分成多个子问题,爬第n阶楼梯的方法数量等于下面两部分之和:
-
爬上第n-1阶楼梯的方法数量(因为再爬1阶就能到第n阶)
-
爬上第n-2阶楼梯的方法数量(因为再爬2阶就能到第n阶)
(2)所以我们得到动态规划方程:dp[n] = dp[n-1] + dp[n-2]
(3)同时需要初始化dp[0] = 1和dp[1] = 1。
(4)利用「滚动变量」进行空间优化。
【算法描述】
cpp
//动态规划
int climbStairs(int n) {
vector<int> dp(n + 2); //dp[i]表示爬到第i阶楼梯的所有方法
dp[1] = 1; //边界:初始dp[1]
dp[2] = 2; //边界:初始dp[2]
for(int i = 3; i <= n; i++)
{
//动态规划方程:爬到第i阶楼梯的所有方法 = 爬到第i-1阶楼梯的所有方法 + 爬到第i-2阶楼梯的所有方法
//因为只用一步从第i-1阶到第i阶只有一种方法,只用一步从第i-2阶到第i阶也只有一种方法
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n]; //返回爬到第n阶楼梯的所有方法
}
//动态规划------空间优化
int climbStairs(int n)
{
int p = 0; //表示dp[i-2]
int q = 0; //表示dp[i-1]
int r = 1; //表示dp[i]
for(int i = 1; i <= n; i++)
{
//滚动变量,每次循环向后滑动一位
p = q;
q = r;
r = p + q; //dp[i] = dp[i-1] + dp[i-2];
}
return r;
}
编辑距离
【题目描述】
给你两个单词 word1
和 word2
, 请返回将 word1
转换成 word2
所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
【输入输出实例】
示例 1:
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
示例 2:
输入:word1 = "intention", word2 = "execution"
输出:5
解释:
intention -> inention (删除 't')
inention -> enention (将 'i' 替换为 'e')
enention -> exention (将 'n' 替换为 'x')
exention -> exection (将 'n' 替换为 'c')
exection -> execution (插入 'u')
【算法思路】
-
dp[i][j]
表示的含义是s1[0:i-1]
转换成s2[0:j-1]
的最少操作数。 -
动态规划方程:
-
如果
s1[i] == s2[j]
,则dp[i][j] = dp[i-1][j-1]
; -
如果
s1[i] != s2[j]
,则有三种情况:-
在
i
后插入一个和s2[j]
一样的字符,则dp[i][j] = dp[i][j-1] + 1
; -
把
s1[i]
删掉,则dp[i][j] = dp[i-1][j] + 1
; -
替换
s1[i]
,换成和s2[j]
一样的字符,则dp[i][j] = dp[i-1][j-1] + 1
; -
上述三种情况对应的就是插入/删除/替换三种操作,因为求的是最少操作数,所以上述三个结果要求最小。
-
上述流程用下图表示:
-
【算法描述】
cpp
int minDistance(string word1, string word2) {
int m = word1.size();
int n = word2.size();
// dp[i][j] 表示 word1[0:i-1] 转换成 word2[0:j-1] 的最少操作数
vector<vector<int>> dp(m+1, vector<int>(n+1, INT_MAX));
// 初始化,word1和word2可以是空字符串
dp[0][0] = 0;
for(int i = 1; i <= m; ++i) {
dp[i][0] = i;
}
for(int j = 1; j <= n; ++j) {
dp[0][j] = j;
}
for(int i = 1; i <= m; ++i) {
for(int j = 1; j <= n; ++j) {
// 动态规划方程
if(word1[i-1] == word2[j-1]) {
dp[i][j] = dp[i-1][j-1];
}
else {
dp[i][j] = min(min(dp[i][j-1], dp[i-1][j]), dp[i-1][j-1]) + 1;
}
}
}
return dp[m][n];
}
【知识点】
C/C++中的 <limits.h> 头文件中定义:
cpp
#define INT_MAX 2147483647
#define INT_MIN (-INT_MAX - 1)
INT_MAX
为 2^31-1 ,即 2147483647 ;
INT_MIN
为 -2^31 , 即 2147483648 ;
除此之外还有INT16_MAX
、INT32_MAX
、INT64_MAX
。
溢出问题
在c/c++中,int 类型的取值范围为 [ -2147483648, 2147483647] ,超过这个范围则会产生溢出问题。
-
当发生上溢时,即
INT_MAX + N
(N为任意正整数),先进行INT_MAX + 1 = INT_MIN
,这时上溢问题就解决了,之后便是正常的加减法,即INT_MIN
再加上剩余的数,其结果为INT_MIN + N -1
; -
当发生下溢时,即
INT_MIN - N
(N为任意正整数),先进行INT_MIN - 1 = INT_MAX
,这时下溢问题就解决了,之后便是正常的加减法,即INT_MAX
再减去剩余的数,其结果为INT_MAX - N +1
;
柱状图中最大的矩形
【题目描述】
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为1。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
【输入输出实例】
示例 1:
输入:heights = [2,1,5,6,2,3]
输出:10
示例 2:
输入:heights = [2,4]
输出:4
【算法思路】
本题目与42、接雨水非常相似,也是有三种方法,如下:
(1)双指针法和动态规划
接雨水是找每列中左侧最高的柱子和右侧最高的柱子。找到后该列的雨水高度可得到,宽度为1,就可得到该列的雨水量,再把每列的雨水累加起来即可;
本题是找每个柱子左右两侧最后一个大于等于该柱子高度的柱子。找到后勾勒出来的矩形的宽度为左右两侧这两列的下标差,高度就是本列柱子的高度,则可计算出本列勾勒出来最大矩形的面积,再把每列勾勒出来的矩形面积求出来,再找最大。
(2)单调栈
接雨水是找每个柱子左右两边第一个大于该柱子高度的柱子,而本题是找每个柱子左右两边第一个小于该柱子的柱子。所以从栈头到栈底的顺序应该是从大到小的顺序。
如上图,可知只有栈里从大到小的顺序,才能保证栈顶元素找到左右两边第一个小于栈顶元素的柱子。
其实就是栈顶和栈顶的下一个元素以及要入栈的三个元素组成了我们要求最大面积的高度和宽度。其中栈顶元素为矩形高度,栈顶的下一个元素以及要入栈的元素的下标构成了矩阵的宽度。
其实可以把这个过程想象成锯木板,如果木板都是递增的那我很开心不用管。如果突然遇到一块木板 i 矮了一截,那我就先找之前最戳出来的一块(其实就是第 i-1 块),计算一下这个木板单独的面积,然后把它锯成次高的,这是因为我之后的计算都再也用不着这块木板本身的高度了。再然后如果发现次高的仍然比现在这个 i 木板高,那我继续单独计算这个次高木板的面积(应该是第 i-1 和 i-2 块组成的木板),再把它俩锯短。直到发现不需要锯就比第 i 块矮了,那我继续开开心心往右找更高的。当然为了避免到了最后一直都是递增的,所以可以在最后加一块高度为0的木板。
这个算法的关键点是把那些戳出来的木板早点单独拎出来计算,然后就用不着这个值了。
【算法描述】
cpp
//双指针法------超时
int largestRectangleArea(vector<int>& heights) {
int n = heights.size();
int maxArea = 0; //记录矩形的最大面积
for(int i = 0; i < n; i++) //遍历每根柱子
{
int left = i - 1;
int right = i + 1;
//找左边最后一个大于等于heights[i]的下标
for(; left >= 0; left--)
{
if(heights[left] < heights[i])
{
break;
}
}
//找右边最后一个大于等于heights[i]的下标
for(; right < n; right++)
{
if(heights[right] < heights[i])
{
break;
}
}
int height = heights[i]; //矩形高度
int width = right - left - 1; //矩形宽度
maxArea = max(maxArea, height*width); //计算最大面积
}
return maxArea;
}
//动态规划
int largestRectangleArea(vector<int>& heights)
{
int n = heights.size();
int maxArea = 0; //记录矩形的最大面积
vector<int> left(n);
vector<int> right(n);
left[0] = -1; //初始化
right[n-1] = n;
//记录每个柱子左边第一个高度小于该柱子的下标
for(int i = 1; i < n; i++)
{
int index = i - 1;
while(index >= 0 && heights[i] <= heights[index])
{
index = left[index]; //动态规划更新,上一次已经求过,不用重复求
}
left[i] = index;
}
//记录每个柱子右边第一个高度小于该柱子的下标
for(int i = n - 2; i >= 0; i--)
{
int index = i + 1;
while(index < n && heights[i] <= heights[index])
{
index = right[index]; //动态规划更新,上一次已经求过,不用重复求
}
right[i] = index;
}
//计算矩形的最大面积
for(int i = 0; i < n; i++)
{
int height = heights[i]; //矩形高度
int width = right[i] - left[i] - 1; //矩形宽度
maxArea = max(maxArea, height*width); //计算最大面积
}
return maxArea;
}
//单调栈
int largestRectangleArea(vector<int>& heights) {
heights.insert(heights.begin(), 0); //数组头部加入元素0
heights.push_back(0); //数组尾部加入元素0
int maxArea = 0; //记录矩形的最大面积
stack<int> s; //单调栈(递减)
s.push(0);
for(int i = 1; i < heights.size(); i++) //遍历各柱子
{
while(heights[i] < heights[s.top()])
{
int height = heights[s.top()]; //矩形高度
//int width = i - s.top(); //这时得到的矩形宽度没有把之前出栈的柱子宽度算上,因为之前出栈的柱子高度肯定大于它后面的柱子高度,所以才被出栈,那么要把前面已出栈的柱子宽度也要算上
s.pop();
int width = i - s.top() - 1; //矩形宽度:把前面已出栈的宽度也要算上
maxArea = max(maxArea, height*width); //计算最大面积
}
s.push(i);
}
return maxArea;
}
扰乱字符串
【题目描述】
使用下面描述的算法可以扰乱字符串 s
得到字符串 t
:
- 如果字符串的长度为 1 ,算法停止
- 如果字符串的长度 > 1 ,执行下述步骤:
- 在一个随机下标处将字符串分割成两个非空的子字符串。即,如果已知字符串
s
,则可以将其分成两个子字符串x
和y
,且满足s = x + y
。 - 随机 决定是要「交换两个子字符串」还是要「保持这两个子字符串的顺序不变」。即,在执行这一步骤之后,
s
可能是s = x + y
或者s = y + x
。 - 在
x
和y
这两个子字符串上继续从步骤 1 开始递归执行此算法。
- 在一个随机下标处将字符串分割成两个非空的子字符串。即,如果已知字符串
给你两个 长度相等 的字符串 s1
和 s2
,判断 s2
是否是 s1
的扰乱字符串。如果是,返回 true
;否则,返回 false
。
【输入输出实例】
示例 1:
输入:s1 = "great", s2 = "rgeat"
输出:true
解释:s1 上可能发生的一种情形是:
"great" --> "gr/eat" // 在一个随机下标处分割得到两个子字符串
"gr/eat" --> "gr/eat" // 随机决定:「保持这两个子字符串的顺序不变」
"gr/eat" --> "g/r / e/at" // 在子字符串上递归执行此算法。两个子字符串分别在随机下标处进行一轮分割
"g/r / e/at" --> "r/g / e/at" // 随机决定:第一组「交换两个子字符串」,第二组「保持这两个子字符串的顺序不变」
"r/g / e/at" --> "r/g / e/ a/t" // 继续递归执行此算法,将 "at" 分割得到 "a/t"
"r/g / e/ a/t" --> "r/g / e/ a/t" // 随机决定:「保持这两个子字符串的顺序不变」
算法终止,结果字符串和 s2 相同,都是 "rgeat"
这是一种能够扰乱 s1 得到 s2 的情形,可以认为 s2 是 s1 的扰乱字符串,返回 true
示例 2:
输入:s1 = "abcde", s2 = "caebd"
输出:false
示例 3:
输入:s1 = "a", s2 = "a"
输出:true
【算法思路】
动态规划
给定两个字符串 T和 S,假设 T 是由 S 变换而来:
子问题就是分别讨论两种情况,T1 是否由 S1 变来,T2 是否由 S2 变来,或 T1 是否由 S2 变来,T2 是否由 S1 变来。
dp[i][j][k][h]
表示 T[k..h]
是否由 S[i..j]
变来。由于变换必须长度是一样的,因此这边有个关系 j − i = h − k
,可以把四维数组降成三维。dp[i][j][k]
表示从字符串 S 中 i 开始长度为 k 的字符串是否能变换为从字符串 T 中 j 开始长度为 k 的字符串。
状态转移方程如下:
①和②中只要有一个为true
,即可说明以s[i]
和s[j]
开始的k个字符匹配,可以相互转换。
初始化:k == 1时,dp[i][j][k] = (s1[i] == s2[j])
,只有一个字符时,直接判断是否相等即可。
最后返回结果:dp[0][0][n]
表示从字符串S[0:n)
是否能变换为从字符串T[0:n)
。
【算法描述】
cpp
bool isScramble(string s1, string s2) {
int n = s1.size();
vector<vector<vector<bool>>> dp(n, vector<vector<bool>>(n, vector<bool>(n+1)));
for(int k = 1; k <= n; ++k) {
for(int i = 0; i + k - 1 < n; ++i) {
for(int j = 0; j + k - 1 < n; ++j) {
// 初始化
if(k == 1) {
dp[i][j][k] = (s1[i] == s2[j]);
continue;
}
// 枚举字符串长度u(取值为1 ~ k-1),因为要划分
for(int u = 1; u <= k-1; ++u) {
// 划分结果中,只要有一组满足,即可说明s[i]和s[j]开始的k个字符匹配
bool reg1 = dp[i][j][u] && dp[i+u][j+u][k-u];
bool reg2 = dp[i][j+k-u][u] && dp[i+u][j][k-u];
if(reg1 || reg2) {
dp[i][j][k] = true;
break;
}
}
}
}
}
return dp[0][0][n];
}
解码方法
【题目描述】
一条包含字母 A~Z 的消息通过以下映射进行了编码:'A' -> "1"、'B' -> "2"、...、'Z' -> "26"。要解码已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母。
例如,"11106" 可以映射为:
-
"AAJF" ,将消息分组为(1 1 10 6)
-
"KJF" ,将消息分组为(11 10 6)
注意,消息不能分组为 (1 11 06),因为 "06" 不能映射为 "F" ,这是由于 "6" 和 "06" 在映射中并不等价。
给定一个只含数字的****非空*字符串s,计算并返回 *解码*方法的*总数****。
【输入输出实例】
示例 1:
输入:s = "12"
输出:2 (解释:"AB"(1 2)或者"L"(12))
示例 2:
输入:s = "226"
输出:3 (解释:"BZ"(2 26),"VF"(22 6)或者"BBF"(2 2 6))
示例 3:
输入:s = "0"
输出:0 (解释:没有字符映射到以 0 开头的数字)
【算法思路】
经典的动态规划解法:
(1)找子问题:dp[i]表示以s[i]为结尾的解码方法总数。
(2)动态方程思想:在进行状态转移时,我们可以考虑最后一次解码使用了s中的哪些字符,那么会有下面的两种情况:
-
第一种情况是我们使用了一个字符,即 s[i] 进行解码,那么只要s[i] ≠ 0,它就可以被解码成 A~I 中的某个字母。由于剩余的前 i-1个字符的解码方法数为dp[i-1],因此我们可以写出状态转移方程:
dp[i] = dp[i-1] 当s[i] ≠ 0
-
第二种情况是我们使用了两个字符,即 s[i-1] 和 s[i] 进行编码。与第一种情况类似,s[i-1] 不能等于0,并且 s[i-1] 和 s[i] 组成的整数必须小于等于26,这样它们就可以被解码成 J~Z 中的某个字母。由于剩余的前 i−2 个字符的解码方法数为dp[i - 2],因此我们可以写出状态转移方程:
dp[i] = dp[i-2] 当s[i-1] ≠ 0且s[i] + s[i-1] ≤ 26
每次找dp[i]时,只有上面两种情况,所以将上面的两种状态转移方程在对应的条件满足时进行累加,即可得到dp[i]的值。
(3)找边界(初始化):当s只有一个数字字符时,则最多只有一个解码方法。如果该字符数字是'0',则解码方法为0;若为其它数字字符,解码方法为1,则初始条件为dp[0] = (s[0] != '0');
(4)找输出:返回以最后一位字符为结尾的解码方法总数dp[len-1]。
(5)空间优化:根据动态方程,使用「滚动变量」将代码进行优化。
【算法描述】
cpp
//动态规划
int numDecodings(string s) {
int len = s.size();
vector<int> dp(len); //dp[i]表示以s[i]为结尾的解码方法总数
dp[0] = (s[0] != '0'); //初始化第一位字符的编码方法总数
for(int i = 1; i < len; i++)
{
//动态规划方程分两种情况:以s[i]为最后一位进行编码,以s[i-1]和s[i]为最后两位进行编码
//将两种情况的dp[i]相加,即得到以s[i]为结尾解码方法总数
if(s[i] != '0') //以s[i]为最后一位进行编码:A~I
{
dp[i] += dp[i-1];
}
if(s[i-1] != '0' && 10*(s[i-1]-'0') + (s[i]-'0') <= 26) //以s[i-1]和s[i]为最后两位进行编码:J~Z
{
if(i > 1) //求dp[2]以后
{
dp[i] += dp[i-2];
}
else
{
dp[i] += 1; //dp[1]的初始化
}
}
}
return dp[len-1]; //返回以最后一位字符为结尾的解码方法总数
}
//动态规划------空间优化
int numDecodings(string s) {
int len = s.size();
int first = 1; //表示dp[i-2]
int second = (s[0] != '0'); //表示dp[i-1]
for(int i = 1; i < len; i++)
{
int third = 0; //表示dp[i],起始均为0
//动态规划方程分两种情况:以s[i]为最后一位进行编码,以s[i-1]和s[i]为最后两位进行编码
//将两种情况的dp[i]相加,即得到以s[i]为结尾解码方法总数
if(s[i] != '0') //以s[i]为最后一位进行编码:A~I
{
third += second;
}
if(s[i-1] != '0' && 10*(s[i-1]-'0') + (s[i]-'0') <= 26) //以s[i-1]和s[i]为最后两位进行编码:J~Z
{
third += first;
}
first = second; //更新first和second,third下次重置为0
second = third;
}
return second; //返回以最后一位字符为结尾的解码方法总数
}
不同的二叉搜索树
【题目描述】
给你一个整数n,求恰由n个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
二叉搜索树:其左子树上所有结点的值均小于根结点的值;其右子树上所有结点的值均大于等于根结点的值;且其左右子树本身又各是一棵二叉搜索树。
【输入输出实例】
示例 1:
输入:n = 3
输出:5
示例 2:
输入:n = 1
输出:1
【算法思路】
(1)找子问题:设dp[i]表示由i个节点组成互不相同的二叉搜索树的数量。
(2)动态规划方程:n个节点存在二叉排序树的个数是dp[n],令 f(i) 为以 i 为根的二叉搜索树的个数,则dp[n] = f(1) + f(2) + f(3) + ... + f(n)。当 i 为根节点时,其左子树节点个数为 i-1 个,右子树节点为 n-i,则f(i) = dp[i-1] * dp[n-i],综合两个公式可以得到卡特兰数公式:
dp[n] = dp[0]*dp[n-1] + dp[1]*dp[n-2] +...+ dp[n-1]*dp[0]
(3)找边界(初始化):当结点为空时,空节点也是一棵二叉树,所以数目为1,即dp[0] = 1;当只有一个结点时,二叉搜索树的数量也为1,即dp[1] = 1。
(4)找输出:dp[n]即为由n个节点组成互不相同的二叉搜索树的数量。
【算法描述】
cpp
int numTrees(int n) {
vector<int> dp(n+1); //dp[i]表示由i个节点组成互不相同的二叉搜索树的数量
dp[0] = 1; //初始化
dp[1] = 1;
for(int i = 2; i <= n; i++)
{
//动态规划方程:dp[n] = dp[0]*dp[n-1] + dp[1]*dp[n-2] +...+ dp[n-1]*dp[0]
for(int j = 0; j < i; j++)
{
dp[i] += dp[j] * dp[i-j-1];
}
}
return dp[n];
}
交错字符串
【题目描述】
给定三个字符串 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 + ...
【输入输出实例】
示例 1:
输入:s1 = "aabcc", s2 = "dbbca", s3 = "aadbbcbcac"
输出:true
示例 2:
输入:s1 = "aabcc", s2 = "dbbca", s3 = "aadbbbaccc"
输出:false
示例 3:
输入:s1 = "", s2 = "", s3 = ""
输出:true
【算法思路】
(1)找子问题:我们使用dp[i][j]
表示 s1 的前 i 个元素和 s2 的前 j 个元素是否能交错组成 s3 的前 i+j 个元素。
(2)动态规划方程:从dp[1][1]
开始遍历,有两种情况可以判断 s1 的前 i 个元素和 s2 的前 j 个元素能交错组成 s3 的前 i+j 个元素:
- s1 的前 i−1 个字符和 s2 的前 j 个字符能构成 s3 的前 i+j−1 位,且 s1 的第 i 位(s1[i−1])等于 s3 的第 i+j 位(s3[i+j−1]),即:
dp[i][j] = (dp[i-1][j] && s1[i-1] == s3[i+j-1])
- s1 的前 i 个字符和 s2 的前 j−1 个字符能构成 s3 的前 i+j−1 位,且 s2 的第 j 位(s2[j−1])等于 s3 的第 i+j 位(s3[i+j−1]),即:
dp[i][j] = (dp[i][j-1] && s2[j-1] == s3[i+j-1])
- 综上两种情况,则
dp[i][j] = (dp[i-1][j] && s1[i-1] == s3[i+j-1]) || (dp[i][j-1] && s2[j-1] == s3[i+j-1])
(3)初始化:初始化 dp 为 (len1+1) * (len2+1) 的 false 数组,dp 记录 s1 和 s2 的首字符为 ' ' ,则dp[0][0] = true
。
- 初始化第一列
dp[i][0]
,遍历第一列,遍历区间 [1, n1],看 s1 的前 i 位是否能构成 s3 的前 i 位,即看 s1 的第 i 位是否等于 s3 的第 i 位,如果等于,则继续判断下一位;如果不等于,说明后续肯定也无法构成,即dp[i-1][0] && s1[i] == s3[i];
- 初始化第一行
dp[0][j]
,遍历第一行,遍历区间 [1, n2],看 s2 的前 j 位是否能构成 s3 的前 j 位,即看 s2 的第 j 位是否等于 s3 的第 j 位,如果等于,则继续判断下一位;如果不等于,说明后续肯定也无法构成,即dp[0][j-1] && s2[j] == s3[j];
(4)找输出:返回dp[n1][n2]
,即为 s3
是否可由 s1
和 s2
交错组成。
【算法描述】
cpp
//动态规划
bool isInterleave(string s1, string s2, string s3)
{
int n1 = s1.size();
int n2 = s2.size();
int n3 = s3.size();
if(n1 + n2 != n3) //如s1和s2长度之和不等于s3,说明s3肯定不是由s1和s2组成的
{
return false;
}
vector<vector<bool>> dp(n1 + 1, vector<bool>(n2 + 1)); //dp[i][j]表示s1的前i个元素和s2的前j个元素是否能交错组成s3的前i+j个元素
dp[0][0] = true; //dp[0][0]:表示s1和s2都为空字符串,能交错组成空字符串s3
for(int i = 0; i < n1; i++) //初始化首列,s2为空字符串
{
if(s1[i] != s3[i])
{
break;
}
dp[i+1][0] = true;
}
for(int j = 0; j < n2; j++) //初始化首行,s1为空字符串
{
if(s2[j] != s3[j])
{
break;
}
dp[0][j+1] = true;
}
for(int i = 1; i <= n1; i++) //遍历 dp 数组
{
for(int j = 1; j <= n2; j++)
{
//动态规划方程:
//s1 的前 i−1 个字符和 s2 的前 j 个字符能否构成 s3 的前 i+j−1 位,且 s1 的第 i 位(s1[i−1])是否等于 s3 的第 i+j 位(s3[i+j−1])
//s1 的前 i 个字符和 s2 的前 j−1 个字符能否构成 s3 的前 i+j−1 位,且 s2 的第 j 位(s2[j−1])是否等于 s3 的第 i+j 位(s3[i+j−1])
dp[i][j] = (dp[i-1][j] && s1[i-1] == s3[i+j-1]) || (dp[i][j-1] && s2[j-1] == s3[i+j-1]);
}
}
return dp[n1][n2];
}
恭喜你全部读完啦!古人云:温故而知新。赶紧收藏关注起来,用之前再翻一翻吧~
📣推荐阅读
C/C++后端开发面试总结:点击进入 后端开发面经 关注走一波
C++重点知识:点击进入 C++重点知识 关注走一波
力扣(leetcode)题目分类:点击进入 leetcode题目分类 关注走一波