前言
排序算法的讲解:分 【思路 + 图解 + 复杂度及稳定性 + 代码】 四个模块分别讲解
- 默认是按从小到大排序
- 排好序称其为有序表,还没排好序的称其为无序表
术语解析
时间复杂度
表示:算法代码的执行时间随数据规模增长的变化趋势,所以也叫 渐进时间复杂度,简称 时间复杂度
空间复杂度
表示:算法的存储空间随数据规模增长的变化趋势,所以也叫 渐进空间复杂度,简称 空间复杂度
稳定性
如果待排序的序列中存在值相等的元素
- 稳定:算法排序后相等元素之间原有的
先后顺序不变 - 不稳定:算法排序后相等元素之间原有的
先后顺序改变常见的排序算法之中,插入排序、合并排序、冒泡排序等都是稳定的,堆排序、快速排序等是不稳定的
不稳定排序的主要缺点是,多重排序时可能会产生问题。假设有一个姓和名的列表,要求按照“姓氏为主要关键字,名字为次要关键字”进行排序。开发者可能会先按名字排序,再按姓氏进行排序。如果排序算法是稳定的,这样就可以达到“先姓氏,后名字”的排序效果。如果是不稳定的,就不行
内存消耗
- 内排序:所有排序操作都在
内存中完成; - 外排序:由于数据太大,因此把数据放在
磁盘中,而排序通过磁盘和内存的数据传输才能进行; - 原地排序:原地排序算法,就是特指空间复杂度是
O(1)的排序算法
排序算法
排序算法主要分为两类:
- 时间复杂度为 O(n²) 的从无序表中挑一个放入到有序表当中的排序算法(冒泡、选择、插入)
- 时间复杂度为 O(nlogn) 的具有二分递归思想的排序算法(归并、快速、堆、希尔)
为了方便快速回忆起来,这里写下通俗理解:(从小到大排序)
- 冒泡排序:每次只比较、交换相邻两个元素,时间 O(n²)、空间 O(1)
- 选择排序:每次从无序区间挑选最小的排在有序区间最前面,时间 O(n²)、空间 O(1)
- 插入排序:每次从无序区间拿个元素在有序区间中遍历找准位置,时间 O(n²)、空间 O(1)
- 归并排序:分治法排序,时间 O(nlogn)、空间 O(n)
- 快速排序:将元素分在中位数的左边和右边的分治法排序,时间 O(nlogn)、空间 O(1)
冒泡排序
思路
- 冒泡排序只会操作
相邻的两个数据 - 每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换
- 一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作
图解
性能分析
- 时间复杂度 O(n²);空间复杂度 O(1)
- 稳定性:稳定 代码
const bubbleSort = arr => {
const length = arr.length;
if (length <= 1) return;
for (let i = 0; i < length - 1; i++) { // 外层只需要 length-1 次就排好了,第 length 次比较是多余的
for (let j = 0; j < length - i - 1; j++) { // 内层的 length-i-1 到 length-1 的位置已排好,无需再比较一次
if (arr[j] > arr[j + 1]) {
const temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
};
选择排序
思路
每次都从无序表中选择最小(大)的那个跟有序表(无序表)的下一个元素(上一个元素)进行位置交换
图解
性能分析
- 时间复杂度 O(n²);空间复杂度 O(1)
- 稳定性:不稳定 代码
const selectionSort = array => {
const len = array.length;
let minIndex, temp;
for (let i = 0; i < len - 1; i++) {
minIndex = i;
for (let j = i + 1; j < len; j++) { // 在无序表中寻找最小的数
if (array[j] < array[minIndex]) {
minIndex = j; // 将这个最小数的索引保存起来
}
}
temp = array[i]; // 将这个最小数与有序表的下一个元素进行交换
array[i] = array[minIndex];
array[minIndex] = temp;
}
return array;
};
插入排序
思路
- 每次都拿无序表中的第一个从有序表的尾部从后往前一个一个比较大小
- 比前一个小 则 继续往前走,比前一个大 或者 到达队头 则 停下
图解
性能分析
- 时间复杂度 O(n²);空间复杂度 O(1)
- 稳定性:稳定 代码
function sort(arr){
for(var i=1; i<arr.length; i++){
for(var j=i; j>0; j--){
if(arr[j]<arr[j-1]){
var k = arr[j];
arr[j] = arr[j-1];
arr[j-1] = k;
}
}
}
return arr;
}
归并排序
思路
- 归并排序采用的是分治思想,采用的是
自上而下的递归方法 - 递归过程就是把大序列分割成一个一个的小序列
- 回溯过程便是把小序列排序后合并成大序列后排序
图解
性能分析
- 时间复杂度:O(nlogn);空间复杂度:O(n)
- 稳定性:稳定
代码
const mergeSort = arr => { // 采用自上而下的递归方法
const len = arr.length;
if (len < 2) return arr;
let middle = Math.floor(len / 2), // length >> 1 和 Math.floor(len / 2) 等价
left = arr.slice(0, middle), // 拆分为两个子数组
right = arr.slice(middle);
return merge(mergeSort(left), mergeSort(right));
};
const merge = (left, right) => {
const result = [];
while (left.length && right.length) {
if (left[0] <= right[0]) { // 注意: 判断的条件是小于或等于,如果只是小于,那么排序将不稳定
result.push(left.shift());
} else {
result.push(right.shift());
}
}
while (left.length) result.push(left.shift());
while (right.length) result.push(right.shift());
return result;
};
注:
x >> 1是位运算中的右移运算,表示右移一位,等同于 x除以 2再取整,即 x >> 1 ===Math.floor(x / 2)
快速排序
需要额外空间的写法
思路
- 以当前无序表的第一个为基准,比它小放左边数组,比它大放右边数组。
- 然后左边数组和右边数组同时又是一个无序表,所以对于这两个数组重复第一步(递归思想)
- 结束递归条件为数组长度为 1 代码
function sort(arr){
let len = arr.length
// 1. 结束递归,开始回溯 / 同时也是考虑到数组为空和只有一个的特殊情况
if (len <= 1) {
return arr; // 把自己弹回去
}
// 2. 每次都是取无序表中的第一个作为基准
let standard = arr.shift()
// 3. 分配到左右数组
let arr1 = []
let arr2 = []
for (let i = 0; i < len - 1; i++) { // 因为拿掉了中间那个,所以长度变小了1
arr[i] < standard ? arr1.push(arr[i]) : arr2.push(arr[i])
}
// 4. 最终结果返回 / 同时触发递归
return sort(arr1).concat([standard], sort(arr2)) // 记得把之前取出的那个元素用[]包裹起来并放在中间位置
}
性能分析
- 时间复杂度 O(nlogn);空间复杂度 O(logn)
- 稳定性:不稳定
不需要额外空间的写法
思路
- 以当前无序表中第一个为基准(黄色),自左至右遍历;
- 当前遍历到的数比基准数小,则放入集合 A (绿色);
- 当前遍历到的数比基准数大,则放入集合 B (紫色);
- 每次交换时遵循原则:比基准数小的才换,只跟集合 B 的首个元素换位置(保持集合 A 总在集合 B 在前面);
- 分开前小后大之后,将基准数和集合 A 最后一个元素交换位置,取 1/2 的无序表回到第 1 步
图解
- 基准数 - 黄色;
- 当前遍历到的数 - 红色;
- 比基准数小的集合 - 绿色;
- 比基准数大的集合 - 紫色;
性能分析
- 时间复杂度:O(nlogn);空间复杂度:O(1)
- 稳定性:不稳定
代码
function MySort(arr,a,b){
let len = b - a
let temp = 0 // i 指向集合 A 的第一个元素,j 指向集合 A 右边第一个元素
// 1. 结束递归,开始回溯 / 同时也是考虑到数组为空和只有一个的特殊情况
if (len <= 1) {
return arr; // 把自己弹回去
}
// 2. 每次都是取无序表中的第一个作为基准
let standard = arr[a]
let i = a + 1
while(arr[i] < standard) i++ // 找比基准数大的数
j = i + 1
while(j < b){
if(arr[j] < standard){
temp = arr[i]
arr[i] = arr[j]
arr[j] = temp
i++
}
j++
}
temp = arr[i - 1]
arr[i - 1] = arr[a]
arr[a] = temp
return sort(arr.slice(a,i)).concat(sort(arr.slice(i,b)))
}
let arr = [9,8,3,11,7,1,12,5]
let a = 0,b = arr.legnth
MySort(arr,a,b)
console.log(arr)
归并排序和快速排序的区别【额外空间】
- 归并排序
自下而上,即对小序列排序后逐步合并(回溯过程)成大序列 - 快速排序
自上而下,即先对大序列进行分割,(以一个基准)分割成左右序列,然后不断的分割下去
希尔排序
思路
-
假定共有 n 个元素,以规定间隔 m(= Math.floor(n / 2)) 取到 m 个由 Math.floor(n / m) ~ Math.floor(n / m) + 1个元素组成的子序列
-
分别对这些子序列进行
插入排序 -
如果 m 等于 1,则结束循环(结束递归);否则,把间隔 m 改为 m / 2
-
重复1、2、3步骤(递归过程) 图解
-
间隔为 4 的选择排序
-
间隔为 2 的选择排序
-
间隔为 1 的选择排序
-
动态图解
性能分析
- 时间复杂度:O(nlogn);空间复杂度:O(1)
- 稳定性:不稳定 代码
const shellSort = arr => {
let len = arr.length,
temp,
gap = 1;
console.time('希尔排序耗时');
while (gap < len / 3) {
//动态定义间隔序列
gap = gap * 3 + 1;
}
for (gap; gap > 0; gap = Math.floor(gap / 3)) {
for (let i = gap; i < len; i++) {
temp = arr[i];
let j = i - gap;
for (; j >= 0 && arr[j] > temp; j -= gap) {
arr[j + gap] = arr[j];
}
arr[j + gap] = temp;
console.log('arr :', arr);
}
}
console.timeEnd('希尔排序耗时');
return arr;
};
排序算法的稳定性
如何判断算法的稳定性
比较的值是相同的两个元素在排序前、排序后的相对顺序是否改变,若不改变则稳定,否则不稳定
常见算法的稳定性
稳定性的必要性(多维排序)
例如要排序的内容是一组原本按照价格高低排序的对象,如今需要按照销量高低排序,使用稳定性算法,可以使得相同销量的对象依旧保持着价格高低的排序展现,只有销量不同的才会重新排序
不过值得注意的是,排序算法是否为稳定的是由具体算法决定的,
不稳定的算法在某种条件下可以变为稳定的算法
只需要在不稳定算法中对某一步做出改变,针对具有相同值的元素在交换之前确保不改变相对顺序即可
例如不稳定的选择排序:每次从无序表中挑选出的最小元素接在有序表的下一个元素是稳定的;但每次从无序表中挑选出最大元素接在有序表的上一个元素是不稳定的