算法第二章实践作业
分治法求第 k 小的数:从算法思路到 C++ 实现
一、问题引入
在学习数据结构与算法的过程中,我发现,很多问题不一定要通过完整地排序才能得到答案。例如,给定一个无序数组,要求找出其中第 k 小的数,如果直接对整个数组进行排序,再取出第 k 个元素,虽然方法比较容易理解,但当我们只需要其中一个元素时,进行完整排序可能会做一些不必要的工作。
例如,给定数组:
A = {7, 2, 9, 4, 1, 6, 3}
要求找出第 3 小的数。把数组从小到大排序后,结果为:
{1, 2, 3, 4, 6, 7, 9}
因此,第 3 小的数是 3。
如果采用普通排序算法,需要先把所有元素排好序,再取出对应位置的元素。而利用分治的思想,可以通过选择一个基准元素,将数组划分为不同的部分,再根据第 k 小的数所在的位置,只对其中一部分继续处理,从而减少不必要的计算。
本文主要介绍利用快速选择算法(Quickselect)寻找第 k 小的数,并分析它的时间复杂度,最后结合 C++ 的学习过程,谈谈我对分治法的理解。
二、利用分治法寻找第 k 小的数
2.1 分治法的基本思想
分治法的核心思想是将一个较大的问题划分为规模更小、与原问题具有相同性质的子问题,再通过解决子问题得到原问题的答案。
一般来说,分治法包含三个基本步骤:
- 分解问题: 把原问题划分成若干个规模较小的子问题。
- 解决子问题: 通过递归或其他方式处理子问题。
- 合并结果: 将子问题的结果组合起来,得到原问题的解。
不过,在求第 k 小的数时,并不需要把所有子问题都解决。我们只需要确定目标元素在哪一部分,然后继续处理这一部分即可。
因此,快速选择算法虽然采用了类似快速排序的划分思想,但两者之间有一个重要区别:快速排序需要递归处理划分后的两部分,而快速选择通常只需要继续处理其中一部分。
2.2 算法的具体思路
首先,需要在数组中选择一个基准元素(pivot)。为了提高算法在不同输入情况下的表现,这里可以随机选择一个元素作为基准。
接着,对当前处理的数组区间进行三路划分,将元素分成三部分:
- 小于基准值的部分: 所有元素都小于 pivot。
- 等于基准值的部分: 所有元素都等于 pivot。
- 大于基准值的部分: 所有元素都大于 pivot。
经过划分后,可以根据目标位置判断下一步应该怎么做。
假设数组采用从 0 开始的下标,原问题中的第 k 小对应的目标下标就是 k - 1。
如果目标下标落在小于基准值的部分,说明要找的数在左边,继续处理左边的区间;如果目标下标落在大于基准值的部分,说明要找的数在右边,继续处理右边的区间;如果目标下标落在等于基准值的部分,那么基准值就是我们要找的数,可以直接返回结果。
这里使用三路划分还有一个好处:当数组中存在多个相同元素时,可以一次性处理所有等于基准值的元素,避免重复处理相同的数值。
需要注意的是,划分完成后,数组不一定是有序的。算法只是确定了不同元素与基准值之间的大小关系,并逐步缩小查找范围。
2.3 自然语言描述
整个算法可以用以下步骤描述:
第一步,检查 k 是否合法。因为数组中有 n 个元素,所以 k 必须满足 1 ≤ k ≤ n。
第二步,将目标下标设置为 k - 1,并把当前查找范围设置为整个数组。
第三步,在当前范围内随机选择一个基准元素,并通过三路划分,将元素分成小于、等于和大于基准值的三个区域。
第四步,判断目标下标所在的区域。如果位于左侧区域,就缩小范围,只在左侧继续查找;如果位于右侧区域,就只在右侧继续查找;如果位于中间区域,说明已经找到第 k 小的数,可以直接返回。
第五步,不断重复上述过程,直到找到目标元素。
这种方法并不需要对整个数组进行排序,而是根据每次划分的结果决定下一步的搜索方向。
2.4 伪代码描述
下面使用伪代码表示该算法:
算法:寻找第 k 小的数
输入:数组 A,整数 k
输出:数组中第 k 小的数
1. 如果 k < 1 或 k > 数组长度 n,则返回错误信息。
2. 设置目标下标 target = k - 1。
设置左边界 left = 0,右边界 right = n - 1。
3. 当 left <= right 时,重复执行:
(1)在 [left, right] 范围内随机选择基准值 pivot。
(2)对当前区间进行三路划分:
得到三个区域:
小于 pivot 的区域 [left, lt - 1]
等于 pivot 的区域 [lt, gt]
大于 pivot 的区域 [gt + 1, right]
(3)如果 target < lt:
说明目标元素在左侧区域,
设置 right = lt - 1。
(4)否则,如果 target > gt:
说明目标元素在右侧区域,
设置 left = gt + 1。
(5)否则:
说明目标元素位于等于 pivot 的区域,
返回 A[target]。
4. 结束。
这里的 lt 和 gt 分别表示等于基准值的区域的左右边界。在实际实现时,划分过程可以直接交换数组元素完成,不需要额外建立三个新数组,因此能够节省额外空间。
三、C++ 代码实现
为了更好地理解算法,我使用 C++ 实现了一个基于随机基准值和三路划分的快速选择算法。与直接排序整个数组相比,这个程序只在必要的区间中继续寻找目标元素。
#include <iostream>
#include <vector>
#include <random>
#include <stdexcept>
#include <utility>
using namespace std;
// 三路划分:小于 pivot、等于 pivot、大于 pivot
pair<int, int> partition3(
vector<int>& a, int left, int right, int pivotIndex) {
int pivot = a[pivotIndex];
int lt = left;
int i = left;
int gt = right;
while (i <= gt) {
if (a[i] < pivot) {
swap(a[lt], a[i]);
++lt;
++i;
} else if (a[i] > pivot) {
swap(a[i], a[gt]);
--gt;
} else {
++i;
}
}
// 返回等于 pivot 的区域的左右边界
return {lt, gt};
}
// 寻找数组中第 k 小的数,k 从 1 开始
int kthSmallest(vector<int>& a, int k) {
if (k < 1 || k > static_cast<int>(a.size())) {
throw invalid_argument("k 的取值不合法");
}
int target = k - 1;
int left = 0;
int right = static_cast<int>(a.size()) - 1;
mt19937 rng(random_device{}());
while (left <= right) {
uniform_int_distribution<int> dist(left, right);
int pivotIndex = dist(rng);
pair<int, int> range =
partition3(a, left, right, pivotIndex);
int lt = range.first;
int gt = range.second;
if (target < lt) {
right = lt - 1;
} else if (target > gt) {
left = gt + 1;
} else {
return a[target];
}
}
throw logic_error("未找到目标元素");
}
int main() {
vector<int> a = {7, 2, 9, 4, 1, 6, 3};
int k = 3;
try {
cout << "第 " << k << " 小的数是:"
<< kthSmallest(a, k) << endl;
} catch (const invalid_argument& e) {
cout << e.what() << endl;
}
return 0;
}
在上述例子中,程序输出的结果是:
第 3 小的数是:3
由于基准元素是随机选择的,程序每次运行时,划分过程可能不同,但对于相同的输入数组和合法的 k,最终找到的第 k 小的数不会发生变化。
3.1 代码中的几个关键点
第一,为什么目标下标是 k - 1?
C++ 的 vector 下标从 0 开始,而题目通常按照第 1 小、第 2 小的方式描述元素。因此,第 k 小的数在从小到大排列后的下标是 k - 1。
例如,第 1 小的数对应下标 0,第 3 小的数对应下标 2。
第二,三路划分是怎么实现的?
代码中使用 lt、i 和 gt 三个变量。
其中,lt 用来表示小于基准值的区域的下一个位置,i 表示当前正在检查的元素,gt 表示大于基准值的区域的前一个位置。
当 a[i] 小于基准值时,将它交换到左侧;当它大于基准值时,将它交换到右侧;当它等于基准值时,直接检查下一个元素。
这里有一个容易出错的地方:当 a[i] > pivot 时,把它和 a[gt] 交换后,只需要减小 gt,不能立即增加 i。因为交换到当前位置的新元素还没有检查,必须继续判断它的大小关系。
第三,为什么找到基准值后不需要继续排序?
因为经过划分,左侧区域的元素全部小于基准值,右侧区域的元素全部大于基准值,而中间区域的元素都等于基准值。
如果目标下标已经落在中间区域,就可以确定答案。我们不关心其他元素在各自区域内的具体顺序,因此没有必要继续进行排序。
四、算法时间复杂度分析
分析算法的时间复杂度时,不能只看循环执行了多少次,还需要考虑每一轮循环处理了多少个元素。
快速选择算法每一轮都需要对当前区间进行划分。如果当前区间有 m 个元素,那么三路划分最多需要检查这些元素一次,因此这一轮的时间复杂度是 O(m)。
整体时间复杂度取决于每次划分后,下一轮需要处理的区间有多大。
4.1 最好时间复杂度
快速选择算法的最好时间复杂度为:
Θ(n)
当第一次选择的基准值恰好就是目标元素,或者目标下标落在基准值对应的等值区域时,算法只需要进行一次划分就可以找到答案。
虽然只进行一次划分,但仍然需要检查当前数组中的元素,因此第一次划分的时间复杂度为 Θ(n),这也是算法最好情况下的时间复杂度。
此外,如果每次划分都比较均衡,那么递归处理的区间规模大致会减半。此时可以写出递推关系:
T(n) = T(n/2) + cn
其中,cn 表示当前划分所需的时间。不断展开可以得到:
T(n) = cn + cn/2 + cn/4 + ...
这是一个等比数列,其和不超过 2cn,因此时间复杂度仍然是 Θ(n)。
也就是说,即使需要进行多轮划分,只要每次都能将待处理的规模缩小到原来的一半左右,总的工作量仍然是线性的。
4.2 最坏时间复杂度
快速选择算法的最坏时间复杂度为:
Θ(n²)
当每次选择的基准值都非常不理想时,例如总是选择当前区间中的最小值或最大值,并且目标元素始终没有落在基准值对应的等值区域中,算法每次只能排除一个元素。
此时,待处理的区间规模可能依次为:
n、n - 1、n - 2、...、2、1
每一轮都需要对当前区间进行划分,所以总的时间开销为:
T(n) = cn + c(n - 1) + c(n - 2) + ... + c
整理后可得:
T(n) = c[n(n + 1)/2]
去掉常数因子和低阶项后,时间复杂度为 Θ(n²)。
这说明,快速选择算法虽然通常比较高效,但如果基准值选择不当,也可能出现性能明显下降的情况。
4.3 平均或期望时间复杂度
采用随机基准值后,如果输入数据固定,而每一轮都从当前区间中随机选择基准元素,那么快速选择算法的期望时间复杂度为:
Θ(n)
随机选择基准值不能保证每次划分都很均衡,也不能保证每次运行都不会遇到很差的情况,但是能够降低算法反复遇到极端划分的风险。
需要注意,期望线性时间并不意味着每一次运行都一定是线性的。对于某一次具体运行,算法仍然可能遇到较差的基准值选择,从而接近平方级的时间复杂度。
综合来看,可以将算法的时间复杂度整理为:
| 情况 | 时间复杂度 | 主要原因 |
|---|---|---|
| 最好情况 | Θ(n) | 一次划分后即可确定答案 |
| 平均或期望情况 | Θ(n) | 随机划分下,待处理区间期望能够较快缩小 |
| 最坏情况 | Θ(n²) | 每次只能排除很少的元素 |
这里还需要说明,以上分析针对的是快速选择算法。其他选择算法,例如使用专门的中位数选取方法,可以通过更复杂的划分过程实现最坏情况下的线性时间,但实现难度也会有所增加。
4.4 空间复杂度
本文的 C++ 实现采用循环缩小区间,没有通过递归创建大量调用栈帧。三路划分直接在原数组中交换元素,额外使用的主要是边界变量、随机数生成器和少量辅助变量,因此辅助空间复杂度为 O(1)。
这里不把输入数组本身占用的空间计算为辅助空间。如果采用递归实现,最坏情况下还需要考虑递归调用栈的额外开销。
五、结合本章学习谈谈对分治法的体会和思考
5.1 分治法并不意味着一定要处理所有子问题
以前学习分治法时,我容易把重点放在如何将问题拆分成若干个小问题,以及怎样把这些小问题的结果合并起来。学习寻找第 k 小的数之后,我发现,分治法并不一定要求我们把每个子问题都解决。
在这个问题中,算法通过一次划分就能够判断目标元素在哪一部分。如果目标元素在左侧,就没有必要再处理右侧;如果目标元素在右侧,同样可以直接忽略左侧。
这让我认识到,分治法除了可以把大问题拆成小问题,还可以利用问题的性质提前排除不需要处理的部分。相比完整排序,这种方法更符合当前问题的实际需求。
5.2 算法效率与问题本身的需求有关
通过比较排序和快速选择,我发现,即使两种算法处理的是同一个数组,它们的执行过程和效率也可能有很大区别。
如果题目要求把所有元素按从小到大的顺序输出,那么排序算法比较合适。如果只要求找到第 k 小的元素,那么完整排序就不一定是最合适的选择。
因此,在设计算法时,不能只考虑某一种算法是否容易实现,还需要先分析题目真正需要什么结果,再选择合适的方法。
5.3 理解算法时,不能只记住复杂度结论
通过分析快速选择算法的最好、最坏和期望时间复杂度,我也认识到,只记住一个 O(n) 或 O(n²) 的结论是不够的,还需要理解这个结论为什么成立。
例如,快速选择算法的每一轮都需要线性时间进行划分,但它只会继续处理其中一个区域。如果待处理区间缩小得比较快,那么前几轮完成了主要工作,后面的工作量就会逐渐减少;如果每次只排除一个元素,累计开销就可能达到平方级。
这也提醒我,在分析递归算法或分治算法时,需要同时考虑每一轮的工作量、子问题的规模以及子问题的数量,不能只根据算法名称判断复杂度。
5.4 编写 C++ 程序时,也要考虑特殊情况
在实现过程中,我还发现,算法思想正确并不代表代码一定没有问题。例如,k 的范围是否合法、数组中是否存在重复元素、划分时边界变量如何更新,这些细节都会影响程序的正确性。
尤其是处理重复元素时,如果仍然使用简单的二路划分,某些输入可能导致算法反复处理相同值。三路划分可以把所有等于基准值的元素集中起来,在处理重复数据较多的情况时更加合适。
此外,随机选择基准值能够改善算法的期望表现,但不能消除最坏情况。因此,写程序不仅要关注结果是否正确,也要考虑算法在不同输入下的运行表现。
5.5 对后续学习的思考
通过这次学习,我对分治法的理解比以前更加具体了。分治不只是一个把问题拆开的过程,更重要的是找出可以利用的性质,判断哪些部分需要继续处理,哪些部分可以直接舍弃。
在后续学习中,我希望能够把这种思路应用到更多问题中,例如快速排序、归并排序、二分查找以及其他选择问题。同时,我也希望进一步理解不同分治算法之间的区别,学会从正确性证明、时间复杂度和空间复杂度等方面综合分析算法,而不只是根据代码能否运行来判断算法的好坏。
六、总结
本文介绍了利用快速选择算法寻找第 k 小的数的方法。该算法通过选择基准元素并进行划分,只对包含目标位置的区域继续查找,从而避免了对整个数组进行完整排序。
在复杂度方面,算法最好情况下的时间复杂度为 Θ(n),采用随机基准值时的期望时间复杂度为 Θ(n),最坏情况下则可能达到 Θ(n²)。在 C++ 实现中,合理处理边界、重复元素以及基准值选择等问题,也是保证算法正确性和运行效率的重要环节。
通过这个问题,我进一步理解了分治法的核心不只是将问题拆分,而是根据问题的特点缩小处理范围,尽可能避免不必要的工作。这种先分析问题、再选择算法的思路,也是我今后学习数据结构与算法时需要不断练习的地方。

浙公网安备 33010602011771号