二分查找这东西,很多同学学到后期会觉得“不就是个 lower_bound 嘛”,但真到竞赛题里,二分考的从来不是“会不会写模板”,而是“能不能看出来这里能用二分”。我当年刷《算法竞赛进阶指南》0x04 这一节时,前几道题还算友好,做到“特殊排序”这道题直接卡了一晚上。不是代码写不出来,而是根本想不通:一个连传递性都不满足的偏序关系,凭什么能用二分去排序?搞清楚这个问题之后,我对二分的理解算是上了一个台阶。这篇文章就把这道题的完整推导过程、正确性证明、代码实现和踩坑记录都摊开来讲,适合正在刷进阶指南、准备 ACM/蓝桥杯或者想真正理解二分本质的读者。
1. 先搞清楚“特殊排序”到底特殊在哪
1.1 题目说的不是普通排序
题目背景大致是这样:有 N 个元素,编号从 1 到 N,系统提供了一个比较函数compare(a, b),返回a是否小于b。注意,这个“小于”并不是我们熟悉的数值大小,而是题目自定义的一种关系。要求你把所有元素排成一个序列,使得序列中任意相邻的两个元素都满足compare(a[i], a[i+1]) == true(也就是前一个元素“小于”后一个元素)。比较函数的调用次数有上限限制,大概是 O(N log N) 级别。
如果你第一次看到这个题,很容易觉得:这有什么特殊的?不就是一个排序吗?我用sort加自定义比较器不就行了?
问题就出在“特殊”这两个字上:题目明确告诉你,这个compare函数表示的关系不满足传递性。这意味着即使compare(a, b)和compare(b, c)都为真,你也不能推出compare(a, c)为真。这在普通排序里是不可想象的,因为所有经典排序算法(快排、归并、堆排)的正确性都建立在“全序关系”之上。一旦传递性不成立,整个排序算法的逻辑地基就塌了。
1.2 为什么说这是二分章节的“压轴题”
这道题放在 0x04 二分章节,而不是排序章节,本身就透露了出题人的意图:它根本不是在考排序,而是在考二分的本质。普通二分查找要求数组有序,但数组有序只是“单调性”的一种表现形式。这道题里,你要维护的是一个满足相邻关系的序列,然后对每个新元素找插入位置。看似是在做插入排序,但查找插入位置的过程,本质上是一个二分查找——而且这个二分查找成立的前提,不是全局有序,而是一个被隐藏起来的单调性。
换句话说,这道题是让你“用二分的眼光看排序”,把二分从“在有序数组里找数”这个具体场景中抽象出来,上升到“在一个满足单调性的判定序列里定位分界点”的高度。想明白这一点,后面遇到各种奇奇怪怪的二分题(交互题、带权二分、二分答案)才能举一反三。
2. 为什么不能用快排也不能用普通插入排序
2.1 经典排序算法全部默认“全序关系”
先复习一个概念:所谓全序关系,要求满足三个性质——自反性、反对称性、传递性。比如整数大小关系,a < b且b < c一定能推出a < c,这就是传递性。快速排序在 partition 的时候,会把比 pivot 小的元素放左边、大的放右边,这个“比 pivot 小”的判断,隐含假设了如果x < pivot且pivot < y,那么x一定也“小于”y(至少不会出现 x 应该放在 y 右边的情况)。归并排序的 merge 阶段同理,堆排序的向下调整也同理。
一旦compare不满足传递性,这些假设全部失效。举个例子,假设有三个元素 A、B、C,compare(A, B) == true,compare(B, C) == true,但compare(A, C) == false。快排选 B 作为 pivot,A 会被分到左边,C 会被分到右边,看起来没错,但最终序列里 A 在 B 前面、B 在 C 前面,而 A 和 C 相邻时(如果它们最后相邻),compare(A, C) == false,直接不满足题目要求。
2.2 反例演示:快排和归并在特殊关系下直接翻车
我们可以构造一个具体的小规模反例来加深印象。假设有 3 个元素 1、2、3,compare函数定义为:
compare(1, 2) = truecompare(2, 3) = truecompare(1, 3) = false
如果调用系统排序(无论快排还是归并),排序算法大概率会输出[1, 2, 3](因为它会认为 1 < 2 < 3),但这个序列里 1 和 2 相邻没问题,2 和 3 相邻没问题,可一旦 1 和 3 在序列中相邻(比如某些分区方式下),就直接违反了相邻递增的条件。更麻烦的是,算法内部比较的顺序是任意的,它可能在某次比较中出现compare(1, 3) = false但依然按照已有逻辑处理,导致不可预测的结果——连 WA 的报错方式都是随机的,调试起来非常痛苦。
2.3 普通插入排序为什么可行但不够好
既然经典排序算法不行,那插入排序呢?插入排序有一点很特殊:它只依赖“当前元素”和“已排序序列中某个位置的元素”进行比较,然后把当前元素插到那个位置后面。它并不要求整个序列满足全局传递性,只需要保证每次插入后,新序列仍然满足相邻递增。如果每次插入都找到正确的位置,那么插入排序在这个特殊关系下是能正确工作的。
但普通插入排序找插入位置是逐个往前比较的,最坏情况下总共要比较 O(N^2) 次。题目明确限制了比较次数,所以必须优化查找过程。怎么优化?用二分。这就是整道题最关键的一步:在已排序序列中二分查找插入位置。
3. 核心思路:在“不传递”的关系里找到隐藏的单调性
3.1 维护一个“合法序列”
我们从头开始,用一个vector<int> res维护当前已经排好的序列。这个序列满足一个性质:对任意i,都有compare(res[i], res[i+1]) == true。初始时序列为空,逐个把元素插进来。每插入一个元素 x,我们要找到一个新的位置 pos,使得插入后(x 在 pos 位置上,即 res[pos-1] 和 x 相邻、x 和 res[pos] 相邻)序列仍然满足相邻递增性质。
问题转化为:对于当前序列,如何确定 x 应该插在哪个位置?
3.2 关键的单调性:能插入的位置是连续的
这里需要停下来想一想。普通有序数组里二分查找,靠的是数组值单调递增。我们现在这个序列只满足相邻递增,并不是全局递增,那怎么二分?
核心观察是这样一个性质:如果 x 可以插入在位置 i(即compare(res[i-1], x) && compare(x, res[i])都成立),那么 x 一定也可以插入在某个更靠后的位置 j(j > i)吗?答案是否定的,因为这个关系并不满足传递性。
但《算法竞赛进阶指南》里给的解法利用了另一个更微妙的单调性。我们定义一个判定条件:check(mid)表示“x 是否可以接在 res[mid] 后面”,即compare(res[mid], x)是否为真。关键结论是:这个判定条件在序列上是单调的——如果check(mid)为假,那么对于所有k <= mid,check(k)也一定为假;如果check(mid)为真,那么对于所有k >= mid,check(k)也一定为真。
这看起来不可思议,因为 compare 不满足传递性。但这个性质不是从 compare 本身推出来的,而是从“序列合法性”这个构造过程中归纳出来的。换句话说,我们维护的序列虽然只要求相邻满足条件,但它隐含了一个更强的结构:对于任意两个元素 res[i] 和 res[j](i < j),我们都能通过归纳构造保证一种“可插入性”的单调性。具体证明放到后面,这里先用直觉理解:每次插入都是通过二分找到的合法位置,所以整个序列的“接受新元素”能力从前往后呈现一种阶梯变化——前半段不接受 x 接在后面,后半段接受。二分就是在找这个转折点。
3.3 为什么二分能找到合法插入点
我们想在序列 res 中找一个位置 pos,使得:
- 如果 pos < n,需要满足
compare(res[pos-1], x)且compare(x, res[pos]); - 如果 pos == n(插到末尾),只需要满足
compare(res[n-1], x)。
利用上面的单调性,我们先找最后一个满足compare(res[i], x)的 i(也就是 x 能接在哪个元素后面),记为 p。如果不存在这样的 p,说明 x 比所有元素都“小”,应该插到开头。如果 p 存在,我们再验证compare(x, res[p+1])是否成立——如果成立,就把 x 插在 p 和 p+1 之间;如果不成立呢?这里其实有个细节,很多题解直接说“二分找到最后一个满足 compare(res[mid], x) 的位置,然后插在后面”,这是不够严谨的。需要再往前或者再往后调整。
实际上,标准的解法是:对序列中每个位置 i,我们判定“x 是否应该插在位置 i 之后”,可以用一个更巧妙的方式——通过比较compare(x, res[mid])来决定二分的走向。具体的二分过程是这样的:
int l = 0, r = res.size(); // 插入位置范围 [0, n] while (l < r) { int mid = (l + r) / 2; if (compare(res[mid], x)) { l = mid + 1; } else { r = mid; } } // 此时 l 是第一个满足 compare(res[l], x) == false 的位置 res.insert(res.begin() + l, x);这段代码的逻辑是:我们在一个“虚拟判定数组”上做二分,判定条件是compare(res[mid], x)。如果为真,说明 x 应该排在 res[mid] 后面(mid 之后),所以左边界右移;如果为假,说明 x 应该排在 res[mid] 前面(mid 之前或等于 mid),所以右边界左移。最终得到的 l 就是插入位置。这其实是“lower_bound”的等价写法,只不过比较方向是自定义的。
3.4 从“找值”到“找位置”的二分离谱抽象
这一段是对二分理解的升华。普通二分的模板是“在一个有序数组里找某个值”,而这道题的二分是在“一系列布尔判定结果”里找边界。判定的结果是true/ false组成的序列,这个序列天然满足单调性(前面一段是 true,后面一段是 false,或者反过来),所以可以二分。你看,二分完全不关心底层的比较是否有传递性,它只关心判定结果是否有单调性。这是这道题最反直觉也最精华的地方。
4. 完整代码实现与逐行解析
4.1 注意题目给的接口格式
《算法竞赛进阶指南》配套的 AcWing 113 特殊排序题,需要实现一个vector<int> specialSort(int N)函数,系统会提供compare函数,要求在函数内返回排好的序列。直接可以在本地调试时实现一个模拟的compare来测试。
4.2 代码实现(C++)
// Forward declaration of compare API. // bool compare(int a, int b); // 返回 a 是否小于 b,注意这个关系不满足传递性。 class Solution { public: vector<int> specialSort(int N) { vector<int> res; for (int x = 1; x <= N; x++) { int l = 0, r = res.size(); while (l < r) { int mid = (l + r) / 2; if (compare(res[mid], x)) { l = mid + 1; } else { r = mid; } } res.insert(res.begin() + l, x); } return res; } };4.3 逐行解释这段代码
整个实现只有十几行,但每一行背后的含义都值得掰开揉碎讲。
vector<int> res是已排序序列,初始为空。外层循环从 1 到 N 逐个插入元素。这里有几个新手的疑问:为什么要从小到大遍历编号?编号的顺序有关系吗?答案是没有。你可以从任意顺序处理元素,每个元素都会通过二分找到自己的位置。从小到大遍历只是方便循环而已。
内层二分,l = 0表示插入位置范围的下界,r = res.size()表示上界(注意不是size()-1,因为插入位置的范围是 0 到 n,共 n+1 个可能位置)。这里用的是左闭右开区间[l, r)。mid = (l + r) / 2取中间位置,注意当序列长度为奇数时,mid 指向中间元素;为偶数时,mid 指向中间两个元素中靠左的那个。
关键就是if (compare(res[mid], x))这一句。如果res[mid]小于 x,说明 x 应该插入到中间位置的右边,所以l = mid + 1;否则说明 x 应该插入到中间位置的左边,所以r = mid。循环结束时,l == r,而且 l 指向的位置就是“第一个不满足compare(res[mid], x)的位置”,也就是要插入的位置。
最后res.insert(res.begin() + l, x)把 x 插入到 vector 的 l 位置。注意 vector 的 insert 会把之后的元素全部后移,这一步是 O(N) 的。但由于 N 最大只有 1000(题目数据范围),O(N^2) 的移动操作完全可以接受,真正的瓶颈是比较次数,而比较次数被二分控制在了 O(N log N)。
4.4 为什么compare(x, res[pos])不需要单独判断
很多第一次接触这道题的人会问:插入之后,x 和它后面的元素之间需要满足compare(x, res[pos])为 true 才对吧?代码里怎么没验证?
答案藏在二分结束的位置里。循环结束后,l 是第一个不满足compare(res[mid], x)的位置(从左往右数)。也就是说,对于所有 i < l,都有compare(res[i], x) == true;对于 i >= l,都有compare(res[i], x) == false(这由单调性保证)。插入到 l 位置后,x 前面的元素是 res[l-1],满足compare(res[l-1], x);x 后面的元素是 res[l](如果存在),它不满足compare(res[l], x),但这不代表compare(x, res[l])为假——注意 compare 并不是非真即假的对偶关系,它可能两者都为真、两者都为假,甚至结果与调用顺序有关。这正是特殊关系的诡异之处,也是很多题解没有讲清楚的地方。实际证明中,我们需要额外证明在 l 位置插入后,compare(x, res[l])也成立。这个证明比较复杂,但结论是成立的——而这个证明依赖于“合法序列的归纳构造”,不是简单看一眼就能信服的。算法竞赛里通常直接背结论:在 l 处插入即可。
4.5 比较次数估算
每插入一个元素 k(第 k 次插入时,res 的长度为 k-1),二分查找需要约ceil(log2(k))次比较。总的比较次数约为 Σ log2(k) ≈ N log2 N。以 N=1000 为例,大约是 1000 * 10 = 10000 次比较,远小于题目限制的 20000 次(如果题目有明确上限的话),完全没问题。如果用普通插入排序逐个比较,最坏情况要接近 50 万次比较,直接超限。这就是用二分优化的意义。
5. 正确性证明:为什么这个二分是可靠的
5.1 用归纳法证明算法正确
这个题的证明并不简单,但它是理解整道题的关键,值得花时间看。
归纳假设:当处理第 k 个元素之前,res 是一个长度为 k-1 的合法序列,并且满足以下额外性质:对于任意 i (0 ≤ i < len),序列 res 中的前缀 res[0..i] 中每个元素都能“接受”后面的新元素插入,而不会破坏相邻递增关系。更具体地说,是在插入新元素 x 时,能提供二分所需的单调性。
插入过程:二分找到一个位置 l,使得对于所有 i < l,都有compare(res[i], x) == true;对于所有 i >= l,都有compare(res[i], x) == false。我们声称把 x 插到 l 位置后序列仍然合法。
需要验证:
- 如果 l == 0,x 变成第一个元素,需要满足
compare(x, res[0])。由二分结果可知,i=0 不满足compare(res[0], x),但我们真正需要的是compare(x, res[0])。这两个不一样。关键证明在于:利用归纳假设里的单调性,可以推出如果compare(res[0], x) == false且 x 能带来合法插入,那么compare(x, res[0])必然成立。这部分证明用到反证法:假如compare(x, res[0]) == false,那么 x 放在开头会导致第一对我相邻元素不合法。但我们知道 res 本身是某个顺序合法插入得到的,这里的矛盾需要具体分析了——篇幅限制不展开全部细节,大致思路是利用“插入过程的对称性”和“先前插入时对应位置的性质”推出矛盾。 - 如果 l == len,x 变成最后一个元素,需要满足
compare(res[len-1], x)。由l == len可知compare(res[len-1], x) == true,直接满足。 - 如果 0 < l < len,需要同时满足
compare(res[l-1], x)和compare(x, res[l])。前者由二分的性质直接得到(因为 l-1 < l)。后者同样需要用归纳假设和反证法证明。
5.2 反证法:为什么二分找到的位置一定合法
假设把 x 插到 l 位置之后,出现了一个不合法的相邻对,那只能是compare(x, res[l]) == false(因为前一对已经验证是合法的)。现在考虑这个不合法的相邻对,它意味着 x 必须在 res[l] 之后才符合某种顺序?这里的推理会比较绕,但可以提供一个直观理解:我们实际上把整个插入过程看成“从后往前扫找可以接受 x 的最后一个位置”。在合法的构造过程中,如果 x 不能被插在 l 位置,那它应该能被插在更早或更晚的位置?但二分把可选范围压缩到了唯一一个“临界点”。临界点的两侧,一侧满足“x 能接在元素后面”,另一侧满足“x 不能接在元素后面”,而恰恰是这个临界点保证了“既能接在前面元素后面,也能被后面元素接受”。这就是隐藏在题目中的对称性,也是这个“特殊排序”能够成立的根本原因。
5.3 复杂度分析汇总
时间上,二分查找每轮 O(log N) 次比较,vector insert 每轮 O(N) 次移动,总时间 O(N^2)(N 比较小时可以接受,数据范围 1000 量级完全没问题)。比较次数是 O(N log N),这是题目的核心约束。空间复杂度 O(N)。如果想进一步优化移动开销,可以用链表,但 vector 的 cache 友好性和简单性在这个数据规模下更有优势。
6. 实战调试与易错点
6.1 边界条件错:while (l < r) 写成了 while (l <= r)
这是最容易犯的错误。插入位置的二分,目标是[0, n]这 n+1 个位置中的某一个,而不是在一个闭区间里找一个确定元素的值。如果用l <= r闭区间写法,配合mid的计算很容易死循环,或者最后得到的位置多 1 少 1。建议直接使用左闭右开[l, r)的 lower_bound 模板,不要自由发挥。
6.2 compare 参数顺序写反
题目里compare(a, b)返回 a 是否小于 b。代码里写compare(res[mid], x)是在问“已排序序列里的中间元素是否小于新元素”,这是判断 x 应该插入到中间元素后面还是前面。如果写成compare(x, res[mid]),二分的走向就反了,排序结果会错。调试时可以先在本地用一个正常的小于号模拟 compare,如果排序结果不是递增,先检查参数顺序。
6.3 打乱插入顺序测试
我在调试时就犯过这个错:只用从小到大插入的方式测,结果正确,但换成从大到小插入就错了。后来发现是二分代码里边界写死了一个特殊情况。建议在本地把元素的插入顺序随机打乱(当然 compare 要相应调整),确保算法不依赖于插入顺序。这个测试很重要,因为正确的做法应当对任意插入顺序都能得到合法序列。
6.4 本地构造特殊 compare 进行对拍
因为题目不提供完整可运行的样例,本地自测需要自己构造一个 compare。一种最简单的构造是:return a < b;,这样特殊排序退化成普通排序,可以验证输出是否为[1, 2, ..., N]。但这样测不出“非传递性”的情况。另一种构造可以故意制造非传递关系,比如compare(a, b)返回(a + b) % 3 != 0之类的随机规则,只保证不矛盾,然后检查输出序列的相邻关系是否真的满足 compare。写一个校验函数,输出后逐对检查,能抓出很多隐蔽错误。
6.5 用stable_sort或sort的诱惑
我见过有人试图用sort(res.begin(), res.end(), compare)来投机取巧。这在普通全序关系下是可行的,但在非传递关系下,标准库的sort行为是未定义的——它可能在任何一次比较后做出不合理的调整,甚至触发越界。千万别这么干。
7. 从这道题抽象出的二分思维,还能用在哪
7.1 交互题里的二分查找
很多交互题会提供一个黑盒查询函数,要求你通过有限的查询次数确定某个隐藏值。只要你能定义出一个“单调”的判定条件,不管这个条件背后的关系多复杂,都可以用二分来压缩查询次数。比如猜数游戏、CP 里的“猜排列”问题,本质都是这道题的一维版本。
7.2 二分答案与带权二分
二分答案的经典模型是:给定一个可行性函数check(mid),它的返回值随着 mid 增大而单调变化(true 一段、false 一段),然后二分求边界。“带权二分”则是在 DP 优化里对某个代价函数加一个惩罚项 λ,用二分去逼近最优分割点。这些场景的共同点都是:检查 mid 是否可行的代价远小于枚举所有情况,就像本题中的 compare 调用代替了线性扫描。
7.3 二分在“不可排序数据”上的应用
普通排序算法要求数据满足全序关系,但很多场景下数据之间的关系是“弱”的(偏序、互不传递、甚至随机)。当你遇到这样的数据时,先不要慌,试着从“插入”的角度思考:能否维护一个已经满足局部条件的序列,然后对每个新元素用二分定位?这种思路在一些构造题里偶尔会出现,属于“非模板化二分”的典型代表。
8. 关于这道题,我想说的最后几句
这道题我前前后后重写了好几遍,每次重写都有新的理解。第一遍照抄题解,AC 了但完全不知道发生了什么;第二遍自己推证明,发现证明里有一个细节(就是 5.1 提到的反证部分)之前根本没注意到;第三遍为了写这篇总结,又去翻了进阶指南对应章节,才真正意识到“特殊排序”这个标题起得有多妙——它特殊的地方不是“排序”本身,而是“排序所依赖的关系不满足传递性”,这在算法竞赛里是很不常见的设定。
如果你刷到这里卡住了,我的建议是:别急着看代码,先拿纸笔模拟 4 个元素的情况,写出每一步二分后插入的状态,再对照推导过程去体会那个单调性是怎么“凭空出现”的。想明白这个,二分这个章节对你来说才算真正过关。之后再遇到任何“感觉能用二分但不知道单调性在哪”的题,你就有了一个靠谱的思路:先找一个判定函数,再去证明它的结果是单调变化的,最后才套模板。顺序反了,就会做得很痛苦。
最后分享一个小技巧:刷进阶指南的时候,每一章后面的习题最好都自己总结成一句话——这道题考的本质到底是什么。比如追求二分章节,普通题是“在有序数组里找值”,这道题是“在单调判定序列里找边界”。把每道题抽象到一句话,你会发现自己对算法的理解会和之前完全不一样。