排序演化(三):快速

590 阅读7分钟

快速排序

我们在归并排序那里,使用了归并,从而让效率明显降低到了nlogn

里面运用了分而治之的思想,在运用该思想时使用递归,让我们的产生了树的结构

在树的结构里,我们发现这种效率会很高。

灵感1

上体育课的时候,体育老师或者体育委员,喊集合的时候,就会用这个手势,表示快速集合。

这里有个小细节,一定有个同学快速站在老师对面,然后我们根据中间人的身高,就知道自己应该是在老师的左边还是右边了。

等确定是在左边或者右边后,我们才会去思考我左边的人是谁右边的人是谁,再进行准确的比较。

但是这有两个前提

  1. 在快速集合的时候,这个中间人能立马站到老师面前,那是因为以前在安排整理队伍的时候,已经排好的,这个中间人是根据记忆就快速站在老师面前的。
  2. 待除了中间人的同学判断完左右之时,后续的调整大家都是靠记忆的,比如谁谁在我左边,谁谁在我右边

灵感2

我们现在回顾一下归并排序

image-20190727095510223

在红色框内,我们先把原数组不断切割,切割至最小规模。

然后才在蓝色框内,把数组合并起来。

如果要改进这个排序算法?你会怎么思考呢?

我们能不能把蓝色框和红色框的行为合二为一呢?

即切割的时候夹另数组有序的作用,切割完成时,数组就有序了?

灵感1 + 灵感2

那我们能不能那把灵感1的快速集合在灵感2的二合一思想结合呢?

对于数组

let arr = [11, 3, 13, 4, 15, 6, 17]

image-20190727105800983

如果我们知道准确的中间值是 11的话,我们把比11小的数分割成左块,比11大的数分割成右块

image-20190727110012761

然后再把蓝色和红色块同理使用中间值分割

image-20190727110439426

整个数组就俨然有序了呀!

在上面的分割中,我们是假装已知了准确的中间值是谁。

问题来了,如果我们不排序,我们怎么可能知道数组的中间值是谁呢?

那我们可以退一步来思考,既然我们无法知道准确的中间值

我们随便取一个数,然后根据这个数的大小把整个数组直接大于该数和小于该数的元素两半分开,那这两半很可能不是等大。

既然随便取了,为了有点中间值的意思,我们就取位置上中间的数吧,比如下图中的 4

image-20190727105800983

然后我们把比 4 大的数 作为左边(包括4本身)

把比 4 小的数 作为右边(不包括4本身了)

image-20190727112453702

然后重复上面的动作,对已经分出来的小块再去类中间值分割

比如,此时左边的只有 4 和 3 了,中间的值取4,那就把3 放在 4的右边了,即两者交换

那右边的中间的值就取15,于是,[13,11,6]成为新的子左块,[15,16]成为新的子右块

image-20190727113030550

再对[13, 11, 6]做分割,我们就会发现整个数组已经有序了

image-20190727113159672

代码实现

中间的值这个名字太长了,我们就称为主元(pivot)

let arr = [11, 3, 13, 4, 15, 6, 17]
let pivot = arr[Math.floor((0 + arr.length) / 2)]
console.log(pivot); // 4

image-20190727105800983

现在问题来了,怎么实现比 4 小的数在左边,比 4 大的数在右边呢?

这就有个特别重要的思想要介绍了: 垃圾分类思想:没有垃圾,只有放错位置的资源。

瞎说的

在pivot左边的,是要比他小的

在pivot右边的,是要比他大的

那如果在左边遇到大于pivot的,我们就去右边找个比pivot小的数,两者一交换,就o啦!

既然要两边找,我们就需要使用两个变量来缓存元素,这里缓存的就是下标。

// 从左边开始
let i = 0
// 从右边开始
let j = arr.length - 1

image-20190727154935380

那很显然,x往右找,即i++

y往左找,即y-—

我们先让x找,x找到比4的数时,就停止,然后让y找个比4大的数,最后两者交换

停止的条件就是 x 小于等于 y吧

那交换过后,x和y还是要继续找的,所以就x++,y- -,不然指着原来的数,有啥意思。

但是生活总是骨感的,如果其中一边找不到呢?

比如arr = [1, 3, 13, 4, 15, 6, 17]

先 i 不断加加找到比 4 大的数,即arr[2] : 13

那让 j 不断减减找到比4小的数,只能是arr[1]:3

此时这个arr[1]已经是在左边了呀!那还有必要交换吗?

当然不需要呀!

所以我们交换前要做个if (i <= j)判断,这要两边找到了才交换

为什么是<=,留在后面说

那我们要做什么吗?

不用,你没发现 i 所指的位置arr[3],即13

[1,3][13,4,15,6,17]

刚好是大小两半的分割点吗?就是我们一开始想要的结果呀!

function partition(arr, left, right) {
  const pivot = arr[Math.floor((left + right) / 2)]
  let i = left
  let j = right
  while (i <= j) {
    while (arr[i] < pivot) i++
    while (pivot < arr[j]) j--
    if (i <= j) {
      [arr[i], arr[j]] = [arr[j], arr[i]]
      i++
      j--
    }
  }
}
let arr = [11, 3, 13, 4, 15, 6, 17]
partition(arr, 0, arr.length - 1)
console.log(arr);
// [ 4, 3, 13, 11, 15, 6, 17 ]

那我们再回顾下,我们这个函数的作用是啥?

是让数组分成大小两部分,那此时我返回 i

调用者就能很好的把数组分割成大小两部分

function quickSort(arr) {
  return quick(arr, 0, arr.length - 1)
}

function quick(arr, left, right) {
  if (arr.length > 1) {
    let index = partition(arr, left, right)
    if (left < index - 1) quick(arr, left, index - 1)
    if (index < right) quick(arr, index, right)
  }
  return arr
}

function partition(arr, left, right) {
  let pivot = arr[Math.floor((left + right) / 2)]
  let i = left
  let j = right
  while (i <= j) {
    while (arr[i] < pivot) i++
    while (pivot < arr[j]) j--
    if (i <= j) {
      [arr[i], arr[j]] = [arr[j], arr[i]]
      i++
      j--
    }
  }
  return i
}

那快速排序就写好了。

function quick(arr, left, right) {
  if (arr.length > 1) {
    let index = partition(arr, left, right)
    if (left < index - 1) quick(arr, left, index - 1)
    if (index < right) quick(arr, index, right)
  }
  return arr
}

既然let index = partition(arr, left, right)返回的index是数组大小两部分的分割点(大部分的第一个元素)

quick(arr, left, index - 1)就表示再对这个小部分数组再分割

quick(arr, index, right)就表示再对这个大部分数组再分割

同理再分割前记得判断,这个数组至少有两个,不然left和index-1都是指向同一个元素,完全没必要分割也分割不出来呀。

当数组只有一个的时候,就无法再分割了。

此时的数组也就有序了,为什么有序了呢?再回去看上面的图片。

这里讲下为什么判断条件一定要是<=

当数组不断分割后,数组已经有序时

arr = [2,4,6,11,33,55,77]

此时low === 3,height === 4

function partition(arr, low, height) {
  const pivot = arr[Math.floor((low + height) / 2)] // pivot === arr[3] === 11
  let i = low // i === 3
  let j = height // j === 4
  while (i < j) {
    while (arr[i] < pivot) i++ // 跳过
    while (pivot < arr[j]) j-- // arr[j] === arr[3] === 11
    if (i < j) { // i === 3, j === 3, 跳过
      [arr[i], arr[j]] = [arr[j], arr[i]]
      i++
      j--
    }
  }
  return i // i === 3
}

然后进入

function quick(arr, low, height) {
  // low === 3, height === 4
  if (arr.length > 1) {
    const index = partition(arr, low, height) // index = 3
    if (low < index - 1) quick(arr, low, index - 1) // 3 < 2
    if (index < height) quick(arr, index, height) // 3 < 4 进入 quick(arr, index, height)
  }
  return arr
}

重点来了此时进入

quick(arr, index, height) // index === 3, height === 4

我们就会开始无限循环这个步骤了.

为什么呢?

问题出在

我们在对3~4分组后,我们应该返回的是4

这样在后续的分组中才会分成

3 ~ (4-1)4 ~ 4

这样的话,就说明此分组已经只有一个元素了,不会继续再分了

如果返回的是3

分组的情况就是

3~(3-1)3~4

会误以为还能继续分,就不断循环分3~4

效率(匆忙)

别的先不说,对比归并排序有个显著的提升就是:不需要额外的空间了!

时间复杂度是 O(nlog(n),且性能通常比其他复杂度O(nlog(n)的排序算法要好。

效率以后再认真写,现在没时间。

然后 ,前面归并排序讲过 快速排序是不稳定的