如何分析一个排序算法
-
排序算法的执行效率:最好情况、最坏情况、平均情况的时间复杂度
-
排序算法的内存消耗:空间复杂度
-
排序算法的稳定性:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变,则称该排序算法稳定
稳定排序的好处:
在真正的软件开发中,我们要排序的往往不是单纯的整数,而是一组对象,然后按照对象的某个 key 来排序。如果我们的需求是按 key1 进行排序,当 key1 的值相同时,按 key2 进行排序,不使用稳定排序的解决方案是先对 key1 进行排序,然后遍历排序之后的值,对每个 key1 值相同的小区间再进行 key2 排序,实现起来会比较复杂。
使用稳定排序的方案:先按照 key2 进行排序,排序完成后,使用稳定排序算法按照 key1 重新排序。
试想一下对一组学生按年龄进行排序,在年龄相等的情况下按照身高进行排序。
以下是在 leetcode 上测的各个排序的时间和空间消耗,可以看出快排无论是在时间还是空间上都比较优秀,归并算法要注意优化,直接全部递归很容易时间复杂度爆表。
冒泡排序(熟悉)
- 基本思想:使用两层循环,外层循环每一次经过两两比较,把每一轮未排定部分最大的元素放到了数组的末尾;
- 优化:当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。第一层循环里设置一个提前退出冒泡循环的标志位,在第二层循环里,当有数据交换时改变它的值,如果经过一次冒泡没有改变过
flag
的值,则break
;
var sortArray = function(nums) {
const len = nums.length;
// 从后往前遍历,排过序的就不用再走一遍了
for(let i=len-1; i>=0; i--){
let flag = true;
for(let j=0; j<i; j++){
if(nums[j]>nums[j+1]){
const temp = nums[j+1];
nums[j+1] = nums[j];
nums[j] = temp;
flag = false;
}
}
if(flag) break;
}
return nums;
};
分析:
-
空间复杂度:
O(1)
,只涉及相邻数据的交换操作,只需要常量级的临时空间,是一个原地排序。 -
稳定性:稳定。当两个相邻的元素大小相等时,不做交换,排序后,其相对位置不变。
-
时间复杂度:
最好情况 :完全有序,一次冒泡,时间复杂度 O(n)
最坏情况:完全逆序,n
次冒泡,时间复杂度 O(n2)
平均情况:n*(n-1)/4 = O(n2)
插入排序(熟悉)
- 思路:每次将一个数字插入一个有序的数组里,成为一个长度更长的有序数组,有限次操作以后,数组整体有序。初始已排序区间只有一个元素,就是数组的第一个元素。
var sortArray = function(nums) {
const len = nums.length;
// 循环不变量:将 nums[i] 插入到区间 [0, i) 使之成为有序数组
for(let i=1; i<len; i++){
// 先暂存这个元素,然后之前元素逐个后移,留出空位
const currentVal = nums[i];
let j = i;
// 注意边界 j > 0
while(j>0 && nums[j-1] > currentVal ){
nums[j] = nums[j-1];
j--;
}
nums[j] = currentVal;
}
return nums;
};
分析:
- 时间复杂度:
最好情况:在数组「几乎有序」的时,插入排序的时间复杂度可以达到O(n)
; 最坏/平均情况:O(n2)
- 空间复杂度:
O(1)
使用到常数个临时变量,是原地排序 - 稳定性:稳定。对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,保持原有的前后顺序不变。
- 「插入排序」在「几乎有序」的数组上表现良好,特别地,在「短数组」上的表现也很好。因为「短数组」的特点是:每个元素离它最终排定的位置都不会太远。为此,在小区间内执行排序任务的时候,可以转向使用「插入排序」。
选择排序(了解)
- 思路:每一轮选取未排定的部分中最小的元素交换到已排定部分的末尾(未排定部分的开头),经过若干个步骤,就能排定整个数组。即:先选出最小的,再选出第 2 小的,以此类推。
var sortArray = function(nums) {
const len = nums.length;
// [0, i) 有序,且该区间里所有元素就是最终排定的样子
for(let i=0; i<len-1; i++){
let minIndex = i;
// 从未排序区间找出最小值, 交换到下标i
for(let j=i+1; j<len; j++){
if(nums[j]<nums[minIndex]){
minIndex = j;
}
}
swap(nums, i, minIndex);
}
return nums;
function swap(nums, index1, index2){
const temp = nums[index2];
nums[index2] = nums[index1];
nums[index1] = temp;
}
};
使用到的算法思想:
-
贪心算法:每一次决策只看当前,当前最优,则全局最优。注意:这种思想不是任何时候都适用。
-
减治思想:外层循环每一次都能排定一个元素,问题的规模逐渐减少,直到全部解决,即「大而化小,小而化了」。运用「减治思想」很典型的算法就是大名鼎鼎的「二分查找」。
分析:
- 时间复杂度:最好,最坏,平均情况的时间复杂度都是
O(n2)
- 空间复杂度:
O(1)
原地排序 - 稳定性:不稳定。因为在交换的时候,前面的值可能会被交换到后面。
- 相比于冒泡和插入,选择排序稍显逊色了。不太常用。但它的交换次数最少,如果在交换成本较高的排序任务中,可以考虑使用「选择排序」。
归并排序(重点)
- 思路: 先把数组从中间分为前后两部分,对前后两部分分别排序,然后借助额外空间,合并两个有序数组,得到更长的有序数组。
- 算法思想:分治思想,分而治之,将大问题分解成小的子问题来解决,小的子问题解决了,大问题也就解决了。分治算法一般都是用递归来实现的,分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。
- 「归并排序」是理解「递归思想」的非常好的学习材料,大家可以通过理解:递归完成以后,合并两个有序数组的这一步骤,想清楚程序的执行流程。即「递归函数执行完成以后,我们还可以做点事情」。
var sortArray = function(nums) {
mergeSort(nums, 0, nums.length-1);
return nums;
// 对数组 nums 的子区间 [left, right] 进行归并排序
// temp: 用于合并两个有序数组的辅助数组,全局使用一份,避免多次创建和销毁
function mergeSort(nums, left, right, temp = []){
if(left>=right) return; //递归终止条件
// let mid = left + right >>> 1;
let mid = left + Math.floor((right-left) / 2);
// 递归左边和右边, 使左右两边分别有序
mergeSort(nums, left, mid, temp);
mergeSort(nums, mid+1, right, temp);
// 左右两边有序之后,对它们进行合并
mergeTwoSortedArray(nums, left, mid, right, temp);
}
// 合并两个有序数组:先把值复制到临时数组,再合并回去
function mergeTwoSortedArray(nums, left, mid, right, temp){
temp = [...nums];
let i = left;
let j = mid+1;
for(let k=left; k<=right; k++){
if(i === mid+1){ //i走到了中间,说明左边已经放完了,之后只需循环把右边放进去
nums[k] = temp[j];
j++;
}else if(j === right+1){ //j走到了最后,说明右边已经放完了,之后只需循环把左边放进去
nums[k] = temp[i];
i++;
}else if(temp[i] <= temp[j]){ // 比较i,j位置的元素,谁小放谁进去,并让其指针右移
// 注意写成 < 就丢失了稳定性(相同元素原来靠前的排序以后依然靠前)
nums[k] = temp[i];
i++;
}else{
nums[k] = temp[j];
j++;
}
}
}
};
优化:
- 在「小区间」里转向使用「插入排序」,参考JavaScript数组的sort排序
- 在「两个数组」本身就是有序的情况下,无需合并;
- 注意:实现归并排序的时候,要特别注意,不要把这个算法实现成非稳定排序,区别就在 <= 和 < ,已在代码中注明。
// 优化后代码:
var sortArray = function(nums) {
const INSERTION_SORT_THRESHOLD = 7;
mergeSort(nums, 0, nums.length-1);
return nums;
// 对数组 nums 的子区间 [left, right] 进行归并排序
function mergeSort(nums, left, right, temp = []){
// 小区间使用插入排序
if(right - left <= INSERTION_SORT_THRESHOLD){
insertSort(nums, left, right);
return;
}
// let mid = left + right >>> 1;
let mid = left + Math.floor((right-left) / 2);
// 递归左边和右边, 使左右两边分别有序
mergeSort(nums, left, mid, temp);
mergeSort(nums, mid+1, right, temp);
// 如果数组的这个子区间本身有序,无需合并
if (nums[mid] <= nums[mid + 1]) {
return;
}
mergeTwoSortedArray(nums, left, mid, right, temp);
}
// 对数组 nums 的子区间 [left, right] 使用插入排序
function insertSort(nums, left, right){
for(let i=left+1; i<=right; i++){
const currentVal = nums[i];
let j = i;
while(j>left && nums[j-1] > currentVal ){
nums[j] = nums[j-1];
j--;
}
nums[j] = currentVal;
}
return nums;
}
// 合并两个有序数组:先把值复制到临时数组,再合并回去
function mergeTwoSortedArray(nums, left, mid, right, temp){
temp = [...nums];
let i = left;
let j = mid+1;
for(let k=left; k<=right; k++){
if(i === mid+1){ //i走到了中间,说明左边已经放完了,之后只需循环把右边放进去
nums[k] = temp[j];
j++;
}else if(j === right+1){ //j走到了最后,说明右边已经放完了,之后只需循环把左边放进去
nums[k] = temp[i];
i++;
}else if(temp[i] <= temp[j]){ // 比较i,j位置的元素,谁小放谁进去,并让其指针右移
// 注意写成 < 就丢失了稳定性(相同元素原来靠前的排序以后依然靠前)
nums[k] = temp[i];
i++;
}else{
nums[k] = temp[j];
j++;
}
}
}
};
分析:
- 时间复杂度:
O(nlogn)
- 空间复杂度:
O(n)
,不是原地排序,因为合并的时候需要额外的空间,辅助数组与输入数组规模相当。 - 稳定性:稳定。
快速排序(重点)
- 思路:快速排序每一次通过分区排定一个元素(基准值),这个元素呆在了它最终应该呆的位置,然后递归地去排它左边的部分和右边的部分,依次进行下去,直到数组有序;
- 算法思想:分而治之(分治思想),与「归并排序」不同,「快速排序」在「分」这件事情上不像「归并排序」无脑地一分为二,而是采用了 partition(分区) 的方法,因此就没有「合」的过程。可以发现,归并排序的处理过程是由下到上的,先处理⼦问题,然后再合并。⽽快排正好相反,它的处理过程是由上到 下的,先分区,然后再处理⼦问题。
如果我们不考虑空间消耗的话,partition()
分区函数可以写得⾮常简单。申请两个临时数组 X
和 Y
,遍历给定数组 nums
,将⼩于 pivot
的元素都拷⻉到临时数组 X
,将⼤于 pivot
的元素都拷⻉到临时数组 Y
,最后再将数组 X
和数组 Y
中数据顺序拷⻉到 nums
中。具体写法可以参考:阮一峰老师的快排实现
但是,如果按照这种思路实现的话,partition()
函数就需要很多额外的内存空间,所以快排就不是原地排序算法了。如果 我们希望快排是原地排序算法,那它的空间复杂度得是 O(1)
,那 partition()
分区函数就不能占⽤太多额外的内存空间,我们就需要在 nums
数组的原地完成分区操作。
使用 单指针 & 交换
完成分区操作的快排:
var sortArray = function(nums) {
quickSort(nums, 0, nums.length-1)
return nums;
function quickSort(nums, left, right){
if(left>=right) return;
// 将nums分区,使得nums[pIndex]左边的都小于nums[pIndex], 右边的都大于nums[pIndex]
let pIndex = partition(nums, left, right);
// 对基准值左右两边递归的分区(排序)
quickSort(nums, left, pIndex-1);
quickSort(nums, pIndex+1, right);
}
// 分区函数
function partition(nums, left, right){
// 取中间的值作为基准值
let mid = left + Math.floor((right-left) / 2);
swap(nums, left, mid); // 把基准值交换到第一项
// 基准值
let pivot = nums[left];
let lt = left; // 开拓小于基准值的区间的指针
// 循环不变量:
// all in [left + 1, lt] < pivot
// all in [lt + 1, i) >= pivot
for(let i=left+1; i<=right; i++){
if(nums[i]<pivot){
lt++;
swap(nums, i, lt)
}
}
swap(nums, left, lt); //将基准值换到中间
return lt;
}
function swap(nums, index1, index2){
const temp = nums[index2];
nums[index2] = nums[index1];
nums[index1] = temp;
}
};
注意事项:
- 针对特殊测试用例:顺序数组或者逆序数组。一定要随机化选择切分元素(pivot),否则在输入数组是有序数组或者是逆序数组的时候,如果
pivot
选的是left
或者right
,快速排序会变得非常慢(等同于冒泡排序或者选择排序), 时间复杂度退化到O(n2)
- 针对特殊测试用例:有很多重复元素的输入数组,有 3 种版本的解法:
- 版本 1:基本解法,就是上述单指针解法,把等于切分元素的所有元素分到了数组的同一侧,可能会造成递归树倾斜;
- 版本 2:双指针解法:把等于切分元素的所有元素等概率地分到了数组的两侧,避免了递归树倾斜,递归树相对平衡;
- 版本 3: 三指针解法:把等于切分元素的所有元素挤到了数组的中间,在有很多元素和切分元素相等的情况下,递归区间大大减少。
之所以解法有这些优化,起因都是来自「递归树」的高度。关于「树」的算法的优化,绝大部分都是在和树的「高度」较劲。类似的通过减少树高度、使得树更平衡的数据结构还有「二叉搜索树」优化成「AVL 树」或者「红黑树」、「并查集」的「按秩合并」与「路径压缩」。
分析:
- 时间复杂度:
O(nlogn)
- 空间复杂度:
O(1)
- 稳定性:不稳定。因为交换时可能会打乱相等元素的原有顺序。
JavaScript数组原生sort排序
问题:JavaScript
数组的原生 sort
排序稳定吗?
不同浏览器的js引擎对sort的实现方式不一样,这里以 chrome V8
来说,
V8 引擎的 sort
实现:对于长度 <= 10 的数组使用 插入排序,比 10 大的数组则使用 原地快速排序。所以当数组长度小于 10 时是稳定的,而当数组长度大于 10 时,sort
是不稳定的。
V8 源码 (710行开始)