「算法的稳定性」 :当待排序序列中有相同的元素时,它们的相对位置在排序前后不会发生改变,那么就称这个排序算法是稳定的,但稳定性不能衡量算法的优劣。
一、快速排序(重点):分区pivot + 三参数
快速排序是使用最广泛的排序算法,速度也较快。递归和迭代都需要掌握!
1. 递归:不创建新数组
「主要思想是将待排序的序列分成前后两部分,前半部分都小于基准、后半部分都大于等于基准,然后分别递归两部分」。
- 选择一个元素作为 「"基准"(pivot)」 ,选最后一个元素
- 所有小于"基准"的元素,都移到"基准"的左边;所有大于"基准"的元素,都移到"基准"的右边。最后将该基准放在两部分的中间位置,这就叫分区操作。
- 对"基准"左边和右边的两个子集,不断重复第一步和第二步,直到所有子集只剩下一个元素为止。
// 数组分区,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
把数组拆成左右两部分。因此非递归算法思路如下:
- 把
left
和right
入队 - 当队列非空时:
- shift 出
left
和right
下标 - 然后 divide() 对这段区域进行排序、分组
- 如果 mid 左边的元素个数大于 1,就将左端的两个下标
left
、mid - 1
入队,右侧同理。
- shift 出
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 + 1
、2*i + 2
- 将无序序列构建成一个堆,通常升序选择大顶堆、降序选择小顶堆;
- 将堆顶元素与末尾元素交换,将最大元素“沉”到数组末端;
- 重新调整堆结构,使其满足大顶堆定义。然后反复执行直到整个序列有序。
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)。
七、其他排序
希尔排序(不花太多时间了解,因为复杂度未知)
计数排序、基数排序、桶排序(知道概念即可,需了解程度最低)
八、使用场景
- 当数据量较大时,应采用快排、堆排序、归并排序。其中:
- 堆排序适合找最大最小元素、TopK之类的。例如找出一千万个数中最小的前一百个。
- 快排适合杂乱的数据,当数据有序时效果反而不好,复杂度O(n^2)。
- 如果要求排序稳定,则可以用归并排序,其他两个都不稳定。
堆排序比较和交换的次数比快排多,所以平均来说比快排慢,所以大多情况下适合快排。
参考blog