分治法学习总结
一、寻找第 k 小元素的分治算法
给定一个有 n 个元素的无序序列,寻找其中第 k 小的元素,可以使用快速选择算法。这里的 k 从 1 开始计算,重复的元素也分别计数。
每次选取当前序列的第一个元素作为基准值 p,将序列分成三组:小于 p 的元素、等于 p 的元素和大于 p 的元素。假设前两组分别有 a 个和 b 个元素,就可以根据 k 判断目标的位置:
- 如果 k≤a,就在小于 p 的那一组中继续寻找第 k 小的元素。
- 如果 a<k≤a+b,直接返回 p。
- 如果 k>a+b,就在大于 p 的那一组中寻找第 k−a−b 小的元素。
第三种情况需要调整 k,因为前面已经排除了 a+b 个元素。递归过程中,如果当前序列只剩一个元素,就直接返回这个元素。
例如,在 [6,2,8,4,1,7,3] 中寻找第 4 小的元素。以 6 为基准,小于 6 的元素有 [2,4,1,3],共 4 个,所以继续在这一组中寻找第 4 小的元素。再以 2 为基准,小于 2 和等于 2 的元素各有 1 个,目标就变成 [4,3] 中的第 2 小元素,最终得到 4。
每次划分后,只需要处理目标所在的那一组,其余元素可以排除,因此不用完成整个序列的排序。
二、最好和最坏时间复杂度
一次划分需要扫描当前序列,规模为 n 时,耗时为 O(n)。
最好情况下,第一次选出的基准值恰好就是要找的元素,完成一次划分后就能得到答案,所以最好时间复杂度为 O(n)。如果每次划分后需要继续处理的元素数量大约减半,总耗时也可以写成:
n+n/2+n/4+…=O(n)。
虽然这种情况下递归层数是 O(log n),但各层都需要扫描相应的元素,所以总时间仍然是 O(n)。
最坏情况下,每次只能排除一个元素,剩余问题的规模依次为 n−1、n−2、……、1。例如,在升序序列中,每次选择第一个元素作为基准,并寻找最大元素,就会出现这种情况。此时:
T(n)=T(n−1)+O(n)。
将各次划分的耗时相加,得到 n+(n−1)+…+1,所以最坏时间复杂度为 O(n²)。
基准值的选择会影响划分效果。随机选择基准值时,算法的期望时间复杂度为 O(n),最坏时间复杂度仍为 O(n²)。
三、对分治法的体会和思考
学习这一章之前,我已经会二分查找,看到分治法时,很容易想到把一个区间分成两半。后来做了归并排序、逆序对统计和最大子段和,我发现每道题在划分之后要做的事情差别很大。
归并排序的过程比较清楚:先分别排好左右两段,再把它们合并。统计逆序对时,我一开始需要仔细想,为什么遇到 a[i]>a[j] 就能加上 mid−i+1。结合左右两段已经有序这个条件,才明白左边从 i 到 mid 的元素都大于 a[j],可以一次统计这些逆序对。
最大子段和让我注意到了跨越中点的情况。只求左右两段的最大值会漏掉一部分答案,还要计算左边的最大后缀和与右边的最大前缀和,再将它们相加。做这类题时,需要先把答案可能出现的位置列完整,才能确定递归结果该怎样处理。
写代码时,我也在区间边界上出过错。归并的右侧指针应该从 mid+1 开始,我曾写成 right;处理右侧剩余元素时,指针不断向右移动,循环条件就应该是 j≤right。这些地方看着只是几行代码,写错以后,整个合并过程都会出问题。递归终止时返回什么也要看题意,例如最大子段和题目允许全为负数时返回 0,只有一个元素时就应该返回 max(a[left],0)。
第 k 小元素这道题和归并排序又有些不同。划分完成后,根据元素数量就能判断下一步该处理哪一组,因此只需要递归一边。分析复杂度时,也要把递归次数和每次处理的元素数量一起考虑,不能看到问题规模减半就直接写 O(log n)。
现在做分治题,我会先写清楚当前函数要解决什么问题、什么时候结束,以及递归返回的结果有什么用。遇到需要合并的题目,再检查有没有跨区间的情况。这样写代码时思路会清楚一些,也更容易发现边界上的错误。
浙公网安备 33010602011771号