稳定性指标
如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
具体事例——电商的订单排序
我们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,用稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。
冒泡排序
每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。
// 冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 0; i < n; ++i) {
// 提前退出冒泡循环的标志位
// 当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作
boolean flag = false;
for (int j = 0; j < n - i - 1; ++j) {
if (a[j] > a[j+1]) { // 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true; // 表示有数据交换
}
}
if (!flag) break; // 没有数据交换,提前退出
}
}
性质:原地算法【空间复杂度为O(1)】、稳定算法(元素大小相等不交换)、平均时间复杂度为O(n^2)。
平均时间复杂度推导
全有序的数组的有序度叫作满有序度,其有序度是n*(n-1)/2。
对于包含 n 个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏情况下,初始状态的有序度是 0,所以要进行 n*(n-1)/2 次交换。最好情况下,初始状态的有序度是 n*(n-1)/2,就不需要进行交换。我们可以取个中间值 n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。换句话说,平均情况下,需要 n*(n-1)/4 次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是 O(n^2),所以平均情况下的时间复杂度就是 O(n^2)。
插入排序
插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。
// 插入排序,a表示数组,n表示数组大小
public void insertionSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 1; i < n; ++i) {
int value = a[i];
int j = i - 1;
// 查找插入的位置
for (; j >= 0; --j) {
if (a[j] > value) {
a[j+1] = a[j]; // 数据移动
} else {
break;
}
}
a[j+1] = value; // 插入数据
}
}
性质:原地算法【空间复杂度为O(1)】、稳定算法(元素大小相等不交换)、平均时间复杂度为O(n^2)。
平均时间复杂度推导
在数组中插入一个数据的平均时间复杂度O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O(n^2)。
比冒泡更受欢迎
从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。
选择排序
选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
function selectSort(arr){
if(arr.length<=1) return arr;
let minIndex;
for(let i =0; i<arr.length; i++){
minIndex = i;
for(let j = i; j <arr.length; j++){
if(arr[j]<arr[minIndex]) minIndex = j;
}
[arr[minIndex],arr[i]] = [arr[i],arr[minIndex]]
}
return arr
}
性质:原地算法【空间复杂度为O(1)】、不稳定算法、平均时间复杂度为O(n^2)。
归并排序
核心思想:分治——先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
递归实现
递推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
终止条件:
p >= r 不用再继续分解
function mergeSort(arr){
if(arr.length<=1) return arr;
let middle = Math.floor(arr.length/2);
let left = arr.slice(0,middle);
let right = arr.slice(middle);
return merge(mergeSort(left),mergeSort(right))
}
function merge(left,right){
let res = [];
while(left.length && right.length){
if(left[0]<=right[0]) res.push(left.shift())
else res.push(right.shift())
}
while(left.length) res.push(left.shift())
while(right.length) res.push(right.shift())
return res
}
性质:非原地排序【空间复杂度O(nlogn)】、稳定算法(取决于merge函数,在大小相同时,先放左边,在放右边,保持顺序的一致性)、时间复杂度O(nlogn);
时间空间复杂度推导
T(n) = 2*T(n/2) + n
= 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
......
= 2^k * T(n/2^k) + k * n
......
当 T(n/2^k)=T(1) 时,有k=log2n,则T(n)=Cn+nlog2n 。因此归并排序的时间复杂度是 O(nlogn)。归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)。空间复杂度推导类似。
快速排序
如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。
递归实现
递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1… r)
终止条件:
p >= r
// 快速排序,A是数组,n表示数组的大小
quick_sort(A, n) {
quick_sort_c(A, 0, n-1)
}
// 快速排序递归函数,p,r为下标
quick_sort_c(A, p, r) {
if p >= r then return
q = partition(A, p, r) // 获取分区点
quick_sort_c(A, p, q-1)
quick_sort_c(A, q+1, r)
}
- 非原地排序
- 原地排序
partition(A, p, r) {
let pivot = A[r]
let i = p
for (let j = p; j++; j < r)
if A[j] < pivot {
[A[i],A[j]]=[A[j],A[i]]
i = i+1
}
}
[A[i],A[r]]= [A[r],A[i]]
return i
通过游标 i 把 A[p...r-1]分成两部分。A[p...i-1]的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i...r-1]是“未处理区间”。我们每次都从未处理的区间 A[i...r-1]中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i]的位置。借助数组插入数据O(1)时间复杂度的思想。
性质:非稳定算法,原地算法,时间复杂度O(nlogn);
与归并算法比较
- 归并从下而上(先处理子问题,在合并),快排从上而下(先分区,在处理子问题)
- 归并稳定,快排不稳定
- 归并空间复杂度O(nlogn),快排为原地算法
- 归并的时间复杂度稳定,但是快排T(n)在大部分情况下的时间复杂度都可以做到 O(nlogn),只有在极端情况下,才会退化到 O(n2)。
利用快排思想在O(n)内查找第K大元素?
选择数组区间 A[0...n-1]的最后一个元素 A[n-1]作为 pivot,对数组 A[0...n-1]原地分区,这样数组就分成了三部分,A[0...p-1]、A[p]、A[p+1...n-1]。
- 如果 p+1=K,那 A[p]就是要求解的元素;
- 如果 K>p+1, 说明第 K 大元素出现在 A[p+1...n-1]区间,再按照上面的思路递归地在 A[p+1...n-1]这个区间内查找。
- 同理,如果 K<p+1,那就在 A[0...p-1]区间查找
优化
O(n^2) 时间复杂度出现的主要原因还是因为我们分区点选得不够合理。
最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。
- 三数取中法 如果数组长度比较大,可选择五数取中,十数取中法。
- 随机法 这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看,也不大可能会出现每次分区点都选得很差的情况,所以平均情况下,这样选的分区点是比较好的。时间复杂度退化为最糟糕的 O(n2) 的情况,出现的可能性不大。
递归要警惕堆栈溢出
- 是限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归。
- 通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制(改为非递归,迭代循环)
线性排序之桶排序
核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
时间复杂度分析
- 各个桶内的数据较为平均:树据n个,桶m个,每个桶内分到的数据为n/m=k,桶内排序利用快排时间复杂度为O(klogk)。那么时间复杂度就位O(mklogk),代入k=n/m,所以时间复杂度为O(nlog(n/m))。如果桶的个数接近n,那么桶排序的时间复杂度为O(n)。
- 但如果桶内数据不平均,极端情况,只有一个桶里有数据,那么桶排序的时间平均度退化为O(nlogn)。
适用场景
桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
实例:比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?
- 将文件分为十等分,若不是均匀分布,有些桶内有较多的数据,可以再次细分,直到所有文件都能读入内存中。
线性排序之计数排序
计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。。
具体实现
// 计数排序,a是数组,n是数组大小。假设数组中存储的都是非负整数。
public void countingSort(int[] a, int n) {
if (n <= 1) return;
// 查找数组中数据的范围
int max = a[0];
for (int i = 1; i < n; ++i) {
if (max < a[i]) {
max = a[i];
}
}
int[] c = new int[max + 1]; // 申请一个计数数组c,下标大小[0,max]
for (int i = 0; i <= max; ++i) {
c[i] = 0;
}
// 计算每个元素的个数,放入c中
for (int i = 0; i < n; ++i) {
c[a[i]]++;
}
// 依次累加
for (int i = 1; i <= max; ++i) {
c[i] = c[i-1] + c[i];
}
// 临时数组r,存储排序之后的结果
int[] r = new int[n];
// 计算排序的关键步骤,有点难理解
for (int i = n - 1; i >= 0; --i) {
int index = c[a[i]]-1;
r[index] = a[i];
c[a[i]]--;
}
// 将结果拷贝给a数组
for (int i = 0; i < n; ++i) {
a[i] = r[i];
}
}
性质:时间复杂度O(n)
计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
比如,还是拿考生这个例子。如果考生成绩精确到小数后一位,我们就需要将所有的分数都先乘以 10,转化成整数,然后再放到 9010 个桶内。再比如,如果要排序的数据中有负数,数据的范围是[-1000, 1000],那我们就需要先对每个数据都加 1000,转化成非负整数。
线性排序之基数排序
假设有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序。
- 电话号码过大,范围太大,桶排序和计数排序都不适合
- 快排的时间复杂度只能达到O(nlogn)
思想
先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。
如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(k*n)。当 k 不大的时候,比如手机号码排序的例子,k 最大就是 11,所以基数排序的时间复杂度就近似于 O(n)。
具体实现
function radixSort(arr,maxDigit){
const len = arr.length;
if(len <= 1) return arr;
let count = [];
let dev = 1, mod = 10;
for(let i = 0; i<maxDigit; i++,dev*=10,mod*=10){
for(let j = 0; j< len; j++){
let digit = parseInt((arr[j] % mod) / dev)
if(!count[digit]) count[digit] = [];
count[digit].push(arr[j])
}
let pos = 0;
for(let j = 0; j < count.length; j++){
if(count[j]){
let value
while((value = count[j].shift())){
arr[pos++] = value
}
}
}
}
return arr
}
var arr = radixSort([1,5,42,31,26,8,6],2)
console.log(arr)
基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。
八大算法对比
- 线性排序算法的时间复杂度比较低,适用场景比较特殊。要写一个通用的排序函数,不能选择线性排序算法。
- 如果对小规模数据进行排序,可以选择时间复杂度是 O(n^2) 的算法
- 如果对大规模数据进行排序,时间复杂度是 O(nlogn) 的算法更加高效。所以,为了兼顾任意规模数据的排序,一般都会首选时间复杂度是 O(nlogn) 的排序算法来实现排序函数。
资料来源
time.geekbang.org/column/arti… time.geekbang.org/column/arti… time.geekbang.org/column/arti… time.geekbang.org/column/arti…