- 最大子段和:练「跨越中点的那部分怎么算」—— 分治的真正难点。 顺便还会看到一件诚实的事:这道题有比分治更好的做法。
- 第 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 暴力
点「运行 ▶」看结果
枚举左端点,往右一路累加,边加边更新。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),但逻辑是乱的,换个变形题就会错。
点「运行 ▶」看结果
4 实测
| n | 暴力 O(n²) | 分治 O(n log n) |
|---|---|---|
| 20 000 | 0.09 秒 | 0.003 秒 |
| 50 000 | 0.58 秒 | 0.004 秒 |
| 100 000 | 2.35 秒 | 0.005 秒 |
5 ★ 但是:这道题有更好的做法
点「运行 ▶」看结果
核心就两行:
cur = max(x, cur + x); // 以 x 结尾的最大和:要么接着延,要么从 x 重新开始
best = max(best, cur);
分治不是万能的。 具体到最大子段和,O(n) 的扫描完胜 O(n log n) 的分治, 而且代码只有两行。
那学分治还有什么用?
因为「跨越中点怎么算」这套思维,在别的题上是唯一出路 —— 比如平面最近点对、比如某些区间统计题。工具箱里要有它, 但拿到题先想有没有更简单的办法,再考虑上工具。
顺带说一句:上面那两行其实就是动态规划 ——
cur 是「以 i 结尾」这个状态,那个 max 就是状态转移方程。
第 21 章会正式讲它。你已经不知不觉写过一次 DP 了。
6 ★ 对拍:三份实现互相验证
值得故意写错的:
- 答案初始化成 0 → 全负数据立刻挂
leftBest允许为空(初始化成 0 而不是LLONG_MIN)→ 逻辑破了- 扫描版写成
cur = max(0LL, cur + x)→ 又是「允许空段」,全负数据挂
后半场 · 第 k 小:只走一边
7 一句话问题
给 n 个数,求从小到大数的第 k 个。
最直接的办法是排序,O(n log n):
点「运行 ▶」看结果
**这份代码在比赛里通常够用。**但它做了多余的事: 我们只想知道第 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)。
点「运行 ▶」看结果
点「运行 ▶」看结果
9 单步看「扔掉一整边」
灰掉的格子不是「处理完了」,是再也不看了。
把 k 改成 1(找最小值)或者 n(找最大值)各播一遍 —— 你会发现无论 k 取多少,候选区间都在稳定地减半。
10 实测:只快一倍,但也值
| n | 排序后取 | 快速选择 |
|---|---|---|
| 1 000 000 | 0.09 秒 | 0.04 秒 |
| 3 000 000 | 0.30 秒 | 0.14 秒 |
不是每个优化都能带来几百倍。这里差的只有一个 log n,
而且这两个数字里还有相当一部分是读输入的时间(三百万个数)。
但两倍也能决定过不过 —— 时限 1 秒的题,0.6 秒和 1.2 秒是两个结果。
能省的常数要省,但别为了两倍的收益去写一个容易写错的复杂算法。
这道题在比赛里其实直接 sort 或者 nth_element 就好。
nth_element(a.begin(), a.begin() + k - 1, a.end());
cout << a[k - 1];它干的就是快速选择这件事,平均 O(n)。比赛里用它。
手写一遍的价值在于理解「只递归一边」这个结构 —— 它在很多题里会以别的面貌出现(比如「区间第 k 小」「找中位数」)。
11 ★ 对拍验证
值得故意写错的:
k传给右半边时忘了减leftLen→ 往右走之后 k 的含义就错了- 划分里
a[j] >= pivot的=去掉 → 大量重复元素时死循环(对拍报超时) - 不随机选基准,固定取第一个 → 有序数据上退化成 O(n²)(小数据看不出来, 但可以自己造一个 10 万的有序数组试试)
12 阶段 2 小结:分治的判断清单
- 分治三步:分 → 治 → 合。 分和治是套路,「合」才是每道题的真正内容。
- 先问「跨越中点的那部分怎么算」。 算得出来就能分治,算不出来就别硬套。
- 如果只需要递归一边,那是减治,复杂度会掉一个数量级(O(n log n) → O(n), 或者 O(n) → O(log n))。
- 分治不是越用越好。 最大子段和有 O(n) 的扫描,第 k 小比赛里直接
nth_element。 工具箱里有它,但先想有没有更简单的路。
13 自测
- 洛谷 P1115 最大子段和 —— 本章原题。先用扫描版过掉,再用分治版交一次
- 洛谷 P1923 求第 k 小的数 —— 快速选择模板题。n 到 500 万,sort 也能过,但正好拿它练手写
- 洛谷 P1226 快速幂 —— 另一个经典分治:a^b = (a^(b/2))² —— 每次问题规模减半。第 42 章会细讲
- 洛谷 P1010 幂次方 —— NOIP1998。递归分解 + 输出格式,练「把问题切成同形状的小问题」
你现在手上有:递归思维(阶段 0)、五个基础零件(阶段 1)、排序与分治(阶段 2)。
第 13 章 DFS 网格连通块开始,进入阶段 3 搜索 —— 信息学竞赛里最能靠「想清楚」拿分的一块。
而你会发现,DFS 就是第 1 章那个「函数调用自己」, 只是把「数字变小」换成了「走到下一个格子」。