排序是我们生活中时常会遇到的问题:上体育课会按照从高到低的顺序排列;高考录取会按照分数依次排名。
拓展到广义的定义,排序就是:现有 n 个数据 a1, a2, a3, … an ,其具有一个可进行比较的属性 y ,现在需要找到 n 个数据的一种排列 {p1, p2, … pn} ,使其属性 y1 ≤ y2 ≤ … ≤ yn。这样的操作就是排序。
常见的排序方法按照性能可以划分为:
简单排序:冒泡排序 、简单选择排序 、直接插入排序
改良排序:希尔排序 、堆排序 、归并排序 、快速排序
第一梯队:计数排序 、桶排序 、基数排序(特定条件下O(n)复杂度)
按照实现原理可以划分为:
插入类排序:直接插入排序 、希尔排序
选择类排序:简单选择排序 、堆排序
交换类排序:冒泡排序 、快速排序
归并类排序:归并排序
后续添加桶排序、基数排序
本文档中的数组都不将下标为0的数据列入排序的队列,将其作为 “哨兵” 来进行使用。
由于排序涉及大量的交换操作,因此就先写下交换程序:
private void swap(int[] nums,int i,int j){
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
并写下打印数组的程序,方便调试:
private String printArr(int[] number)
{
if(number == null)
{
System.out.println("Error");
return null;
}
StringBuffer sb = new StringBuffer("数组为");
for (int i =1;i<number.length;i++)
{
sb.append(number[i]);
sb.append(',');
}
sb.deleteCharAt(sb.length()-1);
return sb.toString();
}
冒泡排序
冒泡排序顾名思义就是数据像泡泡一样从底部冒到顶部。
核心思想就是两层循环,外层循环确定本次数据停止时哪一位置的数字排好序,内层循环进行两两比较来执行排序操作。
同时为了尽早结束不必要的外循环,引入一个 boolean 变量表征此次外循环是否发生数据交换,若没有发生数据交换则代表已完成排序,之后的外循环都是不必要的。
最坏的情况下,排序表是逆序的,此时需要比较 n(n-1)/2 次,并进行等量的记录移动;最好的情况下,排序表本身有序,需要比较 n-1 次,不进行数据交换。因此冒泡排序的时间复杂度是 O(n^2)。
public void bubbleSort(int[] nums){
//初始值设为0,保证能进入外循环
boolean hasSwap = true;
//外循环确定当次排序位置
for(int i=1;i<nums.length-1&&hasSwap;i++){
hasSwap = false;
//内循环若发生交换则下次外循环可以进行
for(int j=nums.length-2;j>=i;j--){
//存在逆序则交换
if(nums[j]>nums[j+1]){
swap(nums,j,j+1);
hasSwap = true;
}
}
}
}
简单选择排序
简单选择排序就是在每个外循环中通过遍历比较获得当前最小的数据,然后将其放到正确的位置上即可。即需要排列第 i 个数时,遍历比较第 i 个到第 n 个数,从中取出最小的数据放到第 i 个位置,然后排列 第 i+1 个数,以此循环。
分析简单选择排序的特点,不管最好、最坏,都需要比较 n(n-1)/2 次,有序的情况下交换次数为 0 次,逆序的情况下交换次数为 n-1 次。因此简单选择排序的时间复杂度是 O(n^2)。
public void selcetSort(int[] nums){
//外循环确定当次排序位置
for(int i=1;i<nums.length-1;i++){
//初始化最小值索引
int min = i;
//从i+1到数组结束,持续比较并更新最小值索引
for(int j=i+1;j<nums.length;j++){
if(nums[j]<nums[min])
min = j;
}
//交换当前排序位置与最小值索引
swap(nums,i,min);
}
}
直接插入排序
直接插入排序认为:在排序第 i 个数据时,前 i-1 个数据都已有序,现在要做的就是将第 i 个数据和前 i-1 个数据进行比较,找到正确的位置插入即可。
最坏情况下,当排序表逆序时,需要比较 (n-1)(n-2)/2 次,移动 (n+4)(n-1) 次 ;最好情况下,当排序表本身有序时,需要比较 (n-1)次,不发生移动。因此直接插入排序的时间复杂度是 O(n^2)。
public void insertSort(int[] nums){
int j;
//默认i之前的数据已排序,现在要排序第i个数据
for(int i=2;i<nums.length;i++){
//如果待排序的第i个数据比已排序数据的最后一位小
//则代表第i个数据需要找到合适的位置插入
if(nums[i-1]>nums[i]){
//哨兵
//将第i个数据赋值给nums[0]方便边界条件的判断
nums[0] = nums[i];
//将第i个数据与已排序数据逐个比较
//若出现不大于的情况则找到插入的位置,跳出循环
for(j=i-1;nums[j]>nums[0];j--){
nums[j+1] = nums[j];
}
//此时的第j个数据比第i个小
//所以第i个数据插入到第j个数据的后面
nums[j+1] = nums[0];
}
}
}
希尔排序
希尔排序就是改良版的插入排序,它设定一个步长,外循环中 对 待排序数据 挨个以步长为偏移地址进行移动(直接插入排序的步长恒为1,每次移动只能前进1,而希尔排序每次比较可以前进一个步长),每次外循环结束后都会将步长减小,来逐次收缩成直接插入排序。
需要注意的是,希尔排序排序的效率和步长的迭代公式有关,并且步长序列的最后一个步长值必须为1,才能保证最后收缩成直接插入排序。希尔排序的时间复杂度为 O(n^(3/2))。
public void shellSort(int[] nums){
//步长初值
int step = nums.length - 1;
//do保证至少循环一次
do{
//步长迭代公式
step = step/3 +1;
int j;
//默认前 step 个数据已排序
for(int i=step+1;i<nums.length;i++){
//需要进行插入
if(nums[i-step]>nums[i]){
//哨兵
nums[0] = nums[i];
//判断要添加一个索引越界的条件
for(j=i-step;j>0&&nums[j]>nums[0];j-=step){
//每次前进一个step
nums[j+step] = nums[j];
}
nums[j+step] = nums[0];
}
}
}while (step!=1);//当step = 1时已退化为直接插入排序,跳出循环
}
堆排序
堆排序是改良版选择排序,它利用了完全二叉树的性质来构建一个大顶堆,每次循环取出堆顶数据将其放到正确位置,并将剩下的数据再维护成一个大顶堆,这样就可以获得有序的数组。堆排序就两个步骤,构建大顶堆,循环维护大顶堆。
由于每次都需要将剩余数据维护成一个大顶堆,因此,不论最好、最坏和平均情况,堆排序的时间复杂度恒定为 O(nlogn)。
public void heapSort(int[] nums){
//从完全二叉树的非叶子节点开始,以其子树节点为排序范围,建立大顶堆
for(int i=nums.length/2;i>0;i--)
heapAdjust(nums,i,nums.length-1);
//从尾部开始,取出剩余数据的最大值放在 i 处
for(int i=nums.length-1;i>0;i--){
swap(nums,1,i);
//维护剩余数据成为大顶堆
heapAdjust(nums,1,i-1);
}
}
//输入堆顶数据索引,堆的终点索引,
//需要将堆顶数据移动到合适位置,维护成大顶堆
private void heapAdjust(int[] nums,int start,int end){
//缓存待排序的堆顶数据
int temp = nums[start];
//遍历堆顶的子节点
for(int j=2*start;j<=end;j*=2){
//若右节点比左节点大
if(j<end&&nums[j]<nums[j+1])
//将较大值提取
j++;
//若缓存大于左右节点值
//说明此时temp已找到正确的位置
if(temp>=nums[j])
//跳出循环
break;
//否则将子节点较大值上移一层,
nums[start] = nums[j];
//将堆顶数据索引下移一层
start = j;
}
//如果发生了子节点上移
if(nums[start]!=temp)
nums[start] = temp;
}
归并排序
归并排序独属于归并类排序方法,比较好地利用了二分的方法来降低时间复杂度。
无序表的排序没有捷径可走,只能逐次遍历比较。但如果是两个有序表合在一起排序呢?每次只需比较两个有序表索引对应的值的大小,然后维护对应索引 +1 即可,时间复杂度是 O(n)。那么如何获得两个有序表呢?我们可以 点一千兵马,兵分一千路。
有 n 个数据的待排序数组,先将其分为 n 个小数组,可知 只有一个数据的数组是有序的,再将其相邻的数组两两归并,获得 n/2 个有序数组,以此类推,总共需要归并 logn 次,因此归并操作需要进行 O(logn) 次。
综上所述,无论最好、最坏或平均情况,都需要归并 O(logn) 次,每次归并花费 O(n) 的时间,因此归并排序的时间复杂度为 O(nlogn) 。
递归写法:
public void mergeSort(int[] nums){
MSort(nums,nums,1,nums.length-1);
}
//输入原始数组,存储数组,起始索引,终止索引
private void MSort(int[] source,int[] destination,int start,int end){
int m;
//辅助数组,缓存未归并的各个数组
int[] TR = new int[source.length];
//当二分成单个数据为一组时,结束二分,
if(end==start)
//将所有数据存到辅助数组中
destination[start] = source[start];
else {
//二分数组
m = (start+end)/2;
//递归调用
MSort(source,TR,start,m);
MSort(source,TR,m+1,end);
//递归返回,归并操作
//递归返回后的数组TR,被分为了两个部分
//start到m 和 m+1到end 分别是有序的
merge(TR,destination,start,m,end);
}
}
//输入原始数组,存储数组,起始索引,中间分隔索引,终止索引
private void merge(int[] source,int[] destination,int start,int m,int end){
//j为后半段索引,k为归并位置索引
int j = m+1,k = start;
for(;start<=m||j<=end;k++){
//若前半段全部归并
if(start>m)
//挨个插入后半段即可
destination[k] = source[j++];
//若后半段全部归并
else if(j>end)
//挨个插入前半段即可
destination[k] = source[start++];
//若前半段索引对应值小于等于后半段,这么写稳定
else if(source[start]<=source[j])
destination[k] = source[start++];
//若前半段索引对应值大于后半段
else
destination[k] = source[j++];
}
}
非递归写法
public void mergeSort2(int[] nums){
//step初值为1
int step = 1;
//辅助数组,存储未全部归并的数组
int[] TR = new int[nums.length];
while(step<nums.length){
//将step长度的相邻数组两两归并到TR
mergePass(nums,TR,step,nums.length-1);
step *=2;
//将step长度的相邻数组两两归并到nums
mergePass(TR,nums,step,nums.length-1);
step *=2;
}
}
//输入原始数组,归并操作后的数组,步长,终止索引
private void mergePass(int[] source,int[] destination,int step,int end){
int start = 1;
//若剩余未归并数据数量大于2*step
while (start<=end-2*step+1){
//相邻且长度为step的数组进行归并
merge(source,destination,start,start+step-1,start+2*step-1);
//更新起始索引
start += 2*step;
}
//若剩余未归并数据数量大于step
//也就是还剩下一个step长度的有序数组和一个长度小于step的有序数组
if(start<end-step+1)
//将其归并
merge(source,destination,start,start+step-1,end);
//剩余未归并数据数量小于等于step,注意它们是有序的哦
//直接依次赋值即可
else
for(;start<=end;start++)
destination[start] = source[start];
}
//输入原始数组,存储数组,起始索引,中间分隔索引,终止索引
private void merge(int[] source,int[] destination,int start,int m,int end){
//j为后半段索引,k为归并位置索引
int j = m+1,k = start;
for(;start<=m||j<=end;k++){
//若前半段全部归并
if(start>m)
//挨个插入后半段即可
destination[k] = source[j++];
//若后半段全部归并
else if(j>end)
//挨个插入前半段即可
destination[k] = source[start++];
//若前半段索引对应值小于等于后半段,这么写稳定
else if(source[start]<=source[j])
destination[k] = source[start++];
//若前半段索引对应值大于后半段
else
destination[k] = source[j++];
}
}
快速排序
快速排序是冒泡排序的改良版本,是基于交换的排序类。核心思想是找到一个枢轴索引,在枢轴左侧的数据都不大于枢轴,在枢轴右侧的数据都不小于枢轴,从而将待排序数组一分为二,并依次对划分出来的子数组查找枢轴不断细分下去,最终得到一个有序的数组。
根据分析可知,在最好的情况下,即枢轴划分得十分均匀,快速排序需要划分 O(logn) 次,每次都需要查找一次枢轴,查找枢轴第一次需要 n-1 次比较,以后每次查找数量逐渐减小,因此快速排序最好的时间复杂度是 O(nlogn) 。
在最坏的情况下,即逆序,枢轴每次都是找到最大或者最小的那个数字,因此每次划分完枢轴只能得到一个待排序序列和一个空序列,而第 i 次划分比较次数是 n-i ,因此快速排序最坏的时间复杂度是 O(n^2) 。
public void quickSort(int[] nums){
QSort(nums,1,nums.length-1);
}
//输入原始数组、待排序起始索引、终止索引
private void QSort(int[] nums,int l,int h){
//要用if而不是while,
//因为对每一对l和h只找一次枢轴索引
if (l<h){
//找到枢轴索引
int p = partition(nums,l,h);
//递归排列划分出的两个序列
QSort(nums,l,p-1);
QSort(nums,p+1,h);
}
}
//输入原始数组,待排序起始索引和终止索引
private int partition(int[] nums,int l,int h){
//哨兵,取nums[l]作为枢轴
//对数组进行排列,使得枢轴左小右大
nums[0] = nums[l];
while (l<h){
//当高段数据小于枢轴时跳出循环
while (l<h&&nums[h]>=nums[0])
h--;
//此时nums[l]装的是枢轴,
//nums[h]装的是小于枢轴的高段数据
//枢轴换到高段去
swap(nums,l,h);
//当低段数据大于枢轴时跳出循环
while (l<h&&nums[l]<=nums[0])
l++;
//此时nums[h]装的是枢轴,
//nums[l]装的是大于枢轴的低段数据
//枢轴 换到低段去
swap(nums,l,h);
}
//最终索引l和h重合,返回l即可
return l;
}
快速排序的优化手段有:
-
优化枢轴选取:可以取数组首个、数组中间和数组最后三个数的中位数作为枢轴,保证枢轴选择为中间的数,有效地分出两个序列。
-
减少不必要的交换:
private int partition(int[] number,int l,int h)
{
//选择number[l]作为枢轴值
number[0] = number[l];
while (l< h)
{
while (l<h&& number[h] >= number[0])
h--;
number[l] = number[h];//将较小值替换到前面
while (l<h&& number[l] <= number[0])
l++;
number[h] = number[l];//将较大值替换到后面
}
//将枢轴值放回正确的位置
number[l] = number[0];
return l;
}
-
优化小数组时的排序方案:
当待排序数组长度不大于7时,调用直接插入排序;当待排序数组长度大于7时,递归调用快速排序。 -
实施尾递归优化,将原本的分路两次递归,替换成起始索引变化的单次递归。
算法比较
排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n^2) | O(n) | O(n^2) | O(1) | 稳定 |
简单选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 稳定 |
直接插入排序 | O(n^2) | O(n) | O(n^2) | O(1) | 稳定 |
希尔排序 | O(nlogn)~O(n) | O(n^1.3) | O(n^2) | O(1) | 不稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 不稳定 |
快速排序 | O(nlogn) | O(nlogn) | O(n^2) | O(logn)~O(n) | 不稳定 |
计数排序 | O(n+m) | O(n+m) | O(n+m) | O(m) | 稳定 |
桶排序 | O(n(logn-logm)) | O(n) | O(nlogn) | O(m) | 稳定 |
基数排序 | O(k(n+m)) | O(k(n+m)) | O(k(n+m)) | O(n+m) | 稳定 |