算法技巧总结(五)动态规划与贪心算法

22 篇文章 0 订阅
1 篇文章 0 订阅

该文章有点长,请耐心阅览一下,阅览完后必有收获!


一、动态规划

什么是动态规划算法呢?

  • 动态规划过程是:每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划(DP)。
  • 动态规划常用于的一个问题就是求最值, 比如说最常见的求最长递增子序列等问题。其实动态规划的问题核心仍然是穷举,想一下求最值,那最可能的就是把所有结果列出来,谁最大要谁。
  • 动态规划是自底向上的处理思想,但它首先要自顶向下的把原问题分解为若干子问题,然后自底向上,先求解最小的子问题,把结果存储在表格中,在求解大的子问题时,直接从表格中查询小的子问题的解,避免重复计算,从而提高算法效率。
  • 核心思想:从上往下分析问题,大问题可以分解为子问题,子问题中还有更小的子问题,然后逐个破解!

动态规划操作过程:

  • 通常动态规划都按照这几步骤进行操作:
    • 1.确定dp数组及其下标含义;
    • 2.确定递推公式,即状态转移方程;
    • 3.dp初始化
    • 4.返回结果

接下来我们直接做题吧,从实践中来到实践中去一步步熟悉动态规划!

1)斐波那契数

题目描述

斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。

也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给你 n ,请计算 F(n) 。

示例
输入:2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1

输入:3
输出:2
解释:F(3) = F(2) + F(1) = 1 + 1 = 2

输入:4
输出:3
解释:F(4) = F(3) + F(2) = 2 + 1 = 3

解法一:递归

这道题目可以说是大部分人的递归入门题,是一道很好的样题,之所以入门是因为题目中将结束条件和等式关系给出来了。所以比较简单。

class Solution {
    public :
    int fib(int n) {
        if(n < 2) return n;
        return fib(n-1)+fib(n-2);
    }
}

递归算法的时间复杂度怎么计算呢,其实就是子问题个数乘以解决一个子问题需要的时间。子问题个数就是递归树中的节点总数,二叉树的节点子树是2^n, 一个子问题需要的时间,在单层里没有循环,只有加法操作,所以为1,所以整体的复杂度就是O(2^N);

指数级别,为什么会是指数级别,原因就在于有太多的重复计算。
在这里插入图片描述

首先要计算f20,递归调用f19,f19去调用f18,这样直到f1,然后最后一颗子树调f2,再逐层返回,最后得到f19,然后要调用f18,f18去调用f17,f17去调用f16,… , 但是我们在算f19的时候已经都算出来了啊,什么f18,f17都有了,就像图里有的,难道还要再算一遍?递归可是耗费巨大啊,其实这就是重叠子问题

所以我们可以用一个备忘录(dp数组)把已经好不容易算出来的结果存起来,那再算的时候如果有,就直接用就可以了。其实这就引出了动态规划,只不过我们的动规不像递归那样自顶向下(要算f20先去算f19,直至f1),动规是自底向上,由f1得到f2,得到f3直至得到f20,所以动规脱离了递归,用for循环的迭代。

解法二:动态规划

动态规划常常用于求解多阶段决策问题
动态规划问题的问法:只问最优解(常和最值联系在一起),不问具体的解;

  • 我们先创建dp数组:

    • 1.确定dp数组和其下标的含义;(第i个索引的斐波那契值)
    • 2.确定递推公式,即状态转移方程;(题目中给出,fn=fn-1+fn-2)
    • 3.dp初始化;
class Solution {
    public:
    int fib(int n) {
        vector<int>dp (n+1);  //第i个索引的函数值为dp[i];
        if(n <= 1) return n;
        dp[0] = 0;  dp[1] = 1;
        for(int i = 2; i < n+1; i++){
            dp[i] = dp[i-1]+dp[i-2];  //递推公式:状态转移方程;
        }
        return dp[n];
    }
}

时间复杂度:O(N);

2)爬楼梯

题目描述

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢? 注意:给定 n 是一个正整数。

示例
输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1.  1 阶 + 12.  2 阶

输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1.  1 阶 + 1 阶 + 12.  1 阶 + 23.  2 阶 + 1

解法一:动态规划

  • 我们先创建dp数组:

    • 1.确定dp数组和其下标的含义;(爬到第i层楼梯有dp[i]种方法)
    • 2.确定递推公式,即状态转移方程;(我们每次都只能爬一层或者爬两层,那如果要爬到第i层,这个状态只能由第i-1或者i-2得到,所以dp[i]= dp[i-1]+dp[i-2])
    • 3.dp初始化;base case; (题目中给出了n为正整数,所以dp[1]=1,dp[2]=2)
    int climbStairs(int n){
        if(n<=2)    return n;
        vector<int>dp(n+1);
        dp[1]=1;dp[2]=2;
        for(int i=3;i<dp.size();i++){
            dp[i]=dp[i-1]+dp[i-2];
        }
        return dp[n];
    }

3)使用最小花费爬楼梯

  • 题目描述

数组的每个下标作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i](下标从 0 开始)。

每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯。

请你找出达到楼层顶部的最低花费。在开始时,你可以选择从下标为 0 或 1 的元素作为初始阶梯。

示例
输入:cost = [10, 15, 20]
输出:15
解释:最低花费是从 cost[1] 开始,然后走两步即可到阶梯顶,一共花费 15 。

输入:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]
输出:6
解释:最低花费方式是从 cost[0] 开始,逐个经过那些 1 ,跳过 cost[3] ,一共花费 6
  • 解法一:动态规划

这道题题目的理解就很抽象,这个题中的体力就有点类似于"买路财"的感觉。你离开第i个阶梯需要花费cost[i],离开之后可以直接走一步或者走两步;

其次就是题目中说的到达楼顶,是要到cost的长度处,不是n-1最后一个元素处,得越过去,类似于到天花板上。 所以在创建dp数组的时候要长度为n+1,最后一个元素才为dp[n];

再其次题目中的可以选择从下标0或者下标1开始,也就是说能自己选这两个位置作为起点。

  • 我们先创建dp数组:

    • 1.确定dp数组和其下标的含义;dp[i]表示达到下标i所需要的最小花费。
    • 2.确定递推公式,即状态转移方程;到下标i处可以由之前的状态来得到,可以从下标i-1花费cost[i-1]到下标i,也可以由下标i-2花费cost[i-2]直接走两步达到。我们要的自然就是两者中的较小那个;所以:dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2]);
    • 3.dp初始化; 这道题很容易出错,因为题目中说可以从0或者1出发,所以到达0或1的最小花费都是0,即dp[0]=dp[1]=0;
    int minCostClimbingStairs(vector<int>&cost){
        vector<int>n(cost.size()+1);
        n[0]=0,n[1]=0;
        for(int i=2;i<n.size();i++){
            n[i]=min(n[i-1]+cost[i-1],n[i-2]+cost[i-2]);
        }
        return n[n.size()-1];
    }

4)买卖股票的最佳时期

  • 题目描述

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。

示例
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
     注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。   
     
输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 没有交易完成, 所以最大利润为 0
  • 解法一:最大最小值
int maxProfit(vector<int>&prices) {
        int min = prices[0];
        int profit = 0;
        for(int i = 0; i < prices.length; i++){
            min = min(min, prices[i]);
            profit = max(profit, prices[i] - min);
        }
        return profit;
    }
}
  • 解法二:动态规划

涉及到最值,没有问具体的策略,只问最后的最优解。所以可以用动态规划来解决。

从题目中可以发现,只能在买入后再卖,所以当天是否持股是一个很重要的因素,然后当天是否持股又和昨天是否持股有关,所以我们可以把当天是否持股设计成状态变量dp

  • 1.确定dp数组和其下标的含义:

    dp[i][0]:下标为i这天结束时,不持股,手上拥有的现金数;
    dp[i][1]:下标为i这天结束时,持股,手上拥有的现金数;

  • 2.确定递推公式,即状态转移方程

    dp[i][0]:今天不持股

    昨天不持股;今天啥都没做;
    昨天持股,今天卖了,现金数增加;

    dp[i][1]:今天持股

    昨天持股;今天啥都没做;
    昨天不持股,今天买了;

  • 3.dp初始化

    不持股:dp[0][0] = 0;
    持股:dp[0][1] = -prices[0];持股的话手上的现金数就是负的;

    int maxProfit(vector<int>&prices){
        int n= prices.size();
        if(n<2) return 0;
        vector<vector<int>>dp(n,vector<int>(2));
        dp[0][0]=0;//没有持股的时候现金
        dp[0][1]=dp[0][0]-prices[0];//持股的时候现金
        for(int i=1;i<n;i++){
             // 这里dp[i - 1][1] + prices[i]为什么能保证只卖了一次,因为下面一行代码买的时候已经保证了只买一次,所以这里自然就保证了只卖一次
            //不管是只允许交易一次还是允许交易多次,这行代码都不用变,因为只要保证只买一次(保证了只卖一次)或者买多次(保证了可以卖多次)即可。
            dp[i][0]=max(dp[i-1][0],dp[i-1][1]+prices[i]);
            //  - prices[i]这里可以理解为dp[0][0] - prices[i],这里为什么是dp[0][0] - prices[i],
            //因为只有这样才能保证只买一次,所以需要用一开始初始化的未持股的现金dp[0][0]减去当天的股价
            dp[i][1]=max(dp[i-1][1],dp[0][0]-prices[i]);
            // 如果题目允许交易多次,就说明可以从直接从昨天的未持股状态变为今天的持股状态,
            //因为昨天未持股状态可以代表之前买过又卖过后的状态,也就是之前交易过多次后的状态。也就是下面的代码。
              // dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
        }
        return dp[n-1][0];
    }

5)最小路径和

  • 题目描述

给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。

示例
image

输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。

输入:grid = [[1,2,3],[4,5,6]]
输出:12
  • 解法一:动态规划

  • 1.确定dp数组和其下标的含义;dp[i][j]表示走到i,j位置时的最小路径和;

  • 2.确定递推公式,即状态转移方程;题目中要求只能向右或者向下走,所以走到i,j的最小路径和只和其左边和上面有关,也就是这两个单元格的较小一个再加上grid[i][j];
    要注意矩阵的左边界是没有左边单元格的,而上边界是没有上边单元格的,所以需要单独处理;左边界就是dp[i][0] =
    dp[i-1][0]+grid[i][0];上边界dp[0][i]=dp[0][i-1]+grid[0][i];

  • 3.dp初始化;base case; 从最左上角位置开始,dp[0][0]=grid[0][0]; 最后返回右下角元素即可:dp[row-1][col-1]

    int minpathSum(vector<vector<int>>&grid){
        int m=grid.size();
        int n =grid[0].size();
        vector<vector<int>>dp(m,vector<int>(n));
        dp[0][0]=grid[0][0];
        for(int i=1;i<m;i++){
            dp[i][0]=dp[i-1][0]+grid[1][0];
        }
        for(int j=1;j<n;j++){
            dp[0][j]=dp[0][j-1]+grid[0][j];
        }
        for(int i=1;i<m;i++){
            for(int j=1;j<n;j++){
                dp[i][j]=min(dp[i-1][j],dp[i][j-1])+grid[i][j];
            }
        }
        return dp[m-1][n-1];
    }

6)打家劫舍

  • 题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4 。

输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12
  • 解法一:动态规划

  • 1.确定dp数组和其下标的含义;dp[i]表示走到第i个房子前的偷到的最大金额;

  • 2.确定递推公式,即状态转移方程;小偷走到第i个房子面前有两种选择:偷或者不偷:1.偷:因为只能跨着房子偷,小偷想要偷第k个,那只能从第k-2个房子过来的,所以金额为dp[i-2]+nums[i];2.不偷:不偷是因为不能偷,因为已经偷过了第i-1房子了,再偷报警了,所以dp[i]=dp[i-1];最终的dp[i]就是两者中的大的。

  • 3.dp初始化; dp[i]和前两个有关,所以遍历从2开dp[0]=nums[0],第一个房子肯定偷能到最大金额;dp[1]=max(nums[0], nums[1]);只能偷一个房子,两间房子肯定偷金额大的那个;

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

7)打家劫舍2

  • 题目描述

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。

给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。

示例
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。

输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4 。

输入:nums = [0]
输出:0
  • 解法一:动态规划

此题和上题打家劫舍最大的区别就在于此题中所有的房子是连成一圈的,剩下的都一模一样。关键就在于从这个条件挖掘出信息,连成一圈,其实可以将其分解为两个子问题,意思就是说第一个房子和最后一个房子不能同时被偷,只能偷一个,所以可以将其分为一个是从0-n-1;一个是从1-n;然后就看两者谁大取谁;剩下的和上题就一样了。

    int rob2(vector<int>&nums){
        int n =nums.size();
        if(n==1)    return nums[0];
        if(n==2)    return max(nums[0],nums[1]);
        return max(rob2(nums,0,n-1),rob2(nums,1,n));
    }
    int rob2(vector<int>&nums,int start,int end){
        int second=nums[start];
        int first=max(nums[start],nums[start+1]);
        for(int i=2+start;i<end;i++){
            int temp = first;
            first = max(second+nums[i], first);
            second = temp;
        }
        return first;
    }

8)最长回文子串

  • 题目描述

给你一个字符串 s,找到 s 中最长的回文子串。

示例
输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。

输入:s = "cbbd"
输出:"bb"

输入:s = "a"
输出:"a"

输入:s = "ac"
输出:"a"
  • 解法一:动态规划

此题可以用动态规划去做,因为回文串天然具有状态转移的性质,比如一个子串如果是回文子串,那把前后两个字符去掉后依然是回文子串。那反过来,就可以根据前面的子串来判断新的子串了。

  • 1.确定dp数组和其下标的含义;dp[i][j]表示子串s[i:j]是否是回文子串;
  • 2.确定递推公式,即状态转移方程;先填写j,因为i必须在j前面,i<j;判断首尾字符,如果不相等直接false,如果相等,那就状态转移,dp[i][j]= dp[i+1][j-1];
  • 3.dp初始化; 长度为1的,也就是dp[i][i]=true;
class Solution {
public:
string longestPalindrome(string s) {
    int n = s.size();
    string ans;
    if (s.empty())
        return 0;
    int len = 1;
    vector<vector<bool>> dp(n, vector<bool>(n, false)); //dp[i][j]代表下标从i到j的子串是否回文
    for (int i = n - 1; i >= 0; i--){  //注意:i要反向遍历,因为下面会出现dp[i+1][j-1]
        for (int j = i; j < n; j++){
            if (i == j)             //一个字符必定回文
                dp[i][j] = true;
            else if (j == i + 1)    //两个字符只要判断它们是否相等
                dp[i][j] = s[i] == s[j];
            else                  //三个及以上看s[i]和s[j]是否相等且去掉头尾的子串是否回文
                dp[i][j] = s[i] == s[j] && dp[i + 1][j - 1];
            if(dp[i][j]){
                if(j-i+1>=len){
                    len=j-i+1;
                    ans=s.substr(i,j-i+1); //更新最长回文子串
                }
            }
        }
    }
    return ans;
    }
};

做了几道动态规划题目,总结出来就是要知道dp数组是干什么的,dp数组的小标意味着啥,递推公式(状态转移方程),找出边界范围即(初始化),然后写代码就完事了。


二、贪心算法

啥是贪心算法???

贪心算法是指在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,只做出在某种意义上的局部最优解。贪心算法不是对所有问题都能得到整体最优解,关键是贪心策略的选择,选择的贪心策略必须具备无后效性,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。

  • 解题的一般步骤是:
    • 1.建立数学模型来描述问题;
    • 2.把求解的问题分成若干个子问题;
    • 3.对每一子问题求解,得到子问题的局部最优解;
    • 4.把子问题的局部最优解合成原来问题的一个解。

这样看来贪心算法与动态规划就有点类似,差不多的步骤,动态规划解决方法能得到整体的最优解,但贪心算法就不一定。

这几天我看了好几篇其他博主写的贪心算法博文后,让我觉得贪心算法实际应用就没有一个较统一的步骤或模板,想到怎么方法解决问题就行了。

1)跳跃游戏

  • 题目描述

给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。

数组中的每个元素代表你在该位置可以跳跃的最大长度。

判断你是否能够到达最后一个下标。

示例 1:

输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 13 步到达最后一个下标。
示例 2:

输入:nums = [3,2,1,0,4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。
  • 解法一:贪心算法
class Solution {
public:
    bool canJump(vector<int>& nums) {
        int n = nums.size();
        int maxJump=0;
        for(int i=0;i<n;i++){
            if(i<=maxJump){
                maxJump=max(maxJump,i+nums[i]);
                if(maxJump>=n-1)    return true;
            }
        }
        return false;
    }
};

2)跳跃游戏2

  • 题目描述

给你一个非负整数数组 nums ,你最初位于数组的第一个位置。

数组中的每个元素代表你在该位置可以跳跃的最大长度。

你的目标是使用最少的跳跃次数到达数组的最后一个位置。

假设你总是可以到达数组的最后一个位置

示例 1:

输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
     从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。
示例 2:

输入: nums = [2,3,0,1,4]
输出: 2
  • 解法一:贪心算法
class Solution {
public:
    int jump(vector<int>& nums) {
        int n=nums.size();int step=0;
        int maxJump=0;int end=0;
        for(int i=0;i<n-1;i++){
            if(i<=maxJump){
                maxJump=max(maxJump,i+nums[i]);
                if(end==i){
                    step++;
                    end=maxJump;
                }
            }
        }
        return step;
    }
};

三、温馨提示

看完是不是觉得又行了???放心吧,遇到较难的动态规划算法题,我们还是不会的。对了顺带一句医院的WiFi信号不错,院友们挺友善的,就是。。。。

看到这,聪明又帅又漂亮品味高的你早已经素质三连了吧!!!

  • 5
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值