对于排序算法执行效率的分析,我们一般会从这几个方面来衡量:
最好情况、最坏情况、平均情况时间复杂度
为什么要区分这三种时间复杂度呢?
第一,有些排序算法会区分,为了好对比,所以我们最好都做一下区分。
第二,对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。
时间复杂度的系数、常数 、低阶
时间复杂度反应的是数据规模 n 很大的时候的一个增长趋势,所以它表示的时候会忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能是 10 个、100 个、1000 个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,就要把系数、常数、低阶也考虑进来。
比较次数和交换(或移动)次数
基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如果在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。
算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。我们今天讲的三种排序算法,都是原地排序算法。
冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
算法描述
var sortArray = function(arr) {
if (arr.length < 2) {
return arr
}
for (let i = 0; i < arr.length; i ++) {
for (let j = 0; j < arr.length - 1 - i; j ++) {
if (arr[j] > arr[j + 1]) {
let temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
return arr;
};
优化:当一次循环没有发生冒泡,说明已经排序完成,停止循环。
function bubbleSort(arr) {
for (let j = 0; j < arr.length; j++) {
let complete = true;
for (let i = 0; i < arr.length - 1 - j; i++) {
// 比较相邻数
if (arr[i] > arr[i + 1]) {
[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
complete = false;
}
}
// 没有冒泡结束循环
if (complete) {
break;
}
}
return arr;
}
冒泡排序是原地排序算法
复杂度:
平均时间复杂度:O(n²)
最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。
最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n2)。
空间复杂度:O(1)
稳定性:
稳定
选择排序算法是一种原址比较排序算法。选择排序算法的思路是:找到数据结构中的最小值并 将其放置在第一位,接着找到第二小的值并将其放在第二位,以此类推。
function selectSort(arr) {
if (arr === null || arr.length < 2) {
return arr
}
let len = arr.length;
for (let i = 0; i < len - 1; i ++) {
let min = i;
for (let j = i + 1; j < len; j ++) {
if (arr[j] < arr[min]) {
min = j;
}
}
let temp = arr[i];
arr[i] = arr[min];
arr[min] = temp;
}
return arr;
}
复杂度:
时间复杂度:O(n²)
选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n²)
。
空间复杂度:O(1)
稳定性:
不稳定
插入排序(Insertion-Sort)的算法描述是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
算法描述
一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下:
function insertSort(arr) {
if (arr === null || arr.length < 2) {
return arr
}
let len = arr.length;
for (let i = 0; i < len; i ++) {
let temp = arr[i];
let k = i - 1;
for(; k >= 0; --k) {
if (arr[k] > temp) {
arr[k + 1] = arr[k];
} else {
break;
}
}
arr[k + 1] = temp;
}
return arr;
}
插入排序是原地排序算法
复杂度:
平均时间复杂度:O(n²)
最好是时间复杂度为 O(n)。这里是从尾到头遍历已经有序的数据。
最坏情况:数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n2)。
空间复杂度:O(1)
稳定性:
稳定
1959年Shell发明,第一个突破O(n²)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。
算法描述
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
function shellSort(arr) {
if (arr === null || arr.length < 2) {
return arr;
}
let len = arr.length;
for (let gap = Math.floor(len / 2); gap > 0; gap = Math.floor(gap / 2)) {
for (let i = gap; i < len; i ++) {
let temp = arr[i];
let k = i - gap;
for (; k >= 0; k = k - gap) {
if (arr[k] > temp) {
arr[k + gap] = arr[k];
} else {
break
}
}
arr[k + gap] = temp;
}
}
return arr;
}
复杂度:
时间复杂度:O(n1.3)
最好情况下:O(n)
最坏情况下:O(n²)
空间复杂度:O(1)
稳定性:
不稳定
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
算法描述
function mergeSort(arr) {
if (arr === null || arr.length < 2) {
return arr;
}
let len = arr.length;
let middle = Math.floor(len / 2);
let left = arr.slice(0, middle);
let right = arr.slice(middle);
return merge(mergeSort(left), mergeSort(right));
}
function merge(left, right) {
let result = [];
while(left.length > 0 && right.length > 0) {
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;
}
复杂度:
时间复杂度:O(nlogn)
归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)。
空间复杂度:O(n)
稳定性:
稳定
快速排序的基本思想:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据比另一部分的所有数据要小,再按这种方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,使整个数据变成有序序列。
算法描述
快速排序使用分治法来把一个串(list)分为两个子串(sub-lists)。具体算法描述如下:
target
(一般选择第一个数)target
小的元素移动到数组左边,比target
大的元素移动到数组右边target
左侧和右侧的元素进行快速排序从上面的步骤中我们可以看出,快速排序也利用了分治的思想(将问题分解成一些小问题递归求解)
function quickSort(arr) {
if (arr === null || arr.length < 2) {
return arr;
}
const target = arr[0];
const left = [];
const right = [];
for (let i = 1; i < arr.length; i++) {
if (arr[i] < target) {
left.push(arr[i]);
} else {
right.push(arr[i]);
}
}
return quickSort(left).concat([target], quickSort(right));
}
快排是一种原地排序算法。
复杂度:
O(nlogn)
,最坏O(n²)
,实际上大多数情况下小于O(nlogn)
O(logn)
(递归调用消耗)稳定性:
不稳定
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
思想:
创建一个大顶堆,大顶堆的堆顶一定是最大的元素。
交换第一个元素和最后一个元素,让剩余的元素继续调整为大顶堆。
从后往前以此和第一个元素交换并重新构建,排序完成。
function heapSort(array) {
creatHeap(array);
// 交换第一个和最后一个元素,然后重新调整大顶堆
for (let i = array.length - 1; i > 0; i--) {
[array[i], array[0]] = [array[0], array[i]];
adjust(array, 0, i);
}
return array;
}
// 构建大顶堆,从第一个非叶子节点开始,进行下沉操作
function creatHeap(array) {
const len = array.length;
const start = parseInt(len / 2) - 1;
for (let i = start; i >= 0; i--) {
adjust(array, i, len);
}
}
// 将第target个元素进行下沉,孩子节点有比他大的就下沉
function adjust(array, target, len) {
for (let i = 2 * target + 1; i < len; i = 2 * i + 1) {
// 找到孩子节点中最大的
if (i + 1 < len && array[i + 1] > array[i]) {
i = i + 1;
}
// 下沉
if (array[i] > array[target]) {
[array[i], array[target]] = [array[target], array[i]]
target = i;
} else {
break;
}
}
}
复杂度:
平均、最好、最坏时间复杂度:O(nlogn)
空间复杂度:O(1)
稳定性:
不稳定
计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
算法描述
假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A[8] 中,它们分别是:2,5,3,0,2,3,0,3。
考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C[6] 表示桶,其中下标对应分数。C[k] 里存储小于等于分数 k 的考生个数。
我们从后到前依次扫描数组 A。比如,当扫描到 3 时,我们可以从数组 C 中取出下标为 3 的值 7,也就是说,到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个,也就是说 3 是数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。当 3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3] 要减 1,变成 6。
以此类推,当我们扫描到第 2 个分数为 3 的考生的时候,就会把它放入数组 R 中的第 6 个元素的位置(也就是下标为 5 的位置)。当我们扫描完整个数组 A 后,数组 R 内的数据就是按照分数从小到大有序排列的了。
算法实现:
// 计数排序,arr 是数组,n 是数组大小。假设数组中存储的都是非负整数。
function countingSort(arr) {
if (arr.length <= 1) return arr;
let len = arr.length;
let max = arr[0];
// 查找数组中数据的范围
for (let i = 1; i < len; i ++) {
if (max < arr[i]) {
max = arr[i]
}
}
let c = [];
for (let i = 0; i <= max; ++i) {
c[i] = 0;
}
// 计算每个元素的个数,放入 c 中
for (let i = 0; i < len; ++i) {
c[arr[i]]++;
}
// 依次累加
for(let i = 1; i <= max; ++i) {
c[i] = c[i-1] + c[i];
}
// 临时数组 r,存储排序之后的结果
let r = [];
for (let i = len - 1; i >= 0; --i) {
let index = c[arr[i]] - 1;
r[index] = arr[i];
c[arr[i]] --;
}
return r;
}
计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
复杂度:
平均时间复杂度:O(n)
空间复杂度:O(1)
稳定性:
不稳定
桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
算法实现:
function bucketSort(arr, bucketSize) {
if(arr.length === 0) {
return arr;
}
let i;
let minValue = arr[0];
let maxValue = arr[0];
for(i = 1; i < arr.length; i++) {
if(arr[i] < minValue) {
minValue = arr[i]; // 输入数据的最小值
} elseif(arr[i] > maxValue) {
maxValue = arr[i]; // 输入数据的最大值
}
}
// 桶的初始化
varDEFAULT_BUCKET_SIZE = 5; // 设置桶的默认数量为5
bucketSize = bucketSize || DEFAULT_BUCKET_SIZE;
let bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1;
let buckets = newArray(bucketCount);
for(i = 0; i < buckets.length; i++) {
buckets[i] = [];
}
// 利用映射函数将数据分配到各个桶中
for(i = 0; i < arr.length; i++) {
buckets[Math.floor((arr[i] - minValue) / bucketSize)].push(arr[i]);
}
arr.length = 0;
for(i = 0; i < buckets.length; i++) {
insertionSort(buckets[i]); // 对每个桶进行排序,这里使用了插入排序
for(let j = 0; j < buckets[i].length; j++) {
arr.push(buckets[i][j]);
}
}
return arr;
}
非原地排序
复杂度:
平均时间复杂度:O(n + k)
最好时间复杂度:O(n)
最坏时间复杂度:O(n²)
空间复杂度:O(n + k)
稳定性:
稳定
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。
算法描述:
算法实现:
rcounter = [];
function radixSort(arr, maxDigit) {
let mod = 10;
let dev = 1;
for(let i = 0; i < maxDigit; i++, dev *= 10, mod *= 10) {
for(let j = 0; j < arr.length; j++) {
let bucket = parseInt((arr[j] % mod) / dev);
if(counter[bucket]==null) {
counter[bucket] = [];
}
counter[bucket].push(arr[j]);
}
let pos = 0;
for(let j = 0; j < counter.length; j++) {
let value = null;
if(counter[j]!=null) {
while((value = counter[j].shift()) != null) {
arr[pos++] = value;
}
}
}
}
return arr;
}
算法分析
基数排序基于分别排序,分别收集,所以是稳定的。但基数排序的性能比桶排序要略差,每一次关键字的桶分配都需要O(n)的时间复杂度,而且分配之后得到新的关键字序列又需要O(n)的时间复杂度。假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d*2n) ,当然d要远远小于n,因此基本上还是线性级别的。
非原地排序
复杂度:
平均、最好、最坏时间复杂度:O(n * k)
空间复杂度:O(n * k)
稳定性:
稳定