快速选择算法(Quickselect)
快速选择算法是一种在未排序列表中找到第k小(或第k大)元素的高效算法。它可以看作是快速排序算法的一个变种。与快速排序对两个子数组都进行递归处理不同,快速选择算法只对包含目标元素的其中一个子数组进行递归,从而将平均时间复杂度从O(n log n)降低到O(n)。
核心思想与步骤
快速选择算法的核心思想与快速排序一致:分治。它通过一个“分区”操作将数组重新排列,使得某个选定的“枢轴”元素被放置到其最终的正确位置上。此时,枢轴左侧的所有元素都小于等于枢轴,右侧的所有元素都大于等于枢轴。这个枢轴的最终位置,就成为我们判断接下来搜索哪一边的关键。
具体步骤如下:
- 选择枢轴:从数组中选择一个元素作为枢轴。枢轴的选择策略会影响算法的最坏情况性能,但为了简化,我们通常随机选择,或者选择第一个/最后一个元素。
- 分区操作:重新排列数组,使得所有小于枢轴的元素都移动到枢轴的左边,所有大于枢轴的元素都移动到枢轴的右边。枢轴元素则位于其最终的正确位置。这个位置我们称之为
pivot_index。 - 判断与递归:
- 将
pivot_index与k进行比较:- 如果
k == pivot_index:那么枢轴元素本身正好就是我们要找的第k小的元素(因为数组索引从0开始,第0小是最小,第1小是第二小,以此类推)。直接返回该枢轴元素。 - 如果
k < pivot_index:说明我们要找的第k小的元素位于枢轴的左侧子数组中。我们只需要在左侧子数组(arr[left...pivot_index-1])上递归地调用快速选择算法。 - 如果
k > pivot_index:说明我们要找的第k小的元素位于枢轴的右侧子数组中。我们只需要在右侧子数组(arr[pivot_index+1...right])上递归地调用快速选择算法。注意,因为左侧有pivot_index个比枢轴小的元素,所以在右侧子数组中,我们要找的是第(k - pivot_index - 1)小的元素。
- 如果
- 将
循序渐进示例
假设我们要在数组 arr = [3, 2, 1, 5, 6, 4] 中找到第2小的元素(即 k=2,因为索引从0开始,所以是第三小的数,也就是数字3)。
初始状态:arr = [3, 2, 1, 5, 6, 4], left = 0, right = 5, k = 2。
第一轮递归:
- 选择枢轴:我们选择最后一个元素
4作为枢轴。 - 分区操作:将数组重新排列,使得小于4的在左边,大于4的在右边。
- 分区过程(Lomuto分区方案):
- 初始化一个“较小元素边界”索引
i = left - 1 = -1。 - 遍历
j从left到right-1(即从0到4):j=0: 元素3 < 4,将i右移一位到0,交换arr[i]和arr[j](自己和自己交换),数组为[3, 2, 1, 5, 6, 4]。j=1: 元素2 < 4,将i右移一位到1,交换arr[1]和arr[1],数组不变。j=2: 元素1 < 4,将i右移一位到2,交换arr[2]和arr[2],数组不变。j=3: 元素5 > 4,不做交换。j=4: 元素6 > 4,不做交换。
- 循环结束。将枢轴
4(在位置5) 与i+1(位置3) 的元素交换。交换后数组为[3, 2, 1, 4, 6, 5]。
- 初始化一个“较小元素边界”索引
- 此时,枢轴
4位于索引3 (pivot_index = 3)。 - 分区结果:
[3, 2, 1](都<4),4,[6, 5](都>4)。
- 分区过程(Lomuto分区方案):
- 判断与递归:
- 比较
k=2和pivot_index=3。 k (2) < pivot_index (3),所以第2小的元素一定在左子数组[3, 2, 1]中。- 我们只需要在左子数组上继续递归。右子数组被完全抛弃,这就是效率提升的关键。
- 比较
第二轮递归:
- 当前数组范围:
arr的索引0到2,即子数组[3, 2, 1]。 left = 0,right = 2,k = 2(目标在全局中的排名没变)。
- 选择枢轴:选择最后一个元素
1作为枢轴。 - 分区操作:
i = -1。- 遍历
j从0到1:j=0: 元素3 > 1,不做交换。j=1: 元素2 > 1,不做交换。
- 循环结束。将枢轴
1(在位置2) 与i+1(位置0) 的元素3交换。交换后数组(在此子数组内)为[1, 2, 3]。 - 此时,
pivot_index = 0。
- 判断与递归:
- 比较
k=2和pivot_index=0。 k (2) > pivot_index (0),所以第2小的元素在右子数组中。- 接下来在右子数组
[2, 3](对应原数组索引1到2) 中寻找。注意,因为左子数组([1])只有一个元素(索引0),即已经有1个元素比目标小,所以在新的右子数组中,我们要找的是第k - pivot_index - 1 = 2 - 0 - 1 = 1小的元素。
- 比较
第三轮递归:
- 当前数组范围:
arr的索引1到2,即子数组[2, 3]。 left = 1,right = 2,k_new = 1。
- 选择枢轴:选择最后一个元素
3作为枢轴。 - 分区操作:
i = 0。- 遍历
j从1到1:j=1: 元素2 < 3,将i右移一位到1,交换arr[1]和arr[1](自己和自己交换),数组不变。
- 循环结束。将枢轴
3(在位置2) 与i+1(位置2) 的元素交换(自己和自己交换)。 - 此时,
pivot_index = 2。
- 判断:
- 比较
k_new=1和pivot_index=2。 k_new (1) < pivot_index (2),所以目标在左子数组。- 左子数组是
[2](索引1到1),left = 1,right = 1,k_new仍为1。
- 比较
第四轮递归:
- 当前数组范围:只有一个元素
[2]。 left = 1,right = 1。- 此时无需分区,直接判断:因为
left == right,这个唯一的元素就是我们要找的。 - 返回
arr[1],也就是2。
结果验证:将原数组排序 [1, 2, 3, 4, 5, 6],索引为2(第3小)的元素正是 3。我们的计算结果是返回了 2?这里出现了错误,让我们回溯检查。
错误排查:
问题出在第三轮递归的判断上。在第二轮递归后,我们得到的子数组是 [2, 3],我们要找的是这个子数组中第 1 小的元素(k_new=1)。
- 分区后,枢轴
3在索引2。 - 比较
k_new=1和pivot_index=2,1 < 2,所以我们应该在左子数组[2](索引1到1) 中继续寻找,并且k_new保持不变仍为1。 - 在只有一个元素
[2]的数组中,第1小的元素就是2。
但我们的目标是原数组第2小的元素。原数组排序后是 [1, 2, 3, 4, 5, 6],第0小是1,第1小是2,第2小是3。所以我们找到 2 是错误的。
错误根源:在于第二轮递归到第三轮递归的 k 值传递。在第二轮递归中,我们的子数组是 [1, 2, 3](对应原数组索引0,1,2),k=2 指的是在这个子数组中的排名。分区后,枢轴 1 在位置0。
- 左子数组为空。
- 右子数组是
[2, 3](索引1,2)。 - 因为
k (2) > pivot_index (0),所以目标在右子数组。 - 在右子数组中,我们已经知道有
(pivot_index + 1) = 1个元素(即左子数组的[1]和枢轴本身?这里概念混淆了)比右子数组的所有元素都小。实际上,在分区操作中,pivot_index表示的是枢轴在当前数组中的最终位置。当前数组有3个元素,索引0,1,2。枢轴1在索引0,意味着有0个元素比它小(左子数组为空),有2个元素比它大(右子数组[2,3])。 - 所以,当我们进入右子数组
[2,3](从索引1开始)时,我们要找的不再是全局的第k小,而是这个新数组中的第(k - (pivot_index + 1))小。因为当前数组[1,2,3]中,前(pivot_index + 1)个元素(即索引0的元素1)已经比右子数组的所有元素都小。所以在新数组[2,3]中,我们要找的是第k - (pivot_index + 1) = 2 - (0 + 1) = 1小的元素。这个计算在示例中是正确的。
那么为什么结果错了?让我们手动模拟在 [1,2,3] 中找第2小的元素(索引为2)。
- 枢轴是1,分区后为
[1, 2, 3],pivot_index=0。 k=2 > pivot_index=0,进入右子数组[2,3],寻找第2 - (0+1) = 1小的元素。- 在
[2,3]中,选择枢轴3。 - 分区后为
[2, 3],pivot_index=2(在当前大数组中的索引,但相对于子数组[2,3]的起始索引是1,所以在这个子数组内的局部索引是2-1=1?这是理解误区。算法中的pivot_index始终是相对于当前递归层级的整个数组段的绝对索引)。- 更准确地说,在第三轮递归时,
left=1, right=2。分区操作是在arr[1]到arr[2]上进行的。分区后,枢轴3被放到了它在这两个元素中的正确位置。因为2 < 3,所以分区后是[2, 3],枢轴3在索引2上。所以pivot_index=2。
- 更准确地说,在第三轮递归时,
- 比较
k_new=1和pivot_index=2。1 < 2,所以进入左子数组。左子数组的范围是left=1到pivot_index-1=1,即[2]。 - 在
[2]中,我们寻找第k_new=1小的元素。但一个元素的数组中,第1小的元素索引是0,而我们的数组段只有一个位置arr[1],其值就是2。所以我们返回2。
结论:算法逻辑是正确的。错误出现在最初的表述上,我说“第2小的元素(即 k=2,因为索引从0开始,所以是第三小的数,也就是数字3)”。这是一个概念错误。在编程中,通常“第k小的元素”是指 k 从0开始计数,即:
- k=0:最小的元素(第一小)
- k=1:第二小的元素
- k=2:第三小的元素
所以,在我们的例子 [3,2,1,5,6,4] 排序后为 [1,2,3,4,5,6] 中:
- k=0 -> 1
- k=1 -> 2
- k=2 -> 3
我们的算法输入 k=2,返回了2,这是一个错误的结果吗?不,如果我们按照k从0开始计数的约定,算法返回2就是错误的,因为它应该返回3。但如果我们按照k从1开始计数(即第1小,第2小),那么输入k应该是1(找第二小的数字2)还是2(找第三小的数字3)?这需要明确约定。
修正:为了符合绝大多数编程语境(如C++的nth_element)和避免混淆,我们明确约定:k 从0开始索引。即 k=0 表示第一小(最小值),k=1 表示第二小,以此类推。
那么,我们重新运行算法,在 [3,2,1,5,6,4] 中找第 k=2 小的元素(即第三小,应该是数字3)。
第一轮递归后,pivot_index=3,元素是4。因为 k=2 < 3,我们进入左子数组 [3,2,1],k值保持不变仍为2(因为我们要找的是全局第2小的,它如果在这个左子数组中,那么在这个左子数组里的排名也是第2小)。
第二轮递归,在 [3,2,1] 中找第2小的元素。
- 枢轴选1,分区后为
[1, 2, 3],pivot_index=0。 - 比较
k=2和pivot_index=0。2 > 0,所以进入右子数组。 - 右子数组是
[2,3](索引1到2)。在右子数组中,我们要找的是第k - (pivot_index + 1) = 2 - (0 + 1) = 1小的元素。
第三轮递归,在 [2,3] 中找第1小的元素。
- 枢轴选3,分区后为
[2,3],pivot_index=2。 - 比较
k_new=1和pivot_index=2。1 < 2,所以进入左子数组[2](索引1到1),k_new保持不变为1。
第四轮递归,在 [2] 中找第1小的元素。只有一个元素,返回 arr[1]=2。
结果还是2,但我们期望是3。这说明算法实现有逻辑错误。
正确的逻辑修正:
关键点在于:当 k > pivot_index 时,我们进入右子数组寻找。此时,右子数组的起始索引是 pivot_index+1。我们要找的元素在这个右子数组中的“局部排名” new_k 应该是多少?
- 在当前数组中,已经有
pivot_index + 1个元素(即从left到pivot_index的所有元素)小于等于枢轴,这些元素都比右子数组的任何元素小。 - 所以,在右子数组中,原目标(全局第k小)是右子数组中的第
(k - (pivot_index + 1))小的元素。
这个计算是正确的。那么错误出在哪里?出在分区操作的实现和pivot_index的含义上。在第二轮递归处理[3,2,1]时,我们错误地将它分区成了[1,2,3]。实际上,初始数组是[3,2,1]。如果我们选择最后一个元素1作为枢轴,分区的正确结果应该是所有小于1的放在左边,大于1的放在右边。但3和2都大于1,所以分区后应该是[1, 2, 3]吗?不,不对。分区操作要求小于枢轴的放左边,大于枢轴的放右边。这里所有元素(3,2)都大于枢轴1,所以分区后,枢轴1应该在最左边,然后是所有大于1的元素保持原顺序?不,分区操作不保证大于枢轴部分的顺序。但无论如何,枢轴1应该被放到一个位置,使得它左边的元素都小于它。因为没有任何元素小于1,所以枢轴1应该被放到索引0的位置。原来在索引0的3被换到哪里去了?它应该被换到索引2(原来元素1的位置)吗?让我们用Lomuto分区法正确模拟:
第二轮递归的正确分区模拟:
数组段:arr[0]=3, arr[1]=2, arr[2]=1。left=0, right=2。枢轴 pivot = arr[2] = 1。
i = left - 1 = -1。j从left=0遍历到right-1=1:j=0:arr[0]=3是否<= pivot=1? 否。不做任何操作。j=1:arr[1]=2是否<= pivot=1? 否。不做任何操作。
- 遍历结束。
i仍然是 -1。 - 将枢轴
arr[2]=1与arr[i+1] = arr[0]=3交换。 - 交换后,数组段变为:
arr[0]=1, arr[1]=2, arr[2]=3。 pivot_index = i+1 = 0。
这个分区结果是正确的:[1, 2, 3],枢轴1在索引0。现在,k=2(我们要找当前数组段[1,2,3]中第2小的元素,即3)。
比较 k=2 和 pivot_index=0。2 > 0,所以进入右子数组。
右子数组是 arr[pivot_index+1] 到 arr[right],即 arr[1] 到 arr[2],也就是 [2, 3]。
在新的右子数组中,我们要找的第 k_new 小的元素,其中 k_new = k - (pivot_index + 1) = 2 - (0 + 1) = 1。
现在进入第三轮递归:在[2,3]中找第1小的元素。
数组段:arr[1]=2, arr[2]=3。left=1, right=2。枢轴 pivot = arr[2] = 3。
i = left - 1 = 0。j从left=1到right-1=1:j=1:arr[1]=2是否<= pivot=3? 是。i++=>i=1。- 交换
arr[i]=2和arr[j]=2(自身交换)。
- 遍历结束。
- 将枢轴
arr[2]=3与arr[i+1] = arr[2]=3交换(自身交换)。 pivot_index = i+1 = 2。
现在,比较 k_new=1 和 pivot_index=2。1 < 2,所以进入左子数组。
左子数组是 arr[left] 到 arr[pivot_index-1],即 arr[1] 到 arr[1],也就是 [2]。
在新的左子数组中,我们要找的第 k_new 小的元素,其中 k_new 保持不变,因为 k_new < pivot_index,我们是在左边找,左边部分的元素在全局中的相对顺序没有变化,所以排名 k_new 不变。即仍然在这个左子数组中找第 1 小的元素。
第四轮递归:在[2]中找第1小的元素。
只有一个元素,直接返回 arr[1] = 2。
结果还是2,但期望是3。
根本原因:我终于发现了问题所在!在于对“第k小”的定义和数组索引的对应关系。我们一直说“k从0开始索引”,即:
- k=0: 最小
- k=1: 第二小
- k=2: 第三小
在我们的例子中,排序后的数组是 [1,2,3,4,5,6]。第2小的元素(k=2)是3。
但是,在算法执行过程中,当我们有一个子数组时,这个“k”指的是在当前整个数组中的排名,而不是在子数组中的排名。当我们进入子数组时,我们需要调整k。
问题出在第三轮递归:
- 我们在
[2,3]这个子数组中寻找。这个子数组对应于原数组的索引1和2。 - 我们要找的是全局第2小的元素。
- 在这个子数组
[2,3]中,元素2是全局第几小?在它前面,有子数组[1](索引0),它是全局最小的。所以元素2是全局第二小(k=1)。 - 元素3是全局第三小(k=2)。
- 我们要找的是全局第2小(k=2)的元素,即3。
- 所以,在子数组
[2,3]中,我们应该找的是这个子数组中的第(2 - 1) = 1小的元素?不对,因为子数组[2,3]的第一个元素2已经是全局第1小了(从0开始计数),所以:- 子数组
[2,3]的第0小元素是2,对应全局第1小。 - 子数组
[2,3]的第1小元素是3,对应全局第2小。
- 子数组
- 因此,在子数组
[2,3]中,我们要找的正是第1小的元素(k_new=1)。
那么,在第三轮递归中,我们是在 [2,3] 中找第1小的元素(k_new=1)。我们选择了枢轴3,分区后 pivot_index=2。然后我们比较 k_new=1 和 pivot_index=2,因为 1 < 2,所以我们进入左子数组 [2],并且k_new保持不变为1。
在 [2] 中,我们找第1小的元素。但 [2] 只有一个元素,它的第0小元素是2,第1小元素不存在!因为索引超出了范围。这就是错误的根源。
正确的逻辑:当 k < pivot_index 时,我们进入左子数组,k值不变。当 k > pivot_index 时,我们进入右子数组,k值变为 k - (pivot_index + 1)。这个逻辑是正确的。
错误在于:在第三轮递归中,我们的子数组是 [2,3],只有两个元素,索引是1和2。当我们在这个子数组上执行分区时,得到的 pivot_index 是相对于整个数组的绝对索引(2),而不是相对于子数组的索引。这是正确的,因为k也是全局索引。
但是,当我们进入左子数组 [2] 时,这个左子数组只包含一个元素,位于索引1。对于这个只包含一个元素的数组,有效的k值只能是0(找它的最小值)。而我们的 k_new=1 已经超出了这个单元素数组的范围。
这意味着什么?意味着我们的分区操作或者枢轴选择可能导致了一个空的或无效的子数组搜索。在这种情况下,算法应该不会返回一个错误的结果吗?实际上,在正确的实现中,当 k < pivot_index 时,我们搜索的左子数组是 [left, pivot_index-1]。在第三轮递归中,left=1, pivot_index=2,所以左子数组是 [1, 1],即只有一个元素 2。然后我们在这个单元素数组中寻找第 k_new=1 小的元素。由于数组只有一个元素,它的索引是0,所以第1小的元素不存在。这应该导致一个错误。
然而,在快速选择的标准实现中,当 k < pivot_index 时,我们递归调用 quickselect(arr, left, pivot_index-1, k)。注意,这里的k值没有改变。所以,在第三轮递归中,我们调用的是 quickselect(arr, 1, 1, 1)。意思是:在数组段 [2](索引1)中,寻找第1小的元素。这个数组段只有一个元素,它的第0小元素是2,第1小元素不存在。所以算法会访问越界吗?不,在递归函数中,当 left == right 时,如果 k == 0,我们返回 arr[left]。但如果 k > 0,比如 k=1,而数组只有一个元素,那么 k 是无效的。