加载中...

动态规划

动态规划(简称dp),是指把一个问题分解为若干个子问题,通过局部最优解得到全局最优的一种算法策略或者说一种思想方法。简单来讲,就是用一个数组表示我们要求的问题的答案,如果你知道前一个问题的答案,你就可以推出后一个问题的答案。



1. 概述

在算法竞赛中,动态规划是一种非常重要且常见的算法或思想。动态规划常用于求解最优化问题,其核心在于将一个问题分解成若干个子问题,并且在对于这些分解的子问题自身就是最优的基础上,得到我们需要解决的问题的最优方案。这种由子问题最优推出全局最优的思想方法能用于解决许多问题,如背包问题、约瑟夫环问题等。



2. 简介

动态规划是一个典型的“空间换时间”的算法思想,将需要解决的问题分解成若干个子问题,用数组存储每个子问题的答案,并且确保每个子问题只被处理一遍,再由子问题的答案推出我们需要求解的问题的答案。


动态规划有以下三个常见的概念:

  1. 状态:指当前所考虑的子问题的情况。例如背包的已用体积、区间的起止点,以及用状态压缩手段压缩后的状态。
  2. 状态转移:指由前一个子问题的答案推出当前问题的答案。一般来讲,会由一个表示赋值的等式给出,成为状态转移方程。
  3. 无后效性:指当前子问题的处理策略与后面问题的解答无关。

下面介绍处理动态规划问题常用的一个技巧——记忆化搜索。

搜索的低效在于没有能够很好地处理重叠子问题;动态规划虽然比较好地处理了重叠子问题,但是在有些拓扑关系比较复杂的题目前,又显得无奈。记忆化搜索正是在这样的情况下产生的,它采用搜索的形式和动态规划中递推的思想将这两种方法有机地综合在一起。

可以简单的理解为,记忆化搜索=搜索的形式+动态规划的思想

动态规划,就是一个最优化问题,先将问题分解为子问题,并且对于这些分解的子问题自身就是最优的才能在这个基础上得出我们要解决的问题的最优方案,要不然的话就能找到一个更优的解来替代这个解,得出新的最优子问题,这当然是和前提是矛盾的。动态规划不同于贪心算法,因为贪心算法是从局部最优来解决问题,而动态规划是全局最优的。用动态规划的时候不可能在子问题还没有得到最优解的情况下就做出决策,而是必须等待子问题得到了最优解之后才对当下的情况做出决策,所以往往动态规划都可以用一个或多个递归式来描述。而贪心算法却是先做出一个决策,然后再去解决子问题。这就是贪心和动态规划的不同。

动态规划的一种变形就是记忆化搜索,就是根据动态规划方程写出递归式,然后在函数的开头直接返回以前计算过的结果,当然这样做也需要一个存储结构记下前面计算过的结果(空间换时间),所以又称为记忆化搜索。



3. 步骤

动态规划一般有以下三个步骤:

  1. 设计状态:指设计出合适的dp数组以及规定dp数组的含义。设计出的dp数组要能够形容各种状态并且无后效性地在状态之间进行转移。
  2. 推理状态转移方程:顾名思义,关键在于如何从已知问题的答案推出当前问题的答案,有的时候需要多个方程,有的时候一个方程要包含多个子状态。
  3. 确定边界条件:递推的初值或者记忆化搜索的回溯条件,以及各个数组的初值。


4. 经典问题

(1)背包问题

背包问题是动态规划问题的一类经典问题,其中包括多种背包问题:01背包问题、完全背包问题、多重背包问题、混合背包问题、二位费用的背包问题、分组背包问题、树上背包问题、背包问题求解方案数、背包问题求解具体方案(俗称背包九讲)。


下面通过01背包问题,分析如何用动态规划思想解决背包问题。

1. 题目介绍
有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。第 i 件物品的体积是 vi ,价值是 wi 。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。输出最大价值。

输入格式
第一行两个整数,N ,V ,用空格隔开,分别表示物品数量和背包容积。
接下来有 N 行,每行两个整数 vi , wi ,用空格隔开,分别表示第 i 件物品的体积和价值。

输出格式
输出一个整数,表示最大价值。

数据范围
0 < N , V ≤ 1000
0 < vi , wi ≤ 1000
2. 代码分析

(Ⅰ)状态 f[i][j] 定义:前 i 个物品,背包容量 j 下的最优解(最大价值):

·当前的状态依赖于之前的状态,可以理解为从初始状态 f[0][0] 开始决策,有 N 件物品,则需要 N 次决策,每一次对第 i 件物品的决策,状态 f[i][j] 不断由之前的状态更新而来。

(Ⅱ)当前背包容量不够( j < v[i] ) ,没得选,因此前 i 个物品最优解即为前 i-1 个物品最优解:

        ·对应代码: f[i][j] = f[i-1][j]

(Ⅲ)当前背包容量够,可以选,因此需要决策选与不选第 i 个物品:

       ·选: f[i][j] = f[i-1][j-v[i]] + w[i]

       ·不选:f[i][j] = f[i-1][j]

       ·我们的决策是如何取到最大价值,因此以上两种情况取max()
//3. 代码实现
#include<bits/stdc++.h>

using namespace std;

const int N = 1005;
int v[N];    // 体积
int w[N];    // 价值 
int f[N][N];  // f[i][j], j体积下前i个物品的最大价值 

int main() 
{
    int n, m;   
    cin >> n >> m;
    for(int i = 1; i <= n; i++) 
        cin >> v[i] >> w[i];

    for(int i = 1; i <= n; i++) 
        for(int j = 1; j <= m; j++)
        {
            //  当前背包容量装不进第i个物品,则价值等于前i-1个物品
            if(j < v[i]) 
                f[i][j] = f[i - 1][j];
            // 能装,需进行决策是否选择第i个物品
            else    
                f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + w[i]);
        }           

    cout << f[n][m] << endl;

    return 0;
}
4. 代码优化

将状态 f[i][j] 优化到一维 f[j] ,实际上只需要做一个等价变形。我们定义的状态 f[i][j] 可以求得任意合法的 i 与 j 的最优解,但题目只需要求得最终状态 f[n][m] ,因此我们只需要一维的空间来更新状态。

(Ⅰ)状态 f[j] 定义:N 件物品,背包容量 j 下的最优解。

(Ⅱ)注意枚举背包容量 j 必须从 m 开始。

(Ⅲ)一维状态下枚举背包容量需要逆序枚举。在二维情况下,状态 f[i][j] 是由上一轮 i-1 的状态得来的, f[i][j] 与 f[i-1][j] 是独立的。而优化到一维后,如果我们还是正序枚举,则有 f[较小体积] 更新到 f[较大体积] ,则有可能本应该用第 i-1 轮的状态却用的第 i 轮的状态。

状态转移方程为: f[j] = max (f[j], f[j-v[i]] + w[i])
#include<bits/stdc++.h>

using namespace std;

const int N = 1005;
int v[N];    // 体积
int w[N];    // 价值 
int f[N];  // f[j], j体积下前i个物品的最大价值 

int main() 
{
    int n, m;   
    cin >> n >> m;
    for(int i = 1; i <= n; i++) 
        cin >> v[i] >> w[i];

    for(int i = 1; i <= n; i++) 
        for(int j = m; j >= 1; j--)		 //一维状态下需要逆序枚举背包容量 
            f[j] = max(f[j], f[j - v[i]] + w[i]); 

    cout << f[m] << endl;

    return 0;
}

(2)线性DP问题

线性dp往往指在一个序列上进行的dp,当然也可能由两个甚至多个序列。一般来讲,线性dp的三个步骤分别有以下特点:

设计状态:至少有一维表示当前考虑的对象在数列上的位置。

状态转移:必须找到这条线上前面的位置的dp值来推出当前位置的dp值。

边界条件:第一个位置单独讨论。


常见的线性dp问题有:数字三角形模型、最长上升子序列、最长公共子序列、最短编辑距离等。下面以最短编辑距离为例,介绍线性dp的思想方法。

1. 题目介绍
给定两个字符串 A 和 B ,现在要将 A 经过若干操作变为 B ,可进行的操作有:
1.删除–将字符串 A 中的某个字符删除。
2.插入–在字符串 A 的某个位置插入某个字符。
3.替换–将字符串 A 中的某个字符替换为另一个字符。
现在请你求出,将 A 变为 B 至少需要进行多少次操作。

输入格式
第一行包含整数 n ,表示字符串 A 的长度。
第二行包含一个长度为 n 的字符串 A 。
第三行包含整数 m ,表示字符串 B 的长度。
第四行包含一个长度为 m 的字符串 B 。
字符串中均只包含大小写字母。

输出格式
输出一个整数,表示最少操作次数。

数据范围
1 ≤ n , m ≤ 1000
2. 代码分析
状态定义: f[i][j] 表示第一个字符串的前 i 个字符变为第二个字符串的前 j 个字符所用的最少操作次数。

分析状态转移方程:把第一个字符串的前 i 个字符变成第二个字符串的前 j 个字符,有三种方法:

·把第一个字符串的前 i 个字符变成第二个字符串的前 j-1 个字符,然后在第一个字符串后面添加第二个字符串的第 j 个字符。

这种情况下, f[i][j] = f[i][j-1] + 1

·把第一个字符串的前 i-1 个字符变成第二个字符串的前 j 个字符,然后去掉最后一个字符。

这种情况下, f[i][j] = f[i-1][j] + 1

·把第一个字符串的前 i-1 个字符变成第二个字符串的前 j-1 个字符。变化之后,对比最后一个字符,如果相等,则变化完成,如果不同,把第一个字符串的最后一个字符变成第二个字符串的最后一个字符。

这种情况下, f[i][j] = f[i-1][j-1] + 1 (最后一个字符不同)或 f[i][j] = f[i-1][j-1] (最后一个字符相同)

取三种情况的最小值,就是 f[i][j]
//3. 代码实现
#include <bits/stdc++.h> 

using namespace std;

const int N = 1010;

int n , m;
char a[N] , b[N];
int f[N][N];//f[i][j]表示把a[1~i]变成b[1~j]需要的最少操作数

int main()
{
    cin >> n >> a + 1 >> m >> b + 1;
    //需要初始化边界情况
    for(int i = 0 ; i <= m ; i++)   f[0][i] = i;//把a[0]变成b[1~i]需要i步
    for(int i = 0 ; i <= n ; i++)   f[i][0] = i;//把a[1~i]变成b[0]需要i步

    //因为初始了边界情况,因此直接从1开始
    for(int i = 1 ; i <= n ; i++)
        for(int j = 1 ; j <= m ; j++)
        {
            f[i][j] = min(f[i - 1][j] + 1 , f[i][j - 1] + 1);//①②情况不需要判断直接执行
            if (a[i] == b[j]) f[i][j] = min(f[i][j], f[i - 1][j - 1]);
            else f[i][j] = min(f[i][j], f[i - 1][j - 1] + 1);
        }

    cout << f[n][m] << endl;
    return 0;
}

(3)区间DP问题

区间dp可以视作线性dp的一个分支,之所以把它单独列出来是因为区间dp的解法比较特殊,同时也比较固定。区间dp与其他线性dp不同的地方在于它的状态是以序列上的一个区间来表示的,而且大区间的答案可以从小区间的答案得到。


区间dp的基本思路:

设计状态:至少要有 dp[ l ][ r ] 两维分别表示区间的左端点和右端点。

状态转移:一般通过枚举区间 [ l, r ] 之间的点 k 把 [ l, r ] 分成 [ l, k ] 和 [ k+1, r ] ,然后用 dp [ l ][ k ] 和 dp [ k+1 ][ r ] 推出 dp [ l ][ r ] 。

边界条件:区间 l==r 时 dp [ l ][ r ] 可以从 a [ l ] 得出(或者为初值)。


区间dp的枚举顺序往往很有趣。根据dp顺序的原则,执行赋值时等号右边的dp值一定要是已经算出来了的结果。所以如果只是简单地从 1~n 分别枚举 l, r, k 就会出错,这里给出两种常用的枚举方法:

Ⅰ.首先枚举长度 len : 1 ~ n ;然后枚举起点 l : 1 ~ n ;这样可以算出终点 r = l + len - 1 ;最后枚举断点 k : l ~ r 。注意终点 r 不等大于序列总长度 n 。

Ⅱ.首先倒序枚举起点 l : n ~ 1 ;然后枚举终点 r : l ~ n ;最后枚举断点 k : l ~ r 。

如果两种枚举都不喜欢,那么也可以用记忆化搜索。


下面通过一道区间dp的题目,介绍区间dp与线性dp的区别,以及区间dp的思想方法。

1. 题目介绍
设有 N堆石子排成一排,其编号为 1,2,3,…,N。
每堆石子有一定的质量,可以用一个整数来描述,现在要将这 N堆石子合并成为一堆。
每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不相同。
例如有 4堆石子分别为 1 3 5 2, 我们可以先合并 1、2堆,代价为 4,得到 4 5 2, 又合并 1、2堆,代价为 9,得到 9 2 ,再合并得到 11,总代价为 4+9+11=24;
如果第二步是先合并 2、3 堆,则代价为 7,得到 4 7,最后一次合并代价为 11,总代价为 4+7+11=22。
问题是:找出一种合理的方法,使总的代价最小,输出最小代价。

输入格式
第一行一个数 N表示石子的堆数 N。
第二行 N 个数,表示每堆石子的质量(均不超过 1000)。

输出格式
输出一个整数,表示最小代价。

数据范围
1 ≤ N ≤ 300
2. 代码分析
状态定义: f[i][j] 表示将第 i 堆石子到第 j 堆石子合并成一堆石子的合并方式所需要的最小代价。

推导状态转移方程:我们需要思考的是,在确定集合为将 i 到 j 堆石子合并的方式后,需要考虑如何堆这个集合进行划分,使得其是我们可以表示出来的。我们可以发现,不管集合是如何划分的,最后一定会剩下两堆,然后把这两堆合并成一堆,所以我们可以以最后一次合并的分界线的位置来进行集合的分类。我们将集合分成若干类是以最后一步是将左边的哪一部分与右边的哪一部分进行了合并,以这个分界线来分类,总共的代价就是每一类的最小代价再取一个 min ,每一步的最小代价就是左边的最小代价加上右边的最小代价再加上最后一步的最小代价。
//3. 代码实现
#include < bits/stdc++.h > 

using namespace std;

const int N = 1010;
int s[N]; // 维护前缀和数组 
int f[N][N];	//f[i][j]表示将第i堆石子到第j堆石子合并成一堆石子的合并方式 

int main()
{
    int n;
    cin >> n;
    for(int i = 1; i <= n; i++) cin >> s[i];

    // 计算前缀和
    for(int i = 1; i <= n; i++) s[i] += s[i-1];

    // 枚举所有状态
    // 长度从小到大来枚举所有状态
    // 区间长度为1时合并不要代价,所以区间长度从2开始
    for(int len = 2; len <= n; len++)
    // 枚举完长度枚举一下起点
        for(int i = 1; i <= n-len+1; i++)
        {

            int l = i, r = i + len -1; 
            // 因为是取min值,所以先将f[l][r]置为无穷
            f[l][r] = 0x3f3f3f3f;
            // 枚举一下分界点,构造状态转移方程
            for(int k = l; k < r; k++) // k从l到r-1
                f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + s[r] - s[l - 1]);
        }
    cout << f[1][n] << endl;
    return 0;
}

(4)状态压缩DP问题

dp的时候需要设计状态,但是有的状态会很复杂。对于复杂的状态,也许就不能再像其他题目那样用一个 i 简单表示。或许这个状态表示一个有 n (n ≤ 16)个元素的集合,甚至包含了每一个元素的情况。为了应对这种情况,我们可以利用状态压缩和位运算,让一个数字表示一个集合。

状压dp也需要三个步骤:

设计状态:至少有一维是用一个数字(二进制)表示一个集合。

状态转移:考察每一个决策对集合的影响,经常使用位运算进行位移。

边界条件:当集合为空或者只有一个元素之类的。

特别注意:状压是指数级算法,所以适合状压的题往往有一个维度的数字很小。


接着通过一道例题,介绍状态压缩dp的解题方法。

1. 题目介绍
求把 N×M 的棋盘分割成若干个 1×2 的长方形,有多少种方案。
例如当 N=2,M=4 时,共有 5种方案。当 N=2,M=3时,共有 3种方案。
如下图所示:
输入格式
输入包含多组测试用例。
每组测试用例占一行,包含两个整数 N 和 M。
当输入用例 N=0,M=0 时,表示输入终止,且该用例无需处理。

输出格式
每个测试用例输出一个结果,每个结果占一行。
 
数据范围
1 ≤ N , M ≤ 11
2. 代码分析
本题等价于找到所有横放 1 × 2 小方格的方案数,因为所有横放确定了,那么竖放方案是唯一的。

用 f[i][j] 记录第 i 列第 j 个状态。 j 状态位等于 1 表示上一列有横放格子,本列有格子捅出来。转移方程即为,本列的每一个状态都由上列所有“合法”状态转移过来,f[i][j] += f[i-1][k] 。

两个转移条件: i 列和 i-1 列同一行不能同时捅出来;本列捅出来的状态 j 和上一列捅出来的状态 k 求异或,得到上一列的连续空行的奇偶性,只有连续空行都是偶数材能放置竖放小方格,即合法。

初始化条件 f[0][0] = 1 ,第 0 列只能是状态 0 ,无任何格子捅出来。最终答案存储在 f[m][0] 中,第 m+1 列不能有任何格子捅出来。

 
//3. 代码实现
#include<bits/stdc++.h>

using namespace std;

const int N = 12, M = 1 << N;
int st[M];
long long f[N][M];

int main()
{
    int n, m;
    while (cin >> n >> m && (n || m))
    {
        for (int i = 0; i < 1 << n; i ++)
        {
            int cnt = 0;
            st[i] = true;
            for (int j = 0; j < n; j ++)
                if (i >> j & 1)
                {
                    if (cnt & 1) st[i] = false; 	// cnt 为当前已经存在多少个连续的0
                    cnt = 0;
                }
                else cnt ++;
            if (cnt & 1) st[i] = false; 	// 扫完后要判断一下最后一段有多少个连续的0
        }

        memset(f, 0, sizeof f);
        f[0][0] = 1;
        for (int i = 1; i <= m; i ++)
            for (int j = 0; j < 1 << n; j ++)
                for (int k = 0; k < 1 << n; k ++)
                    if ((j & k) == 0 && (st[j | k])) 
                    // j & k == 0 表示 i 列和 i - 1列同一行不同时捅出来
                    // st[j | k] == 1 表示 在 i 列状态 j, i - 1 列状态 k 的情况下是合法的.
                        f[i][j] += f[i - 1][k];      
        cout << f[m][0] << endl;
    }
    return 0;
}

(5)树形DP问题

树形dp是一类基于树形结构的动态规划的问题,树形dp也和其他动态规划题目相同,一般需要经过三个步骤。

状态设计:至少有一维表示当前正在考虑的树上节点 p

状态转移:一般使用递归(深搜)由p的子节点的dp值得出p的dp值

边界条件:叶子节点没有子节点,可以只由叶子节点的值得出叶子的dp值


通过一道经典的树形dp问题,介绍树形dp的思想。

1. 题目介绍
Ural 大学有 N 名职员,编号为 1 ∼ N 。
他们的关系就像一棵以校长为根的树,父节点就是子节点的直接上司。
每个职员有一个快乐指数,用整数 Hi给出,其中 1 ≤ i ≤ N 。
现在要召开一场周年庆宴会,不过,没有职员愿意和直接上司一起参会。
在满足这个条件的前提下,主办方希望邀请一部分职员参会,使得所有参会职员的快乐指数总和最大,求这个最大值。

输入格式
第一行一个整数 N 。
接下来 N 行,第 i 行表示 i 号职员的快乐指数 Hi。
接下来 N−1 行,每行输入一对整数 L,K,表示 K 是 L 的直接上司。

输出格式
输出最大的快乐指数。

数据范围
1 ≤ N ≤ 6000 , −128 ≤ Hi ≤ 127
2. 代码分析
状态定义:每个人只有两种状态,则设 dp[0][i] 为第 i 个人不来,他的下属所能获得的最大快乐值; dp[1][i] 为第 i 个人来,他的下属所能获得的最大快乐值。
所以容易推出状态转移方程:

dp[0][1] = \(\sum\limits_{u=son}\)max (dp[1][u] , dp[0][u] ) , 当前节点不选, 那么子节点可以选, 也可以不选;

dp[1][i] = \(\sum\limits_{u=son}\) dp[0][u] + happy[i] , 当前节点选, 子节点不能选.

分析可得,每个人的状态要在下属的状态更新完了才能更新,所以用类似拓扑的方法,只记录每个节点的父节点,最后从所有入度为0的点开始搜索即可。
//3. 代码实现
#include <bits/stdc++.h> 

using namespace std;

const int N = 6010;
int n;
int happy[N]; 		//每个职工的高兴度
int f[N][2]; 		//每一个节点的状态
int e[N],ne[N],h[N],idx; 
bool has_father[N]; 	//判断当前节点是否有父节点

void add(int a,int b)	//在节点a,b之间添加一条有向边 
{ 	
    e[idx] = b,ne[idx] = h[a],h[a] = idx ++;
}

void dfs(int u)		//从入度为0的点开始搜索 
{ 
    f[u][1] = happy[u]; //如果选当前节点u,就可以把f[u,1]先加上他的高兴度
    for(int i = h[u];i != -1;i = ne[i])	//遍历树
    { 
        int j = e[i];
        dfs(j); //回溯
        //状态转移部分
        f[u][0] += max(f[j][1],f[j][0]);
        f[u][1] += f[j][0];
    }
}

int main()
{
    cin >> n;
    for(int i = 1;i <= n;i ++) cin >> happy[i]; 
    memset(h,-1,sizeof h); 
    for(int i = 1;i < n;i ++)
    {
        int a,b; //对应题目中的L,K,表示b是a的上司
        cin >> a >> b; 
        has_father[a] = true; 
        add(b,a); //从b向a连一条有向边 
    }
    
    int root = 1; 
    while(has_father[root]) root ++; 
    dfs(root); //从根节点开始搜索
    cout << max(f[root][0],f[root][1]); //输出不选根节点与选根节点的最大值
    return 0;
}


5. 结语

动态规划是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。

与其说动态规划是某种具体的算法,不如说其是一种解决特定问题的方法,它会出现在各式各样的数据结构中,与之相关的题目种类也更为繁杂。

能用动态规划解决的问题,需要满足三个条件:最优子结构,无后效性和子问题重叠。


最优子结构

具有最优子结构也可能是适合用贪心的方法求解。

注意要确保我们考察了最优解中用到的所有子问题。

1.证明问题最优解的第一个组成部分是做出一个选择;

2.对于一个给定问题,在其可能的第一步选择中,假定你已经知道哪种选择才会得到最优解。你现在并不关心这种选择具体是如何得到的,只是假定已经知道了这种选择;

3.给定可获得的最优解的选择后,确定这次选择会产生哪些子问题,以及如何最好地刻画子问题空间;

4.证明作为构成原问题最优解的组成部分,每个子问题的解就是它本身的最优解。方法是反证法,考虑加入某个子问题的解不是其自身的最优解,那么就可以从原问题的解中用该子问题的最优解替换掉当前的非最优解,从而得到原问题的一个更优的解,从而与原问题最优解的假设矛盾。

要保持子问题空间尽量简单,只在必要时扩展。

最优子结构的不同体现在两个方面:

1.原问题的最优解中涉及多少个子问题;

2.确定最优解使用哪些子问题时,需要考察多少种选择。

子问题图中每个定点对应一个子问题,而需要考察的选择对应关联至子问题顶点的边。

无后效性

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

子问题重叠

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


基本思路

对于一个能用动态规划解决的问题,一般采用如下思路解决:

1.将原问题划分为若干 阶段,每个阶段对应若干个子问题,提取这些子问题的特征(称之为 状态);

2.寻找每一个状态的可能 决策,或者说是各状态间的相互转移方式(用数学的语言描述就是 状态转移方程)。

3.按顺序求解每一个阶段的问题。

如果用图论的思想理解,我们建立一个 有向无环图,每个状态对应图上一个节点,决策对应节点间的连边。这样问题就转变为了一个在 DAG 上寻找最长(短)路的问题。


posted @ 2023-04-28 12:28  邪童  阅读(58)  评论(0编辑  收藏  举报