浅析主流排序算法

排序算法作为计算机科学中的基础课题,其重要性不言而喻。它们在日常开发、数据分析、机器学习等诸多领域都有着广泛的应用。 排序算法

一、排序算法分析

1 执行效率

  • 最好时间复杂度、最坏时间复杂度和平均时间复杂度
  • 时间复杂度的系数、常数和低价
  • 比较次数和交换(或移动)次数

2 内存消耗

  • 空间复杂度
  • 原地排序和非原地排序(原数据存储)

3 稳定性

  • 稳定排序算法:值相等的元素,排序后先后顺序不变
  • 不稳定排序算法:值相等的元素,排序后先后顺序改变

二、排序算法

1. O(n^2)排序

一般使用双循环。

1.1 冒泡排序(bubble sort) O(n^2)

  • 空间复杂度 O(1)
  • 稳定排序
  • 最好时间复杂度:数组已经有序时,O(n)
  • 最坏时间复杂度:数组倒序时,O(n^2)
  • 平均时间复杂度:有序度,无序度,平均交换次数(有点复杂),O(n^2)
//冒泡排序:当一个数比后一个数大时,交换两者的位置,每遍历一次就把遍历范围内的最大数的排到遍历的末尾
//优化:当某次操作已经没有数据交换时,数组变为有序,退出后续遍历
function bubbleSort(nums) {
  let n = nums.length;
  if (n <= 1) return nums;
  for (let i = 0; i < n; i++) {
    let flag = false; //提前退出冒泡循环的标识位
    for (let j = 0; j < n - i - 1; j++) {
      //最大的数已经排到了数据末尾,所以每次循环都减去i
      if (nums[j] > nums[j + 1]) {
        //交换相邻的两个元素
        let tmp = nums[j];
        nums[j] = nums[j + 1];
        nums[j + 1] = tmp;
        flag = true;
      }
    }
    if (!flag) break; //没有数据交换,退出循环
  }

  return nums;
}

1.2 插入排序(insert sort)O(n^2)

  • 已排序区间,未排序区间
  • 查找合适的位置插入数据,从未排序的区间取元素,到已排序区间找到合适的位置插入
  • 原地排序,空间复杂度 O(1)
  • 稳定排序算法
  • 最好时间复杂度:数组已经有序时,O(n)
  • 最坏时间复杂度:数组倒序时,O(n^2)
  • 平均时间复杂度:有序度,无序度,平均交换次数(有点复杂),O(n^2)
function insertSort(nums) {
  let n = nums.length;
  if (n <= 1) return nums;
  for (let i = 1; i < n; i++) {
    let value = nums[i];
    let j = i - 1;
    for (; j >= 0; j--) {
      //查找插入的位置
      if (nums[j] > value) {
        nums[j + 1] = nums[j]; //移动数据
      } else {
        break;
      }
    }
    nums[j + 1] = value;
  }
  return nums;
}

1.3 选择排序(select sort) O(n^2)

  • 已排序区,未排序区
  • 每次从未排序区中取最小的元素,放到已排序区间的末尾
  • 原地排序,空间复杂度 O(1)
  • 不是稳定排序算法
  • 最好时间复杂度:数组已经有序时,O(n^2)
  • 最坏时间复杂度:数组倒序时,O(n^2)
  • 平均时间复杂度:有序度,无序度,平均交换次数(有点复杂),O(n^2)
function selectSort(nums) {
  let n = nums.length;
  if (n <= 1) return nums;

  for (let i = 0; i < n - 1; i++) {
    let minIndex = i;
    for (let j = i; j < n; j++) {
      //查找min
      if (nums[j] < nums[minIndex]) {
        minIndex = j;
      }
    }
    //交换元素
    let tmp = nums[i];
    nums[i] = nums[minIndex];
    nums[minIndex] = tmp;
  }

  return nums;
}

2. O(nlogn) 排序

分治算法思想,递归编程技巧。

2.1 归并排序 O(nlogn)

  • 稳定排序算法
  • 不是原地排序算法
  • 最好时间复杂度:O(nlogn)
  • 最坏时间复杂度:O(nlogn)
  • 平均时间复杂度:O(nlogn)
  • 空间复杂度 O(n+logn),即 O(n)

递推公式:mergeSort(p,r) = mergeSort(mergeSort(p,q),mergeSort(q+1,r))

终止条件:p>=r,mergeSort(p,r)表示对下标 p 到 r 的数组进行归并排序

function mergeSort(nums) {
  let n = nums.length;
  if (n <= 1) return nums;

  let mid = Math.floor(n / 2);
  const left = nums.slice(0, mid);
  const right = nums.slice(mid);

  return merge(mergeSort(left), mergeSort(right));
}

//将两个有序的数组合并成一个
function merge(left, right) {
  let result = [];
  let i = 0;
  let j = 0;

  while (i < left.length && j < right.length) {
    if (left[i] < right[j]) {
      result.push(left[i]);
      i++;
    } else {
      result.push(right[j]);
      j++;
    }
  }

  //处理有剩余的数组
  while (i < left.length) {
    result.push(left[i]);
    i++;
  }

  while (j < right.length) {
    result.push(right[j]);
    j++;
  }

  return result;
}

2.2 快速排序 O(nlogn)

  • 不是稳定排序算法
  • 是原地排序算法
  • 最好时间复杂度:O(nlogn)
  • 最坏时间复杂度:O(n^2)
  • 平均时间复杂度:O(nlogn)
  • 空间复杂度 O(logn)
  • 最坏空格复杂度 O(n)

递推公式:quickSort(p,r) = partition(p,r) + quickSort(p,q) +quickSort(q+1,r)

终止条件:p>=r

function quickSort(nums) {
  let n = nums.length;
  if (n <= 1) return nums;

  let mid = Math.floor(n / 2);
  const pivot = nums[mid];
  const smaller = [];
  const equal = [];
  const larger = [];

  for (let num of nums) {
    if (num < pivot) {
      smaller.push(num);
    } else if (num === pivot) {
      equal.push(num);
    } else {
      larger.push(num);
    }
  }

  return [...quickSort(smaller), ...equal, ...quickSort(larger)];
}

如何优化快速排序?

理想分区点:被分区点分开的两个小区间大小接近相等。

合理选择分区点:

  • 三数取中法:取区间的首、中、尾三个数比较大小,取这三个数的中间值作为分区点。数组大可以用“五数取中“,”十数取中”
  • 随机法:每次从要排序的区间中随机选择一个元素作为分区点。不能保证每次都是最好,但也不会每次都是最差。

3. O(n) 排序

线性排(linear sort),不是基于比较的排序算法

3.1 桶排序(bucket sort) O(n)

核心思想:先定义几个有序的“桶”,将要排序的数据分到这几个”桶“,对每个“桶”单独排序,再把每个桶的数据按照顺序依次取出,组成有序的序列。

桶排序的效果,取决于桶的大小和分布,不是所有情况下都是最优的。

//nums:[22,5,11,41,45,26,29,10,7,8,30,27,42,43,40]
//bucketSize:5
function bucketSort(nums, bucketSize) {
  if (nums.length <= 1) return nums;

  let min = 0; //5
  let max = 0; //46

  //确定最大和最小值
  for (let i = 1; i < nums.length; i++) {
    if (nums[i] < min) min = nums[i];
    else if (nums[i] > max) max = nums[i];
  }

  //创建桶
  let bucket = Array(bucketSize + 1).fill([]);

  //把数据分配到桶中
  for (let i = 0; i < nums.length; i++) {
    let bucketIndex = Math.floor(nums[i] / ((max - min) / bucketSize));
    bucket[bucketIndex].push(nums[i]);
  }

  //对每个桶进行排序,并将结果放进数组
  let res = [];
  for (let i = 0; i < bucket.length; i++) {
    let data = bucket[i].sort((a, b) => a - b);
    res = [...res, ...data];
  }
  return res;
}
let nums = [22, 5, 11, 41, 45, 26, 29, 10, 7, 8, 30, 27, 42, 43, 40];
console.log("原数组:", nums);
console.log("桶排序后:", bucketSort(nums, 5));

3.2 计数排序(counting sort) O(n)

是桶排序的一种特殊情况。桶的个数就是最大值 k。如果 k 远小于 k,则时间复杂度为 O(n),k 很大则不适合使用计数排序。

  • 时间复杂度 O(n+k)
  • 适用于给非负整数排序
function countingSort(nums) {
  if (nums.length <= 1) return nums;

  //查到最大值
  let max = nums[0];
  for (let i = 1; i < nums.length; i++) {
    if (max < nums[i]) max = nums[i];
  }

  //申请一个计数数组c,下标范围是[0,max]
  let c = Array(max + 1).fill(0);
  //计算每个元素的个数,放入数组c
  for (let i = 0; i < nums.length; i++) {
    c[nums[i]]++;
  }

  //依次累加,每一项表示在排序后,小于和等于某数的个数
  for (let i = 1; i <= max; i++) {
    c[i] = c[i - 1] + c[i];
  }

  //数组r,存储排序后的结果
  let r = Array(nums.length);
  //从后往前遍历,为了保证排序算法的稳定性
  for (let i = nums.length - 1; i >= 0; --i) {
    let index = c[nums[i]] - 1;
    r[index] = nums[i];
    c[nums[i]]--;
  }

  return r;
}
const nums = [2, 5, 3, 0, 2, 3, 0, 3];
console.log("排序前:", nums);
console.log("排序后:", countingSort(nums));

3.3 基数排序(radix sort) O(n)

将数据按照每一位的值进行排序,从低位到高位依次排序,最终得到有序的结果。例如比较手机号,从最后第 11 位按照 0-9 排序。

//[123, 45, 67, 890, 12, 345, 678, 901]
function radixSort(nums) {
  if (nums.length <= 1) return nums;

  //查找最大值
  let max = nums[0];
  for (let i = 1; i < nums.length; i++) {
    if (max < nums[i]) max = nums[i];
  }

  //exp基数
  for (let exp = 1; Math.floor(max / exp) > 0; exp *= 10) {
    countingSort(nums, exp);
  }

  return nums;
}

function countingSort(nums, exp) {
  let count = Array(10).fill(0);
  //计算个数
  for (let i = 0; i < nums.length; i++) {
    const digit = Math.floor(nums[i] / exp) % 10;
    count[digit]++;
  }

  //累加
  for (let i = 1; i < 10; i++) {
    count[i] += count[i - 1];
  }

  let res = Array(nums.length);
  for (let i = nums.length - 1; i >= 0; i--) {
    const digit = Math.floor(nums[i] / exp) % 10;
    res[count[digit] - 1] = nums[i];
    count[digit]--;
  }

  for (let i = 0; i < nums.length; i++) {
    nums[i] = res[i];
  }
}

const nums = [123, 45, 67, 890, 12, 345, 678, 901];
console.log("排序前:", nums);
console.log("排序后:", radixSort(nums)); // 输出:[12, 45, 67, 123, 345, 678, 890, 901]

3.4 其他 O(n)的排序算法

  • 堆排序
  • “猴子”排序
  • 睡眠排序
  • “面条”排序