最经典的八大排序,面试常考...

JAVA实现八大排序

前言

排序在算法中有很重要的作用,写下这篇文章来进一步加深自己对目前主流的排序算法的认识。下面我将从时间复杂度、空间复杂度和稳定性三个方面来介绍不同的排序算法。

以下是八种主流算法的时间复杂度、空间复杂度和稳定性的汇总。

排序方法时间复杂度(平均)时间复杂度(最坏)时间复杂度(最好)空间复杂度稳定性
冒泡排序O(n²)O(n²)O(n)O(1)稳定
选择排序O(n²)O(n²)O(n²)O(1)不稳定
插入排序O(n²)O(n²)O(n)O(1)稳定
希尔排序O(n1.3)O(n²)O(n)O(1)不稳定
归并排序O(nlog2n)O(nlog2n)O(nlog2n)O(n)稳定
堆排序O(nlog2n)O(nlog2n)O(nlog2n)O(1)不稳定
快速排序O(nlog2n)O(n²)O(nlog2n)O(log2n)不稳定
桶排序O(n+k)O(n²)O(n)O(n+k)稳定

1、冒泡排序

算法描述:

  1. 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
  2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。
  3. 在这一点,最后的元素应该会是最大的数。
  4. 针对所有的元素重复以上的步骤,除了最后一个,即需要进行length-1次。 5. 第一次是对n个数进行n-1次比较,进行到最后第n个的一个是最大的; ...... 6. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

示例图解: 冒泡排序

代码实现:

	// 冒泡排序
 	public void bubbleSort(int []arr){
        int l = arr.length;
        for(int i = 0; i < l; i++){
            for(int j = 0; j < l-i-1; j++){
                if(arr[j]>arr[j+1]){
                    swap(arr,j,j+1);
                }
            }
        }
    }
    
	// 运用异或运算交换两个数(前提是两数不同地址)
	public void swap(int []arr,int x,int y){
        arr[x] = arr[x] ^ arr[y];
        arr[y] = arr[x] ^ arr[y];
        arr[x] = arr[x] ^ arr[y];
    }

算法分析:

稳定性:

因为是比较下一个,每次交换不会造成稳定性丢失,具有稳定性。

时间复杂度:

这个时间复杂度还是很好计算的:外循环和内循环以及判断和交换元素的时间开销; 最优的情况也就是开始就已经排序好序了,那么就可以不用交换元素了,则时间花销为:[ n(n-1) ] / 2;所以最优的情况时间复杂度为:O( n² ); 最差的情况也就是开始的时候元素是逆序的,那么每一次排序都要交换两个元素,则时间花销为:[ 3n(n-1) ] / 2;(其中比上面最优的情况所花的时间就是在于交换元素的三个步骤);所以最差的情况下时间复杂度为:O( n² ); 综上所述: 平均的时间复杂度为:O(n²)。

空间复杂度:

如果使用临时变量来交换,那么空间复杂度尾O(1)。 如果使用以下三种不使用辅助空间的交换,那么空间复杂度为O(0)。

  • a = a + b; b = a - b; a = a - b;
  • a = a * b; b = a / b; a = a / b;
  • a = a ^ b; b = a ^ b;a = a ^ b;

2、选择排序

算法描述:

  1. 从数组中选择最小元素,将它与数组的第一个元素交换位置。
  2. 再从数组剩下的元素中选择出最小的元素,将它与数组的第二个元素交换位置。
  3. 不断进行这样的操作,直到将整个数组排序。

示例图解: 选择排序

代码实现:

	// 选择排序
	public void selectSort(int []arr){
        int l = arr.length;
        for(int i=0;i<l;i++){
            for(int j=i+1;j<l;j++){
                if(arr[i]>arr[j]){
                   swap(arr,i,j);
                }
            }
        }
    }

	// 运用异或运算交换两个数(前提是两数地址不同)
    public void swap(int []arr,int x,int y){
        arr[x] = arr[x] ^ arr[y];
        arr[y] = arr[x] ^ arr[y];
        arr[x] = arr[x] ^ arr[y];
    }

算法分析:

稳定性:

在选择交换的过程中,次序打乱,不具有稳定性。

时间复杂度:

选择排序的时间复杂度和冒泡排序一样,都是要遍历数组,最好最坏的情况都是O(n²)

空间复杂度:

选择排序的空间复杂度也和冒泡排序一样。用临时变量是O(1),异或运算等则是O(0)。

3、插入排序

算法描述:

  1. 从第一个元素开始,该元素可以认为已经被排序;
  2. 取出下一个元素,在已经排序的元素序列中从后向前扫描;
  3. 如果该元素(已排序)大于新元素,将该元素移到下一位置;
  4. 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
  5. 将新元素插入到该位置后;
  6. 重复步骤2〜5。

示例图解: 插入排序

代码实现:

	// 插入排序
	public void insertSort(int []arr){
        int l = arr.length;
        for(int i=1;i<l;i++){
            for(int j=i;j>0&&arr[j-1]>arr[j];j--){
                swap(arr,j-1,j);
            }
        }
    }
    // 运用异或运算交换两个数(前提是两数地址不同)
    public void swap(int []arr,int x,int y){
        arr[x] = arr[x] ^ arr[y];
        arr[y] = arr[x] ^ arr[y];
        arr[x] = arr[x] ^ arr[y];
    }

算法分析:

稳定性:

每次插入都做比较,不会导致稳定性丢失,具有稳定性。

时间复杂度:

当最好的情况,也就是排序本身是有序的,共需比较n-1次,因为没有移动的记录,时间复杂度为O(n)。当最坏的情况,即排序表是逆序的情况,时间复杂为O(n²)。所以时间复杂度是O(n²)。

空间复杂度:

和冒泡一样,看是不是用临时变量交换数据,所以是O(1) 或者 O(0)。

4、希尔排序

算法描述:

先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:

  1. 选择一个增量序列T1,T2,...,TK,其中TI> TJ,TK = 1;
  2. 按增量序列个数k,对序列进行k趟排序;
  3. 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m的子序列,分别对各子表进行直接插入排序。仅增量因子为1时,整个序列作为一个表来处理,表长度即为整个序列的长度。

示例图解: 在这里插入图片描述

代码实现:

// 希尔排序
public void donaldShell(int[] arr){
        int len = arr.length;
        for(int gap = len/2; gap > 0; gap /= 2){
            for(int i = gap; i < len; i++){
                if(arr[i] < arr[i-gap]){
                    int j = i;
                    int temp = arr[j];
                    while (j-gap >= 0 && temp < arr[j-gap]){
                        arr[j] = arr[j-gap];
                        j -= gap;
                    }
                    arr[j] = temp;
                }
            }
        }
    }

算法分析:

稳定性:

分组直接插入的形式打破了稳定性,所以不具有稳定性。

时间复杂度:

shell排序的时间复杂度是根据选中的 增量 有关的,所以分析shell排序的时间复杂度是个比较麻烦的事;这里只给出答案,不推算了;

     在最优的情况下,时间复杂度为:O(n ^ (1.3) )   (元素已经排序好顺序)

     在最差的情况下,时间复杂度为:O(n ^ 2);

空间复杂度:

使用临时变量,空间复杂度为O(1)。

5、归并排序

算法描述:

  1. 把长度为Ñ的输入序列分成两个长度为N / 2的子序列;
  2. 对这两个子序列分别采用归并排序;
  3. 将两个排序好的子序列合并成一个最终的排序序列。

归并排序是一个经典的递归套路,这种递归想法十分值得学习

示例图解: 归并排序

代码实现:

    //归并排序由两个方法实现,第一个是递归方法,第二个是归并方法
    //时间复杂度O(N*logN) 额外空间复杂度O(N)

    //process方法,用递归方法将左右两边有序,最后mergeSort归并一下
    //符合master公式    T(N) = 2*T(N/2) + O(N)
    // log(b)a=d=1  --> O(N * logN)
    public void process(int []arr,int L,int R){
        if(L == R) return;
        // 运用位运算得出中间数(位运算比单纯的除法更快)
        int mid = L + ((R - L) >> 1);
        process(arr,L,mid);
        process(arr,mid+1,R);
        mergeSort(arr,L,mid,R);
    }

    //mergeSort方法,将两个有序的数组,归并为一个有序数组(外排序的方法,创建一个help空间)
    public void mergeSort(int []arr,int L,int mid,int R){
        int []help = new int[R-L+1];
        int i = 0;
        int p1 = L;
        int p2 = mid+1;
        while(p1 <= mid && p2 <= R){
        
			// 谁小,help数组装谁,小的那个指针下移
            if(arr[p1] <= arr[p2]){
                help[i++] = arr[p1++];
            }
            else{
                help[i++] = arr[p2++];
            }
        }
        
        // 当有一边指针指到尽头,将另一边剩余的数全部排到help数组中
        while(p1 <= mid){
            help[i++] = arr[p1++];
        }
        while(p2 <= R){
            help[i++] = arr[p2++];
        }
        
        // 将help数组中的值全部放入arr数组中,使其有效
        for(i = 0; i < help.length; i++){
            arr[L+i] = help[i];
        }
    }

算法分析:

稳定性:

归并排序通过help数组和两个指针,以原本的顺序放入数组中,所以具有稳定性。

时间复杂度:

在分析归并排序的时间复杂度之前,我们引入一个master公式

master公式是用来解决递归问题时间复杂度的公式。

 T [n] = aT[n/b] + f (n)(直接记为T [n] = aT[n/b] + O(N^d))

①当d<logb a时,时间复杂度为O(n^(logb a)) ②当d=logb a时,时间复杂度为O((n^d)*logn) ③当d>logb a时,时间复杂度为O(n^d)

有关公式的证明,可以参考Master—Theorem 主定理的证明和使用。在这里就不多赘述了。

我们通过这个master公式,分析得知 a = b ,d = 1。d = logb a,所以时间复杂度是O(n*logn)。

空间复杂度:

因为使用了help数组来帮助排序,所以空间复杂度为 O(n)。

6、堆排序

算法描述:

  1. 将初始待排序关键字序列(R1,R2 ... .Rn)构建成大根堆,此堆为初始的无序区;
  2. 将堆顶元素R [1]与最后一个元素 - [R [n]的交换,此时得到新的无序区(R1,R2,...... Rn中-1)和新的有序区(RN),且满足ř并[1,2,...,N-1] <= R [N];
  3. 由于交换后新的堆顶R [1]可能违反堆的性质,因此需要对当前无序区(R1,R2,...... Rn中-1)调整为新堆,然后再次将R [1]与无序区最后一个元素交换,得到新的无序区(R1,R2 ... .Rn-2)和新的有序区(RN-1,RN)的。
  4. 不断重复此过程直到有序区的元素个数为ñ -1,则整个排序过程完成。

堆,是一种特殊的完全二叉树,如果我们想要升序的方式,我们使用大根堆——父节点的值大于左右两个孩子(如果有孩子的话)。想要降序的方式,那么我们使用小根堆——父节点的值小于左右俩孩子。

示例图解: 在这里插入图片描述 在这里插入图片描述

代码实现:

    // 插入构成大根堆
    public void heapInsert(int []arr,int index){
    	// index是左右孩子的任意一个,(index-1)/2 是孩子的父节点
    	// 如果,孩子大于父亲,那么就交换这两者的位置
        while (arr[index] > arr[(index-1)/2]){
            swap(arr,index,(index-1)/2);
            index = (index-1) / 2;
        }
    }

    // 某个父节点(index)更改数值后,构成新的大根堆
    public void heapify(int []arr,int index,int heapSize){
        // left是index的左孩子
        int left = (index * 2) + 1;
        
        // 在left不超过堆的大小范围的情况下
        while(left < heapSize){
        
        	// 先默认左孩子是最大的
            int largest = left;
            
            // 如果右孩子在堆范围内,并且右孩子比左孩子大,那么最大值是右孩子
            if(left+1 < heapSize && arr[left+1] > arr[left]){
                largest = left + 1;
            }
            
            // 如果最大的孩子小于父节点,那么跳出循环
            if(arr[largest] <= arr[index]){
                break;
            }
            
            // 在最大孩子大于父节点的情况下,交换这个孩子和父节点的数据
            swap(arr,largest,index);
            
            // 在交换完毕后,新一轮的父节点是刚刚与其更换的孩子的位置
            index = largest;
            
            // 新一轮的左节点是 index*2+1
            left = index * 2 + 1;
        }
    }

    // 堆排序,先把杂乱的数组一个一个构成大根堆,得到的第一层就是数组最大值
    // 将这个最大值和堆的最后一个数互换位置,heapSize减小1,再次进行新的堆排序,周而复始形成有序数组
    public void heapSort(int []arr){
        if(arr ==null || arr.length < 2){
            return;
        }

		// 下面两种写法都可以

        // 建立一个大根堆
//        for(int i=0;i<arr.length;i++){
//            heapInsert(arr,i);
//        }

        // 从下面构建大根堆(更快)
        for(int i = arr.length - 1;i>=0;i--){
            heapify(arr,i,arr.length);
        }

        int heapSize = arr.length;

        //大根堆的最大值,也就是第一层,和堆的最后一个值互换
        swap(arr,0,--heapSize);

        //新的大根堆,重复把堆中最大值和堆最后一个值互换,最后成为有序数组
        while(heapSize>0){
            heapify(arr,0,heapSize);
            swap(arr,0,--heapSize);
        }
    }
    
    // 采用临时变量进行两数的交换
    public void swap(int []arr,int x,int y){
        int temp = arr[x];
        arr[x] = arr[y];
        arr[y] = temp;
    }

算法分析:

稳定性:

在堆化的过程和与最后一个数的交换过程中,稳定性已经丢失了,所以不具有稳定性。

时间复杂度:

先看一下创建堆的时间复杂度:

  • 需要调整的节点个数:N/2个。根据二叉树的性质我们可知终端节点数n0=度为2节点数n2+1,将常数1忽略掉可得:n0=n2。所有的n2都需要调整,所以需要调整的节点个数为总节点数N/2。

  • 调整一次的时间复杂度:logN。完全二叉树共有N个节点,高度为log(N+1),化简到logN,调整一次的时间复杂度最差情况需要调整二叉树的高度次,所以调整一次的时间复杂度为logN。

  • 所以创建堆的时间复杂度为:N/2logN,化简到NlogN。

然后再来看一下排序时的时间复杂度:

  • 排序时我们采用的是删除堆的思想,而删除堆的时间复杂度也是NlogN。

  • 所以堆排序的时间复杂度为:2NlogN,化简到NlogN。

综上,堆排序的时间复杂度为O(n)。

空间复杂度:

因为堆排序是在自己的数组内原地操作,所以空间复杂度为O(1)。

7、快速排序

算法描述:

快速排序使用分治法来把一个串(名单)分为两个(或三个)子串(子列表)具体算法描述如下:

  1. 从数列中挑出一个元素,称为“基准”(pivot);
  2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置这个称为分区(分区)操作。
  3. 递归地(递归)把小于基准值元素的子数列和大于基准值元素的子数列排序。

简单的快速排序,可以分为两个区,小于等于区和大于区——把相对于标准数大小的其它数放入正确的位置就是在进行排序的过程。这个时候我们只需要一个指针来指向两个区的临界。

如果将快速排序再快一点,那么我们可以分为三个区,小于区、等于区和大于区。只不过这个时候我们需要两个指针来指向三个区的临界。

示例图解: 快速排序

代码实现:

我写的这个版本的快排,是以数组最右边的数为基准,把它视为标准数,然后我分了三个区,用两个指针指向三个区的临界。

    //快速排序
    public void quickSort(int []arr){
    	// 若数组为空,或者只有一个数,直接返回
        if(arr == null || arr.length<2){
            return;
        }
        // 否则进入快排
        quickSort(arr,0,arr.length-1);
    }

    //快速排序的实现
    public void quickSort(int []arr,int l,int r){
        if(l<r){
            swap(arr,l+(int)(Math.random()*(r-l+1)),r); //随机一个非标准数的数与刚开始的标准数交换
            int []p =partition(arr,l,r);    //返回等于区的两个边界值
            quickSort(arr,l,p[0]-1);    //小于区再次快排
            quickSort(arr,p[1]+1,r);    //大于区再次快排
        }
    }

    //得到 ‘等于区’ 的两个边界值,记录为arr[0]和arr[1](如果等于区只有一个数,那么两个边界值相同)
    public int[] partition(int []arr,int l,int r){
        int less = l - 1; // 小于区边界 从l-1开始
        int more = r;   // 大于区边界 从r开始

        //只要 指针l 不与 大于区的边界值相碰就循环
        while(l < more){
            // 如果指针指着的值小于标准数的值,交换指针值和小于区右边一个值,小于区右移一位,指针右移一位
            if(arr[l]<arr[r]){
                swap(arr,++less,l++);
            }

            // 如果指针指着的值大于标准数的值,交换指针值和大于区左边一个值,大于区左移一位,指针原地不动
            else if(arr[l]>arr[r]){
                swap(arr,--more,l);
            }

            // 如果指针值和标准值相同,指针右移
            else{
                l++;
            }
        }

        // 将标准值和大于区第一个值交换,使中间全为等于区
        swap(arr,more,r);

        // 返回等于区的两个边界值
        return new int []{less+1,more};
    }

	// 用临时变量的方法交换两个数
    public void swap(int []arr,int x,int y){
        int temp = arr[x];
        arr[x] = arr[y];
        arr[y] = temp;
    }

算法分析:

时间复杂度:

快速排序的时间主要耗费在划分操作上,对长度为n的区间进行划分,共需n-1次关键字的比较,时间复杂度为O(n)。

对n个元素进行快速排序的过程构成一棵递归树,在这样的递归树中,每一层最多对n个元素进行划分,所花的时间为O(n)。

当初始排序数据随机分布,使每次分成的两个子区间中的元素个数大致相等时,递归树高度为log2n,快速排序呈现最好情况,即最好情况下的时间复杂度为O(nlog2n)。

综上,快速排序算法的平均时间复杂度也是O(nlog2n)。

空间复杂度:

就空间复杂度来说,主要是递归造成的栈空间的使用,最好情况,递归树的深度为log2n,其空间复杂度也就为O(logn),最坏情况,需要进行n‐1递归调用,其空间复杂度为O(n),平均情况,空间复杂度也为O(logn)。

综上,快速排序算法的平均空间复杂度是O(logn)。

8、桶排序

算法描述:

  1. 设置一个定量的数组当作空桶;
  2. 遍历输入数据,并且把数据一个一个放到对应的桶里去;
  3. 对每个不是空的桶进行排序;
  4. 从不是空的桶里把排好序的数据拼接起来。

示例图解: 在这里插入图片描述

代码实现:

	// 桶排序
    public void bucketSort(int[] arr){
    	// 数组为空,或者数据只有一个元素,直接返回
        if (arr == null || arr.length < 2){
            return;
        }
        // 数组不为空,实现桶排序
        bucketSort(arr,0,arr.length-1,new BucketSort().maxbits(arr));
    }

    // 桶排序的实现
    public void bucketSort(int []arr,int l,int r,int digit){
        final int radix = 10;
        int i = 0,j = 0;
        int []bucket = new int[r-l+1];
        for(int d= 1;d<=digit;d++){
            int []count = new int [radix];

            // 计算第d位数的数字出现了几次,记录在count数组中,等于i的数出现的次数
            for(i = l; i <= r; i++){
                j = getDigit(arr[i],d);
                count[j]++;
            }

            // 将数组改为前缀和数组, 小于等于i的数出现的次数
            for(i = 1; i < radix; i++){
                count[i] = count[i] + count[i-1];
            }

            // 省略入桶操作,根据前缀和,直接得到出桶位置count[i]-1,然后count[i]--,前缀和减小一
            for(i = r; i >= l; i--){
                j=getDigit(arr[i],d);
                bucket[count[j]-1] = arr[i];
                count[j]--;
            }

            // 将出桶后的操作记录到原数组
            for(i = l, j = 0; i <= r; i++, j++){
                arr[i] = bucket[j];
            }
        }
    }

    //返回数组中最大数的十进制位数
    public int maxbits(int []arr){
        int max = Integer.MIN_VALUE;
        for (int j : arr) {
            max = Math.max(max, j);
        }
        int res = 0;
        while(max!=0){
            res++;
            max /= 10;
        }
        return res;
    }

    //返回第d位数的数字
    public int getDigit(int x,int d){
        return ((x/((int)Math.pow(10,d-1)))%10);
    }

算法分析:

稳定性:

用桶来进出,先进先出,不破坏原来数据的稳定性,所以具有稳定性

时间复杂度:

对于待排序序列大小为 N,共分为 M 个桶,主要步骤有:

  1. N 次循环,将每个元素装入对应的桶中
  2. M 次循环,对每个桶中的数据进行排序(平均每个桶有 N/M 个元素)

整个桶排序的时间复杂度为:

O(N)+O(M∗(N/M∗log(N/M)))=O(N∗(log(N/M)+1)) O(N)+O(M*(N/Mlog(N/M)))=O(N(log(N/M)+1))O(N)+O(M∗(N/M∗log(N/M)))=O(N∗(log(N/M)+1))

当 N = M 时,复杂度为 O(N) O(N)O(N) 综上,平均时间复杂度为O(n+c)

空间复杂度:

取决于用桶的数量,空间复杂度O(n+c)。

后话

关于数据结构与算法的排序内容就这么多,当然排序算法还有其他的,但是主流的就这么几个。如果弄不懂这些排序的思想,那么请多看看排序算法讲解的视频或者图示,因为图片、视频给予的信息远远大于我写出来的文字。

然后这篇文章是《数据结构与算法》这类文章的第一篇,之后会学习更多的结构与算法,待学习并整理后会发布出来。

如果你从这篇文章中学到了有关排序的知识,那么请点个赞支持一下吧!如果在阅读过程中发现任何错误或者不理解,请在评论区留言!