Loading...
墨滴

linkchen

2021/12/16  阅读:65  主题:红绯

面试中常考的6大排序算法原理, 看完即懂!

本文讲了选择, 插入, 希尔, 归并, 快速, 这6个排序算法, 没有算上冒泡, 是因为我觉得本质上它跟插入排序没有什么不同. 你能理解插入排序, 那冒泡肯定没有任何问题. 并且它们的本身的知识与思路也是一个递进的关系. 只要你一个一个往后看. 你都会发现前个排序会给你理解下一个排序带来很大的帮助.

本文的算法实现代码均出自 《算法》第四版, 我可以向你保证它的权威性😄

一些小tips

  • 在看算法之前, 我们先写好两个工具函数, 并且我会在实现算法的时候频繁用到, 为减少篇幅, 这里先罗列出来, 下方代码中, 我就省略这两个函数的定义啦
const less = (a: number, b: number): boolean => a <= b;

const exch = (arr: number[], i: number, j: number) => {
  const t = arr[i];
  arr[i] = arr[j];
  arr[j] = t;
};
  • 我们所有实现的排序算法, 都是由小到大的排序. 你可以根据需要更改less函数

  • 我们实现的所有算法, 都是可以跑通的, 加上上述的工具函数, 即可放到leetcode: 912. 排序数组中调试

  • 为什么用TypeScript?

    实际上写一个算法用TypeScript好像非常没必要, 但实际上我自己在琢磨算法的时候发现, 用TypeScript能更加直观地阐述我的思路, 避免一些低级错误. 我们并不会有高级的用法, 仅仅是对于类型做一些约束操作, 我也十分推荐你使用TypeScript

选择与插入排序

这里我们把选择排序插入排序放到一起看, 因为它们两非常相似, 本质上就是在指定的位置, 选择排序是往后比较, 而插入排序是往前比较(比较思路上也有一点区别). 这两个算法简单, 并且非常好理解, 对于数据量非常小的场景非常简单有效.

选择排序

原理

  1. 第一个循环遍历整个数组, 拿出每次需要比较的目标元素 nums[i]
  2. 第二个循环遍历从索引i以后的数组元素开始递增
  3. 逐个进行比较, 满足条件则交换位置
  • 步骤2索引i开始的原因很简单, 就是我们不需要去管i以前的元素(它们都已经排序完毕了)

实现

function sortArray(nums: number[]{
  for (let i = 0; i < nums.length; i++) {
    for (let j = i; j < nums.length; j++) {
      if (!less(nums[i], nums[j])) {
        exch(nums, i, j);
      }
    }
  }
  return nums;
}
image.png
image.png

插入排序

原理

  1. 第一个循环遍历整个数组, 使用索引i限制第二个循环的遍历长度
  2. 第二个循环遍历从索引i开始递减
  3. 按第二个循环的遍历顺序比较, jj - 1两个元素的大小, 满足条件则交换位置

实现

function sortArray(nums: number[]): number[] {
  for (let i = 1; i < nums.length; i++) {
    for (let j = i; j > 0 && less(nums[j], nums[j - 1]); j--) {
      exch(nums, j, j - 1);
    }
  }
  return nums;
}
image.png
image.png

选择与插入的图解

image.png
image.png

希尔排序

从图中可以看出, 插入排序选择排序有一个特别严重的问题, 它们的排序逻辑是与相邻位置的元素进行比较. 但如果假设我们的数组非常大, 并且符合排序条件的两个元素相关特别远(一个在数组首部, 一个在尾部), 就会导致上述两种排序算法的性能直线下降(需要进行大规模的比较和交换)

  • 所以我们接下来要介绍的算法, 就是为了解决上述问题的. 我们可以大概看一下它的比较方式(下图). 你可以理解为, 他每次就是基于一个特殊值作为递增或递减的累加值(插入排序中, 我们用的是i++), 希尔排序是基于这点对插入排序做了优化. 使得它能够大范围的比较数组

图中 h = 4 我们可以将其理解为上述的特殊值 image.png

原理

图中有一句话: "一个h有序的数组即是一个由h个有序的子数组组成" 这句话怎么理解呢? 很简单. 假设我们有这样的一个数组:

const arr = [12345678]

我们令 h = 2 基于这个跨度我们可把 arr 分成两个小数组, 如果把循环想成在走路, 那就相当于每走两步, 我们就提出来一个元素

const arr = [12345678]
const cArr= [   2,    4,    6,    8]
const cArr= [1,    3,    5,    7]
  • 是不是发现我们提出来的两个小数组, 也是有序的. 那他就满足了上述的定义.

这就是实际上这就是希尔排序要做的事情, 他要基于这个h值, 将目标数组的每个子数组都变成h有序数组, 这样大数组就自然而然有序了.

image.png
image.png
  1. 确定一个跨度值
  2. 使用插入排序的思路, 去比较元素. 但是在第二个循环, 不再通过j--自减, 而是基于h自减
  3. 将跨度逐渐缩小

实现

function sortArray(nums: number[]{
  const N = nums.length;
  while (h <= Math.floor(N / 3)) h = 3 * h + 1;
  
  while (h >= 1) {
    for (let i = h; i < N; i++) {
      for (let j = i; j >= h && less(nums[j], nums[j - h]); j -= h) {
        exch(nums, j, j - h);
      }
    }
    h = Math.floor(h / 3);
  }
  return nums;
}

注: 为什么我们要基于这样的公式决定跨度的大小?

如何选择递增序列呢? 要回答这个问题并不简单。算法的性能不仅取决于 h,还取决于 h 之间 的数学性质,比如它们的公因子等。有很多论文研究了各种不同的递增序列,但都无法证明某个序 列是“最好的”。算法 2.3 中递增序列的计算和使用都很简单,和复杂递增序列的性能接近。但可 以证明复杂的序列在最坏情况下的性能要好于我们所使用的递增序列。更加优秀的递增序列有待我 们去发现。-----《算法》第四版

希尔排序可视轨迹

image.png
image.png

归并排序

我们从希尔排序看到了一种思路, 就是将数组分成多份, 挨个排序. 这好像也是一种不错的做法.🤔

原理

归并一词, 我觉得可以理解为"递归"和"合并". 这也是归并排序的核心操作.

  1. 拆分数组, 将数组拆成多份. (递归操作直到不可分割)
  2. 将拆分的两份子数组相互比较
  3. 将满足条件的值按顺序放置到原数组中(合并)
image.png
image.png

实现

这一部分实现比前面几种排序稍稍复杂一点, 我们挨个看. 首先理解数组是如何递归拆分的, 然后再来看如何排序与合并

  1. 递归拆分: 要满足递归操作的条件实际上很简单, 只需要确保两点

    (1). 确保在边界的情况阻止继续递归(在这个场景就是无法拆分的时候)

    (2). 确保操作的数据与上一次递归结构上一致(这个场景也满足, 拆分后的数组不还是数组嘛)

  2. 选择一个拆分点: 归并排序中我们采用数组中位

/**
@param {*} nums
@param {*} lo 数组首位
@param {*} hi 数组尾位
@returns
*/

// 首次进入函数的时候, 我们直接取大数组的首尾
function sortArray(nums: number[], lo: number = 0, hi: number = nums.length - 1{
    if (lo >= hi) return // 确保无法拆分后, 停止递归
    let mid = Math.floor(lo + (hi - lo) / 2// 取一个中位数
    sortArray(nums, lo, mid)
    sortArray(nums, mid + 1, hi)
    return nums;
}

上述函数一旦执行, 就能把数组拆分到无法拆分为止. 好了接下来我们来想想怎么将他们排序, 然后合并起来.

  • 如果做一次拆分(递归), 我们就要创建一个新的数组来保存子数组的话. 就太浪费内存了. 我们可以只用一个辅助函数,定义在全局下, 将子数组拷贝一份到这个数组中, 然后排序后再放回.

现在我们来想想, 应该如何排序, 并且在排序的过程中, 会遇到哪几种情况.

  1. 我们在这个函数下, 可以根据 mid 再次将数组分成两部分.

  2. 定义两个变量(以下称为指针), 用来分别表示, 数组的左右个半部分的扫描

  3. 将这两个指针指向的元素进行比较, 将小的放回原数组, 然后这个指针往前移动一位, 继续比较,以此类推

    (1). 这里还有种可能: 左右指针都走到边界了. 这时候就不需要比较, 直接按顺序放入数组即可. 因为他们肯定是最大的元素

let aux: number[] = [];

function mergeArr(nums: number[], lo: number, mid: number, hi: number{
  let i = lo; // 左指针
  let j = mid + 1// 右指针
  for (let k = lo; k <= hi; k++) aux[k] = nums[k]; // 复制一份
  for (let k = lo; k <= hi; k++) {
      if      (i > mid)              nums[k] = aux[j++]
      else if (j > hi)               nums[k] = aux[i++]
      else if (less(aux[i], aux[j])) nums[k] = aux[i++]
      else                           nums[k] = aux[j++]
  }
}

这样排序与合并的逻辑就完成了. 我们只需要在函数拆分完毕后, 调用该函数即可.

/**
@param {*} nums
@param {*} lo 数组首位
@param {*} hi 数组尾位
@returns
*/

// 首次进入函数的时候, 我们直接取大数组的首尾
let aux: number[] = [];

function mergeArr(nums: number[], lo: number, mid: number, hi: number{
  let i = lo; // 左指针
  let j = mid + 1// 右指针
  for (let k = lo; k <= hi; k++) aux[k] = nums[k]; // 复制一份
  for (let k = lo; k <= hi; k++) {
      if      (i > mid)              nums[k] = aux[j++]
      else if (j > hi)               nums[k] = aux[i++]
      else if (less(aux[i], aux[j])) nums[k] = aux[i++]
      else                           nums[k] = aux[j++]
  }
}

function sortArray(nums: number[], lo: number = 0, hi: number = nums.length - 1{
    if (lo >= hi) return // 确保无法拆分后, 停止递归
    let mid = Math.floor(lo + (hi - lo) / 2// 取一个中位数
    sortArray(nums, lo, mid)
    sortArray(nums, mid + 1, hi)
+   mergeArr(nums, lo, mid, hi);
    return nums;
}

归并排序图解

image.png image.png

我们可以通过代码看出来, 归并排序有一个问题, 它仍然需要借助一个辅助数组aux[], 去帮助它完成排序. 那么有没有一种排序算法, 可以实现原地排序呢? 当然了, 就是我们接下来要介绍的快速排序

快速排序

快速排序流行的原因是它实现简单、 适用于各种不同的输入数据且在一般应用中比其他排序算法都要快得多。快速排序引人注目的特点包 括它是原地排序(只需要一个很小的辅助栈),且将长度为 N 的数组排序所需的时间和 NlgN 成正比。 我们已经学习过的排序算法都无法将这两个优点结合起来。-----《算法》第四版

原理

快排的原理可以这么总结: "任意选择一个元素, 并且保证这个元素的左侧元素都不大于它, 这个元素的右侧元素都不小于它" image.png

  1. 我们先任意选择一个元素作为切分元素, 比如:
const arr = [35421]
const v = arr[0// 是的就是这么随意
  1. 我们需要让这个数组满足上述提到的条件, 只需要这么做

    (1). 定义左右指针

    (2). 一起向中间移动(左指针自增, 右指针递减), 并同时进行比较

    (3). 当左右指针都到中间位置后, 将我们的切分元素与中间位置调换

  2. 当前比较结束后, 基于上个步骤最终的中间位置, 可以将数组分成两份, 再让子数组执行步骤1, 2(递归).

实现

function partition (nums: number[], lo: number, hi: number{
    /**
     * 1. 选一个比较值
     * 2. 左右指针都与选定值比较, 如果符合条件, 左右指针向前推进
     * 3. 最后 将 j 值与比较值对调, 返回j
     */

    let i: number = lo
    let j: number = hi + 1
    const v: number = nums[lo]
    while(true) {
        while (less(nums[++i], v)) if (i === hi) break;
        while (less(v, nums[--j])) if (j === lo) break;
        if(i >= j) break;
        exch(nums, i, j)
    }
    exch(nums, lo, j)
    return j
}
function sortArray (nums: number[], lo: number = 0, hi: number = nums.length - 1{
    if (lo >= hi) return // 递归边界
    let j = partition(nums, lo, hi)
    sortArray(nums, lo, j - 1)
    sortArray(nums, j + 1, hi)
    return nums
}

快排序图解

image.png
image.png

堆排序

堆排序由于涉及到一种比较抽象的数据结构堆有序二叉树, 我另开一篇念叨~👌 堆排序的实现与原理

感谢😘


如果觉得文章内容对你有帮助:

linkchen

2021/12/16  阅读:65  主题:红绯

作者介绍

linkchen