个人学习笔记——排序算法的学习与总结
后续再修改
0. 算法概述
排序:将n个性质相同的数据元素按关键字值从小到大或从大到小排为有序序列。
分类
内部排序:待排序记录数量较少时,可全部调入内存进行的排序过程。
外部排序:待排序记录的数量很大,以致于内存不能一次容纳全部记录,所以在排序过程中需要对外存进行访问的排序过程。
衡量排序效率的方法
内部排序:比较次数,也就是时间复杂度
外部排序:IO次数,也就是读写外存的次数
常见的内部排序方法:插入排序、快速排序、选择排序、归并排序、基数排序等
外部排序概述:
第一阶段: 内部排序:按照内存大小将外存记录文件分为长为l的子文件/段,排序后的有序子文件(即归并段)重写入外存;
第二阶段:将归并段逐趟归并,实现归并过程:多路平衡归并/置换-选择排序
内部排序分类
比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此也称为非线性时间比较类排序。
非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。
本文主要介绍按照排序原则分为以下5类排序方法:
- 插入排序;
- 交换排序;
- 选择排序;
- 归并排序;
- 基数排序;
稳定性
排序的稳定性(不同的排序方法,结果可能不同):能保证两个相等的数,经过排序之后,其在序列的前后位置顺序不变。(A1=A2,排序前A1在A2前面,排序后A1还在A2前面)稳定性本质是维持具有相同属性的数据的插入顺序
堆排序、快速排序、希尔排序、直接选择排序是不稳定的排序算法
而基数排序、冒泡排序、直接插入排序、折半插入排序、归并排序是稳定的排序算法。
常用内部排序总结图:
1. 插入排序
1.1 直接插入排序
1.1.1 说明:
设对数据元素序列R1,R2,…,Rn进行排序,则:
- 仅含R1的序列是长度为1的按关键字值有序的序列;
- 依次逐个将Ri(i=2,3,…,n)插入已有的按关键字值有序的序列,并使插入完成后的序列仍按关键字值有序。
举例:
伪代码:
1.1.2 代码
public static void insert_sort(int a[],int length){
int key=0;
for(int i=0;i<length-1;i++){
for(int j=i+1;j>0;j--){
if(a[j] < a[j-1]){
key = a[j-1];
a[j-1] = a[j];
a[j] = key;
}else{ //不需要交换
break;
}
}
}
}
优化:
public static void insert_sort(int[] a){
for(int i=1; i<a.length; i++){
int temp = a[i];//保存每次要插入的数
int j=i;
while(j>0&&a[j-1]>temp) {
a[j] = a[j-1];
j--;
}
a[j] = temp;//将需要插入的数放入这个位置
}
}
1.1.3 复杂度分析
时间复杂度:O( n2 )
空间复杂度:需要一个元素的辅助空间 ==》O(1)
注:
时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。
空间复杂度:是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数,即在交换元素时那个临时变量所占的内存空间;
1.2 希尔排序
1.2.1 说明
在要排序的一组数中,根据某一增量分为若干子序列,并对子序列分别进行插入排序。
具体:
- 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;(一般增量取初次取序列(数组)的一半,以后每次减半,直到增量为1)
- 按增量序列个数k,对序列进行k 趟排序;
- 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入。
好的增量序列的共同特征:
① 最后一个增量必须为1;
② 应该尽量避免序列中的值(尤其是相邻的值)互为倍数的情况。
图例:
希尔排序的时间复杂度与所取增量序列有关。增量序列可有各种取法,但各增量值间应无除1之外的公因子,且最后一个增量必为1。
1.2.2 代码
public static void shell_sort(int a[],int lenth){
int temp = 0;
int incre = lenth; //增量
while(true){
incre = incre/2; //增量每次取序列长的一半
for(int k = 0;k<incre;k++){ //根据增量分为若干子序列
for(int i = k+incre; i<a.length; i+=incre){
temp = a[i];//保存每次要插入的数
int j=i;
while(j>0 && (j-incre)>0 && a[j-incre]>temp) {//注意(j-incre)>0 ,小心数组下标越界异常
a[j] = a[j-incre];
j-=incre;
}
a[j] = temp;//将需要插入的数放入这个位置
}
}
if(incre == 1){
break;
}
}
}
1.2.3 复杂度分析
时间复杂度:O(n1.3)
希尔排序的执行时间依赖于增量序列。
- 最好情况:完全正序,需比较(n-1)次。后移赋值操作为0次 ==》即O(n)
- 最坏情况:O(nlog2n), 常写作 O(nlogn)。
- 渐进时间复杂度(平均时间复杂度):O(n(1.3-2)),常写作O(n1.3) 或者 O(n1.5)
要弄清比较次数和记录移动次数与增量选择之间的关系,并给出完整的数学分析,至今仍是数学难题。
希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;当元素基本有序了,步长很小,插入排序对于有序的序列效率很高。所以,希尔排序的时间复杂度会比O(n²)好一些。
希尔算法在最坏的情况下和平均情况下执行效率相差不是很多,与此同时快速排序在最坏的情况下执行的效率会非常差。希尔排序没有快速排序算法快,因此中等大小规模表现良好,对规模非常大的数据排序不是最优选择。(注:专家们提倡,几乎任何排序工作在开始时都可以用希尔排序,若在实际使用中证明它不够快,再改成快速排序这样更高级的排序算法。)
空间复杂度:需要一个元素的辅助空间 ==》O(1)
2. 交换排序
2.1 冒泡排序
2.1.1 说明
最小的 浮上来
2.1.2 代码
public class bubble_sort3 {
public static void main(String[] args) {
int a[] = {1, 13, 5, 7, 8, 2, 4, 10, 3, 11, 9};
BubbleSort(a);
System.out.println("最终排序结果:");
for(int i=0;i<a.length;i++) {
System.out.print(a[i]+" ");
}
}
static int b = 0;
public static void BubbleSort(int [] a){
int temp;
for(int i=0; i<a.length-1; i++){ //表趟数,一共a.length-1次。
for(int j=a.length-1; j>i; j--){
if(a[j] < a[j-1]){
temp = a[j];
a[j] = a[j-1];
a[j-1] = temp;
}
}
System.out.println("第"+ b++ +"趟排序结果:");
for(int ii=0;ii<a.length;ii++) {
System.out.print(a[ii]+" ");
}
System.out.println();
}
}
}
2.1.3 复杂度分析
时间复杂度:O( n2 )
外循环和内循环以及判断和交换元素的时间开销;
- 最好情况:完全正序,时间花销为:[ n(n-1) ] / 2;所以最优的情况时间复杂度为:O( n2 );
也有说法为O(n),理由是采用标志位,减少不必要的排序次数。 - 最坏情况:完全逆序,每次排序都要交换两个元素,则时间花销为:[ 3n(n-1) ] / 2;(其中比上面最优的情况所花的时间就是在于交换元素的三个步骤);所以最差的情况下时间复杂度为:O( n2 ) ;
- 渐进时间复杂度(平均时间复杂度):O( n2 ) 。
冒泡法排序的最好情况是数据元素集合已经全部排好序,这是循环n-1次每次没有交换动作而退出,因此,冒泡排序的最好情况的时间复杂是是O(n);冒泡排序算法的最坏情况是数据元素集合全部逆序存放,这时循环n-1次 比较次数你n(n-1)/2 和交换移动次数 3n(n-1)/2,因此冒泡排序算法最坏情况时间复杂度为O(n2).
空间复杂度是O(1);
- 最好情况:完全正序,空间复杂度为 0 ;
- 最坏情况:完全逆序,则空间复杂度为:O(n) ;
- 渐进空间复杂度(平均时间复杂度):O(1) 。
2.2 快速排序
2.2.1 说明
选key值;比key小的放左边,反之放右边;分开后的小区域(大于1个数)重复第二步。
图解:
伪代码:
2.2.2 代码
public class quick_sort4 {
public static void main(String[] args) {
int a[] = {1, 13, 5, 7, 8, 2, 4, 10, 3, 11, 9};
quickSort(a, 0, a.length-1);
for(int i=0;i<a.length;i++) {
System.out.print(a[i]+" ");
}
}
public static void quickSort(int a[],int l,int r){
if(l>=r)
return;
int i = l; int j = r; int key = a[l];//选择第一个数为key
while(i<j){
while(i<j && a[j]>=key)//从右向左找第一个小于key的值
j--;
if(i<j){
a[i] = a[j];
i++;
}
while(i<j && a[i]<key)//从左向右找第一个大于key的值
i++;
if(i<j){
a[j] = a[i];
j--;
}
}
//i == j
a[i] = key;
quickSort(a, l, i-1);//递归调用
quickSort(a, i+1, r);//递归调用
}
}
2.2.3 复杂度分析
**时间复杂度:O( nlogn )
- 输入已经有序或者反向有序.
- 围绕最大或者最小值划分 .
- 划分的一边总是为空.
最坏情况相当于退化成了冒泡排序:
最好情况:
平均时间复杂度为O(knlogn),经验表明,在所有时间复杂度处于同数量级的内部排序算法中,快速排序法的常数因子k最小。
空间复杂度是 O(logn);
快速排序使用的空间是O(1)的,也就是个常数级;而真正消耗空间的就是递归调用了,因为每次递归就要保持一些数据:
- 最优的情况下空间复杂度为:O(logn);
- 每一次都平分数组的情况 最差的情况下空间复杂度为:O(n);退化为冒泡排序的情况
- 平均空间复杂度为O(logn)
3. 选择排序
3.1 简单选择排序
3.1.1 说明
在长度为N的无序数组中,第一次遍历n-1个数,找到最小的数值与第一个元素交换;
第二次遍历n-2个数,找到最小的数值与第二个元素交换;
。。。
第n-1次遍历,找到最小的数值与第n-1个元素交换,排序完成。
动图:
3.1.2 代码
public static void select_sort(int array[],int lenth){
for(int i=0;i<lenth-1;i++){
int minIndex = i;//当前最小值
for(int j=i+1;j<lenth;j++){//比较n-i次,得出最小值
if(array[j]<array[minIndex]){
minIndex = j;//当前最小值索引
}
}
if(minIndex != i){//交换
int temp = array[i];
array[i] = array[minIndex];
array[minIndex] = temp;
}
}
}
3.1.3 复杂度分析
时间复杂度: O(n²)
【稳定排序】【交换移动次数较少,节约时间】
无论最好最坏情况,均需要比较 次。
最好(完全正序):交换0次;
最差(完全逆序):交换n-1次。
加起来 ==》总时间复杂度O(n²)
空间复杂度是:O(1)
因为只定义了两个辅助变量,与n的大小无关,所以空间复杂度为O(1)
3.2 堆排序
3.2.1 说明
堆是一个近似完全二叉树的结构,并同时满足堆的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
① 将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
② 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n];
③ 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。
3.2.2 代码
public static void heap_sort(int []a){
//1.构建大顶堆
for(int i=a.length/2-1;i>=0;i--){
//从第一个非叶子结点从下至上,从右至左调整结构
adjustHeap(a,i,a.length);
}
//2.调整堆结构+交换堆顶元素与末尾元素
for(int j=a.length-1;j>0;j--){
int temp=a[0];//将堆顶元素与末尾元素进行交换
a[0] = a[j];
a[j] = temp;
adjustHeap(a,0,j);//重新对堆进行调整
}
}
public static void adjustHeap(int []a,int i,int length){//调整大顶堆(仅是调整过程,建立在大顶堆已构建的基础上)
int temp = a[i];//先取出当前元素i
for(int k=i*2+1;k<length;k=k*2+1){//从i结点的左子结点开始,也就是2i+1处开始
if(k+1<length && a[k]<a[k+1]){//如果左子结点小于右子结点,k指向右子结点
k++;
}
if(a[k] >temp){//如果子节点大于父节点,将子节点值赋给父节点(不用进行交换)
a[i] = a[k];
i = k;
}else{
break;
}
}
a[i] = temp;//将temp值放到最终的位置
}
3.2.3 复杂度分析
时间复杂度分析:O(nlogn)
最好情况(升序是大顶堆;降序时是小顶堆)、最坏情况、平均情况都是进行了N次排序,每次排序访问了logN个元素。==> O(nlogn)
构建堆+重建堆
- 构建堆:建堆本身非常耗时间的(时间复杂度为O(n))[这也是为什么通常情况下排大量数据的时候一般选用快排而不用堆排序。],但比排序的时间小点。
- 交换并重建堆:需交换n-1次,或者说需要进行n-1次重新恢复堆操作。
- 重建堆:根据完全二叉树的性质,[log2(n-1),log2(n-2)…1]逐步递减,近似为nlogn。
所以堆排序时间复杂度最好和最坏情况下都是O(nlogn)级。
空间复杂度:O(1)
堆排序不要任何辅助数组,只需要一个辅助变量,所占空间是常数与n无关,所以空间复杂度为O(1)。
4. 归并排序
4.1 说明
图例:
步骤:
4.2 代码
public class merge_sort2 {
public static void main(String[] args) {
int a[] = {1, 13, 5, 7, 8, 2, 4, 10, 3, 11, 9};
int last = a.length - 1;
int[] temp = new int[a.length];
merge_sort(a, 0, last, temp);
for(int i=0;i<a.length;i++) {
System.out.print(a[i]+" ");
}
}
public static void merge_sort(int a[],int first,int last,int temp[]){
if(first < last){
int middle = (first + last)/2;
merge_sort(a,first,middle,temp);//左半部分排好序
merge_sort(a,middle+1,last,temp);//右半部分排好序
mergeArray(a,first,middle,last,temp); //合并左右部分
}
}
//合并 :将两个序列a[first-middle],a[middle+1-end]合并
public static void mergeArray(int a[],int first,int middle,int end,int temp[]){
int i = first;
int m = middle;
int j = middle+1;
int n = end;
int k = 0;
while(i<=m && j<=n){
if(a[i] <= a[j]){
temp[k] = a[i];
k++;
i++;
}else{
temp[k] = a[j];
k++;
j++;
}
}
while(i<=m){
temp[k] = a[i];
k++;
i++;
}
while(j<=n){
temp[k] = a[j];
k++;
j++;
}
for(int ii=0;ii<k;ii++){
a[first + ii] = temp[ii];
}
}
}
4.3 复杂度分析
时间复杂度:O(nlogn)
- 分解:显而易见。
- 解决:递归地对两个子数组排序。
- 组合:线性时间合并。
空间复杂度:O(n)
每次两个数组进行归并排序的时候,都会利用一个长度为n的数组作为辅助数组用于保存合并序列,所以空间复杂度为O(n).
5. 基数排序
5.1 说明
按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。
① 取得数组中的最大数,并取得位数;
② arr为原始数组,从最低位开始取每个位组成radix数组;
③ 对radix进行计数排序(利用计数排序适用于小范围数的特点);
5.2 代码
public static void RadixSort(int A[],int temp[],int n,int k,int r,int cnt[]){
//k:最大的位数2
//r:基数10
//cnt:存储bin[i]的个数
for(int i=0 , rtok=1; i<k ; i++ ,rtok = rtok*r){
//初始化
for(int j=0;j<r;j++){
cnt[j] = 0;
}
//计算每个箱子的数字个数
for(int j=0;j<n;j++){
cnt[(A[j]/rtok)%r]++;
}
//cnt[j]的个数修改为前j个箱子一共有几个数字
for(int j=1;j<r;j++){
cnt[j] = cnt[j-1] + cnt[j];
}
for(int j = n-1;j>=0;j--){ //重点理解
cnt[(A[j]/rtok)%r]--;
temp[cnt[(A[j]/rtok)%r]] = A[j];
}
for(int j=0;j<n;j++){
A[j] = temp[j];
}
}
}
5.3 复杂度分析
空间复杂度:O(dn)
每一次关键字的桶分配都需要O(n)的时间复杂度,而且分配之后得到新的关键字序列又需要O(n)的时间复杂度。
假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d2n) ,当然d要远远小于n,因此基本上还是线性级别的。
系数2可以省略,且无论数组是否有序,都需要从个位排到最大位数,所以时间复杂度始终为O(dn) 。其中,n是数组长度,d是最大位数。
空间复杂度:O(n+k)
k为桶的数量,需要分配n个数
总结:
【参考】:
十大经典排序算法(动图演示)
八大排序算法详解(动图演示 思路分析 实例代码java 复杂度分析 适用场景)
本科算法课件《补充内容-内部排序.ppt》
MIT 算法课件《Introduction.to.Algorithms.-.Lecture.Notes》