目录

算法思想 - 动态规划算法

# 算法认识

动态规划(Dynamic Programming)简称 DP,对于子问题重叠的情况特别有效,因为它将子问题的解保存在表格中,当需要某个子问题的解时,直接取值即可,从而避免重复计算。

动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。

动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。

与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。

所以动态规划实际上是将问题分化成很多的子问题,然后将当前子问题计算过的最优结果存储起来,当另一个子问题也打算求该结果时,直接返回即可,因为已经是最优解。

动态规划的题目分为两大类,一种是求最优解类,典型问题是背包问题,另一种就是计数类,它们都存在一定的递推性质。前者的递推性质还有一个名字,叫做 「最优子结构」 ——即当前问题的最优解取决于子问题的最优解,后者类似,当前问题的方案数取决于子问题的方案数。所以在遇到求方案数的问题时,我们可以往动态规划的方向考虑。

# 算法性质

动态规划有很多的「高大上」的术语和性质,这些性质也是算法需要考虑的步骤。

子问题重叠

子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。

也就是在求解的过程中,许多子问题的解被反复地使用。为了避免重复计算,动态规划算法采用了填表来保存子问题解。

状态转移方程

动态规划最难的就是求解出状态转移方程,就类似于递推的公式,如:

f(n) = f(n - 1) + f(n - 2)  // n 是 1,2,...,n
dp[i] = dp[i - 1] + dp[i - 2]  // // i 是 1,2,...,i
1
2

其中 f(n)f(n - 1)f(n - 2) 不断转移,直至 n 才得到结果,这就是状态转移方程。

最优子结构

如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。

原问题的解是由多个子问题的最优解构成,比如说,原问题是考出最高的总成绩,那么子问题就是要把语文考到最高,数学考到最高等,为了每门课考到最高,要把每门课相应的选择题分数拿到最高,填空题分数拿到最高等等,当然,最终就是每门课都是满分,这就是最高的总成绩。所以得到了最后正确的结果:最高的总成绩就是总分。

无后效性

即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。

如 A -> B -> C,那么 B 和 C 的结果不会影响 A,同理 C 不会影响 B,但是 A 能影响 B 和 C,毕竟 B 和 C 是通过 A 的结果来算出。

自底向上

动态规划的特点就是从最底部(0 或者 1)蔓延到上面(n),假设存在长度 n,我们知道递归是从 n 到 n - 1 往下遍历,直至到 1,这叫 自顶向下。而动态规划是 自底向上,也就是从 1 到 2 往上遍历,直至到 n。

初始条件

因为动态规划是自底向上,所以我们在求解的时候需要由一些原始条件,如:

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

这样才有具体的值来自底向上:

dp[n] = dp[n - 1] + dp[n - 2];
1

# 步骤实战

动态规划遵循一套固定的流程:递归的暴力解法 -> 带备忘录的递归解法 -> 非递归的动态规划解法。这个过程是层层递进的解决问题的过程,如果没有前面的铺垫,直接看最终的非递归的动态规划解法,会难理解。

# 斐波那契式子

斐波那契式子为:F(0) = 0,F(1) = 1, F(n) = F(n - 1) + F(n - 2)

暴力的递归算法

public int fib(int n) {
    if (n == 1 || n == 2) {
        return 1;
    }
    return fib(n - 1) + fib(n - 2); // 自顶向下
}
1
2
3
4
5
6

要想计算原问题 f(20),就得先计算出子问题 f(19) 和 f(18),然后要计算 f(19),我就要先算出子问题 f(18) 和 f(17),依次类推。最后遇到 f(1) 或者 f(2) 的时候,结果已知,就能直接返回结果。

子问题个数为 O(2^n),所以这个算法的时间复杂度为 O(2^n),效率很低。

这就是我们需要解决动态规划问题的第一个性质:重叠子问题

步骤二、带备忘录的递归解法

即然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;后面每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。

「备忘录」可以是数组,也可以是哈希表,key 为子问题的唯一标识,value 就是解决后的结果。

public int fib(int n) {
    if (n < 1) {
        return 0;
    }
    // 备忘录全初始化为 0
    int[] memo = new int[n + 1];
    memo[0] = 1;
    memo[1] = 1;
    // 初始化最简情况
    fibMemo(memo, n);
    return memo[n - 1];  // 1 1 2 3 5 8 13,因为 i 从 0 开始,所以 n - 1 就是结果
}

public int fibMemo(int[] memo, int n) {
    // 未被计算过
    if (n > 0 && memo[n] == 0) {
        memo[n] = fibMemo(memo, n - 1) + fibMemo(memo, n - 2);
    }
    return memo[n];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。

子问题个数为 O(n)。所以,本算法的时间复杂度是 O(n)。比起暴力算法,效率大幅度提升很多。

至此,带备忘录的递归解法的效率已经和动态规划一样了。实际上,这种解法和动态规划的思想已经差不多了,只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。

动态规划

有「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,叫做 dp,在这张表上完成「自底向上」的推算。

public int fib(int n) {
    int[] dp = new int[n + 1];
    dp[0] = 1;
    dp[1] = 1;
    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2]; // 状态转移方程
    }
    return dp[n - 1];  // 1 1 2 3 5 8 13,因为 i 从 0 开始,所以 n - 1 就是结果
}
1
2
3
4
5
6
7
8
9

dp[i] = dp[i - 1] + dp[i - 2]; 就是 状态转移方程,它是解决问题的核心。我们也很容易发现,其实状态转移方程直接代表着暴力解法。

动态规划问题最困难的就是写出状态转移方程

动态规划优化

根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么长的一个 dp 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化,把空间复杂度降为 O(1):

public int fib(int n) {
    if (n < 2) {
        return n;
    }
    int prev = 0, curr = 1;
    for (int i = 0; i < n - 1; i++) {
        int sum = prev + curr;
        prev = curr;
        curr = sum;
    }
    return curr;
}
1
2
3
4
5
6
7
8
9
10
11
12

# 硬币凑钱

给 k 种面值的硬币,面值分别为 c1,c2,...,ck,再给一个总金额 n,问最少需要几枚硬币凑出这个金额,如果不可能凑出,则回答 -1 。

比如说,k = 3,面值分别为 1,2,5,总金额 n = 11,那么最少需要 3 枚硬币,即 11 = 5 + 5 + 1

暴力的递归算法

public int coinChange(int[] coins, int amount) {
    if (amount == 0) {
        return 0;
    }
    int ans = Integer.MAX_VALUE;
    for (int coin : coins) {
        // 金额不可达
        if (amount - coin < 0) {
            continue;
        }
        int subProb = coinChange(coins, amount - coin);
        // 子问题无解时
        if (subProb == -1) {
            continue;
        }
        ans = Math.min(ans, subProb + 1);
    }
    return ans == Integer.MAX_VALUE ? -1 : ans;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

总时间复杂度为 O(k*n^k)。

带备忘录的递归算法

public int coinChange(int[] coins, int amount) {
    // 备忘录初始化为 -2
    int[] memo = new int[amount + 1];
    Arrays.fill(memo, -2);
    
    return helper(coins, amount, memo);
}
public int helper(int[] coins, int amount, int[] memo) {
    if (amount == 0) {
        return 0;
    }
    // 备忘录不为 -2 代表已经存有最优解
    if (memo[amount] != -2) {
        return memo[amount];
    }
    int ans = Integer.MAX_VALUE;
    for (int coin : coins) {
        // 金额不可达
        if (amount - coin < 0) {
            continue;
        }
        int subProb = helper(coins, amount - coin, memo);
        // 子问题无解时
        if (subProb == -1) {
            continue;
        }
        ans = Math.min(ans, subProb + 1);
    }
    // 记录本轮答案,下标就是凑够当前硬币的最少枚次数
    memo[amount] = (ans == Integer.MAX_VALUE) ? -1 : ans;
    return memo[amount];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

动态规划

如果我们有面值为 1 元、3 元和 5 元的硬币若干枚,如何用最少的硬币凑够 11 元? (表面上这道题可以用贪心算法,但贪心算法无法保证可以求出解,比如 1 元换成 2 元的时候)

如何用最少的硬币凑够i元(i < 11)? 两个原因:

  • 当我们遇到一个大问题时,总是习惯把问题的规模变小,这样便于分析讨论
  • 这个规模变小后的问题和原来的问题是同质的,除了规模变小,其它的都是一样的, 本质上它还是同一个问题(规模变小后的问题其实是原问题的子问题)

当 i = 0,即需要多少个硬币来凑够 0 元。 由于 1,3,5 都大于 0,即没有比 0 小的币值,因此凑够 0 元最少需要 0 个硬币。这时候可以用一个 标记 来表示「凑够 0 元最少需要 0 个硬币」。

那么, 我们用 d(i) = j 来表示凑够 i 元最少需要 j 个硬币。于是我们已经得到了 d(0) = 0,表示凑够 0 元最小需要 0 个硬币。

当 i = 1 时,只有面值为 1 元的硬币可用,因此我们拿起一个面值为 1 的硬币,接下来只需要凑够 0 元即可,即 d(0) = 0。所以有:

d(1) = d(1 - 1) + 1 = d(0) + 1 = 0 + 1 = 1
1

当 i = 2 时,仍然只有面值为 1 的硬币可用,于是我拿起一个面值为 1 的硬币,接下来我只需要再凑够 2 - 1 = 1 元即可, 所以有:

d(2) = d(2 - 1) + 1 = d(1) + 1 = 1 + 1 = 2
1

当 i = 3 时,我们能用的硬币就有两种了:1 元和 3 元。既然能用的硬币有两种,于是就有两种方案。如果我拿了一个 1 元的硬币,我的目标就变为了: 凑够 3 - 1 = 2 元需要的最少硬币数量。即

d(3) = d(3 - 1) + 1 = d(2) + 1 = 2 + 1 = 3
1

这个方案说的是,我拿 3 个 1 元的硬币;第二种方案是我拿起一个 3 元的硬币,我的目标就变成:凑够 3 - 3 = 0元需要的最少硬币数量。即

d(3) = d(3 - 3) + 1 = d(0) + 1 = 0 + 1 = 1
1

这个方案说的是,我拿 1 个 3 元的硬币。

这两种方案哪种更优呢?题目要求使用用最少的硬币数量来凑够 3 元的。所以,选择 d(3) = 1,所以我们得到了 转移状态方程

d(3) = Math.min(d(3 - 1) + 1, d(3 - 3) + 1);
1

从以上的文字中, 我们要得到动态规划里非常重要的两个概念:状态状态转移方程

上文中 d(i) 表示凑够 i 元需要的最少硬币数量,我们将它定义为该问题的 状态, 这个状态是怎么找出来的呢?要根据子问题定义状态,找到子问题,状态也就浮出水面了。最终我们要求解的问题,可以用这个状态来表示:d(11),即凑够 11 元最少需要多少个硬币。

那状态转移方程是什么呢?既然我们用 d(i) 表示状态,那么状态转移方程应该包含了状态 d(i),上文中包含状态 d(i) 的方程是:

d(3) = Math.min(d(3 - 1) + 1, d(3 - 3) + 1);
1

于是它就是状态转移方程,描述状态之间是如何转移的。当然,我们要对它抽象一下,

d(i) = Math.min(d(i), d(i - vj) + 1 ); // 其中 i-vj >= 0,vj 表示第 j 个硬币的面值
1

所以最终代码:

public int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, Integer.MAX_VALUE);
    dp[0] = 0;
    for (int i = 0; i < dp.length; i++) {
        // 内层 for 在求所有子问题 + 1 的最小值
        for (int coin : coins) {
            if (i - coin < 0) {
                continue;
            }
            dp[i] = Math.min(dp[i], 1 + dp[i - coin]); // 状态转移方程
        }
    }
    return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 例题实战

# 爬楼梯

下面介绍先通过典型的动态规划题目总结 计算步骤,然后利用计算步骤完成动态规划的题目。

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 12 个台阶。你有多少种不同的方法可以爬到楼顶呢?

如 n = 2 时,有两种方法,分别是:

  • 1 阶 + 1 阶
  • 直接 2 阶

如 n = 3,有三种方法,分别是:

  • 1 阶 + 1 阶 + 1 阶
  • 1 阶 + 2 阶
  • 2 阶 + 1 阶

计算步骤

  • 特例确定,也就是「剪枝」,判断满足某些条件,直接返回,不需要计算,一般针对起始位置或末尾位置
  • 状态定义:定义状态的空间位置,确保动态规划有足够的空间存放子问题的解,如下题 f[i] 代表走过 i 阶需要的总方法
  • 初始状态,动态规划自底向上,所以底部(0 或者 1)至少要有一个已知的值,然后慢慢推到后面的值
  • 状态转移方程,动态规划最难的就是求解出状态转移方程,这是一种递推规律的公式
  • 返回值:确定最终的返回值

简单动态规划

public int climbStairs(int n) {
    // 特例
    if(n <= 2) {
        return n;
    }
    // 确定空间
    int[] f = new int[n];
    // 初始条件
    f[0] = 1; // -1 才是没有楼梯
    f[1] = 2;
    // 转移方程
    for(int i = 2;i < f.length; i++) {
        f[i] = f[i - 1] + f[i - 2];
    }
    return f[n - 1];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

优化动态规划

因为我们只需要 3 中状态,也就是只需要 3 种子问题的解,即 n、n - 1、n - 2,其他的不需要,所以就利用变量来替换

public int climbStairs(int n) {
    int p = 0, q = 0, r = 1;
    for (int i = 1; i <= n; ++i) {
        p = q; 
        q = r; 
        r = p + q;
    }
    return r;
}
1
2
3
4
5
6
7
8
9

# 最小路径和

题目来自 https://leetcode-cn.com/problems/minimum-path-sum/

解题思路参考:https://leetcode-cn.com/problems/minimum-path-sum/solution/zui-xiao-lu-jing-he-dong-tai-gui-hua-gui-fan-liu-c/

题目

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

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

示例 1:

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

示例 2:

输入:grid = [[1,2,3],[4,5,6]]
输出:12
1
2

此题是典型的动态规划题目。

下面按动态规划的步骤进行计算:

特例确定

如果 DP 长度为 0,返回 0。

状态定义

设 DP 为大小 m x n 矩阵,其中 dp[i][j] 的值代表直到走到 (i,j) 的最小路径和。

初始状态

DP 初始化即可,不需要赋初始值。

状态转移方程

题目要求,只能向右或向下走,换句话说,当前单元格 (i, j) 只能从左方单元格 (i−1, j) 或上方单元格 (i, j−1) 走到,因此只需要考虑矩阵左边界和上边界。

走到当前单元格 (i, j) 的最小路径和 =「从左方单元格 (i-1, j) 与从上方单元格 (i, j−1) 走来的两个最小路径和中较小的」 + 当前单元格值 dp[i][j] 。具体分为以下 3 种情况:

  • 矩阵的第一列进行求和,然后覆盖原来的值。

    dp[i][0] += dp[i - 1][0];
    
    1
  • 矩阵的第一行进行求和,然后覆盖原来的值。

    dp[0][i] += dp[0][i - 1];
    
    1
  • 当左边和上边都不是矩阵边界时: 即当 i、j 不等于 0 时,有

    dp[i][j] += Math.min(dp[i - 1][j], dp[i][j - 1]);
    
    1

返回值

返回 DP 矩阵右下角值,即走到终点的最小路径和。

复杂度分析

时间复杂度 O(M x N):遍历整个 grid 矩阵元素。

空间复杂度 O(1):直接修改原矩阵,不使用额外空间。

代码

grid 代表 DP

class Solution {
    public int minPathSum(int[][] grid) {
        int high = grid.length;
        int width = grid[0].length;
        if (width == 0) {
            return 0;
        }
        // 先将矩阵 [0] 的左右进行叠加
        for (int i = 1; i < high; i++) {
            grid[i][0] += grid[i - 1][0];
        }
        for (int i = 1; i < width; i++) {
            grid[0][i] += grid[0][i - 1];
        }
        for (int i = 1; i < high; i++) {
            for (int j = 1; j < width; j++) {
                grid[i][j] += Math.min(grid[i - 1][j], grid[i][j - 1]);
            }
        }
        return grid[high - 1][width - 1];
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 不同路径

题目来自:https://leetcode-cn.com/problems/unique-paths/

题目

  • 一个机器人位于一个 m x n 网格的左上角

  • 机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角,即 m x n 的终点(对角线的末尾)

  • 问总共有多少条不同的路径?

下面按动态规划的步骤进行计算:

特例确定

无特例。

初始状态

DP 初始化即可,不需要赋初始值。

状态定义

设 m x n 矩阵有 dp[i][j],其中 i 代表矩阵的第 i 行,j 代表第 j 列。

状态转移方程

规律:

  • 如果位置处于第一行或者第一列,则总路径 = 1
  • 不在第一行或者第一列,则某个位置的总路径 = 它上面位置的总路径 + 它左侧位置的总路径

状态转移方程为:

dp[m][n] = dp[m - 1][n] + dp[m][n - 1];
1

返回值

返回 DP 矩阵右下角值,即走到终点的总路径和。

复杂度分析

时间复杂度 O(m x n):遍历整个 DP 矩阵元素。

空间复杂度 O(n):直接修改原矩阵,不使用额外空间。

代码

public int uniquePaths(int m, int n) {
    int[][] dp = new int[m][n];
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            // 起始点为 0,如果在起始点的左侧或者右侧,那么就只有一条路径
            if (i == 0 || j == 0) {
                dp[i][j] = 1;
            }
            // 每条路径的次数都是从上方的路径和左侧的路径相加而得到,具体画图
            else {
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
    }
    // 返回最后一个元素,数组从 0 开始
    return dp[m - 1][n - 1];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 适用场景

适合用动态规划来解决的问题,都具有下面三个特点:最优化原理、最优化原理、有重叠子问题。

如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。某阶段状态(定义的新子问题)一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与其以前的状态有关。子问题之间是不独立的(分治法是独立的),一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)。

# 算法局限

动态规划对于解决多阶段决策问题的效果是明显的,但是动态规划也有一定的局限性。首先,它没有统一的处理方法,必须根据问题的各种性质并结合一定的技巧来处理;另外当变量的维数增大时,总的计算量及存贮量急剧增大。因而,受计算机的存贮量及计算速度的限制,当今的计算机仍不能用动态规划方法来解决较大规模的问题,这就是「维数障碍」。

动态规划大部分都是 空间换时间,因为动态规划需要一个 DP 来存已经计算的子问题的解,所以需要利用大量的空间来存解值,但是在时间上就很快找出该解值,不需要重新求解值。

更新时间: 2024/01/17, 05:48:13
最近更新
01
JVM调优
12-10
02
jenkins
12-10
03
Arthas
12-10
更多文章>