1. 题目到底在讲什么
UVa 13276 这道题,题面借了动画电影《Megamind》的名义,讲的是大反派面前有一串编号从 1 到 N 的卡片,初始顺序完全乱掉了。他每次可以挑出任意一张卡片,把它插回任意位置,包括开头和结尾。目标是用最少的操作次数把这串卡片排成严格递增的序列排序。
仔细想想这个操作有多“霸道”:普通排序题里你通常只能交换相邻元素,或者像插入排序一样把某个元素往前挪一位,但这里允许你一次性把任意元素直接扔到任意位置,相当于一次操作就能跨越任意多个元素。这样强力的操作下,最少到底需要几步?我第一次看到这题,直觉是“模拟一下,每次把当前最小的没放对位置的元素拿出来放到前面”。这个思路听上去合理,但很快就被反例打脸了。
举个很简单的例子:序列[3, 1, 2]。如果照贪心的思路,先把最小的 1 挪到开头,得到[1, 3, 2],然后还得挪 2,总共两次。但如果你直接把 3 挪到最后,一次操作就得到[1, 2, 3]。所以贪心根本不管用,问题一下变得有意思了。
本题的数据范围我记得 N 可以到 100000 左右,这意味着 O(N^2) 的解法在大数据下必挂,必须想一个更聪明的数学结构。很多人拿到这题会试着直接构造操作方案,这其实走偏了。正确的方向是先想清楚:在最终排好的序列里,有哪些元素是可以“完全不动”的?动得越少,总操作次数就越少,而“不动”的元素必然已经满足某种顺序条件。这样,问题就从“怎么移动”变成了“找最长的合法不动子序列”,一下子转到我们熟悉的动态规划领域了。
2. 从“移动元素”到“最长上升子序列”:核心转换
我们先认真分析一下“不动”这个词的精确含义。
假设最终序列是[1, 2, 3, ..., N]。如果你从头到尾都没有碰过某几个元素,那它们在原始序列里的相对位置关系就必须和最终顺序一致。什么意思?如果数值小的元素原本在数值大的元素后面,你又不移动它们,那排序完成后它们还是这个顺序,肯定不正确。所以所有不动的元素,按它们在原始序列出现的位置来看,数值必须是严格递增的。
这就是最长上升子序列(LIS)的定义:在一个序列中找一个长度最长的子序列,使得子序列中元素的数值严格递增,同时它们在原序列中的位置也是递增的。举个例子,原序列[1, 3, 2, 4],它的 LIS 可以是[1, 3, 4],也可以是[1, 2, 4],长度都是 3。那答案是不是就是N - LIS长度 = 4 - 3 = 1?验证一下:把 3 和 2 中的 2 挪到 3 前面,得到[1, 2, 3, 4],确实一次操作就够了。
这里有一个特别容易让人绕进去的误区:很多初学者会以为留下的元素数值必须连续,比如只能留[2, 3, 4]这样连号的片段。我一开始也这么以为,后来发现完全错误。看一个例子:[1, 2, 4, 3],LIS 是[1, 2, 4],三个元素分别是 1、2、4,中间缺了个 3。按照我们的公式,答案应该是 1。实际操作也很简单:把 3 从末尾拿出来,插到 2 和 4 之间,就变成了[1, 2, 3, 4]。注意,留下来的 1、2、4 一次都没动过,而缺的那个 3 是靠一次移动补上的。所以你不需要留下“数值连续”的子序列,只需要留下一个普通的上升子序列,剩下的缺口全部靠移动来填。这个认知如果不纠正,后面的推导全都是歪的。
再说得直白一点:你留下的元素相当于一个“骨架”,它们本来就已经排对了相对顺序。其他元素就像积木一样,被抽出来往骨架的缝隙里插。骨架越长,需要插的积木就越少。所以“尽可能让骨架长”就是我们要做的事,而“最长骨架”就是 LIS。
3. 严格证明:N - LIS 就是答案
光有直觉还不够,我们得验证两个方向:能不能做到 N - LIS 次,以及是不是至少需要这么多。
首先证明“上界”,也就是存在一种方案,只用 N - LIS 次操作就完成排序。假设我们已经找到了一个长度为 L 的 LIS,把它作为稳定框架。剩下 N - L 个元素,我们从左到右依次处理。每次取出一个不属于 LIS 的元素,直接插到它最终该在的位置。因为 LIS 内部的相对顺序本来就是正确的,插入其他元素不会干扰它们的相对位置,所以这个操作过程是安全且可复现的。每处理一个元素只花一次操作,总共就是 N - L 次。这个构造方法非常直观,代码里甚至不需要真的去模拟,因为你只是需要证明解的存在性。
然后证明“下界”,也就是没有任何方案能用少于 N - L 次完成排序。假如你总共只做了 k 次操作,那么就至少有 N - k 个元素从头到尾没动过。没动过的元素必须构成一个严格上升子序列,否则最终序列不可能是递增的。所以没动过的元素数量最多不超过最长上升子序列长度 L,也就是说 N - k ≤ L。把不等式调一下,得到 k ≥ N - L。这就证明了任何方案都至少需要 N - L 次操作。
上界和下界碰到一起,答案就锤死了:最小值恰好等于 N 减去 LIS 长度。这个证明的优雅之处在于,它压根不用关心你具体怎么移动那些非 LIS 元素,只需要抓住“没动过的东西必须有序”这个铁律。做题时只要把证明链条写清楚,哪怕代码写挂了,思路方向也不会错。
再额外验证几个刁钻的例子。[2, 1, 3, 4]的 LIS 是[1, 3, 4]或[2, 3, 4],长度 3,答案为 1,移动 2 到开头即完成。[4, 3, 2, 1]的 LIS 长度是 1(任意单个元素),答案为 3,事实也的确如此,你得把三个元素依次挪到正确位置才行。[1, 2, 3, 4]已经有序,LIS 就是整个序列,长度为 4,答案为 0,什么都不用做。这些例子都能对上。
4. 高效计算 LIS:二分查找法
现在问题的焦点变成了:给定一个长度为 N 的排列,怎么快速求出它的 LIS 长度?N 如果只有几百,可以直接交给 O(N^2) 的动态规划:定义dp[i]为以第 i 个元素结尾的 LIS 长度,转移时扫一遍前面所有元素,看能不能接到后面。这个写法简单但慢,在大数据下必然 TLE。
4.1 贪心 + 二分:为什么能成立
于是搬出经典 O(N log N) 做法。核心是一个辅助数组d,d[k]表示“当前已经处理的元素中,长度为 k 的上升子序列的最小末尾值”。为什么只保留最小末尾值就够?因为对于同样长度的子序列,末尾越小,后面接到更大数字的可能性就越大。这个道理跟买股票类似:同样的仓位,成本越低越好。d数组一定严格递增,这一点可以严格归纳证明。
遍历原序列的每一个数x,在d里用二分查找找到第一个大于等于x的位置p。如果p不存在(即x比d里所有数都大),说明x可以接在最长的子序列后面,形成一个新的更长子序列,于是把x追加到d末尾。否则,用x替换d[p],表示我们找到了一个末尾更小、同样长度的上升子序列。最后d的长度就是 LIS 长度。
这里有一点要注意:题目给的序列是排列,没有重复数字,所以严格上升子序列和直接找“第一个大于等于”的位置是等价的。如果你以后在其他题目里碰到允许重复的情况,求“非降子序列”就需要改用upper_bound,否则会把相等的元素误当成可以“覆盖”的对象,导致长度算不准。这是我踩过的真实坑,后面专门写一节提醒大家。
4.2 代码实现(C++)
我平时刷 UVa 习惯用 C++,模板如下:
#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int N; while (cin >> N) { vector<int> d; for (int i = 0; i < N; ++i) { int x; cin >> x; auto it = lower_bound(d.begin(), d.end(), x); if (it == d.end()) { d.push_back(x); } else { *it = x; } } cout << N - (int)d.size() << '\n'; } return 0; }注意几点:ios::sync_with_stdio(false)和cin.tie(nullptr)可以加速输入输出,建议加上,UVa 上的输入量有时候不小。lower_bound返回的是第一个不小于x的迭代器,正好对应“第一个大于等于”的位置。最终答案直接输出N - d.size(),注意把size_t转成int,否则运算符优先级可能给你带来麻烦(虽然这里只是减号,但养成习惯总没错)。
4.3 Python 版本
如果你更习惯 Python,用bisect模块一行就能搞定查找:
import bisect import sys def solve(): data = sys.stdin.read().split() idx = 0 out_lines = [] while idx < len(data): n = int(data[idx]); idx += 1 d = [] for _ in range(n): x = int(data[idx]); idx += 1 pos = bisect.bisect_left(d, x) if pos == len(d): d.append(x) else: d[pos] = x out_lines.append(str(n - len(d))) sys.stdout.write("\n".join(out_lines)) if __name__ == "__main__": solve()bisect.bisect_left对应 C++ 的lower_bound。Python 的列表插入尾部平均 O(1),二分查找 O(log N),整体依然是 O(N log N)。要注意 UVa 的老版本 Python 可能只支持 2.7,如果在线提交平台比较旧,记得把语法调回 Python 2 兼容模式。我自己在本地测试用 Python 3 没问题,但如果要提交到老平台,最好用 C++,省得版本兼容问题让人血压升高。
5. 复杂度分析与边界条件
这个算法的时间复杂度是 O(N log N),其中 N 是序列长度。空间复杂度 O(N)。对于 UVa 13276 这种带有多个测试用例的题目,每组用例都要重新初始化d,可不能把上一轮的数组残留带到下一轮,否则答案直接飞掉。
边界条件上,题目一般保证 N 是正整数,但严谨起见也可以考虑 N = 0 的情况。当 N 为 0 时,d为空,LIS 长度为 0,输出 0 - 0 = 0,逻辑上是自洽的。不过实际提交中很少出现空数据,所以不用过度担心。
另一个边界是输入格式。UVa 的老题目常常是“一个整数 N 占一行,下一行 N 个整数”,但也可能 N 和后面的数字混在几行里。我最推荐的写法是持续读入,碰到整数就处理,用cin或read().split()都能应对跨行情况。如果你用scanf但读错格式,非常容易出现莫名 WA。我早期在 UVa 上栽过几次,都是因为假定“一行一个数字”,结果数据实际跨行,把后面的数字当成下一组的 N 来读了。
还有一个小地方:如果题目说 N 可能特别大(比如 10 万甚至 100 万),递归写 LIS DP 会栈溢出,但二分法用循环,完全没有这个问题。所以这个解法在性能层面非常稳。
6. 我在实战中踩过的坑
我现在把这些坑一个个写出来,每个都是我真实犯过的错误,希望能帮读者少走弯路。
第一个坑:把“上升子序列”理解成“值连续的子序列”。这个我之前反复强调了,但具体到代码里它还会以另一种方式坑你。比如你写出来的程序去找“相邻数值差为 1 的最长链”,样本一过可能对,样本二就错。我当时做了一个测试[1, 2, 4, 3],正确答案是 1,而错误算法得出 2(保留[1,2,4]或[1,2,3],长度都是 3?等等,这里好好算一下)。其实你会发现,只要找普通 LIS,答案立刻正确。所以一旦出现反例,第一反应就是检查模型是不是过度约束了。
第二个坑:二分包边界搞混淆。求严格上升子序列应该用lower_bound(第一个 >= x 的位置);求非降子序列应该用upper_bound(第一个 > x 的位置)。原题是排列,没有重复值,所以两者表面上没区别,但我后来在另一道允许重复的题里惯性使用了lower_bound,结果算出的长度明显偏大。教训是:不要因为当前题目没有重复就忽略语义,你在总结模板时应该把两种写法都记清楚。
第三个坑:多组数据之间的变量残留。有一次我忘了清空d,直接把下一组数据继续往原数组里塞,导致答案越来越大,WA 得莫名其妙。其实处理方式很简单:每次循环开头都新建一个局部vector<int> d,或者手动d.clear()。在 C++ 里,局部变量会自动销毁,所以我后来习惯把每组数据的处理逻辑放进一个函数里。
第四个坑:输出格式多空行。UVa 的评测器对空白字符相当敏感,如果你在每组输出后面多打一个空行,可能被判 Presentation Error。最稳妥的方式是像我在 Python 代码里那样,把所有答案存进一个列表,最后用join输出,保证每行一个答案,末尾不多一个换行。C++ 就简单cout << ans << '\n'即可。
第五个坑:只看题面不看输入规模。有些 UVa 老题没有明确给 N 的范围,我从样例推测 N 很小,于是用了 O(N^2) 的 DP,提交后 TLE。后来才意识到数据量远超预想。建议做题前先看讨论区或者题目标签,搞清楚约束再决定算法。
7. 延伸:一类“移动元素排序”问题的通用解法
UVa 13276 这类题最大的价值,是训练我们识别“任意位置移动”这种操作的数学本质。以后只要见到“你可以随意把元素移到任意位置”的排序题,第一反应就应该往 LIS 上靠。
举例说,如果操作限制成“只能把任意元素移到序列开头”,那问题就变成求“最长后缀满足递增且连续”之类的结构,解法完全不同。如果操作限制成“只能交换相邻两个元素”,那就是经典逆序对数。如果操作允许“任意交换两个元素”,那答案就是 N - 置换中的循环数。这些变体之间差别很大,但它们都指向同一个分析框架:先把“不可移动的骨架”是什么定义清楚,然后看剩下的部分怎么补。
我还特别喜欢把这题和 Codeforces 上一些类似的LIS题联系起来刷。比如有一类题问“最少需要移动多少个元素,使得数组有序”,它们其实就是这道题的换皮版本。只要把一个数列的 LIS 求出来,答案“嗖”地就出来了。真正吃过亏之后,你会发现很多“看似贪心”的题目,背后藏的都是经典序列算法。
我自己刷完这题后,特意整理过一个笔记,记录了三种“移动排序”题型的对比表格,这里分享给你:
| 操作方式 | 等价核心问题 | 时间复杂度 |
|---|---|---|
| 移动任意元素到任意位置 | N - LIS 长度 | O(N log N) |
| 移动任意元素到序列开头 | N - 最长后缀连续递增长度 | O(N) |
| 交换相邻元素 | 逆序对数 | O(N log N) |
| 任意交换两个元素 | N - 循环数 | O(N) |
这张表我强烈建议收藏。做题时先看清操作定义,然后直接对照表格找模型,能省下大量试错时间。
最后再分享一个做题小技巧:任何算法题,在你写代码之前,先在草稿纸上画一组长度为 4 或 5 的排列,手动推一遍答案,再拿你的算法去跑。比如[2, 4, 1, 3],LIS 是[2, 4]或[1, 3],长度 2,答案是 2。你手动试一下是不是真的两步能完成:把 1 挪到最前面,得到[1, 2, 4, 3],再把 3 挪到 4 前面,得到[1, 2, 3, 4],确实两步。这种小样例不仅帮助你验证公式,还会让记忆更深刻。
UVa 13276 Megamind 这道题,看起来是模拟题,实际上是标准的 LIS 题。刷完它,你对“移动排序”这一类问题的理解会上升一个层次。下次再遇到类似的题目,你就能迅速看穿背景设定,直接拆出核心算法模型。我个人觉得,这才是刷 OJ 最大的乐趣所在。