DAG上最长不下降子序列:结合图论与动态规划的GESP七级精讲
1. 项目概述:从经典LIS到DAG上的动态规划
最近在带学生刷GESP七级的样题,碰到了P10287这道“最长不下降子序列”。乍一看题目名字,心里还嘀咕这不就是经典的LIS(Longest Increasing Subsequence)问题嘛,O(n log n)的二分贪心解法信手拈来。但仔细读完题面才发现,这题把场景放在了一个有向无环图(DAG)上,要求的是图中所有可能路径对应的节点权值序列中,最长不下降子序列的最大长度。这就有意思了,它不再是单纯对一个静态序列求LIS,而是变成了一个图论和动态规划结合的复合问题。对于正在准备GESP七级或者信奥提高组比赛的同学来说,这道题是一个很好的分水岭,能检验你是否真正理解了动态规划的状态设计和转移思想,而不是死记硬背模板。
简单来说,题目给了你一个有向无环图,每个节点上有个权值(范围1到10)。你可以从任意节点出发,沿着有向边走到任意能到达的节点,这样走过的一条路径,就会按顺序产生一个节点权值序列。题目问的是,在所有可能路径产生的所有可能序列中,能找到的最长不下降子序列的长度是多少。这里“不下降”指的是子序列中相邻元素满足前一个小于等于后一个。举个例子,如果一条路径的节点权值是 [3, 1, 4, 4, 2],那么它的一个最长不下降子序列可能是 [3, 4, 4],长度为3。
为什么这道题值得深究?因为它巧妙地绕开了最直接的暴力枚举。图中路径数量可能是指数级的,不可能枚举所有路径再对每个序列求LIS。这就要求我们必须利用DAG的性质和权值范围很小的特点,设计出高效的状态表示和转移方程。接下来,我们就一步步拆解这道题的核心思路、实现细节以及那些容易踩坑的地方。
2. 核心思路拆解:为什么不能直接用经典LIS?
2.1 问题转化与难点分析
首先,我们得明确经典LIS算法为什么在这里不直接适用。经典的O(n log n) LIS算法(维护一个单调数组d,d[i]表示长度为i的上升子序列末尾元素的最小值)是针对一个给定的、确定的线性序列。而本题中,序列本身是不确定的,它依赖于我们在DAG上选择的路径。路径的选取有极大的灵活性,这带来了两个核心难点:
- 路径的起点和终点不固定:你可以从任何一个入度为0的节点(如果没有,也可以从任意节点)开始,在任何节点结束。这意味着序列的开头和结尾是自由的。
- 路径的权值序列不是任意的:虽然起点终点自由,但序列的相邻元素必须对应图中一条有向边。你不能随意拼凑一个权值序列,它必须是一条实际存在的路径。
因此,我们不能对一个静态数组做LIS,而是要在动态规划的过程中,同时考虑“图的连通性”和“序列的单调性”。
2.2 关键突破口:权值范围极小与状态定义
题目给了最重要的一个约束:1 ≤ Ai ≤ 10。权值只有10种可能。这个限制是解题的关键突破口,它允许我们定义一种与权值维度相关的状态。
一个最朴素的想法是模仿经典LIS的DP:设dp[i]表示以节点i为终点的所有路径中,能形成的LIS最大长度。但这样定义不行,因为当我们要用节点i去更新其后继节点j时,我们不知道dp[i]对应的那个最优子序列的最后一个权值是多少。如果A[i] > A[j],那么以i结尾的最优序列可能无法接上j(因为要求不下降)。
所以,我们需要把“子序列最后一个元素的值”这个信息也放进状态里。结合权值范围只有10,我们可以定义:dp[v][x]:表示以节点v为路径终点,并且形成的权值序列中,其最长不下降子序列的最后一个元素权值恰好为x时,该LIS的最大长度。
这里x的取值范围是1到10。这个状态定义是理解本题的核心。它记录的不是路径本身的序列,而是路径序列所对应的那个“最优不下降子序列”的结尾信息。
2.3 状态转移方程推导
有了状态定义,我们来推导转移。假设当前我们正在处理节点v,它有一条入边来自节点u(即u -> v)。我们需要用u的所有状态来更新v的状态。
考虑dp[u][y],它表示以u结尾的路径,其LIS结尾权值为y。现在我们要走到v,节点v的权值为A[v]。对于v的新序列,其LIS有两种可能:
不将
A[v]纳入LIS:那么以v结尾的路径,其LIS可以直接继承来自u的某个最优LIS,前提是这个LIS的结尾权值y能够“兼容”未来的扩展(但在这个状态定义下,我们只关心结尾)。实际上,更准确的思考是,对于v的某个结尾权值x,如果x不等于A[v],那么这个LIS肯定不包含A[v],它只能从u的、结尾权值也为x的状态转移过来,并且要求(u, v)这条边存在。但这样思考有点绕。将
A[v]纳入LIS:这是更主要的情况。如果要将A[v]接在某个以u结尾的LIS后面,那么必须满足y ≤ A[v](不下降条件)。接上之后,新的LIS结尾权值就变成了A[v],并且长度加1。即:dp[v][A[v]] = max(dp[v][A[v]], dp[u][y] + 1),其中y ≤ A[v]。
此外,还有一种特殊情况:路径可以从v自己开始。那么LIS就是只包含A[v]自己,长度为1。所以我们需要初始化:对于所有节点v,dp[v][A[v]]至少为1。
但是,上述关于“不纳入”的思考在实践中可以简化。我们不需要单独为“不纳入”写转移。因为对于v的每一个可能的结尾权值x,它都有可能从u的相同结尾权值x转移而来(只要边存在),并且不改变长度。也就是说,dp[v][x]可以继承dp[u][x]。同时,它还可以通过“纳入A[v]”的方式从dp[u][y] (y ≤ A[v])转移来,并尝试更新dp[v][A[v]]。
然而,这里有一个更优美且正确的转移思路,它需要稍微调整一下状态语义,使其更容易处理: 我们定义f[v][x]:表示所有以节点v为终点的路径所构成的权值序列中,能找到的一个最长不下降子序列,并且这个子序列的最后一个元素“不超过x”的情况下,该子序列的最大长度。
注意,这里从“恰好为x”变成了“不超过x”。这个定义类似于经典LIS中d数组的索引含义。在这个定义下:
- 当
x < A[v]时,任何以v结尾的LIS,如果其最后元素不超过x,那么它肯定不包含A[v](因为A[v]比x大)。所以f[v][x]只能从u的f[u][x]转移而来(继承)。 - 当
x >= A[v]时,以v结尾的LIS有两种可能: a. 不包含A[v]:那么就是f[u][x]。 b. 包含A[v]:那么就是在u的、最后元素不超过A[v]的LIS基础上加1,即f[u][A[v]] + 1。 所以f[v][x] = max(f[u][x], f[u][A[v]] + 1)。
这个f[v][x]状态可以通过前缀最大值快速维护。实际上,我们最终要求的是所有节点v的所有x中,f[v][x]的最大值。而f[v][x]对于x是单调不减的。
但在编程实现时,使用最初“恰好为x”的定义dp[v][x],并通过两种转移(继承和新增)来更新,在思维和代码上更为直接。我们接下来就按这个思路来实现。
转移方程总结(使用dp[v][x]“恰好”定义):对于每条边(u, v):
- 继承转移:对于
x = 1 to 10,dp[v][x] = max(dp[v][x], dp[u][x])。这表示不把A[v]加入LIS,直接从u继承以x结尾的LIS。 - 新增转移:对于所有
y满足1 ≤ y ≤ A[v],dp[v][A[v]] = max(dp[v][A[v]], dp[u][y] + 1)。这表示将A[v]接在u的某个结尾权值y(满足y ≤ A[v])的LIS后面,形成新的以A[v]结尾的LIS。
初始化:对于所有节点v,dp[v][A[v]] = 1(至少可以以自己开头)。
答案:遍历所有节点v和所有权值x,取dp[v][x]的最大值。
2.4 算法流程与复杂度分析
由于图是DAG,我们需要按照拓扑序来递推我们的DP状态,这样才能保证在计算节点v时,所有能到达v的前驱节点u都已经被计算完毕。
- 拓扑排序:使用队列进行Kahn算法,得到节点的拓扑序列。
- DP初始化:创建
dp[n+1][11]数组(下标从1开始),所有元素初始为0。对于每个节点i,令dp[i][A[i]] = 1。 - 按拓扑序DP:按顺序处理拓扑序列中的每个节点
u。遍历u的所有出边(u, v)。对于每条出边:- 先进行“继承转移”:将
dp[u][x]的值尝试更新dp[v][x](对所有x)。 - 再进行“新增转移”:对于所有
y从1到A[v],用dp[u][y] + 1尝试更新dp[v][A[v]]。
- 先进行“继承转移”:将
- 收集答案:在所有DP状态更新完成后,遍历所有
dp[i][x],找到最大值。
复杂度分析:
- 拓扑排序:O(n + m)。
- DP转移:对于每条边
(u, v),我们需要做:- 继承转移:循环10次(x从1到10)。
- 新增转移:循环
A[v]次(y从1到A[v]),因为A[v] ≤ 10,所以最多也是10次。
- 因此,处理每条边的复杂度是O(10) = O(1)。总时间复杂度为O(n + m),在
n, m ≤ 1e5的数据范围下完全可行。 - 空间复杂度:DP数组为O(10 * n),邻接表存储图O(n + m)。
注意:这里有一个关键的优化点。在“新增转移”时,我们不需要真的循环
y从1到A[v]去找dp[u][y]的最大值。因为dp[u][y]是关于y的数组,我们可以维护一个前缀最大值数组premax[u][x] = max(dp[u][1], dp[u][2], ..., dp[u][x])。这样,max{dp[u][y] | y ≤ A[v]}就等于premax[u][A[v]]。这个优化可以将每条边的转移代价降到O(10)的继承转移 + O(1)的新增转移,虽然渐进复杂度没变,但常数更小。在实现时,我们可以选择在更新完一个节点u的所有dp[u][x]后,立即计算其premax数组供后续使用。
3. 代码实现与逐行解析
理解了思路,我们来看C++实现。我会用带详细注释的代码,并解释关键步骤和易错点。
#include <iostream> #include <vector> #include <queue> #include <algorithm> #include <cstring> // 用于memset using namespace std; const int MAXN = 100005; const int MAXV = 11; // 权值最大为10,我们用到下标1-10 int n, m; int A[MAXN]; // 节点权值 vector<int> graph[MAXN]; // 邻接表存图 int inDegree[MAXN]; // 入度数组,用于拓扑排序 int dp[MAXN][MAXV]; // dp[v][x]: 以v结尾的路径,其LIS结尾权值恰好为x的最大长度 int premax[MAXN][MAXV]; // premax[v][x]: dp[v][1..x]中的最大值,用于优化转移 int main() { ios::sync_with_stdio(false); cin.tie(0); cin >> n >> m; for (int i = 1; i <= n; ++i) { cin >> A[i]; } // 读入图,计算入度 for (int i = 0; i < m; ++i) { int u, v; cin >> u >> v; graph[u].push_back(v); inDegree[v]++; } // 初始化dp数组为0 memset(dp, 0, sizeof(dp)); // 初始化:每个节点自身可以构成一个长度为1的序列,LIS就是它自己 for (int i = 1; i <= n; ++i) { dp[i][A[i]] = 1; } // 拓扑排序 queue<int> q; for (int i = 1; i <= n; ++i) { if (inDegree[i] == 0) { q.push(i); } } // 在拓扑排序过程中进行DP while (!q.empty()) { int u = q.front(); q.pop(); // 关键步骤:计算当前节点u的premax数组 // premax[u][x] = max(dp[u][1], dp[u][2], ..., dp[u][x]) for (int x = 1; x <= 10; ++x) { premax[u][x] = max(premax[u][x-1], dp[u][x]); } // 遍历u的所有出边,更新后继节点v的状态 for (int v : graph[u]) { // 转移1:继承转移 (对于所有结尾权值x) for (int x = 1; x <= 10; ++x) { dp[v][x] = max(dp[v][x], dp[u][x]); } // 转移2:新增转移 (将A[v]接在结尾权值y <= A[v]的LIS后面) // 使用premax优化:max{dp[u][y] | 1 <= y <= A[v]} = premax[u][A[v]] int candidate = premax[u][A[v]] + 1; dp[v][A[v]] = max(dp[v][A[v]], candidate); // 拓扑排序:减少v的入度,若为0则入队 inDegree[v]--; if (inDegree[v] == 0) { q.push(v); } } } // 寻找全局答案 int ans = 0; for (int i = 1; i <= n; ++i) { for (int x = 1; x <= 10; ++x) { ans = max(ans, dp[i][x]); } } cout << ans << endl; return 0; }代码关键点解析:
数据结构选择:
vector<int> graph[MAXN]:使用邻接表存储稀疏图,比邻接矩阵更省空间。inDegree[MAXN]:记录每个节点的入度,用于Kahn拓扑排序。dp[MAXN][MAXV]:核心DP数组。第二维大小设为11(索引0-10,我们只用1-10),因为权值最大为10。premax[MAXN][MAXV]:前缀最大值数组,用于优化“新增转移”中求max(dp[u][y])的过程。
初始化:
dp数组全部初始化为0是合理的,因为任何状态的最小值就是0(表示不存在这样的路径)。- 对于每个节点
i,dp[i][A[i]] = 1是基础状态,代表路径只包含节点i自身。
拓扑排序与DP的结合:
- 我们使用队列进行拓扑排序。将初始时所有入度为0的节点入队。
- 在从队列中取出节点
u后,先计算u的premax数组。这一步至关重要,必须在对u的出边进行转移前完成,因为premax是基于u当前已计算好的dp[u][x]值。 - 然后遍历
u的每个后继v,进行两类转移。
转移的细节:
- 继承转移:
for (int x = 1; x <= 10; ++x) dp[v][x] = max(dp[v][x], dp[u][x])。这行代码的含义是,对于v来说,所有以u结尾的路径,都可以通过走(u,v)这条边,将路径延伸到v。延伸后,路径的LIS结尾权值x保持不变,长度也保持不变。所以v的dp[v][x]可以继承u的dp[u][x]。 - 新增转移:
int candidate = premax[u][A[v]] + 1; dp[v][A[v]] = max(dp[v][A[v]], candidate);。这是本题的精髓。premax[u][A[v]]代表了所有以u结尾的路径中,其LIS结尾权值不超过A[v]的最大长度。在这个最优的LIS后面加上节点v(其权值为A[v]),就形成了一个新的、以A[v]结尾的LIS,长度加1。我们用这个值去更新dp[v][A[v]]。 - 注意,这两类转移是独立且都需要执行的。不能只做其中一个。
- 继承转移:
拓扑排序的推进:
- 在更新完节点
v的所有入边(实际代码中是在处理u的出边时更新v)后,将v的入度减1。当v的入度变为0时,说明所有能到达v的前驱节点都已被处理,此时v的dp值已经达到了当前阶段的最大可能值(在DAG上就是最终值),可以将其入队,用于更新它的后继。
- 在更新完节点
答案收集:
- 最终答案存在于所有节点的所有
dp状态中。因为最优路径可能以任何节点结尾,其LIS也可能以任何权值结尾。
- 最终答案存在于所有节点的所有
实操心得:在计算
premax时,循环x从1到10,利用premax[u][x] = max(premax[u][x-1], dp[u][x])递推计算。这样计算出的premax[u][A[v]]就是我们要的max{dp[u][y] | y ≤ A[v]}。这个技巧在权值范围小的问题中非常常用,能将O(n)的查询降到O(1)。
4. 边界情况与测试数据设计
再好的思路,代码写出来也可能有bug。我们需要用一些针对性的测试数据来验证程序的正确性。
4.1 常见边界情况
单节点图(n=1, m=0):
- 输入:
1 05 - 输出应为1。因为只有一条路径(节点1),序列为[5],LIS就是[5]。
- 检查点:初始化
dp[1][5]=1是否生效,答案收集是否能找到这个1。
- 输入:
链状图(题目子任务1):
- 输入:
5 43 1 4 1 51 22 33 44 5 - 这是一个简单的链1->2->3->4->5。路径只有一条:1,2,3,4,5。对应权值序列[3,1,4,1,5]。
- 手工计算LIS:可以是[3,4,5]或[1,4,5]或[1,1,5],长度都是3。
- 输出应为3。
- 检查点:测试DP在简单拓扑序(就是节点顺序)下的转移是否正确。
- 输入:
多分支与汇合点:
- 输入:
4 42 1 3 21 21 32 43 4 - 节点1权值2,节点2权值1,节点3权值3,节点4权值2。
- 路径有:1->2->4 ([2,1,2]),1->3->4 ([2,3,2]),1->2 ([2,1]),1->3 ([2,3])等。
- 考虑路径1->2->4:序列[2,1,2],LIS可以是[2,2]或[1,2],长度2。
- 考虑路径1->3->4:序列[2,3,2],LIS是[2,3]或[2,2],长度2。
- 但最优路径可能是1->3 ([2,3]),LIS长度就是2。或者单独节点3 ([3]),长度1。
- 实际上,最长LIS就是2。程序应输出2。
- 检查点:测试DP在处理有多个前驱的节点(如节点4)时,是否能正确合并来自不同前驱(节点2和节点3)的状态。
- 输入:
权值全部相同:
- 输入:
3 27 7 71 22 3 - 序列为[7,7,7]。不下降子序列就是整个序列,长度为3。
- 检查点:测试“不下降”(
≤)条件在相等权值时的处理。
- 输入:
权值范围很小但图复杂:
- 可以构造一个随机DAG,n和m接近1e5,权值在1-10随机。用我们的程序和小规模暴力程序(枚举所有路径,仅适用于n很小的情况)对拍,验证正确性。
4.2 性能边界测试
题目数据范围是n, m ≤ 1e5。我们需要确保算法在极限数据下不会超时或超内存。
- 时间复杂度:我们的算法是O((n+m)*10),即约1e6量级的操作,在C++中非常轻松。
- 空间复杂度:
dp和premax数组都是n*11,约1e5114Byte ≈ 4.4MB。graph邻接表存储m条边,约2*m*4Byte≈ 0.8MB。总内存远低于限制。
我们可以用以下代码生成一个接近极限的随机DAG进行测试(仅供思路参考,非题解必需):
// 生成一个n=100000, m=100000的随机DAG n = 100000; m = 100000; for(int i=1; i<=n; i++) A[i] = rand()%10+1; // 确保生成的是DAG,一种简单方法是只让i向j连边(i<j) int edgeCount = 0; while(edgeCount < m) { int u = rand()%n + 1; int v = rand()%n + 1; if(u < v) { // 保证无环 graph[u].push_back(v); inDegree[v]++; edgeCount++; } }用我们的算法跑这样的数据,应该在毫秒级完成。
5. 常见错误与调试技巧
即使思路正确,实现时也可能掉进一些坑里。下面是我在实现和教学过程中总结的常见错误。
5.1 拓扑排序处理不当
错误1:未正确处理入度为0的节点初始化。
- 现象:答案偏小,特别是链的起点。
- 原因:只有入度为0的节点才会被初始加入队列。如果某个节点不是起点(入度>0),它的
dp值需要靠前驱节点更新。但如果代码逻辑错误,可能导致这些节点永远无法入队,DP无法传递下去。 - 检查:确保在
main中初始化队列时,将所有inDegree[i]==0的节点入队。在更新v后,判断--inDegree[v]==0再入队。
错误2:在DP更新前就计算了
premax。- 现象:答案错误,通常偏小。
- 原因:
premax[u]必须在节点u的所有入边处理完毕,dp[u]达到最终值后才能计算。如果提前计算(比如在刚把u从队列取出时,但此时dp[u]可能还未被所有前驱更新),那么premax[u]就是基于不完整的数据,导致后续转移错误。 - 我们的代码是正确的:在
while循环中,取出u后,立即计算premax[u],然后才用u去更新后继。这是因为在DAG的拓扑序中,当u被从队列取出时,意味着所有能到达u的节点都已经被处理过了,u的dp值已经确定。
5.2 状态转移遗漏或重复
错误3:只做了“新增转移”,忘了“继承转移”。
- 现象:对于某些路径,答案可能正确,但对于不包含终点权值的LIS,会丢失。
- 分析:考虑一条路径,其最优LIS的结尾权值
x不等于终点节点的权值A[v]。例如路径权值[2,4,1],终点权值A[v]=1,但LIS是[2,4],结尾权值x=4。这个状态dp[v][4]只能通过“继承转移”从dp[u][4]得到(假设u是v的前驱)。如果只做新增转移,dp[v][4]将永远为0。 - 结论:两类转移缺一不可。“继承”保证了LIS不包含当前节点的情况,“新增”保证了LIS包含当前节点的情况。
错误4:在“新增转移”中,错误地使用了
dp[u][A[v]]而不是前缀最大值。- 现象:在某些情况下答案偏小。
- 分析:新增转移的条件是
y ≤ A[v],我们要找的是dp[u][y]的最大值,其中y ≤ A[v]。如果我们只用dp[u][A[v]],那就只考虑了y恰好等于A[v]的情况,而忽略了y < A[v]但dp[u][y]更大的情况。例如,dp[u][3]=5,dp[u][5]=3,A[v]=5。最优选择应该是接在结尾为3的LIS后面(长度5+1=6),而不是接在结尾为5的后面(长度3+1=4)。所以必须取max{dp[u][y] for y<=5},即premax[u][5]。 - 这就是使用
premax数组进行优化的原因。
5.3 数组越界与初始化
- 错误5:权值数组
A或DP数组第二维开小了。- 题目明确
Ai ≤ 10,但数组索引通常从1开始。如果定义int dp[MAXN][10],那么有效索引是0-9。当我们访问dp[i][10]时就会越界。保险起见,可以定义[11],使用1-10。
- 题目明确
- 错误6:DP数组未初始化或初始化错误。
dp数组全部初始化为0是正确的。但别忘了对每个i执行dp[i][A[i]] = 1。如果漏了这一步,答案至少会少1。
5.4 调试技巧
当程序结果不对时,可以按以下步骤排查:
- 小数据手工模拟:用第4节提到的链状图或多分支图,在纸上画出每个节点的
dp数组,手动模拟算法的执行过程,与程序输出对比。 - 打印中间状态:在拓扑排序和DP过程中,打印关键信息。
通过观察// 例如,在处理完节点u后打印 cout << "Node " << u << ": "; for(int x=1; x<=10; x++) cout << dp[u][x] << " "; cout << endl; // 在更新dp[v][x]时打印 // cout << " Update v=" << v << " x=" << x << " to " << dp[v][x] << endl;dp值的变化,可以定位是哪个节点的计算出了问题。 - 对拍:写一个暴力程序(DFS枚举所有路径,对每条路径用O(n log n)求LIS),用于小数据规模(n<=10)下的随机测试。用随机生成的DAG和权值,比较两个程序的输出。这是找到隐蔽错误最有效的方法。
6. 算法扩展与思维提升
解完这道题,我们不妨再思考几个相关问题,把知识融会贯通。
6.1 如果权值范围很大(比如Ai ≤ 1e9)怎么办?
本题的核心优化点在于权值范围只有10,所以我们可以把权值作为状态的一维(大小10)。如果权值范围很大,比如1e9,那么dp[v][x]这个定义在空间和时间上都无法承受。
此时,我们需要另一种思路。注意到LIS本身有O(n log n)的贪心二分解法。我们能不能把这种思想用到图上?可以,但需要结合DAG的DP。
一种可行的状态定义是:dp[v]表示以节点v为终点的所有路径中,其权值序列的LIS最大长度。转移时,我们需要考虑所有前驱u,并找到A[u] ≤ A[v]的那些u中,dp[u]的最大值,然后加1。同时,还要考虑不从u转移的情况(即继承,但继承在这里不好表示,因为dp[v]只存了长度,没存结尾值)。
更准确的做法是,结合经典LIS中“维护末尾元素最小值的数组d”的思想。我们可以为每个节点v维护一个数组d_v[],表示以v结尾的路径中,长度为len的LIS的末尾元素最小值。但这个d_v数组的长度可能达到n,合并起来很复杂。
实际上,当权值范围很大时,这个问题通常需要用到数据结构优化DP,例如用线段树或树状数组维护“以某个权值结尾的LIS最大长度”。在DAG上按拓扑序DP,对于每个节点v,查询所有权值≤ A[v]的前驱状态中的最大值,然后用A[v]去更新权值=A[v]的状态。这样时间复杂度是O((n+m) log W),其中W是权值范围(需要离散化)。这已经超出了GESP七级的范围,更接近省选/NOI的难度。
6.2 如果图不是DAG,而是有环图呢?
题目保证是有向无环图(DAG),所以我们可以用拓扑排序来保证DP的无后效性。如果图中有环,那么路径可以无限长(绕着环走),LIS长度也可能无限大吗?不一定,因为权值序列要求不下降,如果环上所有节点权值单调不降,那么一直绕环确实可以得到无限长的LIS。但如果环上存在权值下降的边,那么LIS长度可能有限。
对于有环图,求所有路径中的最长LIS是一个更困难的问题,可能需要在强连通分量缩点的基础上进行DP,或者转化为最长路等问题,复杂度会大大提高,通常不在算法竞赛的常规考察范围内。
6.3 本题与经典DP问题的联系
这道题本质上是DAG上的动态规划与最长不下降子序列问题的结合。它考察了两种基本模型的融合能力。
- DAG上的DP:通常用于解决有依赖关系、无后效性的最优化问题。拓扑排序是保证计算顺序的关键。
- LIS问题:经典的线性序列上的DP问题,有O(n²)和O(n log n)两种经典解法。
本题的巧妙之处在于,它没有让你直接求路径的权值序列的LIS(那样需要枚举路径),而是通过将LIS的“结尾权值”作为状态的一维,在DP过程中同时维护了“路径延伸”和“序列单调性”两个约束。这种“以状态记录额外信息来满足约束”的思想,在动态规划中非常常见,比如背包问题中记录体积,区间DP中记录区间信息等。
对于信奥选手来说,这道题是一个很好的训练,它要求你不只是套用模板,而是真正理解状态设计的本质,并能根据问题特点(权值范围小)设计出高效的状态表示。
