数据结构&算法-----(6)动态规划

数据结构&算法-----(6)动态规划

判断动态规划

Wikipedia 定义:它既是一种数学优化的方法,同时也是编程的方法。

1. 是数学优化的方法——最优子结构

动态规划是数学优化的方法指,动态规划要解决的都是问题的最优解。而一个问题的最优解是由它的各个子问题的最优解决定的。

由此引出动态规划的第一个重要的属性:最优子结构(Optimal Substructure)。

一般由最优子结构,推导出一个状态转移方程 f(n),就能很快写出问题的递归实现方法。

在这里插入图片描述

2. 是编程的方法——重叠子问题

动态规划是编程的方法指,可以借助编程的技巧去保证每个重叠的子问题只会被求解一次

引出了动态规划的第二个重要的属性:重叠子问题(Overlapping Sub-problems)。

在这里插入图片描述

举例 1:斐波那契数列问题。

解法:为了求出第 5 个斐波那契数,得先求出第 4 个和第 3 个数,但是在求第 4 个数的时候,又得重复计算一次第 3 个数,同样,对于第 2 个数的计算也出现了重复。

因此,判断一个问题能不能称得上是动态规划的问题,需要看它是否同时满足这两个重要的属性:

  • 最优子结构(OptimalSubstructure)
  • 重叠子问题(Overlapping Sub-problems)

举例 2:给定如下的一个有向图,求出从顶点 A 到 C 的最长的路径。要求路径中的点只能出现一次。

在这里插入图片描述

按照题目的要求,可以看到,从A通往C有两条最长的路径:A->B->CA->D->C

① 对于 A -> B -> C,A 到 B 的最长距离是:A -> D -> C -> B

② B 到 C 的最长距离是:B -> A -> D -> C

① + ② 组合路径:A -> D -> C -> B -> A -> D -> C

上述答案并不满足题目的要求。该题并没有一个最优子结构,不是动态规划问题。

举例 3:归并排序和快速排序是否属于动态规划?

  1. 将要排序的数组分成两半,然后递归地进行处理,满足最优子结构的属性;
  2. 不断地对待排序的数组进行对半分的时候,两半边的数据并不重叠不会遇到重复的子数组,不满足重叠子问题的属性。

因此这两种算法不是动态规划的方法。

LeetCode 第 300 题:给定一个无序的整数数组,找到其中最长子序列长度。

说明:

  • 可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
  • 你算法的时间复杂度应该为 O(n2)

注意:子序列和子数组不同,它并不要求元素是连续的

子串和子序列的区别:
子串:字符串中任意个连续的字符组成的子序列。
子序列:字符串中按照前后顺序取出的任意个字符组成,不要求连续。

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

解题思路
在给定的数组里,有很多的上升子序列,例如:[10, 101],[9, 101],[2, 5, 7, 101],以及 [2, 3, 7, 101],只需要找出其中一个最长的。

思路1:
暴力法找出所有的子序列,然后从中返回一个最长的。

从一个数组中罗列出所有的非空子数组有: n×(n + 1)/2 种,即 O(n2),那么罗列出所有的非空子序列有 2^(n−1) 种。复杂度将是 O(2^n)

在这里插入图片描述

思路 2:缩小问题规模

  1. 找最优子结构:输入规模对半分

[10,9,2,5]最长的子序列应该是[2,5],而[3,7,101,4]最长的子序列是[3,7,101],由于3比5小,无法简单地组合在一起。即该方法下,总问题的解无法直观地通过子问题的最优解求得。

在这里插入图片描述

  1. 找最优子结构:每次减一个

假设f(n)表示的是数组nums[0,…,n−1]中最长的子序列,那么f(n−1)就是数组nums[0,…,n−2]中最长的子序列,依此类推,f(1) 就是 nums[0] 的最长子序列。

假设已经解决了 f(1),f(2),… f(n−1) 的问题,考虑最后一个数 nums[n−1],也必然考虑到倒数第二个数 nums[n−2],所以 f(n) 指:如果包含了最后的数,那么最长的子序列应该是什么

注意:最后这个数必须包含在子序列当中的

如何通过 f(1),f(2),…f(n−1) 推导出 f(n) 呢?由于最后一个数是 18,我们只需要在前面的 f(1),f(2),…f(n−1) 当中,找出一个以小于 18 的数作为结尾的最长的子序列,然后把18 添加到最后,那么 f(n) 就一定是以 18 作为结尾的最长的子序列了。

最长的子序列并不一定会包含 18,遍历 f(1),f(2),…f(n−1) ,找出最长的。例如,以 101 结尾的最长的上升子序列是什么。

  1. 找重叠子问题

输入: [10,9,2,5,3,7,101,18]

在分析最后一个数 18 的时候,以 3 结尾的最长的上升子序列长度就是 f(5),因为 3 是第 5 个数。
把问题规模缩小 2 个,当前的数变成 101 的时候,找比它小的数,又发现了 3,这个时候又会去重复计算一遍 f(5)
说明该题有重叠的子问题

因此,可以运用动态规划的方法来解决这个问题。

总结解决动态规划问题的两个难点

(1)如何定义 f(n)
对于这道题而言,f(n) 是以 nums[n−1] 结尾的最长的上升子序列的长度

(2)如何通过f(1),f(2),…f(n−1)推导出f(n),即状态转移方程
本题中,nums[n−1] 和比它小的每一个值 nums[i] 进行比较,其中 1<=i<n,加 1 即可。
因此状态转移方程就是:
f(n)=max (1 <= i < n−1, nums[i−1] < nums[n−1]) { f(i) } + 1
以上证明了这个问题有一个最优的子结构。

① 递归(Recursion)

用递归的方法求解状态转移方程式 f(n)=max (1 <= i < n−1, nums[i−1] < nums[n−1]) { f(i) } + 1。

  • 对于每个 n,要从 0 开始遍历
  • 在 n 之前,找出比 nums[n−1] 小的数
  • 递归地调用 f 函数,找出最大的,最后加上 1
  • 当 i 等于 0 的时候,应该返回 0;当 i 等于 1 的时候应该返回 1。

递归的代码实现:

class LISRecursion {
    // 定义一个静态变量 max,用来保存最终的最长的上升子序列的长度
    static int max;

    public int f(int[] nums, int n) {
        if (n <= 1) {
            return n;
        }

		//maxEndingHere:包含当前最后一个元素的情况下,最长的上升子序列长度。
        int result=0, maxEndingHere=1;

        // 从头遍历数组,递归求出以每个点为结尾的子数组中最长上升序列的长度
        for (int i=1; i < n; i++) {
            result=f(nums, i);

            if (nums[i−1] < nums[n−1] && result + 1 > maxEndingHere) {
                maxEndingHere=result + 1;
            }
        }

        // 判断一下,如果那个数比目前最后一个数要小,那么就能构成一个新的上升子序列 
        if (max < maxEndingHere) {
            max=maxEndingHere;
        }

        // 返回以当前数结尾的上升子序列的最长长度
        return maxEndingHere;
    }

    public int LIS(int[] nums) {
        max=f(nums, nums.length);
        return max; 
    }
}

其中,实现状态转移方程,即 f 函数。

  • 最基本的情况,当数组的长度为 0 时,没有上升子序列,当数组长度为 1 时,最长的上升子序列长度是 1。
  • maxEndingHere 变量的含义就是包含当前最后一个元素的情况下,最长的上升子序列长度。

递归的时间复杂度

用公式法解决该递归问题的时间复杂度,如下。
当 n=1 的时候,递归直接返回 1,执行时间为 O(1),即 T(1)=O(1)
当 n=2 的时候,内部调用了一次递归求解 T(1),所以 T(2)=T(1)
当 n=3 的时候,T(3)=T(1) + T(2)

以此类推,
T(n−1)=T(1) + T(2) + … + T(n−2)
T(n)=T(1) + T(2) + … + T(n−1)
通过观察,我们得到:T(n)=2×T(n−1),这并不满足T(n)=a×T(n/b)+f(n)的关系式。但是T(n)等于两倍的T(n−1),表明,我们的计算是成指数增长的,每次的计算都是先前的两倍。所以 O(n)=O(2n)

② 记忆化(Memoization)(带备忘录)

由于递归的解法需要耗费非常多的重复计算,而且很多计算都是重叠的,避免重叠计算的一种办法就是记忆化。

记忆化,就是将已经计算出来的结果保存起来,那么下次遇到相同的输入时,直接返回保存好的结果,能够有效节省了大量的计算时间。

在之前递归实现的基础上实现记忆化,代码如下:

class LISMemoization {
    static int max;
    // 定义哈希表 cache,用来保存计算结果    
    static HashMap<Integer, Integer> cache;

    public int f(int[] nums, int n) {
    
    	// 调用递归函数的时候,判断 cache 里是否已经保留了这个值。是,则返回;不是,继续递归调用
        if (cache.containsKey(n)) {
            return cache.get(n);
        }
        
        if (n <= 1) {
            return n;
        }

        int result=0, maxEndingHere=1; 
        
        for (int i=1; i < n; i++) {
            result=f(nums, i);

            if (nums[i−1] < nums[n−1] && result + 1 > maxEndingHere) {
                maxEndingHere=result + 1;
            }
        }

        if (max < maxEndingHere) {
            max=maxEndingHere;
        }

        // 在返回当前结果前,保存到 cache
        cache.put(n, maxEndingHere);
        return maxEndingHere;
    }

    public int LIS(int[] nums) {
        max=f(nums, nums.length);
        return max; 
    }
}

递归+记忆化的时间复杂度

  1. 函数 f 按序传递n,n−1,n−2 … 最后是 1,把结果缓存并返回;
  2. 递归返回到输入 n;
  3. 缓存里已经保存了 n−1 个结果;
  4. for 循环调用递归函数 n−1 次,从 cache 里直接返回结果。

上述过程的时间复杂度是 O(1)。即将问题的规模大小从 n 逐渐减小到 1 的时候,通过将各个结果保存起来,可以将 T(1),T(2),….T(n−1) 的复杂度降低到线性的复杂度。

现在,回到T(n),在for循环里,尝试着从T(1),T(2)….T(n−1)里取出最大值,因此 O(T(n))=O(T(1) + T(2) + … + T(n−1))=O(1 + 2 + …. + n−1)=O(n×(n−1)/2)=O(n2)。

最后加上构建缓存 cache 的时间,整体的时间复杂度就是 O(f(n))=O(n) + O(n^2)=O(n2)。

通过记忆化的操作,我们把时间复杂度从 O(2n) 降低到了 O(n2)。

这种将问题规模不断减少的做法,被称为自顶向下的方法。但是,由于有了递归的存在,程序运行时对堆栈的消耗以及处理是很慢的,在实际工作中并不推荐。更好的办法是自底向上。

③ 自底向上(Bottom-Up)(动态规划)

底向上指,通过状态转移方程,从最小的问题规模入手,不断地增加问题规模,直到所要求的问题规模为止。依然使用记忆化避免重复的计算,不需要递归。

代码实现:

class LISDP {
    public int LIS(int[] nums, int n) {
        int[] dp=new int[n]; // 一维数组 dp 存储计算结果
        int i, j, max=0;

        // 初始化 dp 数组里的每个元素的值为 1,即以每个元素作为结尾的最长子序列的长度初始化为 1
        // Arrays.fill(dp, 1); 也行
        for (i=0; i < n; i++) 
        	dp[i]=1;

        // 自底向上地求解每个子问题的最优解
        for (i=0; i < n; i++) {
	        //遍历中遇到的每个元素nums[j]与nums[i]比较,若nums[j]<nums[i],说明nums[i]有机会构成上升序列
	        //若新的上升序列比之前计算过的还要长,更新一下,保存到cache数组
            for (j=0; j < i; j++) {
                if (nums[j] < nums[i] && dp[i] < dp[j] + 1) {
                    dp[i]=dp[j] + 1;
                }
            }
            // 用当前计算好的长度与全局的最大值进行比较  
            max=Math.max(max, dp[i]);
        }

        // 最后得出最长的上升序列的长度
        return max;  
    }
}

动态规划的时间复杂度

由上可知,这一个双重循环。当i=0的时候,内循环执行0次;当i=1的时候,内循环执行1次……以此类推,当i=n−1的时候,内循环执行了n−1次,因此,总体的时间复杂度是 O(1 + 2 + … + n−1)=O(n×(n−1) / 2)=O(n2)

动态规划面试题分类

运用动态规划去解决问题,最难的地方有两个:

  1. 应当采用什么样的数据结构来保存什么样的计算结果
  2. 如何利用保存下来的计算结果推导出状态转移方程

第一个难点,不仅是为了避免重复的计算,也是推导状态转移方程的关键。这一难点往往是在把问题规模缩小的过程中进行的。

解决技巧:假设已经把所有子问题的最佳结果都计算出来了,那么只需要考虑,如何根据这些子问题的结果来得出最终的答案。

根据动态规划问题的难易程度,把常见的动态规划面试题分成如下三大类。

① 线性规划

线性,就是说各个子问题的规模以线性的方式分布,并且子问题的最佳状态或结果可以存储在一维线性的数据结构里,例如一维数组,哈希表等。

解法中,经常会用dp[i]去表示第i个位置的结果,或者从0开始到第i个位置为止的最佳状态或结果。
例如,最长上升子序列。dp[i] 表示从数组第 0 个元素开始到第i个元素为止的最长的上升子序列。

求解 dp[i] 的复杂程度取决于题目的要求,但是基本上有两种形式。

求解 dp[i] 形式一

第一种形式,当前所求的值仅仅依赖于有限个先前计算好的值,也就是说,dp[i] 仅仅依赖于有限个 dp[j],其中 j < i。

举例 1:斐波那契数列,LeetCode70爬楼梯

解法:dp[i]=dp[i−1] + dp[i−2],可以看到,当前值只依赖于前面两个计算好的值。

LeetCode 第 70 题(爬楼梯)就是一道求解斐波那契数列的题目。

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

思路:
第 i 阶可以由以下两种方法得到:

  • 在第( i - 1 ) 阶后向上爬 1 阶
  • 在第( i - 2 ) 阶后向上爬 2 阶
    public int climbStairs(int n) {
        if (n == 1 || n == 2) {
            return n;
        }
        
        int[] dp = new int[n + 1];

        dp[1] = 1;
        dp[2] = 2;

        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }

        return dp[n];
    }
举例 2:LeetCode第 198 题,给定一个数组,不能选择相邻的数,求如何选才能使总数最大

在这里插入图片描述

解法:这道题需要运用经典的 0-1 思想,简单说就是:“选还是不选”。

假设 dp[i] 表示到第 i 个元素为止我们所能收获到的最大总数。

  • 如果选择了第 i 个数,则不能选它的前一个数,因此,收获的最大总数就是 dp[i−2] + nums[i]
  • 不选,则直接考虑它的前一个数 dp[i−1]。因此,可以推导出它的递归公式
    dp[i]=max(nums[i] + dp[i−2], dp[i−1])
    可以看到,dp[i] 仅仅依赖于有限个 dp[j],其中 j=i−1,i−2

代码实现:

public int rob(int[] nums) {
    int n = nums.length;

    // 处理当数组为空或者数组只有一个元素的情况
    if(n == 0) return 0;
    if(n == 1) return nums[0];

    // 定义一个 dp 数组,dp[i] 表示到第 i 个元素为止我们所能收获到的最大总数
    int[] dp = new int[n];

    // 初始化 dp[0],dp[1]
    dp[0] = nums[0];
    dp[1] = Math.max(nums[0], nums[1]);

    // 对于每个 nums[i],考虑两种情况,选还是不选,然后取最大值
    for (int i = 2; i < n; i++) {
        dp[i] = Math.max(nums[i] + dp[i - 2], dp[i - 1]);
    }
    
    return dp[n - 1];
}
举例 3:机器人路径

一个机器人位于一个网格的左上角(起始点在下图中标记为“Start”)。机器人每次只能向下或向右移动一步。机器人试图到达网格的右下角(在下图中标记为“Finish”)。问总共有多少条不同的路径?

说明: 和的值均不超过100。

在这里插入图片描述

解法 1:从起点考虑,暴力法。

解法 2:减小问题规模。

分别计算走到它上面的格子以及左边的格子的步数,相加。递推公式为 dp[i][j]=dp[i−1][j] + dp[i][j−1]

虽然利用一个二维数组去保存计算的结果,但是dp[i][j]所表达的意思仍然是线性的,dp[i][j]表示从起点到(i,j)的总走法。本题不再讨论具体实现。可以看到,dp[i][j] 仅仅依赖于两个先前的状态。

举例 4:LeetCode第 22 题,括号生成(忘了是哪家公司的笔试题)

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。

示例:
输入:n = 3
输出:[
       "((()))",
       "(()())",
       "(())()",
       "()(())",
       "()()()"
     ]

第 1 步:定义状态 dp[i]:使用 i 对括号能够生成的组合。

注意:每一个状态都是列表的形式。

第 2 步:状态转移方程:

  • i 对括号的一个组合,在 i - 1 对括号的基础上得到,这是思考 “状态转移方程” 的基础;
  • i 对括号的一个组合,一定以左括号 "(" 开始,不一定以 ")" 结尾。为此,我们可以枚举新的右括号 ")" 可能所处的位置,得到所有的组合;
  • 枚举的方式就是枚举左括号 "(" 和右括号 ")" 中间可能的合法的括号对数,而剩下的合法的括号对数在与第一个左括号 "(" 配对的右括号 ")" 的后面,这就用到了以前的状态。

状态转移方程是:

dp[i] = "(" + dp[可能的括号对数] + ")" + dp[剩下的括号对数]
  • “可能的括号对数” 与 “剩下的括号对数” 之和得为 i - 1,故 “可能的括号对数” j 可以从 0 开始,最多不能超过 i, 即 i - 1
  • “剩下的括号对数” + j = i - 1,故 “剩下的括号对数” = i - j - 1

整理得:

dp[i] = "(" + dp[j] + ")" + dp[i- j - 1] , j = 0, 1, ..., i - 1

第 3 步: 思考初始状态和输出:

  • 初始状态:因为我们需要 0 对括号这种状态,因此状态数组 dp0 开始,0 个括号当然就是 [""]

  • 输出:dp[n]

这个方法暂且就叫它动态规划,这么用也是很神奇的,它有下面两个特点:

1、自底向上:从小规模问题开始,逐渐得到大规模问题的解集;

2、无后效性:后面的结果的得到,不会影响到前面的结果。

import java.util.ArrayList;
import java.util.List;

public class Solution {

    // 把结果集保存在动态规划的数组里

    public List<String> generateParenthesis(int n) {
        if (n == 0) {
            return new ArrayList<>();
        }
        // 这里 dp 数组我们把它变成列表的样子,方便调用而已
        List<List<String>> dp = new ArrayList<>(n);

        List<String> dp0 = new ArrayList<>();
        dp0.add("");
        dp.add(dp0);

        for (int i = 1; i <= n; i++) {
            List<String> cur = new ArrayList<>();
            for (int j = 0; j < i; j++) {
                List<String> str1 = dp.get(j);
                List<String> str2 = dp.get(i - 1 - j);
                for (String s1 : str1) {
                    for (String s2 : str2) {
                        // 枚举右括号的位置
                        cur.add("(" + s1 + ")" + s2);
                    }
                }
            }
            dp.add(cur);
        }
        return dp.get(n);
    }
}
举例 5:最大子序列和(连续子数组最大和)

最大子序列和是指,给定一组序列,如 [1,-3,2,4,5],求子序列之和的最大值,对于该序列来说,最大子序列之和为 2 + 4 + 5 = 11。
这里的子序列要求是连续的,因此也可以称其为连续子数组最大和。

注意:对于数组全为负的情况,返回0

public static int get(int[] array) {
    int max = 0,temp = 0;
    for (int i = 0; i < array.length; i++) {
        temp += array[i];
        if (temp < 0)
            temp = 0;
        if (max < temp)
            max = temp;
    }
    return max;
}

以数组 [1,-3,2,4,5] 为例,用图形加以说明:
在这里插入图片描述

进阶:循环列表中的最大子序列和问题
循环列表是指序列的首尾相连,例如对于 [1,-3,2,4,5]来说,可以用环形表示:
在这里插入图片描述

如何求它的最大子序列和呢?
这个问题的解可以分为两种情况:

  • 最大子序列没有跨越 array[n-1] 到 array[0] (原问题)
  • 最大子序列跨越 array[n-1] 到 array[0]

当然在知道答案之前,我们并不知道解到底属于哪一种情况,因此可以将两种情况下的解都求出来,然后取其中的最大值就可以了。

对于第一种情况,按照之前的算法已经可以顺利求解出来;

对于第二种情况,我们不妨换个思路,如果最大子序列跨越了 array[n-1] 到 array[0],那么最小子序列肯定不会出现跨越 array[n-1] 到 array[0] 的情况,如下图所示:
在这里插入图片描述

所以,在允许数组跨界(首尾相邻)时,最大子数组的和为下面的最大值

max = { 原问题的最大子数组和,数组所有元素总值 - 最小子数组和 }

求解最小子数组和的方法与最大子数组和的方法正好相反。

代码如下:

public class MaxSequence {
    
    public static int getMax(int[] array) {
        int max = 0, temp = 0;
        for (int i = 0; i < array.length; i++) {
            temp += array[i];
            if (temp < 0)
                temp = 0;
            if ( max < temp)
                max = temp;
        }
        return max;
    }

    public static int getMin(int[] array) {
        int min = 0, temp = 0;
        for (int i = 0; i < array.length; i++) {
            temp += array[i];
            if (temp > 0)
                temp = 0;
            if (temp < min)
                min = temp;
        }
        return min;
    }

    public static int getLoopMax(int[] array) {
        int max1 = getMax(array);
        int min = getMin(array);
        int temp = 0;
        for (int i = 0; i < array.length; i++) {
            temp += array[i];
        }
        return Math.max(max1, temp - min);
    }

    public static void main(String[] args) {
        int[] array = {1,-3,2,4,5};
        System.out.println("原数组:" + Arrays.toString(array));
        System.out.println("最大子数组和(非循环):" + getMax(array));
        System.out.println("最小子数组和(非循环):" + getMin(array));
        System.out.println("最大子数组和(循环):" + getLoopMax(array));
    }
}

输出结果:

原数组:[1, -3, 2, 4, 5]
最大子数组和(非循环)11
最小子数组和(非循环)-3
最大子数组和(循环)12

求解 dp[i] 形式二

第二种求解 dp[i] 的形式,当前所求的值依赖于所有先前计算好的值,也就是说,dp[i] 是各个 dp[j] 的某种组合,其中 j 由 0 遍历到 i−1。

举例:求解最长上升子序列。

解法:dp[i]=max(dp[j]) + 1,0 <= j < i。可以看到,当前值依赖于前面所有计算好的值。

② 区间规划(注意遍历方向 )

区间规划,就是说各个子问题的规模由不同的区间来定义,一般子问题的最佳状态或结果存储在二维数组里。一般用 dp[i][j] 代表从第 i 个位置到第 j 个位置之间的最佳状态或结果。

解这类问题的时间复杂度一般为多项式时间,对于一个大小为 n 的问题,时间复杂度不会超过 n 的多项式倍数。例如,O(n)=n^k,k 是一个常数,根据题目的不同而定。

举例 1 :LeetCode 第 516 题,在一个字符串 S 中求最长的回文子序列。例如给定字符串为 dccac,最长回文就是 ccc。

解法 1:

对于回文来说,必须保证两头的字符都相同。用dp[i][j]表示从字符串第i个字符到第j个字符之间的最长回文,比较这段区间外的两个字符,如果发现它们相等,它们就肯定能构成新的最长回文。

注意这里必有i<=j,所以维护的二维数组只有右上三角部分

而最长的回文长度会保存在 dp[0][n−1] 里。因此,可以推导出如下的递推公式:

  • 当首尾的两个字符相等的时候 dp[0][n−1]=dp[1][n−2] + 2
  • 否则,dp[0][n−1]=max(dp[1][n−1], dp[0][n−2])

代码实现:

    public int longestPalindromeSubseq(String s) {
        int n = s.length();
        // 定义 dp 矩阵,dp[i][j] 表示从字符串第 i 个字符到第 j 个字符之间的最长回文
        int[][] dp = new int[n][n];

        // 初始化 dp 矩阵,将对角线元素设为 1,即单个字符的回文长度为 1
        for (int i = 0; i < n; i++) 
        	dp[i][i] = 1;

        // 从长度为 2 开始,尝试将区间扩大,一直扩大到 n
        for (int len = 2; len <= n; len++) {
            // 在扩大的过程中,每次都得出区间的其实位置i和结束位置j
            for (int i = 0; i < n - len + 1; i++) {
                int j = i + len - 1;

                // 比较一下区间首尾的字符是否相等,如果相等,就加2;
                // 如果不等,从规模更小的字符串中得出最长的回文长度
                if (s.charAt(i) == s.charAt(j)) {
                    dp[i][j] = 2 + (len == 2 ? 0: dp[i + 1][j - 1]);
                } else {
                    dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
                }
            }
        }

        return dp[0][n - 1];
    }

这里在放一下LeetCode中@labuladong的题解,传送门

  • 首先明确一下 base case,如果只有一个字符,显然最长回文子序列长度是 1,也就是 dp[i][j] = 1 (i == j)
  • 因为 i 肯定小于等于 j,所以对于那些 i > j 的位置,根本不存在什么子序列,应该初始化为 0。
  • 另外,看看刚才写的状态转移方程,想求 dp[i][j] 需要知道 dp[i+1][j-1],dp[i+1][j],dp[i][j-1]
    这三个位置;再看看我们确定的 base case,填入 dp 数组之后是这样:

在这里插入图片描述

为了保证每次计算 dp[i][j],左下右方向的位置已经被计算出来,只能斜着遍历或者反着遍历:

在这里插入图片描述

注意: 这里遍历的方向是粉红色的箭头方向!!

根据@labuladong改编的Java代码:

    public int longestPalindromeSubseq2(String s) {
        
        int n = s.length();
        
        // dp 数组全部初始化为 0
        int[][] dp = new int[n][n];
        for (int i=0; i<n; i++) {
            for (int j=0; j<n; j++) {
                dp[i][j]=0;
            }
        }
        
        // base case
        for (int i = 0; i < n; i++)
            dp[i][i] = 1;
        
        // 反着遍历保证正确的状态转移
        for (int i = n - 1; i >= 0; i--) {
            for (int j = i + 1; j < n; j++) {
                // 状态转移方程
                if (s.charAt(i) == s.charAt(j))
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                else
                    dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
            }
        }
        
        // 整个 s 的最长回文子串长度
        return dp[0][n - 1];
    }

解法 2:

如果用线性规划的方法来解,假设已经把S[0],S[0,1],S[0…n−2]中所有最长的回文子序列都找出来了,把最后一个字符加入到S中,能不能成为一个新的最长的回文呢?方法是有的,建议同学们自己尝试一下。

关于区间规划,还有很多题目都有用到,例如给定一系列矩阵,求矩阵相乘的总次数最少的相乘方法。

举例 2 :最长公共子串(快手面试题)

参考文章:
最长公共子串(动态规划)

描述:
有两个字符串(可能包含空格),请找出其中最长的公共连续子串,输出其长度。(长度在1000以内)
例如:
输入:abcde,bcd
输出:3

解析
1、把两个字符串分别以行和列组成一个二维矩阵。
2、比较二维矩阵中每个点对应行列字符中否相等,相等的话值设置为1,否则设置为0。
3、通过查找出值为1的最长对角线就能找到最长公共子串。

比如:str=acbcbcef,str2=abcbced,则str和str2的最长公共子串为bcbce,最长公共子串长度为5。
针对于上面的两个字符串我们可以得到的二维矩阵如下:

在这里插入图片描述

从上图可以看到,str1和str2共有5个公共子串,但最长的公共子串长度为5。
为了进一步优化算法的效率,我们可以再计算某个二维矩阵的值的时候顺便计算出来当前最长的公共子串的长度,即某个二维矩阵元素的值由record[i][j]=1演变为record[i][j]=1 +record[i-1][j-1],这样就避免了后续查找对角线长度的操作了。修改后的二维矩阵如下:

在这里插入图片描述

递推公式为:
A[i] != B[j],dp[i][j] = 0
A[i] == B[j]
i = 0 || j == 0,dp[i][j] = 1
否则 dp[i][j] = dp[i - 1][j - 1] + 1

暴力法:

	public int getLCS(String s, String s2) {
        if (s == null || t == null) {
            return 0;
        }
        int l1 = s.length();
        int l2 = t.length();
        int res = 0;
        for (int i = 0; i < l1; i++) {
            for (int j = 0; j < l2; j++) {
                int m = i;
                int k = j;
                int len = 0;
                while (m < l1 && k < l2 && s.charAt(m) == t.charAt(k)) {
                    len++;
                    m++;
                    k++;
                }
                res = Math.max(res, len);
            }
        }
        return res;
    }

动态规划:

	public int getLCS(String s, String t) {
        if (s == null || t == null) {
            return 0;
        }
        int result = 0;
        int sLength = s.length();
        int tLength = t.length();
        int[][] dp = new int[sLength][tLength];
        for (int i = 0; i < sLength; i++) {
            for (int k = 0; k < tLength; k++) {
                if (s.charAt(i) == t.charAt(k)) {
                    if (i == 0 || k == 0) {
                        dp[i][k] = 1;
                    } else {
                        dp[i][k] = dp[i - 1][k - 1] + 1;
                    }
                    result = Math.max(dp[i][k], result);
                } else {
                    dp[i][k] = 0;
                }
            }
        }
        return result;
    }

简化一下递推公式:
A[i] != B[j],dp[i][j] = 0
否则 dp[i][j] = dp[i - 1][j - 1] + 1
全部都归结为一个公式即可,二维数组默认值为0

原公式:
递推公式为:
A[i] != B[j],dp[i][j] = 0
A[i] == B[j],
i = 0 || j == 0,dp[i][j] = 1
否则 dp[i][j] = dp[i - 1][j - 1] + 1

在这里插入图片描述

public int getLCS(String s, String t) {
        if (s == null || t == null) {
            return 0;
        }
        int result = 0;
        int sLength = s.length();
        int tLength = t.length();
        int[][] dp = new int[sLength + 1][tLength + 1];  //牺牲了一些空间
        for (int i = 1; i <= sLength; i++) {
            for (int k = 1; k <= tLength; k++) {
                if (s.charAt(i - 1) == t.charAt(k - 1)) {
                    dp[i][k] = dp[i - 1][k - 1] + 1;
                    result = Math.max(dp[i][k], result);
                }
            }
        }
//        for (int i = 1; i <= sLength + 1; i++) {
//            for (int k = 1; k <= tLength + 1; k++) {
//                System.out.print(dp[i - 1][k - 1] + " ");
//            }
//            System.out.println();
//        }
        return result;
    }

行、列都多一行,更适应公式。

举例 3 :LeetCode 第72题,编辑距离

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。

你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse ('h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
输入:word1 = "intention", word2 = "execution"
输出:5
解释:
intention -> inention (删除 't')
inention -> enention ('i' 替换为 'e')
enention -> exention ('n' 替换为 'x')
exention -> exection ('n' 替换为 'c')
exection -> execution (插入 'u')

参考 LeetCode 精选题解:
自底向上 和自顶向下
【编辑距离】入门动态规划,你定义的 dp 里到底存了啥

定义 dp[i][j]

  • dp[i][j] 代表 word1 中前 i 个字符,变换到 word2 中前 j 个字符,最短需要操作的次数
  • 需要考虑 word1 或 word2 一个字母都没有,即全增加/删除的情况,所以预留 dp[0][j]dp[i][0]

状态转移方程:

  • word1[i] == word2[j]dp[i][j] = dp[i-1][j-1]
  • word1[i] != word2[j]dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])
  • 其中,dp[i-1][j-1] 表示替换操作,dp[i-1][j] 表示删除操作,dp[i][j-1] 表示插入操作。

注意,针对第一行,第一列要单独考虑,我们引入 '' 下图所示:
在这里插入图片描述

以 word1 为 “horse”,word2 为 “ros”,且 dp[5][3] 为例,即要将 word1的前 5 个字符转换为 word2的前 3 个字符,也就是将 horse 转换为 ros,因此有:
(1) dp[i-1][j-1],替换,即先将 word1 的前 4 个字符 hors 转换为 word2 的前 2 个字符 ro,然后将第五个字符 word1[4](因为下标基数以 0 开始) 由 e 替换为 s(即替换为 word2 的第三个字符,word2[2])
(2) dp[i][j-1],插入,即先将 word1 的前 5 个字符 horse 转换为 word2 的前 2 个字符 ro,然后在末尾补充一个 s,即插入操作
(3) dp[i-1][j],删除,即先将 word1 的前 4 个字符 hors 转换为 word2 的前 3 个字符 ros,然后删除 word1 的第 5 个字符

    public int minDistance(String word1, String word2) {

        int n1 = word1.length();
        int n2 = word2.length();
        int[][] dp = new int[n1+1][n2+1];

        for (int j=1; j<=n2; j++)
            dp[0][j] = dp[0][j-1] + 1;
        
        for (int i=1; i<=n1; i++)
            dp[i][0] = dp[i-1][0] + 1;

        for (int i=1; i<=n1; i++) {
            for (int j=1; j<=n2; j++) {
                if (word1.charAt(i-1) == word2.charAt(j-1))
                    dp[i][j] = dp[i-1][j-1];
                else
                    dp[i][j] = 1 + Math.min(Math.min(dp[i-1][j-1], dp[i][j-1]), dp[i-1][j]);
            }
        }
        
        return dp[n1][n2];
    }

举例 4 :LeetCode312题,戳气球问题

在这里插入图片描述

我@labuladong的题解写的真好,赞!
参考:
经典动态规划:戳气球问题

改变问题:在一排气球points中,请你戳破气球0和气球n+1之间的所有气球(不包括0和n+1),使得最终只剩下气球0和气球n+1两个气球,最多能够得到多少分?

dp[i][j] = x表示,戳破气球i和气球j之间(开区间,不包括ij)的所有气球,可以获得的最高分数为x

状态转移方程:dp[i][j] = dp[i][k] + dp[k][j] + points[i]*points[k]*points[j]

在这里插入图片描述
二维的dp一般都要注意遍历方向,如图:
在这里插入图片描述
代码如下:

int maxCoins(int[] nums) {
    int n = nums.length;
    // 添加两侧的虚拟气球
    int[] points = new int[n + 2];
    points[0] = points[n + 1] = 1;
    for (int i = 1; i <= n; i++) {
        points[i] = nums[i - 1];
    }
    // base case 已经都被初始化为 0
    int[][] dp = new int[n + 2][n + 2];
    // 开始状态转移
    // i 应该从下往上
    for (int i = n; i >= 0; i--) {
        // j 应该从左往右
        for (int j = i + 1; j < n + 2; j++) {
            // 最后戳破的气球是哪个?
            for (int k = i + 1; k < j; k++) {
                // 择优做选择
                dp[i][j] = Math.max(
                    dp[i][j], 
                    dp[i][k] + dp[k][j] + points[i]*points[j]*points[k]
                );
            }
        }
    }
    return dp[0][n + 1];
}

③ 约束规划

在普通的线性规划和区间规划里,一般题目有两种需求:统计和最优解。

这些题目不会对输出结果中的元素有什么限制,只要满足最终的一个条件就好了。但是在很多情况下,题目会对输出结果的元素添加一定的限制或约束条件,增加了解题的难度。

举例:0-1 背包问题。
给定 n 个物品,每个物品都有各自的价值 vi 和重量 wi,现在给你一个背包,背包所能承受的最大重量是 W,那么往这个背包里装物品,问怎么装能使被带走的物品的价值总和最大。

因为很多人都熟悉这道经典题目,因此不去详细讲解,但是建议大家好好去做一下这道题。

NP 完全问题
该例题为NP完全问题。NP是Non-deterministicPolynomial的缩写,中文是非決定性多项式。通俗一点来说,对于这类问题,我们无法在多项式时间内解答。这个概念很难,但是理解好它能帮助你很好的分析时间复杂度。

时间复杂度
时间复杂度并不是表示程序解决问题需要花费的具体时间,而是说程序运行的时间随着问题规模扩大增长的有多快

如果程序具有 O(1) 的时间复杂度,那么,无论问题规模有多大,运行时间都是固定不变的,这个程序就是一个好程序。如果程序运行的时间随着问题规模的扩大线性增长,复杂度是 O(n),也很不错。还有一些平方数 O(n2)、立方数 O(n3) 的复杂度等,比如冒泡排序。另外还有指数级的复杂度,例如 O(2n),O(3n) 等。还有甚至 O(n!) 阶乘级的复杂度,例如全排列算法。分类如下:

  • 多项式级别时间复杂度
    O(1)、O(n)、O(n×logn)、O(n2)、O(n3) 等,可以表示为 n 的多项式的组合

  • 非多项式级别时间复杂度
    O(2n),O(3n) 等指数级别和 O(n!) 等阶乘级别 。

例题分析
回到 0-1 背包问题,经典的解法就是利用动态规划求解,时间复杂度是 O(n×W)

因为物体的重量W是有精度的,如果假设背包的重量是21.17008,物品的重量精确到了小数点后5位,解题的时候,必须对每一个0.00001的重量单位分配一个记忆单元,从 0.00000,0.00001,0.00002 一直分配到 21.17008,虽然背包大小只有不到 22,但是一共分配了 210 多万个单元,这是很可怕的计算量和存储量。

在这里插入图片描述

而计算机都是用二进制来表示一个数,假设涵盖从 0 到 W 的区间需要 m 位的二进制数,那么 W 就能写成 2的m次方。因此 0-1 背包问题的复杂度就成为了 O(n×2^m)

现在问题的规模取决于物品的个数以及需要用多少位二进制数来表示背包的重量,很明显,它是一个指数级的计算量,是一个非多项式级别的复杂度。

④ 必须要有姓名:高楼扔鸡蛋问题

参考文章:
经典动态规划:高楼扔鸡蛋

你面前有一栋从 1 到NN层的楼,然后给你K个鸡蛋(K至少为 1)。现在确定这栋楼存在楼层0 <= F <= N,在这层楼将鸡蛋扔下去,鸡蛋恰好没摔碎(高于F的楼层都会碎,低于F的楼层都不会碎)。现在问你,最坏情况下,你至少要扔几次鸡蛋,才能确定这个楼层F呢?
PS:F 可以为 0,比如说鸡蛋在 1 层都能摔碎,那么 F = 0

深入想容易脑子抽,换个时间点再来思考。晒一下Java代码。


	static Map<Pair<Integer, Integer>, Integer> map = new HashMap<>();

    //k个鸡蛋, n层楼
    private static int dp(int k, int n) {

        //base case
        if (k==1) return n;
        if (n==0) return 0;

        //避免重复计算
        Pair<Integer, Integer> pair = new Pair<>(k, n);
        if (map.containsKey(pair)) {
            return map.get(pair);
        }

        int res = Integer.MAX_VALUE;
        //穷举所有可能的选择
        for (int i=1; i<=n; i++) {
            res = Math.min(res,
                    Math.max(
                            dp(k, n-i),
                            dp(k-1, i-1)
                    )+1);
        }

        //记入备忘录
        map.put(pair, res);

        return res;
    }

拓展:

① 青蛙跳台阶

题目一描述:青蛙跳台阶问题。
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个n级台阶总共有多少种跳法?

分析:
当n = 1, 只有1中跳法;当n = 2时,有2种跳法;当n = 3 时,有3种跳法;当n = 4时,有5种跳法;当n = 5时,有8种跳法;…规律类似于Fibonacci数列:

在这里插入图片描述

递归实现代码:

public int Fibonacci(int n){  
    if(n<=2)  
        return n;  
    return Fibonacci(n-1)+Fibonacci(n-2);  
}  

递归代码中有太多的重复运算。所以,考虑使用使用变量保存住中间结果。
对递归代码进行优化:

public int jumpFloor(int number) {  
    if(number<=2)  
        return number;  
    int jumpone=2; // 离所求的number的距离为1步的情况,有多少种跳法 (代表跳到第二级台阶的跳法数)
    int jumptwo=1; // 离所求的number的距离为2步的情况,有多少种跳法 (代表跳到第一级台阶的跳法数)
    int sum=0;  
    for(int i=3;i<=number;i++){  
        sum=jumptwo+jumpone;  
        jumptwo=jumpone;  
        jumpone=sum;  
    }  
    return sum;  
}

② 青蛙变态跳台阶

一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。

思路:

  • 先跳到n-1级,再一步跳到n级,有f(n-1)种;
  • 先跳到n-2级,再一步跳到n级,有f(n-2)种;
  • 先跳到n-3级,再一步跳到n级,有f(n-3)种;
  • ……
  • 先跳到第1级,再一步跳到n级,有f(1)种;

所以,可以推出如下的公式:

  • f(n)=f(n-1)+f(n-2)+f(n-3)+•••+f(1)
  • f(n-1)=f(n-2)+f(n-3)+•••+f(1)
  • 推出f(n)=2*f(n-1)

算法实现如下:

public int jumpFloor2(int num) {  
    if(num<=2)  
        return num;  
    int jumpone=2; // 前面一级台阶的总跳法数  (当i=3,代表跳到第二级台阶的跳法数)
    int sum=0;  
    for(int i=3;i<=num;i++){  
        sum = 2*jumpone;  
        jumpone = sum;  
    }  
    return sum;  
}
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值