DP动态规划--特点&解法

758 阅读9分钟

这是我参与更文挑战的第 10 天,活动详情查看: 更文挑战

动态规划

动态规划(Dynamic Programming,DP)是求解决策过程最优化的过程,通过把原问题分解为相对简单的子问题的方式求解复杂问题,在数学、管理科学、计算机科学、经济学和生物信息学等领域被广泛使用。

它的基本思想非常简单,若要求解一个给定问题,我们需要求解其不同部分(即子问题),再根据子问题的解得出原问题的解。

通常许多子问题非常相似,为了减少计算量,动态规划法试图每个子问题仅解决一次,一旦算出某个给定子问题的解,则将其记忆化存储,以便下次求解同一个子问题解时可以直接查表。因此具有天然剪枝的功能。

DP 题目的特点

首先我们一起来看一下,什么样的题目可能需要使用动态规划。一般而言(并不绝对),如果题目如出现以下特点,你就可以考虑(有一定概率)使用动态规划。

特点一:计数

题目问:有多少种方法?有多少种走法?

关键字:多少!

特点二:最大值/最小值

题目问:某种选择的最大值是什么?完成任务的最小时间是什么?数组的最长子序列是什么?达到目标最少操作多少次等。

关键字:最!

特点三:可能性

题目问:是否有可能出现某种情况?是否有可能在游戏中胜出?是否可以取出 k 个数满足条件?

关键字:是否!

通常而言,看到这三类题目,就可以尝试往 DP 解法上靠。

DP 的 6 步破题法

找到题目的特点,确定可以使用 DP 之后,接下来就可以准备逐步破题了。

下面我们以一道题目为例,详细介绍破解 DP 问题的思考过程与解题步骤。其实这道题不难,我相信你们都见过,不过我还是希望你能跟着我的思维重新再思考一遍。

【题目】给定不同面额的硬币 coins 和一个总金额 amount,需要你编写一个函数计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,则返回 -1。你可以认为每种硬币的数量是无限的。

输入:coins = [1, 2, 5],amount = 11

输出:3

解释:11 元可以拆分为 5 + 5 + 1,这是最少的硬币数目。

【分析】首先我们看到关键字“最少”,因此可以尝试往 DP 上面想。在 DP 问题上,很多人都存在一个思维误区,这里我们称为误区 1:

利用 DP 求解问题时,一开始就去想第一步具体做什么!

你之所以没有思路,往往是因为采用了一种顺应题意的方法去求解问题。比如题目问:

如何求“最少步数”,你就去想“从头开始怎么走”; 如何选择可以“达到最大收益”,你就真的开始去想“怎么选择”。

这恰恰是 DP 题目给你下的一个“套”,这样思考很容易带你陷入暴力求解的方法,找不到优化的思路。因此,千万不要从第一步开始思考。就这道题而言,就是不要去想,我的第一个硬币怎么选!

那么我们应该从哪里着手呢?答案是:最后一步!

1. 最后一步

以这道题为例,最后一步指的是:兑换硬币的时候,假设每一步操作总是选择一个硬币,那么我们看一下最后一步如何达到 amount?

以给定的输入为例:

coins = [1, 2, 5], amount = 11

最后一步可以通过以下 3 个选项得到:

已经用硬币兑换好了 10 元,再添加 1 个 1 元的硬币,凑成 11 元;

已经用硬币兑换好了 9 元,再添加 1 个 2 元的硬币,凑成 11 元;

已经用硬币兑换好了 6 元,再添加 1 个 5 元的硬币,凑成 11 元。

接下来,应该立即将以上 3 个选项中的未知项展开成子问题!

注意:如果你找的最后一步,待处理的问题规模仍然没有减小,那么说明你只找到了原始问题的等价问题,并没有找到真正的最后一步。

2. 子问题

拿到 3 个选项之后,你可能会想:[10元,9元,6元] 是如何得到?到此时,一定不要尝试递归地去求解 10 元、9 元、6 元,正确的做法是将它们表达为 3 个子问题:

如何利用最少的硬币组成 10 元?

如何利用最少的硬币组成 9 元?

如何利用最少的硬币组成 6 元?

我们原来的问题是,如何用最少的硬币组成 11 元。

不难发现,如果用 f(x) 表示如何利用最少的硬币组成 x 元,就可以用 f(x) 将原问题与 3 个子问题统一起来,得到如下内容:

原问题表达为 f(11);

3 个子问题分别表达为 f(10)、f(9)、f(6)。

接下来我们再利用 f(x) 表示最后一步的 3 个选项:

f(10) + 1 个 1 元得到 f(11);

f(9) + 1 个 2 元得到 f(11);

f(6) + 1 个 5 元得到 f(11)。

3. 递推关系

递推关系,一般需要通过两次替换得到。

最后一步,可以通过 3 个选项得到。哪一个选项才是最少的步骤呢?这个时候,我们可以采用一个 min 函数来从这 3 个选项中得到最小值。

f(11) = min(f(11-1), f(11-2), f(11-5)) + 1

接下来,第一次替换:只需要将 11 换成一个更普通的值,就可以得到更加通用的递推关系:

f(x) = min(f(x-1), f(x-2), f(x-5)) + 1

当然,这里 [1, 2, 5] 我们依然使用的是输入示例,进行第二次替换:

f(x) = min(f(x-y), y in coins) + 1

写成伪代码就是:

f(x) = inf
for y in coins:
    f(x) = min(f(x), f(x-y) + 1)

4. f(x) 的表达

接下来我们要做的就是在写代码的时候,如何表达 f(x)?

这里有一个小窍门。

直接把 f(x) 当成一个哈希函数。那么 f 就是一个 HashMap。

对于大部分 DP 题目而言,如果用 HashMap 替换 f 函数都是可以工作的。如果遇到 f(x, y) 类似的函数,就需要用 Map<Integer x, Map<Integer y, Integer>> 这种嵌套的方式来表达 f(x, y)。

当然,有时候,用数组作为哈希函数是一种更加简单高效的做法。具体来说:

如果要表达的是一维的信息,就用一维数组 dp[] 表示 f(x);

如果要表达的是二维的信息,就用二维数组 dp[][] 表示 f(x, y)

这就是为什么很多 DP 代码里面可以看到很多dp数组的原因。但是,现在你要知道:

用 dp[] 数组并不是求解 DP 问题的核心。

因为,数组只是信息表达的一种方式。而题目总是千万变化的,有时候可能还需要使用其他数据结构来表达 f(x)、f(x, y) 这些信息。比如:

f(x)、f(x, y) 里面的 x, y 都不是整数怎么办?是字符串怎么办?是结构体怎么办?

当然,就这个题而言,可以发现有两个特点:

1)f(x) 中的 x 是一个整数;

2)f(x) 要表达的信息是一维信息。

那么,针对这道题而言言,我们可以使用一维数组,如下所示:

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

数组下标 i 表示 x,而数组元素的值 dp[i] 就表示 f(x)。

那么递推关系可以表示如下:

dp[x] = inf;
for y in coins:
  dp[x] = min(dp[x], dp[x-y] + 1);

5. 初始条件与边界

那么,如何得到初始条件与边界呢?这里我分享一个小技巧: 你从问题的起始输入开始调用这个递归函数,如果递归函数出现“不正确/无法计算/越界”的情况,那么这就是你需要处理的初始条件和边界。

比如,如果我们去调用以下两个递归函数。

coinChange(0):可以发现给定 0 元的时候,dp[amount-x] 会导致数组越界,因此需要特别处理dp[0]。

coinChange(-1) 或者 coinChange(-2) 的调用也是会遇到数组越界,说明这些情况都需要做特别处理。

那么什么情况作为初始条件?什么情况作为边界?答案就是:

如果结果本身的存放不越界,只是计算过程中出现越界,那么应该作为初始条件。比如 dp[0]、dp[1];

如果结果本身的存放是越界的,那么需要作为边界来处理,比如 dp[-1]。

当然,就这道题而言,初始条件是 dp[0] = 0,因为当只有 0 元钱需要兑换的时候,应该是只需 0 个硬币。

6. 计算顺序

说来有趣,计算顺序最简单,我们只需要在初始条件的基础上使用正向推导多走两步可以了。比如:

初始条件:dp[0] = 0

那么接下来的示例中的输入:coins[] = [1, 2, 5]。我们已经知道 dp[0] = 0,再加上可以做的 3 个选项,那么可以得到:

dp[1] = dp[0] + 1 元硬币 = 1

dp[2] = dp[0] + 2 元硬币 = 1

dp[5] = dp[0] + 5 元硬币 = 1

到这里,递推关系好像还没有用到。那什么时候用呢?我们来看下面两种情况:

如下图所示,第一种情况,dp[5] 可以直接通过 dp[0] 得到,值为 1。

如下图所示,第二种,dp[5] 可以通过 dp[3] 得到,值为 3。

此时可以发现,判断具体取哪个值时,就需要用到前面的递推关系了。

f(x) = min(f(x-1), f(x-2), f(x-5)) + 1

我们只需要取较小的值就可以了。

完整代码便可以得出:

class Solution
{
  public int coinChange(int[] coins, int amount)
  {
    // 没有解的时候,设置一个较大的值
    final int INF = Integer.MAX_VALUE / 4;
    int[] dp = new int[amount + 1];

    // 一开始给所有的数设置为不可解。
    for (int i = 1; i <= amount; i++) {
      dp[i] = INF;
    }

    // DP的初始条件
    dp[0] = 0;

    for (int i = 0; i < amount; i++) {
      for (int y : coins) {
        // 注意边界的处理,不要越界
        if (y <= amount && i + y < amount + 1 && i + y >= 0) {
          // 正向推导时的递推公式!
          dp[i + y] = Math.min(dp[i + y], dp[i] + 1);
        }
      }
    }
    return dp[amount] >= INF ? -1 : dp[amount];
  }
}