第二章找第k小分治法作业
分治法求无序数组中的第 k 小元素
一、问题描述
给定一个无序整数数组 a 和一个整数 k,要求找出数组中按从小到大排序后的第 k 个元素。
例如,数组为:
text
7 2 9 4 1 6
将它升序排列后是:
text
1 2 4 6 7 9
因此,第 3 小的元素是 4。
最直接的方法是先对数组排序,再取第 k 个元素,时间复杂度为 O(nlogn)。但如果我们只需要一个排名对应的元素,而不需要完整的有序数组,就可以使用快速选择算法(Quickselect),利用分治思想减少不必要的计算。
二、核心思想:划分后只处理一侧
快速选择借鉴了快速排序中的“划分”操作:
选择一个基准值 pivot。
将数组划分,使得基准值左侧的元素都小于它,右侧的元素都不小于它。
基准值经过划分后,会落到它在当前数组范围内的最终位置。
根据基准位置与目标位置的关系,只继续处理可能包含答案的一侧。
设当前处理区间为 [left, right],目标下标为 target:
如果基准的最终下标等于 target,基准就是答案。
如果基准的最终下标大于 target,答案只可能在左侧。
如果基准的最终下标小于 target,答案只可能在右侧。
与快速排序不同,快速选择不需要同时递归处理左右两边。每轮划分后,另一侧已经可以排除,因此通常能更快地找到目标元素。
三、第 k 小与数组下标的关系
题目中的 k 通常从 1 开始计数,而数组下标从 0 开始,因此:
target=k−1
例如,第 3 小的元素对应数组下标 2。
在快速选择的过程中,我们可以一直使用这个目标下标,而不必每轮重新调整 k。只要划分基于数组的真实下标,直接比较基准下标与 target 即可。
四、划分过程示例
以数组:
text
7 2 9 4 1 6
为例,寻找第 3 小的元素。目标下标为:
text
target = 3 - 1 = 2
假设第一轮选择 6 作为基准。划分后可能得到:
text
2 4 1 6 9 7
基准 6 的下标是 3。由于目标下标 2 小于 3,答案只可能在基准左侧,因此继续处理左侧区间:
text
2 4 1
假设下一轮选 4 作为基准,划分后得到:
text
2 1 4
4 的下标是 2,恰好等于目标下标,因此第 3 小的元素就是 4。
这里并不要求整个数组在算法结束后完全有序。算法只需要确定目标位置上的元素。
五、C++ 实现
下面的实现使用随机基准,并采用 Lomuto 划分方式。程序输入数组长度 n、要查找的排名 k,以及数组中的 n 个整数。
cpp
include
include
include
include
include
using namespace std;
// 将区间 [left, right] 按 pivot 划分。
// 返回 pivot 最终所在的下标。
// 划分完成后:
// a[left ... pivotIndex - 1] < pivot
// a[pivotIndex] == pivot
// a[pivotIndex + 1 ... right] >= pivot
int partition(vector
// 随机选择一个基准,并将其交换到区间末尾
uniform_int_distribution
int pivotIndex = dist(rng);
swap(a[pivotIndex], a[right]);
int pivot = a[right];
int storeIndex = left;
for (int i = left; i < right; ++i) {
if (a[i] < pivot) {
swap(a[storeIndex], a[i]);
++storeIndex;
}
}
// 将基准放到它最终应处的位置
swap(a[storeIndex], a[right]);
return storeIndex;
}
// 查找数组中第 k 小的元素,k 从 1 开始
int quickSelect(vector
if (a.empty()) {
throw invalid_argument("数组不能为空");
}
if (k < 1 || k > static_cast
throw out_of_range("k 必须满足 1 <= k <= 数组长度");
}
int target = k - 1;
int left = 0;
int right = static_cast
random_device rd;
mt19937 rng(rd());
while (left <= right) {
int pivotIndex = partition(a, left, right, rng);
if (pivotIndex == target) {
return a[pivotIndex];
} else if (pivotIndex > target) {
right = pivotIndex - 1;
} else {
left = pivotIndex + 1;
}
}
// 按照循环逻辑不会执行到这里
throw runtime_error("查找失败");
}
int main() {
int n, k;
if (!(cin >> n >> k)) {
cerr << "输入格式错误" << endl;
return 1;
}
if (n <= 0) {
cerr << "数组长度必须大于 0" << endl;
return 1;
}
vector
for (int i = 0; i < n; ++i) {
cin >> a[i];
}
try {
cout << quickSelect(a, k) << endl;
} catch (const exception& e) {
cerr << e.what() << endl;
return 1;
}
return 0;
}
输入示例
text
6 3
7 2 9 4 1 6
输出示例
text
4
六、复杂度分析
每一轮划分都需要扫描当前区间,因此如果当前区间长度为 m,这一轮的时间复杂度为
Θ(m)。
- 期望时间复杂度
使用随机基准时,划分通常不会长期保持极端不平衡。快速选择的期望时间复杂度为 Θ(n)。
均衡划分的直观情况下,算法只继续处理其中一侧,后续问题规模逐步减小。每轮划分虽然需要扫描当前区间,但总处理量通常与数组长度成线性关系。
-
最坏时间复杂度
如果每次选出的基准都恰好是当前区间的最小值或最大值,算法每次只能排除一个元素。此时的递推关系为:T(n)=T(n−1)+Θ(n)
因此最坏时间复杂度为:Θ(n2)
随机选择基准可以降低特定输入反复触发最坏划分的可能性,但不能保证最坏时间复杂度为线性。 -
空间复杂度
上面的实现使用循环而不是递归,除输入数组外只使用常数额外空间,因此辅助空间复杂度为:O(1)
七、与快速排序的区别
快速排序在每次划分后,需要继续处理左、右两个子区间,目标是将整个数组排序。其平均时间复杂度为
O(nlogn)。
快速选择只需要找到一个排名对应的元素。划分后,它会根据目标下标,只继续处理目标所在的一侧,因此期望时间复杂度为
Θ(n)。
可以简单概括为:
快速排序: 两侧都要处理,得到完整有序数组。
快速选择: 只处理一侧,得到指定排名的元素。
八、重复元素与三路划分
上面的划分方法将严格小于基准的元素放到左侧,其余元素放到右侧。即使数组中存在重复元素,它也仍然能够正确返回第 k 小的值。不过,当数组中有大量重复元素,尤其是所有元素都相同时,普通划分可能反复得到很不均衡的结果,导致性能退化。对此可以使用三路划分,将当前区间划分为:
小于基准的部分;
等于基准的部分;
大于基准的部分。
若目标下标落在“等于基准”的范围内,就可以直接返回基准值;否则只需继续处理目标所在的区域。三路划分在重复元素较多时通常更高效。
九、最坏情况下的线性选择:BFPRT
如果应用场景要求严格保证最坏时间复杂度为
Θ(n),可以使用 BFPRT 算法,也称为“中位数的中位数”算法。
它通过将元素分组、选取各组中位数,再递归寻找这些中位数的中位数,来构造较有保障的基准。这样每轮划分都能排除一定比例的元素,从而保证最坏情况下仍为线性时间。
BFPRT 的理论保证更强,但实现复杂,常数开销也较大。一般工程实践中,如果不要求最坏情况保证,随机化快速选择通常更简单实用。
十、总结
快速选择利用分治思想解决无序数组中的第 k 小元素问题。它先通过基准划分确定基准元素的排名,再根据目标位置只处理可能包含答案的一侧。
它的核心优势是:不必对整个数组排序,也不必处理与目标无关的子问题。随机化实现的期望时间复杂度为 Θ(n),但最坏情况仍可能达到 Θ(n 2 );若需要最坏情况下的线性保证,可以考虑 BFPRT。
分治法的关键不仅是把问题拆开,更在于利用每次拆分获得的信息,及时排除不可能包含答案的部分。

浙公网安备 33010602011771号