每一种算法都最好看完第一篇再去找要看的博客,因为这样会帮你梳理好思路,看接下来的博客也就更轻松了。当然,我也会尽量在写每一篇时都可以让不懂这个算法的人也能边看边理解。
1、动规思路简介
动规的思路有五个步骤,且最好画图来理解细节,不要怕麻烦。当你开始画图,仔细阅读题时,学习中的沉浸感就体验到了。
状态表示
状态转移方程
初始化
填表顺序
返回值
动规一般会先创建一个数组,名字为dp,这个数组也叫dp表。通过一些操作,把dp表填满,其中一个值就是答案。dp数组的每一个元素都表明一种状态,我们的第一步就是先确定状态。
状态的确定可能通过题目要求来得知,可能通过经验 + 题目要求来得知,可能在分析过程中,发现的重复子问题来确定状态。还有别的方法来确定状态,但都大同小异,明白了动规,这些思路也会随之产生。状态的确定就是打算让dp[i]表示什么,这是最重要的一步。状态表示通常用某个位置为结尾或者起点来确定,这点在下面的题解中慢慢领会。
状态转移方程,就是dp[i]等于什么,状态转移方程就是什么。像斐波那契数列,dp[i] = dp[i - 1] + dp[i - 2]。这是最难的一步。一开始,可能状态表示不正确,但不要紧,大胆制定状态,如果没法推出转移方程,没法得到结果,那这个状态表示就是错误的。所以状态表示和状态转移方程是相辅相成的,可以帮你检查自己的思路。
要确定方程,就从最近的一步来划分问题。
初始化,就是要填表,保证其不越界。像第一段所说,动规就是要填表。比如斐波那契数列,如果要填dp[1],那么我们可能需要dp[0]和dp[-1],这就出现越界了,所以为了防止越界,一开始就固定好前两个值,那么第三个值就是前两个值之和,也不会出现越界。初始化的方式不止这一点,有些问题,假使一个位置是由前面2个位置得到的,我们初始化最一开始两个位置,然后写代码,会发现不够高效,这时候就需要设置一个虚拟节点,一维数组的话就是在数组0位置处左边再填一个位置,整个dp数组的元素个数也+1,让原先的dp[0]变为现在的dp[1],二维数组则是要填一列和一行,设置好这一行一列的所有值,原先数组的第一列第一行就可以通过新填的来初始化,这个初始化方法在下面的题解中慢慢领会。
第二种初始化方法的注意事项就是如何初始化虚拟节点的数值来保证填表的结果是正确的,以及新表和旧表的映射关系的维护,也就是下标的变化。
填表顺序。填当前状态的时候,所需要的状态应当已经计算过了。还是斐波那契数列,填dp[4]的时候,dp[3]和dp[2]应当都已经计算好了,那么dp[4]也就出来了,此时的顺序就是从左到右。还有别的顺序,要依据前面的分析来决定。
返回值,要看题目要求。
子数组问题,因为并不是所有数组都从头一直到尾,所以最值可能是某一个dp表的元素,所以要对比所有dp表的元素才能返回,比较的办法就是用一个ret来初始化为int最大值或最小值,然后每一次更新dp表就去比较一下,把最值放到ret中,最后返回ret就行。子数组的状态表示通常是满足题目条件的数组数量,要分析这个子数组是由什么构成的。
2、最大子数组和
确定状态。以某个位置为结尾或起点。我们就以i位置为结尾,那么dp[i]就是表示以i位置为结尾的所有子数组,它们的最大值。以i位置为结尾,我们可以这样定义,要么是只有它一个值,要么就要加上前面的若干个值。长度为1或者大于1。所以状态转移方程就可以表示dp[i] = nums[i]和dp[i - 1] + nums[i]的较大值。
初始化的时候,因为需要前一个dp表的值,并且存在只有一个元素的情况,我们不能直接初始化dp[0] = nums[0],所以在dp[0]之前加一个虚拟节点,这样让dp[0]也在整个方程式中。虚拟节点需要不影响后面的值以及要保证好下标的变化。
返回值应当是dp表里的最大值,因为数组中的元素有正数也有负数。
int maxSubArray(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n + 1);
int ret = INT_MIN;
for(int i = 1; i <= n; i++)
{
dp[i] = max(nums[i - 1], dp[i - 1] + nums[i - 1]);
ret = max(dp[i], ret);
}
return ret;
}
3、环形子数组的最大和
==环形问题要转换成普通问题。==这道题有个很巧妙的思路。如果不是环形,那么我们可以求最大子数组和;如果是环形的,有的情况会是最后一部分的元素加上最开始一部分的元素,那么这样的情况如何求最大?我们可以求,除去这些部分的最小子数组和,用整体数组的大小减去它,就是这种情况的最大值。这样环形的问题,就被分成了两个情况,最大子数组和以及总和减去最小子数组和。
确定状态。我们用f和g来表示最大和最小子数组和。这道题以以i结尾的子数组的最大或者最小和。那么f[i]就是以i为结尾的所有子数组的最大和,而g[i]则是最小和。和上一个题一样,f和
g都各自分为两类,一个是长度为1,只有i位置,nums[i],一个是长度不为1,g / f[i - 1] + nums[i]。
初始化的时候要加个虚拟节点,f和g表的虚拟节点的值都是0即可。填表顺序是从左到右,两个表一起填。
返回值是fmax和sum - gmin。但这里有点特殊,如果数组里都是负数,比如-2 -3 -1,fmax肯定是一个负数-1,sum是所有元素的和,而gmin取最小值,其实也是所有元素的和,就变成0了,这样的情况就返回fmax即可,否则才返回两者较大值。
int maxSubarraySumCircular(vector<int>& nums) {
int n = nums.size();
vector<int> f(n + 1), g(n + 1);
int fmax = INT_MIN, gmin = INT_MAX, sum = 0;
for(int i = 1; i <= n; i++)
{
int x = nums[i - 1];
f[i] = max(x, x + f[i - 1]);
fmax = max(fmax, f[i]);
g[i] = min(x, x + g[i - 1]);
gmin = min(gmin, g[i]);
sum += x;
}
return sum == gmin ? fmax : max(fmax, sum - gmin);
}
4、乘积最大子数组
看到乘积会想到一个数值大小的问题。
先确定状态,以i位置为结尾,到i位置时最大的乘积。这里可以分成两种,一个是只有它自己,一个是不只有它自己,也就是长度为1和不为1。要找它前面的数值的乘积,就要用到i - 1位置,i - 1位置也是同样的分析,从开始位置一步步都这样写,那么dp[i] = dp[i - 1] * nums[i]。但是要注意的是,这个题是乘积,如果当前位置是负数,那么前面得到的最大的值,一乘负数,就变成一个最小的值了,为了应对负数,就要找前面数组中最小的乘积,乘负数后才能变成最大的值。所以这里只用一个dp表不太可行,要用两个,分别找最大乘积和最小乘积。两个dp表是f和g表。在f表中,长度为1我们就乘nums[i],长度大于1的情况,nums[i]大于0,就用f[i - 1] *nums[i],nums[i]小于0,那就g[i - 1] * nums[i]。在g表中,也是一样的,长度为1,nums[i],长度大于1,如果nums[i] > 0,那就用g[i - 1]直接乘,如果nums[i] < 0,那就用f[i - 1]来乘。
通过以上的分析,f和g的方程分别是
f[i] = max(nums[i],f[i - 1]nums[i],g[i - 1]nums[i])
g[i] = min(nums[i],f[i - 1]nums[i],g[i - 1]nums[i])
如果当前位置是0怎么办?其实不影响找最大值,并且按照整体的代码,其实也不影响后续的找最大值。
因为有i - 1,我们可以加上虚拟节点,也可以直接初始化。这里采用虚拟节点,需要注意虚拟节点的值不影响后面的填表,以及下标的映射关系。f[i]和g[i]都是一样的比较,只是一个找最大,一个找最小,三个值里,我们应当让原数组的g[0]和f[0]都取nums[i],所以后面的两个值不应当影响它们,填0和int最大值和int最小值都不行,我们初始化为1就好了。
填表顺序是从左往右,两个表一起填。返回值返回f表中的所有值的最大值。
int maxProduct(vector<int>& nums) {
int n = nums.size();
vector<int> f(n + 1), g(n + 1);
f[0] = g[0] = 1;
int ret = INT_MIN;
for(int i = 1; i <= n; i++)
{
int x = nums[i - 1], y = f[i - 1] * nums[i - 1], z = g[i - 1] * nums[i - 1];
f[i] = max(max(x, y), z);
g[i] = min(min(x, y), z);
ret = max(ret, f[i]);
}
return ret;
}
5、乘积为正数的最长子数组长度
这道题和上一题思路相似,但不同的是只要正数。正数可以是正,也可以是负负。
先确定状态,以i位置为结尾的所有子数组中乘积为正数的最长长度。每个子数组都可以是只有当前元素的和不止当前元素的,也就是长度为1和长度不为1,定义一个f表。因为这题要正数,所以两种长度都需要分成两种情况,大于0或者小于0。长度为1的子数组,当前位置大于0,那么f表对应位置就填1,如果小于0,那就填0。长度大于1的子数组,除去当前位置,其实都是以i - 1位置为结尾,所以我们用f[i - 1] + 1来获得结果。但如果nums[i]是负数,那么前面元素的乘积应当为负数才能负负得正,那我们就再用一个g表,用来表示乘积为负数的最长长度。但这里还不能直接用g[i - 1] + 1,如果前面乘积都是正数,那么g[i - 1]应当为0,那么正数乘当前位置,当前位置是负数,那就成负数了,此时的f[i]应当为0,如果g[i - 1]不是0,那才能用g[i - 1] + 1。
综合来看,如果nums[i] > 0,长度不为1的情况下,如果f[i - 1]为0,也就是前面乘积不是正数,那么就是当前位置是正数,所以会是1,而长度为1的情况本身就会填1,所以最大值应当是f[i - 1] + 1。如果nums[i] < 0,最大值应当是g[i - 1] + 1。f表状态转移方程就出现了。nums[i] > 0,f[i] = f[i - 1] + 1;nums[i] < 0,f[i] = g[i - 1] == 0 ? 0 : g[i - 1] + 1。
再看g表,长度为1的情况下,nums[i] > 0,填0,小于0填1。长度大于1的情况下,nums[i] > 0,如果g[i - 1]为0,也就是前面都是正数,乘当前位置的正数还是0,那么g[i]就是0,如果g[i - 1]不是0,那就可以填上g[i - 1] + 1;如果nums[i] < 0,要想让数值为负数,就得让到i - 1位置结尾的乘积是正数,所以就是f[i - 1] + 1,即使f[i - 1]为0,也就是全面乘积是负数也没关系,负负会得正,那么为了让乘积为负数,就只能算当前位置的乘积,也就是nums[i]本身,那就是长度为1的情况,就是1。
综合来看,对于g表,nums[i] < 0,就用f[i - 1] + 1来表示,nums[i] > 0,那么就用判断g[i - 1]的方式来填写。nums[i] > 0,g[i] = g[i - 1] == 0 ? 0 : g[i - 1] + 1;nums[i] < 0,g[i] = f[i - 1] + 1。
初始化,我们加上虚拟节点,需要不影响后续填表以及下标的变化。f和g的方程看一个就好。看一下g表,nums[i] > 0,那么当前位置应当填0,所以虚拟节点填0;nums[i] < 0,对于g表来说,此时应当填1,那么f[i - 1] + 1 = 1,所以f表第一个位置也填0。对应到f表的方程也合适。
填表顺序是从左到右,两个表一起填。返回值,因为题目要求是正数的最长长度,所以是f表中的最大值。
int getMaxLen(vector<int>& nums) {
int n = nums.size();
vector<int> f(n + 1), g(n + 1);
int ret = INT_MIN;
for(int i = 1; i <= n; i++)
{
if(nums[i - 1] > 0)
{
f[i] = f[i - 1] + 1;
g[i] = g[i - 1] == 0 ? 0 : g[i - 1] + 1;
}
else if(nums[i - 1] < 0)
{
f[i] = g[i - 1] == 0 ? 0 : g[i - 1] + 1;
g[i] = f[i - 1] + 1;
}
ret = max(ret, f[i]);
}
return ret;
}
6、等差数列划分
先确定状态。dp[i]表示到i位置时能不能构成等差数列?其实不太行,改成到i位置时等差数组的个数。假设abcd是等差数列,新来的元素e,发现cde可以构成等差数列,那么这时候abcde就是一个等差数列,因为差值相等。所以如果c - d = d - e,那么当前位置的元素e就可以并入等差数列,也就是某个值 + 1,那么除去e之外,前面的这些等差数列个数存在哪里?其实就是dp[i - 1]的数值。如果c - d != d - e,那么以当前位置为结尾的就没有等差数列了,所以dp[i] = 0。
初始化,因为等差数列至少需要3个值,dp[0]和dp[1],也就是前两个元素,这时候没有等差数列,所以应当都是0。填表顺序是从左到右。返回值是dp表中所有值的和。
int numberOfArithmeticSlices(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n);
int sum = 0;
for(int i = 2; i < n; i++)
{
dp[i] = nums[i] - nums[i - 1] == nums[i - 1] - nums[i - 2] ? dp[i - 1] + 1 : 0;
sum += dp[i];
}
return sum;
}
7、最长湍流子数组
湍流子数组是这样的数组,可以只有一个元素,像例子中的9、4、2、10、7、8、8、1、9,其中4、2、10、7、8就是一个湍流数组,我们可以画上升下降的线,2比4小,4下降到2,10比2大,2上升到10,接着,10下降到7,7上升到8。
确定状态。以i位置为结尾的所有子数组中最长湍流子数组的长度。那么湍流子数组应当如何确定?湍流最后一个位置要么是上升,要么是下降。加上当前位置,判断是不是湍流,就需要前两个位置,但是每个位置都代表长度,这时候会发现我们没法确定是否是湍流数组,所以这个状态表示是错误的,我们应当分成两种,到当前位置是上升或者下降状态。f和g表,表示以i位置元素为结尾的所有子数组中,最后呈现上升/下降状态下的最长湍流数组的长度。
当前元素为b,前一个元素为a。对于f表,a > b,那么就不是上升状态,所以只能b自己成一个数组;a < b,那么就符合上升状态,a和前面的那个元素应当呈现下降状态,那就是g[i - 1] + 1,当然如果i - 1位置就没构成湍流数组,g[i - 1] = 0,那么到了b就自成一个数组,就是1;如果a == b,那就不是湍流数组,那就是1。
对于g表,分析思路如上一段一样,a > b是f[i - 1] + 1,a < b是1,a == b是1。
初始化,需要f和g表第一个位置,因为湍流数组可以只有一个元素,而且经过上面的分析,f和g表会有一些位置被填为1,所以我们不如直接全部初始化为1,那么就只用考虑f和g的各一种情况就好。
填表顺序是从左到右,两个表一起填。返回值是f和g表中的最长长度。
int maxTurbulenceSize(vector<int>& arr) {
int n = arr.size();
vector<int> f(n, 1), g(n, 1);
int ret = 1;//最少就是1
for(int i = 1; i < n; i++)
{
if(arr[i - 1] > arr[i]) g[i] = f[i - 1] + 1;
else if(arr[i - 1] < arr[i]) f[i] = g[i - 1] + 1;
ret = max(ret, max(g[i], f[i]));
}
return ret;
}
8、单词拆分
确定状态,以i位置为结尾,从头到i位置,我们先看这部分能否被字典中的单词拼接而成。加上i位置的,有可能i位置的一个字符构成一个单词,有可能是之前的加上i位置的字符构成一个单词。如果前面一部分字符串能被拼接成,后面的一部分单词在字典中,那么整个到i位置的字符串就可以被拼接而成。我们设j为最后一个单词的起始位置的下标,j的取值范围肯定是0 <= j <= i。j为最后一个单词起始位置,那么前面的部分就是0 - (j - 1),那么对应到dp表就是dp[j - 1]是true还是false,剩下的就是s(i - j)是否在字典中,如果是true且后面的单词在字典中,那么dp[i]就是true,否则就是false。
初始化,因为有j - 1,那么j = 0时,我们得需要一个虚拟节点,虚拟节点不影响后续的填表以及要注意下标的变化。虚拟节点应当设置为true,如果第一个字符在字典中,那么就可以设置为true了。所以dp[0] = true。原表的第一个元素就是新表的第二个元素。因为是字符串,所以可以让原字符串最前面加上一个空格字符。
填表顺序是从左到右,返回值则是dp表最后一个值。
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> hash;
for(auto& s: wordDict) hash.insert(s);//将原始数组放在哈希表,这样寻找是否存在与数组中就可以借用哈希表,是一种优化
int n = s.size();
vector<bool> dp(n + 1);//会默认初始化为false
dp[0] =true;
s = ' ' + s;//让原始字符串的下标统一 + 1,和dp表对应
for(int i = 1; i < n + 1; i++)
{
for(int j = i; j >= 1; j--)//最后一个单词的起始位置
{
if(dp[j - 1] && hash.count(s.substr(j, i - j + 1)))
{
dp[i] = true;
break;
}
}
}
return dp[n];
}
9、环绕字符串中唯一的子字符串
确定状态。惯用的dp[i]是以i位置结尾的所有子串里,有多少个在base中出现过。当前位置的元素可以单独一个元素,也可以和前面的元素结合,也就是长度为1和长度不为1,所以dp[i]分为两种情况。长度为1的出现次数就是1,长度大于1,如果要连续,前面的字符的ANSCII码值+1应当和当前字符一样,或者前面是z,当前是a也可,这两个情况符合一个就行,那么dp[i]就是dp[i - 1]存的值。
初始化,因为要用到i - 1,所以要初始化0位置的值。单独一个字符肯定出现在base中,所以dp表应当整体初始化为1。填表顺序是从左到右。
返回值是一个重要的问题。题目要求整个字符串中有多少个子串在base中出现,所以应当是dp表中所有元素的和,但这样会出错,因为会有重复值,比如例子中的cac。所以返回的时候一定要去重,如何去重?回到状态表示,同样以某个字符结尾的所有子串,较长的那个肯定已经包含了较短的那个,这个可以自己举例子去发现,所以我们只需要统计较大值。那么获取较大的又是一个问题,我们不如创建一个大小为26的数组,字母有26个,每个都保存着相应字符结尾的最大的dp值,这个最大值我们遍历dp表就可以获得。最后返回大小为26的数组的所有元素和即可。
int findSubstringInWraproundString(string s) {
int n = s.size();
vector<int> dp(n, 1);
for(int i = 1; i < n; i++)
{
if(s[i - 1] + 1 == s[i] || (s[i - 1] == 'z' && s[i] == 'a'))
dp[i] += dp[i - 1];
}
int hash[26] = {0};
for(int i = 0; i < n; i++)
{
hash[s[i] - 'a'] = max(hash[s[i] - 'a'], dp[i]);//为什么取max?因为同样字符可能出现过多次,比如a,一开始有a,z后面还会有a,所以要取max,不能直接取dp[i]
}
int sum = 0;
for(auto e : hash) sum += e;
return sum;
}
结束。