找第k小的数——分治算法的一次实践与思考
找第k小的数——分治算法的一次实践与思考
一、问题背景
这周算法课讲分治法,老师布置了这道题:用分治的思想找出一组数中第k小的数。乍一看好像不难,排个序取第k个就行了,但那样是O(nlogn),而分治可以做到平均O(n)。这个差距还是挺吸引人的,所以我想认真梳理一下。
二、算法描述
找第k小的数,分治的思路其实和快速排序很像,核心就三步:分解、解决、合并。不过这里“合并”这一步基本不需要做什么,因为答案在分解的过程中就已经确定了。
用自然语言描述大概是这样的:
- 从数组里随便选一个数作为基准(pivot),一般选第一个或者随机选一个。
- 把数组重新排列,比pivot小的放左边,比pivot大的放右边。这时候pivot所在的位置就确定了,假设它的下标是p。
- 比较k和p的关系:
· 如果k == p,那pivot就是第k小的数,直接返回。
· 如果k < p,说明第k小的数在左半部分,递归在左边找第k小的数。
· 如果k > p,说明在右半部分,递归在右边找第k-p小的数(注意这里k要减去p,因为左边已经有p个数比它小了)。
伪代码大概长这样:
function findKth(arr, left, right, k):
if left == right:
return arr[left]
pivot = partition(arr, left, right) // 返回基准最终位置
pos = pivot - left + 1 // 基准是当前范围内的第pos小
if k == pos:
return arr[pivot]
else if k < pos:
return findKth(arr, left, pivot - 1, k)
else:
return findKth(arr, pivot + 1, right, k - pos)
partition函数就是快排里那个,用双指针从两端向中间扫,把小于基准的换到左边,大于的换到右边。
三、时间复杂度分析
最好情况
最好情况就是每次选基准都能把数组均匀分成两半。第一次partition要遍历n个数,第二次遍历n/2,第三次n/4……加起来就是:
n + n/2 + n/4 + ... ≈ 2n
所以最好时间复杂度是O(n)。
最坏情况
最坏情况是每次选的基准都是当前范围内的最大或最小数,比如数组已经有序,然后每次选第一个当基准。这样每次只能排除一个元素,递归深度变成n,每次partition的代价是n、n-1、n-2……加起来就是:
n + (n-1) + (n-2) + ... + 1 = n(n+1)/2
所以最坏时间复杂度是O(n²)。
平均情况
如果基准是随机选的,平均情况下每次能排除一部分元素,期望时间复杂度是O(n)。这个证明稍微复杂一点,但直观上理解就是每次不会太倒霉,递归深度大概是logn级别,总代价还是线性的。
四、对分治法的体会和思考
学完这一章,我对分治法有几个比较深的感受。
第一,分治法的关键在于“分解”得合理。 快排和找第k小用的都是partition,但找第k小时我们只需要递归一边,这就是分治灵活的地方——不是所有分治都要两边都处理。这让我意识到,分治不一定非要“分而治之”到底,有时候只治一边反而更高效。
第二,基准的选择很影响性能。 最坏情况O(n²)其实挺吓人的,所以实际中一般用随机选基准或者三数取中来避免。这让我想到,算法的理论复杂度和实际表现之间,往往需要一些工程上的小技巧来平衡。
第三,分治和递归是绑在一起的。 写代码的时候递归的边界条件特别重要,比如left == right的时候要直接返回,不然会死循环。还有就是k在右边递归时要减去pos,这个细节我一开始写错了,后来画了个图才理清楚。
第四,和排序的关系。 找第k小其实可以看成快排的“半成品”——快排两边都要排,而这里只需要一边。这种“只做一半”的思想在很多地方都有用,比如二分查找也是类似的思路。
总的来说,分治法不是一种具体的算法,更像是一种思考问题的方式:把大问题拆成小问题,然后利用小问题的解来构造大问题的解。但具体怎么拆、拆完怎么处理,还是要看问题的特点。找第k小这个例子就很好地体现了这一点——同样是分治,它比快排少做了一半的工作,效率反而更高。
写完发现,其实这道题最坑的地方就是k在右边递归时的偏移量,我第一遍写的时候直接传k进去,结果怎么都不对。后来在纸上模拟了一遍才反应过来,左边那pos个数已经确定比目标小了,所以右边只需要找第k-pos小的。这种细节,不自己动手写一遍真的很难发现。
浙公网安备 33010602011771号