【知识点】一文讲清动态规划的本质

一文讲清动态规划的本质

动态规划 Dynamic Programming (DP) 是算法领域的核心思想之一,却同时也是让许多学习者感到棘手的难点之一。动态规划的难点在于它不是简单的数学推导,也不单纯考验人们的程序设计能力,而更像是一种从思维方式到问题建模的一次深刻训练。

本文将从动态规划的定义出发,深入探讨动态规划的本质、应用场景、核心特点、解题思路以及如何逐步提升解决动态规划问题的能力。

动态规划的定义和应用:将大问题分解成许多小问题

动态规划的核心思想可以被概括为一句话:将一个复杂的问题分解成多个简单的子问题,并通过保存子问题的解来避免重复计算,从而提高求解效率

换句话说,动态规划适用于解决以下两类问题:

  1. 最优子结构问题:所谓的最优子结构问题,就是一个最优解可以通过其子问题的最优解而构成。
  2. 子问题重叠问题:多个子问题在递归的过程中会重复出现,并且会被多次计算到。

E.g. 01 - 斐波那契数列 \(\mathrm{I}\)

我们都知道斐波那契的通项公式是 \(F(n) = F(n-1) + F(n-2)\)。以计算斐波那契数列的第五项 \(F(5)\) 为例,很显然,\(F(5)\) 就是我们需要求解的大问题,而这个大问题可以被逐步分解为两个小问题,即 \(F(4)\) 和 \(F(3)\) 。只要我们知道了 \(F(4)\) 和 \(F(3)\),那么我们就可以将这两项相加来得到最终的结果。这个就是最优子结构的体现。

那什么时候会出现子问题重叠呢?我们不妨用代码来实现斐波那契数列:

cpp 复制代码
// 函数的定义
int fib(int n){
    // 根据斐波那契数列的定义:
    // 该数列的第零项为0,第一项为1
    if (n == 0) return 0;
    if (n == 1) return 1;
    // 根据式子写出递归代码。
    return fib(n-1) + fib(n-2);
}

// 执行函数
cout << fib(5) << endl;

这是一个很简单的递归算法。通过模拟递归算法我们可以画出一个递归图:

通过观察这个图可以发现,在计算 \(F(5)\) 的时候,\(F(3)\) 被 \(F(5)\) 调用过一次,同时又被 \(F(4)\) 调用一次。相同地,\(F(2)\) 被 \(F(3)\) 调用了两次,被 \(F(4)\) 也调用了一次。我们可以发现这个图中有很多的重复计算,这会大大浪费程序的运行时间。然而这就是所谓的子问题重叠问题。

针对这类型的问题,有什么解决方案呢?最直接的方法就是把已经计算过的答案保存下来,下次再调用的时候直接获取结果就可以了,这种方法就是大家常说的"记忆化"。记忆化搜索的代码如下:

cpp 复制代码
memset(memo, -1, sizeof memo);

int fib(int n) {
    // 如果已经计算过,直接返回结果
    if (memo[n] != -1) return memo[n];
    // 否则,递归计算,并将结果存储在 memo 中
    memo[n] = fib(n - 1) + fib(n - 2);
    return memo[n];
}

这个代码与普通斐波那契数列唯一的区别就是增加一个 memo 数组,判断某一个值是否已经被计算过。如果已经被计算过就直接返回,而不需要继续递归来消耗时间。

而另一种解决办法就是"递推",我们可以从小到大,先计算 \(F(0)\) 和 \(F(1)\) 并把答案保存下来,计算 \(F(2)\) 的时候就可以立刻通过将 \(F(0)\) 和 \(F(1)\) 相加得到结果。这种递推的做法就是所谓的动态规划思想。而这么做的代码也更加简洁:

cpp 复制代码
int fib[10005];
fib[0] = 0, fib[1] = 1;
for (int i=2; i<=n; i++)
    fib[i] = fib[i-1] + fib[i-2]
cout << f[n] << endl;

因此,动态规划的核心思想就是通过 记录重用 子问题的解,避免重复计算,从而提高求解效率。动态规划是一种典型的 拿空间换时间 的思想(毕竟现在的社会,时间成本比空间成本高太多了)。

动态规划与分治思想的主要区别就是分治算法主要应用于子问题不重叠的问题,而针对子问题重叠的问题,动态规划是一个更好的选择。

动态规划的性质

动态规划的三个基本性质是:最优子结构子问题重叠 以及 无后效性。前两个性质已经在第一部分提及过了,本部分主要讲解无后效性。

无后效性,又称马尔可夫性,这意味着某阶段的状态一旦确定,就不接受该状态之后决策的影响。即,某状态以后的过程不会影响之前的状态,只与当前的状态有关。用一句话概括就是"过去的事情不会影响未来,只关注当前的状态"。

除了无后效性,动态规划的另一个核心在于 最优性原理,即一个最优解包含其子问题的最优解。这一原理是由理查德·贝尔曼(Richard Bellman)提出。最优性原理是动态规划思想的理论基石。

E.g. 02 - 斐波那契数列 \(\mathrm{II}\)

回到原本斐波那契数列场景,当计算 \(F(5)\) 时,答案只依赖于当前状态 \(F(4)\) 和 \(F(3)\) 的值,与更早的历史状态无关。这就是为什么,我们在计算 \(F(5)\) 的时候可以直接使用 \(F(3)\) 和 \(F(4)\) 之前被计算出来的结果相加得到答案,而不是从 \(F(1)\) 和 \(F(0)\) 开始重新计算。

与此同时,在具有无后效性的场景中,一旦某一个状态的值已经被计算下来并可以转移到下一个状态时,这个状态将不会再被改变。换句话说,某状态以后的过程不会影响以前计算出来的状态,只与当前的状态有关。

无后效性是避免重复计算子问题的前提条件。

动态规划和递归的关联

在看到这里,你会发现动态规划和递归这两个思想有着异曲同工之妙。事实上,大多数的动态规划问题都可以使用记忆化搜索(递归)来解决。动态规划和暴力递归的关键区别如下:

  • 递归:通过函数自调用,将问题分解为子问题,直到达到基本情况。递归可能导致大量重复计算,效率较低。
  • 动态规划:通过记录已解决的子问题的解,避免重复计算。动态规划通常采用自底向上的方式,效率更高。

因此广义而言。带有记忆化搜索的递归算法也可以被称之为是动态规划。

解决动态规划问题的基本思路

解决动态规划问题的基本思路如下:

步骤一:确定不同的阶段和变量

根据题目的特性,我们可以把一个问题拆解成小问题,将一个小问题拆分成一个个次小问题,以此类推。那么在这之中,每一个需要解决的问题就被称之为一个阶段(状态)。因此对于动态规划问题,第一步需要做的就是尝试如何把大问题分解成许多个有关联的问题。同时,我们需要找到每一个阶段之间都是通过什么变量关联起来的。在斐波那契数列问题中,变量就是斐波那契数列的项数。

步骤二:确定状态转移方程式

在动态规划中,我们把每一个"阶段"称之为一个状态。例如,\(F(5)\) 就可以被称之为是一个状态。我们需要搞清楚大问题的状态和小问题的状态之间的关联。

在斐波那契数列中,这个关联就是:序列的第 \(i\) 项为序列的第 \(i-1\) 和第 \(i-2\) 项的和。用数学表示就是 \(F(n) = F(n-1) + F(n-2)\)。在其他的例子中(更加现实一点的),状态转移方程往往与决策有着密不可分的关系(例子请参考【E.g. 03 - 数楼梯】)。

在设计状态转移方程式时,你需要包括所有可能涉及到的变量。以确保能在后续的程序实现步骤中保留所有的可能性。

步骤三:确定初始状态和边界条件

将问题分解成不能够再被分解的问题,这些问题被称之为最小问题。对于每一个最小问题,应当可以做到直接得出答案,而不需要通过计算。

例如在斐波那契数列中,所谓的最小问题就是 \(F(0)\) 和 \(F(1)\) 的值。这两个值的计算不需要依赖于任何的递推式子,而是根据定义或者逻辑直接给出的。

因此我们需要设置 \(F(0)\) 和 \(F(1)\) 的初始值,并且将这两个状态设置成边界条件。

E.g. 03 - 数楼梯

题目描述

一个楼梯有 \(N\) 级台阶,上楼可以一步上一阶,也可以一步上二阶。请你编写一个程序,计算走到第 \(N\) 级台阶共有多少种不同的走法。

解题思路

这是一道动态规划的经典例题,对于初学者而言可能对这道题目没有任何的头绪,让我们按照【解决动态规划问题的基本思路】一步一步来解决这道题。

首先确定不同的阶段,对于这道题而言,很显然不同的的阶段就是走到不同阶数的方案数,而当前楼梯的阶级就是唯一的变量。每上一层楼梯,这个变量就会增加。我们不妨定义 \(dp_i\) 表示走到第 \(i\) 阶楼梯的方案数。

接下来确定变量和状态转移方程,在这个场景中,我们思考每一个状态之间的关联。假设我们要知道走到第十级台阶的方案数,但是我们知道想要走到第十级台阶,我们只有两个可能性(决策):

  1. 从第九级台阶迈一步走到第十级台阶。
  2. 从第八级台阶迈两步走到第十级台阶。

除了这两种方法,我们没有选择。换句话说,走到某一级台阶的方案数只跟走到这之前一级和之前两级台阶的方案数有关系(无后效性),并且方案数就是这两种可能性相加的和(逻辑上也是这样子的)。用一个更宽泛的式子来描述就是:

\[\large{dp_i = dp_{i-1} + dp_{i-2}} \]

而这个式子就是所谓的状态转移方程式。

因此,在设计状态转移方程式的时候我们着重观察"可能性"和"决策"这两个关键点。

最后就是确定初始状态和边界条件了,根据题目要求,状态的边界条件在 \(1\le i \le n\):

  1. 当只有一级台阶的时候,只有一种可能性,就是从初始平台迈一步来到第一级台阶。因此 \(dp_1 = 1\)。
  2. 当有两级台阶的时候,有两种可能,一种是从初始平台迈两步直接来到第二级台阶,另外一种就是连续走两步走到第二级台阶。因此 \(dp_2 = 2\)。
  3. 到了第三级台阶,套用公式可以得到 \(dp_3 = dp_1 + dp_2\),因此不再需要继续计算初始条件了。

最后,本题的 C++ 标准代码如下:

cpp 复制代码
#include <iostream>
using namespace std;

int n, dp[35];

int main(){
    dp[1] = 1, dp[2] = 2;
    cin >> n;
    for (int i=3; i<=n; i++)
        dp[i] = dp[i-2] + dp[i-1];
    cout << dp[n];
    return 0;
}

E.g. 04 - 最长公共子序列

题目描述

给定两个字符串 XY,求出这两个字符串的最长公共子序列 LCS 的长度。注意,这里的子序列不要求连续,但必须保持顺序。

样例输入输出:

text 复制代码
输入: X = "ABCBDAB", Y = "BDCAB"
输出: LCS长度 = 4,LCS = "BCAB"

解题思路

这道题相比较前面的题目难度有一定的提升,我们依旧按照前文提到的基本思路来一步一步地解决这个动态规划问题。

首先来确定不同的状态(阶段),我们的目标是找出两个字符串 XY 的最长公共子序列。为了将问题分解,我们可以定义阶段和变量如下:

  • 阶段: 我们考察两个字符串的前缀,分别为 X[0:i]Y[0:j](即 X 的前 i 个字符和 Y 的前 j 个字符)。
  • 变量: 两个变量 ij 用于表示字符串 XY 的子问题范围。

在每个阶段,我们的任务是确定 X[0:i]Y[0:j] 的最长公共子序列的长度。这就将整个问题分解为多个规模更小的子问题。

因此,我们考虑用 \(dp_{i, j}\) 来表示字符串 X[0:i]Y[0:j] 的最长公共子序列的长度。

看到这里,很多人会有些许疑问(其实我当初也对这个状态的定义感到困惑,但在经过大量的实践后我发现这确实是最好的状态定义方法。事实上,我认为状态的定义在某种程度上确实是一种玄学,如果实在没有办法搞懂为什么这么定义的话就直接理解就行,不需要刨根问底。但我依然准备了几点理由):

为什么定义阶段为 X[0:i]Y[0:j]

原因 1:递归思想的启发

公共子序列的性质决定了大问题可以由小问题递归构建:

  • 假如我们已经知道了 X[0:i-1]Y[0:j-1] 的最长公共子序列,那么我们可以根据 X[i-1]Y[j-1] 的关系推导出 X[0:i]Y[0:j] 的结果。
  • 这表明,将大问题划分为较短的前缀问题是自然的。

原因 2:易于表示动态变化

通过前缀的定义,我们可以在任意时刻只关注子字符串 X[0:i]Y[0:j]

  • 当前阶段的解法只需要依赖之前的阶段,而不用考虑整个字符串。
  • 动态规划的核心是利用阶段间的状态关系来避免重复计算,通过子问题的答案逐步推导出最终答案。

为什么选择子字符串的前缀?

原因 1:动态规划的自底向上特性

动态规划要求我们从最小的问题开始求解,而前缀是自然的分解方式:

  • 对于 X[0:i]Y[0:j],最小的子问题是 i=0j=0,即两个字符串中任意一个为空时,公共子序列长度为 0。
  • 从空字符串到完整字符串的逐步扩展非常符合动态规划的思路。

原因 2:控制问题规模

通过逐步扩展前缀,我们可以在二维表格 dp[i][j] 中用已知的小问题结果构造更大的问题:

  • 如果我们随机考察 XY 的任意部分(如子序列中间),那么问题之间的关联关系会变得复杂,动态规划就难以实施。
  • 而利用前缀作为阶段,每次只需要看最后一个字符的关系,并结合已有的子问题结果,问题变得简单而清晰。

接下来就到了第二步,确定状态转移方程式。通过考察字符串 XY 的最后一个字符,我们先把所有的可能性的找出来:

【情况一】当 X[i-1] == Y[j-1] 时(即两个字符串的最后一个字符相同):

这意味着这两个字符可以成为最长公共子序列的一部分。因此,我们可以将这两个字符加入到之前计算的最长公共子序列中。因此可得 \(dp_{i, j} = dp_{i-1, j-1}\)。 这里的 dp[i][j] 表示字符串 X 的前 i 个字符和字符串 Y 的前 j 个字符的最长公共子序列的长度。

dp[i-1][j-1] 表示在不考虑这两个最后字符的情况下,字符串 X 的前 i-1 个字符和字符串 Y 的前 j-1 个字符的最长公共子序列的长度。由于 X[i-1]Y[j-1] 相同,我们将这两个字符加入到子序列中,因此长度增加了 1。

【情况二】当 X[i-1] != Y[j-1] 时(即两个字符串的最后一个字符不同):

由于最后一个字符不同,我们无法同时将它们加入到公共子序列中。因此,我们需要决定是排除 X 的最后一个字符还是排除 Y 的最后一个字符,以找到更长的公共子序列。因此可得 \(dp_{i, j} = \max(dp_{i-1, j}, dp_{i, j-1})\)。

dp[i-1][j] 表示排除 X 的最后一个字符后,字符串 X 的前 i-1 个字符和字符串 Y 的前 j 个字符的最长公共子序列的长度。

dp[i][j-1] 表示排除 Y 的最后一个字符后,字符串 X 的前 i 个字符和字符串 Y 的前 j-1 个字符的最长公共子序列的长度。

我们取这两者中的较大值,确保选择了能够形成最长子序列的路径。

最后就是处理边界了和初始条件了,当其中一个字符串为空时(即 i==0 || j == 0),则公共子序列的长度为零。

最后,本题的 C++ 代码如下:

cpp 复制代码
#include <iostream>
#include <algorithm>
using namespace std;

const int N = 100005;
int a[N], b[N], dp[N][N];

int main(){
    int n; cin >> n;
    for (int i=1; i<=n; i++) cin >> a[i];
    for (int i=1; i<=n; i++) cin >> b[i];
    for (int i=1; i<=n; i++){
        for (int j=1; j<=n; j++){
            if (a[i] == b[j]){
                dp[i][j] = dp[i-1][j-1]+1;
            } else{
                dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
            }
        }
    }
    cout << dp[n][n] << endl;
    return 0;
}

E.g. 05 - 最长上升子序列

题目描述

给定一个长度为 \(N\) 的序列 \(S = a_1, a_2, \cdots, a_n\),请求求出这个序列的最长上升子序列。子序列不要求连续,但每一个元素出现的顺序需要与原序列中元素出现的顺序对应。

解题思路

按照相同的方法,我们先尝试把这个大问题分解成小问题。通过观察题目,注意到长度为 \(N\) 的序列的最长上升子序列一定是由一个更短的序列添加一个元素得来的,因此我们只需要计算将这个元素拼接到哪一个更短的序列中可以得到一个更长的上升子序列即可。因此可以定义 \(dp_i\) 为以第 \(i\) 个元素为结尾的最长上升子序列的长度。

接下来是状态转移方程,根据上述的定义可以得到 \(dp_i = \max_{S_j < S_i, 1\le j < i}(dp_j + 1)\)。该如何理解呢?

为了计算 \(dp_i\),我们需要分析以 \(S_i\) 为结尾时,最长上升子序列的可能性。对于每一个 \(S_i\),我们需要考虑之前所有的元素(即 \(S_1\) 到 \(S_{i-1}\)),是否可以成为 \(S_i\) 的前驱:

  • 如果 \(S_j < S_i\) 且 \(j < i\),说明 \(S_i\) 可以接在以 \(S_j\) 为结尾的最长上升子序列后面,构成一个更大的上升子序列。

  • 那么这个更大的上升子序列的长度就可以表示为:

    \[dp_i = \max(dp_j + 1),对于所有 j 满足 S_j < S_i 且 j < i $$。 \]

最后就是初始值了,我们需要一开始把 dp 数组中的所有元素都设置为 \(1\),因为无论如何,数组中的任意一个元素都可以自成一个长度为 \(1\) 的上升子序列。

最后 C++ 代码如下:

cpp 复制代码
#include <iostream>
using namespace std;

const int N = 10005;
int n, count, ans;
int arr[N], dp[N];

int main(){
    cin >> n;
    for (int i=1; i<=n; i++){
        cin >> arr[i]; dp[i] = 1;
    }
    
    for (int i=1; i<=n; i++){
        for (int j=1; j<i; j++){
            if (arr[i] > arr[j]){
                dp[i] = max(dp[i], dp[j]+1);
            }
        }
    }
    
    for (int i=1; i<=n; i++)
        ans = max(ans, dp[i]);
    
    cout << ans << endl;;
    return 0;
}

E.g. 06 - 钞票问题

题目描述

Macw 在一个神奇的国家,这个国家的货币只有 \(1\) 元,\(5\) 元, \(11\) 元三种面值的钞票,现在 Macw 想购买一个价值为 \(n\) 元的物品,请问 Macw 最少需要准备多少张钞票刚好能够凑够 \(n\) 元?

解题思路

这道题也是动态规划问题的经典题型。依旧按照上述动态规划"三步曲",我们先来将问题分解出来,既然我们不知道凑够 \(n\) 元最少需要的钞票数量,但是与【E.g. 03 - 数楼梯】类似,我们可以将问题分解为凑够 \(n-1, n-2, n-3, \cdots, 1\) 元钱所需要的钞票数量,并从小到大来解决。因此我们定义状态 \(dp_i\) 为凑够 \(i\) 元钱所需要的最少钞票数量。

接下来找状态转移方程,依旧寻找可能的可能性。假设我们现在有 \(i\) 元钱,我不知道凑够 \(i\) 元钱所需要的钞票,但是我们知道:

  1. 我只需要在 \(i- 1\) 元钱所需要的最少面额下增加一张面额为 \(1\) 的钱即可凑到 \(i\) 元钱。
  2. 我只需要在 \(i- 5\) 元钱所需要的最少面额下增加一张面额为 \(5\) 的钱即可凑到 \(i\) 元钱。
  3. 我只需要在 \(i- 11\) 元钱所需要的最少面额下增加一张面额为 \(11\) 的钱即可凑到 \(i\) 元钱。

以上就是三种选择性,我们只需要找到所需钞票数量最少的那一个组合就好了,即:

\[\large{dp_i = \max(dp_{i-1} + 1, dp_{i-5} + 1, dp_{i-11} + 1)} \]

化简可得:

\[\large{dp_i = \max(dp_{i-1}, dp_{i-5}, dp_{i-11}) + 1} \]

相同地,由于一开始我们不知道凑够某一个特定面值所需要的最少钞票数量,因此我们将 \(dp_i\) 全部赋值为 \(\infty\)。而对于 \(dp_0\) 我们知道凑够 \(0\) 元钱不需要任何一张钞票,因此我们特设 \(dp_0 = 0\) 作为我们的初始值。

最后,我们的 C++ 代码如下:

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;

const int N = 2e6 + 10;
int dp[N]; 
int c[5] = {1, 5, 11} , n;

int main() {
    cin >> n;
    memset(dp, 0x3f, sizeof(dp));
    dp[0] = 0;
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j < 3 ; j++){
            // 如果减去数字后索引出现负数,那么应该跳过循环
            if (i >= c[j]) 
            	dp[i] = min((dp[i - c[j]] + 1) , dp[i]);
        }
    }
    cout << dp[n] << endl;
    return 0;
}

以上就是一些基础的动态规划问题的解决方案。实际上,动态规划问题是算法和程序设计竞赛中的非常一大块内容,难度较高的动态规划问题通常与其他算法一起出现,也有许多不同的优化方法降低动态规划的时间复杂度和空间复杂度(例如线段树优化 DP、斜率优化 DP、单调队列优化 DP 等)。

我对动态规划问题学习的建议

说句实话,在我刚学习动态规划问题的时候也是一知半解,一直是一个一知半解的状态,许多题目也都是凭感觉做的。但随着刷题量越来越多,我也渐渐发现部分动态规划是有迹可循的,大部分题目都可以套类似的状态的定义和状态转移方程。毕竟动态规划这个思想本身也特别难懂,还是得需要自己去多练习才可以发现规律和找到解题技巧。

总而言之,动态规划是一种强大的算法设计方法,适用于具有最优子结构、无后效性和子问题重叠性质的问题。该思想通过将复杂问题分解为子问题,记录并重用子问题的解,动态规划能够高效地求解多阶段决策问题。理解并掌握动态规划的本质,对于解决复杂问题和优化算法性能具有重要意义。

References

GeeksforGeeks. (n.d.). Dynamic Programming . Retrieved from https://www.geeksforgeeks.org/dynamic-programming/

Gupta, R. (2023). Mastering Dynamic Programming: A Step-by-Step Guide . Medium. Retrieved from https://medium.com/@connectto.guptaraghav/mastering-dynamic-programming-a-step-by-step-guide-741d9d7d142

Stanford University. (n.d.). Dynamic Programming . Retrieved from https://web.stanford.edu/class/cs97si/04-dynamic-programming.pdf

Stanford University. (2013). Dynamic Programming Lecture Notes (CS161) . Retrieved from https://web.stanford.edu/class/archive/cs/cs161/cs161.1138/lectures/16/Small16.pdf

Bellman, R. (1966). Dynamic Programming . Science , 153(3731), 34--37. doi:10.1126/science.153.3731.34