C++算法 —— 动态规划(5) 子序列


每一种算法都最好看完第一篇再去找要看的博客,因为这样会帮你梳理好思路,看接下来的博客也就更轻松了。当然,我也会尽量在写每一篇时都可以不懂这个算法的人也能边看边理解。

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]也就出来了,此时的顺序就是从左到右。还有别的顺序,要依据前面的分析来决定。

返回值,要看题目要求。

子序列问题,因为序列的元素可以在原数组中不连续,所以i位置的元素也可以和i - 4位置的元素连接,所以把i之前的所有位置都设为j,j被表示为很多个值,比如i - 1,i - 2,主要用作分析。

第一题是一个被当做模板的题,有关子序列的题的分析思路都可以在这道题里找到源头。这篇博客除了这道题外,如果要看其它的题,都建议先看完第一题。

2、最长递增子序列

300. 最长递增子序列

在这里插入图片描述

子序列不同于子数组,虽然里面的元素都是从左往右的顺序,但子序列里的元素在原数组中可以不连续,也就是原数组abcd,acd就是一个子序列,da就不是子序列,因为相对顺序和原数组不对应,而acd不能是子数组,abc是子数组,所以子序列其实包含子数组。

严格递增意思就是没有重复的元素,只有递增的趋势,不能有相等的元素。

先确定状态,惯用这个写法,dp[i]表示为以i位置元素结尾的所有子序列中,最长递增子序列的长度。可以只用当前位置的元素来构成一个子序列,也可以把当前元素前面的若干个元素拼接起来做子序列,也就是长度大于1和长度为1的子序列。当前位置i可以和i - 1,i - 2,i - 3等位置的元素拼接,我们暂且把i之前的位置称作j,也就是说j <= i,且无论j是多少,nums[j]小于nums[i]才能组成子序列,如果可以组成,那么当前位置i存储的长度就应当是dp[j] + 1,但dp[j]也应当找出最大值,也就是从0到j位置存的最大值,再去+1。所以这里就得是N ^ 2的时间复杂度。如果只取当前元素作为子序列,那么就存1。所以方程就出来了。

初始化,通过上面的分析可以发现,两个方程1和max(dp[j] + 1)最低都是1,所以我们就初始化dp表为1,这样长度为1的情况就不需要考虑了。填表顺序是从左到右,返回值是dp表中最大值,子序列可能是任意一个位置有最长长度,所以不能只取最后一个值,而是dp表所有值取最大。

这道题用动规并不是优秀的解法,后面的算法博客会写到这题更好的解法。

    int lengthOfLIS(vector<int>& nums) {
        int n = nums.size();
        vector<int> dp(n, 1);
        int ret = 1;
        for(int i = 1; i < n; i++)
        {
            for(int j = 0; j < i; j++)
            {
                if(nums[j] < nums[i])
                {
                    dp[i] = max(dp[j] + 1, dp[i]);
                }
            }
            ret = max(ret, dp[i]);
        }
        return ret; 
    }

3、摆动序列

376. 摆动序列

在这里插入图片描述

摆动序列的意思就是一些数字,从左到右,每两个数之间的差应当是一正一负,并且差值不能是0,仅有一个元素也可以是摆动序列。

确定状态。其实如果看了上一个博客的话,会发现它和等差数列划分这道题很像,但也有所不同。我们定dp[i]是以i位置元素为结尾的所有子序列中,最长的摆动序列的长度,因为题目要求返回最长长度。但这个状态能解决问题吗?摆动序列需要看前一个差值是负数还是正数,然后当前位置和前一个位置的差值就对应着,是正数或者负数,才能确定到i位置时,是否能构成摆动序列,所以状态应当分成两种,f和g表。差值是负数,意思就是前一个值比后一个值大,那么从前一个值到后一个值就是在下降,如果比后一个值小,就是在上升。所以我们定义g[i]是以i位置元素结尾的所有子序列中,最后一个位置呈现下降趋势的最长摆动序列的长度,因为题目要求返回最长长度,f和g表就是对这个状态再做区分,而f表则表示上升趋势。

和上一个题一样,i位置的元素可能和i - 1或者i - 2,i - 3位置的元素连接构成一个子序列,我们就把前面这几个值都设置为j,j可表示为不同的值。仅有一个元素,就是长度为1的序列,那么f[i]就是1;不是一个元素,和之前的元素连接,也就是长度不为1的序列,而f[i]表示的到i位置时呈现上升趋势,也就是i 比 i - 1位置的元素大,那如果想达到这个效果,i - 2到i - 1就得是下降趋势,下降趋势正好就是g[i - 1],然后再 + 1即可。不过应当是g[j] + 1,从0 到 j最长的序列再 + 1。g表也是如此,要么是1,要么是f[j] + 1。

初始化,因为一个元素时应当填1,所以我们不如把整个表都初始化为1,这样就不用考虑长度为1的情况了。填表顺序是从左到右,两个表一起填。返回值是f和g表中的最大值。

    int wiggleMaxLength(vector<int>& nums) {
        int n = nums.size();
        vector<int> g(n, 1);
        auto f = g;
        int ret = 1;
        for(int i = 1; i < n; i++)
        {
            for(int j = 0; j < i; j++)
            {
                if(nums[j] < nums[i]) f[i] = max(g[j] + 1, f[i]);
                else if(nums[j] > nums[i]) g[i] = max(f[j] + 1, g[i]);
            }
            ret = max(max(g[i], f[i]), ret);
        }
        return ret;
    }

4、最长递增子序列的个数

673. 最长递增子序列的个数

在这里插入图片描述

看这道题之前先看最长递增子序列,以下的题解也会建立在那道题基础之上。

之前的题返回长度,这次返回个数。解决这个问题之前我们先看一个问题,如何用一次遍历来解决在数组中找出最大值出现的次数这个问题?这里的解决办法就是先取第一个数为最大数,让计数初始化为1,如果找到一个最大数小于的数字,那么什么都不做;如果等于这个数,那就计数 + 1;如果大于这个最大值,那么就更换最大值,然后计数再赋值为1。

回头再看这道题,我们要找最长子序列的个数。让dp[i]表示以i位置元素为结尾的所有的子序列中,最长子序列的个数。但我们应当知道何为最长?也就是最长子序列的长度,才能判断是否更新最长子序列,以及我们还得有个计数。所以得有两个dp表,一个表示以i位置元素为结尾的所有的子序列中,最长递增子序列的长度len[i],一个表示个数count[i]。将i前面的位置设置为j,j可以表示成i - 1, i - 2等,len[i] = len[j] + 1这个等式一定成立,但len[j]必须得是从0到j这个位置的最大值才行。如果len[j] + 1等于len[i],也就是长度没变,还是这个长度,那么count[i]就只是count[j] + 1,多一个;如果是大于len[i],那么最长的子序列应当以某一个j位置元素结尾的序列,那么此时count就是j位置的count,其实也就是不变,len[i]赋值为len[j] + 1;如果是小于,那就还是之前j位置的count,len[i]的值就还是len[i]的值。

通过以上的分析,我们可以把len和count全部初始化为1。填表顺序是从左到右,两个表一起填,返回值就是count表中的最大值。

看代码来理解整体思路。

    int findNumberOfLIS(vector<int>& nums) {
        int n = nums.size();
        vector<int> len(n, 1), count(n, 1);
        int retlen = 1, retcount = 1;
        for(int i = 1; i < n; i++)
        {
            for(int j = 0; j < i; j++)
            {
                if(nums[j] < nums[i])
                {
                    if(len[j] + 1 == len[i]) count[i] += count[j];
                    else if(len[j] + 1 > len[i])
                    {
                        len[i] = len[j] + 1;
                        count[i] = count[j];
                    }
                }
            }
            if(retlen == len[i]) retcount += count[i];//到这里就看看长度有没有变,retlen代表上一个i位置的代表长度的值,和现在i位置的比较,决定retlen和retcount的值,retcount就代表最长子序列的个数
            else if(retlen < len[i])
            {
                retlen = len[i];
                retcount = count[i];
            }
        }
        return retcount;
    }

5、最长数对链

646. 最长数对链

在这里插入图片描述

因为b要求小于c,所以不能等于。

确定状态。如果是以i位置为结尾,那么比i小的其实有可能在i左边,也可能在i右边。所以针对原数组我们还得需要处理一下,以每个数对的第一个来排成升序,为什么这么排?两个数对,从左到右分别是abcd,根据题目,a < b,c < d,排序后,a一定<= c,所以a一定 < d,所以a一定不能连接后面的数对,这样就能保证符合条件的数对都在前面。排序后,dp[i]就表示以i位置为结尾的所有数对链中,最长的数对链的长度。i位置的数对可以和前面,不仅仅只是前一个数对连接,我们可以把前面的位置都设为j。j可以取i - 1, i - 2位置的数对,这样就会分成两种情况,长度为1,只有i位置的数对,长度不为1,包含前面的数对链,也就是dp[j] + 1,而dp[j]需要求最大值,才能+1赋值给dp[i],从0到j循环来找最大值。

初始化,因为一个元素也可以形成数对链,所以全部初始化为1,这样也不用考虑长度为1的数对了。填表顺序是从左到右。返回值,最长数对链有可能出现在以某个数对结尾的数对链,所以需要比较dp表所有的值来找到最大值返回。

    int findLongestChain(vector<vector<int>>& pairs) {
        sort(pairs.begin(), pairs.end());
        int n = pairs.size();
        vector<int> dp(n, 1);
        int ret = 1;
        for(int i = 1; i < n; i++)
        {
            for(int j = 0; j < i; j++)
            {
                if(pairs[j][1] < pairs[i][0]) dp[i] = max(dp[j] + 1, dp[i]);
            }
            ret = max(ret, dp[i]);
        }
        return ret;
    }

6、最长定差子序列

1218. 最长定差子序列

在这里插入图片描述

惯用的思路就是dp[i]表示以i位置的元素为结尾的所有的子序列中,最长的定差子序列的长度。
假设当前位置的元素为a,和a相差差值的元素是b,b可能在前面的序列出现不止一次,但为了要最长,我们应当选择最靠近a的那个,b所在的位置设为j,那么dp[i]就是dp[j] + 1。

但是找到最靠近a的b就需要遍历,我们不如把b加上对应的dp表里的值放到哈希表里,这样直接映射到哈希表中去找,更甚一步,我们可以a和dp[i]的值放到哈希表中,这样就不需要dp表,在哈希表中做动态规划。

初始化,因为单个元素也可以,所以哈希表中,hash[arr[0]] = 1,也就是arr的第1元素对应的长度是1。填表顺序是从左到右。返回值是dp表的最大值,也就是哈希表的最大值。

    int longestSubsequence(vector<int>& arr, int difference) {
        unordered_map<int, int> hash;
        hash[arr[0]] = 1;
        int ret = 1;
        for(int i = 1; i <arr.size(); i++)
        {
            hash[arr[i]] = hash[arr[i] - difference] + 1;//对应着dp[i]的方程。dp[i]就是hash[arr[i]]
            //如果b不存在,那么hash[arr[i] - difference]就是0,0+1=1,就是1
            //如何解决最后一个b的问题,在遍历中,同样的b,会映射到同样的位置,重复地覆盖,所以最后留下的就是最靠近a的b的值
            ret = max(ret, hash[arr[i]]);
        }
        return ret;
    }

7、最长斐波那契子序列的长度

873. 最长的斐波那契子序列的长度

在这里插入图片描述

题目意思是,要想形成这个序列,至少是3个数,并且一个值可以由前两个值相加而成,并且没有重复的元素,符合严格递增。

确定状态。惯用的是dp[i]表示以i位置结尾的所有子序列中,最长的斐波那契子序列的长度。在一个序列的元素可以在原数组中不连续,比如2345,235就是斐波那契序列。我们设i位置之前的位置为j,那么除了nums[j]和nums[i]这两个元素,最后一个元素位置就是i - j,但是有一个问题,j位置不确定,我们无法找到这个斐波那契序列是什么样的,它无法找到j前面的值是什么样的,dp[j]也无法表示,所以这个状态是错误的。一个序列三个元素,假设最后一个是a,第二个是b,那么第一个就是a - b,如果以b结尾,那么所处的序列中,第二个是a - b,第一个就是b - (a - b)。所以通过a和b就可以确定整个长序列中所有的元素,所以我们需要以dp[i][j]来表示以i位置和j位置的元素为结尾的所有子序列中,最长的斐波那契序列的长度。无论是数字本身,还是下标,都可以通过后面两个数来找到。

现在我们重新设置一下三个数,从左到右分别是abc,下标是kij。通过bc找a,a = c - b。如果a不存在,那么序列只有2个数,所以填2,因为dp[i][j]表示的是长度,但因为小于3,最终肯定还是按照0处理;如果b < a <c,那么也不符合定义,也设为2;如果a存在且a < b,那么就符合定义,是我们理想的情况,那么dp[j]就是dp[i] + dp[k]。

这个题也有和上一个题一样的困惑,上面的分析中,a的确定是通过b和c来确定的,但遍历时是从左到右的,那么下标该如何确定?以及a可能有多个,我们只需要最近的那个a,如果要遍历找到最近的那个a的话,时间复杂度会到达N ^ 3,所以优化一下,把元素和对应的下标绑定,用哈希表来完成。

初始化,把表里的值都初始化为2。三个值是abc,对应的下标设为kij,dp[i][j]需要用到dp[k][i],在二维数组中,dp[k][i]在dp[i][j]上面,所以我们只需要关心上面的值即可。填表顺序是从上到下。返回值是dp表中的最大值,因为序列最长的可能出现某一个位置,所以不是最后一个值。但有特殊情况,如果原数组只有3个数,124,那么不能构成斐波那契序列,dp表的值都是2,那么就判断一下,如果最大值小于3就返回0,不是就返回最大值。

    int lenLongestFibSubseq(vector<int>& arr) {
        int n = arr.size();
        unordered_map<int, int> hash;
        for(int i = 0; i < n; i++) hash[arr[i]] = i;
        int ret = 2;
        vector<vector<int>> dp(n, vector<int>(n, 2));
        for(int j = 2; j < n; j++)
        {
            for(int i = 1; i < j; i++)
            {
                int a = arr[j] - arr[i];
                if(a < arr[i] && hash.count(a)) dp[i][j] = dp[hash[a]][i] + 1;
                ret = max(ret, dp[i][j]);
            }
        }
        return ret < 3 ? 0 : ret;
    }

8、最长等差数列

1027. 最长等差数列

在这里插入图片描述

确定状态,dp[i]表示以i位置为结尾的等差子序列的最大长度。i位置之前,有可能和i - 1,i - 2位置的元素连接,但是按照这个状态,dp存的是长度,我们并不能判断出dp[i]是否能由dp[i - 2]来获得,因为i - 2位置的值和i的值的差值是否能进入这个等差序列是未知的,所以这个状态不行。三个值分别是abc,b - a = c - b,所以a = 2b - c,通过两个值能找到前面一个等差序列的值,所以我们需要二维数组dp[i][j],新的状态就是dp[i][j]是以i位置和j位置的元素为结尾的所有的子序列的最大长度。

现在我们重新设置一下三个数,从左到右分别是abc,下标分别是kij。通过bc找a,a = 2b - c。如果a不存在,那么序列只有2个数,所以填2,因为dp[i][j]表示的是长度,但因为小于3,最终肯定还是按照0处理;如果b < a <c,那么也不符合定义,也设为2;如果a存在且a < b,那么就符合定义,是我们理想的情况,那么dp[j]就是dp[i] + dp[k]。

这个题也有和上一个题一样的困惑,上面的分析中,a的确定是通过b和c来确定的,但遍历时是从左到右的,那么下标该如何确定?以及a可能有多个,我们只需要最近的那个a,如果要遍历找到最近的那个a的话,时间复杂度会到达N ^ 3,所以优化一下,把元素和对应的下标绑定,用哈希表来完成。但还是不行,因为如果题目数组元素过多的话,创建一个哈希表就需要遍历一次,整体的时间复杂度依然不小。

那再来一个更好的优化,边做动规边保存离它最近元素的下标。先看初始化,把表里的值都初始化为2,之后如果最大值是2那就返回0。三个值是abc,对应的下标设为kij,dp[i][j]需要用到dp[k][i],在二维数组中,dp[k][i]在dp[i][j]上面,所以我们只需要关心上面的值即可。

填表时,第一次循环先固定最后一个数,第二遍循环枚举倒数第二个数;或者先固定倒数第二个数,然后枚举最后一个数。但应该使用第二个办法。三个数,下标分别是kij。如果固定了j,i往左移动找合适的位置,找到后,再去确定k,如果下一次j往后挪了一步,那么i还是需要找合适的位置,然后再确定k,所以不如固定i,k的范围就固定在0 ~ i,而这一次计算完后,i和j往后挪一步,k的范围就大一步,更方便。

返回值是dp表中的最大值。

    int longestArithSeqLength(vector<int>& nums) {
        unordered_map<int, int> hash;
        hash[nums[0]] = 0;//先把0位置的值初始化
        int n = nums.size(), ret = 2;
        vector<vector<int>> dp(n, vector<int>(n, 2));
        for(int i = 1; i < n; i++)//边做动规边哈希
        {
            for(int j = i + 1; j < n; j++)
            {
                int a = 2 * nums[i] - nums[j];
                if(hash.count(a))
                    dp[i][j] = dp[hash[a]][i] + 1;
                ret = max(dp[i][j], ret);
            }
            hash[nums[i]] = i;
        }
        return ret;
    }

9、等差数列划分 II

446. 等差数列划分 II - 子序列

在这里插入图片描述

有一定要求,必须至少3个元素。

确定状态。这道题状态的分析和第7个,第8个一样,建议看完第8题再来看第9题,以下的题解也基于第8题状态表示的结论。

三个数abc,下标分别是kij,a = 2b - c。a的位置只能在b前面,在b后面或者不存在我们就初始化对应的位置的值为2。a可能有好几个,但因为这题要个数,并不是要最长,所以我们就可以加上所有的a。假设前面所有的a的下标为k1 ~ kx,有两种情况,一个是除了kx,i,j外,kx前还有若干个符合等差序列的数字,这些情况的个数放在了dp[kx][i],也就是下标kx和i对应元素为结尾的等差序列,后面加上个j就是了,个数不变;另一个就是只有kx,i,j下标对应的元素的3个数字的等差序列,也就是kx前没有数字,这时候就是又加上了一个情况,所以dp[i][j] = dp[kx][i] + 1。kx不只有一个,所以找到一个dp[kx][i],就dp[i][j] = dp[kx][i] + 1。这里还要考虑时间复杂度问题,因为找到每一个a还需要多遍历,更耗费时间,所以我们就把元素和下标就放在哈希表中。

初始化时,因为要拿2个元素来表示状态,那这个序列就初始化为2,这样如果整个数组都不满足,dp表里的值都是2,那么最大值就是2,然后直接返回0就行。有个优化和第8题一样,为了更方便地找kx,我们固定倒数第1个数,移动倒数第2个数。返回值就是dp表的总和。

    int numberOfArithmeticSlices(vector<int>& nums) {
        int n = nums.size();
        unordered_map<long long, vector<int>> hash;
        for(int i = 0; i < n; i++) hash[nums[i]].push_back(i);
        vector<vector<int>> dp(n, vector<int>(n));
        int sum = 0;
        for(int j = 2; j < n; j++)
        {
            for(int i = 1; i <j; i++)
            {
                long long a = (long long)2 * nums[i] - nums[j];
                if(hash.count(a))
                {
                    for(auto k : hash[a])
                    {
                        if(k < i) dp[i][j] += dp[k][i] + 1;
                    }
                }
                sum += dp[i][j];
            }
        }
        return sum;
    }

结束。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值