原来如此,排序复杂度的 n log n 是这么来的

267 阅读4分钟

前言

虽说网上有许多的排序文章了,但是在叙述方面,往往都直接以 代码 + 图片 的形式来说明。

既没有思路,又没有推导过程,往往会导致死记硬背,考虑到对新手并不友好,于是写下这篇文章。

希望按通俗易懂的思路聊聊排序,也巩固一下自己的记忆。

开胃小菜之冒泡排序

原理

// 思路: 每次确定一个最大值置于队尾,直到确定了所有的最大值

那么问题就分解为两个

  1. 如何确定一个最大值
  2. 如何将最大值置于队尾

冒泡排序给出了巧妙的解答:比较 j 和 j + 1,哪个大哪个往后放,通过不断循环增 j,一定能把最大值运送到最后一位。 这种做法即达成了确定最大值,又将最大值运送到了最后一位,一箭双雕。

实现

// 第一重 for 循环:每次能确定 1 个最大值,所以要走 n - 1 遍

// 第二重 for 循环:比较 j 和 j + 1,哪个大哪个往后放,通过不断循环,一定能把最大值运送到最后一位

const bubbleSort = (arr) => {
  for (let i = 0; i < arr.length - 1; i++) {
    for (let j = 0; j < arr.length - i; j++) {
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }
  return arr;
};

分析

复杂度

  • 最好情况:O(n)(当输入数组已经是有序时,仅需一轮检查)
  • 平均情况:O(n²)(需要多次遍历)
  • 最坏情况:O(n²)(当输入数组是反向排序的时,需进行最大次数的交换)

O(n²) 很好理解,双重 for 循环,自然会产生平方,但最好情况的 O(n)是如何产生的呢

实际上,想达到 O(n),代码是不够完善的,我们需要稍作修改:

const bubbleSort = (arr) => {
    for (let i = 0; i < arr.length - 1; i++) {
        let swapped = false; // 标记是否发生了交换
        for (let j = 0; j < arr.length - i; j++) { 
            if (arr[j] > arr[j + 1]) {
                [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
                swapped = true; // 发生了交换
            }
        }
        // 如果没有发生交换,提前结束排序
        if (!swapped) {
            break; 
        }
    }
    return arr;
};

可以看到,我们可以通过引入一个标志位 swapped,在每次内层循环中判断是否有元素发生了交换。如果没有交换,说明数组已经是有序的,可以提前终止外层循环。

这么一来,只走一次 n ,就已经结束了代码,这才是真正的 O(n)

稳定性

稳定性一般指,如果 a 和 b 相等,且 a 原先在 b 前,那么排序后,a 也应该在 b 前。

注意看我们的核心交换代码

if (arr[j] > arr[j + 1]) {
    [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}

仅在 a > b 时,才进行交换,而 a = b 时并不做处理,所以他是稳定的。

剖析快速排序

原理

// 快速排序:充分利用递归思想的排序,每次取出一个值,并将其他值分为左右两堆, 递归 处理

问题也划分为两个:

  1. 怎么取出来一个值?
  2. 递归怎么写

对于问题1,随便取即可,一般会取数组中间那个值。而问题2,相信写过斐波那契数列的同学应该并不陌生,直接来看代码吧。

实现

const quickSort = (arr) => {
  // 递归的尽头,数组长度小于等于 1 就不需要排序了
  if (arr.length <= 1) {
    return arr;
  }

  // 选一个基准数
  const midIndex = Math.floor(arr.length / 2);
  const mid = arr[midIndex];
  const left = [];
  const right = [];

  // 排除这个基准数
  arr = [...arr.slice(0, midIndex), ...arr.slice(midIndex + 1)];
  
  // 分堆
  arr.forEach((item) => {
    item > mid ? right.push(item) : left.push(item);
  });

  // 递归
  return [...quickSort(left), mid, ...quickSort(right)];
};

分析

复杂度

  • 最好情况:O(n log n)
  • 平均情况:O(n log n)
  • 最坏情况:O(n²)

先来看最坏情况是如何产生的:每次通过 mid 进行分堆时,都分出来一个空堆,一个满元素堆,这相当于只排序了一个 mid 元素,所以一共要分 n 个才能分完。而每次划分,我都需要比较 n 次,当前项比 mid 更大还是更小,这么一来就是 n * n 的复杂度了。

再说最好情况:显然,应该跟最坏情况相对,每次都能分出来两个数量相等的堆。

这么一来,问题转化为了:要多少次 / 2 才能使最后堆的元素数量为 1 ?

我们可以列出公式:设第k步后,n/(2^k) = 1

那么就可以推导出:k = log₂(n), 缩写即为 k = logn

所以我们需要划分 logn 次,并且每次划分,都需要比较 n 次,那么复杂度自然就为:n * logn,即 O(nlogn)

稳定性

回顾冒泡排序的稳定性,通过 a = b 时并不做处理的方式来实现了稳定。但快速排序不同,由于抽取不同的基准数,会导致一些不稳定的情况。举个例子说明吧:

比方说 [-1,-1,-1]这个数组,第二个 -1 被抽取为基准数时,第一和第三个 -1 都会被划分到另一边去,最终和基准数合并。显然,顺序相较于之前被打乱了,所以它是不稳定的。

其他排序算法大同小异,暂不赘述了。如果这篇文章帮到你,欢迎点赞~