算法第二章实践作业

分治法求第 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 分治法的基本思想

分治法的核心思想是将一个较大的问题划分为规模更小、与原问题具有相同性质的子问题,再通过解决子问题得到原问题的答案。

一般来说,分治法包含三个基本步骤:

  1. 分解问题: 把原问题划分成若干个规模较小的子问题。
  2. 解决子问题: 通过递归或其他方式处理子问题。
  3. 合并结果: 将子问题的结果组合起来,得到原问题的解。

不过,在求第 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++ 实现中,合理处理边界、重复元素以及基准值选择等问题,也是保证算法正确性和运行效率的重要环节。

通过这个问题,我进一步理解了分治法的核心不只是将问题拆分,而是根据问题的特点缩小处理范围,尽可能避免不必要的工作。这种先分析问题、再选择算法的思路,也是我今后学习数据结构与算法时需要不断练习的地方。

posted @ 2026-10-11 15:51  张三10086  阅读(7)  评论(0)    收藏  举报