排序算法是计算机算法里的基础概念,也是很多大厂面试的必考内容。不管你是应届毕业的小鲜肉还是浸淫技术多年的老司机,都免不了会和排序打交道。正因为大部分小伙伴平日的学习和工作中直接接触算法的几率并不大,所以时间久了以后难免生疏。正因为忘的差不多了,所以有必要把一些经典排序算法的思想和实现思路再温习一下。所谓温故而知新,总会有新的收获。
本文涉及的主要知识点:
时间复杂度是一个函数,使用O表示,一般情况下,时间复杂度由高到低的顺序是O(n2)>O(nlogn)>O(n)>O(logn)>O(1)。
一般如果一种算法的时间复杂度高于O(n2),那么就需要考虑是否可以寻找更优化的方案,在代码层面,一般是以循环的嵌套层次来辨别算法的时间复杂度。
比如两层循环嵌套,那么每层循环都需要执行n次,一共是n2次,所以很明显这是一个O(n2)的算法,只有一重循环的情况下,时间复杂度就是O(n),如果下面的代码所示:
for(int i= 0;0<n;i++){
for(int j=0;j<n;j++){
//do something
}
}
那么什么时候是会出现O(nlogn)呢?,看下如下的代码:
for(int i= 0;i<n;i++){
for(int j=i;j<n;j++){
//do something
}
}
这种内层循环的起始值为i,而且随着i的增加,内层的执行次数会越来越少,对于这种情况,时间复杂度就是O(nlogn)
对于一重循环条件下,如果循环标志位是跳跃式的递进,那么可以认为这种算法为O(logn),这种情况成为对数阶,性能优于O(n)
代码如下所示:
for(int i=0;i<n;i*=2){
//do something
}
性能最好的算法是O(1),这表示我们再有限次的执行后便可以算出结果。比如:
int sum = 2+3+4;
需要明确的是,算法的时间复杂度仅仅是对算法计算规模的一种模糊的表示法,算法的实际执行时间是无法通过提前计算获得的,这取决于实际的算法软件运行环境和物理机器的性能。
我们上面的复杂度函数的自变量都是以n的指数或者对数做为参考,有时候你也可以参考两个自变量的时间复杂度函数,比如O(N*M),这个时候,如果n和M相近,则复杂度近似于O(n2),如果n和m相距非常大。则复杂度可以近似于O(n)。所以,对于这样的情况,时间复杂度还可以分为平均时间复杂度,最好或者最坏时间复杂度。所以对于某个具体的算法,我们一般评估其最坏时间复杂度,因为在最坏的情况下,才能预测极限场景下的算法适用性。
因为现代计算机硬件的发展,空间的成本已经越来越低,所以目前对一个算法的评价,空间复杂度的指标优先级也相应的降低,当然对于一些嵌入式设备、或者对空间有所限制的设备条件下,空间复杂度依然是一个算法是否适用的重要参考。
空间复杂度和时间复杂度的表示方式的一样的都是使用**O(n)**来表示。一般情况下如果这个算法执行所需的其他额外空间是有限的,那么这个算法的空间复杂度就是O(1),相对应的,如果算法在运行过程中,需要进行临时变量的设置、存储和读取,那么其空间复杂度可以认为是O(n),比如递归算法。
算法的稳定性一般是指算法执行过程中,相同的值在排序后和排序前是否有过位置的相对移动,如果有移动,那么就是不稳定算法,如果没有移动,那么就是稳定算法。
算法的稳定性在某些实际场景下具有一定的意义,比如一个班级的学生排名,如果是按照分数排序,而分数相同的情况下入再按照入学时间顺序排序,那么稳定排序算法,对于分数相同的学生,其最终的排序结果是可以接受的,而不稳定的算法,则会产生不正确的排序结果。
桶排序,顾名思义,就是对待排序的数列,进行一个最大范围的设置,然后使用另外一个有顺序的容器(比如桶),分别装入对应的数,然后按顺序拿出容器中的值,这样得到的结果就是排序完成的结果。
比如:我们有:9、5、3、6、1这五个数,那么这里最大值是9,我们准备10个桶,分别从0-9进行标志,然后将每个值分别放入对应标号的桶中,最后顺序输出桶中的值,这样便完成了排序。
package com.xuxinyu.sort;
public class BucketSort {
private int[] bucket;
private int[] array;
public BucketSort(int range,int[] array){
this.bucket = new int[range];
this.array = array;
}
/*
* 桶排序的设计思路:
* 定义两个数组,A数组为待排序数据,B数组为桶
* 遍历A数组,将A数组中的元素拿出,以元素的值做为B数组的索引,找到B数组对应的位置(这就要保证,B数组的初始长度一定要大于A数组中的元素的最大值)
* 此时,B数组的对应位置值为0,每次遍历到相同的位置,B数组对应的值加1(这样B的值为几,就有几个元素)
* */
public void sort(){
if(array!=null && array.length>1){
for(int i = 0;i<array.length;i++){
bucket[array[i]]++;//bucket数组中放入的值要根据array数组来对应
}
}
}
/*
* 取数排序后的数据逻辑:
* 遍历B数组(从小到大则从0开始,从大到小则从B.length-1开始)
* B数组的每个元素,如果有值(=1),则直接输出B的索引值
* 如果有多个值(>1),则重复输出索引值
* */
public void print(){
for(int i = bucket.length-1;i>=0;i--){
for(int j = 0;j<bucket[i];j++){
System.out.println(i);
}
}
}
public void print2(){
for(int i = 0;i<bucket.length;i++){
for(int j = 0;j<bucket[i];j++){
System.out.println(i);
}
}
}
public static void main(String[] args){
int[] array = {5,9,1,9,5,3,7,6,1};
BucketSort bucketSort = new BucketSort(10,array);
bucketSort.sort();
bucketSort.print2();
}
}
桶排序只需要做两件事:放入桶中,再从桶中取出值,也就是一次遍历所有的数,再一次遍历所有的桶,如果待排列的数列的元素个数不是非常多,以及数列的最大值不是很大,那么桶排序的时间复杂度为O(n+m),这个可以说是相当快速,性能相当好的算法。
但是另一方面,如果待排序数列的数据量非常多或者最大值特别大,比如100万,那么这个时候就需要100万个桶,这就造成了空间的严重浪费。此时桶排序时间性能的优势便不具备性价比。
冒泡排序的思想是一种比较经典的排序算法,如果我们希望对待排序数列从小到大排列,他的主要思路就是:
package com.xuxinyu.sort;
public class Bubble {
private int[] array;
public Bubble(int[] array){
this.array = array;
}
public void sort(){
int length = array.length;
if(length>0){
/*
* 外层循环控制的是比较的轮次,如果有10个数,那么比较9轮即可
* 所以外层循环的次数为数组长度减1
* 而且循环起点必须为1,因为轮次没有第0次的说法
* */
for(int i = 1;i<length;i++){
/*内层循环控制每一轮次比较的次数,所以内层循环的比较次数总是比轮层少1
* 比如,有10个数,那么第一轮比较9次,第二轮比较8次
* 所以,j的取值为length-i,其中i为轮次,油外层循环控制
* */
for(int j = 0;j<length-i;j++){
if(array[j]>array[j+1]){
int tmp = array[j];
array[j] = array[j+1];
array[j+1] = tmp;
}
}
}
}
}
public void print(){
for(int i = 0;i<array.length;i++){
System.out.println(array[i]);
}
}
public static void main(String[] args){
int[] aar = {7,2,1,6,3,7,10};
Bubble bubble = new Bubble(aar);
bubble.sort();
bubble.print();
}
}
通过对冒泡排序的分析,可以发现,冒泡排序需要执行n-1个轮次的尾部到头部的数据比较处理。而且在每轮排序中,因为是相邻的数据进行比较,在最坏的情况下,至少需要O(N2)的复杂度,当然冒泡排序最好的情况下复杂度可以提升到O(N),这就是待排序数列本身就是有序的,那么遍无需进行数据调换操作。但是这种情况几乎不可能出现,所以普遍认冒泡排序的平均复杂度为O(N2)
对于空间复杂度,因为冒泡排序仅仅需要一个额外的临时变量,所以空间复杂度为O(1)
冒泡排序也是一种稳定算法,因为在比较过程中,相同的元素值,其相对位置不变。
快速排序使用了分治思想来处理序列,具体来说,其主要思路如下:、寻找一个基准值,让大于基准值的数位于基准值的右边,小于基准值的数位于基准值的左边,这样,基准值的左右两边就相对有序了,而且形成了两个分区。接着对两个分区的数据再次进行新的基准值比较,这样就形成了四个分区,这样反复分区下去直至所有的排序完成。
快速排序的基准值的选取,一般情况下可以取待排序数列的第一个数做为基准,所有比基准值大的都被移动到右边,比基准值小的都被移动到左边,和基准值相等的数不做移动。所以本质上,我们是在寻找一个中间的数位k,k左边的值都比k小,k右边的值都比k大。
这个思路很好,既然如此,那么问题来了,我们应该如何移动对应的数据呢?想一下冒泡排序,冒泡的移动是一种顺序移动,也就是假如头部和尾部的数据需要交换,但是中间的数据是有序的,那么冒泡仍然需要进行n-1次比较才能执行交换动作,这个效率就太低了,是否可以直接进行数据交换呢?答案是可以的。
快速排序的数据比较,遵循以下的规则(假设目标排序完成的数列是最小值在最左边,最大值在最右边):
package com.xuxinyu.sort;
public class QuickSort {
private int[] array;
public QuickSort(int[] array){
this.array = array;
}
public void sort(){
quickSort(array,0,array.length-1);
}
public void print(){
for(int i = 0;i<array.length;i++){
System.out.println(array[i]);
}
}
/*
* 快排的分治思想:
* 设置基准值,从左右两边分别开始和基准值进行比较,比基准值大的放在右边,比基准值小的放在左边,并标记左右两边的数的位置
* 直到左右标志位相等。结束本轮比较
* 开始下一轮比较的方法和上面的方法一样,是将整个数组分成两个小的数组,进行递归处理
*
* 代码实现方式
* 设定一个key值。用于存储基准值src[begin]
* 设置数组的头尾两个标志位
* 尾部处理:循环开始,如果收尾标志未相等,并且尾部的值大于基准值,则不做任何处理,标志位直接递减,直到遇到尾部的值小于基准值,退出当前循环
* 退出循环后,将尾部对应的值放入队头部(因为基准值是头部),并立刻将头部标志位增加一位
* 头部进入循环,如果遇到头部值大于基准值的,退出循环,否则一直递增头部标志位
* 头部退出循环后将头部对应值,放入刚刚尾部的对应值的地方(其实就是做了交换)
*
* 整个大循环。头尾部标志位相碰则退出
*
* 最后将key值恢复到头部标志位处,完成一轮处理
*
* 分治处理前半段和后半段的数据(递归执行)
*
* */
public void quickSort(int[] src,int begin,int end){
if(begin<end){
int key = src[begin];
int i = begin;
int j = end;
while (i<j){
while (i<j && src[j]>key){
j--;
}
if(i<j){
src[i] = src[j];
i++;
}
while (i<j && src[i]<key){
i++;
}
if (i<j){
src[j] = src[i];
j--;
}
}
src[i] = key;
quickSort(src,begin,i-1);
quickSort(src,i+1,end);
}
}
public static void main(String[] args){
int[] arr = {5,9,1,9,15,2,1,41,8};
QuickSort quickSort = new QuickSort(arr);
quickSort.sort();
quickSort.print();
}
}
冒泡排序的数据交互是依次进行比较交换,而快速排序进行的是跳跃式的交换,这样比较的次数下降很多,效率提升明显。
快速排序在情况最坏的情况下,时间复杂度和冒泡是一样的,即O(n2),但是这种情况并不常见,所以快速排序的平均时间复杂度为O(nlogn)
快速排序的空间复杂度一般认为为O(logn),因为通常采用递归法进行分治处理,递归的执行,需要消耗一定的额外空间。
对于稳定性,快速排序是不稳定的算法,可能对相同值的元素的相对位置造成改变
整体来说,快速排序是所有相同数量级的所有排序算法中,平均性能最好的算法。
插入排序的核心思路就是,将整个待排序数列划分为已排序和待排序两个部分,然后不断的扩充已排序部分,缩减待排序部分,直至完成整体的数列排序
现在分析下插入排序的主要实现方式,假设我们需要对待排序的数列从小到大排序。我们进行如下操作:
package com.xuxinyu.sort;
public class InsertSort {
private int[] array;
public InsertSort(int[] array){
this.array = array;
}
public void sort(){
if(array == null){
throw new RuntimeException("array is null");
}
if(array.length>0){
/*
* 外层循环主要保证比较的轮次。一共至少需要比较length-1轮(第一个元素无需比较)
*
* 内层。首先将第二个元素拿出放入tmp,做为比较值
* 进行内层循环,如果前一个元素值大于比较值,则将前一个元素和后一个元素(比较值)调换
*
* 一旦前一个元素小于后一个元素,。退出内层循环
*
* 将tmp的值放入调换完成后的空位内
*
* */
for(int i = 1;i<array.length;i++){
int temp = array[i];
int j = i;
while(j>0 && array[j-1]>temp){
array[j] = array[j-1];
j--;
}
array[j] = temp;
}
}
}
public void print(){
for(int i = 0;i<array.length;i++){
System.out.println(array[i]);
}
}
public static void main(String[] args){
int[] arr = {5,9,1,9,15,2,1,41,8};
InsertSort insertSort = new InsertSort(arr);
insertSort.sort();
insertSort.print();
}
}
插入排序的实现方式可以看出,核心逻辑就是两层循环嵌套,所以一般认为插入排序的时间复杂度为O(N2),如果数列是近似的已排序,那么插入排序的性能可以提高到近似O(n),因为一旦比较过程中发现不需要和已排序部分的最大值进行互换,那么后面的值也无需再进行比较。所以使用插入排序,待排序数列越近似排序,性能越好。
插入排序的空间复杂度为O(1),属于常量级,因为只需要一个tmp临时变量用来存储临时数据。
同时,可以容易的看出,插入排序是一种稳定性排序
希尔排序的基本思想是:把待排序数列按照一定的增量,进行分割,比如长度为10的数列,增量为5,那么便可以分成1——6,2——7,,3——8,,4——9,,5——10,这5个数列,然后对每个小数列进行插入排序操作(也就是比较+调换),完成以后,按照一定的规则缩减增量,再次进行数列分割和比较,重复上述过程直至增量缩减为1,完成排序。
希尔排序的增量规则,一般可以选择数列长度length/2的方式进行缩减。每次缩减,数列整体的顺序都趋于有序,这个对于插入排序来说,是个利好的现象,所以说希尔排序是一种优化版的插入排序
package com.xuxinyu.sort;
public class ShellSort {
private int[] array;
public ShellSort(int[] array){
this.array = array;
}
public void sort(){
if(array == null){
throw new RuntimeException("array is not null");
}
int temp;
for(int k = array.length/2 ;k>0;k/=2){
for(int i = k;i<array.length;i++){
for(int j = i;j>=k;j-=k){
if(array[j-k]>array[j]){
temp = array[j-k];
array[j-k] = array[j];
array[j] = temp;
}
}
}
}
}
public void print(){
for(int i = 0;i<array.length;i++){
System.out.println(array[i]);
}
}
public static void main(String[] args){
int[] arr = {5,9,1,9,15,2,1,41,8};
ShellSort shellSort = new ShellSort(arr);
shellSort.sort();
shellSort.print();
}
}
希尔排序其实是使用了一种增量的方式。分割待排序数列,然后再对分割后的数列进行插入排序操作,所以希尔排序的具体性能,取决于增量的取值方式。
时间复杂度上,由于增量的不确定,所以复杂度也不确定
对于每次除以2的增量方式,希尔排序的最佳时间复杂度为O(n),也就是本身有序的情况下,最坏情况下希尔排序的复杂度依然是O(N2)。但是一般认为,希尔排序的平均复杂度为O(N1.3)。
对于空间复杂度,希尔复杂度和插入排序是一样的都是O(1)
希尔排序的算法是不稳定的算法,因为虽然是一种优化版额插入排序,但是因为需要进行数列分割,如果数列中相同值的元素被分割到不同的子数列中,那么依然会导致相对位置的变化。