算法都是套路系列-动态规划模板

动态规划模板目录

    • 动态规划解题框架
      • 凑零钱问题
      • 01背包
      • 完全背包
      • 完全背包例子(凑零钱2)+公式推导
        • 完全背包思路
        • 思考状态和状态转移方程
        • 优化状态转移方程(重点)
        • 考虑空间优化(重要)

动态规划解题框架

首先,动态规划问题的一般形式就是求最值。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用比较多,比如说让你求最长递增子序列呀,最小编辑距离呀等等。

既然是要求最值,核心问题是什么呢?求解动态规划的核心问题是穷举。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值呗。

首先,动态规划的穷举有点特别,因为这类问题存在「重叠子问题」,如果暴力穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。

而且,动态规划问题一定会具备「最优子结构」,才能通过子问题的最值得到原问题的最值。

另外,虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,只有列出**正确的「状态转移方程」**才能正确地穷举。

以上提到的重叠子问题、最优子结构、状态转移方程就是动态规划三要素。具体什么意思等会会举例详解,但是在实际的算法问题中,写出状态转移方程是最困难的,这也就是为什么很多朋友觉得动态规划问题困难的原因,我来提供我研究出来的一个思维框架,辅助你思考状态转移方程:

明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义

按上面的套路走,最后的结果就可以套这个框架:

# 初始化 base case
dp[0][0][...] = base
# 进行状态转移
for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 求最值(选择1,选择2...)

下面通过斐波那契数列问题和凑零钱问题来详解动态规划的基本原理。前者主要是让你明白什么是重叠子问题(斐波那契数列没有求最值,所以严格来说不是动态规划问题),后者主要举集中于如何列出状态转移方程。

1、暴力递归

斐波那契数列的数学形式就是递归的,写成代码就是这样:

int fib(int N) {
    if (N == 1 || N == 2) return 1;
    return fib(N - 1) + fib(N - 2);
}

递归算法的时间复杂度怎么计算?就是用子问题个数乘以解决一个子问题需要的时间。

动态规划问题的第一个性质:重叠子问题。下面进行解决

2、带备忘录的递归解法

一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典),思想都是一样的。

int fib(int N) {
    if (N < 1) return 0;
    // 备忘录全初始化为 0
    vector memo(N + 1, 0);
    // 进行带备忘录的递归
    return helper(memo, N);
}

int helper(vector& memo, int n) {
    // base case 
    if (n == 1 || n == 2) return 1;
    // 已经计算过
    if (memo[n] != 0) return memo[n];
    memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
    return memo[n];
}

3、dp 数组的迭代解法

有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,就叫做 DP table 吧,在这张表上完成「自底向上」的推算岂不美哉!

int fib(int N) {
    vector dp(N + 1, 0);
    // base case
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i++)
        dp[i] = dp[i - 1] + dp[i - 2];
    return dp[N];
}

f(n - 1) + f(n - 2)就是此状态的状态方程

千万不要看不起暴力解,动态规划问题最困难的就是写出这个暴力解,即状态转移方程。只要写出暴力解,优化方法无非是用备忘录或者 DP table,再无奥妙可言。

这个例子的最后,讲一个细节优化。细心的读者会发现,根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化,把空间复杂度降为 O(1):

int fib(int n) {
    if (n == 2 || n == 1) 
        return 1;
    int prev = 1, curr = 1;
    for (int i = 3; i <= n; i++) {
        int sum = prev + curr;
        prev = curr;
        curr = sum;
    }
    return curr;
}

这个技巧就是所谓的「状态压缩」,如果我们发现每次状态转移只需要 DP table 中的一部分,那么可以尝试用状态压缩来缩小 DP table 的大小,只记录必要的数据,上述例子就相当于把DP table 的大小从 n 缩小到 2。后续的动态规划章节中我们还会看到这样的例子,一般来说是把一个二维的 DP table 压缩成一维,即把空间复杂度从 O(n^2) 压缩到 O(n)。

凑零钱问题

给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。

1、暴力递归

思考如何列出正确的状态转移方程

1、确定 base case,这个很简单,显然目标金额 amount 为 0 时算法返回 0,因为不需要任何硬币就已经凑出目标金额了。

2、确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount

3、确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。

4、明确 dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。所以我们可以这样定义 dp 函数:

dp(n) 的定义:输入一个目标金额 n,返回凑出目标金额 n 的最少硬币数量。

# 伪码框架
def coinChange(coins: List[int], amount: int):

    # 定义:要凑出金额 n,至少要 dp(n) 个硬币
    def dp(n):
        # 做选择,选择需要硬币最少的那个结果
        for coin in coins:
            res = min(res, 1 + dp(n - coin))
        return res

    # 题目要求的最终结果是 dp(amount)
    return dp(amount)

我们加上 base case 即可得到最终的答案。显然目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1:

def coinChange(coins: List[int], amount: int):

    def dp(n):
        # base case
        if n == 0: return 0
        if n < 0: return -1
        # 求最小值,所以初始化为正无穷
        res = float('INF')
        for coin in coins:
            subproblem = dp(n - coin)
            # 子问题无解,跳过
            if subproblem == -1: continue
            res = min(res, 1 + subproblem)

        return res if res != float('INF') else -1

    return dp(amount)

递归算法的时间复杂度分析:子问题总数 x 每个子问题的时间

子问题总数为递归树节点个数,这个比较难看出来,是 O(n^k),总之是指数级别的。每个子问题中含有一个 for 循环,复杂度为 O(k)。所以总时间复杂度为 O(k * n^k),指数级别。

2、带备忘录的递归

类似之前斐波那契数列的例子,只需要稍加修改,就可以通过备忘录消除子问题:

def coinChange(coins: List[int], amount: int):
    # 备忘录
    memo = dict()
    def dp(n):
        # 查备忘录,避免重复计算
        if n in memo: return memo[n]
        # base case
        if n == 0: return 0
        if n < 0: return -1
        res = float('INF')
        for coin in coins:
            subproblem = dp(n - coin)
            if subproblem == -1: continue
            res = min(res, 1 + subproblem)

        # 记入备忘录
        memo[n] = res if res != float('INF') else -1
        return memo[n]

    return dp(amount)

3、dp 数组的迭代解法

当然,我们也可以自底向上使用 dp table 来消除重叠子问题,关于「状态」「选择」和 base case 与之前没有区别,dp 数组的定义和刚才 dp 函数类似,也是把「状态」,也就是目标金额作为变量。不过 dp 函数体现在函数参数,而 dp 数组体现在数组索引:

dp 数组的定义:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出

int coinChange(vector& coins, int amount) {
    // 数组大小为 amount + 1,初始值也为 amount + 1
    vector dp(amount + 1, amount + 1);
    // base case
    dp[0] = 0;
    // 外层 for 循环在遍历所有状态的所有取值
    for (int i = 0; i < dp.size(); i++) {
        // 内层 for 循环在求所有选择的最小值
        for (int coin : coins) {
            // 子问题无解,跳过
            if (i - coin < 0) continue;
            dp[i] = min(dp[i], 1 + dp[i - coin]);
        }
    }
    return (dp[amount] == amount + 1) ? -1 : dp[amount];
}

计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷举,穷举所有可能性。算法设计无非就是先思考“如何穷举”,然后再追求“如何聪明地穷举”。

列出动态转移方程,就是在解决“如何穷举”的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整。

01背包

import java.util.Scanner;

public class Text {
	public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        int m = in.nextInt();//大小
        int n = in.nextInt();//物品数
        int[] dp = new int[m+5];//开背包质量的大小
        for(int i=1;i<=n;i++) {
            int v = in.nextInt();//物品体积
            int w = in.nextInt();//物品价值
            for(int j=m;j>=v;j--)
                dp[j] = Math.max(dp[j], dp[j-v]+w);
        }
        in.close();
        System.out.println(dp[m]);
	}
}

完全背包

import java.util.Scanner;

public class Text {
	public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        int m = in.nextInt();//大小
        int n = in.nextInt();//物品数
        int[] dp = new int[m+5];//开背包质量的大小
        for(int i=1;i<=n;i++) {
            int v = in.nextInt();//物品体积
            int w = in.nextInt();//物品价值
            for(int j=v;j<=m;j++)
                dp[j] = Math.max(dp[j], dp[j-v]+w);
        }
        in.close();
        System.out.println(dp[m]);
	}
}

完全背包例子(凑零钱2)+公式推导

给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。

输入: amount = 5, coins = [1, 2, 5]
输出: 4
解释: 有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

完全背包思路

  • 「完全背包」问题的特点是:背包里的物品可以无限次选取;
  • 本题特殊的地方在于:从背包里选取的物品 必须刚好装满 需要考虑的容量,而不是小于等于,注意这点细微的区别。
  • 「完全背包」问题是「0-1」背包问题的扩展。它们的区别在于:
  • 「0-1」背包问题:当前考虑的物品拿或者不拿;
  • 「完全」背包问题:当前考虑的物品拿或者不拿,如果拿,只要背包能装下,就可以一直拿,直到背包装不下为止。

求解思路依然是:一个一个物品考虑,容量一点一点扩大,整个过程是一个 尝试 和 比较 的过程。

思考状态和状态转移方程

第 1 步:定义状态
dp[i][j]:硬币列表的前缀子区间 [0, i] 能够凑成总金额为 j 的组合数。

说明:背包问题有一个特点,顺序无关,因此可以一个一个硬币去考虑。

第 2 步:状态转移方程
对于遍历到的每一种面值的硬币,逐个考虑添加到 「总金额」 中。由于硬币的个数可以无限选取,因此对于一种新的面值的硬币 coins[i],依次考虑选取 0 枚、1 枚、2 枚,以此类推,直到选取这种面值的硬币的总金额超过需要的总金额 j 为止。

状态转移方程是:

dp[i][j] = dp[i - 1][j - 0 * coins[i]] + 
           dp[i - 1][j - 1 * coins[i]] +
           dp[i - 1][j - 2 * coins[i]] + 
           ... + 
           dp[i - 1][j - k * coins[i]]

说明:状态转移方程基于「分类计数原理」:完成一件事,有 n 类办法,在第 1类办法中有 m1种不同的方法,在第 2 类办法中有 m2种不同的方法,……,在第 n 类办法中有 mn种不同的方法,那么完成这件事共有:N = m1 + m2 + … + mn
种不同的方法。

上述公式需要满足:j - k * coins[i] >= 0dp[i][j] 相对于 dp[i - 1][j] 而言,多考虑的一枚硬币,是当前正在考虑的那枚硬币的面值,coins[i],而这枚硬币选取的个数(从 0 开始)就是 dp[i][j] 这个问题可以分解的各个子问题的分类标准。

第 3 步:思考初始化
dp[0][0] 的值设置为 1,这一点可能比较难理解,但它作为被参考的值,可以使得后续的状态转移方程正确。原因是:当 dp[i - 1][j - k * coins[i]] 的第 2 维 j - k * coins[i] == 0 成立的时候,k 个硬币 coin[i] 恰好成为了一种组合。因此,dp[0][0] = 1 是合理的(代入状态转移方程,值正确)。

填写第 1 行(下标为 0的那一行),也是初始化的时候需要考虑的内容。第 1 行只考虑第 1 枚硬币 coins[0],能够组合出的容量只有 coins[0] 的整数倍数。

第 4 步:思考输出
输出就是表格的最后一格的数值,即 dp[len - 1][amount]。

第 5 步:考虑空间优化
当前状态行的值,只和上一行的状态值相关,可以使用滚动数组技巧。一个更经典的「空间优化」的方法是采用和「0-1」背包问题相对的赋值的方式

    public int change(int amount, int[] coins) {
        int len = coins.length;
        if (len == 0) {
            if (amount == 0) {
                return 1;
            }
            return 0;
        }

        int[][] dp = new int[len][amount + 1];
        dp[0][0] = 1;

        // 填第 1 行
        for (int i = coins[0]; i <= amount; i += coins[0]) {
            dp[0][i] = 1;
        }

        for (int i = 1; i < len; i++) {
            for (int j = 0; j <= amount; j++) {
                for (int k = 0; j - k * coins[i] >= 0; k++) {
                    dp[i][j] += dp[i - 1][j - k * coins[i]];
                }
            }
        }
        return dp[len - 1][amount];
    }

补充:使用「滚动数组」技巧

如果使用「滚动数组」,当前行的值应该先恢复为 0,这是因为上一行在 j - k * coins[i] >= 0 的时候才计算结果,上一行后面的部分没有计算就直接到下一行了。

如果直接使用「滚动数组」的话,就 有可能 引用到错误的结果。想象一下填表的过程,如果不设置为 0,就有可能引用到错误的结果。也就是说,在填表的时候,不是每一格都会计算结果 ,这个细节如果不好想明白,可以在纸上模拟一次填表的过程。

    public int change(int amount, int[] coins) {
        int len = coins.length;
        if (len == 0) {
            if (amount == 0) {
                return 1;
            }
            return 0;
        }

        int[][] dp = new int[2][amount + 1];
        dp[0][0] = 1;

        for (int i = coins[0]; i <= amount; i += coins[0]) {
            dp[0][i] = 1;
        }

        for (int i = 1; i < len; i++) {
            // 注意:如果写成滚动数组的情况,这一行完全参考上一行的值
            // 当前行的值应该先设置为 0,这是因为上一行只在 j - k * coins[i] >= 0 的时候才计算结果,后面的部分程序没有计算直接跳到下一行了
            // 如果不清空为 0,就有可能引用到错误的结果
            Arrays.fill(dp[i & 1], 0);
            
            for (int j = 0; j <= amount; j++) {
                for (int k = 0; j - k * coins[i] >= 0; k++) {
                    dp[i & 1][j] += dp[(i - 1) & 1][j - k * coins[i]];
                }
            }
        }
        return dp[(len - 1) & 1][amount];
    }

优化状态转移方程(重点)

根据状态转移方程其实可以得到递推公式。状态转移方程的表达形式「看起来」像是一个「无穷级数」,可以通过如下方式得到如下递推公式:

dp[i][i] = dp[i-1][j-0 * coins[i]+
dp[i-1][j-1* coins[i]+
dp[i-1][j-2 * coins[i]+
.
dpi-1][j-k* coins[i]]

这里 j - k * coins[i] >= 0。将 jcoins[i] 替换,得:

dp[i][j- coins[i]]= dp[i-1][j- coins[i]- 0 *coins[i]]+
dp[i-1][j- coins[i]-1* coins[i]+
dp[i-1][j - coins[i]-2* coins[i]]+
..
dpi-1][j- coins[i]-(k-1)* coins[i]]
注意3在于等式⒄选取同样的j和k的前提下。这里是k-1

这里 j - coins[i] - k * coins[i] >= 0。整理得:

dp[i][j- coins[il]]= dp[i-1][j- coins[i]]+
dp[i-1][j-2* coins[i]+
dp[i-1][j-3* coins[i]]+
..
dpi-1][j一k* coins[i]]

这里 j - k * coins[i] >= 0。

将等式 (1) - 等式(3),得:

dp [ i ] [ j ] − dp [ i ] [ j − c o i n s [ i ] ] = dp [ i − 1 ] [ j ] (4) \text{dp}[i][j] - \text{dp}[i][j - coins[i]] = \text{dp}[i - 1][j]\tag{4} dp[i][j]dp[i][jcoins[i]]=dp[i1][j](4)

整理得:

dp [ i ] [ j ] = dp [ i − 1 ] [ j ] + dp [ i ] [ j − c o i n s [ i ] ] (5) \text{dp}[i][j] = \text{dp}[i - 1][j] + \text{dp}[i][j - coins[i]]\tag{5} dp[i][j]=dp[i1][j]+dp[i][jcoins[i]](5)

所以其实每一行单元的值的填写只要看它的左边的值。如果没有左边,它至少是上一行单元格的值。

    public int change(int amount, int[] coins) {
        int len = coins.length;
        if (len == 0) {
            if (amount == 0) {
                return 1;
            }
            return 0;
        }

        int[][] dp = new int[len][amount + 1];
        dp[0][0] = 1;

        for (int i = coins[0]; i <= amount; i += coins[0]) {
            dp[0][i] = 1;
        }

        for (int i = 1; i < len; i++) {
            for (int j = 0; j <= amount; j++) {
                dp[i][j] = dp[i - 1][j];
                if (j - coins[i] >= 0) {
                    dp[i][j] += dp[i][j - coins[i]];
                }
            }
        }
        return dp[len - 1][amount];
    }

考虑空间优化(重要)

由状态转移方程(5) 知道,当前状态值参考了当前行前面的只,因此将空间优化到一维表格的时候,正序遍历是合理的。

    public int change(int amount, int[] coins) {
        int len = coins.length;
        if (len == 0) {
            if (amount == 0) {
                return 1;
            }
            return 0;
        }

        int[] dp = new int[amount + 1];
        dp[0] = 1;

        for (int i = coins[0]; i <= amount; i += coins[0]) {
            dp[i] = 1;
        }

        for (int i = 1; i < len; i++) {
            
            // 从 coins[i] 开始即可
            for (int j = coins[i] ; j <= amount; j++) {
                dp[j] += dp[j - coins[i]];
            }
        }
        return dp[amount];
    }

你可能感兴趣的:(蓝桥杯,ACM,算法模板,算法,动态规划)