6
头图

本文参与了 SegmentFault 思否年度征文「一名技术人的 2022」,欢迎正在阅读的你也加入。

前言

什么是排序?排序在 JavaScript 中对于大部分人来讲是这样的:

arr.sort() // 默认排序,会将元素转换为字符串,然后比较它们的 UTF-16 代码单元值实现排序
arr.sort((a, b) => { return a - b }) // 自定义排序,递增
arr.sort((a, b) => { return b - a }) // 自定义排序,递减

有毛病吗?没毛病!但真的只是这样吗?

23BEC8A5.jpg

死亡连问系列:

  • 不用 sort() 怎么写排序?
  • 有没有了解过 sort() 的原理?
  • 你还知道哪些排序算法?
  • 这些排序算法都有哪些区别呀?

不好意思!🍎我真的只会用 Array.prototype.sort() 写✍排序! 大部分人第一反应绝对是业务中确实不需要自己写排序算法呀!问这些问题干嘛!(你猜猜

就好像买东西,有人觉得能解决当前问题就行,有人觉得既可以解决当前问题又可以解决其他问题才行,为什么?没有为什么,面试官也有不同的需求!23BF0C55.png

下面我们就从以下两个方面来聊一聊:

  • 常见的排序算法
  • Array.prototype.sort() 的原理

常见的排序算法

常见排序算法主要包含如下 5 种:

  • 冒泡排序
  • 选择排序
  • 插入排序
  • 归并排序
  • 快速排序

如果你还在死记所谓的模板,不出意外的话会三番两次的遗忘,不如给自己一点时间去了解其核心的思想,真正做到让 核心思想 带着你把代码写出来!

冒泡排序 — normal 版本

时间复杂度:O(n^2)

核心思想

所谓的 冒泡 其实就是在 每一轮的遍历 中选出一个 最值(最小/最大) 值移动到数组的 两端(最左端/最右端)

基于 最大值冒泡 可以理解为,从第一个元素开始,重复比较相邻的两个元素

  • 如果 前一项元素 > 后一项元素,则交换它们的位置
  • 否则 不交换,继续比对后续的元素

基于 最小值冒泡 可以理解为,从第一个元素开始,重复比较后续的元素

  • 如果 头部元素 > 后续元素,则交换它们的位置
  • 否则 不交换,继续比对后续的元素

JavaScript 实现

如下是选择每次遍历的 最大值 移动端数组的 最右端 来实现 冒泡

export function bubbleSort(arr) {
  const len = arr.length;

  // 外层遍历负责从头到尾进行比较
  for (let i = 0; i < len; i++) {
    for (let j = 0; j < len; j++) {
      // 若相邻元素前面的数比后面的大,则交换
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }
  
  return arr;
}

如下是选择每次遍历的 最小值 移动端数组的 最左端 来实现 冒泡

export function bubbleSort(arr) {
  const len = arr.length;
  
  // 外层循环负责从前往后遍历数组元素,每次遍历时找出比它小的和它替换
  for (let i = 0; i < len; i++) {
      // 当内层循环遍历完一次数组,就能找出本次遍历中的最小值,并把最小值移动到数组头部
       for (let j = i + 1; j < len; j++) {
           // 只要当前头部元素大于后续任意元素就直接交换位置
           if (arr[i] > arr[j]) {
               [arr[i], arr[j]] = [arr[j], arr[i]]
           }
       }
   }

  return arr;
}

冒泡排序 — better 版本

时间复杂度:O(n^2)

核心思想

以上 normal 版本 的实现是最基本的实现,只考虑核心思想,没有考虑重复比较的问题,比如基于 最大值冒泡 的方式中使用到的核心代码为:

// 外层遍历负责从头到尾进行比较
  for (let i = 0; i < len; i++) {
    for (let j = 0; j < len; j++) {
      // 若相邻元素前面的数比后面的大,则交换
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }

里面用到的 双重循环 中的内层循环每次都只是简单的从头遍历到尾,但是真的有必要吗?

前面核心思想部分我们是不是讲过,内层循环每次遍历结束后,本次遍历的最大值就会被移动到数组尾部,即如下:

  • 第 1 次内层循环遍历结束,得到 第 n 大值
  • 第 2 次内层循环遍历结束,得到 第 n-1 大值
  • 第 3 次内层循环遍历结束,得到 第 n-2 大值
  • ......
  • 第 n 次内层循环遍历结束,得到 第 1 大值最小值

这就引出了值得优化的点,就是每次内层循环遍历时,就只需要比较 n - i 之前的元素,因为从 n - i 到 n 的元素都已经有序了。

JavaScript 实现

export function betterBubbleSort(arr) {
  const len = arr.length;

  for (let i = 0; i < len; i++) {
    // len - i 避免遍历到已经有序的部分
    for (let j = 0; j < len - i; j++) {
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }

  return arr;
}

冒泡排序 — best 版本

时间复杂度:O(n) —— O(n^2)

核心思想

以上 better 版本 的实现已经是优化内层循环的遍历次数,但是还有一种情况不得不考虑,那就是传入的数组本身就是 有序数组 时,按正常逻辑有序数组就不需要遍历了,但是 JavaScript 中可没有提供给你一个啥属性能够标识它是否是有序的,因此还是得通过遍历数组才能知道它到底有没有序。

而这样的方式,基于 better 版本 来讲,它的内层循环该遍历多少次,还是会遍历多少次,即使一次也没有发生过交换操作。

那怎么办?怎么标识一个数组是不是有序的呢?

我们知道只要内层循环中进入交互操作的条件分支,那么证明数组必然是无序的,因此可以定义一个 isOrder 用于标识数组是否有序,默认数组是有序的,直接向外进行返回即可;但只要发生交换操作,就将 isOrder 的值改变,证明 当前数组是无序 的,需要继续往后进行判断。

这样一来,当传入数组是有序时,只需要外层循环执行 1 次,内层循环执行 n 次,就可以判断出当前数组是否有序,因此 最好的情况下时间复杂度为 O(n),最坏情况下时间复杂度为 O(n^2)

JavaScript 实现

export function bestBubbleSort(arr) {
  const len = arr.length;
  // 定义 isOrder 用于标识数组是否有序,默认是有序的
  let isOrder = true;

  for (let i = 0; i < len; i++) {
    for (let j = 0; j < len - i; j++) {
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
        isOrder = false;
      }
    }

    // isOrder 的值没有发生更改,意味着数组是有序的,不需要进行额外排序
    if (isOrder) return arr;
  }

  return arr;
}

选择排序

时间复杂度:O(n^2)

核心思想

所谓 选择 就是选择 最值(最大/最小),就是每次遍历确定 最小值索引,每轮遍历结束把 最小值放到数组头部(由于上面我们已经演示过不同最值的实现方式,考虑到篇幅,这里我们就以最小值的形式来看看)。

JavaScript 实现

看着下面的实现,不知道你会不会发现,这和我们前面讲的 基于 最小值 冒泡 的实现方式很类似,只是如下的方式多了 最小值索引 minIdx,并且交换操作是发生在 内层循环结束后,而前者是在 内层循环中 进行的交换操作。

export function selectSort(arr) {
  const len = arr.length;

  // 定义最小值索引
  let minIdx;

  for (let i = 0; i < len; i++) {
    // 将每次循环索引 i 认为是本次遍历的 最小值索引 minIdx
    minIdx = i;

    // i、j 定义为本次需要遍历区间的 边界,i 为 左边界,j 为 右边界
    for (let j = i; j < len; j++) {
      if (arr[j] < arr[minIdx]) {
        minIdx = j;
      }
    }

    // 若当前 minIdx 和 i 不相等,则表明当前已经找到新的最小的元素,则进行交换
    if (minIdx !== i) {
      [arr[i], arr[minIdx]] = [arr[minIdx], arr[i]];
    }
  }

  return arr;
}

插入排序

时间复杂度:O(n^2)

核心思想

所谓 插入 就是指将当前遍历到的元素往 已有序的部分 中进行插入动作,已有序部分你大可以默认数组的 第一个元素 就是 已有序部分,后续遍历的元素只要保证在前面已有序的部分中找合适的位置进行插入即可。

JavaScript 实现

export function insertSort(arr) {
  const len = arr.length;

  // temp 用来保存当前需要插入的元素
  let temp;

  // i = 1 即默认第一位元素(即 i = 0)是有序的
  for (let i = 1; i < len; i++) {
    // j 用于帮助 temp 寻找自己应该有的定位
    let j = i;
    temp = arr[i];

    // j 此时为有序区域的 右边界,因此 j - 1 就是有序区域中的内容
    // 判断 j 前面一个元素是否比 temp 大
    while (arr[j - 1] > temp) {
      // 如果是,则将 j 前面的一个元素后移一位,为 temp 让出位置
      arr[j] = arr[j - 1];
      j--;
    }

    // 循环让位,最后得到的 j 就是 temp 的正确索引
    arr[j] = temp;
  }

  return arr;
}

归并排序

时间复杂度:O(nlog(n))

核心思想

所谓 归并 翻译过来就是 递归 + 合并递归 就是用于处理相同且重复的内容,那 合并 是合并什么!既然 需要合并,那意味着 先得分开,分谁?当然是将数组划分成子数组了(难道是分蛋糕吗),即只要保证子数组有序,且保证合并后的数组也保证是有序的,那么最后一次合并得到的数组自然就是有序的。

这其实是 分而治之 的思想,指的是 将一个大问题分解为若干个子问题,针对子问题分别求解后,再将子问题的解整合为大问题的解

JavaScript 实现

时间复杂度的分析:

每一轮递归,都需要做 切分合并 操作,其中对于 n 的数组来说,需要切分 log2(n) 次,而切分的实际动作是固定的如下代码所示,因此其时间复杂度为 O(1),而在合并操作中通过 while 来实现两个数组的有序合并,因此其时间复杂度为 O(n),所以最终整体的时间复杂度为 O(nlog(n))

// 计算分割点 
const mid = Math.floor(len / 2) 
// 递归分割左子数组,然后合并为有序数组 
const leftArr = mergeSort(arr.slice(0, mid)) 
// 递归分割右子数组,然后合并为有序数组 
const rightArr = mergeSort(arr.slice(mid,len))
log2(n) 表示的是 以 2 的多少次方等于 n,数学上叫 以 2 为底 n 的 对数,但在时间复杂度中一般涉及常数部分可以直接忽略不考虑,即 log2(n) 表示为 log(n)
export function mergeSort(arr) {
  const len = arr.length;

  // 定义递归边界
  if (len <= 1) {
    return arr;
  }

  // 获取中间元素的索引值
  const midIdx = Math.floor(len / 2);

  // 根据中间索引 midIdx 划分左右两个子数组,即进行了 分割
  const left = mergeSort(arr.slice(0, midIdx));
  const right = mergeSort(arr.slice(midIdx, len));

  // 将左右两个子数组进行有序的合并
  return mergeArr(left, right);
}

// 通过双针指针合并两个有序数组
function mergeArr(arr1, arr2) {
  const len1 = arr1.length;
  const len2 = arr2.length;

  // 定义 l r 指针,分别指向 arr1 arr2 中的元素
  let i = 0,
    j = 0;

  // 定义结果集
  const res: any[] = [];

  // 循环合并数组,直到至少一个数组被遍历完
  while (i < len1 && j < len2) {
    if (arr1[i] > arr2[j]) {
      res.push(arr2[j]);
      j++;
    } else {
      res.push(arr1[i]);
      i++;
    }
  }

  // 判断具体是哪个数组被遍历完,将另一个数组直接进行合并即可
  if (i < len1) {
    return res.concat(arr1.slice(i));
  } else {
    return res.concat(arr2.slice(j));
  }
}

快速排序

时间复杂度:O(nlog(n)) —— O(n^2)

核心思想

快速排序 实际上和 归并排序 的思想是高度统一的,都是利用 分治思想 将大问题的解变成小问题的解,但区别在于 归并 是将数组真正进行了分割,而 快排 则是直接在原有的数组内部进行排序,不会真正将数组进行分割,而是用索引值作为指针来代替。

JavaScript 实现

时间复杂度分析:

  • 最好情况

    • 每次选择基准值,都刚好是当前子数组的中间数,即确保每一次分割都能将数组分为两半,进而只需要递归 log2(n)
    • 也可以认为 快排归并 核心思路一致,于是时间复杂度也为 O(nlog(n))
  • 最坏情况

    • 每次划分取到的都是当前数组中的 最大值/最小值,此时 快排 退化为 冒泡排序,因此时间复杂度是 O(n^2)
export function quickSort(arr, left = 0, right = arr.length - 1) {
  // 递归边界
  if (arr.length > 1) {
    // lineIndex 表示下一次划分左右子数组的索引位
    const lineIndex = partition(arr, left, right);

    // 如果左边子数组的长度不小于 1,则递归快排这个子数组
    if (left < lineIndex - 1) {
      // 左子数组以 lineIndex-1 为右边界
      quickSort(arr, left, lineIndex - 1);
    }

    // 如果右边子数组的长度不小于1,则递归快排这个子数组
    if (right > lineIndex) {
      // 右子数组以 lineIndex 为左边界
      quickSort(arr, lineIndex, right);
    }
  }

  return arr;
}

// 以基准值为轴心,划分左右子数组的过程
function partition(arr, left, right) {
  // 基准值默认取中间位置的元素
  let pivotValue = arr[Math.floor(left + (right - left) / 2)];

  // 初始化左右指针
  let i = left;
  let j = right;

  // 当左右指针不越界时,循环执行以下逻辑
  while (i <= j) {
    // 左指针所指元素若小于基准值,则右移左指针
    while (arr[i] < pivotValue) {
      i++;
    }

    // 右指针所指元素大于基准值,则左移右指针
    while (arr[j] > pivotValue) {
      j--;
    }

    // 若 i<=j,则意味着基准值【左边】存在较大元素 或【右边】存在较小元素
    // 交换两个元素确保左右两侧有序
    if (i <= j) {
      [arr[i], arr[j]] = [arr[j], arr[i]];
      i++;
      j--;
    }
  }

  // 返回左指针索引作为下一次划分左右子数组的依据
  return i;
}

Array.prototype.sort() 的原理

v8 为了实现 字节码 + 即时编译(JIT) 的优化,sort 函数在 7.0 后使用谷歌自研的 Torque 语言(即 .tq)来开发,且排序算法变成了 TimSort

7.0 版本及之前的实现

源码地址

该版本及之前使用 js 来开发,核心内容如下:

  • 数组长度 <= 10 时,使用 插入排序
  • 数组长度 > 10 时,使用 快速排序
  // Insertion sort is faster for short arrays.
  if (to - from <= 10) {
    // 插入排序
    InsertionSort(a, from, to);
    return;
  }
  ...
  if (to - high_start < low_end - from) {
    // 快速排序
    QuickSort(a, high_start, to);
    to = low_end;
  } else {
    QuickSort(a, from, low_end);
    from = high_start;
  }

原因在于当数据比较少的时候,插入排序 可能执行时间 更短,比如数组长度为 10 时:

  • 插排 的时间复杂度为:O(n^2),即此时只需要执行 10*10=100
  • 快排 的时间复杂度虽可能为 O(nlog(n)),但其可能为 f*n*(log n)+c,其中 f 为系数,c 为常数,假设 f=10,c=20,此时 O(10) = 10*10*log10+20,在这种情况下明显执行次数会大于 100

Torque 中的实现

源码地址

该版本及使用 tq 来开发,核心内容如下:

  • 数组长度较小 时,使用 二分插入排序
  • 数组长度较大 时,使用 归并排序

其他具体内容可直接查看源码中与 TimeSort 相关部分。

  while (remaining != 0) {
    let currentRunLength: Smi = CountAndMakeRun(low, low + remaining);

    // If the run is short, extend it to min(minRunLength, remaining).
    if (currentRunLength < minRunLength) {
      const forcedRunLength: Smi = SmiMin(minRunLength, remaining);
      
      // 二分插入排序
      BinaryInsertionSort(low, low + currentRunLength, low + forcedRunLength);
      
    }
    
    ...

    // 归并
    MergeCollapse(context, sortState);
}

最后

2022 即将过去,收拾好心情,继续努力!!!未来可期,大家加油!!!


熊的猫
966 声望339 粉丝

业精于勤立不易方,而后鹏程万里!