必须了解的编程基础--动态规划篇小节:从简单、中等到困难题

写在前面

动态规划题目,状态定义很关键,超级关键。状态如何定义:靠积累、靠结合题目要求思考、靠举例归纳。

1. 简单题目

1.1 [LeetCode 70 爬楼梯]1

  • 通过这个简单题来理解动态规划和回溯法和区别与联系。
  • 同时说明动态规划的原理。
  • 简单题目简单在于状态转移方程特别直观好想。

1.1.1 回溯解法:暴力搜索

首先,这种解法是超时的,之所以还这么做的原因是为了在面试的时候保底。多练习递归或者暴力搜索法,以防在面试的时候,啥都不会。
爬楼梯抽象出来就是将一个整数不停地减小,每次只有两种选择:策略1:减去1;策略2:减2,直到减为0。问一共有多少种不同的策略组合方式。以3和4为例,问题的求解可以变成求解二叉树的分枝个数。注意,其中当节点为1或2时停止,因为当节点为1或2时,再往下分解其叶子节点的个数就是1或2.

必须了解的编程基础--动态规划篇小节:从简单、中等到困难题_第1张图片
图1 爬楼梯问题转化为整数的二叉树分解

典型的二叉树结构递归的问题,二叉树结构的每一层都有两种策略:减1或者减2,而所求的分枝个数,就是两种策略下的分枝个数之和。代码如下:

class Solution {
     
public:
    int climbStairs(int n) {
     
        if (n == 1 || n ==2) return n;
        return climbStairs(n-1) + climbStairs(n-2);
    }
};

结果超时,最后执行的输入是44。

分析超时的原因,子树被重复多次计算。以图2为例进行说明:

必须了解的编程基础--动态规划篇小节:从简单、中等到困难题_第2张图片
图2 回溯解法爬楼梯超时原因分析

图2的左图时,计算过一次3的结果;但是进行到图2中,即计算4的时候,3又要再计算一次;同样在计算5的时候,4的子树又要再被计算一次。因此,从左往右观察图2,这个二叉树结构每增加一层,就有将近一半的节点要再被重新计算一次。
所以,如果从这个二叉树的叶子节点往根节点方向计算,同时用dp数组来存储每个子树根节点的值,那么就可以避免子树重复计算,时间复杂度自然就会降下来。修改代码如下:

class Solution {
     
public:
    int climbStairs(int n) {
     
        int dp[n+1];
        for (int i=1; i<=n; i++) {
     
            if (i<3) dp[i] = i;
            else dp[i] = dp[i-1] + dp[i-2];
        }
        return dp[n];
    }
};

其实这个就是动态规划解法。

1.1.2 动态规划解法

动态规划方法,是利用问题各个阶段的关系,逐个求解,最终求得全局最优解。

本题的动态规划原理2

  1. 原问题和子问题:原问题是n阶台阶走法,子问题是1阶台阶、2阶台阶、…、n-1阶台阶的走法;
  2. 确认状态:本题的动态规划状态单一,第i个状态即为i阶台阶的走法数量。
  3. 确认边界状态值:因为有的时候边界条件不一定符合递推关系。这里的边界条件是,1阶台阶和2阶台阶的走法数量, 分别为1和2.也就是dp[1] = 1, dp[2] = 2;
  4. 确定状态转移方程:核心是走1步或走2步:将求第i个状态的值转移为求第i-1和第i-2个状态的值,动态规划的状态转移方程是:dp[i] = dp[i-1] + dp[i-2];

代码如下:

class Solution {
     
public:
    int climbStairs(int n) {
     
        int dp[n+3];   // 对n=0时,不会出现dp数组越界的情况
        // 边界条件
        dp[1] = 1;
        dp[2] = 2;
        // 递推
        for (int i=3; i<=n; i++) {
     
            dp[i] = dp[i-1] + dp[i-2];
        }
        return dp[n];
    }
};

1.2 LeetCode 198 打家劫舍3

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

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

示例 1:

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

一般很难立刻想到题目对应的动态规划解法的四要素内容,需要通过枚举例子来慢慢寻找,本题的动态规划四要素是2

  1. 原问题和子问题:原问题是n个房间的最优解,子问题是前1个房间、前2个房间、…、前n-1个房间的最优解;
  2. 确认状态:第i个状态即为前i个房间的最优解。
  3. 确认边界状态值:前1个房间最优解即为第一个房间的现金;前2个房间最优解即为前两个房间中最大的现金值。
  4. 确定状态转移方程:核心是不相邻:a. 选择第i个房间:第i个状态最优解等于第i房间现金+第i-2个状态的最优解。b. 不选择第i个房间:第i个状态的最优解等于第i-1个状态的最优解。状态转移方程:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
class Solution {
     
public:
    int rob(vector<int>& nums) {
     
        if (nums.size() == 0) return 0;
        if (nums.size() == 1) return nums[0];
        int n = nums.size();
        int dp[n+2];
        // 边界条件
        dp[0] = nums[0];
        dp[1] = max(nums[0], nums[1]);
        // 递推
        for (int i=2; i<n; i++) {
     
            dp[i] = max(dp[i-1], nums[i]+dp[i-2]);
        }
        return dp[n-1];
    }
};

1.3 LeetCode 53 最大子序和4

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例:

输入: [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

分析:

  • 如果按照前面两题的思路设计状态的话,即定义第i个状态为前i个数字的最优解。会发现很难发现第i个状态和第i-1个状态之间有什么联系。
  • 那是因为本题的关键是子数组中的元素要是连续的,而上面的状态设计没有体现这一点。
  • 因此,考虑这样设计状态:第i个状态为以第i个元素结尾的子数组的最优解。通过枚举例子可以发现有这样的状态转移方程:dp[i] = max(dp[i-1]+nums[i], nums[i])

于是,该题的动态规划四要素是:

  1. 原问题和子问题:原问题是长度为n的数组的最大子数组和,子问题是以第1个元素结尾的最大子数组和、以第2个元素结尾的最大子数组和、…、以第n-1个元素结尾的最大子数组和;
  2. 确认状态:第i个状态即为以第i个元素结尾的子数组的最优解。
  3. 确认边界状态值:以第i个元素结尾的子数组的最优解为第一个元素值;
  4. 确定状态转移方程:核心是元素连续:a. 选择以第i-1元素结尾的子数组:第i个状态的最优解等于第i-1个状态的最优解+nums[i]。b.不选择以第i-1元素结尾:第i个状态的最优解等于nums[i]。状态转移方程:dp[i] = max(dp[i-1]+nums[i], nums[i])
class Solution {
     
public:
    int maxSubArray(vector<int>& nums) {
     
        if (nums.size() == 0) return 0;
        if (nums.size() == 1) return nums[0];
        int dp[nums.size()];
        // 边界条件
        dp[0] = nums[0];
        int ans=dp[0];
        // 递推
        for (int i=1; i<nums.size(); i++) {
     
            dp[i] = max(nums[i]+dp[i-1], nums[i]);
            ans = ans>dp[i] ? ans:dp[i];
        }
        return ans;
    }
};

1.4 简单题小节

  • 状态设计很关键,要能体现问题的要求。
  • 爬楼梯的要求是一次走1步或者2步,而将第i个状态设计为i阶楼梯走法的数量,可以很容易发现第i个状态和第i-1、i-2状态之间的联系为:dp[i]=dp[i-1] + dp[i-2];
  • 打家劫舍的要求是不能连续两家作案,因而将第i个状态设计为前i个房屋的最优解,也很容易发现第i个状态和第i-1、i-2个状态之间的联系:dp[i] = max(dp[i-1], nums[i]+dp[i-2]), 也体现了不能连续作案的要求。
  • 连续子数组最大和问题的要求是元素连续,因为将第i个状态设计为以第i个元素结尾的子数组。同样很容易通过枚举例子发现第i个状态和第i-1个状态之间的联系:dp[i] = max(dp[i-1]+nums[i], nums[i])
  • 边界条件考虑也很重要。所谓边界条件,就是那些可能不满足状态转移方程的起始状态

2. 中等题目

2.1 LeetCode 322. 零钱兑换5

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

示例 1:

输入: coins = [1, 2, 5], amount = 11
输出: 3 
解释: 11 = 5 + 5 + 1

分析:

  • 试试贪心:[1,2,5,7,10] 14, 贪心思想寻找的结果是[10, 2, 2]不是最优解[7,7], 所以贪心思想不合适本题。
  • 使用动态规划,思考状态如何定义?显然dp[i]表示金额i的最优解,而dp[]数组存储金额1至金额i的最优解。
  • 状态转移方程:第i状态和哪些状态有联系?
  • 以coins = [1, 2, 5], amount = 11为例:由金额组成开始分析 11 = 1 + (11-1) = 2 + (11-2) = 5 + (11-5);所以,状态11与状态 10、9和6必然有关。就像爬楼梯第i状态和第i-1和i-2状态有联系一样。
    初始条件:dp[1] = 1, dp[2] = 1, dp[3] = -1, dp[4] = -1, dp[5]=1, 剩余到11都是-1;
    枚举归纳递:
    3 = 1 + (3-1) => dp[3] = 1 + dp[3-1] = 2;
    3 = 2 + (3-2) => dp[3] = 1 + dp[3-2] = 2;
    dp[3] = min(2,2) = 2;
    4 = 1 + (4-1) => dp[4] = 1 + dp[4-1] = 3;
    4 = 2 + (4-2) => dp[4] = 1 + dp[4-2] = 2;
    dp[4] = min(2,3,2) = 2;
    5 = 1 + (5-1) => dp[5] = 1 + dp[5-1] = 3;
    5 = 2 + (5-2) => dp[5] = 1 + dp[5-2] = 3;
    5 = 5 + (5-5) => dp[5] = 1 + dp[5-5] = 1; 
    dp[5] = min(3,3,1) = 1;
    6 = 1 + (6-1) => dp[6] = 1 + dp[6-1] = 2;
    6 = 2 + (6-2) => dp[6] = 1 + dp[6-2] = 3;
    6 = 5 + (6-5) => dp[6] = 1 + dp[6-4] = 2;
    dp[6] = min(2,3,2) = 2;
    7 = 1 + (7-1) => dp[7] = 1 + dp[7-1] = 3;
    7 = 2 + (7-2) => dp[7] = 1 + dp[7-2] = 2;
    7 = 5 + (7-5) => dp[7] = 1 + dp[7-5] = 2;
    dp[7] = min(3,2,2) = 2;
    8 = 1 + (8-1) => dp[8] = 1 + dp[8-1] = 3;
    8 = 2 + (8-2) => dp[8] = 1 + dp[8-2] = 3;
    8 = 5 + (8-5) => dp[8] = 1 + dp[8-5] = 3;
    dp[8] = min(3,3,3) = 3;
    9 = 1 + (9-1) => dp[9] = 1 + dp[9-1] = 4;
    9 = 2 + (9-2) => dp[9] = 1 + dp[9-2] = 3;
    9 = 5 + (9-5) => dp[9] = 1 + dp[9-5] = 3;
    dp[9] = min(4,3,3) = 3;
    10 = 1 + (10-1) => dp[10] = 1 + dp[10-1] = 4;
    10 = 2 + (10-2) => dp[10] = 1 + dp[10-2] = 4;
    10 = 5 + (10-5) => dp[10] = 1 + dp[10-5] = 2;
    dp[10] = min(4,4,2) = 2;
    11 = 1 + (11-1) => dp[11] = 1 + dp[11-1] = 3;
    11 = 2 + (11-2) => dp[11] = 1 + dp[11-2] = 4;
    11 = 5 + (11-5) => dp[11] = 1 + dp[11-5] = 3;
    dp[11] = min(4,4,3) = 3;
    
  • 因此,由金额i的组成分析可知:i = (i-coins[0]) + coins[0]; i = (i-coins[1]) + conins[1]; i = (i - coins[2]) + coins[2];
    然后推出与状态i有联系的状态:dp[i] = min(dp[i-coins[0]], dp[i-coins[1]], dp[i-coins[2]]) + 1;
  • 有dp[5]的求解可知,设定边界条件dp[0] = 0;
  • 由dp[3]、dp[4]和dp[5]的求解可知,i-coins[j] >= 0;
  • 由dp[11] = 1 + dp[11-1]可知,如果dp[11-1]一直是初始值-1,那么dp[11]结果就是0,这就出现错误。因此,需要 dp[i-coins[j]] != -1;

于是,该题的动态规划四要素是:

  1. 原问题和子问题:原问题是金额i的最优解;子问题是金额1的最优解、金额2的最优解、…、金额i-1的最优解。
  2. 确认状态:第i个状态是金额i的最优解。
  3. 确认边界状态值:第0个状态为0,dp[0] = 0;
  4. 确定状态转移方程:核心是元素金额i与小于i的其他金额之间的联系:dp[i] = min(dp[i-coins[0]], dp[i-coins[1]], …, dp[i-coins[j]]) + 1; 当然前提是 i - coins[j]>=0, 而且dp[i-coins[j]] != -1;

代码如下:

class Solution {
     
public:
    int coinChange(vector<int>& coins, int amount) {
     
        int dp[amount+1];
        for (int i=0; i<amount+1; i++) dp[i] = -1;
        // 边界条件
        dp[0] = 0;
        // 递推
        for (int i=1; i<=amount; i++) {
     
            for (int j=0; j<coins.size(); j++) {
     
                if ( i - coins[j] >= 0 && dp[i-coins[j]] != -1) {
     
                    // 因为dp[i]=-1, 所以min()不适合用来更新dp[i]
                    if (dp[i] == -1 || dp[i] > dp[i-coins[j]]+1) {
     
                        dp[i] = dp[i-coins[j]]+1;
                    }
                }
            }
        }
        return dp[amount];
    }
};

2.2 LeetCode120 三角形最小路径和6

给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。

相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。

例如,给定三角形:

[
     [2],
    [3,4],
   [6,5,7],
  [4,1,8,3]
]

自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)

分析:

  • 从下往上和从上往下思考,哪个考虑的边界条件更少?从下往上考虑的更少。从下往上,上一层的元素(i,j)只取决与下一层中的(i+1,j)和(i+1,j+1),而且(i+1,j)和(i+1,j+1)一定会在三角形内。但是,如果从上往下考虑,那么下一层的元素(i,j)要取决于上一层的(i-1, j)和(i-1, j-1), 但是上一层的(i-1, j)和(i-1, j-1)可能会出界。

  • 因为考虑边界条件少,所以选择从下往上递推。

  • 定义状态,dp[i][j]表示为从最底层到元素(i,j)的最优解。状态dp[i][j]和子问题状态的关系通过枚举例子归纳得出:

    以示例为例:
    初始状态:dp[3][0] = 4, dp[3][1] = 1, dp[3][2] = 8, dp[3][3] = 3;
    dp[2][0] = nums[2][0] + min(dp[3][0], dp[3][1]) = 6 + min(4,1) = 7;
    dp[2][1] = nums[2][1] + min(dp[3][1], dp[3][2]) = 5 + min(1,8) = 6;
    dp[2][2] = nums[2][2] + min(dp[3][2], dp[3][3]) = 7 + min(8,3) = 10;
    dp[1][0] = nums[1][0] + min(dp[2][0], dp[2][1]) = 3 + min(7,6) = 9;
    dp[1][1] = nums[1][1] + min(dp[2][1], dp[2][2]) = 4 + min(6,10) = 10;
    dp[0][0] = nums[0][0] + min(dp[1][0], dp[1][1]) = 2 + min(9, 10) = 11;
  • 状态转移公式为:dp[i][j] = nums[i][j] + min(dp[i+1][j], dp[i+1][j+1])

  • 边界条件就是最底层元素;

class Solution {
     
public:
    int minimumTotal(vector<vector<int>>& triangle) {
     
        if (triangle.size() == 0) return 0;
        // 初始化dp[][]为0
        vector<vector<int>> dp;
        for (int i=0; i<triangle.size(); i++) {
     
            dp.push_back(vector<int>());
            for (int j=0; j<triangle[i].size(); j++) {
     
                dp[i].push_back(0);
            }
        }
        // 边界条件
        dp.back() = triangle.back();

        // 状态转移方程
        for (int i=triangle.size()-2; i>=0; i--) {
     
            for (int j=0; j<triangle[i].size(); j++) {
     
                dp[i][j] = triangle[i][j] + min(dp[i+1][j], dp[i+1][j+1]);
            }
        }

        return dp[0][0];
    }
};

2.3 LeetCode 300. 最长上升子序列7

给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例:

输入: [10,9,2,5,3,7,101,18]
输出: 4 
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4

算法时间复杂度要求为 O ( n 2 ) O(n^2) O(n2)

考虑动态规划的解法,关键是如何确定第i个状态dp[i]表示什么?
有两种选择2
(1) 像1.2打家劫舍那样,dp[i]表示前i个数字中的最优解,即前i个数字中最长上升子序列的长度。
(2) 像1.3最大和的连续子数组那样,dp[i]表示以第i个数字结尾的最优解,即以第i个数字结尾的最长上升子序列的长度。

选择哪种的依据是,能否找到dp[i]和dp[i-1]的关系!
或者还可以这么推理:定义了dp[i]之后,看看dp[i-1]、dp[i-2]、…有哪些会影响dp[i]的取值。很明显的一点是,以(2)定义状态i,很容易发现这一点。

例如, [10,9,2,5,3,7,101,18]:
dp[0] = 1;
dp[1]: nums[1] < nums[0] => dp[1] = 1;
dp[2]: nums[2] < nums[1] => dp[2] = 1;
dp[3]: nums[3] > nums[2] => dp[3] = dp[2] + 1 = 2;
dp[4]: nums[4] > nums[2] => dp[4] = dp[2] + 1 = 2;
dp[5]: nums[5] > nums[3] => dp[5] = dp[3] + 1 = 3;
dp[6]: nums[6] > nums[5] => dp[6] = dp[5] + 1 = 4;
dp[7]: nums[7] > nums[5] => dp[7] = dp[5] + 1 = 4;

而结果ans = max(dp[0], dp[1], dp[2],...,dp[7]) = 4;

所以状态转移方程是:

当 nums[i] > nums[j]时,
dp[i] = max(dp[i], 1+dp[j])

而且,如果j是从i开始递减,那么只需要在遇到第一个符合状态转移方程的状态终止即可。

代码如下:

class Solution {
     
public:
    int lengthOfLIS(vector<int>& nums) {
     
        if (nums.size()==0) return 0;
        // 状态定义为以第i个数字结尾的最长上升子序列长度
        // 初始化dp值为1,同时也定义好了边界条件dp[0]=1
        vector<int> dp(nums.size(), 1);
        int ans=1;

        // 递推
        for (int i=1; i<nums.size(); i++) {
     
            for (int j=i-1; j>=0; j--) {
     
                if (nums[i]>nums[j])
                    dp[i] = max(dp[i], 1+dp[j]);
                    continue;
            }
            ans = ans>dp[i] ? ans:dp[i];
        }
        return ans;
    }
};

2.4 LeetCode 64 最小路径和8

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

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

示例:

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

通过上述的题目的分析,很容易想到这一题的状态dp[i][j],从左上角到(i,j)的最短距离。从而状态转移方程也就呼之欲出。本题与上面不同的就是边界条件,即第一行和第一列的元素不满足状态转移方程。

class Solution {
     
public:
    int minPathSum(vector<vector<int>>& grid) {
     

        vector<vector<int>> dp = grid;
        // 边界条件
        for (int i=1; i<grid[0].size(); i++) dp[0][i] += dp[0][i-1];
        for (int i=1; i<grid.size(); i++) dp[i][0] += dp[i-1][0];
        // 递推
        for (int i=1; i<grid.size(); i++) {
     
            for (int j=1; j<grid[0].size(); j++) {
     
                dp[i][j] = min(dp[i][j-1], dp[i-1][j]) + grid[i][j];
            }
        }
        return dp[grid.size()-1][grid[0].size()-1];
    }
};

2.5 中等题小节

  1. 中等难度的题目的主要共同点是状态不好确定,不像简单题目(1.3除外)那么明显。
  2. 简单题目中的状态就是题目的问题,例如,(1.1)爬楼梯,问题是n阶楼梯有多少种走法,状态i就定义为i阶楼梯有i中走法;(1.2)打家劫舍问题是n房间能打劫的最大金额,状态i就定义为前i个房间最大金额。
  3. 中等题目的状态需要思考+枚举例子归纳才能确定;通常思考的方向是,先预定义状态i,然后通过枚举例子或者思考状态i-1, i-2,…,等能否与状态i发生联系。如果这个联系是普遍的可重复的,那么状态就定义正确了。
  4. 例如2.1零钱兑换,定义状态dp[i]为获得金额i用的最少硬币数。由金额i=coins[j] + (i-coins[j]) 推断, dp[i] 和 dp[i-coins[j]] 有普遍联系,然后通过枚举例子归纳状态转移方程。
  5. 例如2.2三角形最短路径和,可以发现最短路径取决于对元素(i,j)的位置,因此状态定义为dp[i][j],又鉴于从底层往上定义状态也可以不用考虑边界条件,所以定义dp[i][j]为从底层到元素(i,j)的最短路径。然后通过枚举例子,归纳状态转移方程。
  6. 例如2.3最长上升子序列,定义状态dp[i]为以第i个数字结尾的最长上升子序列长度。容易想到的是,dp[i]是和nums[i]左面第一个小于nums[i]的数字对应的状态有关。通过枚举例子归纳出状态转移方程。

3. 困难题目

3.1 LeetCode 300. 最长上升子序列7

要求使用 O ( n l o g n ) O(nlogn) O(nlogn)的时间复杂度实现。

核心关键:序列如果上升的越慢,那么就越有可能得到最长序列。

  1. 依旧关注上升子序列的结尾元素。
  2. 如果已经得到的上升子序列的结尾的数越小,遍历的时候后面接上一个数,就会有更大的可能性构成一个更长的上升子序列;
  3. 既然结尾越小越好,我们可以记录在长度固定的情况下,结尾最小的那个元素的数值,这样定义也是为了方便得到「状态转移方程」。9

定义状态:st[i]表示长度为i+1的上升子序列的最后一个元素的最小可能取值
与其他状态的联系:如果要组成一个长度为i+2的上升子序列,需要一个大于st[i]的元素。最终st的长度就是最长上升子序列的长度。

例如:

[10,9,2,5,3,7,101,7,18]:
(nums[0]) st = [10]
(nums[1]) st = [9]
(nums[2]) st = [2]
(nums[3]) st = [2,5]
(nums[4]) st = [2,3]
(nums[5]) st = [2,3,7]
(nums[6]) st = [2,3,7,101]
(nums[7]) st = [2,3,7,101]
(nums[8]) st = [2,3,7,18]
class Solution {
     
public:
    int lengthOfLIS(vector<int>& nums) {
     
        if (nums.size()==0) return 0;

        vector<int> st;
        st.push_back(nums[0]);

        // 递推
        for (int i=1; i<nums.size(); i++) {
     
            if (nums[i] > st.back()) {
     
                st.push_back(nums[i]);
            }
            else {
     
                for (int j=0; j<st.size(); j++) {
     
                    // 如果小于号=> nums[7] st = [2,3,7,7],
                    // 从而nums[8] st = [2,3,7,7,18]
                    // 我们需要的是严格增的子序列
                    if (nums[i] <= st[j]) {
     
                        st[j] = nums[i];
                        break;
                    }
                }
            }
        }
        return st.size();
    }
};

st内是有序的序列,可以通过二分查找来更新st内的值。

class Solution {
     
public:
    int binary_search( vector<int> st, int target) {
     
        int index = -1;
        int left = 0;
        int right = st.size() - 1;
        while (index == -1) {
     
            int mid = (left + right) >> 1;
            if (st[mid] == target) index = mid;
            else if (target < st[mid] ) {
     
                if (mid == 0 || target > st[mid-1] ) {
     
                    index = mid;
                }
                right = mid - 1;
            } 
            else {
     
                if (mid == st.size() - 1 || target < st[mid+1]) {
     
                    index = mid + 1;
                }
                left = mid + 1;
            }
        }
        return index;
    }

    int lengthOfLIS(vector<int>& nums) {
     
        if (nums.size()==0) return 0;

        vector<int> st;
        st.push_back(nums[0]);

        // 递推
        for (int i=1; i<nums.size(); i++) {
     
            if (nums[i] > st.back()) {
     
                st.push_back(nums[i]);
            }
            else {
     
                int pos = binary_search(st, nums[i]);
                st[pos] = nums[i];
            }
        }
        return st.size();
    }
};

3.2 LeetCode 174地下城游戏10

一些恶魔抓住了公主(P)并将她关在了地下城的右下角。地下城是由 M x N 个房间组成的二维网格。我们英勇的骑士(K)最初被安置在左上角的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。

骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。

有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为负整数,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为正整数,则表示骑士将增加健康点数)。

为了尽快到达公主,骑士决定每次只向右或向下移动一步。

编写一个函数来计算确保骑士能够拯救到公主所需的最低初始健康点数。

例如,考虑到如下布局的地下城,如果骑士遵循最佳路径 右 -> 右 -> 下 -> 下,则骑士的初始健康点数至少为 7。

-2 (K) -3 3
-5 -10 1
10 30 -5 §

思考状态如何定义:
(1) 如果按照2.4中的方法定义状态dp[i][j]:从左上角到房间(i,j)的最大血量,显然不是题目要求的至少血量。通过示例也能验证这个看法。因此,2.4的状态定义方式在本题行不通。
(2) 跳出2.4的思维固式,重新思考本题。除了像2.4那样从左上角往右下角递推,本题还可以从右下角往左上角递推。同时状态设计要和题目的要求"到右下角至少需要多少血量"联系起来。
(3) 依据(2)的思考方向,定义dp[i][j]:从房间(i,j)到右下角的最少血量,且 d p [ i ] [ j ] ≥ 1 dp[i][j] \geq 1 dp[i][j]1,因为骑士血量为0或低于0时就挂了。
(4) 有了状态dp[i][j]的定义,通过思考+枚举例子很容易归纳出状态转移方程。dp[i][j]和(i,j)的下面和右面有关,dp[i][j] = min(dp[i+1][j], dp[i][j+1]) - dp[i][j]; 同时 d p [ i ] [ j ] ≥ 1 dp[i][j] \geq 1 dp[i][j]1
(5) 这种方式下,边界条件是最后一行和最后一列。

代码如下:

class Solution {
     
public:
    int calculateMinimumHP(vector<vector<int>>& dungeon) {
     
        if (dungeon.size() == 0) return 1;
        vector<vector<int>> dp = dungeon;
        // 边界条件
        int m = dungeon.size()-1;
        int n = dungeon[0].size()-1;
        dp[m][n] = dp[m][n]>0?1:1-dp[m][n];
        for (int i=n-1; i>=0; i--) {
     
            dp[m][i] = dp[m][i+1] - dp[m][i];
            if (dp[m][i] <= 0) dp[m][i] = 1;
        }
        for (int i=m-1; i>=0; i--) {
     
            dp[i][n] = dp[i+1][n] - dp[i][n];
            if (dp[i][n] <= 0) dp[i][n] = 1;
        }

        // 递推
        for (int i=m-1; i>=0; i--) {
     
            for (int j=n-1; j>=0; j--) {
     
                dp[i][j] = min(dp[i+1][j], dp[i][j+1]) - dp[i][j];
                if (dp[i][j] <= 0) dp[i][j] = 1;
            }
        }
        return dp[0][0];
    }
};

3.3 困难题总结

  1. 有些题目的动态规划解法并不是那么规整,例如最长上升子序列的 O ( n log ⁡ n ) O(n\log n) O(nlogn)。我觉得这个解法更有点像贪心算法的思想。我觉的这种题目包括其二分法优化时间复杂度需要靠个人的积累来完成,这个是很难在头一次接触的时候就想到这种解法的,对我个人来说,只能靠积累。
  2. 规整的动态规划的困难级别的题目的难点依旧是状态的定义,同样地,只要状态定义合适,题目就基本解决一大半了。
  3. 对于状态定义来说,还是要紧扣题目要求来思考。例如,3.2 地下城游戏。题目要求的是“计算确保骑士能够拯救到公主所需的最低初始健康点数”,而使用2.4最小路径和的状态定义方式,求的是路径和最小值,这个显然不是题目要求的。紧扣“至少血量和血量不能低于0”要求,于是定义dp[i][j]为房间(i,j)到终点的最少血量。这样就贴合了题目至少多少血量的要求。
  4. 状态定义符合题目所求内容,这个很重要。因为这个正确与否直接关系到后面递推关系能否找到。(有点废话)

  1. https://leetcode-cn.com/problems/climbing-stairs/ ↩︎

  2. https://www.bilibili.com/video/BV1GW411Q77S?p=9 ↩︎ ↩︎ ↩︎

  3. https://leetcode-cn.com/problems/house-robber ↩︎

  4. https://leetcode-cn.com/problems/maximum-subarray/ ↩︎

  5. https://leetcode-cn.com/problems/coin-change/ ↩︎

  6. https://leetcode-cn.com/problems/triangle/ ↩︎

  7. https://leetcode-cn.com/problems/longest-increasing-subsequence/ ↩︎ ↩︎

  8. https://leetcode-cn.com/problems/minimum-path-sum/ ↩︎

  9. https://leetcode-cn.com/problems/longest-increasing-subsequence/solution/dong-tai-gui-hua-er-fen-cha-zhao-tan-xin-suan-fa-p/ ↩︎

  10. https://leetcode-cn.com/problems/dungeon-game/ ↩︎

你可能感兴趣的:(编程基础,动态规划,面试,算法)