彻底理解常见排序算法

351 阅读17分钟

排序(Sorting)是一个非常常见的功能,在平时生活中也是随处可见的,排序算法就是研究如何对一个集合进行高效排序的算法,也是在面试时非常常见的面试题型之一

认识

在计算机科学与数学中,排序算法(Sorting algorithm)是一种能将一串资料依照特定排序方式排列的算法

  • 虽然排序算法从名称来看非常容易理解,但是从计算机科学发展以来,在此问题上已经有大量的研究

  • 由于排序非常重要而且可能非常耗时,所以它已经成为一个计算机科学中广泛研究的课题,人们已经研究出一套成熟的方案来实现排序

在计算机科学所使用的排序算法通常依以下标准分类

  • 计算的时间复杂度:使用大O表示法,也可以实际测试消耗的时间

  • 内存使用量(甚至是其他电脑资源):比如外部排序,使用磁盘来存储排序的数据

  • 稳定性:稳定排序算法会让原本有相等键值的纪录维持相对次序(排序后没有交换相等值的前后位置)

  • 排序的方法:插入、交换、选择、合并等等

  • 常见的排序算法非常多: 我们主要学习前七个

    • 冒泡排序(Bubble Sort
    • 选择排序(Selection Sort
    • 插入排序(Insertion Sort
    • 归并排序(Merge Sort
    • 快速排序(Quick Sort
    • 堆排序(Heap Sort
    • 希尔排序(Shell Sort
    • 计数排序
    • 桶排序
    • 基数排序
    • 内省排序
    • 平滑排序
  • 排序算法的时间复杂度: image.png

工具封装

  • 交换数组两元素方法

    export function swap(arr: number[], i: number, j: number) {
      // const temp = arr[i];
      // arr[i] = arr[j];
      // arr[j] = temp;
    
      [arr[i], arr[j]] = [arr[j], arr[i]];
    }
    
  • 检查排序后的数组是不是有序数组

    export function isSorted(arr: number[]): boolean {
      for (let i = 0; i < arr.length; i++) {
        if (arr[i] > arr[i + 1]) {
          return false;
        }
      }
      return true;
    }
    
  • 自动生成随机数组成数组并排序

    type SortAlgoFn = (arr: number[]) => number[];
    export function testSort(sortFn: SortAlgoFn) {
      const nums = Array.from({ length: 10 }, () => {
        return Math.floor(Math.random() * 200);
      });
    
      console.log("排序前的原数组:", nums);
      const newNums = sortFn(nums);
      console.log("排序后的新数组:", newNums);
      console.log("是否排序后有正确的顺序?", isSorted(newNums));
    }
    
  • 查看对包含十万条数据的数组进行排序的效率

    export function measureSort(sortFn: SortAlgoFn, n: number = 100000) {
      const arr = Array.from({ length: n }, () => Math.floor(Math.random() * n));
    
      const startTime = performance.now();
      sortFn(arr);
      const endTime = performance.now();
    
      const timeElapsed = (endTime - startTime).toFixed(2);
      console.log(
        `大量数据测试:使用 ${sortFn.name} 算法 排序 ${n} 个元素 消耗时间为 ${timeElapsed} 毫秒.`
      );
    }
    

冒泡排序

我们先从一个最简单的排序算法入手:冒泡排序(Bubble Sort),这个算法的名字由来是因为越大的元素会经由交换慢慢浮到数组的尾端,故名冒泡排序

  • 它的实现简单,代码实现也容易理解,但是在实际的应用中,冒泡排序并不常用,因为它的效率较低,通常被更高效的排序算法代替,如快速排序、归并排序等

思路

  • 第一个元素开始,逐一比较相邻元素的大小

  • 如果前一个元素比后一个元素大,则交换位置

  • 在第一轮比较结束后,最大的元素被移动到了最后一个位置

  • 在下一轮比较中,不再考虑最后一个位置的元素,重复上述操作

  • 每轮比较结束后,需要排序的元素数量减一,直到没有需要排序的元素,排序结束

  • 这个流程会一直循环,直到所有元素都有序排列为止

  • 小优化:如果元素不需要交换位置,则后面的不必再循环比较

Snipaste_2024-11-17_16-05-36.png

实现

import { measureSort, swap, testSort } from "./utils";

function bubbleSort(arr: number[]): number[] {
  let n = arr.length; // 算法中常用n表示数组长度,方便大O表示法计算

  for (let i = 0; i < n; i++) {
    let swapped = false;
    for (let j = 0; j < n - 1 - i; j++) {
      if (arr[j] > arr[j + 1]) {
        swap(arr, j, j + 1);
        swapped = true;
      }
    }
    if (!swapped) break;
  }
  return arr;
}
testSort(bubbleSort);
measureSort(bubbleSort);

image.png

时间复杂度

时间复杂度关注的是算法的性能在大规模输入下的增长趋势,冒泡排序的时间复杂度主要取决于数据的初始顺序,最坏情况下时间复杂度是O(n^2),不适用于大规模数据的排序 image.png

  • 最好情况O(n):当数组已经有序时,不需要任何交换操作

    • 经过优化版本(添加一个标志变量 swapped),可以在第一轮遍历发现没有交换后提前终止

    • 比较次数为 n−1,总复杂度为 O(n)

  • 最坏情况O(n^2):待排序的序列是完全逆序的,每一轮都需要比较和交换

    • 第一轮比较 n−1 次,第二轮比较 n−2 次,以此类推,最后一轮比较 1

    • 总次数就为: image.png

    • 交换次数与比较次数相同,因为每次比较都需要交换,所以总操作次数是 O(n^2)

  • 平均情况O(n^2):待排序的序列是随机排列的,每一对元素的比较和交换都有1/2的概率发生

    • 比较次数仍为 n(n−1)/2,交换次数为大约一半的比较次数:n(n−1)/4

    • 交换次数和比较次数加起来总的时间复杂度还是 O(n^2)

冒泡排序适用于数据规模较小的情况,因为它的时间复杂度为O(n^2),对于大数据量的排序会变得很慢

选择排序

选择排序(Selection Sort)的实现方式很简单,并且容易理解,因此它是学习排序算法的很好的选择

  • 虽然选择排序的实现非常简单,但是它的时间复杂度较高,对于大规模的数据排序效率较低

  • 如果需要对大规模的数据进行排序,通常会选择其他更为高效的排序算法,例如快速排序、归并排序等

思路

  • 遍历数组,找到未排序部分的最小值

    • 首先,将未排序部分的第一个元素标记为最小值

    • 然后,从未排序部分的第二个元素开始遍历,依次和已知的最小值进行比较

    • 如果找到了比最小值更小的元素,就更新最小值的位置

  • 若最小值位置不是标记的第一个元素位置就将标记的第一个元素和最小值位置的元素交换

  • 重复执行步骤 1 和 2,直到所有元素都有序

Snipaste_2024-11-18_10-20-28.png

实现

import { measureSort, swap, testSort } from "./utils";

function selectionSort(arr: number[]): number[] {
  const n = arr.length;
  for (let i = 0; i < n - 1; i++) {
    let minIndex = i;
    for (let j = i + 1; j < n; j++) {
      if (arr[minIndex] > arr[j]) {
        minIndex = j;
      }
    }
    minIndex !== i && swap(arr, i, minIndex);
  }

  return arr;
}
testSort(selectionSort);
measureSort(selectionSort);

image.png

时间复杂度

  • 比较次数

    • 外层循环执行 n−1 次(因为最后一个元素自动有序)

    • 内层循环 n-i-i 次,比较 n-i-1

    • 总的比较次数并进行等差数列求和为: Snipaste_2024-11-18_13-54-31.png

    • 因此比较次数为 O(n^2)

  • 交换次数

    • 每轮最多进行一次交换,总交换次数为 n−1,因此交换次数为 O(n)
  • 最好情况O(n^2):待排序的数组本身就是有序的,需要比较O(n^2)不需要交换

  • 最坏情况O(n^2):待排序的数组是倒序排列的,需要 O(n^2) 比较和 O(n) 交换

  • 平均情况O(n^2):待排序的数组是随机排列的,每一对元素的比较和交换都有1/2的概率发生,仍为 O(n^2)

交换次数远少于比较次数,仅为 O(n),所以选择排序相较于冒泡排序有一定的性能优势,适用于小规模数据或需要尽量减少交换操作的场景,但在实际应用中较少使用,因为性能低于更高效的排序算法(如快速排序或堆排序)

插入排序

插入排序(Insertion Sort)是一种简单直观的排序算法,它虽然没有快速排序和归并排序等高级排序算法的复杂性和高效性,但是它的实现非常简单,而且在一些特定的场景下表现也很好

插入排序就像我们打扑克牌时,摸到一张新牌需要插入到手牌中的合适位置一样

  • 我们会将新牌和手牌中已有的牌进行比较,找到一个合适的位置插入新牌

  • 如果新牌比某张牌小,那么我们就把这张牌向右移动一位,为新牌腾出位置

  • 一直比较直到找到一个合适的位置将新牌插入,这样就完成了一次插入操作

思路

  • 首先从第二个元素开始,不断与前面的有序数组元素进行比较

  • 如果前面元素大于当前元素,就往后移动一位,方便当前元素插入

  • 否则,继续与前面的有序数组元素进行比较

  • 以此类推,直到整个数组都有序

Snipaste_2024-11-18_14-13-15.png

实现

import { measureSort, testSort } from "./utils";

function insertionSort(arr: number[]): number[] {
  const n = arr.length;

  for (let i = 1; i < n; i++) {
    const key = arr[i];
    let j = i - 1;
    while (j >= 0 && arr[j] > key) {
      arr[j + 1] = arr[j];
      j--;
    }
    arr[j + 1] = key;
  }

  return arr;
}
testSort(insertionSort);
measureSort(insertionSort);

image.png

时间复杂度

插入排序的时间复杂度分析主要取决于数组中元素之间的初始顺序 image.png

  • 最好情况O(n):待排序的数组本身就是有序的

    • 对于每个元素只比较一次,总共比较 n−1 次,移动次数 0

    • 比较次数为 O(n),移动次数为 O(0),总时间复杂度为:O(n)

  • 最坏情况O(n^2):待排序的数组是倒序排列的 Snipaste_2024-11-18_13-54-31.png

  • 平均情况O(n^2):待排序的数组是随机排列的 image.png

如果数组部分有序,插入排序可以比冒泡排序和选择排序更快,但是如果数组完全逆序,则插入排序的时间复杂度比较高,不如快速排序或归并排序

归并排序

归并排序是一种非常高效的排序算法,它的核心思想是分治

  • 这个算法最早出现在1945年,由约翰·冯·诺伊曼(John von Neumann)(现代计算机之父,冯·诺依曼结构、普林斯顿结构)首次提出

  • 当时他在为美国政府工作,研究原子弹的问题,他在研究中提出了一种高效计算的方法,这个方法就是归并排序

思路

归并排序的基本思路是分治法,分成子问题分别解决,然后将子问题的解合并成整体的解

  • 分解(Divide:使用递归算法来实现分解过程

    • 如果待排序数组长度为1,认为这个数组已经有序,直接返回

    • 将待排序数组从中间分成两个子数组,分别对这两个子数组进行递归排序

  • 合并(Merge:合并过程中,需要比较每个子数组的元素并将它们有序地合并成一个新的数组

    • 可以使用两个指针 ij 分别指向两个子数组的开头,比较它们的元素大小,并将小的元素插入到新的有序数组中

    • 如果其中一个子数组已经遍历完,就将另一个子数组的剩余部分直接插入到新的有序数组中,最后返回这个有序数组

  • 递归终止:当子数组的长度小于等于1时,认为这个子数组已经有序,递归结束

Snipaste_2024-11-18_14-14-13.png

实现

import { measureSort, testSort } from "./utils";

function mergeSort(arr: number[]): number[] {
  if (arr.length <= 1) return arr;

  // 1.分解(divide): 对数组进行分解(分解成两个小数组)
  const mid = Math.floor(arr.length / 2);
  const leftArr = arr.slice(0, mid);
  const rightArr = arr.slice(mid);
  const newLeftArr = mergeSort(leftArr);
  const newRightArr = mergeSort(rightArr);

  // 2.合并(merge): 将两个子数组进行合并(双指针)
  const newArr: number[] = [];
  let i = 0;
  let j = 0;
  while (i < newLeftArr.length && j < newRightArr.length) {
    if (newLeftArr[i] <= newRightArr[j]) {
      newArr.push(newLeftArr[i]);
      i++;
    } else {
      newArr.push(newRightArr[j]);
      j++;
    }
  }

  if (i < newLeftArr.length) {
    newArr.push(...newLeftArr.slice(i));
  }
  if (j < newRightArr.length) {
    newArr.push(...newRightArr.slice(j));
  }

  return newArr;
}
testSort(mergeSort);
measureSort(mergeSort);

image.png

时间复杂度

  • 分解次数

    • 将数组递归地分解为两半,直到子数组的长度为 1,这个过程实际上构建了一棵完全二叉树

    • 分解只是将数组切成两半,分解的层数为 log⁡n,没有复杂操作,因此每一层的分解操作的时间复杂度是 O(1),分解总的时间复杂度是 O(log n)

  • 合并次数

    • 在每一层的合并中,所有的子数组长度之和等于 n,即需要遍历数组的所有元素,每一层的合并代价是 O(n)
  • 合并的层数等于分解的层数为 log⁡n,每一层的合并代价是 O(n),有 logn 层,总的时间复杂度是O(nlog n)(无论是最优、最坏还是平均情况)

  • 最好情况 O(nlog n):待排序的数组本身就是有序的

  • 最坏情况 O(nlogn):待排序的数组是倒序排列的

  • 平均情况 O(nlogn):待排序的数组是随机排列的

快速排序

快速排序(Quick Sort)是一种经典的排序算法,有时也被称为划分交换排序partition-exchange sort) ,它的发明人是一位名叫 Tony Hoare(东尼·霍尔)的计算机科学家

  • Tony Hoare1960年代初期发明了快速排序,是在一份ALGOL60手稿中

  • 为了让稿件更具可读性,他采用了这种新的排序算法

  • 当时快速排序还没有正式命名,后来被 Tony Hoare 命名为 quicksort

  • 由于快速排序的思想非常巧妙,因此在计算机科学中得到了广泛的应用

虽然它的名字叫做快速排序,但并不意味着它总是最快的排序算法, 它的实际运行速度取决于很多因素,如输入数据的分布情况、待排序数组的长度等等

思路

快速排序(Quick Sort)是一种基于分治思想的原地排序算法,不需要额外的数组空间

  • 选择基准(Pivot:从数组中选择最后一个元素作为基准(可以是第一个元素、最后一个元素、中间元素或随机选择)

  • 分区(Partition

    • 设置两个指针i表示小于基准的区域的末尾位置,j遍历数组的当前元素

    • i从左往右寻找比基准大的值停止,j从右往左寻找比基准小的值停止

    • 交换ij位置的两个元素,循环当i大于等于j时停止

    • 循环完后将基准放到正确位置

  • 递归处理:对基准左右两部分的子数组重复上述步骤,直到左右两部分只剩下一个元素,整个数组变得有序

image.png

实现

import { measureSort, swap, testSort } from "./utils";

function quickSort(arr: number[]): number[] {
  partition(0, arr.length - 1);

  function partition(left: number, right: number) {
    if (left >= right) return;

    let pivot = arr[right]; // 选取最右侧为基准值
    let i = left; // 左指针
    let j = right - 1; // 右指针

    while (i <= j) {
      // 移动左指针,找到一个大于等于 pivot 的元素
      while (arr[i] < pivot) {
        i++;
      }
      // 移动右指针,找到一个小于等于 pivot 的元素
      while (arr[j] > pivot) {
        j--;
      }
      // 交换元素,使左边小于 pivot,右边大于 pivot
      if (i <= j) {
        swap(arr, i, j);
        i++;
        j--;
      }
    }

    // 把 pivot 放到正确的位置
    swap(arr, i, right);

    // 递归对左右两边进行分区
    partition(left, i - 1); // 处理左区间
    partition(i + 1, right); // 处理右区间
  }

  return arr;
}

testSort(quickSort);
measureSort(quickSort);

image.png

时间复杂度

  • 最好情况 O(nlog⁡n):待排序的数组本身就是有序的

    • 每次分区都能将数组均匀分成两半

    • 总的递归层数为 log⁡n,每层处理 O(n) 个元素

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

  • 最坏情况 O(n^2):待排序的数组是倒序排列的

    • 每次分区都选到极端的基准(如数组已排序时选择最左或最右的元素)

    • 总的递归层数为 n,每层处理 O(n) 个元素,时间复杂度:O(n^2)

  • 平均情况 O(nlogn):待排序的数组是随机排列的,分区较为均匀,时间复杂度O(nlog⁡n)

快速排序的时间复杂度主要取决于基准元素的选择、数组的划分、递归深度等因素,它的性能优于许多其他排序算法,因为它具有良好的局部性和使用原地排序的优点

堆排序

堆排序(Heap Sort)是一种基于比较的排序算法,它的核心思想是使用二叉堆来维护一个有序序列,堆排序和选择排序有一定的关系,因为它们都利用了选择这个基本操作,堆相关知识学习这篇文章**juejin.cn/post/743069…**

思路

  • 构建一个最大堆

    • 遍历待排序序列,从最后一个非叶子节点开始,依次对每个节点进行下滤

    • 下滤:

      假设当前节点的下标为 i,左子节点的下标为 2i+1,右子节点的下标为 2i+2,父节点的下标为 (i-1)/2

      对于每个节点 i,比较它和左右子节点的值,找出其中最大的值,并将其与节点 i 进行交换

  • 每次将堆顶元素(最大值)与数组最后一个元素交换

  • 调整剩余部分为最大堆,重复下滤操作直到排序完成

image.png

实现

  • 依赖heap

    import { Heap } from "../堆Heap/实现";
    import { measureSort, swap, testSort } from "./utils";
    
    function heapSort(arr: number[]): number[] {
      const heap = new Heap(arr);
      // console.log(arr); // [ 10, 5, 3, 4, 1 ],原数组被改变,是因为Heap中this.data 被赋值为 array 的引用
    
      // 建堆之后进行循环交换
      for (let i = arr.length - 1; i > 0; i--) {
        heap.swap(0, i);
        heap.length--;
        heap.percolateDown(0);
      }
      return heap.data;
    }
    
    testSort(heapSort);
    measureSort(heapSort);
    
  • 不依赖类实现

    import { measureSort, swap, testSort } from "./utils";
    
    function heapSort(arr: number[]): number[] {
      let n = arr.length;
      function percolateDown(i: number) {
        const li = 2 * i + 1; // 左节点index
        const ri = li + 1; // 右节点index
        if (li >= n) return; // 没有左子节点
    
        // 确定要交换的子节点(默认为左子节点)
        let si = li;
        if (ri < n && arr[ri] > arr[si]) {
          // 如果右子节点存在且大于左子节点,选择右子节点
          si = ri;
        }
        if (arr[si] > arr[i]) {
          // 有左子节点并且值大于节点值
          swap(arr, i, si);
          percolateDown(si);
        }
      }
    
      let i = Math.floor((n - 1) / 2); // 最后的非叶子结点的索引
      while (i >= 0) {
        percolateDown(i);
        i--;
      }
    
      // 建堆之后进行循环交换
      for (let i = n - 1; i > 0; i--) {
        swap(arr, 0, i);
        n--;
        percolateDown(0);
      }
      return arr;
    }
    
    testSort(heapSort);
    measureSort(heapSort);
    

image.png

时间复杂度

  • 建堆复杂度

    • 如果堆有 n 个节点,其高度为 h 完全二叉树的高度与节点数量是对数关系

    • 层数越深的节点越多,但调整次数越少,因此,堆化的调整过程最多会经过 O(log⁡n)

  • 排序复杂度:每次调整是 O(log⁡n),做 n−1 次,所以排序耗时是 O(nlog⁡n),堆排序的性能与输入数据无关

  • 最好情况 O(nlogn):待排序的数组本身就是有序的

  • 最坏情况 O(nlogn):待排序的数组是倒序排列的

  • 平均情况 O(nlogn):待排序的数组是随机排列的

堆排序具有时间复杂度为 O(nlogn) 的优秀性能,并且由于它只使用了常数个辅助变量来存储堆的信息,因此空间复杂度为 O(1)

但是由于堆排序的过程是不稳定的,即相同元素的相对位置可能会发生变化,因此在某些情况下可能会导致排序结果不符合要求

总的来说堆排序是一种高效的、通用的排序算法,它适用于各种类型的数据,并且可以应用于大规模数据的排序