目录
概述
一、插入排序
1)直接插入排序
2)希尔排序
二、交换排序
1)冒泡排序
2)快速排序
三、选择排序
1)简单选择排序
2)堆排序
四、归并排序
五、计数排序
六、桶排序
七、基数排序
各类排序的时间复杂度、空间复杂度、稳定性总结
概述
排序有内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。
我们这里说的十大排序就是内部排序。
一、插入排序
1)直接插入排序
基本思想:
插入排序的基本思想就是将无序序列插入到有序序列中。例如要将数组a=[49,38,65,97,76,13,27,49]排序,可以将49看做是一个有序序列,将[38,65,97,76,13,27,49]看做一个无序序列。无序序列中38比49小,于是将38插入到49的左边,此时有序序列变成了[38,49],无序序列变成了[65,97,76,13,27,49]。无序序列中65比49大,于是将65插入到49的右边,有序序列变成了[38,49,65],无序序列变成了[97,76,13,27,49]。以此类推,最终数组按照从小到大排序。
代码实现:
public void InsertSort(int[] a){
for(int i=1;i<a.length;i++){
if(a[i]<a[i-1]){//<,需要将a[i]插入有序子表
int j;
int temp=a[i];
for(j=i-1;j>=0 && temp<a[j];j--)
a[j+1]=a[j];//记录后移
a[j+1]=temp;//插入到正确位置(temp赋给a[j+1],不是赋给a[j]。j--后不符合条件退出循环,所以j要加1)
}
}
}
时间复杂度:O(n^2)
插入排序是稳定的
其他插入排序有折半插入排序,2-路插入排序,表插入排序
2)希尔排序
基本思想:
希尔排序是对插入排序的优化,先将待排记录序列分割成为若干子序列分别进行插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行一次直接插入排序。希尔排序的划分子序列不是像归并排序那种的二分,而是采用的叫做增量的技术(d=5,d=3,d=1)。
代码实现:
public void ShellSort(int[] a){
int d;//d为增量
for(d=a.length/2;d>=1;d/=2){
//插入排序的一轮
for(int i=d;i<a.length;i++){
if(a[i]<a[i-d]){
int j;
int temp=a[i];
for(j=i-d;j>=0 && temp<a[j];j-=d)
a[j+d]=a[j];
a[j+d]=temp;
}
}
}
}
时间复杂度:希尔排序时效分析很难,关键码的比较次数与记录移动次数依赖于增量因子序列d的选取,特定情况下可以准确估算出关键码的比较次数和记录的移动次数。增量因子序列可以有各种取法,但增量因子中除1 外没有公因子,且最后一个增量因子必须为1。
希尔排序是不稳定的
二、交换排序
1)冒泡排序
基本思想:
相邻两节点进行比较,大的向后移一个,经过第一轮两两比较和移动,最大的元素移动到了最后,第二轮次大的位于倒数第二个,依次进行。这是最基本的冒泡排序。
代码实现:
public void BubbleSort(int[] a){
for (int i=0;i<a.length-1;i++) {//外层循环控制排序的趟数
for(int j=0;j<a.length-1-i;j++){//内层循环控制每趟排序
if(a[j]>a[j+1]){
int temp=a[j];
a[j]=a[j+1];
a[j+1]=temp;
}
}
}
}
时间复杂度:O(n^2)
冒泡排序是稳定的
2)快速排序
基本思想:
a)一趟排序的过程
b)排序的全过程
快速排序是对冒泡排序的一种改进。它的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,已达到整个序列有序。一趟快速排序的具体过程可描述为:从待排序列中任意选取一个记录(通常选取第一个记录)作为基准值(pivot),然后将记录中关键字比它小的记录都安置在它的位置之前,将记录中关键字比它大的记录都安置在它的位置之后。这样,以该基准值为分界线,将待排序列分成两个子序列。
代码实现(递归):
public int Patition(int[] a,int low,int high){//一趟快速排序
int pivot=a[low];
while(low<high){
while(low<high && pivot<=a[high])
high--;
if(low<high){
a[low]=a[high];
low++;
}
while(low<high && pivot>=a[low])
low++;
if(low<high){
a[high]=a[low];
high--;
}
}
a[low]=pivot;
return low;
}
public void QuickSort(int[] a,int low,int high){
if(low<high){
int pivotLoc=Patition(a,low,high);
QuickSort(a,low,pivotLoc-1);
QuickSort(a,pivotLoc+1,high);
}
}
//调用QuickSort方法是传入的low为0,high为a.length-1
时间复杂度:平均时间复杂度O(n log n),若初始记录序列按关键字有序或基本有序时,快速排序将蜕化为冒泡排序,时间复杂度为O(n^2)
快速排序是不稳定的(例如序列5 3 3 4 3 8 9 10 11,基准元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱,所以快速排序是一个不稳定的排序算法,不稳定发生在基准元素和其他交换的时刻。)
三、选择排序
1)简单选择排序
基本思想:
在要排序的一组数中,选出最小的数与第1个位置的数交换;然后在剩下的数当中再找最小的数与第2个位置的数交换,依次类推,直到第n-1个元素(倒数第二个数)和第n个元素(最后一个数)比较为止。
代码实现:
public void SelectionSort(int[] a){
for(int i=0;i<a.length;i++){
int pos=i;
for(int j=i+1;j<a.length;j++){
if(a[pos]>a[j])
pos=j;
}
if(i!=pos){
int temp=a[pos];
a[pos]=a[i];
a[i]=temp;
}
}
}
时间复杂度:O(n^2)
选择排序是不稳定的(序列5 8 5 2 9,第一遍选择第1个元素5会和2交换,那么原序列中2个5的相对前后顺序就被破坏了,所以选择排序不是一个稳定的排序算法。)
2)堆排序
基本思想:
堆的定义如下: n个元素的序列{k1, k2, ... , kn}当且仅当满足以下条件时,称之为堆。
可以将堆看做是一个完全二叉树。并且,每个结点的值都大于等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于等于其左右孩子结点的值,称为小顶堆。
初始时把要排序的n个数的序列看作是一棵顺序存储的二叉树(一维数组存储二叉树),调整它们的存储顺序,使之成为一个堆,将堆顶元素输出,得到n 个元素中最小(或最大)的元素,这时堆的根节点的数最小(或者最大)。然后对前面(n-1)个元素重新调整使之成为堆,输出堆顶元素,得到n 个元素中次小(或次大)的元素。依此类推,直到只有两个节点的堆,并对它们作交换,最后得到有n个节点的有序序列。称这个过程为堆排序。
因此,实现堆排序需解决两个问题:
1. 如何将n 个待排序的数建成堆;
2. 输出堆顶元素后,怎样调整剩余n-1 个元素,使其成为一个新堆。
首先讨论第二个问题:输出堆顶元素后,对剩余n-1元素重新建成堆的调整过程。
调整小顶堆的方法:
1)设有m 个元素的堆,输出堆顶元素后,剩下m-1 个元素。将堆底元素送入堆顶(最后一个元素与堆顶进行交换),堆被破坏,其原因仅是根结点不满足堆的性质。
2)将根结点与左、右子树中较小的元素进行交换。
3)若与左子树交换:如果左子树堆被破坏,即左子树的根结点不满足堆的性质,则重复方法 (2).
4)若与右子树交换,如果右子树堆被破坏,即右子树的根结点不满足堆的性质。则重复方法 (2).
5)继续对不满足堆性质的子树进行上述交换操作,直到叶子结点,堆被建成。
称这个自根结点到叶子结点的调整过程为筛选。如图:
再讨论对n 个元素初始建堆的过程。
建堆方法:对初始序列建堆的过程,就是一个反复进行筛选的过程。
1)n 个结点的完全二叉树,则最后一个结点是第个结点的子树。2)筛选从第个结点为根的子树开始,该子树成为堆。
3)之后向前依次对各结点为根的子树进行筛选,使之成为堆,直到根结点。
如图建堆初始过程:无序序列:(49,38,65,97,76,13,27,49)
代码实现:
//大顶堆
public void HeapAdjust(int[] a,int i,int length){
int child=2*i+1;//child为左孩子节点的下标
while(child<length){
if(child+1<length && a[child]<a[child+1])//child+1为右孩子节点的下标
child++;//child记录较大的孩子节点的下标
if(a[i]<a[child]){//如果较大孩子节点大于父节点,那么将较大孩子节点向上移动,替换父节点
int temp=a[i];
a[i]=a[child];
a[child]=temp;
i=child;//重新设置i,即待调整的下一个节点的位置
child=2*i+1;
}
else//如果父节点大于它的孩子节点,则不需要调整,直接退出
break;
}
}
public void HeapSort(int[] a){
//初始堆(a.length/2-1是第一个非叶子节点)
for(int i=a.length/2-1;i>=0;i--){
HeapAdjust(a,i,a.length);
}
//从最后一个元素开始对序列进行调整,直到第一个元素
for(int i=a.length-1;i>0;i--){
int temp=a[i];
a[i]=a[0];
a[0]=temp;
HeapAdjust(a,0,i);
}
}
时间复杂度:
设树深度为k,。从根到叶的筛选,元素比较次数至多2(k-1)次,交换记录至多k 次。所以,在建好堆后,排序过程中的筛选次数不超过下式:
而建堆时的比较次数不超过4n 次,因此堆排序最坏情况下,时间复杂度也为:O(nlogn )。
堆排序是不稳定的
四、归并排序
基本思想:
“归并”的含义是将两个或两个以上的有序序列组合成一个新的有序表。假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到(表示不小于x的最小整数)个长度为2(或者是1)的有序子序列,再两两归并。如此重复,直到得到一个长度为n的有序序列为止。这种排序方法称为2-路归并排序。
代码实现(递归):
public void Merge(int[] a,int start,int mid,int end,int[] temp){
int i=start,j=mid+1;
int k=0;
while(i<=mid && j<=end){
if(a[i]<=a[j]){
temp[k++]=a[i++];
}else{
temp[k++]=a[j++];
}
}
while(i<=mid){
temp[k++]=a[i++];
}
while(j<=end){
temp[k++]=a[j++];
}
//复制回原数组
k=0;
while(start <= end){
a[start++] = temp[k++];
}
}
public void MergeSort(int[] a,int start,int end) {
int[] temp = new int[a.length];//排序前先建好长度等于原数组长度的临时数组
if (start < end) {
int mid = (start + end) / 2;
MergeSort(a, start, mid);//左边归并排序
MergeSort(a, mid + 1, end);//右边归并排序
Merge(a, start, mid, end, temp);//将两个有序的子数组合并
}
}
时间复杂度:O(n log n)
归并排序是稳定的
五、计数排序
基本思想:
计数排序要求输入的数据必须是有确定范围的整数。
算法的步骤如下:
(1)找出待排序的数组中最大和最小的元素
(2)统计数组中每个值为i的元素出现的次数,存入数组C的第i项
(3)对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
(4)反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1
代码实现:
public int[] countingSort(int[] a){
int max=max(a);
int[] bucket=new int[max+1];
int[] result=new int[a.length];
for(int val:a){
bucket[val]++;
}
int j=0;
for(int i=0;i<bucket.length;i++){
while(bucket[i]>0){//i存放的是值,bucket[i]存放的是值出现的次数
result[j++]=i;
bucket[i]--;
}
}
return result;
}
public int max(int[] a) {
int max=a[0];
for(int i=1;i<a.length;i++){
if(a[i]>max){
max=a[i];
}
}
return max;
}
时间复杂度:O(n+k)
六、桶排序
基本思想:
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:
1)在额外空间充足的情况下,尽量增大桶的数量
2)使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中
同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。
示意图:
元素分布在桶中:
然后,元素在每个桶中排序:
代码实现:
public void bucketSort(int[] a){
int max=Integer.MIN_VALUE;
int min=Integer.MAX_VALUE;
for(int i=0;i<a.length;i++){
max=Math.max(max,a[i]);
min=Math.min(min,a[i]);
}
//桶数
int bucketNum=(max-min)/a.length+1;
List<List<Integer>> list=new ArrayList<>();
for(int i=0;i<bucketNum;i++){
list.add(new ArrayList<>());
}
//将每个元素放入桶
for(int i=0;i<a.length;i++){
int num=(a[i]-min)/a.length;
list.get(num).add(a[i]);
}
//对每个元素进行排序
for(int i=0;i<list.size();i++){
Collections.sort(list.get(i));
}
System.out.println(list.toString());
}
时间复杂度:O(n+k)
七、基数排序
略
各类排序的时间复杂度、空间复杂度、稳定性总结
说明:
当原表有序或基本有序时,直接插入排序和冒泡排序将大大减少比较次数和移动记录的次数,时间复杂度可降至O(n);
而快速排序则相反,时间复杂度提高为O(n^2);
原表是否有序,对简单选择排序、堆排序、归并排序和基数排序的时间复杂度影响不大。
一般不使用或不直接使用传统的冒泡排序。
基数排序是一种稳定的排序算法,但有一定的局限性:
1)关键字可分解。
2)记录的关键字位数较少,如果密集更好
3)如果是数字时,最好是无符号的,否则将增加相应的映射复杂度,可先将其正负分开排序。
稳定性:
排序算法的稳定性:若待排序的序列中,存在多个具有相同关键字的记录,经过排序, 这些记录的相对次序保持不变,则称该算法是稳定的;若经排序后,记录的相对 次序发生了改变,则称该算法是不稳定的。
稳定性的好处:排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所用。基数排序就是这样,先按低位排序,逐次按高位排序,低位相同的元素其顺序再高位也相同时是不会改变的。另外,如果排序算法稳定,可以避免多余的比较。
选择排序算法准则:
影响排序的因素有很多,平均时间复杂度低的算法并不一定就是最优的。相反,有时平均时间复杂度高的算法可能更适合某些特殊情况。同时,选择算法时还得考虑它的可读性,以利于软件的维护。一般而言,需要考虑的因素有以下四点:
1)待排序的记录数目n的大小;
2)记录本身数据量的大小,也就是记录中除关键字外的其他信息量的大小;
3)关键字的结构及其分布情况;
4)对排序稳定性的要求。
设待排序元素的个数为n:
a)当n较大,则应采用时间复杂度为O(nlog2n)的排序方法:快速排序、堆排序或归并排序。
快速排序:是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短
堆排序 : 如果内存空间允许且要求稳定性的
归并排序:它有一定数量的数据移动,所以我们可能过与插入排序组合,先获得一定长度的序列,然后再合并,在效率上将有所提高
b)当n较小,可采用直接插入或直接选择排序。
直接插入排序:当元素分布有序,直接插入排序将大大减少比较次数和移动记录的次数
直接选择排序 :元素分布有序,如果不要求稳定性,选择直接选择排序