阶段 2 · 排序与分治 · 第 12 章普及组 J

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

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

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

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

前半场 · 最大子段和

1一句话问题

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

输入

9
-2 1 -3 4 -1 2 1 -5 4

输出

6

第一行是 n,第二行是那 n 个数。和最大的子段是 4 -1 2 1,和为 6。 ⚠ 程序只输出这个和,不输出是哪一段。

⚠ 「非空」这两个字是送命点

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

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

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

2暴力

maxsubBrute.cpp暴力
// 最大子段和 —— 暴力:枚举所有的段
//
// 输入:第一行 n,第二行 n 个整数(可能有负数)
// 输出:所有**非空连续子段**里,和最大的那个是多少
//
// 例:-2 1 -3 4 -1 2 1 -5 4 → 答案 6(子段 4 -1 2 1)
//
// 注意「非空」:即使全是负数,也必须选至少一个,
// 所以那时答案是「最大的那个负数」,不是 0。这是最容易写错的边界。
//
// 暴力:枚举左端点,往右一路累加,边加边更新答案。O(n²)。
// (这里用了第 5 章那一招 —— 不要另开一重循环去求段和,边枚举边累加就行。)
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
if (n <= 0) { cout << 0 << "\n"; return 0; }
vector<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
long long best = a[0];
for (int l = 0; l < n; l++) {
long long sum = 0;
for (int r = l; r < n; r++) {
sum += a[r];
best = max(best, sum);
}
}
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

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

★ 关键的一步

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

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

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

[ ...... mid ][ mid+1 ...... ]
|------------||--------------|
      ^              ^
      |              +-- 右边:以 mid+1 开头的一段
      +----------------- 左边:以 mid 结尾的一段

既然它必须同时包含 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)
// 最大子段和 —— 分治
//
// 输入输出和 maxsubBrute.cpp 完全一样。
//
// 分治三步(第 11 章那个框架,一模一样):
//
// 最大子段 = 三者取最大:
// ① 完全落在左半边的 → 递归解决
// ② 完全落在右半边的 → 递归解决
// ③ **跨过中点的** → 这才是要动脑的部分
//
// 跨过中点的那一段长什么样?它必然是
// 「左半边里以 mid 结尾的某一段」 + 「右半边里以 mid+1 开头的某一段」。
//
// 这两截可以各自 O(长度) 地扫出来:
// 从 mid 往左走,一路累加,记住最大值 → 左边最好的后缀
// 从 mid+1 往右走,一路累加,记住最大值 → 右边最好的前缀
// 两个加起来就是跨越中点的最优解。
//
// ⚠ 这两截都**必须非空**(至少含 mid 和 mid+1 各一个元素),
// 否则「跨过中点」这个前提就不成立了,会和 ①② 重复。
//
// 复杂度:T(n) = 2T(n/2) + O(n) —— 和归并排序一模一样,所以是 O(n log n)。
#include <bits/stdc++.h>
using namespace std;
vector<long long> a;
long long solve(int l, int r) {
if (l == r) return a[l]; // 边界:只剩一个元素
int mid = l + (r - l) / 2;
long long best = max(solve(l, mid), solve(mid + 1, r)); // ①②
// ③ 跨过中点:左边最好的后缀 + 右边最好的前缀
long long sum = 0, leftBest = LLONG_MIN;
for (int i = mid; i >= l; i--) { sum += a[i]; leftBest = max(leftBest, sum); }
sum = 0;
long long rightBest = LLONG_MIN;
for (int i = mid + 1; i <= r; i++) { sum += a[i]; rightBest = max(rightBest, sum); }
return max(best, leftBest + rightBest);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
if (n <= 0) { cout << 0 << "\n"; return 0; }
a.assign(n, 0);
for (int i = 0; i < n; i++) cin >> a[i];
cout << solve(0, n - 1) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

4实测

同题对比:枚举所有段 vs 分治
跑完把 n 改成 10 万、20 万 —— 暴力是 O(n²)。
枚举所有段
分治
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★ 但是:这道题有更好的做法

maxsubScan.cpp扫描 O(n)
// 最大子段和 —— 从左到右扫一遍就够了(O(n))
//
// 输入输出和前两份完全一样,但只有三行核心代码。
//
// 想法:**以第 i 个元素结尾**的最大子段和是多少?记作 cur。
// 要么接着前面那一段往下延(cur + a[i]),
// 要么干脆从 a[i] 自己重新开始(a[i])—— 取大的那个。
//
// cur = max(a[i], cur + a[i]);
// ans = max(ans, cur);
//
// 什么时候该「重新开始」?当 cur 已经变成负数时 ——
// 一个负数的前缀只会拖累后面,不如扔掉。
//
// ============ 为什么这一章要放一份「比分治还快」的代码 ============
//
// 因为它诚实:**分治不是万能的,这道题有更好的做法。**
//
// 学分治的价值在于「跨越中点怎么算」这套思维,它在很多题上是唯一出路
// (比如平面最近点对)。但具体到最大子段和,O(n) 的扫描完胜。
//
// **拿到题先想有没有更简单的办法,再考虑上工具。**
//
// 顺带一提:上面那两行其实就是动态规划 —— cur 是「以 i 结尾」这个状态,
// 那个 max 就是状态转移方程。第 21 章会正式介绍它。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
if (n <= 0) { cout << 0 << "\n"; return 0; }
long long best = LLONG_MIN, cur = 0;
for (int i = 0; i < n; i++) {
long long x;
cin >> x;
cur = max(x, cur + x); // 要么接着延,要么从 x 重新开始
best = max(best, cur);
}
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(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 换成扫描版也可以,三份实现应该两两一致。
// 最大子段和 —— 分治
//
// 输入输出和 maxsubBrute.cpp 完全一样。
//
// 分治三步(第 11 章那个框架,一模一样):
//
// 最大子段 = 三者取最大:
// ① 完全落在左半边的 → 递归解决
// ② 完全落在右半边的 → 递归解决
// ③ **跨过中点的** → 这才是要动脑的部分
//
// 跨过中点的那一段长什么样?它必然是
// 「左半边里以 mid 结尾的某一段」 + 「右半边里以 mid+1 开头的某一段」。
//
// 这两截可以各自 O(长度) 地扫出来:
// 从 mid 往左走,一路累加,记住最大值 → 左边最好的后缀
// 从 mid+1 往右走,一路累加,记住最大值 → 右边最好的前缀
// 两个加起来就是跨越中点的最优解。
//
// ⚠ 这两截都**必须非空**(至少含 mid 和 mid+1 各一个元素),
// 否则「跨过中点」这个前提就不成立了,会和 ①② 重复。
//
// 复杂度:T(n) = 2T(n/2) + O(n) —— 和归并排序一模一样,所以是 O(n log n)。
#include <bits/stdc++.h>
using namespace std;
vector<long long> a;
long long solve(int l, int r) {
if (l == r) return a[l]; // 边界:只剩一个元素
int mid = l + (r - l) / 2;
long long best = max(solve(l, mid), solve(mid + 1, r)); // ①②
// ③ 跨过中点:左边最好的后缀 + 右边最好的前缀
long long sum = 0, leftBest = LLONG_MIN;
for (int i = mid; i >= l; i--) { sum += a[i]; leftBest = max(leftBest, sum); }
sum = 0;
long long rightBest = LLONG_MIN;
for (int i = mid + 1; i <= r; i++) { sum += a[i]; rightBest = max(rightBest, sum); }
return max(best, leftBest + rightBest);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
if (n <= 0) { cout << 0 << "\n"; return 0; }
a.assign(n, 0);
for (int i = 0; i < n; i++) cin >> a[i];
cout << solve(0, n - 1) << "\n";
return 0;
}
点一下即可编辑

值得故意写错的:

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

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

7一句话问题

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

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

selectBrute.cpp排完再取
// 第 k 小 —— 暴力:排完序再取
//
// 输入:第一行 n k,第二行 n 个整数
// 输出:从小到大数,第 k 个数是多少(k 从 1 开始)
//
// 排序 O(n log n),然后 O(1) 取。
//
// 这份代码没什么可说的,而且**在比赛里通常够用** ——
// n = 10⁶ 时 sort 也就 0.1 秒。
//
// 但它做了多余的事:我们只想知道第 k 个是谁,
// 却把**所有** n 个数的顺序都排出来了。下一份代码只做必要的那部分。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, k;
if (!(cin >> n >> k)) return 0;
if (n <= 0 || k < 1 || k > n) { cout << 0 << "\n"; return 0; }
vector<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
sort(a.begin(), a.end());
cout << a[k - 1] << "\n";
return 0;
}
点一下即可编辑
输入(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)
// 第 k 小 —— 快速选择(quickselect)
//
// 输入输出和 selectBrute.cpp 完全一样,但平均只要 O(n)。
//
// ============ 和快排的唯一区别:只递归一边 ============
//
// 快排划分完之后,基准就待在它最终的位置 p 上了:
// 左边全部 <= 基准,右边全部 >= 基准。
//
// 于是:
// p == k-1 → 基准就是答案,直接返回
// p > k-1 → 第 k 小在左边,**只递归左边**
// p < k-1 → 第 k 小在右边,**只递归右边**
//
// 快排两边都要排,所以是 O(n log n);
// 这里每次只走一边,工作量是 n + n/2 + n/4 + … = 2n,所以平均 **O(n)**。
//
// (等比数列求和:每次问题规模减半,总工作量只是第一次的两倍。
// 这个「只往一边走」的结构,其实第 8 章的二分就是它的极端版本 ——
// 二分每次只做 O(1) 的工作,所以是 O(log n)。)
//
// 这类「每次砍掉一部分、只在剩下的里面继续找」的做法,有个专门的名字叫**减治**
// (decrease and conquer),区别于两边都要处理的**分治**。
//
// ⚠ 和快排一样,基准必须随机选,否则会被有序数据卡成 O(n²)。
//
// 顺带一提:C++ 标准库里这个功能叫 nth_element(first, first + k - 1, last),
// 比赛里直接用它就行 —— 但手写一遍能让你真正理解「只递归一边」这件事。
#include <bits/stdc++.h>
using namespace std;
vector<long long> a;
mt19937 rng(20241202u);
/** 返回 a[l..r] 里第 k 小的数(k 从 1 开始,相对这一段而言) */
long long quickSelect(int l, int r, int k) {
if (l >= r) return a[l];
int p = l + (int)(rng() % (unsigned)(r - l + 1));
swap(a[l], a[p]);
long long pivot = a[l];
int i = l, j = r;
while (i < j) {
while (i < j && a[j] >= pivot) j--;
while (i < j && a[i] <= pivot) i++;
if (i < j) swap(a[i], a[j]);
}
swap(a[l], a[i]); // 基准归位到下标 i
int leftLen = i - l + 1; // 左半段(含基准)有几个数
if (k == leftLen) return a[i]; // 基准就是第 k 小
if (k < leftLen) return quickSelect(l, i - 1, k); // 只走左边
return quickSelect(i + 1, r, k - leftLen); // 只走右边
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, k;
if (!(cin >> n >> k)) return 0;
if (n <= 0 || k < 1 || k > n) { cout << 0 << "\n"; return 0; }
a.assign(n, 0);
for (int i = 0; i < n; i++) cin >> a[i];
cout << quickSelect(0, n - 1, k) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
selectTrace.cpp过程演示
盯住「还剩多少个候选」这个数字 —— 它一路减半。最后一行会告诉你累计处理了多少元素,那个数接近 2n。
// 快速选择 —— 把「每次只走一边」打印出来
//
// 输入:n k / n 个整数(用小数据)
// 输出:每一轮的候选区间、选的基准、划分结果,以及往哪边走
//
// 跑一遍,盯住「还剩多少个候选」这个数字:
// 它大致是 n → n/2 → n/4 → … 一路减半。
//
// 加起来 n + n/2 + n/4 + … ≈ 2n,所以平均是 O(n)。
// **每次只走一边,总工作量只是第一次的两倍** —— 这就是减治的威力。
//
// 对比一下快排:它两边都要走,所以每一层的总量都还是 n,共 log n 层。
#include <bits/stdc++.h>
using namespace std;
vector<long long> a;
mt19937 rng(20241203u);
int rounds = 0, totalWork = 0;
void show(int l, int r, int i) {
cout << " [";
for (int p = l; p <= r; p++) {
if (p == i) cout << "(" << a[p] << ")";
else cout << a[p];
cout << (p == r ? "" : " ");
}
cout << "]\n";
}
long long quickSelect(int l, int r, int k) {
if (l >= r) {
cout << " 区间只剩一个数 " << a[l] << " —— 它就是答案\n";
return a[l];
}
rounds++;
totalWork += r - l + 1;
cout << " 第 " << rounds << " 轮:候选区间 [" << l << ", " << r << "],还剩 "
<< (r - l + 1) << " 个候选,要找其中第 " << k << " 小\n";
int p = l + (int)(rng() % (unsigned)(r - l + 1));
swap(a[l], a[p]);
long long pivot = a[l];
cout << " 随机选基准 = " << pivot << "\n";
int i = l, j = r;
while (i < j) {
while (i < j && a[j] >= pivot) j--;
while (i < j && a[i] <= pivot) i++;
if (i < j) swap(a[i], a[j]);
}
swap(a[l], a[i]);
cout << " 划分后(括号里是基准,它已经在最终位置上了):\n";
show(l, r, i);
int leftLen = i - l + 1;
if (k == leftLen) {
cout << " 基准正好是第 " << k << " 小 —— 答案就是它\n";
return a[i];
}
if (k < leftLen) {
cout << " 第 " << k << " 小在基准左边 → 只递归左边,右边 "
<< (r - i) << " 个数**再也不看了**\n";
return quickSelect(l, i - 1, k);
}
cout << " 第 " << k << " 小在基准右边 → 只递归右边,左边 "
<< leftLen << " 个数**再也不看了**\n";
return quickSelect(i + 1, r, k - leftLen);
}
int main() {
int n, k;
if (!(cin >> n >> k)) return 0;
if (n <= 0 || n > 30 || k < 1 || k > n) { cout << "这份是用来看过程的,请用 1 <= k <= n <= 30\n"; return 0; }
a.assign(n, 0);
for (int i = 0; i < n; i++) cin >> a[i];
cout << "数组:";
for (int i = 0; i < n; i++) cout << a[i] << " ";
cout << " 要找第 " << k << " 小\n\n";
long long ans = quickSelect(0, n - 1, k);
cout << "\n答案 = " << ans << "\n";
cout << "一共 " << rounds << " 轮,累计处理了 " << totalWork << " 个元素(n = " << n
<< ",注意它接近 2n 而不是 n·log n)\n";
return 0;
}
点一下即可编辑
输入(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 000 0.09 秒 0.04 秒
3 000 000 0.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 小 —— 快速选择(quickselect)
//
// 输入输出和 selectBrute.cpp 完全一样,但平均只要 O(n)。
//
// ============ 和快排的唯一区别:只递归一边 ============
//
// 快排划分完之后,基准就待在它最终的位置 p 上了:
// 左边全部 <= 基准,右边全部 >= 基准。
//
// 于是:
// p == k-1 → 基准就是答案,直接返回
// p > k-1 → 第 k 小在左边,**只递归左边**
// p < k-1 → 第 k 小在右边,**只递归右边**
//
// 快排两边都要排,所以是 O(n log n);
// 这里每次只走一边,工作量是 n + n/2 + n/4 + … = 2n,所以平均 **O(n)**。
//
// (等比数列求和:每次问题规模减半,总工作量只是第一次的两倍。
// 这个「只往一边走」的结构,其实第 8 章的二分就是它的极端版本 ——
// 二分每次只做 O(1) 的工作,所以是 O(log n)。)
//
// 这类「每次砍掉一部分、只在剩下的里面继续找」的做法,有个专门的名字叫**减治**
// (decrease and conquer),区别于两边都要处理的**分治**。
//
// ⚠ 和快排一样,基准必须随机选,否则会被有序数据卡成 O(n²)。
//
// 顺带一提:C++ 标准库里这个功能叫 nth_element(first, first + k - 1, last),
// 比赛里直接用它就行 —— 但手写一遍能让你真正理解「只递归一边」这件事。
#include <bits/stdc++.h>
using namespace std;
vector<long long> a;
mt19937 rng(20241202u);
/** 返回 a[l..r] 里第 k 小的数(k 从 1 开始,相对这一段而言) */
long long quickSelect(int l, int r, int k) {
if (l >= r) return a[l];
int p = l + (int)(rng() % (unsigned)(r - l + 1));
swap(a[l], a[p]);
long long pivot = a[l];
int i = l, j = r;
while (i < j) {
while (i < j && a[j] >= pivot) j--;
while (i < j && a[i] <= pivot) i++;
if (i < j) swap(a[i], a[j]);
}
swap(a[l], a[i]); // 基准归位到下标 i
int leftLen = i - l + 1; // 左半段(含基准)有几个数
if (k == leftLen) return a[i]; // 基准就是第 k 小
if (k < leftLen) return quickSelect(l, i - 1, k); // 只走左边
return quickSelect(i + 1, r, k - leftLen); // 只走右边
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, k;
if (!(cin >> n >> k)) return 0;
if (n <= 0 || k < 1 || k > n) { cout << 0 << "\n"; return 0; }
a.assign(n, 0);
for (int i = 0; i < n; i++) cin >> a[i];
cout << quickSelect(0, n - 1, k) << "\n";
return 0;
}
点一下即可编辑

值得故意写错的:

  • 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 章那个「函数调用自己」, 只是把「数字变小」换成了「走到下一个格子」。