【C++】动态规划之状态转移方程(单串)

目录

一、单串

最长递增子序列(依赖O(n)个子问题)

300. 最长递增子序列

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

最大子数组和(依赖O(1)个子问题,可以用滚动数组优化空间复杂度)

53.最大子数组和

152. 乘积最大子数组

单串问题:变形,需要两个位置的情况: dp[i][j] 以 j, i 结尾

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

二、双串

三、区间

四、前缀和

五、背包问题


一、单串

最长递增子序列(依赖O(n)个子问题)

300. 最长递增子序列

300. 最长递增子序列 - 力扣(Leetcode)

思路:

(1)“长度的最值”  求最值那就是dp[i]=max(),dp[i]记录的就是最值~

(2)“递增”  nums[i]>nums[j]

一个一个穷举试试看?

AC代码:

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        /*找到其中最长严格递增子序列的长度。*/
        int n=nums.size();
        vector<int> dp(n+1,1);//dp[i]:截止到i为止(包括i),的最长递增子序列的长度,子序列一定要用到i结尾,否则会出错!!
        dp[0]=1;
        for(int i=1;i<=n;++i){//规模是1~n
            for(int j=1;j<=i;++j){//每个规模的状态转移和这个规模内的所有子规模都有关~所以要遍历子规模呀
                if(nums[i-1]>nums[j-1]){//严格递增所以用>
                    dp[i]=max(dp[i],dp[j]+1);
                }
            }
        }
        int res=*max_element(dp.begin(), dp.end());
        return res;
    }
};

踩坑1:

为什么一定要以nums[i]结尾?因为不这样做,会导致不连续的子序和对计数产生干扰和混淆!!

 假设不以nums[i]结尾,代码:

    int lengthOfLIS(vector<int>& nums) {
        /*找到其中最长严格递增子序列的长度。*/
        int n=nums.size();
        vector<int> dp(n+1,1);//dp[i]:截止到i为止(包括i),的最长递增子序列的长度
        dp[0]=1;
        for(int i=1;i<=n;++i){//规模是1~n
            for(int j=1;j<=i;++j){//每个规模的状态转移和这个规模内的所有子规模都有关~
                if(nums[i-1]>nums[j-1]){//严格递增所以用>
                    dp[i]=max(dp[i],dp[j]+1);
                }
                else{
                    dp[i]=max(dp[i],dp[j]);
                }
            }
        }
        return dp[n];
    }
};

出错!

错误例子[4,10,4,3,8,9]

输出4

预期3

反思:错在了nums[5] 8那里,规模为6的数组里,如果子序列不以8结尾,有[4,10]长度是2,这样8前面的3记录的是2,又8>3那么 8记录的是2+1=3,但是规模为6的数组里不存在长度为3的子序列,只有[4,10]和[3,8]两个长度为2的序列,但8这里却把[4,10]和[3,8]混在一起。


踩坑2:dp[i]记录的是以nums[i]结尾的子序列的长度,而到底以哪个nums[i]结尾的子序列最长,是不一定的!最长子序列可能出现在前部,也可能出现在后部(见图),所以dp[i]的最大值不一定出现在结尾,所以要找出最大的dp[i]再返回而不是直接返回dp[n]!!!


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

673. 最长递增子序列的个数 - 力扣(Leetcode)

比最长递增子序列多的两步:

(1)用一个数组记录以该元素结尾的最长递增子序列的个数

(2)记录(或重置更新或累加)最长递增子序列的个数

(1)用一个数组记录以该元素结尾的最长递增子序列的个数

代码:

if (nums[i] > nums[j]) {
    if (dp[j] + 1 > dp[i]) {
        dp[i] = dp[j] + 1;
        cnt[i] = cnt[j]; // 重置计数
    } 
    else if (dp[j] + 1 == dp[i]) {
        cnt[i] += cnt[j];
    }
}

 解释:

如果是由上一个刚转移过来的,那么cnt[i]递增子序列的个数和上一个是一样的,因为上一个必选。

如果dp[i]已经遇到过由上一个转移的元素(此时dp[i]已经+1了),又遇到了可以转移的元素,那么cnt[i]递增的子序列个数又可以增加一波了,增加的个数就是新的可转移的元素它的最长递增子序列的个数(同理这个元素也是必选的)

 (2)记录(或重置更新或累加)最长递增子序列的个数

在每一轮求得规模为i的数组的最长递增子序列的长度与个数之后,应该记录(或重置更新或累加)最长递增子序列的个数。用一个maxLen长度计数器记全局的最长子序列长度,用ans个数计数器最长递增子序列的个数记录如果发现了规模为i的数组中出现了比之前更长的子序列,那么长度计数器更新为dp[i],个数计数器重置为cnt[i]

代码:

if (dp[i] > maxLen) {//用一个maxLen记全局的最长子序列,如果发现了一个比原来更长的子序列
                maxLen = dp[i];//更新最长子序列
                ans = cnt[i]; // 重置计数
            } else if (dp[i] == maxLen) {//如果最长子序列有很多个,出现在很多个不同长度的子数组dp[i]中,那么应该把它们累加起来
                ans += cnt[i];

完整代码:

class Solution {
public:
    int findNumberOfLIS(vector<int> &nums) {
        int n = nums.size(), maxLen = 0, ans = 0;
        vector<int> dp(n), cnt(n);
        for (int i = 0; i < n; ++i) {
            dp[i] = 1;
            cnt[i] = 1;
            for (int j = 0; j < i; ++j) {
                if (nums[i] > nums[j]) {
                    if (dp[j] + 1 > dp[i]) {
                        dp[i] = dp[j] + 1;
                        cnt[i] = cnt[j]; // 重置计数
                    } else if (dp[j] + 1 == dp[i]) {
                        cnt[i] += cnt[j];
                    }
                }
            }
            if (dp[i] > maxLen) {
                maxLen = dp[i];
                ans = cnt[i]; // 重置计数
            } else if (dp[i] == maxLen) {
                ans += cnt[i];
            }
        }
        return ans;
    }
};

最大子数组和(依赖O(1)个子问题,可以用滚动数组优化空间复杂度)

53.最大子数组和

53. 最大子数组和 - 力扣(Leetcode)

思路:一句话!之前小于零的,不要来找我;之前大于零的,欢迎加入!

(1)贪心

(2)动态规划

本质上还是贪心的思路,因此每个规模的答案只依赖O(1)个规模的子问题。

遍历一遍数组,记当前遍历到的元素为nums[i]

如果nums[i]之前的子数组的和dp[i]<0,dp[i]=nums[i];

反之dp[i]=dp[i-1]+nums[i];

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int n=nums.size();
        vector<int> dp(n);
        dp[0]=nums[0];
        for(int i=1;i<n;++i){
            if(dp[i-1]<0){
                dp[i]=nums[i];
            }
            else{
                dp[i]=dp[i-1]+nums[i];
            }
        }
        int res= *max_element(dp.begin(),dp.end());
        return res;
    }
};

152. 乘积最大子数组

152. 乘积最大子数组 - 力扣(Leetcode)

//152. 乘积最大子数组
class Solution {
public:
    int maxProduct(vector<int>& nums) {
        int res=nums[0];
        int pre_min=nums[0];
        int pre_max=nums[0];
        for(int i=1;i<nums.size();i++){
            int cur_max=max(nums[i],max(nums[i]*pre_min,nums[i]*pre_max));
            int cur_min=min(nums[i],min(nums[i]*pre_min,nums[i]*pre_max));
            res=max(res,cur_max);
            pre_max=cur_max;
            pre_min=cur_min;
        }
        return res;
    }
};

单串问题:变形,需要两个位置的情况: dp[i][j] 以 j, i 结尾

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

873. 最长的斐波那契子序列的长度 - 力扣(Leetcode)

(1)思路:

斐波那契数列的定义:arr[i]=arr[j]+arr[k],其中i>j>k

因为当下标 i 确定时,任何小于下标 i 的下标 j 都可能满足 arr[j] 是某个斐波那契子序列中 arr[i] 前面的一个数字,因此只有当确定斐波那契子序列的最后两个数字时,才能确定整个斐波那契子序列。所以用二维数组dp[j][i]表示以arr[j]和arr[i]结尾的斐波那契数列的最大长度。

举例子[1,2,3,4,5,6,7,8,9]找规律

我们可以找到最长的斐波那契数列是[1,2,3,5,8],次之是[1,2,3,5]和[1,4,5,9],那么怎么发现dp[4][7](以5,8结尾)和dp[2][4](以3,5)的关系呢?我们按照斐波那契数列的公式多列几个式子:

arr[i]=arr[j]+arr[k],

arr[j]=arr[k]+arr[l],

……

例子中

arr[7]=arr[4]+arr[2],

arr[4]=arr[2]+arr[1],

可以看出dp[2][4]=4,dp[4][7]=5

dp[4][7]=dp[2][4]+1,斐波那契数列加上arr[i],长度加1。

那么是否能猜测:dp[j][i]=dp[k][j]+1

即在以 arr[k] 和 arr[j] 作为最后两个数字的斐波那契子序列的后面添加 arr[i] ,即可得到以 arr[j] 和 arr[i] 作为最后两个数字的斐波那契子序列。

经过枚举几个例子验证是可以的。

接下来考虑边界条件和分类讨论,就是说dp[j][i]=dp[k][j]+1是否能适用于所有情况?

我们发现是不能的,对于[1,2,3, dp[2][3]=3,dp[2][1]=0,dp[2][3]!=dp[2][1]+1,这是因为斐波那契数列的长度至少为3,当不能形成斐波那契数列时长度为0,因此这种情况要单独讨论。

总结起来,状态转移方程如下:

(2) 优化(两数之和的方法):

(因为arr[k]<arr[j]<arr[i],所以arr[i]=arr[j]+arr[k]<2arr[j],所以有解时必然有arr[i]<2arr[j] )

(3)代码:

//873. 最长的斐波那契子序列的长度
class Solution {
public:
    int lenLongestFibSubseq(vector<int>& arr) {
        unordered_map<int, int> indices;
        int n = arr.size();
        for (int i = 0; i < n; i++) {
            indices[arr[i]] = i;//找下标
        }

        vector<vector<int>> dp(n, vector<int>(n,0));
        int ans = 0;
        for (int i = 0; i < n; i++) { //arr[i]=arr[j]+arr[k]
            for (int j = i - 1; j >= 0 && arr[j] * 2 > arr[i]; j--) {
                int k = -1;
                if (indices.count(arr[i] - arr[j])) {//两数之和!用哈希表找第三个数在不在数组中!!!秒呜呜呜呜呜
                    k = indices[arr[i] - arr[j]];//找第三个数的下标,记为k
                    dp[j][i] = max(dp[k][j] + 1, 3);
                    ans = max(ans, dp[j][i]);
                }
            }
        }
        return ans;
    }
};

带维度单串 dp[i][k] 

 

 

 

二、双串

三、区间

四、前缀和

五、背包问题

        01背包:n种物品,每种物品1个,第i个物品价值wi,体积vi。问:一个体积为V的背包,怎么放物品,才能使背包价值最大?

分析:

        限制条件:物品种类(i=1,2,3,4....I ),背包的体积V,物品数量{0,1}

       状态定义一 dp[i] :前i个物品中,使得背包体积最大的组合方式

        反驳:不是无后效性。 1:1,2:3  3:5 4:20 V=5 如果按照第一种状态定义,先放1->2->3(抛弃1)->4抛弃(2,3)实际上是

dp[i][j]:  前i个物品中,使得背包体积为j空间的物品的最大价值

dp[i][j]=dp[i-1][j-vi]+wi

dp[0][0]=0

什么是记忆化搜索呢?搜索的低效在于没有能够很好地处理重叠子问题;动态规划虽然比较好地处理了重叠子问题,但是在有些拓扑关系比较复杂的题目面前,又显得无奈。记忆化搜索正是在这样的情况下产生的,它采用搜索的形式和动态规划中递推的思想将这两种方法有机地综合在一起,扬长避短,简单实用,在信息学中有着重要的作用。

  用一个公式简单地说:记忆化搜索=搜索的形式+动态规划的思想。

  动态规划:就是一个最优化问题,先将问题分解为子问题,并且对于这些分解的子问题自身就是最优的才能在这个基础上得出我们要解决的问题的最优方案,要不然的话就能找到一个更优的解来替代这个解,得出新的最优自问题,这当然是和前提是矛盾的。动态规划不同于 贪心算法,因为贪心算法是从局部最优来解决问题,而动态规划是全局最优的。用动态规划的时候不可能在子问题还没有得到最优解的情况下就做出决策,而是必须等待子问题得到了最优解之后才对当下的情况做出决策,所以往往动态规划都可以用 一个或多个递归式来描述。而贪心算法却是先做出一个决策,然后在去解决子问题。这就是贪心和动态规划的不同。

一般遇到一个动态规划类型的问题,都先要确定最优子结构,还有重叠子问题,这两个是动态规划最大的特征,然后就是要写 动态规划的状态方程,这个步骤十分十分的重要的,写动归方程是需要一定的经验的,这可以通过训练来达到目的。接着就是要自底向上的求解问题的,先将最小规模的子问题的最优解求出,一般都用一张表来记录下求得的解,到后来遇到同样的子问题的时候就可以直接查表得到答案,最后就是通过一步一步的迭代得出最后问题的答案了。

我的理解最重要的东西就是一定会要一个数组或者其他的存储结构存储得到的子问题的解。这样就可以省很多时间,也就是典型的空间换时间

动态规划的一种变形就是记忆化搜索,就是根据动归方程写出递归式,然后在函数的开头直接返回以前计算过的结果,当然这样做也需要一个存储结构记下前面计算过的结果,所以又称为记忆化搜索。

记忆化搜索递归式动态规划

1.记忆化搜索的思想

记忆化搜索的思想是,在搜索过程中,会有很多重复计算,如果我们能记录一些状态的答案,就可以减少重复搜索量

2、记忆化搜索的适用范围

根据记忆化搜索的思想,它是解决重复计算,而不是重复生成,也就是说,这些搜索必须是在搜索扩展路径的过程中分步计算的题目,也就是“搜索答案与路径相关”的题目,而不能是搜索一个路径之后才能进行计算的题目,必须要分步计算,并且搜索过程中,一个搜索结果必须可以建立在同类型问题的结果上,也就是类似于动态规划解决的那种。

也就是说,他的问题表达,不是单纯生成一个走步方案,而是生成一个走步方案的代价等,而且每走一步,在搜索树/图中生成一个新状态,都可以精确计算出到此为止的费用,也就是,可以分步计算,这样才可以套用已经得到的答案

3、记忆化搜索的核心实现

a. 首先,要通过一个表记录已经存储下的搜索结果,一般用哈希表实现

b.状态表示,由于是要用哈希表实现,所以状态最好可以用数字表示,常用的方法是把一个状态连写成一个p进制数字,然后把这个数字对应的十进制数字作为状态

c.在每一状态搜索的开始,高效的使用哈希表搜索这个状态是否出现过,如果已经做过,直接调用答案,回溯

d.如果没有,则按正常方法搜索

4、记忆化搜索是类似于动态规划的,不同的是,它是倒做的“递归式动态规划”。

————————————————

版权声明:本文为CSDN博主「剑锋OI」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/hjf1201/article/details/78680814

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值