阶段 2 · 排序与分治 · 第 12 章

分治进阶:跨越中点,以及只走一边

分治的难点永远在「合」。而有一类问题连合都不用 —— 每次直接扔掉一半。

例题:最大子段和 · 第 k 小 建议用时:100 分钟
阶段 2 的收官章,两个例子讲两件事
  • 最大子段和:练「跨越中点的那部分怎么算」—— 分治的真正难点。 顺便还会看到一件诚实的事:这道题有比分治更好的做法。
  • 第 k 小:见识另一种分治 —— 划分之后只递归一边。 两边都走是 O(n log n),只走一边是 O(n)。

学完这一章,你会对「什么时候该用分治、什么时候别用」有判断力, 而不是看到「切两半」就往上套。

前半场 · 最大子段和

1 一句话问题

给一个可能含负数的数组,求非空连续子段里,和最大的那个是多少。

输入 9
     -2 1 -3 4 -1 2 1 -5 4
输出 6                    子段是 4 -1 2 1
⚠ 「非空」这两个字是送命点

即使全是负数,也必须选至少一个数。

所以 -5 -2 -9 的答案是 -2(最大的那个负数),不是 0

很多人的代码把答案初始化成 0,在全负数据上直接错。 下面的生成器会专门造这种数据。

2 暴力

maxsubBrute.cpp暴力
输入(stdin)
输出
点「运行 ▶」看结果

枚举左端点,往右一路累加,边加边更新。O(n²)。 (注意这里用了第 5 章那一招:不要另开一重循环求段和,边枚举边累加就行。)

3 ★ 关键的一步:跨越中点的那一段长什么样

★ 关键的一步

分治三步(和第 11 章一模一样的框架):

最大子段 = 三者取最大:
   ① 完全落在左半边的      → 递归
   ② 完全落在右半边的      → 递归
   ③ 跨过中点的            → 唯一要动脑的部分

跨过中点的那一段,一定是这个形状:

[ ...... mid ][ mid+1 ...... ]
   └─── 左边以 mid 结尾的一段 ───┘ └─── 右边以 mid+1 开头的一段 ───┘

既然它必须同时包含 mid 和 mid+1,那两截就是独立的: 左边取「以 mid 结尾的最大和」,右边取「以 mid+1 开头的最大和」,加起来就是最优。

而这两截都可以从中点往外扫一遍求出来,各 O(长度):

// 从 mid 往左走,一路累加,记住最大值
long long sum = 0, leftBest = LLONG_MIN;
for (int i = mid; i >= l; i--) { sum += a[i]; leftBest = max(leftBest, sum); }

T(n) = 2T(n/2) + O(n) —— 和归并排序同一个式子,所以是 O(n log n)。

⚠ 那两截都必须非空

leftBest 要从「只含 a[mid]」开始算,不能允许「一个都不选」。

否则「跨过中点」这个前提就破了 —— 它会退化成 ① 或 ② 的情况, 虽然答案碰巧还对(因为取 max),但逻辑是乱的,换个变形题就会错。

maxsubDivide.cpp分治 O(n log n)
输入(stdin)
输出
点「运行 ▶」看结果

4 实测

同题对比:枚举所有段 vs 分治
跑完把 n 改成 10 万、20 万 —— 暴力是 O(n²)。
枚举所有段
分治
n暴力 O(n²)分治 O(n log n)
20 0000.09 秒0.003 秒
50 0000.58 秒0.004 秒
100 0002.35 秒0.005 秒

5 ★ 但是:这道题有更好的做法

maxsubScan.cpp扫描 O(n)
输入(stdin)
输出
点「运行 ▶」看结果

核心就两行:

cur = max(x, cur + x);      // 以 x 结尾的最大和:要么接着延,要么从 x 重新开始
best = max(best, cur);
★ 这份代码放在这里,是为了诚实

分治不是万能的。 具体到最大子段和,O(n) 的扫描完胜 O(n log n) 的分治, 而且代码只有两行。

那学分治还有什么用?

因为「跨越中点怎么算」这套思维,在别的题上是唯一出路 —— 比如平面最近点对、比如某些区间统计题。工具箱里要有它, 但拿到题先想有没有更简单的办法,再考虑上工具

顺带说一句:上面那两行其实就是动态规划 —— cur 是「以 i 结尾」这个状态,那个 max 就是状态转移方程。 第 21 章会正式讲它。你已经不知不觉写过一次 DP 了。

6 ★ 对拍:三份实现互相验证

对拍器
生成器有 1/5 的概率造出全负数组 —— 那是「非空」这个条件的照妖镜。把 fast 换成扫描版也可以,三份实现应该两两一致。

值得故意写错的:

  • 答案初始化成 0 → 全负数据立刻挂
  • leftBest 允许为空(初始化成 0 而不是 LLONG_MIN)→ 逻辑破了
  • 扫描版写成 cur = max(0LL, cur + x) → 又是「允许空段」,全负数据挂

后半场 · 第 k 小:只走一边

7 一句话问题

n 个数,求从小到大数的第 k 个。

最直接的办法是排序,O(n log n):

selectBrute.cpp排完再取
输入(stdin)
输出
点「运行 ▶」看结果

**这份代码在比赛里通常够用。**但它做了多余的事: 我们只想知道第 k 个是谁,它却把所有 n 个数的顺序都排出来了。

8 ★ 关键的一步:划分之后,只有一边值得看

★ 关键的一步

用第 10 章快排的划分:随机选一个基准,把小的甩左边、大的甩右边。 划分完之后基准就在它最终的位置 p 上了。

于是:

左边(含基准)有 leftLen 个数:

  k == leftLen  →  基准就是第 k 小,直接返回
  k <  leftLen  →  第 k 小在左边,右边整片**再也不看了**
  k >  leftLen  →  第 k 小在右边,左边整片**再也不看了**,且在新区间里找第 k-leftLen 小

快排两边都要继续,这里只要一边。

工作量:n + n/2 + n/4 + … = 2n。所以平均是 O(n),比排序还快一个 log。

这种「每次砍掉一部分、只在剩下的里面继续」的套路有个专门的名字: 减治(decrease and conquer),区别于两边都要处理的分治

第 8 章的二分其实就是减治的极端版本 —— 它每一步只做 O(1) 的工作,所以是 O(log n)。

selectFast.cpp快速选择 O(n)
输入(stdin)
输出
点「运行 ▶」看结果
selectTrace.cpp过程演示
盯住「还剩多少个候选」这个数字 —— 它一路减半。最后一行会告诉你累计处理了多少元素,那个数接近 2n。
输入(stdin)
输出
点「运行 ▶」看结果

9 单步看「扔掉一整边」

快速选择:每一步扔掉一整边
答案 3
第 1 / 10 步
7
2
9
4
1
8
3
6
5
0
0
1
2
3
4
5
6
7
8
9
还剩多少个候选
10
在这段里找第几小
4
累计看过的元素
0
排序法要处理
10 个 × log₂ 层
灰色 = 已经被整边扔掉的数,再也不会看它们。 候选个数每轮大致减半:n + n/2 + n/4 + … ≈ 2n。
要在 10 个数里找第 4 小。排序法会把全部 10 个数都排好 —— 但我们只想要一个数。

灰掉的格子不是「处理完了」,是再也不看了

把 k 改成 1(找最小值)或者 n(找最大值)各播一遍 —— 你会发现无论 k 取多少,候选区间都在稳定地减半。

10 实测:只快一倍,但也值

同题对比:排序后取 vs 快速选择
k 取中位数(最吃力的位置)。注意这次差距只有两倍左右 —— 因为差的只是一个 log,而且很大一部分时间花在读输入上。
排序后取
快速选择
n排序后取快速选择
1 000 0000.09 秒0.04 秒
3 000 0000.30 秒0.14 秒
✓ 这个「只快两倍」也值得看

不是每个优化都能带来几百倍。这里差的只有一个 log n, 而且这两个数字里还有相当一部分是读输入的时间(三百万个数)。

但两倍也能决定过不过 —— 时限 1 秒的题,0.6 秒和 1.2 秒是两个结果。

能省的常数要省,但别为了两倍的收益去写一个容易写错的复杂算法。 这道题在比赛里其实直接 sort 或者 nth_element 就好。

标准库里就有:nth_element
nth_element(a.begin(), a.begin() + k - 1, a.end());
cout << a[k - 1];

它干的就是快速选择这件事,平均 O(n)。比赛里用它。

手写一遍的价值在于理解「只递归一边」这个结构 —— 它在很多题里会以别的面貌出现(比如「区间第 k 小」「找中位数」)。

11 ★ 对拍验证

对拍器
生成器专攻边界:k=1(最小)、k=n(最大)、大量重复元素(划分时最容易死循环)、已排序 / 完全逆序(检验随机基准)。

值得故意写错的:

  • k 传给右半边时忘了减 leftLen → 往右走之后 k 的含义就错了
  • 划分里 a[j] >= pivot= 去掉 → 大量重复元素时死循环(对拍报超时)
  • 不随机选基准,固定取第一个 → 有序数据上退化成 O(n²)(小数据看不出来, 但可以自己造一个 10 万的有序数组试试)

12 阶段 2 小结:分治的判断清单

★ 四句话
  1. 分治三步:分 → 治 → 合。 分和治是套路,「合」才是每道题的真正内容
  2. 先问「跨越中点的那部分怎么算」。 算得出来就能分治,算不出来就别硬套。
  3. 如果只需要递归一边,那是减治,复杂度会掉一个数量级(O(n log n) → O(n), 或者 O(n) → O(log n))。
  4. 分治不是越用越好。 最大子段和有 O(n) 的扫描,第 k 小比赛里直接 nth_element。 工具箱里有它,但先想有没有更简单的路。

13 自测

自测清单0 / 9
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
阶段 2 到此结束 —— 下一站是搜索

你现在手上有:递归思维(阶段 0)、五个基础零件(阶段 1)、排序与分治(阶段 2)。

第 13 章 DFS 网格连通块开始,进入阶段 3 搜索 —— 信息学竞赛里最能靠「想清楚」拿分的一块。

而你会发现,DFS 就是第 1 章那个「函数调用自己」, 只是把「数字变小」换成了「走到下一个格子」。