概述 排序算法总结
排序算法
最好 最坏 平均
是否基于比较
是否稳定
空间复杂度
冒泡排序
O(n) O(n2 ) O(n2 )
√
√
O(1)
插入排序
O(n) O(n2 ) O(n2 )
√
√
O(1)
选择排序
O(n2 ) O(n2 ) O(n2 )
√
×
O(1)
快速排序
O(nlogn) O(n2 ) O(nlogn)
√
×
O(1)
归并排序
都是O(nlogn)
√
√
O(n)
堆排序
O(nlogn)
√
O(1)
桶排序
O(n)
×
√
计数排序
O(n)
×
√
基数排序
O(n)
×
√
排序算法的分析要点 执行效率主要从以下三个方面进行分析:
最好情况、最坏情况、平均情况时间复杂度
时间复杂度的系数、常数、低阶,在对同一阶时间复杂度的排序算法性能对比时需要考虑
比较次数和交换(移动)次数,基于比较的排序算法
排序算法的空间消耗主要分为原地排序和非原地排序,原地排序算法就是空间复杂度为O(1)的算法。
排序算法还有一个重要的度量指标:稳定性 ,待排序的序列中存在值相等的元素,排序后相等元素之间原有的先后顺序不变,则是稳定的排序算法。
排序算法 基于比较的排序算法最频繁的操作就是比较和交换,在ES6中的交换 可以用[a, b] = [b, a]
来简单实现,对于比较 ,由于JavaScript没有实现统一的接口,对于不同类型的比较需要写不同的比较代码进行操作。
逆序数 有序数对是数组中具有有序关系的元素对的个数。完全有序的数组的有序数对=初始数组的有序数对+逆序数。
冒泡排序 冒泡排序过程中,交换的次数等于逆序数。
function bubbleSort (arr ) { if (arr.length < 2 ) return ; for (let i = 0 ; i < arr.length; i++){ let flag = false ; for (let j = i + 1 ; j < arr.length; j++){ if (arr[i] > arr[j]) { [arr[i], arr[j]] = [arr[j], arr[i]]; flag = true ; } } if (!flag) break ; } }
插入排序 插入排序过程中,交换的次数等于逆序数。插入排序和冒泡排序的交换次数都等于逆序数,但是在操作过程中,冒泡排序需要三个赋值操作才能交换,而插入排序只要一个,略优于冒泡排序。
function insertSort (arr ) { if (arr.length < 2 ) return ; for (let i = 1 ; i < arr.length; i++) { let val = arr[i]; for (let j = i - 1 ; j >= 0 ; j--) { if (val < arr[j]) arr[j + 1 ] = arr[j]; else break ; } } }
选择排序 选择排序不稳定,和冒泡、插入比稍微逊色。
function selectSort (arr ) { if (arr.length < 2 ) return ; for (let i = 0 ; i < arr.length - 1 ; i++) { let min = i; for (let j = i + 1 ; j < arr.length; j++) { if (arr[min] > arr[j]) min = j; } [arr[i], arr[min]] = [arr[min], arr[i]]; } }
归并排序 function mergeSort (arr ) { mergeSortC(arr, 0 , arr.length - 1 ); } function mergeSortC (arr, p, q ) { if (p >= q) return ; let r = Math .floor((p + q) / 2 ); mergeSortC(arr, p, r); mergeSortC(arr, r + 1 , q); merge(arr, p, r, q); } function merge (arr, p, r, q ) { let i = p, j = r + 1 ; let nums = []; for (let k = p; k <= q; k++) { nums[k] = arr[k]; } for (let k = p; k <= q; k++) { if (i > r) { arr[k] = nums[j++]; } else if (j > q) { arr[k] = nums[i++]; } else if (nums[i] < nums[j]) { arr[k] = nums[i++]; } else { arr[k] = nums[j++]; } } }
归并排序需要额外的存储空间,空间复杂度是O(n)。
快速排序 function quickSort (arr ) { quickSortC(arr, 0 , arr.length - 1 ); } function quickSortC (arr, p, q ) { if (p >= q) return ; let r = partition(arr, p, q); quickSortC(arr, p, r - 1 ); quickSortC(arr, r + 1 , q); } function partition (arr, p, q ) { let pivot = arr[q]; let i = p; for (let j = p; j <= q - 1 ; j++) { if (arr[j] < pivot) { [arr[i], arr[j]] = [arr[j], arr[i]]; i++; } } [arr[i], arr[q]] = [arr[q], arr[i]]; return i; }
快速排序的优化 每次分区选择最后或第一个数据,快速排序很可能会退化成O(n2 ),快速排序恶化的原因主要是分区点选择的不合理。最理想的情况是:被分区点分开的两个分区中数据的数量差不多。常见的分区算法有:
将数组的第一个、最后一个和中间的三个数中取三个数的中间值作为分区点。当排序的数组比较大的时候,可以采样更多的数据来取比如五数取中。
从排序区间中随机选择一个元素作为分区点。
桶排序 桶排序的数据要很容易就能划分成m个桶,同时,桶与桶之间有着天然的大小顺序。其次,数据在各个桶之间的分布是比较均匀的。桶排序适合用在外部排序中,即数据量大(存储在外部磁盘中的数据),内存有限。
计数排序 桶排序的一种特殊情况。桶的数量可以大于要排序的数的范围,比如数字范围是[0, 100],即可以用101个桶,每个桶存储一个数字的对象,即是计数排序。
function countSort (a ) { const max = Math .max(...a); let result = [], temp = Array (max + 1 ).fill(0 ); for (let i = 0 ; i <= max; i++) { temp[a[i]]++; } for (let i = 1 ; i <= max; i++) { temp[i] += temp[i - 1 ]; } for (let i = a.length - 1 ; i >= 0 ; i--) { let index = temp[a[i]] - 1 ; result[index] = a[i]; temp[a[i]]--; } for (let i = 0 ; i < a.length; i++) { a[i] = result[i]; } }
基数排序 基数排序需要可以分割出独立的位来比较,而且位之间有递进的关系,如果a数据的高位比b数据大,那剩下的低位就不用比较了,比如11位手机号码的排序,可以从最后一位开始逐位比较进行排序。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则就不能到O(n)了。
堆排序 堆是一个完全二叉树,且堆中的每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。一般用数组存储堆,数组第0个元素为空。数组中下标为i的节点的左子节点是i*2
,右子节点是i*2+1
,父节点是i/2
。
堆排序的过程大致分解成建堆和排序两个步骤。
建堆 从后往前处理数组,并且每个数据都是从上往下堆化。下面以大顶堆为例。
class HeapSort { constructor (capacity) { this .capacity = capacity; this .heap = []; } buildHeap(a, n) { for (let i = Math .floor(n / 2 ); i > 0 ; i--) { this .heapify(a, n, i); } } heapify(a, n, i) { while (true ) { let maxPos = i; if (i * 2 <= n && a[i] < a[i * 2 ]) maxPos = i * 2 ; if (i * 2 + 1 <= n && a[maxPos] < a[i * 2 + 1 ]) maxPos = i * 2 + 1 ; if (maxPos === i) break ; [a[i], a[maxPos]] = [a[maxPos], a[i]]; i = maxPos; } } }
排序 将堆顶元素移除,并与数组最后一个元素交换,对剩下的n-1
个元素重新建堆。
sort(a) { this .buildHeap(a, this .capacity); let k = this .capacity; while (k > 0 ) { [a[k], a[1 ]] = [a[1 ], a[k]]; k--; heap(a, k, 1 ); } }
堆排序性能没有快速排序好的原因主要有两方面,一是堆排序数据访问的方式是跳跃的,对CPU缓存不友好。二是对于同样的数据,堆排序的数据交换次数要多于快速排序。
应用
优先级队列
优先级队列中,优先级最高的最先出队。堆和优先级队列非常相似,一个堆就可以看做一个优先级队列。
求Top K问题
可以维护一个大小为K的小顶堆,顺序遍历数组,从数组中取出元素与堆顶元素比较,如果比堆顶元素大,就把堆顶元素删除,并且插入到堆中,否则不做处理。遍历完后堆中的数据就是前K大数据了。时间复杂度O(nlogk)。
求中位数
维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。如果新加入的数据小于等于大顶堆的堆顶元素,将新数据插入到大顶堆,否则插入到小顶堆。大顶堆的堆顶元素就是中位数。