都2022了听说你还不懂快速排序?必须安排!

965 阅读12分钟

1、故事背景

假设在某个平行时空某一期的《开学第一课》的节目中,央视邀请了现任职或曾经就职于央视的主持人共10位嘉宾一同参与歌曲小合唱《歌唱祖国》,主持人为了考虑合唱效果,需要按嘉宾的身高安排坐位。 首先主持人先邀请10位嘉宾走上舞台,嘉宾们的初始相对位置如下图所示: 初始化.png 主持人有着极强的计算机天赋,为了尽快的安排好各个嘉宾对应的座位,于是她就采取了“一定的”的策略安排嘉宾席位,主持人首先请上了两位工作人员上台帮忙。

第一步: 主持人有请一坐号位的朱迅女士出列;请工作人员A站在1号座位前,请工作人员B站在10号座位前,如下图所示: 选取主元2.png 第二步: 主持人让工作人员B从右往左依次询问嘉宾身高,工作人员B首先询问郎永淳先生的身高(本文所有身高信息均非真实数据,如有雷同,纯属巧合),当工作人员B发现郎永淳先生身高大于朱迅女士身高,于是工作人员B接着向左查看,走到9号座位前询问李思思女士发现也不满足,一直这样往左走,直到发现欧阳夏丹女士身高小于朱迅女士的身高,于是主持人安排欧阳夏丹女士到1号座位前等待就坐,并请工作人员A向右边移动一个座位位置,如下图所示: 交换第一次.png 第三步: 主持人让工作人员A从左往右依次询问嘉宾身高,工作人员A首先询问撒贝宁先生的身高,发现撒贝宁先生身高大于朱迅女士身高,于是安排撒贝宁先生到7号座位前等待就坐,并且工作人员B站到6号座位张泉灵女士前,如下图所示: 第二次交换.png 第四步: 主持人让工作人员B继续从右往左依次询问嘉宾身高,工作人员B发现没有嘉宾符合条件,于是直到和工作人员A站在同一个座位面前。显然,此刻工作人员A左侧的嘉宾的身高都会比朱迅女士的小,工作人员B右侧的嘉宾身高都会比朱迅女士的身高大,于是,主持人让朱迅女士先回到工作人员A、B相遇的座位面前就坐,紧接着主持人请(并告诫工作人员A、B以后每次只要他们同时站在一个座位面前,就把最先开始请出列的嘉宾安排在当前位置上就坐)工作人员A、B到候场区稍事休息,如图所示: 填入主元2.png 第五步: 主持人开始数朱迅女士左边的未就坐嘉宾人数,主持人发现朱迅女士左边只有一位嘉宾未就坐,于是主持人就告诉欧阳夏丹女士,您请在1号座位就坐。

第六步: 主持人发现朱迅女士右边还有好多嘉宾的座位还等待安排,于是请董卿女士出列,请工作人员A站在3号座位前,请工作人员B站到郎永淳先生的位置前。如下图所示: 选取主元3.png 第七步: 主持人让工作人员B依次从右往左询问嘉宾身高,但是这一次工作人员B直到和工作人员A站在一起了也没有发现有嘉宾身高小于董卿女士身高,于是,主持人让董卿女士先回到工作人员A、B相遇的座位面前就坐,紧接着请工作人员A、B先回到刚上台的位置等候,如图所示: 放入主元3.png 第八步: 主持人请尼格买提先生出列,请工作人员A站在4号座位前,请工作人员B站到郎永淳先生的座位前,如图所示: 选取主元5.png 第九步: 主持人让工作人员B从右往左依次询问嘉宾身高,工作人员B首先询问郎永淳先生的身高,工作人员B发现郎永淳先生身高大于尼格买提先生的身高,于是工作人员B接着走到9号座位前询问李思思女士发现也不满足,一直这样往前走,直到发现张泉灵女士身高小于尼格买提先生的身高,于是主持人安排张泉灵女士到4号座位前等待就坐,并请工作人员A向右边移动一个座位位置,如下图所示: 主元5交换一次.png 第十步: 主持人让工作人员A从当前面对的嘉宾开始询问嘉宾身高,工作人员A发现王冰冰女士身高大于尼格买提先生,于是,让王冰冰女士到6号座位前等待就坐,如下图所示: 主元6交换2次.png 第十一步: 主持人请尼格买提先生到工作人员A所站座位前坐下,然后请工作人员A、B到候场区稍事休息,如下图所示: 填入主元5.png 第十二步: 主持人开始数尼格买提先生左边的未就坐嘉宾人数,主持人发现只有张泉灵女士一位嘉宾尚未就坐,于是主持人,告诉张泉灵女士在4号座位就坐。如图所示: 递归4.png 第十三步: 主持人请王冰冰女士出列,并请工作人员A站在6号座位前,请工作人员B站在10号位置前。如下图所示: 选取主元8.png 第十四步:主持人让工作人员B从右往左依次询问嘉宾身高,工作人员B首先询问郎永淳先生的身高,工作人员B发现郎永淳先生身高小于王冰冰女士的身高,因此主持人请郎永淳先生到6号座位面前等待就坐,并请工作人员A向右边移动一个座位位置,如下图所示: 主元8交换一次.png 第十五步: 主持人让工作人员A从左往右依次询问嘉宾身高,直到发现康辉先生身高大于王冰冰女士身高,于是主持人请康辉先生到10号座位前等待就坐,并请工作人员B向左边移动一个座位的位置,如下图所示: 主元8交换2次.png 第十六步:主持人让工作人员B继续向左找身高小于王冰冰女士的嘉宾,但是发现往左走了一位就和工作人员A相遇了,根据主持人之前的交代,此刻请王冰冰女士在当前位置就坐,王冰冰女士就坐后,工作人员A,B回到候场区等候,如图所示: 填入主元8.png 第十七步:主持人发现王冰冰女士左侧还有两位嘉宾未就坐,于是,分别让工作人员A来到6号座位面前,工作人员B来到7号座位面前,并且让郎永淳先生出列,如图所示: 递归2.png 第十八步:工作人员发现撒贝宁先生身高大于郎永淳先生,于是继续向左询问,但是发现和工作人员A又站在了同一个位置前面,于是让郎永淳先生在当前位置坐下,如图所示: 递归2-填入主元6.png 第十九步:主持人让撒贝宁先生在当前位置坐下,此处不再赘述。

第二十步:主持人发现王冰冰女士右侧还有两位嘉宾未就坐,这儿的操作步骤与第十八和第十九步类似,此处也不再赘述。

第二十一步: 主持人确定所有的嘉宾已经全部就坐,工作人员A,B离场,此刻观众朋友们发现我们的嘉宾已经按身高从1号座位依次排到第10号座位,如图所示: 排序完成.png

2、快速排序的原理

上面这个过程主持人就是使用的是快速排序的方法确定嘉宾的位置顺序的。

(1)首先设定一个分界值(也可叫主元,pivot),通过该分界值将数组分成左右两部分。 

(2)将大于或等于分界值的数据集中到数组右边,小于分界值的数据集中到数组的左边。此时,左边部分中各元素都小于或等于分界值,而右边部分中各元素都大于或等于分界值。

(3)然后,左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理。 

(4)重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左、右两个部分各数据排序完成后,整个数组的排序也就完成了。

2.3 代码实现(使用JavaScript)

function _quickSort(arr, left, right) {
  /* 如果只有一个嘉宾(或者没有嘉宾)还没有坐下,直接让嘉宾在当前位置坐下即可 */  
  if (left >= right) {
    return
  }
  /* 让当前待排序序列中第一个嘉宾先出列,空出当前位置 */
  let pivot = arr[left]
  /* 工作人员A需要站的未排序序列的开始位置 */
  let i = left;
  /* 工作人员B需要站的未排序序列的结束位置 */
  let j = right;
  /* 如果工作人员A和工作人员B还没有相遇,则继续循环 */
  while (i < j) {
    /* 如果没有嘉宾的身高小于作为主元的嘉宾身高,并且工作人员B还没有和工作人员A相遇 */
    while (i < j && pivot < arr[j]) {
      /* 工作人员B向左边查找 */
      j--
    }
    /* 如果工作人员B还没有和工作人员A相遇,说明此刻前面的while循环是因为找到了嘉宾身高小于主元嘉宾的身高而退出的 */
    if (i < j) {
        /* 把当前找到的嘉宾放到已出列嘉宾(空位)所在的位置,因为当前位置已经处理过了,因为此刻工作人员所在的当前位置已经处理过了,所以需要让工作人员A向右边移动一个位置 */
      arr[i++] = arr[j]
    }
    /* 如果没有嘉宾的身高大于作为主元的嘉宾身高,并且工作人员A还没有和工作人员B相遇 */
    while (i < j && pivot > arr[i]) {
      /* 工作人员B向右边查找 */
      i++
    }
    /* 如果工作人员A还没有和工作人员B相遇,说明此刻前面的while循环是因为找到了嘉宾身高大于主元嘉宾的身高而退出的 */
    if (i < j) {
      /* 把当前找到的嘉宾放到空位(之前后面身高较低的嘉宾已经提到前面去了)所在的位置,因为此刻工作人员B所在的当前位置已经处理过了,所以需要让工作人员B向左边移动一个位置 */  
      arr[j--] = arr[i]
    }
  }
  /* 此刻这个空位的左边的嘉宾身高一定比主元小,右边的嘉宾身高一定比主元大,因此直接当主元嘉宾在这个位置坐下 */ 
  arr[i] = pivot
  // 分别再对主元嘉宾左侧的嘉宾按这个规则排序
  _quickSort(arr, left, i - 1)
  // 分别再对主元嘉宾右侧的嘉宾按这个规则排序
  _quickSort(arr, i + 1, right)
}

/** 为了使得外界有简易的接口形式,因此不宜直接对外暴露复杂的_quickSort函数 
 */
function quickSort(arr) {
  return Array.isArray(arr) ? _quickSort(arr, 0, arr.length - 1) : arr
}

3、进阶

在主持人安排嘉宾座位的例子中,我们发现我们的快速排序并不是那么“快”,这是因为最开始几次排序我们的主元都太“靠边”了,导致了我们一次划分,被移动的元素并不是特别多。

如果我们每次都刚好取到中间的那个元素,岂不是美滋滋吗?

另外还有一个问题是快速排序是使用递归进行排序的,每次递归,函数会进栈,这就意味着会消耗大量的存储空间。在主持人安排嘉宾座位的例子中,我们出现了好几次只有一两个元素待排,却还是不得不使用递归的这种尴尬的场景,这也使得我们的快速排序变的“慢”了,于是,我们又引进一个策略,当元素比较少的时候,我们就直接使用简单排序方式进行排序即可

于是,基于以上的分析,便有了进阶的三元取值法对其进行改进。 代码实现如下:

/**
 * 定义直接插入排序的方法,此直接插入排序方法和普通的插入排序有区别,因为是针对数组的某一个片段进行排序,因此需要引入一个offset偏移量参数
 * @param {Array} arr 待排序数组
 * @param {Number} offset 初始偏移量
 * @param {Number} length 待排序片段的总长度
 */
function _insertionSort(arr, offset, length) {
  let temp, i;
  for (let p = offset; p < offset + length; p++) {
    temp = arr[p];
    for (i = p; i >= 1 && temp < arr[i - 1]; i--) {
      arr[i] = arr[i - 1];
    }
    arr[i] = temp;
  }
}


/**
 * 定义交换函数
 */
function _swap(arr, posA, posB) {
  let temp = arr[posA]
  arr[posA] = arr[posB]
  arr[posB] = temp
}

/**
 * 定义三元取中的方法
 */
function _mediant(arr, left, right) {
  let center = Math.floor((left + right) / 2)
  if (arr[left] > arr[center]) {
    _swap(arr, left, center)
  }
  if (arr[left] > arr[right]) {
    _swap(arr, left, right)
  }
  if (arr[center] > arr[right]) {
    _swap(arr, center, right)
  }
  // 将中间这个元素藏到最后一个元素的前面去
  _swap(arr, center, right - 1)
  // 返回中间这个值作为主元
  return arr[right - 1]
}

function _quickSort(arr, left, right) {
  let len = right - left + 1
  // 本例元素少于5个使用直接插入排序进行,否则使用快速排序
  if (len > 5) {
    // 通过三元法取得一个主元
    let pivot = _mediant(arr, left, right)
    /*
      定义两个初始变量,为了便于编程,我们让i指针最开始指向开始位置,让j指针指向结束位置的前一个位置,为什么可以这样操作呢,因为在我们的三元函数里面,
      我们已经把主元藏到了最后一个元素的前面,也就是说最后一个元素一定是比主元大的,可以跳过
    */
    let i = left
    let j = right - 1
    while (true) {
      /*
        由于我们的最后一个元素的前面一个元素是主元,因此,主元这个位置也是直接不需要考虑的,因此在此处需要先进行j--,跳过当前主元所在的位置,再开始向左循环比较, 如果j指针找到了比主元小的元素(最坏走到了第一位)的话,就退出循环
       */
      while (pivot < arr[--j]);
      /* 由于我们的第一个元素是在三元函数里面交换过的,因此,这个元素是一定比主元小的,在此处需要先进行i++,跳过当前元素所在的位置,再开始向右循环比较,当找到比主元大的(最坏情况找到主元所在的位置)就结束循环 */
      while (pivot > arr[++i]);
      // 如果我们的两个指针相遇了,说明本轮元素已经将所有的比主元大的挪动到了主元后面的位置,比主元小的元素全部挪动到了主元的前面位置,循环就可以结束了
      if (i >= j) {
        break
      }
      // 否则说明,我们的两个指针现在所在的位置的两个元素需要交换
      _swap(arr, i, j)
    }
    // 当本轮循环完成的时候,i和j已经相遇,此刻,这个位置的元素是一定比主元大的(因为内层循环的退出条件是右侧指针找到比主元小的停止,左侧指针找到比主元大的停止),因此,我们把主元放在这个位置即可。
    arr[right - 1] = arr[i]
    arr[i] = pivot
    // 递归的划分i左侧的元素
    _quickSort(arr, left, i - 1)
    // 递归的划分i右侧的元素
    _quickSort(arr, i + 1, right)
  } else {
    // 因为left是从当前待排序片段的开始位置
    _insertionSort(arr, left, len)
  }
}

/**
 * 定义标准的排序函数接口,便于外界调用
 */
function quickSort(arr) {
  return Array.isArray(arr) ? _quickSort(arr, 0, arr.length - 1) : arr
}

4、总结

快速排序的自然语言描述大致如下:

0、如果当前待排序序列片段元素小于等于1个就不用移动,直接结束;否则按照一定的策略取一个元素作为主元;

1、左指针向右移,找大于主元的,找到停在当前位置;如果找到最后一个元素也没有找到就停止;

2、右指针向左移,找小于主元的,找到停在当前位置;如果找到第一个元素也没有找到就停止;

3、如果第一二步找到了的话,就将小元素和大元素(相对于主元)互换;

4、当i指针和j指针相遇的时候,就填入主元;没有相遇就继续重复一二步;

5、递归处理i指针左边的序列,递归处理i指针右边的序列;

快速排序是在冒泡排序上的改进,快速排序之所以快就是元素一次移动的步幅特别大;另外在每次移动元素的过程中,一旦中间元素位置确定,后续其位置将不再改变。

快速排序是不稳定的排序算法,其性能的好坏由我们选取的分界值决定,如果分界值选的差,性能低至O(N2,备注:2是平方的意思)(降至冒泡排序🐶),如果分界值选的好,性能高至O(N*logN)。

由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱404189928@qq.com,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。