算法篇08、动态规划算法

612 阅读6分钟

动态规划的关键是定义dp数组所代表的含义,并能写出状态转移方程;只要这两步完成了,动态规划问题基本就解决了;其中dp数组中dp是dynamic programming的缩写;

我们首先看一道经典的动态规划问题,基本也是学习动态规划的入门题,就是著名的斐波那契数;

1、leetcode 509--斐波那契数

斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是: F(0) = 0,F(1) = 1 F(n) = F(n - 1) + F(n - 2),其中 n > 1 给你 n ,请计算 F(n) 。

示例 1:

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

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

题解如下所示,斐波那契数应该是最简单的动态规划问题了,因为斐波那契数的定义中已经把状态转移方法列出来了,我们直接定义dp数组就代表斐波那契数,然后把定义转换为状态转移方程求解即可;

//leetcode 509 斐波那契数
public int fib(int n) {
    if (n == 0) {
        return 0;
    }
    if (n == 1) {
        return 1;
    }
    int[] dp = new int[n + 1];
    dp[0] = 0;
    dp[1] = 1;
    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
}

2、leetcode 70--爬楼梯

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

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:

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

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

题解如下所示,dp数组的定义是:dp[i]表示爬上i级台阶的方法数;然后我们根据题目要求列出状态转移方法,dp[i] = dp[i - 1] + dp[i - 2] 状态转移方程为什么是这样呢?题目说每次可以爬一阶或者两阶台阶,那爬i阶台阶的方法就等于爬i-1阶台阶和爬i-2阶台阶的方法的和;

//leetcode 70 爬楼梯
//跟斐波那契数列思想完全相同
//关键是定义dp数组的含义 dp[i]表示爬上i级台阶的方法数
public int climbStairs(int n) {
    if (n == 1) {
        return 1;
    }
    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];
}

3、leetcode 300--最长递增子序列

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1:

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。

题解如下所示,定义dp数组的含义:dp[i]表示以nums[i]结尾的最长递增子序列的长度,根据dp数组的定义,我们可以求状态转移方程,首先for循环遍历数组,当遍历到某个元素时,再将该元素和它前面的所有元素比较,如果这个元素比前面的元素大,那么他们就可以组成一个递增子序列,我们要做的就是取当前的dp[i]和前面元素j的dp[j]+1两者之间的最大值;

这样遍历完整个数组后,我们就可以得到dp数组的所有值了,根据dp数组的定义,我们只需要遍历一遍dp数组,取dp数组的最大值即可;

//leetcode 300 最长递增子序列
//关键是定义dp数组的含义 dp[i]表示以nums[i]结尾的最长递增子序列的长度
public int lengthOfLIS(int[] nums) {
    int[] dp = new int[nums.length];
    Arrays.fill(dp, 1);
    for (int i = 0; i < nums.length; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[j] < nums[i]) {
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }
    }
    int res = 1;
    for (int i = 0; i < dp.length; i++) {
        res = Math.max(res, dp[i]);
    }
    return res;
}

4、leetcode 1143--最长公共子序列

给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。

例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。 两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。

示例 1:

输入:text1 = "abcde", text2 = "ace" 
输出:3  
解释:最长公共子序列是 "ace" ,它的长度为 3 。

示例 2:

输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc" ,它的长度为 3 。

示例 3:

输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0 。

题解如下所示,这种两个字符串或者两个数组的题目,一般都需要定义二维dp数组;定义dp数组的含义:dp[i][j]表示text1[0...i-1]和text2[0...j-1]最长公共子序列的长度;根据dp数组的定义,dp[text1.length()][text2.length()]就是所要求的结果;

求解状态转移方程:开一个双层嵌套的for循环依次遍历字符处1和字符串2,如果第一个字符串(i-1)处索引的字符和第二个字符串(j-1)处索引的字符相同的话,dp[i][j] = dp[i - 1][j - 1] + 1;否则dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);这样走完双层嵌套的for循环,dp[text1.length()][text2.length()]也就求得结果了;

//leetcode 1143 最长公共子序列
//dp[i][j]表示text1[0...i-1]和text2[0...j-1]最长公共子序列的长度
public int longestCommonSubsequence(String text1, String text2) {
    if (text1 == null || text1.length() == 0 || text2 == null || text2.length() == 0) {
        return 0;
    }
    int[][] dp = new int[text1.length() + 1][text2.length() + 1];
    for (int i = 1; i <= text1.length(); i++) {
        for (int j = 1; j <= text2.length(); j++) {
            if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[text1.length()][text2.length()];
}

5、leetcode 516--最长回文子序列

给定一个字符串 s ,找到其中最长的回文子序列,并返回该序列的长度。可以假设 s 的最大长度为 1000 。

示例 1:
输入:
"bbbab"
输出:
4
一个可能的最长回文子序列为 "bbbb"。

示例 2:
输入:
"cbbd"
输出:
2
一个可能的最长回文子序列为 "bb"

解法1:定义dp数组直接求解

题解如下所示,定义dp数组:dp[i][j]表示子数组s[i...j]的最长回文子序列的长度;

dp[i][j]的值取决于dp[i+1][j-1] dp[i+1][j] dp[i][j-1]三个位置的值,因此需要从后往前遍历,当我们把dp二维数组画出来的时候,dp[i][j]的值需要从左下方,左方,下方三个位置进行推导,因此for循环遍历时i代表行,从n - 2到0;j代表列,从i + 1到n;当s.charAt(i) == s.charAt(j)时,dp[i][j] = dp[i + 1][j - 1] + 2;否则dp[i][j] = Math.max(dp[i][j - 1], dp[i + 1][j]);

根据dp数组的定义,最后返回dp[0][n-1]即可;

//leetcode 516 最长回文子序列
//dp[i][j]表示子数组s[i...j]的最长回文子序列的长度
public int longestPalindromeSubseq(String s) {
    if (s == null || s.length() == 0) {
        return 0;
    }
    int n = s.length();
    int[][] dp = new int[n][n];
    //base case
    for (int i = 0; i < n; i++) {
        dp[i][i] = 1;
    }
    //dp[i][j]的值取决于dp[i+1][j-1] dp[i+1][j] dp[i][j-1]三个位置的值,因此需要从后往前遍历
    for (int i = n - 2; 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][j - 1], dp[i + 1][j]);
            }
        }
    }
    return dp[0][n - 1];
}

解法2:利用最长公共子序列求解

首先借助StringBilder将所求字符串的逆序串求出来,然后求原串和逆序串的最长公共子序列,求得的最长公共子序列就是结果;

//leetcode 516 最长回文子序列 解法2,借助最长公共子序列
public int longestPalindromeSubseq2(String s) {

    StringBuilder builder = new StringBuilder();
    for (int i = s.length()-1; i >= 0 ; i--) {
        builder.append(s.charAt(i));
    }
    String resverseText = builder.toString();
    return longestCommonSubsequence(s,resverseText);
}

//leetcode 1143 最长公共子序列
//dp[i][j]表示text1[0...i-1]和text2[0...j-1]最长公共子序列的长度
public int longestCommonSubsequence(String text1, String text2) {
    if (text1 == null || text1.length() == 0 || text2 == null || text2.length() == 0) {
        return 0;
    }
    int[][] dp = new int[text1.length() + 1][text2.length() + 1];
    for (int i = 1; i <= text1.length(); i++) {
        for (int j = 1; j <= text2.length(); j++) {
            if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[text1.length()][text2.length()];
}

6、leetcode 53--求最大子数组的和

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

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

示例 2:
输入:nums = [1]
输出:1

解法1如下所示,使用暴力解法,开一个双重嵌套的for循环,让第二个循环的索引从i+1开始,每循环一次表示加一个数,然后比较一下取最大值,最后全部循环完之后结果就是最大的那个子数组的和;不过算法时间复杂度是O(n^2)级别的;效率有点低;

解法1:暴力算法

//leetcode 53 求最大子数组的和
public int maxSubArray(int[] nums) {
    int res = Integer.MIN_VALUE;
    for (int i = 0; i < nums.length; i++) {
        int temp = nums[i];
        res = Math.max(res, temp);
        for (int j = i + 1; j < nums.length; j++) {
            temp = temp + nums[j];
            res = Math.max(res, temp);
        }
    }
    return res;
}

解法2动态规划如下所示,定义dp数组:dp[i]表示以nums[i]为结尾的最大子数组和

定义好dp数组的含义之后,我们在for循环中遍历数组,dp[i]就取当前遍历到的元素nums[i]和nums[i] + dp[i - 1]之间的最大值即可;通俗来说,就是dp[i]要不就是nums[i]本身,要不就是本身加上前一个数的dp[i];最后遍历dp数组,找出最大值即可;

解法2:动态规划

//leetcode 53 求最大子数组的和
//dp[i]表示以nums[i]为结尾的最大子数组和
public int maxSubArray(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }
    int[] dp = new int[nums.length];
    //第一个元素的最大子数组和就是本身
    dp[0] = nums[0];
    for (int i = 1; i < nums.length; i++) {
        dp[i] = Math.max(nums[i], nums[i] + dp[i - 1]);
    }
    int res = Integer.MIN_VALUE;
    for (int i = 0; i < dp.length; i++) {
        res = Math.max(res, dp[i]);
    }
    return res;
}

7、leetcode 718--最长重复子数组

给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。

示例:

输入:
A: [1,2,3,2,1]
B: [3,2,1,4,7]
输出:3
解释:
长度最长的公共子数组是 [3, 2, 1]

题解如下所示,定义dp数组,dp[i][j]表示以nums1[i-1]和nums2[j-1]为结尾的两个子数组的最长重复子数组的长度; 这题我们可以类比最长公共子序列;开一个双重嵌套的for循环,依次遍历数组1和数组2,如果nums1[i - 1] == nums2[j - 1],那么dp[i][j] = dp[i - 1][j - 1] + 1;

最后再开一个双重嵌套for循环取二维数组dp的最大值即可;

//leetcode 718 最长重复子数组
//dp[i][j]表示以nums1[i-1]和nums2[j-1]为结尾的两个子数组的最长重复子数组的长度
public int findLength(int[] nums1, int[] nums2) {
    if (nums1 == null || nums1.length == 0 || nums2 == null || nums2.length == 0) {
        return 0;
    }
    int[][] dp = new int[nums1.length + 1][nums2.length + 1];
    for (int i = 1; i <= nums1.length; i++) {
        for (int j = 1; j <= nums2.length; j++) {
            if (nums1[i - 1] == nums2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            }
        }
    }
    int res = 0;
    for (int i = 0; i <= nums1.length; i++) {
        for (int j = 0; j <= nums2.length; j++) {
            res = Math.max(res, dp[i][j]);
        }
    }
    return res;
}

8、0-1背包问题

0-1背包问题是典型的动态规划问题,0-1背包问题有很多变体,这里我们讨论一下最基本的0-1背包;

背包容量(可承载重量)为W,物品种类为N(每种物品只有一个),所有物品的重量为weight数组,对应的价值为value数组;其实关系就是weight.length = value.length = N;

求返回背包可以装的最大价值是多少?

其实0-1背包问题的dp数组特别难定义也特别难理解;dp数组定义:dp[i][w]表示 对于前i个物品,当前的背包容量为w,这种情况下可以装的最大价值是dp[i][w];

开一个双重嵌套的for循环,外层循环物品,内层循环背包容量,当遍历到物品i时,背包只有两种状态,要么还有容量装下物品i,要么没有容量装下物品i;没有容量的情况好说,dp[i][w] = dp[i - 1][w],等于包含上一个物品的dp,因为当前物品装不下;如果容量可以装下当前物品,那么dp[i][w] = Math.max(dp[i - 1][w], dp[i - 1][w - weight[i - 1]] + value[i - 1]),也就是物品i放入或者不放入背包,择优选择;

//0-1背包问题
//背包容量(可承载重量)为W,物品种类为N(每种物品只有一个),所有物品的重量为weight数组,对应的价值为value数组
//返回背包可以装的最大价值是多少
//dp数组的定义 dp[i][w]表示 对于前i个物品,当前的背包容量为w,这种情况下可以装的最大价值是dp[i][w]
public int knapsack(int W, int N, int[] weight, int[] value) {
    int[][] dp = new int[N + 1][W + 1];
    for (int i = 1; i <= N; i++) {
        for (int w = 1; w <= W; w++) {
            if (w - weight[i - 1] < 0) {
                //背包容量不够了,这种情况下物品i只能选择不放入背包
                dp[i][w] = dp[i - 1][w];
            } else {
                //物品i放入或者不放入背包,择优选择
                //dp[i]表示第i个物品,但是i是从1开始计算的,因此weight[i - 1]和value[i - 1]分别表示第i个物品
                dp[i][w] = Math.max(dp[i - 1][w], dp[i - 1][w - weight[i - 1]] + value[i - 1]);
            }
        }
    }
    return dp[N][W];
}

题目来源出处:

来源:力扣(LeetCode) 链接:leetcode-cn.com/problemset/…