oracle 去掉最右边_可能是东半球最容易理解的快速排序讲解

快速排序(quick sort)是排序算法中最经典的之一

无论是知乎还是其他技术博客网站,快速排序可能已经被很多人写过,甚至都快写烂了~

但是!我还是想写一个我的版本!一个可能是东半球最容易理解的快速排序讲解

c21598565d9d8969a9dc7afda521bd68.png

快速排序的思想其实挺简单

3f523b49e1c6353ed1cec4ebb75a8a18.png

第一步,在数组中找一个支点(pivot),把数组中小于支点值的数都放到支点左边,把数组中大于支点值的数都放在支点的右边 —— 比如我们拿 数字 7 作为支点,那么我们想得到的效果如下

4a3336c52b2315be4f326d6daefdd83a.png

第二步,递归地对支点左边的数字使用第一步的方法处理一遍

66c745400b09185c57b7c5f0508892d2.png

第三步,递归地对支点右边的数字使用第一步的方法处理一遍

最后就得到了一个排好序的数组了

89c5c98a527a1debf4d7fdaaa5252b42.png

思想简单,但是翻译成代码对不少程序员就有难度了

首先,快速排序是个递归算法,先写一点伪代码

  private void sort(int[] arr, int low, int high) {
    // 把数组按上述第一步处理,并返回支点下标
    int pivotIndex = partition(arr, low, high);

    // 递归
    sort(arr, low, pivotIndex - 1);
    sort(arr, pivotIndex + 1, high);
  }

很明显,核心算法在 partition 里

partition 算法里的第一个任务是确定支点 ,通常有以下三种方法:

1. 直接使用 high 下标对应的值 即 arr[high]

2. 取数组中 arr[low], arr[(low + high) / 2] , arr[high] 这三个值的中位数

3. 随机取一个下标,使用它对应的值 即 arr[randomIndex]

接下来是如何 把数组中的元素根据小于支点的值在左,大于等于支点的值在右的方式重新排

实际上有很多种实现,我列举常用的三种:

实现一

创建额外两个数组,遍历当前数组,把小于支点的数放在数组A里,大于等于支点的数放在数组B里。遍历结束后,把数组A和数组B的数覆盖到原数组上。

这个方法我就不写实现了,简单粗暴,但是需要使用额外的空间,所以不是最优解

有兴趣可以看看 阮一峰老师 早期的这篇博客

快速排序(Quicksort)的Javascript实现 - 阮一峰的网络日志​www.ruanyifeng.com
e54a2991e925533c1c4db8f7b1d93d3f.png

实现二

先把支点与当前排序的最后一个数调换位置(这一步可以让算法大大简化,后面会讨论)

然后对剩余的数做如下操作:

a. 自左向右查找 大于等于支点的数,找到后暂停,记下标 left

b. 自右向左查找 小于支点的数,找到后记下标 right

c. 调换 left 和 right 位置上的数 —— 让左边的数都是小数,而右边都是大数

d. 重复 a +b + c 直到 a与b 相遇,即 left 已经大于等于 right

e. 继续重复 a 直到 找到大于等于支点的数 或者 到达末尾

f. 最后把 left 对应的值与数组最后一个数交换位置 —— 因为 left 大于等于支点,且 left 之前的数都一定小于支点;最后一位就是支点;交换过后,就达到了我们要的效果,即支点左边都小于它,支点右边都大于等于它。

  private int partition(int[] arr, int low, int high) {
    // 随机取一个数作为支点
    int pivotIndex = random.nextInt(high - low + 1) + low;
    int pivot = arr[pivotIndex];

    // 先把支点与最后一位交换位置 —— 方便下面的遍历
    swap(arr, pivotIndex, high);

    int left = low, right = high - 1;

    while (left < right) {
      // left要找到的是 大于等于支点 的数的下标
      while (left < high && arr[left] < pivot) {
        left++;
      }

      if (left >= right) {
        break;
      }

      // right 要找到的是 小于支点 的数的下标
      while (right >= low && arr[right] >= pivot) {
        right--;
      }

      if (left >= right) {
        break;
      }

      // 调换 left 和 right 位置上的数 —— 让左边的数都是小数,而右边都是大数 
      swap(arr, left, right);
      left++;
      right--;
    }

    // 继续自左向右遍历,确保 left 对应的数 大于等于支点
    while (left < high && arr[left] < pivot) {
      left++;
    }

    // 因为left上的数大于等于支点,与 high 交换位置后, left 上的值就是支点,left 左边的值都小于支点,右边都大于等于支点
    swap(arr, left, high);
    return left;
  }


// 交换数组上 i 和 j 的数
  private void swap(int[] arr, int i, int j) {
    if (i == j) {
      return;
    }
    int n = arr[i];
    arr[i] = arr[j];
    arr[j] = n;
  }

这种实现便于理解,但是稍微有点繁琐 —— 不是代码繁琐而是一些边缘情况的逻辑细节

有读者可能会说,我见过这种实现更”简洁“的写法 —— 我也见过,其实无非就是把 自增操作放在条件语句判断里再去掉大括号如

while (left < high && arr[left++] < pivot);
while (right >= low && arr[right--] >= pivot);

我个人是比较反对这种写法的,看起来代码行数少了,但这不代表简洁 —— 简洁的目的不只在于代码少,还要保证可读性 —— 几个月后你再回来读这段代码还能快速理解吗?扯远了~

总结:这种实现核心算法是维护两个指针(下标),自两端向中间扫描交换位置,最后将末尾的支点放在中间

实现三

与实现二类似,先把支点与当前排序的最后一个数调换位置

维护一个指针 j(下标)指向 自左向右第一个(可能)大于等于支点的数 —— 为什么是”可能“,因为这个指针的初始值是 low,不一定大于等于支点;而且在一种极端情况下(支点是最大值),这个指针也不会指到大于支点的数。

自左向右遍历数组,遇到比支点小的数就把这个数与指针 j 对应的数 交换位置 —— 确保左边的数都能小于支点,并把 指针 j 向右移动一位

最后,交换 指针 j 与 最后一位数

  private int partition1(int[] arr, int low, int high) {
    // 随机取一个数作为支点
    int pivotIndex = random.nextInt(high - low + 1) + low;
    int pivot = arr[pivotIndex];

    // 先把支点与最后一位交换位置 —— 方便下面的遍历
    swap(arr, pivotIndex, high);

    // 指针 j 指向 自左向右第一个(可能)大于等于支点的数 
    int j = low;

    for (int i = low; i < high; i++) {
      if (arr[i] < pivot) {
        swap(arr, i, j); // 小于支点的数就跟 指针j 交换位置
        j++;
      }
    }

   // 因为 指针j上 的数大于等于支点,与 high 交换位置后, j 上的值就是支点,j 左边的值都小于支点,右边都大于等于支点
    swap(arr, high, j);

    return j;
  }

明显感觉到这种实现比实现二更简洁,但是理解起来稍稍困难一点。

实现二使用的是左右两个指针 相向而行,而实现三使用的是两个指针(其中一个指针用于遍历)同向而行 即 都是自左向右。

实现二可以想象成两个小矮人从两端相向开始走,左边的小矮人采大蘑菇,右边的小矮人采小蘑菇,两个人都采到时就抛给对方交换 —— 所以 左边的蘑菇都是小蘑菇 而 右边都是大蘑菇

实现三可以想象两个小矮人都从左边向前出发,走后边的小矮人一遇到大蘑菇就蹲在那儿等,走前边的小矮人采到小蘑菇就回头跟后边的小矮人交换 —— 所以 后边的蘑菇都是小蘑菇 而 前边都是大蘑菇

ba849a9876cf0b84519d7ee7d38d0465.png

有读者可能已经发现了个问题 —— 假如 指针j 指向的数是比 支点小的数 怎么办?

答:其实没问题的。这种情况只会发生在遍历开始的时候,指针 j 的初始位置 low 可能指向的是 比 支点小的数,但遍历指针 i 也会看到这个数,于是跟 指针 j 交换 (此时 i 跟 j 其实是相等的)它们的位置交换等同于什么都没发生。—— 只要记住一点 :遍历使用的指针 i 一定是比 指针j 更靠前 或者 跟指针j 在一样的位置

实现二和三 共同的一点是都首先把 支点与最后一位交换,这样做的目的是 把支点先从之后的遍历中摘出来、不参与,最后再把支点放到中间位置,简化了整个流程。

相比实现一,实现二和三 都不需要额外空间,属于原地排序,对内存使用是比较友好的。


总结

快速排序算法是个递归算法

通常有三种选择支点的方式

partition 的实现通常有三种,后两种都是原地排序不需要额外空间,都使用到了双指针 —— 尝试使用小矮人采蘑菇的比喻来理解

尽量保持算法代码可读性,方便自己记忆和复习


拓展

快速排序算法还可以用在其他算法题上,比如找到数组中第 K 小的数 —— 如果支点在位置 k 上,支点不就是第 K 小的数了吗?

快速排序中使用到的 双指针 是解算法题的一大利器,熟练掌握双指针可以攻克很多算法题

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值