1. 从“友好城市”这道题看动态规划的区间建模思路
第一次看到“友好城市”这个标题,很多人会以为是一道图论题或者并查集题,毕竟“城市”和“友好”两个词很容易让人联想到连通性、配对关系。但真正做过这道题的人都知道,它本质上是一道披着应用题外衣的最长上升子序列问题,也就是经典的LIS模型。这个判断非常关键,因为它决定了你后面所有的建模方向——如果你往图论方向想,可能会陷入建图、找环、拓扑排序的泥潭;而一旦识别出LIS的本质,整道题的难度会瞬间下降一个档次。
这道题的核心场景是这样的:一条河将城市分为南北两岸,北岸有n个城市,南岸也有n个城市,每个北岸城市最多只能和南岸的一个城市建立友好关系,而且航线不能交叉。问最多能建立多少对友好城市关系。这个“航线不能交叉”的约束,就是整道题从普通配对问题升级为动态规划问题的关键所在。
为什么“航线不交叉”这么重要?你可以这样理解:假设北岸城市按位置从左到右编号为1到n,南岸城市也按位置从左到右编号。如果北岸的i城市连接了南岸的a城市,北岸的j城市连接了南岸的b城市,且i小于j,那么航线不交叉的条件就是a必须小于b。换句话说,北岸城市的顺序和南岸配对城市的顺序必须保持一致。这就把问题转化成了:给定一组配对关系,找出最长的子序列,使得北岸城市递增的同时南岸城市也递增。
这个转化过程是整道题最精妙的地方。很多初学者在这里卡住,是因为他们没有意识到“不交叉”这个几何约束可以等价转化为“顺序一致”的代数约束。一旦完成了这个转化,剩下的就是标准的LIS求解了。
这道题适合谁来学习?我认为它非常适合已经掌握了LIS基本解法,但还不太会做模型转化的同学。因为LIS本身的DP解法并不复杂,但“友好城市”这道题的价值在于教你如何从实际问题中抽象出LIS模型。这种建模能力,才是算法竞赛中真正拉开差距的地方。另外,这道题也适合作为区间DP和排序预处理的入门练习,因为它涉及到了排序和LIS两个知识点的结合。
2. 核心细节解析与建模要点
2.1 为什么排序是解题的第一步
拿到这道题,第一反应应该是把北岸城市按位置排序。为什么?因为题目给出的输入顺序可能是乱的,而航线不交叉的约束是建立在位置顺序上的。如果你不排序,就无法保证你处理的配对关系是按照北岸城市从左到右的顺序排列的。
具体来说,假设输入给出了若干对友好城市关系,每对包含一个北岸城市编号和一个南岸城市编号。你需要先按照北岸城市编号从小到大排序。排序之后,你得到的序列中,北岸城市是递增的,那么问题就变成了:在这个序列中,找出南岸城市编号的最长上升子序列。
这里有一个细节需要注意:题目中北岸城市和南岸城市的编号都是1到n,而且每个城市最多只能连接一个对岸城市。这意味着排序之后,北岸城市编号是严格递增的,不会出现重复。这个性质保证了我们后续LIS求解的正确性。
注意:如果题目允许一个城市连接多个对岸城市,那么排序后北岸城市编号可能出现重复,这时候就需要额外处理。但本题中每个城市最多连接一个,所以排序后北岸编号严格递增,可以直接对南岸编号求LIS。
2.2 LIS的两种解法选择
排序完成后,问题就变成了求南岸城市编号序列的最长上升子序列。LIS有两种经典解法:一种是O(n²)的动态规划,另一种是O(n log n)的贪心加二分。
对于这道题来说,n的范围通常不会特别大,O(n²)的解法完全够用。但如果你想提升自己的算法能力,我建议两种方法都实现一遍。O(n²)的解法更直观,适合理解LIS的本质;O(n log n)的解法更高效,适合在实际比赛中使用。
O(n²)解法的核心思想是:定义dp[i]为以第i个元素结尾的最长上升子序列长度。状态转移方程是dp[i] = max(dp[j]) + 1,其中j < i且a[j] < a[i]。这个方程的意思是,如果前面的某个元素比当前元素小,那么就可以接在它后面形成一个更长的上升子序列。
O(n log n)解法的核心思想是维护一个数组tails,其中tails[i]表示长度为i+1的上升子序列的最小结尾元素。遍历原序列时,对于每个元素,用二分查找在tails中找到第一个大于等于它的位置,替换掉那个位置的元素。如果找不到这样的位置,就把它追加到tails末尾。最终tails的长度就是LIS的长度。
这两种解法各有优劣。O(n²)的解法代码简单,容易调试,适合初学者;O(n log n)的解法效率高,但二分查找的边界条件容易写错,需要仔细处理。
2.3 边界条件与特殊情况的处理
在实际写代码的时候,有几个边界条件需要特别注意。
第一个是n=0的情况。虽然题目通常不会给出n=0的测试数据,但严谨的代码应该处理这种情况,直接输出0即可。
第二个是所有南岸城市编号都相同的情况。这时候LIS的长度是1,因为严格上升子序列不能包含相等的元素。如果你的代码在判断上升条件时用了小于等于,就会得到错误的结果。
第三个是南岸城市编号完全逆序的情况。这时候LIS的长度也是1,因为没有任何一对元素满足上升条件。这种情况可以用来测试你的代码是否正确处理了“不接在任何元素后面”的情况。
提示:在写LIS的O(n²)解法时,初始化dp数组全部为1,而不是0。因为每个元素本身至少可以构成一个长度为1的上升子序列。这个细节看似简单,但很多人在紧张的时候会忘记。
3. 完整实操过程与代码实现
3.1 数据读取与预处理
首先,我们需要读取输入数据。题目通常会先给出一个整数n,表示城市的数量,然后给出n行,每行两个整数,分别表示一对友好城市的北岸编号和南岸编号。
读取数据后,我们需要把这些配对存储起来,然后按照北岸城市编号排序。在C++中,可以用pair或者结构体来存储,然后用sort函数排序。在Python中,可以用列表存储元组,然后用sort方法排序。
排序之后,我们只需要提取南岸城市的编号,形成一个序列,然后对这个序列求LIS。
#include <iostream> #include <algorithm> using namespace std; const int N = 5010; pair<int, int> cities[N]; int a[N], dp[N]; int main() { int n; cin >> n; for (int i = 0; i < n; i++) { cin >> cities[i].first >> cities[i].second; } sort(cities, cities + n); for (int i = 0; i < n; i++) { a[i] = cities[i].second; } // 接下来对a数组求LIS int ans = 0; for (int i = 0; i < n; i++) { dp[i] = 1; for (int j = 0; j < i; j++) { if (a[j] < a[i]) { dp[i] = max(dp[i], dp[j] + 1); } } ans = max(ans, dp[i]); } cout << ans << endl; return 0; }上面这段代码是O(n²)的LIS解法。它的逻辑非常清晰:外层循环遍历每个元素,内层循环遍历它之前的所有元素,如果发现前面的元素比当前元素小,就尝试更新dp[i]。最后答案就是dp数组中的最大值。
3.2 O(n log n)解法的实现细节
如果你想让代码更高效,可以用O(n log n)的解法。这种解法的核心是维护一个tails数组,并且用二分查找来更新它。
#include <iostream> #include <algorithm> #include <vector> using namespace std; const int N = 5010; pair<int, int> cities[N]; int main() { int n; cin >> n; for (int i = 0; i < n; i++) { cin >> cities[i].first >> cities[i].second; } sort(cities, cities + n); vector<int> tails; for (int i = 0; i < n; i++) { int x = cities[i].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; }这段代码的关键在于lower_bound的使用。lower_bound返回的是第一个大于等于x的位置。如果找到了这样的位置,就用x替换掉它;如果没找到,说明x比tails中所有元素都大,直接追加到末尾。这样维护的tails数组始终是严格上升的,而且它的长度就是LIS的长度。
注意:这里用的是lower_bound而不是upper_bound,因为我们要找的是严格上升子序列。如果题目要求的是非严格上升子序列(允许相等),那就应该用upper_bound。这个区别非常关键,写错的话会导致答案偏大。
3.3 参数计算与复杂度分析
对于O(n²)的解法,时间复杂度是O(n²),空间复杂度是O(n)。当n=5000时,n²=25,000,000,这个计算量在现代计算机上大约需要0.1秒左右,完全可以在1秒的时间限制内完成。
对于O(n log n)的解法,时间复杂度是O(n log n),空间复杂度是O(n)。当n=5000时,n log n大约等于5000乘以12,也就是60,000次操作,比O(n²)快了三个数量级。如果n的范围扩大到10^5甚至10^6,O(n log n)的解法依然能够轻松应对。
在实际比赛中,如果题目没有明确给出n的范围,我通常会优先选择O(n log n)的解法,因为它更保险。但如果n的范围很小(比如n≤1000),用O(n²)的解法反而更不容易出错,因为二分查找的边界条件容易写错。
3.4 测试用例验证
为了验证代码的正确性,我们可以构造几个测试用例。
测试用例1:n=3,配对关系为(1,2), (2,1), (3,3)。排序后南岸序列为2,1,3。LIS为2(可以是2,3或者1,3)。正确答案是2。
测试用例2:n=4,配对关系为(1,4), (2,3), (3,2), (4,1)。排序后南岸序列为4,3,2,1。LIS为1。正确答案是1。
测试用例3:n=5,配对关系为(1,1), (2,2), (3,3), (4,4), (5,5)。排序后南岸序列为1,2,3,4,5。LIS为5。正确答案是5。
这三个测试用例覆盖了LIS的三种典型情况:部分上升、完全下降、完全上升。如果你的代码能正确输出2、1、5,那么基本逻辑就是正确的。
4. 常见问题与排查技巧实录
4.1 排序后忘记提取南岸序列
这是初学者最容易犯的错误。有些人排序之后,直接在原数组上做LIS,但原数组是pair类型的,比较的是first和second的组合,而不是单纯的second。这样得到的LIS长度可能不对。
正确的做法是:排序后,单独提取出second组成一个新的数组,然后对这个数组求LIS。这个步骤虽然简单,但在紧张的比赛环境中很容易被忽略。
4.2 二分查找的边界条件写错
在使用O(n log n)解法时,lower_bound和upper_bound的选择非常关键。如果你不小心用了upper_bound,那么当序列中存在相等元素时,你会把它们当作上升的一部分,导致LIS长度偏大。
举个例子,假设南岸序列是1,2,2,3。正确的LIS长度是3(1,2,3),但如果你用upper_bound,可能会得到4,因为你会把两个2都算进去。这个错误在测试用例中不一定能发现,因为很多题目的测试数据可能不包含重复元素。但一旦遇到重复元素,就会出错。
提示:在写二分查找的时候,建议先用小规模数据手动模拟一遍,确认边界条件正确后再提交。这个习惯能帮你省下很多调试时间。
4.3 数组大小开小了
这道题中n的范围通常是5000左右,所以数组开到5010就够了。但有些题目可能会把n的范围设得更大,比如10^5。如果你按照5000来开数组,就会导致数组越界,程序崩溃或者得到错误结果。
我的建议是:在不确定n的范围时,先看一下题目的数据范围说明。如果找不到,就按照最坏情况来开数组,比如开到10^6。多开一点空间不会影响程序的正确性,但开小了会导致严重的运行时错误。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 答案偏小 | 排序后没有提取南岸序列 | 检查LIS操作的对象 | 单独提取second组成新数组 |
| 答案偏大 | 二分查找用了upper_bound | 检查二分函数的选择 | 改用lower_bound |
| 程序崩溃 | 数组大小不足 | 检查n的范围 | 增大数组容量 |
| 答案错误 | dp数组初始化错误 | 检查dp初始值 | 全部初始化为1 |
| 部分测试点超时 | 用了O(n²)解法但n很大 | 分析时间复杂度 | 改用O(n log n)解法 |
4.5 独家避坑技巧
第一个技巧是:在排序之前,先检查输入数据中是否有重复的北岸城市编号。虽然题目通常保证每个城市最多连接一个对岸城市,但如果你不确定,可以先做一次去重或者检查。这个步骤能帮你避免一些奇怪的边界情况。
第二个技巧是:在写O(n log n)解法时,可以用一个辅助函数来封装二分查找的逻辑,这样主循环会更清晰,也更容易调试。比如你可以写一个int findPosition(vector<int>& tails, int x)函数,专门负责返回第一个大于等于x的位置。
第三个技巧是:在提交之前,用随机数据生成器生成几组小规模测试数据,然后用暴力解法(比如枚举所有子序列)来验证你的答案。这种方法虽然麻烦,但能发现很多隐藏的逻辑错误。特别是对于LIS这种边界条件较多的算法,暴力验证非常有必要。
第四个技巧是:如果你在比赛中遇到了类似的“配对+不交叉”问题,可以先尝试把问题转化为LIS模型。转化的关键是找到两个维度上的单调性约束。在这道题中,北岸位置和南岸位置就是两个维度,不交叉要求它们同向单调。这种建模思路在区间调度、任务安排等问题中也非常常见。
5. 从这道题延伸出的算法思维
5.1 区间建模与偏序关系
“友好城市”这道题的本质是二维偏序问题。每个友好城市对可以看作一个点(x, y),其中x是北岸位置,y是南岸位置。航线不交叉的条件等价于:选出的点集中,x和y都严格递增。这就是一个典型的二维偏序最长链问题。
对于二维偏序问题,通用的解法是:先按一维排序,然后在另一维上求LIS。这个思路可以推广到很多类似的问题中。比如“俄罗斯套娃信封”问题,就是先按宽度排序,然后在高度上求LIS。再比如“最长数对链”问题,也是类似的思路。
掌握了这个建模方法,你就能解决一大类看起来不同但本质相同的问题。这种举一反三的能力,比单纯会做一道题重要得多。
5.2 排序预处理的作用
在这道题中,排序预处理起到了降维的作用。原本问题有两个维度:北岸位置和南岸位置。通过排序,我们把北岸位置这个维度固定住了(变成了递增的顺序),然后只需要在南岸位置上求LIS。这样就把二维问题降成了一维问题。
这种“排序降维”的技巧在算法竞赛中非常常见。比如在计算几何中,我们经常先按x坐标排序,然后处理y坐标;在区间问题中,我们经常先按左端点排序,然后处理右端点。排序的本质是引入一个顺序,让我们可以按照这个顺序逐步处理问题,而不需要考虑所有可能的组合。
5.3 动态规划与贪心的选择
LIS问题有两种解法:动态规划和贪心加二分。这两种解法代表了两种不同的算法思维。
动态规划的思路是:考虑所有可能的子问题,然后从中选择最优的。这种思路的优点是直观、容易理解,缺点是效率可能不高。在LIS问题中,O(n²)的DP解法就是这种思路的体现。
贪心加二分的思路是:维护一个当前最优的状态,然后逐步更新它。这种思路的优点是效率高,缺点是不太直观,需要证明贪心策略的正确性。在LIS问题中,O(n log n)的解法就是这种思路的体现。
在实际比赛中,如果时间允许,我通常会先写一个O(n²)的DP解法,确保逻辑正确,然后再尝试优化成O(n log n)。这样既能保证正确性,又能提升效率。如果时间紧张,就直接写O(n log n)的解法,但一定要仔细检查二分查找的边界条件。
5.4 实际应用场景
虽然“友好城市”是一道算法题,但它背后的建模思路在实际中也有应用。比如在物流配送中,我们需要把货物从多个仓库运送到多个目的地,要求路线不交叉,这就是一个类似的问题。再比如在电路板设计中,我们需要把多个引脚连接起来,要求连线不交叉,这也是一个类似的问题。
这些实际问题的共同特点是:有两个维度的约束,而且这两个维度之间存在单调性关系。一旦识别出这种结构,就可以用排序加LIS的方法来求解。所以,学好这道题不仅仅是为了应付比赛,更是为了培养一种解决实际问题的建模能力。
我在实际做题的时候发现,很多看起来复杂的问题,一旦找到了正确的建模方向,就会变得非常简单。而找到正确方向的关键,就是多做题、多总结、多思考。每做完一道题,我都会问自己:这道题的核心模型是什么?这个模型还能解决哪些类似的问题?这种反思习惯,比单纯刷题的数量更重要。