前言
文章内容中的一些思想,基本都是在以前学习中感悟中得到。其实在刚开始接触排序算法时,感到也非常陌生,学思想的时候,感觉也就那么回事,但是学完没多久忘了。主要是算法思想太多,有的内容太过复杂,于是我总结了每个算法最根本的思想,通过这根本的思想去回忆起对应的算法。另外,在文章中某些有联系的算法,我还采用了递进式的讲解方式,把算法的缺点,优点,以及怎么优化,以及优化后的算法基本都展现了出来。这样也是帮助大家记忆时能有联系性,而不是一块一块地去记忆。值得注意的是文章中的图,确实花费了老大功夫,也是精髓所在。
纸上得来终觉浅,绝知此事要躬行。 只有你在看明白之后,能够多思考,多输出,这样才能扎扎实实,希望本篇文章能够帮助到大家。
1排序的基本概念
1.1 排序的定义
排序概念:重新排列表中的数据,使得表中的元素满足按关键字有序的过程。
算法的 稳定性: 排序完成之后,若表中 相同元素的相对位置没有发生改变。(即:没排序之前,A元素在B元素前,排完序之后,A元素还在B元素前,称这种算法是稳定的,反之。)
时间复杂度:算法中基本操作执行的次数
空间复杂度:对一个算法在运行过程中临时占用存储空间大小的一个量度。
排序分类:在排序过程中,根据数据元素是否都在内存中,将排序分为内部排序与外部排序两个大类。
内部排序:排序期间元素全部放在内存中
外部排序:排序的时候元素无法全部同时存放在内存中,排序的时候元素不断地在内外存之间移动。
2 插入排序
基本思想:每次将一个待排序的元素插入前面已经排好的子序列,使得这个子序列保持有序。
2.1直接插入排序
无序序列向有序序列排序后再插入
①算法思想:
②动图演示:
③代码展示:
static void insertSort(int[] arr){
for (int i = 1; i < arr.length; i++) { //从数组中第二个元素开始找到该元素对应的位置
int key=arr[i]; //待排序元素关键字
int j=i-1; //定义从后往前开始比较的位置
while(j>=0 && arr[j]>key){
arr[j+1]=arr[j]; //元素向后移动
j--;
}
//将待排序元素插入到合适的位置上
arr[j+1]=key;
}
}
public static void main(String[] args) {
int[] arr={53,56,63,90,120,1};
System.out.println("未排序的数组:");
for (int i : arr) {
System.out.print(i+" ");
}
// 开始给原有的数组进行排序
insertSort(arr);
System.out.println();
System.out.println("已排序的数组:");
for (int i : arr) {
System.out.print(i+" ");
}
}
④算法性能:
时间复杂度: O()
空间复杂度: O(1) 仅使用了常数个辅助单元
稳定性: 稳定 (在比较的时候出现相同值的元素,就停止比较了)
使用性:适用于顺序存储和链式存储
2.2 折半插入排序
无序序列向有序序列跳着排序后再插入
不难看出,直接插入比较次数很大,相当于是挨个比较。直接插入排序是:边比较边插入。下面引出一个算法将比较和插入操作分离。 先折半查找确定各元素待插入的位置,再统一移动数据。引出折半插入排序的目的在于 减少比较次数,相当于跳着比较,但是 元素移动的次数没有改变。
①算法(伪代码):
void InsertSort(ElemType A[], int n) {
int i, j, low, high, mid;
for (i = 2; i <= n; i++) { //依次将A[2]~A[n]插入前面的已排序序列//将A[i]暂存到A[01//设置折半查找的范围
A[O] = A[i];
low = 1;
high = i - 1;
while (low <= high) {
mid = (low + high) / 2;//取中间点
//折半查找(默认递增有序)
if (A[mid] > A[0])
high = mid - 1;//查找左半子表
else low = mid + 1;// 查找右半子表
for (j = i - 1; j >= high + 1; --j)
A[j + 1] = A[j];
A[high + 1] = A[0]; //统一后移元素,空出插入位置//插入操作
}
}
}
②算法性能:
时间复杂度:O()虽然在比较的时候是折半比较,这相较于直接插入ee而言确实提高了,从O()提高到了,但是在移动元素的却是O()。折半插入在进行数据量少的排序时,性能也不错的。
空间复杂度:O(1)
稳定性:稳定 (13行代码中,在大于0时才会跳转到左子表,相等的时候会跳转到右边。注意此时升序排列)
2.3 希尔排序
增量之间排序,增量递减。
直接插入排序时,若待排序序列为正序时,时间复杂度仅为O(n),可以看出 直插排序适用基本有序的的排序表和数据量不大的排序表。希尔排序就是基于这两点改进而来,仔细想一下,希尔排序在进行到最后一趟排序时,表基本有序了。
①算法思想:
②动图演示:
③算法性能:
时间复杂度:O()
空间复杂度:O(1)
稳定性:不稳定 在排序的过程中,相同值的元素的相对位置发生了改变。
3交换排序
基本思想:根据序列中两个元素的比较结果来对换这两个元素在序列中的位置。
3.1冒泡排序
相邻元素两两比较,交换排序。
①算法思想:
从后往前(或从前往后)两两比较相邻元素的值,若这两个元素的值未按照整个序列的排序,就交换这两个元素的位置。最终每次会确定一个元素的位置,持续n轮后,n个元素都找到属于自己的位置。
②动图演示:
③算法性能:
时间复杂度:O() (从比较的次数可以看出,最差比较时,第一次:n-1,第二次:n-2 ....,第n次:1,等差数列求和即可理解)
空间复杂度:O(1)(仅借用了常数个辅助单元)
稳定性:稳定 (值相等的元素不进行交换)
3.2 快速排序(重点)
Quick Sort
平均性能最优排序算法。理解起来有点难。
①算法思想:
首先确定第一个元素为基准元素 pivot。
然后用high指针指向的元素和pivot作比较,high值大(这是正常的,不需要交换),high指针--,再次比较,若此时high值比pivot值小时,交换两个元素的位置。(换完位置就要变换指针)接着用low指针指向的元素和pivot比较,low值小(这是正常的,不需交换),low++,再次比较,若此时low值比pivot值大时,交换两个元素的位置。
接着这次变换指针,重复上面的步骤,直到确定基准元素的最终的位置,这是一趟排序。
若基准元素不是边界元素时,以基准元素为中心,可以把整个序列拆分成两个子序列。然后再对两个子序列进行上诉过程。
②算法代码:
@Test
public void method_01(){
int[] arr={20,23,455,23,1,55,66,1,24,408};
quickSort(arr,0,arr.length-1);
// Arrays.sort(arr); 其实数组工具类中的排序方法也是用的快排思想
System.out.println(Arrays.toString(arr));
}
void quickSort(int[] arr ,int low,int high){
if (low<high){
int 枢纽位置 =partition(arr,low,high); //划分位置
//开始对两个子表进行划分 (递归)
quickSort(arr,low,枢纽位置-1);
quickSort(arr,枢纽位置+1,high);
}
}
int partition(int[] arr,int low,int high){
//一趟排序
int pivot=arr[low]; // 以第一个元素为枢纽元素
//快排优化思想:
while (low<high){ //总条件,用来控制指针移动
while (low<high && arr[high]>= pivot)
high--;
arr[low]=arr[high]; //比枢纽元素小的元素移动到左端
while (low<high && arr[low]<=pivot)
low++;
arr[high]=arr[low]; //比枢纽元素大的元素移动到右端
}
arr[low]=pivot; //一次确定一个枢纽位置
return low; //返回枢纽元素的最终位置
}
③算法动图:
④算法性能:
时间复杂度: O() (平均时间复杂度) , O()(最坏的情况下,eg:表中顺序基本有序)但是在进行快排优化过后,最坏的时间复杂度也就为 O() 。
空间复杂度:O()(可以这样理解:每次基准元素都把表拆分成两个表,然后那两个表在同时递归)。 O(n) (最坏情况下,这样就是递归n次了)。
稳定性: 不稳定
若初始序列有序,则快速排序性能最差。
4 选择排序
在一个序列中,每次从无序子序列中 选择一个最小的元素,放入前面,形成有序的子序列,重复n次过程,就把完成对这个序列的排序了。
4.1简单选择排序
①算法思想:
假设排序表为L[1...n],第i躺从L[i...n]中选择值最小的元素与L[i]交换,每一趟排序可以最终确定一个元素的位置经过n-1轮排序排序之后,整个序列就有序了。
②动图演示:
③算法性能:
时间复杂度:O()
空间复杂度:O(1)
稳定性:不稳定(在交换元素时,相对的位置有可能变了)
4.2堆排序
向上构造,向下调整。
大根堆:最大值元素存放在根结点,且任一非根结点的值小于等于其双亲结点的值
小根堆:反之。
①算法思想:
②步骤演示:
③堆中进行插入操作:(这里尤其要注意比较次数和向向下调整过程)
④算法特性:
时间复杂度:O()
空间复杂度:O(1) (仅借用了常数个辅助单元)
稳定性:不稳定(排序的时候已经体现出来了)
5.归并排序和基数排序
5.1 归并排序
局部有序 归并后 整体有序
归并的含义是将两个或两个以上的有序表组合成一个新的有序表。
m路归并:相邻m个有序表为一组,每趟组内排序,当一个组内排完序之后,这个组便会成为一个新的有序表,接着这个新的有序表会加入下一趟排序,重复上述过程,使得最后成为一个整体有序表。
①算法思想:
结论:m路归并时,在一个组内每选出一个元素时,都要经过m-1次比较。
②动图演示:
③算法性能
时间复杂度:O() m路归并的时间复杂度
空间复杂度:O(1)
稳定性:稳定
这里可能考察 m路归并时排序的趟数
5.2 基数排序
先分配,再收集。
基数排序是一种比较特殊的排序算法,不基于比较和移动进行排序,而是基于关键字各位的大小进行排序。
①算法思想:
很明显,最大数的位数决定了排序的趟数,数据元素的个数决定了每趟比较的次数
②算法性能:
时间复杂度:O(d(n+r)) d:排序趟数 n:元素个数 r:按照分配的个数
空间复杂度:O(r) (辅助长度:分配数的个数)
稳定性:稳定 (基你太稳)
基数排序擅长解决的问题:
①数据元素的关键字可以拆分成d组,即d比较小时
②每组关键字的取值范围不大,即r比较小
③数据元素个数大时。
6外部排序
对大文件排序
6.1 外部排序的基本概念
引入外部排序的目的是: 针对 大文件进行 排序。
由于大文件中记录很多,信息量庞大,无法将整个文件复制进内存中,只能将待排序的数据放在外存上,等到这些数据要排序的时候,再将这些数据调入到内存中。我们将这种在排序时候,数据在内存和外存之间发生交换的排序,称为 外部排序。
6.2 外部排序的方法
铺垫一些知识:
文件通常是按照块存储在磁盘上的,操作系统也是按块对磁盘上的信息进行读写的。
由于内存和外存之间的速度差异太大,所以引入缓冲区去缓冲两者之间的速度。
①算法思想:
在整个排序的过程中大致思想分为两个部分:
第①部分:外存中数据跑到内存,先进行内部排序,产生归并段。
第②部分:归并段中的数据在跑到内存中进行归并排序,直至全部数据有序。
第①部分具体思想:
首先有缓冲区大小的会先读入缓冲区,会进行内部排序,比较 (缓冲区的数.lenght-1)次,选出最小值,放入输出缓冲区,输出缓冲区满了之后,会把整个输出缓冲区中的数据,写回到外存中,这是一个归并段。......,就这样比较,数据放到输出缓冲区,当有输入缓冲区空了之后,会立即将数据从外存写入到缓冲区中(这一步一定不要忘记),一直反复,把数据排序完。到最后会生成多个归并段。
第②部分具体思想:
采用归并的思想,对第一部分的产生的归并段进行归并排序。例如:2路归并。首先, 归并段中的数据读入到缓冲区中,生成一个新的归并段。(当有缓冲区为空时,要看外存同一组要归并的归并段中是否还有数据,有数据读到缓冲区中,反之)。 ....归并到最后归并段越来越大,重复上诉行为。随着归并排序的结束,整个外部排序也就结束了。(在归并排序中,有图可以理解帮助上述思想)
②算法分析
外部排序的总时间:内部排序时间+外存信息读写时间(时间最多)+内部归并所需时间(第二多)
当我们在进行第②部分归并排序的时候,每趟归并都会进行IO,于是我们要开始减少与磁盘交互的数量。所以我们不妨思考可以减少归并趟数来优化外部排序。这也意味着要增大归并路数,也意味着要增加缓冲区的数量。
但是这也是需要付出代价:
①缓冲区数量变大,内存区开销就会增加
②缓冲区中的关键字个数就会变多,选出一个最小值比较次数变多,内部排序时间上升了。
6.3 多路平衡归并与败者树
第②部分优化:减少内部排序时间
上面内容讲了,为了减少IO次数,可以增加缓冲区,这样就可以增加归并排序的归并段数,从而减少读写的时间。但是又引发了一个新的问题:在归并排序中,使用k路平衡归并,选出一个最小的元素需要对比(k-1)次,导致了内部排序的时间增加了。于是 我们引入败者树来减少内部排序的时间。
-
多路平衡归并概念:
①最多只能有k个段归并为一个
②每趟归并中,若有m个归并段参与归并,则经过这一趟处理可以得到个新的归并段
-
败者树:
假如现在内存中一共有8个输入缓冲区,可以同时有8个归并段同时进入内存进行归并排序。传统方式需要挨个段进行比较,需要比较7次才获得一个最小的数据。现在看看在引入败者树后,比较方式会发生哪些变化:
变化:从原来的对比关键字k-1次,到有了败者树的 次。 (当然第一次在比较的时候,还是进行了k-1次)
6.4 置换-选择排序
第①部分优化:减少归并趟数
在进行第二部分的归并排序时,不难发现我们可以减少归并趟数,从而减少IO次数,减少时间。于是我们引入了置换-选择排序来增加每个归并段内的数量,来减少初始的归并段数。在没有使用置换选择排序时,归并段中数据个数取决于输入缓冲区的大小。
置换选择排序在产生归并段的时候,并不会像最开始的外部排序一样产生大小相同的归并段,产生归并段的大小完全去决于磁盘中元素排序情况。
6.5 最佳归并树
配合置换选择排序,减少第②部分时IO次数
思想:类似哈弗曼编码的思想,是在置换选择排序所产生的结果下的排序进行的优化。 在经过置换选择排序算法后,一个大文件有可能出现若干个大小不相等的归并段,当然这些归并段的个数相比较最原先的的算法产生的归并段数已经减少,随之的IO次数也减少了,但是也有优化的空间。
置换选择排序所产生的不同大小的归并段,在进行归并排序时,每次归并都可以选择不同归并段大小,每种选择方式都有可能产生不同的IO次数, 最佳归并树用最少的IO次数来完成归并排序的。
最佳归并树是配合置换选择排序一起使用,置换选择排序是优化外部排序的第一部分,那么 最佳归并树就是在使用过置换选择排序的基础上针对外部排序的第二部分优化。
①算法思想
在找最佳归并树时, 注意看是否还要添加虚段。有的问题也经常考察在找到最佳归并树时,要添加几个归并段。
7.总结排序特点
①复杂度对比
②应用场景对比
-
直接插入排序或者简单选择排序:待排序元素n较少时
-
直接插入排序或冒泡排序:待排序元素n基本有序
-
快排、堆排、归并排序:待排序元素n较大时
快排和堆排时间复杂度都是O(logn),且不稳定,唯一区别就是空间复杂度快排需要一个O(logn)的堆栈空间,而堆排的空间复杂度是O(1)。归并排序的时间复杂度O(logn),空间O(1),但是它稳定。
-
基数排序:待排元素n较大时,记录关键字的位数较少且可以分解
后话
路漫漫其修远兮,吾将上下而求索。如果本篇文章帮助到大家了,还麻烦大家多多点赞,让更多的人能够看到,帮助更多的人。在写本篇文章时,也是挤出时间完成,内容有点仓促,如果文章中有出错的地方,还请大家在评论区留言。