本篇文章总结一下各种常见的排序算法,并对各种算法的原理、复杂度、稳定性等性质进行分析;最后我们看一下这些算法在实际生产中的应用
为了方便描述,除了特殊说明,文中的排序都是对int数组进行从小到大排序(内部排序)。
1. 选择排序
【算法思想】
选择排序算法的思路很简单,假设数组的长度是
N
(
N
>
=
0
)
N(N>=0)
N(N>=0),选择排序算法需要执行
N
−
1
N-1
N−1趟:
- 第一趟:在array[ 0 ] … array[ N-1 ] 中找到一个最大的数,把它和array[ N-1 ]交换;
- 第二趟:在array[ 0 ] … array[ N-2 ] 中找到一个最大的数,把它和array[ N-2 ]交换;
- …
- 第N-1趟:在array[ 0 ] … array[ 1 ] 中找到一个最大的数,把它和array[ 1 ]交换;
经过 N − 1 N-1 N−1趟的操作后,对于任何array[index] ( 1 < = i n d e x < = N − 2 1<=index<=N-2 1<=index<=N−2),都有
- a r r a y [ i n d e x ] < = a r r a y [ i n d e x + 1 ] array[index] <= array[index+1] array[index]<=array[index+1]成立,这是因为 a r r a y [ i n d e x + 1 ] array[index+1] array[index+1]是 a r r a y [ 0 ] . . . a r r a y [ i n d e x + 1 ] array[ 0 ] ... array[ index+1 ] array[0]...array[index+1]中的最大值;
- a r r a y [ i n d e x − 1 ] < = a r r a y [ i n d e x ] array[index-1] <= array[index] array[index−1]<=array[index]成立,这是因为 a r r a y [ i n d e x ] array[index] array[index] 是 a r r a y [ 0 ] . . . a r r a y [ i n d e x ] array[ 0 ] ... array[ index ] array[0]...array[index] 中的最大值;
- 特别的,当index是 N − 2 N-2 N−2时,有 a r r a y [ N − 2 ] < = a r r a y [ N − 1 ] array[N-2] <= array[N-1] array[N−2]<=array[N−1];
对于0,有 a r r a y [ 0 ] < = a r r a y [ 1 ] array[0]<=array[1] array[0]<=array[1]成立(因为 a r r a y [ 1 ] array[1] array[1] 是 a r r a y [ 0 ] . . . a r r a y [ 1 ] array[ 0 ] ... array[ 1 ] array[0]...array[1] 中的最大值),因此整个数组有序。
【实现代码】
public void insertionSort(int[] array){
if(array==null || array.length<=1)return;
int n = array.length, i ,j ,maxIndex;
//选择排序需要依次找到最大的,第二大的,。。。第n-1大的 分别放在索引n-1,n-2 ... 1处
//我们依次枚举这些索引,找到最大的元素放进去就可以了
for(i = n-1; i>0; --i){
//从0 到 i中 找一个最大的放到i的位置上,默认认为0号元素是最大的,然后循环中不断更新它
maxIndex = 0;
for(j=1; j<=i; ++j)
if(array[maxIndex]<array[j])maxIndex = j;
if(maxIndex != i && array[maxIndex] != array[i]) swap(array,maxIndex,i);
}
}
public void swap(int[] array,int i,int j){
int t = array[i];
array[i] = array[j];
array[j] = t;
}
gif展示,虽然图上每次选的是最小的,但是不影响
【时间复杂度】
需要执行
N
−
1
N-1
N−1趟,第一趟需要比较
N
−
1
N-1
N−1次,第一趟需要比较
N
−
2
N-2
N−2次,… 最后一趟需要比较1次,因此总的比较次数:
s
u
m
=
∑
i
=
1
N
−
1
i
=
N
(
N
−
1
)
2
sum = \sum_{i=1}^{N-1}{i} = \frac{N(N-1)}{2}
sum=i=1∑N−1i=2N(N−1)
因此时间复杂度
O
(
N
2
)
O(N^2)
O(N2)。
其实很多数组在排序之前,都是部分有序的,但是插入排序没有利用到数组部分有序,因此任何情况下,时间复杂度都是 O ( N 2 ) O(N^2) O(N2)。
【空间复杂度】
O ( 1 ) O(1) O(1)
【稳定性】
排序算法的稳定性概念:如果某个排序算法,不影响相同的记录在排序之前的相对顺序,那么这个排序算法就是稳定的,否则不稳定。
它的意思是说,例如在排序之前,数组是这样的 [1, 3, 2, 4, 5A, 7, 9, 5B, 20]
(数组有两个5,分别使用5A和5B区分),从小到大排序之后,要保证5A出现在5B前面, 即:[1, 2, 3, 4, 5A, 5B, 7, 9, 20], 而不是:[1, 2, 3, 4, 5B, 5A, 7, 9, 20]。上面的概念应该好理解,但是稳定性的有什么意义呢?
基数排序 基数排序的思想这里先简单提一下,它的思想大概是(暂且考虑两位整数的排序),先排序个位数,然后在排序十位数,如果个位数排序好了,在排序十位数的时候,就需要使用稳定的排序算法,才可以得到正确的结果。例如有四个数,15,16,17, 18,十位数都是1,如果使用不稳定的排序算法对十位数进行排序,可能就会使得15跑到16后面,而稳定的排序算法就会在关键字相同时,保留原有的顺序。
复杂对象的多重排序 例如有很多商品对象,里面有价格(price)和销量(amount)字段,如果最初的顺序是按照价格从小到大排序的,即最初的顺序是有意义的,那现在要求对这个商品序列按照销量从小到大排序,既然原来的顺序是有意义的,那么当销量相同时,就不应该更改原来的价格顺序,即要保证价格还是从小到大的,这时就需要使用到稳定的排序算法。
假设在某一趟的排序中,在 a r r a y [ 0 ] . . . a r r a y [ i ] , 1 < = i < = N − 1 array[ 0 ] ... array[ i ], 1<=i<=N-1 array[0]...array[i],1<=i<=N−1,中找到了一个最大的值所在的索引是maxIndex,此时需要把 a r r a y [ m a x I n d e x ] array[ maxIndex ] array[maxIndex]和 a r r a y [ i ] array[ i ] array[i]进行交换,如果在maxIndex和i之间存在一个索引j,并且 a r r a y [ j ] = = a r r a y [ i ] array[ j ] == array[ i ] array[j]==array[i],则交换之后, a r r a y [ i ] array[ i ] array[i]就会出现在 a r r a y [ j ] array[ j ] array[j]的前面。
例如:数组 {1, 8, 4, 6, 2, 3, 2}
第一轮肯定要把8(红色)和2(绿色)进行交换,在8和最后一个数之间还有一个2(黄色),这样交换以后,就会导致绿色的2排到黄色的2之前。
{1, 2, 4, 6, 2, 3, 8}
所以选择排序不稳定。
选择排序的思想是每一趟都从一个范围中找到一个最大的,然后放在指定位置来实现的,寻找最大值的过程是采用逐个比较的方法,时间复杂度是线性的,有没有一种办法能加快这个过程呢?有的,下面我们来看堆排序。
2. 堆排序
【铺垫】
讲堆排序之前,先说一下堆数据结构。(如果有已经知道了堆的大佬,请跳过。),在说堆之前,先说两个特殊的二叉树:
-
满二叉树
顾名思义,树上结点满了的二叉树;准确来说,一个有 k , k > = 1 k,k>=1 k,k>=1层且有 2 k − 1 2^k -1 2k−1个结点的二叉树(实际上,有k层,最多只有 2 k − 1 2^k -1 2k−1个结点) -
完全二叉树
对二叉树从1开始,自上而下,自左而右的依次编号,如果某一个二叉树它的编号依次可以和满二叉树一一对应,那么这个二叉树就是完全二叉树
因此满二叉树是一个特殊的完全二叉树。
堆就是一颗完全二叉树,因为完全二叉树结构很规整,可以直接用数组存储,所以堆又叫数组存储的二叉树。堆在数组中存储的方式,还是按照上面的编号顺序,依次存放到数组的0,1,2…N-1的位置上。
设堆的结点个数为N,数组索引范围[0,N-1],有关堆存储的几点性质:
- 对于任意一个结点,其索引
i
i
i,其父节点索引(如果有的话)
p
p
p,左孩子索引(如果有的话)
l
l
l,右孩子索引(如果有的话)
r
r
r,且
i
,
p
,
l
,
r
∈
[
0
,
N
−
1
]
i,p,l,r\in[0,N-1]
i,p,l,r∈[0,N−1]那么有
p = ⌊ i − 1 2 ⌋ l = 2 i + 1 r = l + 1 p=\lfloor\frac{i-1}{2}\rfloor\\ l = 2i+1\\ r = l+1 p=⌊2i−1⌋l=2i+1r=l+1成立 - 堆的层数 L = ⌈ log 2 ( N + 1 ) ⌉ L = \lceil\log_{2}{(N+1)}\rceil L=⌈log2(N+1)⌉
- 叶子结点的索引范围: [ N 2 , N − 1 ] [\frac{N}{2},N-1] [2N,N−1]
上面三条结论的简单证明,如果不感兴趣可以跳过:
对于第一个结论,只需要证明 l = 2 i + 1 l = 2i+1 l=2i+1即可。对于满二叉树,它每一层的节点数是 L ( h ) = 2 h − 1 , h ∈ [ 1 , + ∞ ) L(h) = 2^{h-1},h\in[1,+\infty) L(h)=2h−1,h∈[1,+∞),每层节点数成等比数列,前N层的节点数之和: S ( N ) = ∑ i = 1 N 2 i − 1 = 2 N − 1 ( 运 用 等 比 数 列 求 和 ) S(N) = \sum_{i=1}^{N}{2^{i-1}} = 2^{N}-1(运用等比数列求和) S(N)=∑i=1N2i−1=2N−1(运用等比数列求和),可以得到 S ( N ) S(N) S(N)只是比第N+1层的节点数 2 N + 1 − 1 = 2 N 2^{N+1-1}=2^{N} 2N+1−1=2N少了1,所以 S ( N ) = L ( N + 1 ) − 1 S(N) = L(N+1)-1 S(N)=L(N+1)−1,也就是说,某一层的节点数等于前面所有层的节点数+1。因为索引从0开始,所以每个元素的索引表示它前面的所有元素的个数。我们只需要知道在编号为 i i i的结点的左孩子前面,还有几个元素即可。设索引为 i i i的结点所在的层编号是 h h h,这一层第一个结点的索引为 k k k,且令 o f f s e t = i − k offset = i-k offset=i−k,表示在第h层,在i之前还有offset个结点。那么前h层,一共有 k + ( k + 1 ) = 2 k + 1 k+(k+1)=2k+1 k+(k+1)=2k+1个结点,i的左孩子一定在h+1层,那个它距离h+1层第一个结点的偏移是 2 o f f s e t = 2 i − 2 k 2offset = 2i-2k 2offset=2i−2k,那么在l之前一共有 2 k + 1 + 2 i − 2 k = 2 i + 1 2k+1+2i-2k=2i+1 2k+1+2i−2k=2i+1个结点,即l的索引是 2 i + 1 2i+1 2i+1。
第二个结论,层数为h的满二叉树的结点总数为 2 h − 1 2^{h}-1 2h−1,如果堆是一个满二叉树,结点个数为 N N N,那么层数显然是整数,即 log 2 ( N + 1 ) \log_{2}{(N+1)} log2(N+1),否则,堆的最后一层没有排满,但是也占据一层,所以层数是小数,但是大于 log 2 ( N + 1 ) \log_{2}{(N+1)} log2(N+1),所以取 ⌈ log 2 ( N + 1 ) ⌉ \lceil\log_{2}{(N+1)}\rceil ⌈log2(N+1)⌉。
第三个结论 设最后一个具有子结点的索引是k,如果:
- N是偶数,则N-1是奇数,由于右孩子的索引是 2 i + 2 2i+2 2i+2是偶数,所以最后一个具有子结点的结点,没有右孩子,只有左孩子,那么其左子结点索引 2 k + 1 2k+1 2k+1,则 2 k + 1 < N = > k < = ⌊ N − 1 2 ⌋ 2k+1<N=>k<=\lfloor\frac{N-1}{2}\rfloor 2k+1<N=>k<=⌊2N−1⌋,剩下的都是叶子结点,叶子节点的索引范围 [ ⌊ N − 1 2 ⌋ + 1 , N − 1 ] [\lfloor\frac{N-1}{2}\rfloor+1,N-1] [⌊2N−1⌋+1,N−1],由于N是偶数, ⌊ N − 1 2 ⌋ + 1 = N 2 \lfloor\frac{N-1}{2}\rfloor+1 = \frac{N}{2} ⌊2N−1⌋+1=2N,即叶子节点的索引范围 [ N 2 , N − 1 ] [\frac{N}{2},N-1] [2N,N−1]。
- N是奇数,则N-1是偶数,则最后一个具有子结点的结点有右孩子,即 2 k + 2 < N = > k < N − 2 2 = > k < = ⌊ N 2 ⌋ − 1 2k+2<N=>k<\frac{N-2}{2}=>k<=\lfloor\frac{N}{2}\rfloor-1 2k+2<N=>k<2N−2=>k<=⌊2N⌋−1,即叶子节点的索引范围 [ ⌊ N 2 ⌋ , N − 1 ] [\lfloor\frac{N}{2}\rfloor,N-1] [⌊2N⌋,N−1].
当N是奇数时, ⌊ N 2 ⌋ = N 2 \lfloor\frac{N}{2}\rfloor=\frac{N}{2} ⌊2N⌋=2N,综上,叶子结点的范围 [ N 2 , N − 1 ] [\frac{N}{2},N-1] [2N,N−1];可以看出几乎一半都是叶子结点。
最大堆:设堆结点个数 N ∈ [ 1 , + ∞ ) , N \in [1, +\infty), N∈[1,+∞),对任何一个结点的索引 i ∈ [ 0 , N − 1 ] i \in [0, N-1] i∈[0,N−1],其父节点索引(如果存在的话) p ( i ) p(i) p(i),有 a r r a y [ i ] < = a r r a y [ p ( i ) ] array[i]<=array[p(i)] array[i]<=array[p(i)]成立
最小堆:设堆结点个数 N ∈ [ 1 , + ∞ ) , N \in [1, +\infty), N∈[1,+∞),对任何一个结点的索引 i ∈ [ 0 , N − 1 ] i \in [0, N-1] i∈[0,N−1],其父节点索引(如果存在的话) p ( i ) p(i) p(i),有 a r r a y [ i ] > = a r r a y [ p ( i ) ] array[i]>=array[p(i)] array[i]>=array[p(i)]成立
显然一个排序的数组就是一个最大堆/最小堆,反之不真。
对于一个最大堆来说,堆顶的元素就是最大的元素,最小堆亦然。下面我们以最大堆为例,说明堆的操作,假设堆的元素个数 N N N。
-
如何把一个数组整理成最大堆?上浮(shift_up)和下沉(shift_down)操作
对于任何一个元素,如果其值大于其父元素,根据大顶堆的定义,此时应该把当前元素和其父元素交换,然后再考查其父元素是不是大于其父元素的父元素;否则结束,这个操作称为元素上浮,代码如下:/** * 把索引为shiftUpIndex的堆元素上浮 * @param heap 存储堆的数组 * @param N 堆元素个数 * @param shiftUpIndex 等待上浮的元素索引 */ public void shiftUp(int[] heap, int N, int shiftUpIndex) { //边界校验 if (shiftUpIndex >= N) return; //暂存等待上浮的元素的值 int t = heap[shiftUpIndex], parentIndex; //只要等待上浮的元素索引大于0(因为索引为0的元素是堆顶了,没法上浮) 就不断考察其父元素和当前元素的大小关系 while (shiftUpIndex > 0) { parentIndex = (shiftUpIndex - 1) >> 1; //对于最大堆来说,如果父元素小于子元素,则子元素上浮,把父元素的值赋值给子元素 if (heap[parentIndex] < t) { heap[shiftUpIndex] = heap[parentIndex]; //更新等待上浮元素的索引 shiftUpIndex = parentIndex; } else { //否则上浮结束 break; } } heap[shiftUpIndex] = t; }
对于任何一个元素,如果其值小于其某个元素,根据大顶堆的定义,此时应该把当前元素和其两个子元素中最大的那个元素交换,然后再考查被交换的子元素是不是小于它的某个子元素;否则结束,这个操作称为元素下沉/** * 把索引为shiftUpIndex的堆元素下沉 * @param heap 存储堆的数组 * @param N 堆元素个数 * @param shiftDownIndex 等待下沉的元素索引 */ public void shiftDown(int[] heap, int N, int shiftDownIndex) { //边界校验 对于索引大于等于N/2的元素,是叶子结点,叶子结点不需要下沉 int leafBound = N >> 1; if (shiftDownIndex >= leafBound || shiftDownIndex < 0) return; //暂存等待下沉的元素的值 int t = heap[shiftDownIndex], childIndex; //只要等待下沉的元素索引小于叶子元素的索引边界(因为超过叶子元素的索引边界的元素就是叶子元素了,没法下沉) 就不断考察其最大的子元素和当前元素的大小关系 while (shiftDownIndex < leafBound) { childIndex = (shiftDownIndex << 1) + 1; if(childIndex + 1 < N && heap[childIndex + 1] > heap[childIndex])childIndex++; //对于最大堆来说,如果子元素大于当前元素,则当前元素下沉,把子元素的值赋值给当前元素 if (heap[childIndex] > t) { heap[shiftDownIndex] = heap[childIndex]; //更新等待下沉元素的索引 shiftDownIndex = childIndex; } else { //否则下沉结束 break; } } heap[shiftDownIndex] = t; }
上浮和下沉时间复杂度:这个与元素所在堆的层次有关
最好情况下,元素是在倒数第二层,最多只需要进行一次比较即可,是 O ( 1 ) O(1) O(1),
最坏情况下,元素是在第一层(堆顶),最多需要比较 堆的层数-1 次,根据上面的结论2,即 ⌈ log 2 ( N + 1 ) ⌉ − 1 = O ( log 2 N ) \lceil\log_{2}{(N+1)}\rceil-1 = O(\log_{2}{N}) ⌈log2(N+1)⌉−1=O(log2N);
假设元素在第 l ∈ [ 1 , ⌈ log 2 ( N + 1 ) ⌉ ] l\in[1,\lceil\log_{2}{(N+1)}\rceil] l∈[1,⌈log2(N+1)⌉]层,最多需要比较 堆的层数-l次。
空间复杂度: O ( 1 ) O(1) O(1);
当一个元素上浮时,就有一个元素下沉,上浮和下沉是相对的。
如何把一个数组整理成一个最大堆呢?可以使用上浮,也可以下沉,这里使用下沉举例,因为叶子结点是不需要下沉的,所以根据最大堆的要求,把所有的非叶子结点都下沉操作一遍即可。代码如下:/** * 把数组调整成最大堆 * @param heap 调整之前的数组 * @param N 堆元素个数 */ public void adjustHeap(int[] heap, int N){ if(N <= 1)return; // N/2-1 是最后一个非叶子结点的索引 for(int i = (N>>1)-1; i>=0; --i) shiftDown(heap, N, i); }
时间复杂度,最好 O ( N ) O(N) O(N),最坏情况下:也是 O ( N ) O(N) O(N)。
最坏情形证明如下:设堆层数 L L L,第 i ∈ [ 1 , L ] i\in[1, L] i∈[1,L]层的元素最多需要比较的次数是 L − i L-i L−i,第 i i i层的元素个数最多是 2 i − 1 2^{i-1} 2i−1,所以所有的非叶子结点下沉最多需要比较次数 S = ∑ i = 1 L − 1 2 i − 1 ( L − i ) S=\sum_{i=1}^{L-1}{2^{i-1}(L-i)} S=∑i=1L−12i−1(L−i),代入 L = log 2 ( N + 1 ) L =\log_{2}{(N+1)} L=log2(N+1),整理得到 S = N + 1 − log 2 N + 1 − 1 S=N+1-\log_2{N+1}-1 S=N+1−log2N+1−1,因此时间复杂度 O ( N ) O(N) O(N)
空间复杂度: O ( 1 ) O(1) O(1)。 -
向堆末尾添加元素
向堆中添加元素,一般都是添加在末尾,然后把最后的这个结点上浮即可。/** * 向堆中添加元素 * @param heap 存储堆的数组 * @param N 堆中的元素个数 * @param node 新加入的结点 */ public void heapInsertion(int[] heap, int N, int node){ if(N==heap.length) throw new UnsupportedOperationException("堆数组已满"); heap[N] = node; shiftUp(heap,N+1,N); }
平均时间复杂度 O ( log 2 N ) O(\log_{2}{N}) O(log2N)
-
移除堆顶元素
移除的方法是,把堆顶元素和最后一个元素交换,然后堆元素个数减一,最后让新的堆顶元素下沉。/** * 移除堆顶元素 * @param heap 保存堆的数组 * @param N 堆中的元素个数 * @return 堆顶元素 */ public int pop(int[] heap, int N){ //把最后一个元素赋值给堆顶,返回原堆顶 //最后下沉新的堆顶元素,调整堆 int ans = heap[0]; heap[0] = heap[N-1]; shiftDown(heap,N-1,0); return ans; }
平均时间复杂度 O ( log 2 N ) O(\log_{2}{N}) O(log2N)
前面说了这么多,大概介绍了一下堆是什么以及操作,其实堆的操作不只这几个,这里只是介绍了堆排序需要用到的,下面我们看一下堆排序的思想:
【算法思想】
我们只需要把一个待排序的数组整理成最大堆,然后不断pop即可(没错,就这么简单😄)。
【实现代码】
完整代码如下:
/**
* 把索引为shiftUpIndex的堆元素下沉
* @param heap 存储堆的数组
* @param N 堆元素个数
* @param shiftDownIndex 等待下沉的元素索引
*/
public void shiftDown(int[] heap, int N, int shiftDownIndex) {
//边界校验 对于索引大于等于N/2的元素,是叶子结点,叶子结点不需要下沉
int leafBound = N >> 1;
if (shiftDownIndex >= leafBound || shiftDownIndex < 0) return;
//暂存等待下沉的元素的值
int t = heap[shiftDownIndex], childIndex;
//只要等待下沉的元素索引小于叶子元素的索引边界 就不断考察其最大的子元素和当前元素的大小关系
while (shiftDownIndex < leafBound) {
childIndex = (shiftDownIndex << 1) + 1;
if(childIndex + 1 < N && heap[childIndex + 1] > heap[childIndex])childIndex++;
//对于最大堆来说,如果子元素大于当前元素,则当前元素下沉,把子元素的值赋值给当前元素
if (heap[childIndex] > t) {
heap[shiftDownIndex] = heap[childIndex];
//更新等待下沉元素的索引
shiftDownIndex = childIndex;
} else {
//否则下沉结束
break;
}
}
heap[shiftDownIndex] = t;
}
/**
* 把数组调整成最大堆
* @param heap 调整之前的数组
* @param N 对元素个数
*/
public void adjustHeap(int[] heap, int N){
if(N <= 1)return;
// N/2-1 是最后一个非叶子结点的索引
for(int i = (N>>1)-1; i>=0; --i)
shiftDown(heap, N, i);
}
/**
* 移除堆顶元素
* @param heap 保存堆的数组
* @param N 堆中的元素个数
* @return 堆顶元素
*/
public int pop(int[] heap, int N){
//把最后一个元素赋值给堆顶,返回原堆顶
//最后下沉新的堆顶元素,调整堆
int ans = heap[0];
heap[0] = heap[N-1];
shiftDown(heap,N-1,0);
return ans;
}
/**
* 堆排序
* @param array 等待排序的数组
*/
public void heapSort(int[] array){
if(array==null || array.length<=1)return;
int len = array.length;
adjustHeap(array, len);
for(int i = len-1; i>0; --i){
array[i] = pop(array, i+1);
}
}
gif展示(初始已经构建好了最大堆,下图展示不断pop的过程)
【时间复杂度】
实际上,堆排序是选择排序的升级版,它们基础思想都是从分别选择最大的,第二大的,…一直到第
N
−
1
N-1
N−1大的,分别放到最后一个,倒数第二个,…第二个元素的位置,区别是
- 选择排序在找到最大的元素时,最后交换的一步把原来在最大值的位置的元素又放回了原无序数组中,相当于这一轮比较的价值就是找到一个最大值,下一轮寻找用不到上一轮的结果,又得重新比较;
- 堆排序把找最大的元素的过程使用堆来完成,堆数据结构缓存了部分元素之间的有序性,因此降低了时间复杂度。
堆排序的时间包括构建堆的时间和每次pop调整堆的时间,构建堆时间复杂度
O
(
N
)
O(N)
O(N),
每次pop都需要把队末尾的元素放到堆顶,平均来说,比较次数为当时堆层数-1,而堆层数等于当时的对元素个数的对数,即
log
2
(
N
+
1
)
\log_{2}{(N+1)}
log2(N+1),随着不断pop,堆的元素个数在不断减少,即最多比较的次数为
∑
i
=
2
N
(
log
2
(
i
+
1
)
−
1
)
=
log
2
(
N
+
1
)
!
2
−
N
+
1
=
O
(
N
log
2
N
)
\sum_{i=2}^{N}{(\log_{2}{(i+1)-1)}} = \frac{\log_{2}{(N+1)!}}{2}-N+1=O(N\log_{2}{N})
∑i=2N(log2(i+1)−1)=2log2(N+1)!−N+1=O(Nlog2N)。 (至于为什么
O
(
log
2
N
!
)
=
O
(
N
log
2
N
)
O(\log_{2}{N!}) =O(N\log_{2}{N})
O(log2N!)=O(Nlog2N)请参考这篇文章)
【空间复杂度】
O
(
1
)
O(1)
O(1)
【稳定性】
假设当前元素的结点索引是
i
i
i,其左孩子结点索引
2
i
+
1
2i+1
2i+1,如果此时要发生
h
e
a
p
[
i
]
heap[ i ]
heap[i]和
h
e
a
p
[
2
i
+
1
]
heap[2i+1]
heap[2i+1]的交换,且在
i
i
i和
2
i
+
1
2i+1
2i+1之间有一个结点
h
e
a
p
[
p
]
heap[p]
heap[p]的值等于
h
e
a
p
[
2
i
+
1
]
heap[2i+1]
heap[2i+1],这样
h
e
a
p
[
2
i
+
1
]
heap[2i+1]
heap[2i+1]就跑到了
h
e
a
p
[
p
]
heap[p]
heap[p]的前面。所以不稳定
3. 插入排序
【算法思想】
插入排序的思想是,使用一个指针把数组分成了两部分,不妨认为左边部分是已经排好序的,右边部分是没有排序的;遍历依次右边部分的每一个元素,在左边有序的元素中找一个合适的位置插入当前元素,使得插入以后的数组是有序的,这样整个数组就是有序的了。
【实现代码】
/**
* 插入排序
* @param array 等待排序的数组
*/
public void insertionSort(int[] array){
if(array == null || array.length <= 1)return;
int len = array.length, i, j, t;
//i左边是有序的(不包括i),右边是无序的(包括i)
for(i=1; i<len; ++i){
//暂存当前元素
t = array[i];
j = i-1;
//在有序区中找一个合适的位置,把当前元素放进去即可
while ( j > -1 && array[j] > t){
//只要比当前元素大的元素,都往前挪一步
array[j+1] = array[j];
--j;
}
array[j+1] = t;
}
}
gif演示
【时间复杂度】
假设数组长度
N
N
N,对于索引从1到
N
−
1
N-1
N−1中的每一个元素,我们都需要在有序数组中为其找到一个合适的插入位置,
最好的情况下,数组已经有序,每次找插入位置的时间只需要比较1次,此时时间复杂度
O
(
N
)
O(N)
O(N),
最坏情况下,数组反序,第
i
∈
[
1
,
N
−
1
]
i\in[1,N-1]
i∈[1,N−1]次选择插入位置需要比较
i
i
i次,总共比较次数
∑
i
=
1
N
−
1
i
=
N
(
N
−
1
)
2
\sum_{i=1}^{N-1}{i} =\frac{N(N-1)}{2}
∑i=1N−1i=2N(N−1),即时间复杂度
O
(
N
2
)
O(N^2)
O(N2),
【空间复杂度】
O
(
1
)
O(1)
O(1)
【稳定性】
这个取决于while ( j > -1 && array[j] > t){
这行代码的中array[j] 和 t的判断符号,如果是大于,则稳定,如果是大于等于,那么当两个元素相等时,也会使得元素后移,这样相同的元素就移动到了当前元素后边,就不稳定了。但是一般我们会说插入排序是稳定的。
插入排序在选择插入位置的时候,是线性的时间复杂度,这样的效率并不是很高,特别是当数组反序的时候,每次都要比较一遍以找到最合适插入的位置;当某个数很大时(例如{100,1,2,4,3,5},100很显然因该放在最后一个位置,因为它比较大)选择排序每次仅仅将它前移一个位置,有没有什么办法,让大的数据一次多走一段呢?下面请看希尔排序。
4. 希尔排序
【算法思想】
上面讲的插入排序在数组有序时,或者数组大部分有序时,效率是很高的,基本上一遍扫描就可以完成排序。希尔排序使用了预处理的思想,在数组上以一定跨度的步长来对数组使用插入排序预先排序(粗略排序),此时比较大的元素会被放到比较后边的位置,随着步长的不断缩短,由于经过预处理的数组是部分有序的,因此每一轮的插入排序的时间复杂度都不会很高,最后一轮时,步长变为1,此时数组已经基本很有序了,再使用插入排序就可以很快把数组整理有序。因为希尔排序是通过缩短步长,一步一步提高排序的精度,因此又称为缩小增量排序。
【实现代码】
/**
* 希尔排序
* @param array 待排序的数组
*/
public void shellSort(int[] array){
if(array==null || array.length <= 1)return;
//gap是步长,初始值是数组长度的一半
int len = array.length, gap = len >> 1, i, j, k, t;
while (gap>0){
//步长为gap时,把整个数组分成了gap组,对每一组元素分别使用插入排序进行排序
//当gap为1时,整个数组被分为1组,相当于直接插入排序了
for (i=1; i<=gap; ++i){
for(j=i-1+gap; j<len; j+=gap){
k = j-gap;
t = array[j];
while (k >= i-1 && array[k] > t){
array[k+gap] = array[k];
k-=gap;
}
array[k+gap] = t;
}
}
//步长缩短
gap>>=1;
}
}
视频演示
希尔排序
【时间复杂度】
希尔排序是插入排序的升级版本,它比插入排序快的原因主要在于下面的两点原因:
- 当gap较大(例如:数组长度的一半)的时候,数组被分为gap组,每组中的元素个数很少(例如:gap为数组长度的一半时,每组中只有两个元素),插入排序在数据规模小的时候效率较高(按照最坏情况考虑,当数据规模较小时,复杂度 O ( N ) O(N) O(N)和 O ( N 2 ) O(N^2) O(N2)差别不大);
- 当gap较小的时候,数组被分为的组数较少,每组的元素个数较多(当gap为1时,数据被分为1组),但此时数组已经基本有序,插入排序在数据基本有序的时候效率较高;几乎是线性的。
准确来说,希尔排序的时间复杂度跟步长的选择方法有关,步长选择不好的话,将严重影响排序的效率(有可能比直接插入排序还要低),步长的选择应遵循以下原则:
- 最后一个步长是1,(如果不是1,只能保证最后的数组是以某个步长有序的,不能保证整个数组是有序的);
- 应该尽量避免步长序列(尤其是两个相邻的步长)互为倍数的情况,(个人理解是因为如果步长成倍数关系,例如10和5,当以步长为10排序完成后,再以步长为5进行排序,就会有一半的元素被再次排序,会造成一定的重复工作,所以应尽量避免这种操作。)
假设数组长度 N N N,那么在上文中实现的算法中,是依次取 N 2 \frac{N}{2} 2N, N 4 \frac{N}{4} 4N,… , N 2 i \frac{N}{2^i} 2iN,来作为每次排序的步长的,这种选择方式叫做shell增量序列,这种步长的选择方法不是最优的方法,常见的步长选择方法及其对应的时间复杂度如下:
- shell增量序列,公式: N 2 i \frac{N}{2^i} 2iN,最坏情况下时间复杂度 O ( N 2 ) O(N^2) O(N2)
- Hibbard增量序列,公式: 2 k − 1 2^k-1 2k−1,举例:1,3,7,15,31, 63, 127,…,最坏情况下时间复杂度 O ( N 3 2 ) O(N^{\frac{3}{2}}) O(N23),平均时间复杂度 O ( N 5 4 ) O(N^{\frac{5}{4}}) O(N45),
- Sedgewick增量序列,公式: min { 9 ∗ 4 i − 9 ∗ 2 i + 1 , 4 k − 3 ∗ 2 k + 1 } , ( i > = 0 , k > = 0 ) \min\{9*4^i-9*2^i+1, 4^k-3*2^k+1\}, (i>=0,k>=0) min{9∗4i−9∗2i+1,4k−3∗2k+1},(i>=0,k>=0),举例:1, 5, 19, 41, 109, 209, 505, 929, 2161…,最坏情况下时间复杂度 O ( N 4 3 ) O(N^{\frac{4}{3}}) O(N34),平均时间复杂度 O ( N 7 6 ) O(N^{\frac{7}{6}}) O(N67),目前已知复杂度最低的增量序列。
想要弄清关键词比较次数和记录移动次数与增量选择之间的关系,并给出完整的数学分析,今仍然是数学难题。还有很多增量序列的选择方式,大家可以参考下面的文章,这里就不详细展开了。
希尔排序(百度百科)
shell排序(百度百科)
排序算法之希尔排序及其增量序列
希尔排序增量序列简介
【空间复杂度】
O
(
1
)
O(1)
O(1)
【稳定性】
因为希尔排序是跳跃比较并交换元素的,很容易举出反例,因此算法不稳定。
5. 冒泡排序
【算法思想】
冒泡排序的思想是把待排序的数组的每一个元素看作一个重量等于元素的值的气泡,值越小的元素对应的气泡越轻,反之越重。根据轻气泡上浮,重气泡下沉的道理,不断扫描待排序元素,如果两个元素违反了上述原则,就把他们交换,直到任意两个元素都满足上述规则为止。
【实现代码】
/**
* 冒泡排序,从小到大
* @param array 待排序的数组
*/
public void bubbleSort(int[] array){
if(array==null || array.length <= 1)return;
int len = array.length, i, j, t;
boolean changed;
//枚举冒泡边界
for(i=len-1; i>=1; --i){
changed = false;
for(j=1; j<=i; ++j){
if(array[j-1]>array[j]){
//不符合轻在上,重在下的原则,则交换(轻重只是个逻辑概念,我们在这里把值大的元素上浮)
t = array[j-1];
array[j-1] = array[j];
array[j] = t;
changed = true;
}
}
//如果这一轮没有发生元素交换,则排序完成
if(!changed)return;
}
}
gif演示:
冒泡排序的改进1:
例如一个数组冒泡到某一轮的时候,是这样的{1,2,3,4,5,6,9,8,7}
,其实1到6已经是有序的了,我们下次再扫描的时候,就不需要从第一个开始,直接从9和8开始比较即可。其实在每一趟的比较中,只需要记住第一次发生交换的位置,那么这个位置之前的元素都是已经有序的了。同样道理,每一轮排序中最后一次发生交换的位置以后的元素都是有序的,也没有必要再次比较。例如数组{1,3,2,4,5,6,7}
,第一轮比较之后,最后一次比较发生在2和3之间,因此下一次只需要比较到2即可,没必要把后面的3到7都比较一遍,改进代码如下:
/**
* 冒泡排序 改进1
* @param array 等待排序的数组
*/
public void improvedBubbleSort(int[] array){
if(array==null || array.length<=1)return;
//firstChangedIndex记录第一次发生交换的位置,初始化为1, lastChangedIndex记录最后一次发生交换的位置,初始化为len-1
int len = array.length, i, j, t, firstChangedIndex = 1, lastChangedIndex = len-1;
boolean changed;
for (i = lastChangedIndex; i>0; ){
changed = false;
for(j = firstChangedIndex; j<=i; ++j){
if(array[j-1] > array[j]){
t = array[j-1];
array[j-1] = array[j];
array[j] = t;
if(!changed){
//记录第一次发生交换的位置
firstChangedIndex = j;
}
//记录最后一次发生交换的位置
lastChangedIndex = j;
changed = true;
}
}
if(!changed)break;
//如果某一轮firstChangedIndex索引处的元素发生第一次交换,则array[0]...array[firstChangedIndex-2]的元素已经有序,下次从firstChangedIndex-1开始比较即可
firstChangedIndex = Math.max(1,firstChangedIndex-1);
//如果某一轮lastChangedIndex索引处的元素发生最后交换,则array[lastChangedIndex]...array[len-1]的元素已经有序,下次比较到lastChangedIndex-1即可
i = lastChangedIndex-1;
}
}
冒泡排序的改进2:
冒泡排序有时候是不对称的,例如数组{10,1,2,3,4,5,6,7,8,9}
,只需要一次扫描即可完成排序,但是对于数组{2,3,4,5,6,7,8,9,10,1}
则需要N-1次扫描扫描才可以,主要原因是我们冒泡的方向是单向的(从左到右),我们可以两个方向同时开始冒泡,来避免这种不对称性带来的低效;改进代码如下:
/**
* 冒泡排序双向冒泡优化
* @param array 待排序的数组
*/
public void doubleDirectionBubbleSort(int[] array) {
if (array == null || array.length <= 1) return;
int i, j, t, low = 0, high = array.length - 1;
boolean changed;
while (low < high) {
changed = false;
//正向冒泡
for (i = low; i < high; ++i) {
if (array[i] > array[i + 1]) {
t = array[i];
array[i] = array[i + 1];
array[i + 1] = t;
changed = true;
}
}
--high;
//反向冒泡
for (j = high; j > low; --j) {
if (array[j] < array[j - 1]) {
t = array[j];
array[j] = array[j - 1];
array[j - 1] = t;
changed = true;
}
}
++low;
if (!changed) break;
}
}
【时间复杂度】
冒泡排序的过程与选择排序很相似,都是每一轮选择一个最大值放在索引
N
−
1
,
N
−
2
,
.
.
.
,
1
N-1,N-2,...,1
N−1,N−2,...,1的位置上,只不过选择排序是基于选择的,冒泡排序是基于交换的。从上面的gif发现,冒泡排序在找到最大值的同时还把大的元素往前带,因此冒泡排序优于选择排序。
假设数组长度: N ∈ [ 2 , + ∞ ] N\in[2,+\infty] N∈[2,+∞]:
- 在最好的情况下,数组已经有序,因此只需要一遍扫描即可,时间复杂度 O ( N ) O(N) O(N);
- 最坏的情况下,数组反序,因此需要 N − 1 N-1 N−1遍扫描,第 i ( i ∈ [ 1 , N − 1 ] ) i(i\in[1,N-1]) i(i∈[1,N−1])次扫描需要比较次数 N − i N-i N−i,因此总的比较次数为: ∑ i = 1 N − 1 N − i = N ( N − 1 ) 2 \sum_{i=1}^{N-1}{N-i}=\frac{N(N -1)}{2} ∑i=1N−1N−i=2N(N−1),时间复杂度 O ( N 2 ) O(N^2) O(N2)。
- 平均时间复杂度: O ( N 2 ) O(N^2) O(N2)。
由于平均情况下,时间复杂度达到 O ( N 2 ) O(N^2) O(N2),所以实际生产使用中一般不会采用冒泡排序。
【空间复杂度】
O ( 1 ) O(1) O(1)
【稳定性】
在冒泡时,如果两个数是严格的大于或小于关系时才交换,此时是稳定的,否则不稳定。
冒泡排序一次只能让当前元素和相邻的元素在不符合顺序的时候发生交换,交换步长是1,如果一个数很大,我们能否一次让它多移动几个位置呢?或者说把大的元素限定在一个范围内?请看下面的快速排序。
6. 快速排序
【算法思想】
基于分治的思想,选择一个枢轴元素(Pivot),把大于Pivot的元素放在它右边,小于等于Pivot的元素放在它的左边,这样的一次操作称为一次partition,对于Pivot左右两边的区间分别递归进行partition操作,如果某个区间的长度等于1,则partition返回。
【实现代码】
快速排序的递归主程序很好实现,代码如下:
/**
* 快速排序,保证start,end不会越界
* @param array 等待排序的数组
* @param start 排序范围的开始下表
* @param end 排序范围的结束下表
*/
public void quickSort(int[] array,int start,int end){
if (array == null || array.length <= 1 || end<=start) return;
int pivot = partition(array,start,end);
quickSort(array,start,pivot-1);
quickSort(array,pivot+1,end);
}
一般来说,枢轴元素选取数组的第一个元素,对于partition方法有好多种实现方式,下面介绍几种:
- 双指针遍历
代码如下:/** * 双指针partition * @param array 等待partition数组 * @param low 排序左边界 * @param high 排序右边界 * @return 枢轴下标 */ private int partition(int[] array, int low, int high) { //用指针low指向数组的左边界,high指向有边界,暂存枢轴元素 int pivot = low,base = array[pivot]; while (low<high){ //当前元素大于等于base,high左移 while (low<high && array[high]>=base)--high; //当前元素小于等于base,low右移 while (low<high && array[low]<=base)++low; //当low小于high时,第一个while停下来的条件是遇到了小于base的元素,同理第二个while停下来的条件是遇到了大于base的元素,于是二者交换 if(low<high)swap(array,low,high); } //最外层的while每进行一轮,low下标及其左边的元素,都小于等于base,不会出现low==high时,low下标处的元素大于base // 因此当low high相遇时,把low处的元素和pivot交换,返回low或者high即可。 swap(array,pivot,low); return low; } private void swap(int[] array, int i, int j) { if(i!=j && array[i]!=array[j]){ int t = array[i]; array[i] = array[j]; array[j] = t; } }
- 双指针遍历2,其实交换需要三次赋值操作,不如直接覆盖,代码如下:
/** * 双指针partition * @param array 等待partition数组 * @param low 排序左边界 * @param high 排序右边界 * @return 枢轴下标 */ private int partition(int[] array, int low, int high) { int base = array[low]; while (low<high){ while (low<high && array[high]>=base)--high; //当low小于high,且当前元素小于base时,就把当前元素赋值到low即可; if(low<high)array[low++] = array[high]; while (low<high && array[low]<=base)++low; //当low小于high,且当前元素大于base时,就把当前元素赋值到high即可, if(low<high)array[high--] = array[low]; } //最后把base覆盖即可 array[low] = base; return low; }
- 双指针同相遍历
过程如下图所示:/** * 双指针单向遍历 * @param array 等待partition数组 * @param low 排序左边界 * @param high 排序右边界 * @return 枢轴下标 */ private int partition(int[] array, int low, int high) { //j是当前元素的下标,low是最后一个小于枢轴元素的下标,即小于枢轴元素的边界,初始化为start int j = low+1, base = array[low],pivot = low; while (j<=high){ //如果当前元素大于base,则跳过访问下一个元素即可,否则发现了一个小于base的元素,则更新边界 if(array[j]<base){ ++low; //如果low不等于j,说明在low后面发现了小于base的元素,并且此时low处的元素大于等于base,则交换即可 if(low!=j)swap(array,low,j); } ++j; } //最后把枢轴元素和low当前元素交换 swap(array,low,pivot); return low; }
快速排序优化,我们基于以下思路优化快速排序:
-
每次枢轴元素都选取第一个,或者数组最后一个,这样的选取方法是不好的,例如原数组有序的时候,我们每次都选取第一个元素作为枢轴,这样小于枢轴的区间没有元素,剩下的所有元素都在大于等于枢轴的部分,这样的分治几乎没有意义,造成快速排序的时间复杂度比插入排序还要糟糕,一次在选取枢轴元素上,我们可以使用以下方法:
- 随机选取枢轴元素;
- 三数取中法,即选取排序数组的开始边界,结束边界和中间的数,这三个数的中位数作为枢轴;
-
当递归到某一轮,数组规模较小,且基本有序时,插入排序能够出色的完成任务,例如当数组规模小于等于10时,使用插入排序,参考《数据结构与算法分析》
-
当数组中大部分都是重复元素dup时(例如1000个5,1个10,1个8),我们选择一个枢轴,这个枢轴是dup的可能性会很大,当我们partition数组时,大量的dup元素会被划分到单个区域,这样又一次失去了分治的意义了。基于这一点,我们可以:
- 把等于枢轴的元素都几乎均等的分配到两个区间中,这就是二路快排,代码如下:
/** * 二路partition,与单路partition的区别在于low high指针移动的条件在于当前元素是否严格的大于小于base * @param array 等待partition数组 * @param low 排序左边界 * @param high 排序右边界 * @return 枢轴下标 */ private int partition2Way(int[] array, int low, int high){ //因为array[low]处是枢轴元素,所以low从low+1开始 int pivot = low, base = array[low++]; while (low<=high){ //当前元素严格大于base,high左移 while (low<=high && array[high]>base)--high; //当前元素严格小于base,low右移 while (low<=high && array[low]<base)++low; //如果此时array[high]==base,且array[low]==base,这样就会把相等的元素大致平均地分到两个区间 if(low<=high)swap(array,low++,high--); } //下标小于low的元素都是小于等于base的,当low大于high时,high下标处的元素小于等于base,但是low(low不越界情况下)处的元素一定不小于base,因此把high和pivot处的元素交换,返回high swap(array,pivot,high); return high; }
- 把枢轴分配到两个分区中,不如直接把等于枢轴的元素集中起来,下次partition时,跳过重复元素,这就是三路快排,代码如下:
/** * 三路快速排序,保证start,end不会越界 * @param array 等待排序的数组 * @param start 排序范围的开始下标 * @param end 排序范围的结束下标 */ public void quickSort3Way(int[] array,int start,int end){ if (array == null || array.length <= 1 || end<=start) return; //pivotLt,pivotGt和i把数组划分成了四个区间, // pivotLt及其左边的都小于base, // pivotGt及其右边都大于base, // pivotLt和i之间是等于枢轴的元素, // i到pivotGt之间是没有遍历到的元素 int pivotLt = start, pivotGt = end+1, base = array[start],i = start+1; while (i<pivotGt){ //如果当前元素小于base,首先pivotLt扩大,然后把array[i]和array[pivotLt]交换,最后i前进 if(array[i]<base) swap(array,i++,++pivotLt); else if(array[i]>base) //如果当前元素大于base,首更新区间边界,pivotGt缩减,然后把array[i]和array[pivotGt]交换,此时i不增加,因为i到pivotGt之间的元素还没有遍历 swap(array,i,--pivotGt); else //等于base的就跳过 ++i; } //最后交换枢轴 swap(array,start,pivotLt); //递归下一步 quickSort(array,start,pivotLt-1); quickSort(array,pivotGt,end); }
-
使用CPU多核心的能力,多线程处理两个分区。
小结:一般来说,采用三数取中获取枢轴,使用三路快排再结合插入排序,可以达到不错的性能了,实际生产环境也是多种排序配合使用,发挥各种排序算法的优势。
参考:
三种快速排序以及快速排序的优化
快速排序以及优化(单路排序、二路排序以及三路排序)
gif演示:
【时间复杂度】
快速排序在一次partition后,至少找到了一个关键字的所在的最终位置,通过分治同时把数组划分成了大于和小于关键字的两个区间,把问题的解限制在了比较小的范围内,因此比冒泡排序效率要好。
最好情况下时间复杂度:
O
(
N
log
2
N
)
O(N\log_{2}{N})
O(Nlog2N)
最坏情况下时间复杂度:
O
(
N
2
)
O(N^2)
O(N2)
平均时间复杂度:
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN)
对于时间复杂度的说明,前方高能,不感兴趣者跳过,设数组长度为 n n n,快速排序时间复杂度设为 T ( n ) T(n) T(n),快速排序采用了分治的思想,其实主要是分的过程,因为分到一定程度就不用治了(只有一个元素的数组本身就有序),因此需要知道分了多少次,以及每次分的时间复杂度,partition过程的时间复杂度是线性的,即 O ( n ) O(n) O(n)
- 最好情况下,partition函数每次把数组分成两个长度相等的子数组,那么T(n)可以表示为:
T ( n ) = 2 T ( n 2 ) + O ( n ) ; T(n) = 2T(\frac{n}{2})+O(n); T(n)=2T(2n)+O(n);
迭代展开:
T ( n ) = 2 ( 2 ( T ( n 4 ) + O ( n 2 ) ) ) + O ( n ) = 4 ( T ( n 4 ) ) + 2 O ( n ) = 2 2 T ( n 2 2 ) + 2 log 2 2 O ( n ) T ( n ) = 4 ( 2 ( T ( n 8 ) + O ( n 4 ) ) ) + 2 O ( n ) = 8 ( T ( n 8 ) ) + 3 O ( n ) = 2 3 T ( n 2 3 ) + 2 log 2 3 O ( n ) ⋯ T ( n ) = 2 log 2 n T ( n 2 log 2 n ) + 2 log 2 ( log 2 n ) O ( n ) = n T ( n n ) + log 2 n O ( n ) = n T ( 1 ) + O ( n log 2 n ) T(n) = 2( \quad 2(\quad T(\frac{n}{4})+O(\frac{n}{2})\quad) \quad ) + O(n) = 4(T(\frac{n}{4}))+2O(n)=2^2T(\frac{n}{2^2})+2^{\log_22}O(n)\\ \ \\ T(n) = 4(\quad 2(\quad T(\frac{n}{8})+O(\frac{n}{4}) \quad)\quad)+2O(n) =8(T(\frac{n}{8}))+3O(n)= 2^3T(\frac{n}{2^3})+2^{\log_23}O(n)\\ \cdots\\ T(n) = 2^{\log_2n}T(\frac{n}{2^{\log_2n}})+2^{\log_2(\log_2n)}O(n) = nT(\frac{n}{n})+\log_2nO(n)= nT(1) + O(n\log_2n) T(n)=2(2(T(4n)+O(2n)))+O(n)=4(T(4n))+2O(n)=22T(22n)+2log22O(n) T(n)=4(2(T(8n)+O(4n)))+2O(n)=8(T(8n))+3O(n)=23T(23n)+2log23O(n)⋯T(n)=2log2nT(2log2nn)+2log2(log2n)O(n)=nT(nn)+log2nO(n)=nT(1)+O(nlog2n)
其中T(1)的时间是常量,设为c,所以最终的时间复杂度为:
T ( n ) = c n + O ( n log 2 n ) = O ( n log 2 n ) T(n) = cn + O(n\log_2n) = O(n\log_2n) T(n)=cn+O(nlog2n)=O(nlog2n)
即,最好情况下,partition函数把数组分成等长的两个子数组,然后递归的对这两个子数组排序,当它递归到 log 2 n \log_2n log2n层时,就剩下一个元素,不用排序,直接返回。因此快速排序函数总共被调用
∑ i = 0 log 2 n 2 i = 2 0 ∗ ( 1 − 2 log 2 n ) 1 − 2 = n − 1 \sum_{i=0}^{\log_2n}{2^i} = \frac{2^0*(1-2^{\log_2n})}{1-2} = n-1 i=0∑log2n2i=1−220∗(1−2log2n)=n−1次(算上最开始被调用的一次),递归深度 log 2 n \log_2n log2n。- 最坏情况下,就是partition每次划分,得到的大于基准和小于基准的区间,其中一个区间的长度为0,那么需要划分n-1次,才可以解决问题,并且第i次划分,需要经过一次时间复杂度为 n − i n-i n−i的线性扫描,所以总的时间复杂度为:
T ( n ) = ∑ i = 1 n − 1 n − i = ( n − 1 + ( n − ( n − 1 ) ) ) ( n − 1 ) 2 = n ( n − 1 ) 2 = O ( n 2 ) T(n)=\sum_{i=1}^{n-1}{n-i} = \frac{(n-1\quad +\quad (n-(n-1))\quad )(n-1)}{2} = \frac{n(n-1)}{2} = O(n^2) T(n)=i=1∑n−1n−i=2(n−1+(n−(n−1)))(n−1)=2n(n−1)=O(n2)- 平均情况下,partition函数把原数组分成两个子数组,设小于基准的数组的长度为 k k k,从而大于基准的数组长度 n − 1 − k n-1-k n−1−k, k ∈ [ 0 , n − 1 ] k\in[0,n-1] k∈[0,n−1],并且假设k的任意一个取值的可能性都是相等的,那么平均情况下的时间复杂度可以为:
T ( n ) = 1 n ∑ i = 0 n − 1 [ T ( i ) + T ( n − 1 − i ) ] + O ( n ) = 2 n ∑ i = 0 n − 1 T ( i ) + O ( n ) T(n) = \frac{1}{n}\sum_{i=0}^{n-1}{[T(i)+T(n-1-i)]}+O(n) = \frac{2}{n}\sum_{i=0}^{n-1}{T(i)}+O(n) T(n)=n1i=0∑n−1[T(i)+T(n−1−i)]+O(n)=n2i=0∑n−1T(i)+O(n)
令:
S ( n ) = ∑ i = 0 n T ( i ) S(n) = \sum_{i=0}^{n}{T(i)} S(n)=i=0∑nT(i)
那么:
T ( n ) = 2 n S ( n − 1 ) + O ( n ) T(n) = \frac{2}{n}S(n-1)+O(n) T(n)=n2S(n−1)+O(n)
去分母:
n T ( n ) = 2 S ( n − 1 ) + O ( n 2 ) ( 1 ) nT(n) = 2S(n-1)+O(n^2)\quad\quad\quad\quad(1) nT(n)=2S(n−1)+O(n2)(1)
用n-1替换n:
( n − 1 ) T ( n − 1 ) = 2 S ( n − 2 ) + O ( ( n − 1 ) 2 ) ( 2 ) (n-1)T(n-1) = 2S(n-2)+O((n-1)^2)\quad\quad\quad\quad(2) (n−1)T(n−1)=2S(n−2)+O((n−1)2)(2)
(2)-(1)得:
n T ( n ) − ( n − 1 ) T ( n − 1 ) = 2 ( S ( n − 1 ) − S ( n − 2 ) ) + O ( n ) nT(n) - (n-1)T(n-1) = 2(S(n-1)-S(n-2))+O(n) nT(n)−(n−1)T(n−1)=2(S(n−1)−S(n−2))+O(n)
代入 S ( n ) S(n) S(n)并整理:
n T ( n ) = ( n + 1 ) T ( n − 1 ) + O ( 2 n ) nT(n) = (n+1)T(n-1)+O(2n) nT(n)=(n+1)T(n−1)+O(2n)
左右两边同时除以 n ( n + 1 ) n(n+1) n(n+1),
T ( n ) n + 1 = T ( n − 1 ) n + O ( 2 n + 1 ) \frac{T(n)}{n+1} = \frac{T(n-1)}{n}+O(\frac{2}{n+1}) n+1T(n)=nT(n−1)+O(n+12)
从而:
T ( n − 1 ) n = T ( n − 2 ) n − 1 + O ( 2 n ) T ( n − 2 ) n − 1 = T ( n − 3 ) n − 2 + O ( 2 n − 1 ) ⋯ T ( 2 ) 3 = T ( 1 ) 2 + O ( 2 3 ) \frac{T(n-1)}{n} = \frac{T(n-2)}{n-1}+O(\frac{2}{n})\\ \frac{T(n-2)}{n-1} = \frac{T(n-3)}{n-2}+O(\frac{2}{n-1})\\ \cdots\\ \frac{T(2)}{3} = \frac{T(1)}{2}+O(\frac{2}{3}) nT(n−1)=n−1T(n−2)+O(n2)n−1T(n−2)=n−2T(n−3)+O(n−12)⋯3T(2)=2T(1)+O(32)
上面的等式左右两边相加,整理忽略常数项:
T ( n ) n + 1 = O ( 2 ∑ i = 3 n + 1 1 n ) \frac{T(n)}{n+1} = O(2\sum_{i=3}^{n+1}{\frac{1}{n}}) n+1T(n)=O(2i=3∑n+1n1)
当n趋于无穷大时:
T ( n ) n + 1 = 2 O ( ∫ 3 n + 1 1 x d x ) = 2 O ( ln ( n + 1 ) − ln 3 ) = O ( 2 ln n ) \frac{T(n)}{n+1} = 2O(\int_3^{n+1}\frac{1}{x} dx) = 2O(\ln{(n+1)}-\ln3) = O(2\ln{n}) n+1T(n)=2O(∫3n+1x1dx)=2O(ln(n+1)−ln3)=O(2lnn)
所以:
T ( n ) = 2 ( n + 1 ) O ( ln n ) = O ( n ln n ) T(n) = 2(n+1)O(\ln{n}) = O(n\ln{n}) T(n)=2(n+1)O(lnn)=O(nlnn)
【空间复杂度】
快速排序的空间复杂度于递归栈的深度有关,
最好情况:
log
2
N
\log_{2}{N}
log2N,
最坏情况:
O
(
N
)
O(N)
O(N),
平均情况:
log
2
N
\log_{2}{N}
log2N
【稳定性】
快速排序交换元素是跳跃式的,因此不稳定,例如三路快排,当遍历到当前元素小于枢轴时,需要和pivotLt下元素交换,pivotLt下的元素此时很有可能等于枢轴,这样就交换了两个相等记录的相对位置。
因此,不稳定。
7. 归并排序
【算法思想】
归并排序也是基于分治的思想,与快速排序不同的是,快速排序重点在分,而归并排序重点在治。排序过程是,如果数组长度小于等于1,则不用排序直接返回,否则把数组分成长度相等的两个子数组(如果数组长度是奇数,则有一个子数组多一个),递归的对着两个子数组分别排序,然后再把排好序的子数组进行合并。
【实现代码】
public void mergeSort(int[] array,int low, int high,int[] temp){
if(array.length<=1 || high<=low)return;
int mid = (low+high)>>1;
//递归地对划分的数组排序
mergeSort(array,low,mid,temp);
mergeSort(array,mid+1,high,temp);
//合并
merge(array,low,mid,high,temp);
}
private void merge(int[] array, int low, int mid, int high, int[] temp){
if(array[mid] <= array[mid+1])return;
int t=0, i = low, j = mid+1;
while (i<=mid && j<=high) {
if (array[i] <= array[j])
temp[t++] = array[i++];
else temp[t++] = array[j++];
}
while (i<=mid)
temp[t++] = array[i++];
while (j<=high)
temp[t++] = array[j++];
t = 0;
i = low;
//拷贝
while (i<=high)array[i++] = temp[t++];
}
这是自顶向下的实现方式,通过代码可以知道,排序过程首先对数组不断对半划分,直到某个子数组的长度等于1时开始回升,在回升的过程中不断调用merge方法合并两个有序数组,达到整体有序,这个过程中递归深度是 log 2 N \log_{2}{N} log2N,我们可以采用自底向上的方式,通过迭代消除递归,节省空间,思路如下:第一次把每两个元素看作一组,第二次把每4个元素看作一组,…,直到最后把整个等待排序的数组看作一组,最终达到整体有序,代码如下:
public void mergeSortFromBottomUp(int[] array,int low, int high,int[] temp){
if(array.length<=1 || high<=low)return;
//step就是每一轮中 排序小组的长度,从2开始 依次为2,4,8,16,... 但是要小于待排序记录的长度的二倍
//i和j分别是每一个排序小组的左右边界
int len = high-low+1,step = 2, i, j, doubleLen = len<<1, mid;
for(; step<doubleLen; step<<=1){
for(i=0;i<len;i+=step){
//右边界可能超过数组长度,所以截取
j = Math.min(len-1,i+step-1);
mid = i+(step>>1)-1;
//mid值也有可能超过数组边界,只有mid小于右边界时,才合并,否则无需合并
if(mid<j){
merge(array,i,mid,j,temp);
}
}
}
}
gif动图演示,这个演示的是递归版本的。
【时间复杂度】
假设数组长度
N
N
N,归并排序的时间复杂度
T
(
N
)
T(N)
T(N),根据归并排序的思想,是把一个数组均分成两段,然后对这两段递归排序,然后再把结果合并起来,那么可以知道:
排序的总时间 = 递归排序字数组的时间+合并结果的时间
合并结果我们只需要一次线性扫描即可完成任务,时间复杂度 O ( N ) O(N) O(N),即:
T
(
N
)
=
2
O
(
N
2
)
+
O
(
N
)
T(N) = 2O(\frac{N}{2}) +O(N)
T(N)=2O(2N)+O(N)
可以参考前面关于快速排序的最好时间复杂度证明,可知:
T
(
N
)
=
c
N
+
O
(
n
log
2
N
)
=
O
(
N
log
2
N
)
T(N) = cN + O(n\log_2N) = O(N\log_2N)
T(N)=cN+O(nlog2N)=O(Nlog2N)
归并排序没有用到数组的已经有序的部分,因此不分最好和最坏的情况,时间复杂度都是
O
(
N
log
2
N
)
O(N\log_2N)
O(Nlog2N)。
根据这个时间复杂度公式,我们可以知道优化归并排序的思路,当我们把数组一次均分为2等份时,归并排序的执行过程可以看作一个二叉树,当我们把数组一次均分为3份,4份时,…, m m m份时,那执行过程就是一颗3、4、m叉树,相应的时间复杂度也是 O ( N log m N ) O(N\log_mN) O(NlogmN),在一定程度上可以降低时间复杂度。
【空间复杂度】
对于数组来说,排序的时候需要一个辅助数组进行合并,并且如果是递归实现的话,还需要
O
(
log
2
N
)
O(\log_2N)
O(log2N)的递归栈空间,因此空间复杂度
O
(
N
)
O(N)
O(N)。
如果你对空间的要求比较苛刻,可以采用原地归并,即merge函数不使用辅助的空间。方法如下:
假设归并的数组为array,起始位置s,中间位置mid,结束位置e,
- i = s,j = mid
- 从i开始,如果i<mid && array[i]<=array[j],则++i;假设存在某一个i使得array[i]>array[j];停下来
- 如果 j<=e && array[j]<array[i],则++j;假设存在某一个j使得array[j]>array[i];停下来
- 此时array[s]…array[i-1]都小于等于array[mid],且array[mid]…array[j-1]都小于array[i],且这两段本身是有序的,因此array[s]…array[i-1],array[mid]…array[j-1]这两段组合起来,整体是有序的。因此我们可以把array[i]…array[mid-1]与array[mid]…array[j-1]这两段交换一下位置,使得array[s]…array[i-1],array[mid]…array[j-1]拼成一整段,然后i+(j-1-mid+1),后移
- 此时我们只需要把array[i]…array[j-1]、array[j]…array[e]这两个有序数组合并即可,这个问题是原问题的子问题,因此可以考虑递归或者迭代解决。
如何把两个相邻的子数组交换一下位置呢?假设有一个数组arr,长度为n,把arr[0]…arr[mid]和arr[mid+1]…arr[n-1]交换下位置,有下面的几种方法:
- 暴力破解,不说了,大家都容易想到的做法
- 循环移动,我们使用一个辅助空间t把arr[0]移动到t,然后从i从1开始到n-1,arr[i-1] = arr[i];最后把t给arr[n-1],这样就实现了数组循环移动,只需要这样重复执行mid+1次即可,时间复杂度 O ( n 2 ) O(n^2) O(n2)
- 数组反转,首先把arr[0]…arr[mid]反转,然后把arr[mid+1]…arr[n-1]反转,最后把整个数组反转即可,大家多验证几次就明白了其中的道理,时间复杂度 O ( n ) O(n) O(n)
至于如何把数组反转,很简单,不再赘述。
原地归并排序的空间复杂度 O ( 1 ) O(1) O(1),时间复杂度 O ( N 2 log 2 N ) O(N^2\log_2N) O(N2log2N)
归并排序同样适用于链表,当对链表排序时,空间复杂度 O ( 1 ) O(1) O(1)。关于链表排序,大家可以在leetcode上练习,传送门。
【稳定性】
当两个有序数组合并时,遇到相等的元素时且如果此时判断逻辑是小于等于,那么相元素的相对位置不会改变,因此归并排序是稳定的。
8. 计数排序
【算法思想】
利用查找表的思想,即统计元素出现的次数,然后依次输出各个元素即可。
【实现代码】
public void countSort(int[] array){
if(array==null || array.length<=1)return;
int min = array[0], max = min, i, j, len = array.length;
for(i=1; i<len; ++i){
if(array[i]<min)min = array[i];
if(array[i]>max)max = array[i];
}
//计数数组保存了每一个元素出现的次数,存在下标为 当前元素-最小值
int[] count = new int[max-min+1];
for(i=0; i<len; ++i) ++count[array[i]-min];
j = 0;
//从小到大把各个元素输出
for(i=0; i<max-min+1; ++i){
while (count[i]>0){
array[j++] = i+min;
--count[i];
}
}
}
这个实现是针对于整数排序,如果对对象数组排序,代码如下:
public void countSort(int[] array){
if(array==null || array.length<=1)return;
int min = array[0], max = min, i, len = array.length;
for(i=1; i<len; ++i){
if(array[i]<min)min = array[i];
if(array[i]>max)max = array[i];
}
//计数数组保存了每一个元素出现的次数,存在下标为 当前元素-最小值
int[] count = new int[max-min+1];
for(i=0; i<len; ++i) ++count[array[i]-min];
//这样做的目的是,count数组中保存到不再是每个元素出现的次数,而是array数组中的每个元素,在排好序的数组中的最终位置
for(i=1; i<max-min+1; ++i) count[i]+=count[i-1];
//这是结果数组
int[] result = new int[len];
//遍历原数组中的每一个元素,把他放在结果数组中,
//count[array[i]-min]-1代表它应该在结果数组中的位置
//count[array[i]-min]-- 每次放一个,就把其最终位置减一
// 从后往前遍历源数组,为的是保证排序的稳定性
for(i=len-1; i>-1; --i) result[--count[array[i]-min]] = array[i];
//拷贝结果
System.arraycopy(result,0,array,0,len);
}
gif演示
【时间复杂度】
计数排序的关键思想在于计数,假设数组长度为
N
N
N,最大值
m
a
x
max
max,最小值
m
i
n
min
min,在前面的代码中可以发现排序时对源数组扫描了3次,对计数数组扫描了一次,因此时间复杂度为
m
a
x
{
O
(
N
)
,
O
(
m
a
x
−
m
i
n
)
}
max\{O(N),O(max-min)\}
max{O(N),O(max−min)}
因此,当数组中的元素都集中在某一个较小的范围时,使用计数排序可以在线性时间内完成排序;当数组的元素极差(max-min)很大时(例如,1,2,5,2,1000000,就需要遍历1000000-1个元素),不适合使用。
【空间复杂度】
排序中申请了两个数组,一个是结果数组,一个是计数数组,因此空间复杂度:
m
a
x
{
O
(
N
)
,
O
(
m
a
x
−
m
i
n
)
}
max\{O(N),O(max-min)\}
max{O(N),O(max−min)}
因此,当数组中的元素都集中在某一个较小的范围时,计数排序空间复杂度
O
(
N
)
O(N)
O(N);当数组的元素极差(max-min)很大时,就会浪费很多空间。
不足之处,计数排序不能解决小数的问题
【稳定性】
稳定
9. 桶排序
【算法思想】
桶排序是在计数排序的基础上的扩展,计数排序是特殊的桶排序,只不过内一个桶都装相同的元素。排序方法是,根据元素的范围把元素放在不同的桶中,然后对各个桶分别排序,最终把各个桶的元素依次输出即可。
【实现代码】
public void bucketSort(int[] array){
if(array==null || array.length<=1)return;
int min = array[0], max = min, i, j, len = array.length;
for(i=1; i<len; ++i){
if(array[i]<min)min = array[i];
if(array[i]>max)max = array[i];
}
//确定桶个数
int bucketNumber = (max-min)/len+1;
List<?>[] buckets = new ArrayList<?>[bucketNumber];
List<Integer> list = null;
//根据当前元素的范围确定放在哪个桶
for(i=0; i<len; ++i){
list = (List<Integer>) buckets[(array[i] - min) / len];
if (list == null) list = new ArrayList<>();
list.add(array[i]);
buckets[(array[i] - min) / len] = list;
}
//对每个桶分别排序
for(i = 0; i<bucketNumber; ++i)
if ((list = (List<Integer>) buckets[i]) != null && list.size()>1) Collections.sort(list);
j = 0;
//结果输出
for(i = 0; i<bucketNumber; ++i){
if ((list = (List<Integer>) buckets[i]) != null) {
for (Integer integer : list) {
array[j++] = integer;
}
}
}
}
gif演示
【时间复杂度】
桶排序的时间主要是,把每一个元素放到不同的桶中的时间+对各个桶排序的时间。假设数组长度
N
N
N,桶的个数
M
M
M,假设
N
N
N个元素均匀的分在
M
M
M个桶中,每个桶中
N
M
\frac{N}{M}
MN个元素,对每个桶排序的算法时间复杂度设为
N
M
log
N
M
\frac{N}{M}\log\frac{N}{M}
MNlogMN,把每一个元素放到不同的桶中的时间复杂度
O
(
N
)
O(N)
O(N),所以总的时间复杂度:
T
(
N
)
=
O
(
N
)
+
M
N
M
log
N
M
=
O
(
N
)
+
N
log
N
M
T(N) = O(N) + M\frac{N}{M}\log\frac{N}{M} = O(N)+N\log\frac{N}{M}
T(N)=O(N)+MMNlogMN=O(N)+NlogMN
可见,时间复杂度与桶的个数有关,当桶的个数与数组的长度相等时,时间复杂度是
O
(
N
)
O(N)
O(N),当桶的个数大于数组的长度时是没有意义的,当桶的个数为1时,时间复杂度为
O
(
N
log
N
)
O(N\log{N})
O(NlogN),因此所有的数据在一个桶中(或者大部分元素都在一个或者少数几个桶中的时候),桶排序失效。
因此比较好的做法是,设置 N N N个桶,判断每个元素在哪个桶可以使用如下代码:
for(i=0; i<len; ++i){
list = (List<Integer>) buckets[(array[i] - min) / (max - min) * len];
//......
}
【空间复杂度】
把原数组元素都放进了桶中,需要
O
(
N
)
O(N)
O(N)的空间,开设
M
M
M个桶,需要
O
(
M
)
O(M)
O(M)的空间,因此空间复杂度
O
(
N
+
M
)
O(N+M)
O(N+M)。
【稳定性】
取决于对每一个桶排序的算法稳定性。
10. 基数排序
【算法思想】
基数排序的思路是,一位一位的排序,比如先排序各位,在排序十位,… ,最后在排序最高位(也可以反过来),那么如何排序每一位呢?因为对于十进制的数来说,每一位的范围是
[
0
,
9
]
[0,9]
[0,9],对于有范围的数排序,可以使用计数排序(也可以用其他方法,但是要保证是稳定的排序,想一想为什么?)。
【实现代码】
public void radixSort(int[] array){
if(array==null || array.length<=1) return;
int len = array.length, maxLength = maxLengthOf(array), index=0;
//count数组存储各个数位的个数,例如个位是2的有几个, bucket数组是辅助数组
int[] count = new int[10], bucket = new int[len], t, ans = array;
while (index<maxLength){
for (int i = 0; i < 10; i++)
count[i]=0;
//对index+1位上的数进行计数,例如index==0,那就是统计个位上不同元素的个数
for (int i = 0; i < len; i++)
++count[radixOf(array[i],index)];
//计数完毕后们需要把原来数组里的元素,按照桶中的顺序,整理使得按照第index+1位有序,例如index==0 就是按照个位有序
// 假如count[0]的值大于0,表示个位(index==0)为0的数至少一个,我们最终整理好的数组中,肯定前array[0]个都是个欸为0的,因此
//array[0]代表了最后一个个位为0的数,在整理好的数组中的下标+1的值,因此我们通过下面的操作,就可以知道个位为任何值的数在
//最终整理好的数组中的位置
//请参考前面的计数排序
for (int i = 1; i < 10; i++)
count[i]+=count[i-1];
//把元素组中的数整理成按照个位有序的状态(index==0),从后往前是为了保证稳定
for(int i = len-1; i>-1; --i)
bucket[--count[radixOf(array[i],index)]] = array[i];
if(index+1==maxLength){
if(bucket!=ans)
System.arraycopy(bucket,0,ans,0,len);
break;
}
t = array;
array = bucket;
bucket = t;
++index;
}
}
/**
* 获取数组中最大数的位数
* @param array 数组
* @return 最大的位数
*/
private int maxLengthOf(int[] array){
int max = array[0];
for (int i = 1; i < array.length; i++)
if(max<array[i])max = array[i];
return lengthOf(max);
}
/**
* 获取一个正整数的有几位
* @param num 正整数
* @return 位数
*/
private int lengthOf(int num){
int len = 0;
while (num!=0){
++len;
num/=10;
}
return len;
}
/**
* 获取正整数的第index+1位上的数字
* @param num 一个正整数
* @param index 第几位
* @return 第index+1位上的数字
*/
private int radixOf(int num,int index){
int[] table = {1,10,100,1000,10000,100000,1000000,10000000,100000000,1000000000};
return (num/table[index])%10;
}
【时间复杂度】
基数排序是对每一位分别排序,对任意一位排序的时间复杂的都需要线性扫描数组两次,第一次对当前位计数装桶,第二次是从桶中倒出来,整理成已当前位有序的形式,这样需要执行的次数取决于当前数组的最大值的位数,设数组长度
N
N
N,最大数的位数
R
R
R,则时间复杂度
O
(
R
N
)
O(RN)
O(RN),一般来说,R是一个比较小的数,因此时间复杂度近似
O
(
N
)
O(N)
O(N)。
【空间复杂度】
排序过程中需要申请一个与原数组等长的辅助数组和一个长度等于基数的桶,所以空间复杂度
O
(
N
+
r
)
O(N+r)
O(N+r),r是基数,对于十进制数,它就是10。
【稳定性】
稳定
11. 小结
以上10种排序算法总结如下:
图中堆排序的最好情况下的时间复杂的是
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN),写错了。
下面回答一个问题:
通过表格我们可以看到归并排序和堆排序的时间复杂度上界是 O ( N l o g N ) O(NlogN) O(NlogN),而快速排序的时间复杂度下界是 O ( N l o g N ) O(NlogN) O(NlogN),最坏情形下时间复杂度甚至达到 O ( N 2 ) O(N^2) O(N2),那快速排序快在哪?
-
快速排序比归并排序和堆排序比较的次数相对少
快速排序的一次partition把数组分为大于枢轴和小于枢轴的两部分,在左右两边递归的过程中,就不会有其他元素和枢轴相比的情况了,因为枢轴元素已经在最终的位置;
对于堆排序,每一次取堆顶元素的时候,都把最后一个元素放到新的堆顶,此时新的堆顶比其左右孩子小很多,需要经过过次比较才可以回到正确的位置;且在元素下沉的时候,父结点在和其子元素比较之前,其两个子节点要先比较,经过多次比较才换来一次有价值的交换;堆排序的大部分时间都花在了调整堆上;
归并排序中,当两个小数组合并完成后,还要参与后续更大数组的合并,还要进行比较,因此多做了无用功。虽然快排的平均时间复杂的也是 O ( N l o g N ) O(NlogN) O(NlogN),但是他的常数系数要小很多。 -
快速排序空间局部性更好
快排顺序遍历数组的时候,是顺序访问的,根据空间局部性原理,处理器可以预取数据到缓冲区,能够减少内存缺页中断的发生次数
堆排序访问数组元素的时候是跳跃的,不能很好的利用空间局部性。 -
快速排序达到最坏情况的概率小
假设数组长度 N N N,使用随机选择枢轴的方法,每一次的枢轴都使得数组划分后,所有的元素都划分在枢轴同一侧的概率是 2 N − 1 2^{N-1} 2N−1,况且通过例如三数取中法,双枢轴的方法,使得这样的概率更加小。所以说,一旦划分平衡,快排的时间复杂度就会很接近 O ( N l o g N ) O(NlogN) O(NlogN)。
因此,但数据量大的时候,堆排序的效率与快排相比,还是有很大差距的。
参考:快排为什么那样快,《算法艺术与信息学竞赛》
其他排序算法:
限于篇幅,还以一些排序算法列在这里,大家有兴趣可以自行搜索学习:
- 内省排序(C++ STL中使用的排序算法)
- 圈排序
- 图书馆排序
- 耐心排序
- Smooth Sort
- Strand Sort
- 鸽巢排序
- 鸡尾酒排序
- 地精排序
- 奇偶排序
- 梳排序
- 珠排序
- Proxmap Sort
- 锦标赛排序
- Bogo Sort(猴子排序)
- 二叉搜索树排序
排序算法的优缺点以及使用场景:
12. 排序算法的实际应用
没有最好的,万能的排序算法,在实际的应用中,都是结合具体的应用场景,采用各个排序算法的优势,取长补短。下面举几个例子:
- C++中的sort函数,使用的是结合了快速排序、堆排序以及插入排序的内省排序,这个排序算法在数组长度小于等于16的时候采用插入排序(插入排序在数组规模较小的时候效率较高)完成,否则使用快速排序进行排序,当由于枢轴选择不当,超过了递归的闸值的时候,就会改为堆排序(超过闸值意味着有可能导致最坏的时间复杂度 O ( N 2 ) O(N^2) O(N2),而堆排序在最坏情况下也保证 O ( N l o g N ) O(NlogN) O(NlogN)的时间复杂度)。详细请见:std::sort源码剖析。
- Tim Sort,这是一个稳定的排序算法,内部采用了插入插入排序和归并排序实现。基本方法是先对数组进行划分,对每一个子数组进行插入排序,然后对每个子数组采用归并排序合并。这个方法应用在Python、JDK7和Android SDK的对象排序中。详情请见:What is Timsort Algorithm?