分治法(3):快速选择算法

Last updated on May 5, 2025 pm

本文从经典的选择问题出发,即寻找第k小的数,介绍了简单分治法,并用随机数方法中位数的中位数算法(Median of Medians)进行了改进。

让我们考虑一个经典的选择问题。给定一列整数x1,x2,,xnx_1, x_2, \ldots, x_n(记作SS)和一个整数kk,找出这列数中第kk小的整数xx^*

朴素分治法

首先,让我们从一些简单的想法开始。

一些简单想法

两种最容易想到的思路是:

  • 逐一选择法。先遍历数组,选出最小的元素。再次遍历数组,选出第二小的元素。重复上述操作,直到选出第kk小的元素。该算法时间复杂度为O(nk)O(nk)
  • 排序选择法。先将整个数组从小到大排序,再找到第kk个元素即可。该算法的时间复杂度为O(nlogn)O(n \log n)

我们可以用分治法做得更好吗?

分治法设计

与之前的思路不同,这次我们并不能将原数组平均划成两部分,在每一部分中找第kk小的数之后合并。因为每一部分第kk小的数不一定存在,而且我们无法合并出原数组第kk小的数。因此,我们需要设计新的思路。

这里我们考虑快速排序的划分思路。我们先从数组中任取一个枢轴vv,用vv将数组划分成三部分,分别是所有元素小于vvLL、所有元素等于vvMM、所有元素大于vvRR,再从包含xx^*的部分中递归地找到xx^*即可。

朴素分治法设计

以下面的数组为例,我们选择v=3v = 3作为枢轴,将数组划分为三部分。

朴素分治法数组示例

这样,我们可以得到一个大致排序的数组。这样,根据kk的不同,我们可以在不同的部分中寻找xx^*

朴素分治法划分示例

  • 如果kLk \le |L|,表明xx^*LL中,故我们在LL中找第kk小的元素。
  • 如果L<kL+M|L| < k \le |L| + |M|,表明xx^*MM中,输出vv即可。
  • 如果k>L+Mk > |L| + |M|,表明xx^*RR中,故我们在RR中找第kLMk - |L| - |M|小的元素。

朴素分治法动画(以k=4为例)

由此,我们可以写出整个算法。

朴素分治法设计及时间复杂度分析

这个算法的正确性可以由基于数组大小nn的数学归纳法证明。

时间复杂度分析

从上图的分析中可以看出,该算法的时间复杂度

T(n)O(n)+max{T(L),T(R)}T(n) \le O(n) + max\{T(L), T(R)\}

由于 L+M+R=n|L| + |M| + |R| = nM1|M| \ge 1,从而有 L,Rn1|L|, |R| \le n - 1,故

T(n)O(n)+T(n1)O(n)+O(n1)+T(n2)O(n)+O(n1)++O(1)=O(n2)\begin{aligned} T(n) \le& \, O(n) + T(n - 1) \\ \le& \, O(n) + O(n - 1) + T(n - 2) \\ \le& \, \cdots \\ \le& \, O(n) + O(n - 1) + \cdots + O(1) \\ =& \, O(n^2) \end{aligned}

这样的时间复杂度是很糟糕的,甚至比逐一选择法的O(nk)O(nk)和排序选择法的O(nlogn)O(n \log n)还要差。

这是因为我们的放缩太松了吗?还是时间复杂度确实很差?让我们考虑一种最不幸的情况,即k=1k = 1,且每次vv都选到了最大的元素。那么我们每次递归nn只能减小1,即

T(n)=O(n)+T(n1)=O(n)+O(n1)+T(n2)==O(n2)T(n) = O(n) + T(n - 1) = O(n) + O(n - 1) + T(n - 2) = \cdots = O(n^2)

这表明,这个算法最坏情况下的时间复杂度确实是O(n2)\bm{O(n^2)}

那如果我们超级幸运呢?也就是每次都能选到中位数作为vv。那么我们有

T(n)=O(n)+T(n2)=O(n)+O(n2)+T(n4)==O(n)T(n) = O(n) + T\left(\frac{n}{2}\right) = O(n) + O\left(\frac{n}{2}\right) + T\left(\frac{n}{4}\right) = \cdots = O(n)

但是,选到中位数的要求过高,它甚至和一次选中第kk小的元素难度相同。事实上,我们并不用那么幸运。考虑如果我们每次选中的vv在中位数附近,或者更精确地说,在[13n,23n]\left[\frac{1}{3}n, \frac{2}{3}n\right]范围内,那么我们有

T(n)=O(n)+T(23n)=O(n)+O(23n)+T(49n)==O(n)T(n) = O(n) + T\left(\frac{2}{3}n\right) = O(n) + O\left(\frac{2}{3}n\right) + T\left(\frac{4}{9}n\right) = \cdots = O(n)

在比较幸运的情况下,我们也能达到O(n)O(n)的时间复杂度!基于这个发现,我们可以想到第一个改进方法——采用随机性来选到比较理想的枢轴

用随机性改进算法

为了加入枢轴选择的随机性,我们只需要在朴素分治法的设计中修改一个词,将“任意选择枢轴”改为“随机选择枢轴”。看似微小的改动,会对算法性能产生多大的改变呢?

采用随机性的快速选择算法

我们何时幸运?

为了分析时间复杂度,我们需要区分枢轴的好坏,即何时我们算是“幸运的”。在这里,我们定义落在中间S2\frac{|S|}{2}区域的是好枢轴。

枢轴区域划分

那么,我们会有如下的两个事实:

  1. 我们有12\dfrac{1}{2}的概率是幸运的。
  2. 如果我们总是幸运的,那么有

T(n)=T(3n4)+O(n)=O(n)T(n) = T\left(\frac{3n}{4}\right) + O(n) = O(n)

时间复杂度的数学期望

然而,我们并不总是幸运的,T(n)T(n)也会因为枢轴选择的不同而变化。因此,对于随机算法,我们一般会研究时间复杂度的的数学期望

τ(n)\tau(n)表示我们将问题规模从nn降到3n4\dfrac{3n}{4}需要的时间,那么我们有

T(n)=τ(n)+T(3n4)T(n) = \tau(n) + T\left(\frac{3n}{4}\right)

两边取数学期望,得

E[T(n)]=E[τ(n)+T(3n4)]=E(τ(n))+E[T(3n4)]E[T(n)] = E\left[\tau(n) + T\left(\frac{3n}{4}\right)\right] = E(\tau(n)) + E\left[T\left(\frac{3n}{4}\right)\right]

选到一次好枢轴,问题规模就能变为原来的34\dfrac{3}{4},而选到坏枢轴只能让问题规模减小1。由于我们每一轮选到好枢轴的概率是12\dfrac{1}{2},我们选到好枢轴所需轮数的数学期望为2,因此E(τ(n))=O(2n)=O(n)E(\tau(n)) = O(2n) = O(n)

选到好枢轴所需轮数XX服从p=12p = \dfrac{1}{2}的几何分布,即XG(12)X \sim G\left(\dfrac{1}{2}\right)。根据概率论,其数学期望E(X)=2E(X) = 2

从而,

E[T(n)]=O(n)+E[T(3n4)]=O(n)E[T(n)] = O(n) + E\left[T\left(\frac{3n}{4}\right)\right] = \bm{O(n)}

事实上,对于这样的随机算法,我们还可以研究最坏运行时间、时间复杂度为O(n)O(n)的概率等。

中位数的中位数算法

那么如果我们不引入随机性,是否有其他改进的方法呢?这次,我们既不“任意选择枢轴”也不“随机选择枢轴”,而是尝试“选一个好的枢轴”。所谓“好的枢轴”,当然是指靠近中部的元素,这样才能使我们的问题规模快速缩小。

非随机性的快速选择算法设计

但是,这其实存在着一种平衡,即找到好枢轴所需时间和枢轴质量之间的平衡。我们可以通过排序找到中位数(最好的枢轴),但是这会花费太多时间;我们也可以任意选择一个枢轴,但不好的枢轴会导致算法性能较差。也就是说,当枢轴质量上升,找到它所需的时间也会增加。总的运行时间可以表示为

T(n)=T(cn)+findPivot+O(n)T(n) = T(c \cdot n) + findPivot + O(n)

那么,我们如何才能花较小的代价,找到更好的枢轴呢?这里,我们介绍中位数的中位数算法(Median of Medians)

算法设计

以下图为例,给定一个数集SS

  1. 先把SS划分为大小为5的子集。

将S划分为大小为5的子集示意图

  1. 在每个子集中找出中位数,记作v1v_1, v2v_2, v3v_3

在子集中找出中位数示意图

  1. 将枢轴vv取为v1v_1, v2v_2, v3v_3的中位数。

算法分析

那么,这个找枢轴的过程需要花费多少时间呢?不难发现,步骤1需要O(n)O(n),步骤2需要O(n)O(n)(因为在5个数中找中位数只需要O(1)O(1))。但是步骤3呢?想要找到子集的中位数,我们只能递归地寻找,因此需要T(n5)T\left(\dfrac{n}{5}\right)。故寻找枢轴的时间为T(n5)+O(n)\bm{T\left(\dfrac{n}{5}\right) + O(n)}

问题是,这样找到的枢轴质量究竟如何?看起来,它是中位数的中位数,所以应该大致位于中间位置。让我们定量分析一下。

中位数的中位数算法选取枢轴示意图

上图中,每一行表示SS的一个子集,其中的整数从小到大排列。从上至下,各个子集按中位数的大小从小到大排列。中央的vv是算法选出的枢轴,蓝色部分都不大于vv,紫色部分都不小于vv。我们可以发现,

  • 我们有n5\dfrac{n}{5}个子集,所以有n5\dfrac{n}{5}个中位数。
  • vv不大于n10\dfrac{n}{10}个中位数,同时不小于n10\dfrac{n}{10}个中位数。
  • 每个中位数都不小于同组的2个整数,同时不大于同组的2个整数。
  • vv不大于3n10\dfrac{3n}{10}个整数,同时不小于3n10\dfrac{3n}{10}个整数。

因此,我们有max{T(L),T(R)}T(7n10)\bm{\max\{T(|L|), T(|R|)\} \le T\left(\dfrac{7n}{10}\right)}

这样,我们就可以逐步分析该算法的时间复杂度。

中位数的中位数算法的时间复杂度分析

因此,我们可以得到

T(n)=T(n5)+T(7n10)+O(n)=T(0.2n)+T(0.7n)+O(n)T(n) = T\left(\frac{n}{5}\right) + T\left(\frac{7n}{10}\right) + O(n) = T(0.2n) + T(0.7n) + O(n)

这个式子无法用主定理化简,但我们可以先尝试用树形图猜测T(n)T(n)

中位数的中位数算法的树形图分析

注意到,第0层的合并代价为O(n)O(n),第1层的合并代价为O(0.9n)O(0.9n),第2层的合并代价为O(0.81n)O(0.81n)\ldots,第k层的合并代价为O(0.9kn)O(0.9^k n)。我们可以猜测,每层的合并代价构成等比数列,其最终的和为O(n)O(n)

接下来,我们用数学归纳法证明T(n)=O(n)\bm{T(n) = O(n)},即证明T(n)BnT(n) \le Bn

由于T(n)=T(0.2n)+T(0.7n)+O(n)T(n) = T(0.2n) + T(0.7n) + O(n),我们设此处O(n)CnO(n) \le Cn

  • 基本情况:T(1)=1BnT(1) = 1 \le Bn
  • 归纳:

T(n)T(0.2n)+T(0.7n)+Cn0.9Bn+CnBn\begin{aligned} T(n) \le& \, T(0.2n) + T(0.7n) + Cn \\ \le& \, 0.9Bn + Cn \\ \le& \, Bn \end{aligned}

B=10CB = 10C,那么我们就有T(n)10Cn=O(n)T(n) \le 10Cn = O(n)

CC表示的是取枢轴、划分数组等操作的数量。如果取C=4C = 4,那么T(n)40nT(n) \le 40n!由此可以看出,该算法虽然时间复杂度为O(n)O(n),但nn前面的常数其实很大。

请小心,在数学归纳法中不要使用大OO表示法,因为这可能会造成问题。

例如在朴素分治法中,我们有

T(n)=T(n1)+O(n)T(n) = T(n - 1) + O(n)

如果假设T(n)=O(n)T(n) = O(n),采用

T(n)=T(n1)+O(n)=O(n)+O(n)=O(n)T(n) = T(n - 1) + O(n) = O(n) + O(n) = O(n)

这样的归纳法证明,这将会是巨大的错误。

这是因为在归纳中,我们需要保证nn前的常数不变,而不能和nn有关。

事实上,常数个O(n)O(n)的和仍然是O(n)O(n),但这里的O(n)O(n)是递归的,从而nn前面的常数取决于递归层数,与nn的大小有关,这将会导致该常数随nn的增大而不断增大,无法找到上界。

T(n)=T(0.4n)+T(0.7n)T(n) = T(0.4n) + T(0.7n)为例。如果认为T(n)nT(n) \le n,采用

T(n)0.4n+0.7n=1.1nT(n) \le 0.4n + 0.7n = 1.1n

会发现问题。
而如果直接采用大OO表示法,

T(n)O(n)+O(n)=O(n)T(n) \le O(n) + O(n) = O(n)

会得到荒谬的结论!

事实上,每递归一层,其中蕴含的常数就会以变为1.1倍,而不能用某一常数当作上界,也就不能用O(n)O(n)表示!

因此,我们在用数学归纳法证明时,必须展开O(n)O(n)前的常数

注:本文中所有图片均来自张宇昊老师的课程PPT。


分治法(3):快速选择算法
https://cny123222.github.io/2025/03/11/分治法-3-:快速选择算法/
Author
Nuoyan Chen
Posted on
March 11, 2025
Licensed under