分治法求第k小的数
这周的作业是写一篇关于分治法的博客:描述求第 k 小的数的分治算法、分析它的复杂度,再谈谈对分治法的体会。课上正好讲了这个问题的做法,我把它和自己的理解整理成了下面这篇。
问题描述
给定一个长度为 n 的数组 A 和一个整数 k(1 ≤ k ≤ n),求 A 中第 k 小的数。
例如 A = [7, 10, 4, 3, 20, 15],k = 3,第 3 小的数是 7。
最直接的做法是先把数组排好序,再取下标 k-1 的元素——排序至少是 O(n log n),而且做了很多没用的工作:我们只关心排在第 k 位的是谁,其余元素之间的顺序其实无所谓。
分治法给了另一条路:不用完整排序,借用快速排序的 partition(划分)操作,每次只往包含答案的那一侧继续找。这个做法通常叫快速选择(quickselect)。
思路与分析
算法描述(自然语言)
- 在当前区间 A[lo..hi] 里取一个基准元素 pivot,这里固定取区间的第一个元素 A[lo];
- 对区间做一次划分(partition):重排区间内的元素,让比 pivot 小的都在左边、比 pivot 大的都在右边,最后把 pivot 放到分界位置,记它的下标为 p;
- 记 rank = p - lo + 1,它表示基准是当前区间里第 rank 小的元素。比较 k 和 rank:
- k = rank:答案就是 A[p],直接返回;
- k < rank:答案在左半边,继续在 A[lo..p-1] 里找第 k 小;
- k > rank:答案在右半边,继续在 A[p+1..hi] 里找第 k - rank 小;
- 边界:区间只剩一个元素时直接返回它。
关键在第三步:每次划分之后都能确定答案在基准的哪一侧,然后把另一侧整段丢掉,只对剩下的那一侧继续分治。
伪代码:
SELECT(A, lo, hi, k): // 在 A[lo..hi] 中找第 k 小(k 从 1 计)
if lo == hi: return A[lo]
p ← PARTITION(A, lo, hi) // 取 A[lo] 为基准,返回基准归位后的下标
rank ← p - lo + 1 // 基准是当前区间里第 rank 小
if k == rank: return A[p]
else if k < rank: return SELECT(A, lo, p - 1, k)
else: return SELECT(A, p + 1, hi, k - rank)
其中 PARTITION 就是快速排序里的那一步划分:以 A[lo] 为基准,把区间重排成"小于基准的在左、大于基准的在右",基准归位后返回它的下标。
有一个容易写错的细节:进入右半边递归时,目标排名要更新成 k - rank。左半边连同基准一共 rank 个元素已经被排除,答案的排名会相应减小。
复杂度分析
每次划分都要扫描当前区间的所有元素,代价与区间长度成正比;划分之后,只需要继续处理答案所在的那一侧。
最好情况:O(n)
最好情况是每次划分后,基准都正好落在区间中间,把当前区间对半分。此时递推式为 \(T(n) = T(n/2) + cn\),展开得 \(T(n) \le cn(1 + 1/2 + 1/4 + \dots) \le 2cn = O(n)\)。每层只做一次线性扫描,而且只进入一半的递归,所以整体是线性的。
最坏情况:O(n²)
最坏情况出现在基准每次都选得很"偏"的时候。以固定取首元素为基准为例:如果数组已经基本有序,基准恰好是当前区间里的最小元素,划分后它左边为空、右边还剩 n-1 个元素——问题规模每层只缩小 1,递推式变成 \(T(n) = T(n-1) + cn = O(n^2)\)。这和快速排序退化成 O(n²) 是同一个原因:每层都只排除一个元素,却付了一次线性扫描的代价。
作为对比:先把数组完整排序再取对应位置,需要 O(n log n);而这个算法顺利的时候只要 O(n),代价是最坏情况会退化。
体会与思考
- 分治的效率来自"只处理一侧"。这个算法和快速排序用的是同一个划分动作,但快速排序递归时左右两边都要处理,它每次只进入与答案有关的那一侧,另一侧整段丢掉——不用完整排序,也不需要把两半合起来。
- 分析最坏情况时,要主动去构造"最坏的输入"。基准固定取首元素时,接近有序的数组会让每层只排除一个元素,复杂度直接退化到 O(n²)。这提醒我:看一个算法不能只算顺利的时候,还得想清楚什么样的数据会把它逼到最坏。
- 分治的"合"不一定都需要显式写出来。通常说分治是"分、治、合"三步:这里"分"是划分,"治"是只对一侧递归,而"合"几乎不需要——因为每层划分完就能确定答案在哪一侧,答案的位置在缩小范围的过程中自然就锁定了。

浙公网安备 33010602011771号