再温排序
先来个总览,知其庞然大体,而入之其微,后而一窝端
需要先知道的几个概念:
稳定排序:在待排序的文件中,若存在若干个相同关键字的记录,经过排序后这些具有相同关键字的记录相对顺序不改变。
不稳定排序:与稳定排序相反
内部排序:待排序的记录存放在计算机随机存储器(RAM)中进行排序的过程
-
外部排序:待排序记录的数量很大,以至于内存不能容纳完全部的记录,需要在排序过程中对外存进行访问的排序(也就是说涉及到了内外存交换)
排序算法性能评价
- 执行时间和所需辅助空间
- 算法本身的复杂度
空间复杂度:所需辅助空间不依赖于排序问题的规模n,则辅助空间为O(1),称之为就地排序。而非就地排序的辅助空间一般为O(n);
时间复杂度:大多数排序算法的时间开销主要是关键字的比较与移动。
稳定排序
排序算法 | 时间复杂度 | 空间复杂度 |
---|---|---|
冒泡排序 | 平均,最差都是O(n^2),最好O(n) | O(1) |
双向冒泡排序 | 平均,最差都是O(n^2),最好O(n) | O(1) |
插入排序 | 平均,最差都是O(n^2),最好O(n) | O(1) |
归并排序 | 最差,最差,平均都是O(nlogn) | O(n) |
桶排序 | O(n) | O(k) |
基数排序 | O(dn) (d是常数) | O(n) |
二叉树排序 | O(nlogn) | O(n) |
图书馆排序 | O(nlogn) | (1+x)n |
不稳定排序
排序算法 | 时间复杂度 | 空间复杂度 |
---|---|---|
选择排序 | 最差,平均都是O(n^2) | 1 |
希尔排序 | O(nlogn) | 1 |
堆排序 | 最差,最好,平均都是O(nlogn) | 1 |
快速排序 | 平均O(nlogn),最坏O(n^2) | O(logn) |
内部排序:冒泡排序,插入排序,选择排序,快速排序,堆排序,基数排序
外部排序:归并排序,桶排序,基数排序
一些实践总结:
简单排序中直接插入最好,快速排序最快,待排序记录为正序的时候直接插入和猫婆都最佳
n<=50的时候,可以直接插入或者选排序,记录规模小的时候,直接插入比较好,否则因为直接选择移动的记录次数少于直接插入,则选择 选择排序较好。
待排序记录基本有序的情况下,选用直接插入,冒泡,随机快速排序较好。
若n的规模较大的时候,采用事假复杂度为 O(nlogn)的排序算法较好:快速排序,归并排序,堆排序。
冒泡排序(稳定)
思想:以正序为例,每一趟排序比较都将未排序部分的最小元素移动交换到未排序部分的第一位。
经过优化的冒泡排序,在最好情况下,也就是进行第一次冒泡即可的情况(已有正序),而且第一次冒泡肯定是n次比较交换,无法避免。
最坏情况是逆序的情况比较交换次数是:1+2+...+n-1+n = n +(n^2-n)/2 = (1+n)*n/2=O(n^2)
public static void bubbleSort(int a[]){
for(int i = 0;i<a.length;i++){ //冒泡次数
for(int j = a.length-1;j>i;j--){ //从底往上冒泡的过程
if(a[j] < a[j-1]) // 比前一个小则上冒,等于的时候不做出来,所以是稳定排序
swap(a,j,j-1);
}
}
}
public static void bubbleSortGood(int a[]){
boolean flag = true; //只要一趟冒泡过程没有发生交换,则已有序,直接退出冒泡的循环即可
for(int i = 0;i<a.length;i++){
flag = true;
for(int j = a.length-1;j>i;j--){
if(a[j] < a[j-1]){
swap(a,j,j-1);
flag = false;
}
}
if(flag)
break;
}
}
选择排序(不稳定)
思想:每趟比较交换中将最小的元素交换打动未排序部分的第一位。交换原则是当前记录小于未排序部分的第一位则此两个数交换。
public static void selectSort(int a[]){
for(int i=0;i<a.length-1;i++){
for(int j= i+1;j<a.length;j++){
if(a[i]>a[j])
swap(a,i,j);
}
}
}
举个例子说明为什么会不稳定:
假设待排序序列为:7 , 8 , 9 ,7, 5 , 3 。记a1 =0 第一个7出现的位置,a2=3,第二个7出现的位置。在第一趟选择排序的时候 index = 0-3时均没有交换。index = 4,此时5<7,则交换。交换后序列变成了:5,8 , 9 ,7 , 7 , 3,那么显然的,原来第一个7的位置变成了4,此时与未排序的序列中的同关键字7的相对位置发生了改变。
复杂度同冒泡排序一样分析即可。
插入排序(稳定)
思想:将未排序部分的第一个记录插入到已排好序的部分之中。想想你玩扑克的场景。
public static void insertSort(int a[]){
for(int i=1;i<a.length;i++){
//待排序的元素,从以排好序的部分的后面向前比较,知道第一次遇到小鱼或者等于的元素则停止
//否则比较一次则交换一次(相当于将插入位置后的元素后移)
for(int j = i;j>0 && a[j]<a[j-1];j--)
swap(a,j,j-1);
}
}
最坏的情况下,序列已经是降序序列,此时需要进行n(n-1)/2次比较。最好的情况是已经是升序序列,则只需要进行n-1次比较即可。
希尔排序(不稳定)
思想:先将整个待排序序列分成若干个子序列,分别对每个子序列进行直接插入排序,待每个子序列都有序后,在对整个序列进行一次直接插入排序。其实分割子序列是按照一个“增量”进行分割的,“增量;“增量”的选取会直接影响到算法的性能。
public static void shellSort(int a[]){
int step = a.length/2; //这里的划分策略是折半划分
while(step!=0){ //划分的子序列数减少
for(int k=0;k<step;k++){ //对每个子序列进行直接插入排序
for(int i=k;i<k+step;i++){
for(int j=i;j>k && a[j]<a[j-1];j--){
swap(a,j,j-1);
}
}
}
step=step>>1;
}
}
测试:
对序列:3 , 5 , 33 , 2 , 4 , 6 , 23 , 56 , 23 , 24 , 4 , 54 , 6 进行直接插入排序与希尔排序,执行swap()方法的次数分别是:
直接插入排序:25次
希尔排序:16次
以上只是从单一数据进行说明,并非对每个序列排序都符合以上的交换次数对比关系。
有人大量测试得到希尔排序比较和移动次数大约在:n^1.25 - 1.6* n^1.25
以下分析为什么希尔排序相对直接插入排序有更好的性能。
- 待排序序列基本有序的时候,直接插入排序需要的移动交换次数是均相对少的(相对于冒泡,选择);
- n 值较小的时候 n 与 n^2的差别也较小,所以直接插入排序的最好时间复杂度O(n) 与最坏时间复杂度O(n^2)差别也不大。
- 希尔排序开始的时候增量较大,那个划分的子序列就较小,所以子序列的直接插入排序较快。而后来增量逐渐较小,划分的子序列数就减少,然而之前的划分中已经是现在的子序列基本有序,所以在新的子序列进行直接插入排序也比较快。
快速排序(不稳定)
基本思想:通过一趟排序将待排序的记录分割成独立的两部分。其中一部分的关键字均比另一部分的记录关键字小,则可以分别继续对这两部分进行排序,已达到整个序列有序的目的。
步骤
一:
- 初始是 l ,h 分别为0,序列的长度
- 如果l>=h则排序完成,否则进行步骤3
- 进行步骤二,返回值记为 p。
- 对序列arr[l ... p]重复步骤2
- 对序列arr[p+1... h]重复步骤2
二:
- 从序列中选取一个记录作为轴枢,一般是当前序列的第一个元素作为轴枢。记为 p 。此外low,high分别为序列的起始下标,结束下标。
- 从结束下标往左进行比较(也就是执行 --high 操作),找到第一个比 p 小的记录,并将该记录设置到 low 位置,也就是执行arr[low] = arr[high]。
- 从low位置开始向右进行比较(++low),找到第一个比p大的记录,并将该记录设置到high位置(arr[high] = arr[low]);
- 重复步骤2,3,直到low>=high(此时完成的是一次划分序列的操作),返回此时的位置索引low
public static void quickSort(int a[],int low,int high){
if(low<high){
int p = partition(a, low, high);
quickSort(a, low, p-1);//递归对轴枢左边的序列进行排序
quickSort(a, p+1, high);//递归对轴枢右边的序列进行排序
//可以发现,只需要对子序列递归的排序即可,不需要合并子序列排序的结果
}
}
public static int partition(int a[],int low,int high){
int t = a[low];
while(low<high){//交替从左右两边向中间扫描比较
//将比轴枢位置小的记录移动到轴枢位置的左边
while(low<high && t<=a[high])
--high;
a[low] = a[high];
//将比轴枢位置大的记录移动到轴枢位置的右边
while(low<high && t>=a[low])
++low;
a[high] = a[low];
}
a[low] = t;//将轴枢位置的值放到正确的位置,一趟排序后应该在的位置
return low;
}
以上递归实现的快速排序中,因为递归会需要一个额外的栈空间进行维护每个递归。假若每趟排序都将记录分割成接近的两个子序列,那个就是说类似满二叉树的结构,此时栈的最大深度为 log2 n +1。而加入每趟排序之后,轴枢位置总偏于两端的话,类似只有左子树或者右子树的二叉树,那个栈的最大深度是 n 。经过优化,也就是说每次的到的分割的子序列,先对长度较短的序列进行快速排序,那么栈的最大深度可以降到log n 。
当待排序记录(基本)有序的时候,快排会退化成冒泡排序。
以上方法的快速排序的时间复杂度主要来自partition函数。所以 T(n) = P(n) +T(k-1) +T(n-k) 。P(n)是对n个记录进行一趟快排的时间,而且在随机的序列中一趟快速排序之后 k 在 1 ~ n之间的任何一个值的概率相等,那么所需要的平均时间是:T(n) = (n+1)T(n-1)/n+(2n-1)c/n ,c是一个常数。最后得到的是O(nlogn)的数量级。、
堆排序(不稳定)
要理解堆排序,我们需要先了解什么事堆。
堆:n个元素的序列{k1,k2,k3,...,kn}满足如下的关系:
最小堆:ki<=k(2i) && ki<=k(2i+1)
最大堆:ki>=k(2i) && ki>=k(2i+1)
i=1~floor(n/2)
一个堆可以对应一个完全二叉树
思想:通过将一个无序的序列构建成一个最大堆或者最小堆,每次从堆顶就可以获取得到当前堆的最大或者最小值,当堆顶无元素的时候则可以得到一个有序的序列了。
建堆的过程其实就是一个反复调成的过程(保证当前建立好的是一个堆)。如果将这个序列看做是一个完全二叉树的话,那个最后一个非终端结点就是floor(n/2)。
public static void heapAdjust(int a[],int s,int l){
int t = a[s]; //先保存当前节点的值
//从当前节点的左孩子开始寻找,直到没有孩子节点(也就是自己就是叶子节点的时候)
for(int i=2*s;i<l;i*=2){
//t节点存在右孩子,且右孩子大于左孩子(说明右孩子可能大于t对应的节点,大于就需要调整~)
//那么令当前节点加一(指向右孩子)
if(i<l && a[i]<a[i+1])
i++;
if(t>a[i]) //t对应的节点大于两个孩子节点,所以不用调整直接退出
break;
//将找到的大于的t节点对应的节点,交换到 s 对应的节点处,同时更新s为当前的i值
//也就是如果下次还存在交换,那么就是针对最新的s(上次交换的s)
a[s] = a[i];
s = i;
}
a[s] = t; //t最终被交换到的是s处(上面的for循环直接被break的话,相当于没有交换)
}
public static void HeapSort(int a[]){
//从左后一个非终端结点向前,因为如果是叶子节点
//那么不会存在比自己大的孩子节点了
for(int i = a.length/2;i>=0;i--)
heapAdjust(a, i, a.length);
for(int i=a.length-1;i>0;i--){
//最大堆堆顶是最大的元素
//那么这里是不断交换到i(i从末尾向前移动)
//那么最大值就会从最后递减向前排列
swap(a,0,i);
//只调整0~i-1的元素,i-1之后的已经有序了
heapAdjust(a, 0, i-1);
}
}
由上可见,堆排序的主要耗费时间是在于初始建堆以及反复调整新堆上,而对于深度为k的堆,调整算法中进行关键字比较的次数最多为2(k-1)次。,所以在建立n个元素,深度为h的堆的时候,总共进行的关键字比较次数不超过4n。而堆的深度为floor(log2 n )+1。新堆调整次数为n-1次,则总共进行的比较次数不超过:
2(floor(log2 (n-1)) + floor(log2 (n-1)) +... + floor(log2 2))< 2nlog2 n
所以时间复杂度是:O(nlogn)
空间复杂度:O(1)
因为只有一个保存临时节点信息的变量。
归并排序(稳定)
思想:将两个已经排好序的序列合并成一个有序序列。
步骤:
- 将相邻两个记录进行归并操作,得到floor(n/2)个子序列,排序后每个序列包含两个记录。
- 将上诉序列再次进行归并操作,将形成floor(n/4)个子序列,每个子序列包含四个记录
- 重复步骤2直到所有记录排序完毕。
public static void merge(int a[],int temp[],int s,int mid,int e){
int i = s;
int j = mid+1;
int x = s;
//先将需要合并的两个序列的公共部分由小到大复制到缓存数组
while(i<=mid && j<=e){
if(a[i]>a[j])
temp[x++] = a[j++];
else
temp[x++] = a[i++];
}
//以下两部是将由剩余没有复制到缓存数组的记录直接放到缓存数组后面
//因为两个需要合并的序列长度不一样的时候肯定会由一个序列的会有剩下
//没有复制到缓存数组的
while(i<=mid)
temp[x++] = a[i++];
while(j<=e)
temp[x++] = a[j++];
//将缓存数组的记录,重新复制回原数组
x = s;
while(x<=e)
a[x] =temp[x++];
}
public static void MSort(int a[],int temp[],int s,int e){
if(s<e){
//演示的是二路归并(最简单的归并)
int mid = (s+e)/2;
MSort(a,temp,s,mid);
MSort(a,temp,mid+1,e);
merge(a,temp,s,mid,e);//从最递归低层返回的时候开始合并
}
}
public static void mergeSort(int a[]){
int temp[] = new int[a.length];
MSort(a,temp,0,a.length-1);
}
在归并排序中,首先需要一个和原来序列长度异常的辅助序列。空间是O(n),而在递归进行划分归并的时候需要借用递归栈,递归的次数就是进行归并的次数,也就是log2 n 。所以总的归并排序的空间复杂度还是O(n)的。
先看看分割序列的时候,如果给予以上的二路归并的话,也就是递归的深度,其时间复杂度是O(nlogn)。而在合并的时候时间不会超过O(n)。所以归并的时间复杂度是O(nlogn)。