内部排序算法思路与实现【附图解&复杂度分析】
内部排序
排序也称排序算法(Sort Algorithm),排序是将一组数据,依指定的顺序进行排列的过程。排序算法分为两类:
- 内部排序:将需要处理的所有数据都加载到内部存储器中进行排序。
- 外部排序:数据量过大,无法全部加载到内存中,需要借助外部存储进行排序。
排序算法根据稳定性可以分为:
- 稳定排序:相同元素在排序后的前后相对顺序保持不变。比如{5’,2,5’’},排序后为{2,5’,5’’},{5’} 与{5’’} 相对顺序没有改变。
- 非稳定排序: 相同元素在排序后的前后相对顺序可能发生了变化。比如{5’,2,5’’},排序后为{2,5’’,5’},{5’’} 与{5’} 相对顺序发生了改变。
1. 直接插入排序
插入排序包括直接插入排序和希尔排序。
对于插入排序,特征是进行元素间的比较。
算法思想:
对于待排序的数组,构建有序序列,对于无序序列中的每个元素,在有序序列中从后向前寻找到相应的位置进行插入。
例如,对于有序序列 {38,49,65,97} ,要插入元素 56,显然 56 要插入在 65 之前,则将 {65} , {97} 向后移动一位,将 {56} 插入在 {65} 之前,排序后数组为 {38,49,56,65,97}。
算法图解:对数组 [4,2,2,78,5,45] 进行直接插入排列:
算法稳定性:在直接插入排序中,对于相同的数据,可以设置条件确定插入位置,不必改变先后顺序。比如上例中,两个元素2的相对前后顺序没有变化。算法稳定。
代码实现:
/**
* 简单插入排序:稳定排序
* @param arr 传入的数组
*/
public static void insertSort(int[] arr){
int len=arr.length;
for(int i=1;i<len;i++){
int cur=arr[i];
//在有序序列中查找合适的插入位置
int j;
for(j=i-1;j>=0&&arr[j]>cur;j--){
arr[j+1]=arr[j];
}
//插入该元素
arr[j+1]=cur;
}
}
复杂度分析:
时间复杂度 O(N2)
最好情况为数组升序排列,只需要比较 n-1 次即可,时间复杂度 O(N)。
最坏情况为数组降序排列,需要比较 1+2+…+(n-1)=n×(n-1)/2 次,交换 n-1 次,时间复杂度为 O(N2)。
插入排序的平均时间复杂度为 O(N2)。
空间复杂度 O(1)
直接插入排序使用常数级别的额外空间。
算法特征:
- 适合少量数据的排序;算法稳定。
- 插入排序在对几乎已经排好序的数据操作时,效率高,可以达到线性排序的效率。
- 插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位。
2. 希尔排序
算法思想:
希尔排序又称缩小增量排序,是插入排序的改进形式。
考虑到直接插入排序在序列几乎排好序时效率达到线性排序级别,希尔排序依据不断递减的步长将待排序序列划分为若干子序列,对每个子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全部记录依次进行直接插入排序。
算法图解:对数组 [49’,38,65,97,76,13,27,49’’,55,04] 进行希尔排序:
算法稳定性:一次插入排序是稳定的,但是希尔排序对数据进行分组处理,分别进行插入排序,相同元素可能划分在不同组中,从而相对前后顺序会发生改变。比如上例中49’’ 与49’ 划分在不同组中,排序后顺序发生了变化。希尔排序不稳定。
代码实现:
/**
* 希尔排序:缩小增量排序,步长每次模2递减
* @param arr 待排序数组
*/
public static void shellSort(int[] arr){
int len=arr.length;
int gap=len/2;
//步长递减
while(gap>=1) {
/* 每次分为gap组 */
for (int i = 0; i < gap; i++) {
//对于每一组,进行直接插入排序
for (int j = i + gap; j < len; j += gap) {
int cur = arr[j];
int pre = j - gap;
while (pre >= 0 && arr[pre] > cur) {
arr[pre + gap] = arr[pre];
pre -= gap;
}
arr[pre+gap] = cur;
}
}
gap/=2;
}
}
复杂度分析:
时间复杂度 O(N log2 N)
希尔排序可以取不同的增量序列,相应的时间复杂度也不同;希尔排序最后一轮的增量必须为 1,保证排序完成后数组一定有序。
步长序列为 n/2i 时,最坏情况下退化为直接插入排序,时间复杂度为 O(N2)。
步长序列为 2k-1 时,最坏情况下时间复杂度为 O(N3/2)。
步长序列为 2i3j 时,最坏情况下时间复杂度为 O(N log2 N)。
希尔排序的渐进时间复杂度为O(N log2 N)。
空间复杂度 O(1)
希尔排序使用常数级别的额外空间。
3. 简单选择排序
选择排序包简单选择排序和堆排序。
对于选择排序,特征是进行元素间的比较与交换。
算法思想:
核心思想是比较,交换。在数组元素的比较中找到一个最小的元素,将其放在起始位置;之后每次从未排序序列中找到最小元素,将其放到已排序序列的末尾(即与未排序序列的首元素进行交换),直到所有元素均排序完毕。
算法图解:对数组 [4,5’,78,5’’,17,1] 进行简单选择排序:
算法稳定性:
在寻找最小元素,进行元素交换的过程中,可能导致相同元素的前后顺序发生变换。
例如:对于序列 (7) 2 5 9 3 4 [7] 1,用直接选择排序算法进行排序时候, (7) 和 1 调换, (7) 就跑到了 [7] 的后面了,原来的次序改变了,这样就不稳定了。如上例中的 5’ 与 5’’ 顺序改变。
选择排序不稳定。
代码实现:
/**
* 简单选择排序,不稳定
* @param arr 待排序数组
*/
public static void selectSort(int[] arr){
int len=arr.length;
for(int i=0;i<len-1;i++){
//已排序序列为[0,i),在未排序序列[i,len-1]中寻找最小元素下标
int min=i;
for(int j=i+1;j<len;j++){
if(arr[j]<arr[min]){
min=j;
}
}
//交换当前元素arr[i]与最小元素arr[min]
if(i!=min){
int temp=arr[i];
arr[i]=arr[min];
arr[min]=temp;
}
}
}
复杂度分析:
时间复杂度 O(N2)
无论初始序列如何,简单选择排序在每次寻找最小值的过程中总要与相邻元素进行比较,比较次数为 1+2+…+(n-1)= n×(n-1)/2,每次比较可能发生交换,最坏情况下交换次数为 n-1 。所以简单选择排序的时间复杂度为 O(N2)。
空间复杂度 O(1)
简单选择排序使用常数级别的额外空间。
4. 堆排序
堆
堆的结构为完全二叉树,分为大顶堆和小顶堆:
- 大顶堆:每个结点的值都大于或等于其左右子结点的值,即 arr[i] ≥ arr[2×i+1] && arr[i] ≥ arr[2×i+2] ,在堆排序中用来实现升序排列。
- 小顶堆:每个结点的值都小于或等于其左右子结点的值,即 arr[i] ≤ arr[2×i+1] && arr[i] ≤ arr[2×i+2] ,在堆排序中用来实现降序排列。
算法思想:
堆排序属于选择排序。
考虑将包含 N 个元素的数组进行升序排列,堆排序分为两步:
- 建堆。先将待排序的数组建成大顶堆,使得每个父节点都大于等于它的左右子节点,时间复杂度为 O(N)。
- 堆调整。将堆顶最大值与数组末尾元素交换,调整堆顶元素使得剩下的n-1个元素仍构成大顶堆,重复步骤2,直到得到一个有序序列,时间复杂度为 O(N log N)。
算法特征:首先构造大顶堆,之后进行 n-1 次循环,每次循环可以确定一个最大值放在当前序列末尾。
算法图解:对数组 [4,6,2,5,9’,9’’,1] 进行堆排序:
1.建堆
对于 N 个节点的完全二叉树,非叶子结点个数为 N/2 ,进行 N/2 次堆调整,最终构建的大顶堆为 [9’,6,9’’,5,4,2,1]。
2.堆调整
对于步骤1构建的大顶堆 [9’,6,9’’,5,4,2,1],此时堆顶元素为整个数组最大值,将其与末尾元素交换,从堆顶开始维护堆,重新寻找剩余元素中的最大值,将其交换到堆顶。
算法稳定性:
堆排序属于选择排序,特征是进行元素间的比较交换,在构造堆和调整堆的过程中,为了维护堆顶元素,可能导致相同元素的前后顺序发生变换。
如上例中对于数组 [4,6,2,5,9’,9’’,1] 进行堆排序,在建堆后 9’ 为堆顶元素,进行第 1 次堆调整后,把 9’ 作为排好序的元素放在数组末尾,导致 9’ 与 9’’ 的相对顺序改变。
堆排序不稳定。
代码实现:
/**
* 堆排序,不稳定排序
* @param arr 待排序数组
*/
public static void heapSort(int[] arr){
//step1:建造大顶堆
int len=arr.length;
for(int start=len/2-1;start>=0;start--){
maxHeapify(arr,start,len);
}
//step2:堆调整
for(int j=len-1;j>0;j--){
//交换arr[0],arr[j]
int temp=arr[0];
arr[0]=arr[j];
arr[j]=temp;
//堆调整
maxHeapify(arr,0,j);
}
}
/**
* 调整索引start处的数据,使以其为堆顶元素的堆符合大顶堆的性质
* @param arr 待排序数组
* @param start 起始处理坐标
* @param end 终止处理坐标
*/
public static void maxHeapify(int[] arr,int start,int end){
//记录堆顶元素下标
int pre=start;
for(int j=2*start+1;j<end;j=j*2+1){
//当前节点arr[pre]的左右子节点的较大者
if(j+1<end&&arr[j]<arr[j+1]){
j++;
}
//若子节点大于父节点,交换arr[j],arr[pre],调整堆顶元素
if(arr[j]>arr[pre]){
int temp=arr[j];
arr[j]=arr[pre];
arr[pre]=temp;
}
//更新父节点
pre=j;
}
}
复杂度分析:
时间复杂度 O(N log N)
堆排序的时间复杂度= 建堆+堆调整= O(N) + O(N log N) = O(N log N)。
<1> 建堆时间复杂度 O(N)
如:建立大顶堆
思路:假设有n个节点,那么根据堆是完全二叉树的结构,堆的最后一个非叶子结点下标为 n/2-1。
处理过程:
- 从最后一个非叶子结点开始,找出其叶子结点的最大值,若大于原来的非叶子节点,则交换。
- 非叶子结点下标依次递减,直到处理完所有的非叶子结点,完成大顶堆的构建。
建堆的时间复杂度为什么是O(N)?
假设n个节点构成了一棵完全二叉树,那么二叉树深度为 h = log2 n 。由于叶子结点默认有序,我们从下标最大的非叶子结点开始考虑。
那么第 h 层有 2(h-1) 个数据,交换次数为1 ;第 h-1 层有 2(h-2) 个数据,交换次数为 2,…,第 3 层有2(3-1) 个数据,交换次数为 h-2;第 2 层有 2(2-1) 个数据,交换次数为 h-1;第 1 层有 1 个数据,交换次数为 h。
设总交换次数为s,
式1:s=h×2(0)+(h-1)×2(1)+(h-2)×2(2)+…+3×2(h-3)+2×2(h-2)+1×2(h-1)
式1×2得到式2:
式2:2s=h×2(1)+(h-1)×2(2)+(h-2)×2(3)+…+3×2(h-2)+2×2(h-1)+1×2(h)
错位相减,式2-式1:
s=2(1)+2(2)+2(3)+…+2(h-1)+(2(h)-h)=2×2(h)-h-2=2×n-log2 n-2=O(N)
所以建堆的时间复杂度为 O(N)
<2> 堆调整时间复杂度 O(N log N)
注意到前面已经建好了大顶堆,其时间复杂度为O(N)。现在计算的是每次将堆顶元素与末尾数据交换,进行堆调整的过程。
假设n个节点构成了一棵完全二叉树,那么二叉树深度为 h = log2 n 。注意到我们每次都从根节点 arr[0] 向下判断,不同的是判断的深度在不断递减。即代码:maxHeapify(int[] arr,int start,int end),每次从根节点arr[0]开始调整堆,但是决定结束深度的end在不断变小。
那么第 h 层有 2(h-1) 个数据,交换次数为 h;第 h-1 层有 2(h-2) 个数据,交换次数为 h-1,…,第 3 层有 2(3-1) 个数据,交换次数为 3;第 2层有 2(2-1) 个数据,交换次数为 2;第 1 层有一个数据,交换次数为 1。
设总交换次数为s,
式1:s=1×2(0)+2×2(1)+3×2(2)+…+(h-2)×2(h-3)+(h-1)×2(h-2)+h×2(h-1)
式1×2:
式2:2×s=1×2(1)+2×2(2)+3×2(3)+…+(h-2)×2(h-2)+(h-1)×2(h-1)+h×2(h)
错位相减法,式2-式1:
s=h×2(h)-[2(1)+2(2)+2(3)+…+2(h-1)]-1=h×2(h)-2(h)-3=nlogn-n-3=O(nlog n)
所以堆调整的时间复杂度为 O(N log N)。
空间复杂度 O(1)
堆排序不要任何辅助数组,只需要几个辅助变量,所占空间是常数,所以空间复杂度为O(1)。
5. 冒泡排序
冒泡排序思路包括冒泡排序和快速排序。
冒泡排序的特征是交换。
算法思路:对于待排序序列从前向后遍历,依次比较相邻元素的值,若当前元素大于下一个元素,则把两者进行交换。每一趟遍历把当前未排序序列中的最大值交换到序列最后一位。
算法图解:对数组 [4,2’,78,2’’,45,1] 进行冒泡排序:
算法稳定性:冒泡排序进行的是相邻元素间的比较交换,所以对于相同元素,不会改变其前后顺序。冒泡排序稳定。
算法特征:
- 冒泡排序每次将前面的最大值比较交换到后面的位置,但是比选择排序更糟糕的是,一旦出现了前面数据大于后面数据,就要不断进行交换的过程。
- 一共进行 n-1 次大的循环,n 为数组长度。每次未排序序列的长度在不断变小,第 1 趟比较交换 n-1 次,第 2 次 n-2 次…,第 n-1 次比较交换 1 次。
- 冒泡排序稳定。
代码实现:
/**
* 冒泡排序,稳定排序
* @param arr 待排序数组
*/
public static void bubbleSort(int[] arr){
int len=arr.length;
for(int i=0;i<len-1;i++){
//标记本次遍历是否进行了交换
boolean flag=false;
for(int j=0;j<len-i-1;j++){
//交换arr[j],arr[j+1]
if(arr[j]>arr[j+1]){
int temp=arr[j];
arr[j]=arr[j+1];
arr[j+1]=temp;
flag=true;
}
}
//该次没有进行元素交换,数组有序,提前终止
if(!flag){
break;
}
}
}
复杂度分析:
时间复杂度 O(N2)
最好情况下,数组正序,冒泡排序进行 n-1 次比较即刻终止,时间复杂度 O(N)。
最坏情况下,数组逆序,冒泡排序需要进行 n-1 次遍历,每次未排序序列的长度在不断变小,第 1 趟比较交换 n-1 次,第 2 趟比较交换 n-2 次…,第 n-1 趟比较交换 1 次。所以总的 比较次数 为 1+2+3+…+(n-1)=n×(n-1)/2,每次比较都要进行交换,交换次数 同样为 1+2+3+…+(n-1)=n×(n-1)/2,时间复杂度为 O(N2)。
简单冒泡排序的平均时间复杂度为 O(N2)。
空间复杂度 O(1)
简单冒泡排序使用常数级别的额外空间。
6. 快速排序
快速排序对冒泡排序做出改进。
6.1 随机化快排
算法思路:
- 快速排序基于分治法,使用递归实现。
- 快速排序的特征是每次找一个 基准值 pivot,对待排序序列进行一次遍历,将 小于 pivot的元素放在左半部分 ,将 大于等于 pivot的元素放在右半部分。在本次处理结束后 ,基准值pivot处于中间位置。这一操作称为 分区(partition)。
- 之后,对左右两部分序列分别进行递归处理。
算法图解:对数组[4,3’,5,2,6,1,3’’]进行快速排序:
算法稳定性:在数组元素与基准元素进行交换时,可能会导致相同元素的前后顺序发生变化。比如上例中当基准值为 4 时,进行一轮交换,元素 3’’ 被交换到 3’ 前面,导致最终结果中 3’’ 与 3’ 前后顺序发生变化。快速排序不稳定。
代码实现:
/**
* 快速排序,不稳定排序
* @param arr 待排序数组
* @param left 左边界
* @param right 右边界,[left,right]
*/
public static void quickSort(int[] arr,int left,int right){
if(left>=right){
return;
}
int mid=partition(arr,left,right);
//左递归
quickSort(arr,left,mid-1);
//右递归
quickSort(arr,mid+1,right);
}
/**
* 快速排序分片,根据基准值将序列划分为两部分
* @param arr 待排序数组
* @param left 左边界
* @param right 右边界
* @return 返回基准值的下标
*/
public static int partition(int[] arr,int left,int right){
//基准元素下标
int pivot=left;
//维护基准值右边第一个位置
int index=left+1;
for(int i=index;i<=right;i++){
//若当前元素arr[i]<arr[pivot],则交换arr[index],arr[i]
if(arr[i]<arr[pivot]){
swap(arr,i,index);
index++;
}
}
//交换基准值到其位置index-1
swap(arr,pivot,index-1);
//返回基准值下标
return index-1;
}
/**
* 置换函数:交换数组两个数据
* @param arr 数组
* @param i 下标i
* @param j 下标j
*/
public static void swap(int[] arr,int i,int j){
if(i==j){
return;
}
int temp=arr[i];
arr[i]=arr[j];
arr[j]=temp;
}
复杂度分析:
时间复杂度 O(N log N)
<1> 最好情况下时间复杂度 O(N log N)
每次分片得到的两个子序列长度接近相等,这样对于 N 个元素的数组,递归树的深度不超过 log2 N +1 ,时间复杂度最小。
分析思路 1:对于递归树的每一层,都需要遍历 n 个元素,根据各自的基准值进行元素交换,递归树共有 log n 层,时间复杂度为 O(N log N)。
分析思路 2:对于 n 个元素,设算法的时间复杂度为 T(n),显然 T(n) 包括一次遍历当前层元素的时间复杂度 O(n),以及递归处理长度接近 n/2 的左右子序列需要的时间 T(n/2)。即算法的时间复杂度递归公式为:T(n)=2×T(n/2)+O(n),并且T(1)=1。
递推分析:
T(n) =2×T(n/2)+O(n)
=2×[2T(n/4) +O(n/2)]+1O(n)= 4T(n/4)+2O(n)
=4×[2T(n/8) +O(n/4)]+2O(n)= 8T(n/8)+3O(n)
=8×[2T(n/16)+O(n/8)]+3O(n)=16T(n/16)+4O(n)
=… …
=nT(n/n)+kO(n) [当 k=log2n 时]
=n+n log n
=O(n log n)
所以最好情况下快速排序的时间复杂度为 O(N log N)。
<2> 最坏情况下时间复杂度 O(N2)
最坏情况下,每次分片得到的两个子序列长度分别为 n0-1 和 0 (n0为当前待排序子序列的长度),这样对于 N 个元素的数组,递归树退化为链表,深度为 N,时间复杂度最大。
例如,对于升序或降序数组进行排序时,如果每次分片时默认的基准值都为当前区间左端点,导致划分的左右子区间长度分别为 n0-1 和 0,就会出现最坏情况。
最坏情况下,算法的时间复杂度递归公式为:T(n)=T(n-1)+T(0)+O(n)=T(n-1)+O(n),其中 T(0)=O(1)。
递推分析:
T(n) =T(n-1)+O(n)
=T(n-2)+O(n-1)+O(n)
=T(n-3)+O(n-2)+O(n-1)+O(n)
=… …
=T(0)+O(1)+O(2)+O(3)…+O(n-2)+O(n-1)+O(n)
=n×(n+1)/2
=O(n2)
快速排序的最差时间复杂度为 O(N2)。
<3> 快速排序期望时间复杂度 O(N log N)
通常情况下认为,在同数量级 O(N log N) 的排序算法中,快速排序的平均性能最好。
空间复杂度 O(log N)
对于原地分割的快速排序版本:在最好情况下,递归树深度为 log n,每一层在原数组上修改,只使用了常数空间存储变量,每次递归只返回基准值索引,空间复杂度为 O(log N)。在最坏情况下,递归树退化为链表,深度为 n,空间复杂度为 O(n)。 快速排序平均空间复杂度为 O(log N)。
6.2 双路快排
出现原因:
- 对于上述随机化快速排序,如果数组中包含大量的重复元素,在分片时由于左半部分序列均 小于 基准值,右半部分均 大于等于 基准值,左右序列长度极不平衡,甚至会导致递归树退化为链表,效率低下。
- 双路快排用来改进这种不平衡现象,使用两个索引遍历数组,使 小于等于 基准值的元素在左序列,大于等于 基准值的元素在右序列,尽量平衡左右序列。
算法思路:
- 双路快速排序算法是随机化快速排序的改进版本,基于分治法,使用递归实现。
- 快速排序的特征是每次找一个 基准值 pivot,对待排序序列进行一次遍历,将 小于等于 pivot的元素放在左半部分 ,将 大于等于 pivot的元素放在右半部分。在本次处理结束后 ,基准值pivot处于中间位置。这一操作称为 分区(partition)。
- 之后,对左右两部分序列分别进行递归处理。
代码实现:
1.双路快排形式1:赋值法
/**
* 2.1 双路快排-赋值
* @param arr 数组
* @param left 左边界
* @param right 右边界
*/
public static void quickSortTwoWays(int[] arr,int left,int right){
if(left>=right){
return;
}
int mid=partitionTwoWays(arr,left,right);
//左右递归
quickSortTwoWays(arr,left,mid-1);
quickSortTwoWays(arr,mid+1,right);
}
/**
* 2.1 双路快排分片-赋值
* @param arr 数组
* @param left 左边界
* @param right 右边界
* @return 返回基准值索引
*/
public static int partitionTwoWays(int[] arr,int left,int right){
//随机在[left,right]范围内选择一个数作为基准值
swap(arr,left,(int)(Math.random()*(right-left+1))+left);
int l=left,r=right;
int pivot=arr[left];
while(l<r){
while(l<r&&arr[r]>pivot){
r--;
}
if(l<r){
arr[l++]=arr[r];
}
while(l<r&&arr[l]<pivot){
l++;
}
if(l<r){
arr[r--]=arr[l];
}
}
arr[l]=pivot;
return l;
}
public static void swap(int[] arr,int i,int j){
if(i==j){
return;
}
int temp=arr[i];
arr[i]=arr[j];
arr[j]=temp;
}
2.双路快排形式2:交换法
/**
* 2.2 双路快速排序-交换
* 左半部分小于等于基准值,右半部分大于等于基准值
* @param arr 待排序数组
* @param left 左边界索引
* @param right 右边界索引
*/
public static void quickSortTwo(int[] arr,int left,int right){
if(left>=right){
return;
}
int mid=partitionTwo(arr,left,right);
//左右递归
quickSortTwo(arr,left,mid-1);
quickSortTwo(arr,mid+1,right);
}
/**
* 2.2 双路快排分片-交换
* @param arr 待排序数组
* @param left 左边界索引
* @param right 右边界索引
* @return 返回基准值下标
*/
public static int partitionTwo(int[] arr,int left,int right){
//随机在[left,right]范围内选择一个数作为基准值
swap(arr,left,(int)(Math.random()*(right-left+1))+left);
int pivot=arr[left];
int i=left+1,j=right;
while(true){
while(i<=right&&arr[i]<pivot){
i++;
}
while(j>=left+1&&arr[j]>pivot){
j--;
}
if(i>j){
break;
}
swap(arr,i,j);
i++;
j--;
}
swap(arr,left,j);
return j;
}
public static void swap(int[] arr,int i,int j){
if(i==j){
return;
}
int temp=arr[i];
arr[i]=arr[j];
arr[j]=temp;
}
复杂度分析:
时间复杂度 O(N log N)
对于递归树的每一层,都需要遍历 n 个元素,根据各自的基准值进行元素交换,递归树共有 log n 层,时间复杂度为 O(N log N)。
空间复杂度 O(log N)
在最好情况下,递归树深度为 log n,每一层在原数组上修改,只使用了常数空间存储变量,每次递归只返回基准值索引,空间复杂度为 O(log N)。在最坏情况下,递归树退化为链表,深度为 n,空间复杂度为 O(n)。 快速排序平均空间复杂度为 O(log N)。
6.3 三路快排
算法思路:
- 对于上述双路快排,如果数组中包含大量的重复元素,在分片时只是尽量让左右序列长度取得平衡,实际效果不一定好。
- 三路快排用来改进这种现象,集中存放重复出现的基准值,具体使用三个索引遍历数组,使 小于 基准值的元素在左序列,等于 基准值的元素在中间序列,大于 基准值的元素在右序列,之后对左右子序列进行递归处理。
代码实现:
/**
* 3.三路快排
* @param arr 待排序数组
* @param left 左边界
* @param right 右边界
*/
public static void quickSortThree(int[] arr,int left,int right){
if(left<right){
int[] temp=partitionThree(arr,left,right);
quickSortThree(arr,left,temp[0]);
quickSortThree(arr,temp[1],right);
}
}
/**
* 3.三路快排分片
* @param arr 数组
* @param left 左边界
* @param right 右边界
* @return 返回两个中间临界值lt,gt
*/
public static int[] partitionThree(int[] arr,int left,int right){
//选择基准数
int v=arr[left];
//中间两个临界值
int lt=left;
int gt=right+1;
//i为扫描元素
int i=left+1;
/**
* | v | <v | | ==v | | ... | | >v |
* | | | | | | | | |
* left [left+1 lt] [lt+1 i-1] i gt-1 [gt right]
*/
//arr[left+1,lt]<v
//arr[lt+1,i-1]==v
//arr[gt,right]>v
while(i<gt){
//小于,交换arr[i],arr[lt+1]
if(arr[i]<v){
swap(arr,i,lt+1);
lt++;
i++;
}else if(arr[i]>v) {
//大于,交换arr[i],arr[gt-1]
swap(arr,i,gt-1);
gt--;
}else {
//等于,i++
i++;
}
}
//交换left,lt
swap(arr,left,lt);
lt--;
//返回两个中间边界
return new int[]{lt,gt};
}
public static void swap(int[] arr,int i,int j){
if(i==j){
return;
}
int temp=arr[i];
arr[i]=arr[j];
arr[j]=temp;
}
复杂度分析:
时间复杂度 O(N log N)
对于递归树的每一层,都需要遍历 n 个元素,根据各自的基准值进行元素交换,递归树共有 log n 层,时间复杂度为 O(N log N)。
空间复杂度 O(log N)
在最好情况下,递归树深度为 log n,每一层在原数组上修改,只使用了常数空间存储变量,每次递归只返回中间两个基准值索引,空间复杂度为 O(log N)。在最坏情况下,递归树退化为链表,深度为 n,空间复杂度为 O(n)。 快速排序平均空间复杂度为 O(log N)。
7. 归并排序
算法思路:
- 归并排序基于分治法,分为 分割 与 合并 两个阶段。
- 在分割阶段,不断将当前待排序序列分为前后长度相同的两部分,对于两个子序列进行递归分割,直到子序列长度为1。
- 在合并阶段,对于相邻的子序列进行两两合并,其中元素按升序排列,并回溯处理,不断合并相邻序列,直到整个序列有序。
算法图解:对数组 [4,3’,5,2’,6,1,3’’,2’’] 进行归并排序:
算法稳定性:对于 N 个元素的数组,进行 log N 次分割,每次分割不改变数组元素位置, 进行 log N 次合并,每次合并都是相邻两个数组的合并,不改变相同元素的前后顺序。归并排序稳定。
代码实现:
归并排序递归版
/**
* 归并排序:分,将大问题分成小问题,进行递归
* @param arr 数组
* @param left 左边界
* @param right 右边界
*/
public static void mergeSort(int[] arr,int left,int right){
if(left>=right){
return;
}
int mid=(left+right)/2;
//归
mergeSort(arr,left,mid);
mergeSort(arr,mid+1,right);
//并
mergeHelp(arr,left,right,mid);
}
/**
* 归并排序:治,将分的小问题逐步合并,每一次回溯计算一次答案,回溯结束后把答案合并在一起
* @param arr 数组
* @param left 左边界
* @param right 右边界
* @param mid 中间值
*/
public static void mergeHelp(int[] arr,int left,int right,int mid){
int[] temp=new int[right-left+1];
int l=left,r=mid+1;
int index=0;
//[l,mid]与[mid+1,r]
while(l<=mid&&r<=right){
temp[index++]=(arr[l]<=arr[r])?arr[l++]:arr[r++];
}
while(l<=mid){
temp[index++]=arr[l++];
}
while(r<=right){
temp[index++]=arr[r++];
}
//将整个临时数组作为排序后结果,复制到目标数组arr中,其中目标数组从left开始
System.arraycopy(temp,0,arr,left,temp.length);
}
复杂度分析:
时间复杂度 O(N log N)
无论长度为 N 的初始序列如何,归并排序都要进行 log N 次分割,分割过程不进行比较操作,再进行 log N 次合并,每次合并都要对整个数组进行一次遍历,比较一层的元素大小,时间复杂度 O(N),总共有 N 层,归并排序时间复杂度为 O(N log N)。
空间复杂度 O(N)
归并排序在每一层的合并过程中,需要一个长度为 N 的辅助数组记录该层合并结果,在每一层回溯后,该数组即释放,所以归并排序的空间复杂度为 O(n)。
8. 基数排序
算法思路:
- 基数排序是一种非比较型排序算法。
- 基数排序将数字分割成不同的位,高位补零,从低位到高位进行处理。
- 对于十进制数的比较,建立10个桶 count[10] ,对于每一位,统计数字0-9的数;之后计算 count[10] 的前缀和,用来确定元素的新位置;根据前缀和,完成该数位的排序。
- 一直到最高位统计结束,即可得到排序结果。
算法图解:对数组 [3’,12,9,3’’,24,6,100,87,33,4] 进行基数排序:
算法稳定性:如图所示,某数位数字相同的元素在同一个桶中,通过 正序 计算桶 count[ ] 的前缀和,逆序 确定元素的新位置,可以使相同数字的前后顺序保持不变。基数排序稳定。
代码实现:
/**
* 基数排序,稳定排序
* @param arr 待排序数组
*/
public static void radixSort(int[] arr){
int len=arr.length;
//建立辅助数组,存放临时结果
int[] buf=new int[len];
//建立10进制数的桶,用来统计当前位置数字0-9的数目
int[] count=new int[10];
//获取数组中最大值
int maxValue=Integer.MIN_VALUE;
for(int n:arr){
if(n>maxValue){
maxValue=n;
}
}
//获取最大值的位数d
int d=(maxValue+"").length();
long exp=1;
for(int i=0;i<d;i++){
//每次清空10个桶
Arrays.fill(count,0);
//1.遍历数组arr[],统计当前数字位0-9的个数
for (int value : arr) {
int digit = (value / (int) exp) % 10;
count[digit]++;
}
//2.统计各个桶中元素的数目和
for(int j=1;j<10;j++){
count[j]+=count[j-1];
}
//3.逆序遍历数组arr[],根据桶中数字位置,将当前元素加入临时数组buf[]
for(int j=len-1;j>=0;j--){
int digit=(arr[j]/(int)exp)%10;
buf[count[digit]-1]=arr[j];
count[digit]--;
}
//将临时数组复制到原数组,更新exp
System.arraycopy(buf,0,arr,0,len);
exp*=10;
}
}
复杂度分析:
时间复杂度 O(d (N+k) )
对于长度为 N 的 k 进制数组进行基数排序,若数组中最大值的位数为 d ,那么需要进行 d 次循环。每次循环需要遍历两次长度为 N 的数组,遍历两次长度为 k 的桶,所以一次循环的时间复杂度为 O(N+k) ,一共进行 d 轮循环,所以基数排序时间复杂度为 O(d (N+k) )。
空间复杂度 O(N+k)
对于长度为 N 的 k 进制数组进行基数排序,需要建立长度为 N 的辅助数组,用来记录每一次遍历后排序的结果;还需要建立长度为 k 的桶,用来记录每一次该数字位中各个数字的数目,所以基数排序的空间复杂度为 O(N+k)。
内部排序算法复杂度对比
算法稳定性:
- 不稳定排序:快(快速)、希(希尔)、选(选择)、堆(堆排序)。
- 稳定排序:冒(冒泡)、插(插入)、归(归并)、基(基数)。
算法复杂度:
排序算法 | 最好时间复杂度 | 最坏时间复杂度 | 平均时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
插入 | O(N) | O(N2) | O(N2) | O(1) | 稳定 |
希尔 | O(N log2 N) | O(N2) | O(N log2 N) | O(1) | 不稳定 |
选择 | O(N2) | O(N2) | O(N2) | O(1) | 不稳定 |
堆 | O(N log N) | O(N log N) | O(N log N) | O(1) | 不稳定 |
冒泡 | O(N) | O(N2) | O(N2) | O(1) | 稳定 |
快速 | O(N log N) | O(N2) | O(N log N) | O(log N) | 不稳定 |
归并 | O(N log N) | O(N log N) | O(N log N) | O(N) | 稳定 |
基数 | O(d (N+k) ) | O(d (N+k) ) | O(d (N+k) ) | O(N+k) | 稳定 |
参考资料
- 《数据结构:C语言版》严蔚敏,吴伟民编著,清华大学出版社。
- 《算法导论(第3版)》Thomas H.Cormen 等著,机械工业出版社。
- 维基百科,排序算法。