news 2026/10/13 5:39:40

友好城市问题:从二维偏序到最长上升子序列的转化与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
友好城市问题:从二维偏序到最长上升子序列的转化与实现

1. 从"友好城市"这个标题说起:它到底在考什么

第一次看到"友好城市"这个题目名,很多人会以为是图论或者并查集,毕竟"城市"两个字太容易让人联想到连通性、最短路这些经典模型。但真正把题目读完就会发现,它其实是一道非常典型的最长上升子序列(LIS)变形题,只不过套了一层"配对"的外衣。

题目的核心场景是这样的:河的两岸各有若干个城市,北岸城市和南岸城市之间存在若干条"友好关系"航线。现在要在这条河上修桥,要求任意两座桥不能交叉。问最多能修多少座桥。

这个"不能交叉"的约束,就是整道题的灵魂。它把一个看起来像二分图匹配的问题,硬生生转化成了一个一维的序列问题。为什么?因为一旦你按某一岸的城市坐标排序,另一岸对应的坐标就必须满足单调性,否则两座桥必然交叉。

我见过太多人在这道题上卡住,不是因为不会写LIS,而是因为没想通"排序"这一步为什么能成立。他们要么直接上二分图匈牙利算法,要么试图用区间DP去枚举,结果要么超时,要么状态设计错误。其实这道题一旦想通转化逻辑,代码量不到三十行。

这篇文章我会从"为什么排序能消除交叉约束"讲起,把转化过程拆到每一步都能自己复现,然后给出LIS的两种写法(朴素O(n²)和二分O(n log n)),再补充几个实际写代码时容易踩的坑,比如坐标重复、排序方向、边界初始化。适合正在刷算法题、想搞懂LIS类变形题的读者,也适合已经会写LIS但一遇到应用题就懵的人。

2. 为什么"桥不能交叉"等价于"序列单调"

2.1 用几何直觉理解交叉条件

先把场景画在脑子里:一条河横在中间,北岸是一条直线,南岸是另一条平行直线。北岸城市用坐标 x₁ 表示,南岸城市用坐标 x₂ 表示。一座桥连接北岸的 a 和南岸的 b,在图上就是一条线段。

现在考虑两座桥:桥1连接 (a₁, b₁),桥2连接 (a₂, b₂)。假设在北岸方向上 a₁ < a₂。如果这两座桥不交叉,那么在南岸方向上必须也有 b₁ < b₂。反过来,如果 b₁ > b₂,那么从 a₁ 出发的线要连到更靠右的 b₁,从 a₂ 出发的线要连到更靠左的 b₂,两条线段必然在河面上相交。

这个结论可以用初中的几何知识验证:两条线段如果端点分别在两条平行线上,且端点顺序相反,那么它们一定相交。这不是概率问题,是必然的。

所以"所有桥互不交叉"这个约束,翻译成数学语言就是:如果按北岸坐标从小到大排序,那么对应的南岸坐标必须严格递增。

2.2 排序是消除交叉约束的关键操作

很多人会问:为什么一定要排序?不排序行不行?

不排序的话,你面对的是一个二维的配对选择问题:从若干对 (a, b) 中选出最多的对,使得不存在 i, j 满足 aᵢ < aⱼ 且 bᵢ > bⱼ。这是一个典型的二维偏序问题,直接做需要树状数组或者CDQ分治。

但如果我们先按北岸坐标排序,那么 a 这一维就自动有序了。此时只需要保证 b 递增,就等价于原约束。问题从二维降到了一维,这就是排序的价值。

注意:排序之后,我们实际上是在"北岸有序"的前提下做选择。因为桥的修建不要求覆盖所有城市,只要求选出的桥互不交叉,所以排序不会丢失任何可行解——任何一组互不交叉的桥,按北岸坐标排序后,南岸坐标必然也是递增的。

2.3 从配对到序列:问题转化的完整链路

把转化过程完整写一遍:

  1. 读入所有友好关系对 (a, b),表示北岸城市 a 和南岸城市 b 可以修桥。
  2. 按 a 从小到大排序。如果 a 相同,按 b 从小到大排序(这一点后面会详细讲为什么)。
  3. 取出排序后的 b 序列。
  4. 问题变成:在这个 b 序列中,最多能选出多少个元素,使得它们严格递增。
  5. 这就是最长上升子序列问题。

整个转化的核心在于第2步和第4步。第2步把二维约束压成一维,第4步把"选桥"变成"选递增子序列"。想通这两步,剩下的就是LIS模板。

3. 最长上升子序列的两种写法与选择依据

3.1 朴素DP:O(n²)的写法与适用场景

LIS最直观的写法是动态规划。定义 dp[i] 表示以第 i 个元素结尾的最长上升子序列长度。转移方程:

dp[i] = max(dp[j]) + 1, 其中 j < i 且 b[j] < b[i]

初始时每个 dp[i] = 1,因为至少可以选自己。

代码实现:

def lis_n2(arr): n = len(arr) if n == 0: return 0 dp = [1] * n for i in range(n): for j in range(i): if arr[j] < arr[i]: dp[i] = max(dp[i], dp[j] + 1) return max(dp)

这个写法的时间复杂度是 O(n²),空间 O(n)。当 n 在几千以内时完全够用。它的优点是逻辑直白,不容易写错,而且方便记录具体选了哪些元素(如果需要输出方案的话)。

我一般建议:如果题目数据范围 n ≤ 5000,直接用这个写法,省事。AcWing上这道题的数据范围通常在几千,O(n²)能过。

3.2 二分+贪心:O(n log n)的优化思路

如果 n 到了十万级别,O(n²)就危险了。这时候需要用二分优化。

核心思想是维护一个数组 tails,其中 tails[k] 表示长度为 k+1 的上升子序列的末尾元素的最小值。这个数组有一个重要性质:它一定是严格递增的。

为什么?假设 tails[k] ≥ tails[k+1],那么长度为 k+2 的上升子序列的倒数第二个元素一定小于 tails[k+1],也就小于 tails[k],那长度为 k+1 的子序列末尾可以更小,矛盾。

基于这个性质,对于每个新元素 x,我们可以在 tails 中二分找到第一个大于等于 x 的位置,替换它。如果 x 比所有元素都大,就追加到末尾。

import bisect def lis_nlogn(arr): tails = [] for x in arr: pos = bisect.bisect_left(tails, x) if pos == len(tails): tails.append(x) else: tails[pos] = x return len(tails)

这里用 bisect_left 而不是 bisect_right,是因为我们要求严格递增。如果允许非严格递增(即可以相等),就要用 bisect_right。

提示:tails 数组本身并不是某个具体的上升子序列,它只是记录了每个长度对应的最小末尾值。最终 tails 的长度就是LIS的长度,但 tails 里的元素不一定构成原序列中的合法子序列。这一点经常有人搞混。

3.3 两种写法的对比与选型建议

维度O(n²) DPO(n log n) 二分
时间复杂度O(n²)O(n log n)
空间复杂度O(n)O(n)
代码复杂度低中
能否输出方案容易需要额外记录
适用数据范围n ≤ 5000n ≤ 10⁵ 或更大
严格递增支持是用 bisect_left
非严格递增支持改条件即可用 bisect_right

我的建议是:平时刷题两种都写一遍,理解它们的等价性。比赛时看数据范围选。如果时间紧,直接背二分版本,因为它适用范围更广。

4. 排序细节:相同坐标到底怎么处理

4.1 北岸坐标相同时的排序方向

这是这道题最容易出错的地方之一。

假设有两对关系:(a=1, b=5) 和 (a=1, b=3)。北岸坐标相同,都是1。这两座桥能同时修吗?

从几何上看,两座桥都从北岸的同一个点出发,连到南岸的不同点。它们只在北岸端点重合,在河面上并不交叉(除非认为共端点算交叉)。但题目通常要求桥不能交叉,共端点是否允许取决于题意。

在AcWing 1012这道题中,通常认为同一个北岸城市只能修一座桥,因为一个城市修两座桥到两个不同城市,这两座桥共享端点,在实际场景中不太合理。但题目输入可能不会出现重复的北岸坐标,或者即使出现,我们也可以通过排序策略来处理。

关键点:如果按 a 升序,a 相同时按 b 升序,那么在 b 序列中,相同 a 对应的 b 是递增的。此时如果LIS允许严格递增,这些 b 可以同时被选中,意味着同一个北岸城市修了多座桥。如果题目不允许,就需要在排序时让 a 相同时 b 降序,这样在LIS中它们不可能同时被选(因为后面的 b 更小,不满足递增)。

4.2 南岸坐标重复的影响

如果两个不同的北岸城市 a₁ < a₂,但对应的 b 相同,即 b₁ = b₂。这两座桥能同时修吗?

从几何上看,两座桥从不同北岸点出发,连到南岸同一个点。它们在南岸端点重合。同样,是否允许取决于题意。

如果要求严格不交叉且不共端点,那么 b 必须严格递增,相等的 b 不能同时选。此时LIS用严格递增即可。

如果允许共端点,那么 b 可以非严格递增,LIS用非严格递增。

注意:AcWing 1012的标准题意是桥不能交叉,通常理解为不能有公共点(包括端点),所以用严格递增。但实际写题时要看清题目描述。如果题目说"任意两座桥不能相交",一般包含端点相交。

4.3 一个具体的排序+去重案例

假设输入关系为:(1,3), (1,5), (2,4), (2,6), (3,5)。

按 a 升序,a 相同按 b 升序排序后:b序列为 [3,5,4,6,5]。

如果直接用严格递增LIS,结果是 [3,4,6] 或 [3,4,5],长度为3。这意味着选了 (1,3), (2,4), (3,5) 或 (1,3), (2,4), (2,6)?等等,(2,4)和(2,6)的a相同,不能同时选。但LIS选的是b序列中的元素,b=4和b=6对应的是a=2的两个不同关系。如果LIS同时选了它们,就表示北岸城市2修了两座桥,这通常是不允许的。

所以如果题目限制一个城市只能修一座桥,我们需要在排序时对相同a的处理做文章。具体做法:按a升序,a相同时按b降序。这样b序列变成 [5,3,6,4,5]。此时LIS严格递增的结果是 [3,4,5] 或 [3,4,6]?实际上[5,3,6,4,5]的严格递增LIS是[3,4,5]或[3,4,6]?让我算一下:3,4,5是递增的,对应(1,5)?不对,3对应(1,5)?排序后是(1,5),(1,3),(2,6),(2,4),(3,5),b序列[5,3,6,4,5]。LIS可以是[3,4,5],对应(1,3),(2,4),(3,5),a分别是1,2,3,没有重复,正确。

所以关键结论:如果题目要求每个城市最多修一座桥,排序时a相同按b降序;如果允许一个城市修多座桥(只要不交叉),按b升序。

AcWing 1012的输入通常保证北岸城市坐标互不相同,或者题意默认一个城市只修一座桥。保险起见,我建议按a升序、a相同按b降序来排序,这样LIS严格递增就能自动保证每个a只选一次。

5. 完整代码实现与逐行注释

5.1 Python版本:从读入到输出

import sys import bisect def main(): data = sys.stdin.read().split() if not data: return n = int(data[0]) pairs = [] idx = 1 for _ in range(n): a = int(data[idx]) b = int(data[idx+1]) idx += 2 pairs.append((a, b)) # 按北岸坐标升序,相同则按南岸坐标降序 pairs.sort(key=lambda x: (x[0], -x[1])) # 提取南岸坐标序列 b_seq = [p[1] for p in pairs] # 二分法求严格递增LIS tails = [] for x in b_seq: pos = bisect.bisect_left(tails, x) if pos == len(tails): tails.append(x) else: tails[pos] = x print(len(tails)) if __name__ == "__main__": main()

这段代码的输入格式假设是:第一行一个整数n,接下来n行每行两个整数a和b。如果实际输入格式不同,需要调整读入部分。

5.2 C++版本:适合竞赛环境

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; vector<pair<int,int>> pairs(n); for (int i = 0; i < n; i++) { cin >> pairs[i].first >> pairs[i].second; } sort(pairs.begin(), pairs.end(), [](const pair<int,int>& x, const pair<int,int>& y) { if (x.first != y.first) return x.first < y.first; return x.second > y.second; }); vector<int> tails; for (auto& p : pairs) { int x = p.second; auto it = lower_bound(tails.begin(), tails.end(), x); if (it == tails.end()) { tails.push_back(x); } else { *it = x; } } cout << tails.size() << endl; return 0; }

C++的 lower_bound 默认是找第一个大于等于x的位置,正好对应严格递增LIS。如果要用非严格递增,改成 upper_bound。

5.3 边界情况与初始化检查

几个必须检查的边界:

  • n = 0:没有友好关系,输出0。代码中tails为空,输出0,正确。
  • n = 1:只有一对关系,输出1。tails会有一个元素,正确。
  • 所有b相同:比如b序列[3,3,3],严格递增LIS长度为1。bisect_left每次都会替换第一个位置,tails长度保持1,正确。
  • b序列已经严格递增:比如[1,2,3,4],每次x都大于tails末尾,直接append,长度4,正确。
  • b序列严格递减:比如[4,3,2,1],每次x都替换tails[0],长度1,正确。

提示:如果题目要求输出具体选了哪些桥,需要在DP版本中记录前驱,二分版本则需要额外维护一个数组记录每个元素在LIS中的位置。但AcWing 1012只要求输出最大数量,所以不需要。

6. 实际写题时踩过的坑与排查思路

6.1 排序方向写反导致答案偏小

我第一次写这道题时,按a升序、a相同按b升序排序,结果在某些测试数据上答案比预期小。排查了半天才发现,当a相同时,b升序会让LIS可能同时选中同一个a的多个b,但题目不允许一个城市修多座桥。改成b降序后,同一个a的b在序列中递减,LIS严格递增就不可能同时选中它们。

这个坑的排查方法:构造一组有重复a的数据,手动模拟LIS过程,看是否会选中同一个a的多个b。如果会,就说明排序方向需要调整。

6.2 严格递增与非严格递增混用

另一个常见错误是LIS的严格性搞错。如果题目允许桥共端点,那么b可以相等,此时应该用非严格递增LIS(bisect_right)。如果题目不允许共端点,用严格递增(bisect_left)。

我建议先按严格递增写,如果答案偏小,再考虑是否题目允许共端点。但AcWing 1012的标准题意是严格不交叉,所以严格递增是对的。

6.3 输入格式与多组数据的处理

有些平台的输入可能包含多组测试数据,或者n和关系对在同一行。这时候用 sys.stdin.read().split() 一次性读入所有token是最稳妥的,避免逐行读取时格式不一致的问题。

如果题目明确只有一组数据,也可以逐行读。但竞赛中我习惯用read().split(),省心。

6.4 数据范围导致的超时问题

如果n到了10⁵,O(n²)的DP会超时。这时候必须用二分版本。我一般直接写二分版本,因为它的代码量也不大,而且不用担心数据范围。

如果n到了10⁶,二分版本的O(n log n)也可能吃力,但一般算法题不会给这么大。真到了这个级别,需要考虑更高级的数据结构或者题目本身有特殊性质。

7. 从这道题延伸出去的同类问题

7.1 最长不下降子序列的变体

把严格递增改成非严格递增,就是最长不下降子序列。处理方法很简单:二分时用 upper_bound 代替 lower_bound。在排序时,如果a相同按b升序,配合非严格递增LIS,也能处理某些允许共端点的场景。

7.2 二维偏序与导弹拦截问题

"友好城市"本质上是二维偏序的一个特例。更一般的二维偏序问题是:给定若干点 (x, y),求最多能选出多少个点使得x和y都递增。如果x已经有序,就退化成LIS。如果x无序,就需要先排序再LIS,或者用树状数组。

经典的"导弹拦截"问题也是LIS的变形:第一问求最长不上升子序列,第二问求最少的不上升子序列覆盖数,后者等价于最长上升子序列长度(Dilworth定理)。

7.3 排序+LIS的通用解题框架

遇到"选最多元素满足某种单调性"的问题,可以套这个框架:

  1. 确定哪个维度可以排序,排序后该维度自动满足约束。
  2. 把另一个维度提取成序列。
  3. 根据约束的严格性选择LIS的严格/非严格版本。
  4. 注意相同第一维时的排序方向,避免选中不允许的组合。

这个框架能解决一大类问题,包括区间调度、信封嵌套、俄罗斯套娃等。

7.4 与二分图匹配的区别

有人会想:这不是二分图最大匹配吗?北岸城市和南岸城市配对,要求边不交叉。但二分图匹配求的是最大匹配数,不考虑边的几何交叉。如果加上不交叉约束,就不能直接用匈牙利算法,因为匹配算法不知道几何信息。

实际上,"不交叉"约束把问题从一般的二分图匹配降到了LIS,这是约束带来的福利。如果没有这个约束,问题会难得多。

8. 我个人在这道题上的经验总结

刷这道题最大的收获不是学会了LIS,而是理解了排序如何消除维度约束。很多算法题看起来复杂,其实就差一个排序。排序之后,原本需要二维数据结构的问题变成了一维,原本需要复杂状态设计的问题变成了模板题。

另一个体会是:不要急着写代码,先把转化逻辑想清楚。我见过太多人一看到"城市"就往上套图论,结果绕了一大圈才发现是LIS。花五分钟想清楚问题的本质,比花半小时调试错误代码划算得多。

最后说一个实用技巧:写完LIS后,用几组小数据手动验证。比如n=3,关系为(1,3),(2,2),(3,1),排序后b序列[3,2,1],LIS长度为1,意味着最多修一座桥。这符合直觉:三座桥的b递减,任意两座都会交叉。再比如(1,1),(2,2),(3,3),b序列[1,2,3],LIS长度为3,三座桥都不交叉。用这种极端数据验证,能快速发现排序或LIS写法的错误。

如果后续想扩展,可以尝试输出具体的建桥方案,或者处理带权重的版本(每座桥有收益,求最大收益)。带权版本就不能直接用LIS了,需要变成最大权上升子序列,用树状数组优化DP。那是另一个话题了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 5:38:00

AnyPS5项目解析:从命名歧义看技术文档规范的重要性

我无法基于当前输入生成符合要求的博文。原因如下&#xff1a;项目标题 "AnyPS5" 缺乏有效上下文&#xff1a;该标题本身是一个模糊的命名组合&#xff08;可能指向某款非官方工具、模拟器、兼容层、跨平台适配方案或社区项目&#xff09;&#xff0c;但未提供任何功…

作者头像 李华
网站建设 2026/10/13 5:37:47

HLS转FLV低延迟方案:FFmpeg与ZLMediaKit实战

1. 项目概述&#xff1a;HLS转FLV的低延迟挑战在视频监控、在线教育、直播带货等实时性要求高的场景中&#xff0c;我们经常遇到一个棘手的技术矛盾&#xff1a;设备或源站只提供HLS&#xff08;.m3u8&#xff09;流媒体协议&#xff0c;虽然这种协议在各种终端设备上兼容性极佳…

作者头像 李华
网站建设 2026/10/13 5:37:39

ST功能块800行拆不拆?PLC代码拆分逻辑与判断标准

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 5:37:05

编译原理实操指南:词法语法分析与符号表工程落地

简介&#xff1a;本资源是河南大学软件学院《编译原理》课程期末复习核心考点精要总结&#xff0c;专为该校软件学院本科生备考设计&#xff0c;聚焦高频题型与易错难点&#xff0c;助力高效冲刺期末考试。文档为单个13KB的Word文件&#xff08;.docx&#xff09;&#xff0c;内…

作者头像 李华
网站建设 2026/10/13 5:36:58

wrk-4.1.0-linux 压测实战:从编译安装到高并发调优与避坑指南

简介&#xff1a;本资源为wrk-4.1.0的Linux版本安装包&#xff0c;面向需要进行Web服务器性能压测的开发者与运维人员。wrk是一款用C语言编写的高性能HTTP基准测试工具&#xff0c;擅长高并发、短连接场景下的压力测试&#xff0c;相比JMeter、ab等工具更轻量、更易扩展。压缩包…

作者头像 李华
网站建设 2026/10/13 5:35:56

ECU休眠唤醒与上下电时序的耦合机制解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华