Leetcode之动态规划专题

363 阅读7分钟

一. 动态规划算法概述

1.什么是动态规划算法

首先,动态规划问题的一般形式就是求最值。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用比较多,比如说让你求最长递增子序列呀,最小编辑距离呀等等。

既然是要求最值,核心问题是什么呢?求解动态规划的核心问题是穷举

掌握递归思维,只有列出正确的「状态转移方程」,才能正确地穷举。而且,你需要判断算法问题是否具备「最优子结构」,是否能够通过子问题的最值得到原问题的最值。另外,动态规划问题存在「重叠子问题」,使用「备忘录」或者「DP table」来优化穷举过程。

重叠子问题、最优子结构、状态转移方程就是动态规划三要素。

动态规划思维框架:明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义

image.png

2.什么样的问题可以考虑使用动态规划解决呢?

如果一个问题,可以把所有可能的答案穷举出来,并且穷举出来后,发现存在重叠子问题,就可以考虑使用动态规划。

比如一些求最值的场景,如最长递增子序列、最小编辑距离、背包问题、凑零钱问、序列问题、贪心类型、游戏迷宫等等,都是动态规划的经典应用场景。

动态规划的解题思路

动态规划的核心思想就是拆分子问题,记住过往,减少重复计算。(子问题之间完全独立,上下层问题之间完全依赖)  并且动态规划一般都是自底向上的,做动态规划的思路:

  • 穷举分析
  • 确定边界
  • 找出规律,确定最优子结构
  • 写出状态转移方程

更术语的解释:blog.csdn.net/qq_37763204…

二. 动态规划细节讲解

1)重叠子问题

LeetCode509: 斐波那契数列

image.png

解决方案:使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典)

image.png

2)最优子结构

Leetcode322: 零钱兑换 要符合「最优子结构」,子问题间必须互相独立(类似数学归化思想)

假设你有面值为 1, 2, 5 的硬币,你想求 amount = 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10, 9, 6 的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1, 2, 5 的硬币),求个最小值,就是原问题的答案。因为硬币的数量是没有限制的,所以子问题之间没有相互制,是互相独立的。

自顶向下:

dp 函数:dp(n) 表示,输入一个目标金额 n,返回凑出目标金额 n 所需的最少硬币数量

image.png

自底向上

dp 数组的迭代解法

自底向上使用 dp table 来消除重叠子问题,dp 数组的定义:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出

image.png

3) 状态转移方程

设计动态规划的通用技巧:数学归纳思想

Leetcode300: 最长递增子序列

数学归纳思想:我们先假设这个结论在 k < n 时成立,然后根据这个假设,想办法推导证明出 k = n 的时候此结论也成立。如果能够证明出来,那么就说明这个结论对于 k 等于任何数都成立。

同理:假设 dp[0...i-1] 都已经被算出来了,可以通过这些结果算出 dp[i]

定义:dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度

image.png nums[5] = 3,既然是递增子序列,我们只要找到前面那些结尾比 3 小的子序列,然后把 3 接到这些子序列末尾,就可以形成一个新的递增子序列,而且这个新的子序列长度加一

image.png

扑克牌二分法优化
时间复杂度为 O(NlogN)

image.png

image.png

拓展到二维
Leetcode:354 俄罗斯套娃信封问题
这道题目其实是最长递增子序列的一个变种,因为每次合法的嵌套是大的套小的,相当于在二维平面中找一个最长递增的子序列,其长度就是最多能嵌套的信封个数
解题思路: 先对宽度 w 进行升序排序,如果遇到 w 相同的情况,则按照高度 h 降序排序;之后把所有的 h 作为一个数组,在这个数组上计算 LIS 的长度就是答案

image.png image.png image.png

三.真题实战

1).Leetcode 53:最大子序和
dp数组含义:以 nums[i] 为结尾的「最大子数组和」为 dp[i]

image.png

2).Leetcode 1143:最长公共子序列
base case: dp[0][0]=0

image.png

image.png
3).Leetcode 583. 两个字符串的删除操作
结合上一题“最长公共子序列” image.png
4).Leetcode 712. 两个字符串的最小ASCII删除和
结合上一题“最长公共子序列” image.png

四.拓展:背包问题

给你一个可装载重量为 W 的背包和 N 个物品,每个物品有重量和价值两个属性。其中第 i 个物品的重量为 wt[i],价值为 val[i],现在让你用这个背包装物品,最多能装的价值是多少?

第一步要明确两点,「状态」和「选择」

  • 状态是「背包的容量」和「可选择的物品」
  • 选择是「装进背包」或者「不装进背包」

第二步要明确 dp 数组的定义

  • dp[i][j]:对于前i个物品,当前容量j,产生的最大价值就是dp[i][j]
  • base case是 dp[...][0] = dp[0][...] = 0,因为没有物品或者背包没有空间的时候,能装的最大价值就是 0。

第三步,根据「选择」,思考状态转移的逻辑

image.png

image.png

Leetcode416: 分割等和子集

image.png

image.png

Leetcode518: 零钱兑换II

image.png

image.png

五.拓展:贪心算法

什么是贪心选择性质呢,简单说就是:每一步都做出一个局部最优的选择,最终的结果就是全局最优。
很经典的贪心算法问题 Interval Scheduling(区间调度问题),
Leetcode 435:无重叠区间
解题思路:
1、从区间集合 intvs 中选择一个区间 x,这个 x 是在当前所有区间中结束最早的end 最小)。
2、把所有与 x 区间相交的区间从区间集合 intvs 中删除。
3、重复步骤 1 和 2,直到 intvs 为空为止。之前选出的那些 x 就是最大不相交子集。

image.png

class Solution {
public:
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        if(intervals.size()==1) return 0;
        sort(intervals.begin(),intervals.end(),[](const vector<int>& a,const vector<int>& b){
            return a[1]<b[1];
        });
        int minCut = 1, cur = 0;
        for(int i=1;i<intervals.size();i++){
            if(intervals[i][0] >= intervals[cur][1]) {
                cur = i;
                minCut++;
            }
        }
        return intervals.size()-minCut;
    }
};

还有一类可以用贪心算法解决的经典问题:会议室安排问题
Leetcode 253:会议室 II

第一个场景,假设现在只有一个会议室,还有若干会议,你如何将尽可能多的会议安排到这个会议室里?

这个问题需要将这些会议(区间)按结束时间(右端点)排序,然后进行处理,详见前文 贪心算法做时间管理

第二个场景,给你若干较短的视频片段,和一个较长的视频片段,请你从较短的片段中尽可能少地挑出一些片段,拼接出较长的这个片段。

这个问题需要将这些视频片段(区间)按开始时间(左端点)排序,然后进行处理,详见后文 剪视频剪出一个贪心算法

第三个场景,给你若干区间,其中可能有些区间比较短,被其他区间完全覆盖住了,请你删除这些被覆盖的区间。

这个问题需要将这些区间按左端点排序,然后就能找到并删除那些被完全覆盖的区间了,详见后文 删除覆盖区间

第四个场景,给你若干区间,请你将所有有重叠部分的区间进行合并。

这个问题需要将这些区间按左端点排序,方便找出存在重叠的区间,详见后文 合并重叠区间

第五个场景,有两个部门同时预约了同一个会议室的若干时间段,请你计算会议室的冲突时段。

这个问题就是给你两组区间列表,请你找出这两组区间的交集,这需要你将这些区间按左端点排序,详见后文 区间交集问题

第六个场景,假设现在只有一个会议室,还有若干会议,如何安排会议才能使这个会议室的闲置时间最少?

这个问题需要动动脑筋,说白了这就是个 0-1 背包问题的变形:

会议室可以看做一个背包,每个会议可以看做一个物品,物品的价值就是会议的时长,请问你如何选择物品(会议)才能最大化背包中的价值(会议室的使用时长)?

当然,这里背包的约束不是一个最大重量,而是各个物品(会议)不能互相冲突。把各个会议按照结束时间进行排序,然后参考前文 0-1 背包问题详解 的思路即可解决,等我以后有机会可以写一写这个问题。

第七个场景,就是本文想讲的场景,给你若干会议,让你合理申请会议室。
这道题的本质是:给你输入若干时间区间,让你计算同一时刻「最多」有几个区间重叠

image.png

Leetcode1024:视频拼接
区间问题肯定按照区间的起点或者终点进行排序
贪心策略:
1、要用若干短视频凑出完成视频 [0, T],至少得有一个短视频的起点是 0。

2、如果有几个短视频的起点都相同,那么一定应该选择那个最长(终点最大)的视频。如果起点相同,那肯定是越长越好.

基于以上两个特点,将 clips 按照起点升序排序,起点相同的按照终点降序排序,最后得到的区间顺序就像这样:

image.png

class Solution {
public:
    int videoStitching(vector<vector<int>>& clips, int time) {
        int res = 0, curEnd = 0,nextEnd = 0, i=0;
        sort(clips.begin(),clips.end(),[](const auto &a, const auto &b){
            if (a[0]==b[0]) return a[1]>b[1];
            else return a[0]<b[0];
        });
        int n = clips.size();
        while(i<n && clips[i][0]<=curEnd){
            while(i<n && clips[i][0]<=curEnd){
                nextEnd = max(nextEnd,clips[i][1]);
                i++;
            }
            res++;
            curEnd = nextEnd;
            if(curEnd>=time) return res;
        }
        return -1;
    }
};

贪心算法经典问题:跳跃游戏
leetcode45:跳跃游戏
解法1: 动态规划

class Solution {
public:
    int jump(vector<int>& nums) {
        int n = nums.size();
        vector<int> dp(n, n);//自底向上递推公式:从0跳到第i个位置需要的最小步数
        dp[0] = 0;
        for(int i=0;i<n;i++){
            int steps = nums[i];
            for(int j=1;j<=steps && i+j<n ;j++){
                dp[i+j] = min(dp[i+j], dp[i]+1);
            }
        }
        return dp[n-1];
    }
};

解法2: 贪心算法

int jump(vector<int>& nums) {
        //贪心策略,每次选取能跳最远的格子
        int n = nums.size(), farthest = 0, end = 0, jumps = 0;
        for(int i=0;i<n-1;i++){
            farthest = max(farthest, nums[i]+i);
            if(i == end){
                end = farthest;
                jumps++;
            }
        }
        return jumps;
    }

参考