概要

快排的思想是这样的:如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。
image.png
根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
如果我们用递推公式来将上面的过程写出来的话,就是这样:

  1. 递推公式:
  2. quick_sort(pr) = quick_sort(pq-1) + quick_sort(q+1 r)
  3. 终止条件:
  4. p >= r

原理

将递推公式转换为代码如下:

  1. // 快速排序,a是数组,n表示数组的大小
  2. static void quickSort(int[] a, int n) {
  3. quickSortInternally(a, 0, n-1);
  4. }
  5. // 快速排序递归函数,p,r为下标
  6. static void quickSortInternally(int[] a, int p, int r) {
  7. if (p >= r) return; // 逐层分解到最后一个元素终止递归
  8. int partition = partition(a, p, r); // 获取分区点
  9. quickSortInternally(a, p, partition-1);
  10. quickSortInternally(a, partition + 1, r);
  11. }

归并排序中有一个 merge() 合并函数,快速排序有一个 partition() 分区函数。partition() 分区函数就是随机选择一个元素作为 pivot分区值(一般情况下,可以选择 p 到 r 区间的最后一个元素或数组的第一个元素),然后对 A[p…r]分区,函数返回 pivot 的下标。
分区函数算法

  1. static int partition(int[] data, int p, int r) {
  2. int pivot = data[p]; // 默认取数组第一个值为分区值
  3. int left = p;
  4. int right = r;
  5. while (left < right) {
  6. // 1. 从左开始查找大于分区值的元素
  7. while (data[left] <= pivot && left < right) {
  8. left++;
  9. }
  10. // 2.从右边开始查找小于分区值的元素
  11. while (data[right] > pivot) {
  12. right--;
  13. }
  14. // 如果left与right未相遇,则交换
  15. if (left < right) {
  16. int temp = data[left];
  17. data[left] = data[right];
  18. data[right] = temp;
  19. }
  20. }
  21. // 循环结束,表示找到了一个分区位置,该位置坐标的数据比分区值小,右边的数据比分区大
  22. // 由于默认的分区值为数组第一个元素,因此需要交换数据,并返回真正的分区下标位置
  23. int temp = data[p];
  24. data[p] = data[right];
  25. data[right] = temp;
  26. System.out.println(String.format("查找分区点,[p=%d,r=%d],pivot=%d", p, r, right));
  27. return right;
  28. }

测试

  1. public static void main(String[] args) {
  2. int[] data = new int[]{90, 65, 7, 305, 120, 110, 8};
  3. System.out.println("Before Sort\n" + Arrays.toString(data));
  4. quickSort(data, data.length);
  5. System.out.println("After QuickSort\n" + Arrays.toString(data));
  6. }

说明

1)快速排序是一种原地排序算法,空间复杂度为O(1), 因为其实现上是基于原数组空间,不需要申请额外的内存即可完成排序
2)由于排序过程中,涉及到交换,因此快速排序不是稳定的排序算法(元素原有的相对位置可能会发生改变)
3)快速排序在大部分情况下的时间复杂度都可以做到 O(nlogn),在极端情况下,会退化到 O(n2)。

实践

O(n) 时间复杂度内求无序数组中的第 K 大元素?比如,4, 2, 5, 12, 3 这样一组数据,第 3 大元素就是 4。
快排核心思想就是分治和分区,我们可以利用分区的思想来解决。
我们选择数组区间 A[0…n-1]的最后一个元素 A[n-1]作为 pivot,对数组 A[0…n-1]原地分区,这样数组就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1]。
如果 p+1=K,那 A[p]就是要求解的元素;如果 K>p+1, 说明第 K 大元素出现在 A[p+1…n-1]区间,递归地在 A[p+1…n-1]这个区间内查找。同理,如果 K

为什么是 p + 1呢?因为数组下标是从0开始的,第K个元素是从自然是1开始计算的,所以查找到分区下标p时,需要加1。

  1. /**
  2. * 基于快排的分区分治思想查询第K大的元素
  3. * @param data
  4. * @param k
  5. * @return
  6. */
  7. public static int findKLargestByQuickPartition(int[] data, int k) {
  8. if (data == null || data.length < k) {
  9. return -1;
  10. }
  11. // [0....p-1] [p] [p+1....n-1]
  12. int partition = partition(data, 0, data.length - 1); // 获取分区点
  13. while (partition + 1 != k) { // 为什么是+1,因为数组下标是从0开始,第K个最大值其实就是分区值+1后的下标
  14. if (k > partition + 1) {
  15. partition = partition(data, partition + 1, data.length - 1); // 获取分区点
  16. } else {
  17. partition = partition(data, 0, partition - 1); // 获取分区点
  18. }
  19. }
  20. return data[partition];
  21. }
  22. /**
  23. * 分区函数:该函数可以减少交换次数
  24. * 查找一个分区值,大于该值的在左边,小于该值的在右边
  25. * @param data
  26. * @param p
  27. * @param r
  28. * @return
  29. */
  30. private static int partition(int[] data, int p, int r) {
  31. int pivot = data[p]; // 默认取数组第一个值为分区值
  32. int left = p;
  33. int right = r;
  34. while (left < right) {
  35. // 1. 从左开始查找小于分区值的元素
  36. while (data[left] >= pivot && left < right) {
  37. left++;
  38. }
  39. // 2.从右边开始查找大于分区值的元素
  40. while (data[right] < pivot) {
  41. right--;
  42. }
  43. // 如果left与right未相遇,则交换
  44. if (left < right) {
  45. int temp = data[left];
  46. data[left] = data[right];
  47. data[right] = temp;
  48. }
  49. }
  50. // 由于默认的分区值为数组第一个元素,因此需要交换数据,并返回真正的分区下标位置
  51. int temp = data[p];
  52. data[p] = data[right];
  53. data[right] = temp;
  54. System.out.println(String.format("查找分区点,[p=%d,r=%d],pivot=%d", p, r, right) + ",Data=" + Arrays.toString(data));
  55. return right;
  56. }

为什么上述解决思路的时间复杂度是 O(n)?
第一次分区查找,我们需要对大小为 n 的数组执行分区操作,需要遍历 n 个元素。第二次分区查找,我们只需要对大小为 n/2 的数组执行分区操作,需要遍历 n/2 个元素。依次类推,分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为 1。如果我们把每次分区遍历的元素个数加起来,就是:n+n/2+n/4+n/8+…+1。这是一个等比数列求和,最后的和等于 2n-1。所以,上述解决思路的时间复杂度就为 O(n)。