动态规划基础

动态规划是一种把原问题分解为更简单的问题的,用来解决一类特定问题的方法。这个特定问题要满足三个基本条件:最优子结构,无后效性和子问题重叠。

动态规划基础

这一部分向读者简单介绍一下动态规划的基本思路,以及动态规划中状态转移方程的设计思路。

引入

这里使用OI-wiki中使用的题目来做引入。

IOI 1994 / USACO1.5 数字三角形
给定一个 \(r\) 行的数字三角形(\(r \leq 1000\)),需要找到一条从最高点到底部任意处结束的路径,使路径经过数字的和最大。每一步可以走到当前点左下方的点或右下方的点。

        7 
      3   8 
    8   1   0 
  2   7   4   4 
4   5   2   6   5

在上面这个例子中,最优路径是 \(7 \to 3 \to 8 \to 7 \to 5\)

朴素的想法

这道题目最朴素暴力的想法自然就是枚举——把每一种可能的路径都枚举一边,然后看看哪条路径经过的数字和最大。因为每层到下一层都有左右两个选择,想要走完每个路径,自然要把所有选择都选择一边,最终形成的路径个数就是 \(2^{(r-1)}\) 个。每一条路径都要走r次才能到达最低层,也就是说,最终的时间复杂度是 \(O(r·2^{(r-1)})\),这显然是不能接受的。

再次分析思考

对于这个问题,我们要找到一条经过的数字和最大的路径,可以看作是寻找最优方案类型的题目。此时,让我们暂且换个角度看这道题。对于此题,在确定第 \(r\) 层选择的数字后,第 \(r-1\) 层其实就只有 1 或 2 个选择了。只有 1 个选择的就不用考虑了,来看看有两个选择的情况。

有两个选择时,根据题目要求,我们肯定会选择目前经过的数字和更大的一个选择。假设我们已经知道了到达第 4 层的每个数字的最好路径,那么对于题目中第 5 层第 2 个数字 5 来说,上一层的 2 所对应的路径经过的数字和是 20,7 所对应的是 25,那么自然就选择 7 了。

显然,在前一层的结果确定的情况下,求出到达当前这一层每个数字的最优路径是很好求的。而在求出了到达最后一层每个数字的最优路径所对应的结果后,再去寻找这样一条整体的最优路径就很好找了。

总结出基本思路

此时,这道题的思路就很明显了,要寻找最优路径,我就先找到达第 \(r\) 层每个数字的最优路径,进一步的,就要找到第 \((r-1)\) 层每个数字的最优路径……逐层递推,直到第1层只有一个数字,此时只有一条路径,最优路径自然就是它自己。这时在往下层层返回,就可以求出最终答案了。

但从下往上找,代码实现是有些困难的,但没关系,我们也可以从上往下按照原本的顺序写。

最终思路与步骤

首先找第1层每个数字的最优路径,只有 1 个数字,最优路径自然也是他本身。
第二层,有两个数字,按照我们的方法来,从它们各自对应的前一层的选择中,选择相对最优的路径来延申到自己。
后续每层同理,直到最后一层完成,此时就可以根据最后一层的结果,来找到最优路径经过的数字和了。
因为每一层都要根据前一层的结果来计算,所以我们还要创建一个二维数组来保存每一层的结果。

因为这题仅要求输出最终路径经过的数字和,而不要求输出路径,所以使用一维数组也可以解决本题。
下面给出使用二维数组的代码,使用一维数组的代码请读者自行思考。

代码

#include <bits/stdc++.h>

using namespace std;

const int MAX_SIZE = 1010;
int nums[MAX_SIZE][MAX_SIZE] = {0};  // 存储数字金字塔中的数字
int res[MAX_SIZE][MAX_SIZE] = {0};   // 存储每一层的结果

int main() {
    int r;
    cin >> r;

    // 输入数字金字塔
    for (int i = 1, x; i <= r; i++) {
        for (int j = 1; j <= i; j++) {
            cin >> nums[i][j];
        }
    }

    // 第一行第一个数字的最优路径对应的数字和自然是它本身
    res[1][1] = nums[1][1];
    for (int i = 2; i <= r; i++) {  // 于是从第2层开始计算
        for (int j = 1; j <= i; j++) {
            // 因为每行第一个数字前还有一个0,
            // 而0肯定小于对应行真正的第一个数字对应的结果,
            // 所以可以直接写这样一个判断,就已经包含两种情况了
            if (res[i - 1][j - 1] > res[i - 1][j]){
                res[i][j] = res[i - 1][j - 1] + nums[i][j];
            } else {
                res[i][j] = res[i - 1][j] + nums[i][j];
            }
        }
    }

    // 从最后一行中选取结果最大的一个
    int max = 0;
    for (int i = 1; i <= r; i++) {
        if (res[r][i] > max) max = res[r][i];
    }
    cout << max;
    return 0;
}

动态规划适用问题的三大特征

最优子结构

如果对于一个多阶段决策过程的优化问题,不论初始状态和初始决策如何,对于先前决策造成的状态而言,余下的那些决策必构成一最优决策,则称该问题具有最优子结构性。这是百度百科中给出的定义,而本文也将给出更简单点的,判断问题是否具有最优子结构的方法。
如果要确定一个问题是否存在最优子结构:

  1. 这个问题能否分解成规模更小的子问题。
  2. 这个问题能否根据它的子问题的最优解来得出自己的最优解。
  3. 子问题最终是否能分解成不可再分解的最小子问题,且这个最小子问题的最优解是否易于得出。
  4. 如果子问题不能分解成不可再分解的最小子问题,那么能否确保所有子问题最终都能分解成一个易于得出最优解的子问题。

如果满足以上条件,那么我们可以说,这个问题是具有最优子结构性的。

对于引入的问题,原问题是求\(r\)层的数字金字塔的最优路径,可以先分解成寻找最后一层每一个数字的最优路径的问题。
对于寻找当前这一层每一个数字最优路径的问题,又可以分解成寻找这一层每一个数字对应的前一层1或2个数字的最优路径的问题。
如此递推的分解,可以分解到寻找第一层每一个数字的最优问题,而这个问题最优解是易于得出的。这样就满足了 1、3 两条。
而每一层每一个数字的最优路径又可以由上一层对应的 1 或 2 个数字的最优解综合的出。这样就满足了第2条。所以引入的问题是具有最优子结构性的。

无后效性

无后效性要求已经求解的子问题,不会再受到后续决策的影响。

对于引入的问题,显而易见,后面每一层不管怎么做,都不会改变前面任意一层中的任意一个数字,自然也不会影响前面已经求解好的问题。所以具有无后效性。

子问题重叠

如果有大量的重叠子问题,我们可以用空间将这些子问题的解存储下来,避免重复求解相同的子问题,从而提升效率。

对于引入的问题,同一层相邻的两个数字各自分解出的1或2个子问题中,一定有且仅有一个问题是这两个数字的公共子问题,即这是他们重叠的子问题。由此得出,引入的问题具有大量的重叠子问题。


如果一个问题具有以上三个特征,我们可以初步得出结论:这个问题可以用动态规划方法来解决。

动态规划解题思路

动态规划的解题思路简单地说只有两步:分解原问题为子问题,并将其归纳成状态,然后寻找这些状态之间相互转移的方式。

寻找状态

这一步要先将原问题分解为子问题,然后分析提取出它们的特征,这个特征我们就称之为状态。

对于简单的问题,它的状态其实就是子问题的最优解,这可能很难理解,但很好用,可以先这么记住。

那么对于引入的问题,它的状态就很简单,就是每个子问题的最优解,即每层每个数字的最优路径对应的数字和。

寻找状态转移的方式(状态转移方程)

因为动态规划的状态间转移的方式常用数学方程来描述,所以又称其为状态转移方程。

状态间的转移方式,简单点说,就是通过已经找到了最优解的子问题,来寻找它们的父问题的方式。

父问题:一个问题分解成若干子问题,这个问题就是这些子问题的父问题。

找到了状态间的转移方式后,就可以用状态转移方程来描述出来了。

因作者能力不足,这里无法总结一个比较通用的方法,还需读者自行多加练习。后续可能会进行更新。

对于引入的问题,我们要根据当前问题的 1 或 2 个子问题的最优解来寻找自己的最优解。而这里的最优解就是要让路径经过的数字和最大,所以我们就要看哪个子问题对应的数字和最大,来得出当前问题对应的数字和,即最优解。
状态间的转移方式就是判断子问题最优解的大小,选择更大的那个,再和当前问题自身的数字相加,得出当前问题的最优解。状态转移方程显而易见:

\[当前问题的最优解 = \max(子问题 1 的最优解, 子问题 2 的最优解) + 当前问题对应的数字 \]

考虑到第一层的唯一一个数字没有子问题,同时有些问题只有一个子问题(实际代码中可使用技巧,使这种情况和有两个子问题的情况合并起来),因此补充如下(为保证横向篇幅,偶数行是对应上一行的条件):

\[当前问题的最优解 = \begin{cases} 当前问题对应的数字, \newline \qquad \text{如果当前为第 1 层} \newline 子问题的最优解 + 当前问题对应的数字, \newline \qquad \text{如果当前问题只有一个最优}解 \newline \max(子问题 1 最优解, 子问题 2 最优解) + 当前问题对应的数字 \newline \qquad \text{如果当前问题有两个最优解} \end{cases} \]

\(f_{i,j}\) 表示到达第 \(i\) 层第 \(j\) 个数字的最优路径经过的数字和,\(w_{i,j}\) 表示第 \(i\) 层第 \(j\) 个数字,则上述方程可表示为:

\[f_{i, j} = \begin{cases} w_{i,j}, & i = 1 \newline f_{i-1,j} + w_{i,j}, & i \ge 2, j = 1 \newline \max(f_{i-1, j-1}, f_{i-1,j}) + w_{i, j}, & i \ge 2, j \ge 2 \end{cases} \]

结合数字金字塔阅读这个方程:

        7 
      3   8 
    8   1   0 
  2   7   4   4 
4   5   2   6   5

当然,对于本文引入的问题,还需要一步:

\[res = \max(f_{r,j}), \text{ 其中 } j=1, 2, 3,...,r \]

最后补充一点,状态转移方程可能比较难以理解,本文的讲解也确实是作者已经尽力了。
后续也许会配图?也许能更好理解一些。
如果确实不明白的话,可以多找几个简单的动态规划题目,做的时候找到题目的状态,和状态之间的转移方式,能够写出代码通过题目就行。然后再反过来根据代码来推出状态转移方程,再去思考它和状态之间的转移方式的关系。
这样多来几次应该会好一些。
因为作者动态规划还没有学很多,所以文章中可能存在一些错误。
欢迎各位巨佬批评指正,蒟蒻在此感激不尽!


简单的测试题

洛谷 B3637 最长上升子序列

参考资料





Copyright (C) <2025.11.23> <\(\text{BronzeAgeGO}\)>
本文的全部内容在CC BY-SA 4.0SATA协议下提供。

posted @ 2025-11-26 23:24  BronzeAgeGO  阅读(0)  评论(0)    收藏  举报