ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

DAG上最长不下降子序列:结合图论与动态规划的GESP七级精讲

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算法(维护一个单调数组dd[i]表示长度为i的上升子序列末尾元素的最小值)是针对一个给定的、确定的线性序列。而本题中,序列本身是不确定的,它依赖于我们在DAG上选择的路径。路径的选取有极大的灵活性,这带来了两个核心难点:

  1. 路径的起点和终点不固定:你可以从任何一个入度为0的节点(如果没有,也可以从任意节点)开始,在任何节点结束。这意味着序列的开头和结尾是自由的。
  2. 路径的权值序列不是任意的:虽然起点终点自由,但序列的相邻元素必须对应图中一条有向边。你不能随意拼凑一个权值序列,它必须是一条实际存在的路径。

因此,我们不能对一个静态数组做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有两种可能:

  1. 不将A[v]纳入LIS:那么以v结尾的路径,其LIS可以直接继承来自u的某个最优LIS,前提是这个LIS的结尾权值y能够“兼容”未来的扩展(但在这个状态定义下,我们只关心结尾)。实际上,更准确的思考是,对于v的某个结尾权值x,如果x不等于A[v],那么这个LIS肯定不包含A[v],它只能从u的、结尾权值也为x的状态转移过来,并且要求(u, v)这条边存在。但这样思考有点绕。

  2. 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。所以我们需要初始化:对于所有节点vdp[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]只能从uf[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)

  1. 继承转移:对于x = 1 to 10dp[v][x] = max(dp[v][x], dp[u][x])。这表示不把A[v]加入LIS,直接从u继承以x结尾的LIS。
  2. 新增转移:对于所有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。

初始化:对于所有节点vdp[v][A[v]] = 1(至少可以以自己开头)。

答案:遍历所有节点v和所有权值x,取dp[v][x]的最大值。

2.4 算法流程与复杂度分析

由于图是DAG,我们需要按照拓扑序来递推我们的DP状态,这样才能保证在计算节点v时,所有能到达v的前驱节点u都已经被计算完毕。

  1. 拓扑排序:使用队列进行Kahn算法,得到节点的拓扑序列。
  2. DP初始化:创建dp[n+1][11]数组(下标从1开始),所有元素初始为0。对于每个节点i,令dp[i][A[i]] = 1
  3. 按拓扑序DP:按顺序处理拓扑序列中的每个节点u。遍历u的所有出边(u, v)。对于每条出边:
    • 先进行“继承转移”:将dp[u][x]的值尝试更新dp[v][x](对所有x)。
    • 再进行“新增转移”:对于所有y从1到A[v],用dp[u][y] + 1尝试更新dp[v][A[v]]
  4. 收集答案:在所有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; }

代码关键点解析:

  1. 数据结构选择

    • vector<int> graph[MAXN]:使用邻接表存储稀疏图,比邻接矩阵更省空间。
    • inDegree[MAXN]:记录每个节点的入度,用于Kahn拓扑排序。
    • dp[MAXN][MAXV]:核心DP数组。第二维大小设为11(索引0-10,我们只用1-10),因为权值最大为10。
    • premax[MAXN][MAXV]:前缀最大值数组,用于优化“新增转移”中求max(dp[u][y])的过程。
  2. 初始化

    • dp数组全部初始化为0是合理的,因为任何状态的最小值就是0(表示不存在这样的路径)。
    • 对于每个节点idp[i][A[i]] = 1是基础状态,代表路径只包含节点i自身。
  3. 拓扑排序与DP的结合

    • 我们使用队列进行拓扑排序。将初始时所有入度为0的节点入队。
    • 在从队列中取出节点u后,先计算upremax数组。这一步至关重要,必须在对u的出边进行转移前完成,因为premax是基于u当前已计算好的dp[u][x]值。
    • 然后遍历u的每个后继v,进行两类转移。
  4. 转移的细节

    • 继承转移for (int x = 1; x <= 10; ++x) dp[v][x] = max(dp[v][x], dp[u][x])。这行代码的含义是,对于v来说,所有以u结尾的路径,都可以通过走(u,v)这条边,将路径延伸到v。延伸后,路径的LIS结尾权值x保持不变,长度也保持不变。所以vdp[v][x]可以继承udp[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]]
    • 注意,这两类转移是独立都需要执行的。不能只做其中一个。
  5. 拓扑排序的推进

    • 在更新完节点v的所有入边(实际代码中是在处理u的出边时更新v)后,将v的入度减1。当v的入度变为0时,说明所有能到达v的前驱节点都已被处理,此时vdp值已经达到了当前阶段的最大可能值(在DAG上就是最终值),可以将其入队,用于更新它的后继。
  6. 答案收集

    • 最终答案存在于所有节点的所有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 常见边界情况

  1. 单节点图(n=1, m=0)

    • 输入:1 05
    • 输出应为1。因为只有一条路径(节点1),序列为[5],LIS就是[5]。
    • 检查点:初始化dp[1][5]=1是否生效,答案收集是否能找到这个1。
  2. 链状图(题目子任务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在简单拓扑序(就是节点顺序)下的转移是否正确。
  3. 多分支与汇合点

    • 输入: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)的状态。
  4. 权值全部相同

    • 输入:3 27 7 71 22 3
    • 序列为[7,7,7]。不下降子序列就是整个序列,长度为3。
    • 检查点:测试“不下降”()条件在相等权值时的处理。
  5. 权值范围很小但图复杂

    • 可以构造一个随机DAG,n和m接近1e5,权值在1-10随机。用我们的程序和小规模暴力程序(枚举所有路径,仅适用于n很小的情况)对拍,验证正确性。

4.2 性能边界测试

题目数据范围是n, m ≤ 1e5。我们需要确保算法在极限数据下不会超时或超内存。

  • 时间复杂度:我们的算法是O((n+m)*10),即约1e6量级的操作,在C++中非常轻松。
  • 空间复杂度dppremax数组都是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的节点都已经被处理过了,udp值已经确定。

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]得到(假设uv的前驱)。如果只做新增转移,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 调试技巧

当程序结果不对时,可以按以下步骤排查:

  1. 小数据手工模拟:用第4节提到的链状图或多分支图,在纸上画出每个节点的dp数组,手动模拟算法的执行过程,与程序输出对比。
  2. 打印中间状态:在拓扑排序和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值的变化,可以定位是哪个节点的计算出了问题。
  3. 对拍:写一个暴力程序(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中记录区间信息等。

对于信奥选手来说,这道题是一个很好的训练,它要求你不只是套用模板,而是真正理解状态设计的本质,并能根据问题特点(权值范围小)设计出高效的状态表示。

返回列表