第二章作业

一、用伪代码描述找第 k 小的数的分治算法
找第 k 小的数,基本思路其实是从快速排序那里"偷"来的:快排每次选一个基准元素 pivot,把数组分成两半,左边都比 pivot 小,右边都比 pivot 大。那我们划分完之后看一眼 pivot 排在了第几位(记为 p,从 1 开始数):

如果 p 正好等于 k,那 pivot 就是第 k 小的数,直接收工;
如果 k < p,说明第 k 小的数在左半边,递归去左半边找;
如果 k > p,说明第 k 小的数在右半边,而且因为在左半边已经"淘汰"了 p 个数,所以去右半边找第 k - p 小的数。
和快排最大的区别就是:快排两边都要排,而我们只需要递归其中一边,另一边直接扔掉不管了,所以平均下来比快排快。

伪代码如下:

plaintext

Select(A, left, right, k):
if left == right:
return A[left] // 只剩一个元素,它就是答案

pivotIndex = Partition(A, left, right) // 划分,返回基准的最终下标
p = pivotIndex - left + 1 // pivot 是区间里第 p 小

if k == p:
return A[pivotIndex]
else if k < p:
return Select(A, left, pivotIndex - 1, k) // 去左边找第 k 小
else:
return Select(A, pivotIndex + 1, right, k - p) // 去右边找第 k-p 小
其中 Partition 就是快排里那个划分过程:选 A[right] 当基准,用双指针(或者单指针)扫一遍,把比基准小的放左边,比基准大的放右边,最后把基准放到中间,返回它的下标。

二、时间复杂度分析
最好情况:O(n)​

最幸运的情况是第一次划分,pivot 就恰好落在第 k 个位置,一次 Partition(O(n))就结束了。就算不收工那么快,只要每次划分都比较均匀( pivot 大致在中位附近),问题的规模就是 n + n/2 + n/4 + …,这是一个等比数列求和,收敛到 2n,所以仍然是 O(n)​。

最坏情况:O(n²)​

最坏情况是每次选的 pivot 都是当前区间里最大或最小的元素,划分极度不平衡——比如数组已经有序,而我们固定选最后一个元素当 pivot。这样每次只能把问题规模减少 1,总代价是 n + (n-1) + (n-2) + … = O(n²)​。

平均情况:O(n)​

随机选 pivot 的话,可以证明期望时间复杂度是 O(n)。实际写代码时一般会随机挑基准,或者课本上还提到一种"中位数的中位数"(BFPRT)的选法,可以保证划分不至于太烂,把最坏情况也压到 O(n),不过那个选 pivot 本身就要花不少功夫,常数比较大,实际中不如随机化来得实在。

三、对分治法的体会和思考
学完这一章,我对分治法的理解大概可以概括成三个词:分、治、合——把大问题拆成若干个结构相同的小问题,递归地解决小问题,再把结果合并起来。

几点比较深的体会:

分治的关键不在"分",而在"合"的代价。​ 主定理(Master Theorem)那一块让我印象挺深的:T(n) = aT(n/b) + f(n),最后复杂度谁说了算,就看子问题的总开销 aT(n/b) 和合并开销 f(n) 谁占主导。比如归并排序合并要 O(n),快排划分也是 O(n),都是 nlogn;但如果"合"这步设计得不好,分了也白分。

有时候"不全递归"反而更快。​ 这次这个找第 k 小的题就是很好的例子:和快排一样的划分框架,但因为我们知道答案只可能在一边,所以只递归一半,平均复杂度就从 O(nlogn) 降到了 O(n)。这让我意识到分治不是死板的模板,想清楚"哪些子问题其实不用解"往往比埋头写递归更重要。

分治和递归是绑在一起的,边界条件特别容易翻车。​ 写递归的时候最容易出错的就是终止条件:区间只剩一个元素时直接返回、k 减错了(忘了减去左边淘汰掉的 p 个)、递归区间多一个少一个……这些小地方我在本机调的时候都踩过坑。所以我的习惯是先把边界想清楚再写主体。

递归也不是白来的,它有开销。​ 每次递归调用都要压栈,n 很大的时候有爆栈的风险。像这个算法其实可以很方便地改成迭代写法(用 while 循环不断缩小区间),把递归深度从 O(logn) 降到 O(1) 的额外空间,这在工程上是个挺实用的技巧。

posted @ 2026-10-13 21:53  ww02ww  阅读(3)  评论(0)    收藏  举报