常见的排序算法

150 阅读5分钟

「算法的稳定性」 :当待排序序列中有相同的元素时,它们的相对位置在排序前后不会发生改变,那么就称这个排序算法是稳定的,但稳定性不能衡量算法的优劣。

一、快速排序(重点):分区pivot + 三参数

快速排序是使用最广泛的排序算法,速度也较快。递归和迭代都需要掌握!

1. 递归:不创建新数组

「主要思想是将待排序的序列分成前后两部分,前半部分都小于基准、后半部分都大于等于基准,然后分别递归两部分」。

  1. 选择一个元素作为 「"基准"(pivot)」 ,选最后一个元素
  2. 所有小于"基准"的元素,都移到"基准"的左边;所有大于"基准"的元素,都移到"基准"的右边。最后将该基准放在两部分的中间位置,这就叫分区操作。
  3. 对"基准"左边和右边的两个子集,不断重复第一步和第二步,直到所有子集只剩下一个元素为止。
// 数组分区,left 和 right 是数组的起始下标和结束下标
const divide = (arr, left, right) => {
  const pivot = arr[right];  // 取最后一个元素为基准值
  let p = left;  // 待交换位置
  
  for (let i = left; i < right; i++) {  // 排除最后一个元素
    if (arr[i] < pivot) { 
      [arr[p], arr[i]] = [arr[i], arr[p]];
      p++;
    }
  }
  // 所有大于基准的元素,都移到基准的右边
  [arr[p], arr[right]] = [arr[right], arr[p]];  
  return p; // 注意返回值
};

// 函数参数列表里的两个默认参数必须加上
const quickSort = (nums, left = 0, right = nums.length - 1) => {  
  if (left < right) {
    const mid = divide(nums, left, right); // 指针 mid 的左侧都小于pivot,右侧都大于
    quickSort(nums, left, mid - 1);
    quickSort(nums, mid + 1, right);
  }
  return nums;
};

时间复杂度:O(nlogn)。

空间复杂度:O(logn)。 递归栈的开销。

2. 非递归:队列+循环

当数据量很大时,递归快排会造成栈溢出。另外,递归主要是在划分子区间,而对于非递归,「递归 = 队列保存区间 + 循环代替递归的重复调用」

对于递归方法每次分组都会 return 一个指针mid,然后我们使用left、right、mid - 1、mid + 1把数组拆成左右两部分。因此非递归算法思路如下:

  • leftright入队
  • 当队列非空时:
    • shift 出leftright下标
    • 然后 divide() 对这段区域进行排序、分组
    • 如果 mid 左边的元素个数大于 1,就将左端的两个下标leftmid - 1入队,右侧同理。
const divide = function(arr, left, right) {  // 和递归的 divide() 相同
  let pivot = arr[right];  // 基准值
  let p = left;  // 待交换位置
  for (let i = left; i < right; i++) {
    if (arr[i] <= pivot) {
      [arr[i], arr[p]] = [arr[p], arr[i]];
      p++;
    }
  }
  [arr[p], arr[right]] = [arr[right], arr[p]];
  return p;
};

const quickSort = (nums, left = 0, right = nums.length - 1) => {
  const queue = [left, right];  // 先进先出队列,初始输入的是整个数组的范围
  
  while (queue.length > 0) {
    let l = queue.shift();  
    let r = queue.shift();
    let mid = divide(nums, l, r);
    
    if (l < mid - 1)  queue.push(l, mid - 1); // 直到左侧的元素个数只有一个时,停止循环
    if (r > mid + 1) queue.push(mid + 1, r);
  }
  return nums;
}

时间复杂度:O(nlogn)。

空间复杂度:O(logn)。栈的开销仍然存在。

二、归并排序(重点):递归 + 合并有序数组

  • 对一个长为 n 的待排序序列,我们将其分解成两个长度为n / 2的子序列,不断递归直到长度等于 1。
  • 然后合并两个有序子数组。

splice会改变原数组,slice不会改变原数组。

const merge = function(arr1, arr2) { // 新建数组,合并两个有序数组为一个数组
  const res = [];
  let p1 = 0, p2 = 0;
  
  while (p1 < arr1.length || p2 < arr2.length) {
    if (p1 >= arr1.length) {  // a1遍历完成, a2还未遍历完
      res.push(arr2[p2++]);
    } else if (p2 >= arr2.length) {  // a2遍历完成,a1还未遍历完
      res.push(arr1[p1++]);
    } else {
      res.push(arr1[p1] > arr2[p2] ? arr2[p2++] : arr1[p1++]);
    }
  }
  return res;
}

const mergeSort = function(nums) {
  if (nums.length < 2)  return nums;   // 递归终止条件
 
  let mid = Math.floor(nums.length / 2);  // 分割为两个子序列,然后递归合并
  const rightNums = nums.splice(mid); // 原地拆分成两个子数组
  
  return merge(mergeSort(nums), mergeSort(rightNums));
}

时间复杂度:O(nlogn)。 先将待排序序列折半成两个子序列递归调用,然后再合并两个有序子序列。所以我们可以列出归并排序运行时间T(n)的递归表达式T(n) = 2T(n / 2) + O(n)。其中O(n)是合并子序列的时间复杂度,

空间复杂度:O(n)。 合并子序列需要 O(n) 的额外空间,递归调用层数最深 log以2为底n,所以还需要 O(logn) 的栈空间,因此空间复杂度是 O(n)。

三、堆排序(较难):建立堆,每次输出顶部元素

堆是具有以下性质的完全二叉树,大顶堆是每个节点的值都大于或等于其左右孩子节点的值,小顶堆是xx小于或等于xx。

  • 大顶堆:a[i] >= a[2i + 1] && a[i] >= a[2i + 2]
  • 小顶堆:a[i] >= a[2i + 1] && a[i] <= a[2i + 2]
  • 最后一个非叶子节点的下标:Math.floor(len / 2) - 1
  • 二叉树中任一节点i的左右子节点下标为2*i + 12*i + 2

思路参考

  1. 将无序序列构建成一个堆,通常升序选择大顶堆、降序选择小顶堆;
  2. 将堆顶元素与末尾元素交换,将最大元素“沉”到数组末端;
  3. 重新调整堆结构,使其满足大顶堆定义。然后反复执行直到整个序列有序。
let len; // 全局变量

// 每一次heapify,都能构建一次大顶堆
const heapify = (nums, i) => {
    let left = 2 * i + 1, right = 2 * i + 2, largest = i;
    // 找出当前节点和其左右子节点中的最大值,下标为largest
    if (left < len && nums[left] > nums[largest])   largest = left;
    if (right < len && nums[right] > nums[largest]) largest = right;

    if (largest != i) { // 最大值不是父节点时,进行调整使其满足最大堆定义
        [nums[i], nums[largest]] = [nums[largest], nums[i]]; // 父子节点交换
        heapify(nums, largest); // 此时largest是子节点,继续从下一层调整
    }
}

const heapSort = (nums) => {
    len = nums.length;
    for (let i = Math.floor(len / 2) - 1; i >= 0; i--) { // 初始化大顶堆,从最后一个非叶子节点开始遍历
        heapify(nums, i);
    }

    for (let i = len - 1; i > 0; i--) { // 每次循环都会找出一个当前最大值,数组长度减一
        [nums[0], nums[i]] = [nums[i], nums[0]]; // 记住顺序
        len--;
        heapify(nums, 0);
    }
    return nums;
}

时间复杂度:O(nlogn)。

  • 完全二叉树:若设二叉树的深度为 h,除第 h 层外,其它层的结点数都达到了最大个数,并且第 h 层所有的结点都连续集中在最左边。

  • 满二叉树:每层的结点数都达到最大值。

四、插入排序:打扑克

  • 对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
  • 在实现上,在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为新元素提供插入空间。

插入排序思路

const insertSort = (nums) => {
    for (let i = 1; i < nums.length; i++) { // 1.已经手握一张牌 2.现在要摸第几张牌
        for (let j = i; j > 0; j--) { // 摸到牌后,从后向前依次和已经摸到的牌比较
            if (nums[j - 1] > nums[j]) { // 因为 j - 1,所以遍历条件 j > 0
                [nums[j], nums[j - 1]] = [nums[j - 1], nums[j]];
            }
        }
    }
    return nums;
}

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

空间复杂度:O(1)。

五、冒泡排序:记双循环

  • 共比较len - 1趟;
  • 每次从无序序列头部开始,两两比较,直到将最值交换到无序序列的[队尾]、成为有序序列的一部分。
const bubbleSort = (nums) => {
  const len = nums.length;
  for (let i = 0; i < len - 1; i++) {  //共遍历 len - 1 次
    for (let j = 0; j < len - 1 - i; j++) { // 每次的最值都会交换到队尾
      if (nums[j] > nums[j + 1]) {
        [nums[j], nums[j + 1]] = [nums[j + 1], nums[j]];
      }
    }
  }
  return nums;
};

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

空间复杂度:O(1)

六、选择排序:有序区和无序区

  • 将整个序列划分为有序区和无序区,初始时有序区为空。
  • 遍历查找无序区中的最小元素,并将其插入有序区的末尾。
const selectSort = (nums) => {
  for (let i = 0; i < nums.length - 1; i++) {
    let min = i; // 假设第一个元素是最小值
    for (let j = i + 1; j < nums.length; j++) {   // 遍历查找无序区的最小元素索引
      if (nums[j] < nums[min]) min = j;
    }
    [nums[i], nums[min]] = [nums[min], nums[i]]; // 注意!
  }
  return nums;
};

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

空间复杂度:O(1)。

image.png

七、其他排序

希尔排序(不花太多时间了解,因为复杂度未知)

计数排序、基数排序、桶排序(知道概念即可,需了解程度最低)

八、使用场景

  1. 数据量较大时,应采用快排、堆排序、归并排序。其中:
    • 堆排序适合找最大最小元素、TopK之类的。例如找出一千万个数中最小的前一百个。
    • 快排适合杂乱的数据,当数据有序时效果反而不好,复杂度O(n^2)
    • 如果要求排序稳定,则可以用归并排序,其他两个都不稳定。

堆排序比较和交换的次数比快排多,所以平均来说比快排慢,所以大多情况下适合快排

参考blog

leetcode排序算法讲解