Java数据结构初阶——七大排序算法及“非比较”排序

接下来博主会持续更新JavaSE、Java数据结构、MySQL、JavaEE、微服务、Redis等等内容的知识点整理。后续我也会精心制作算法解析、项目经验系列内容,内容绝对干货。相信这些文章能够成为我和大家的"葵花宝典",喜欢的话就关注一下吧!敬请期待!

文章目录

七大基于比较的排序算法基本原理及实现

常见的排序算法一览

内部排序&&外部排序

内部排序和外部排序

  1. 内部排序 (Internal Sorting)

数据全部在内存中进行排序,不涉及磁盘等外部存储器的I/O操作。

特点:

  • 数据量相对较小,能完全加载到内存中
  • 排序速度快,只涉及内存操作
  • 常见的排序算法都属于内部排序
  1. 外部排序 (External Sorting)

数据量太大,无法全部加载到内存中,需要在内存和外部存储(磁盘)之间多次交换数据进行排序。

特点:

  • 处理海量数据(GB、TB级别)
  • 涉及磁盘I/O操作,I/O效率是关键
  • 通常采用"分而治之"的策略(归并)

插入排序

基本思想:

  • 遍历一个序列,对每个遍历到的元素,该元素为新元素,之前处理完的序列为已有序列。遍历这个已有序列,找一个合适的位置将新元素插入。这样最后整个序列就完成排序了。

就像我们平时玩扑克牌时,每拿到一张新牌就会在手中已有的牌中寻找一个合适的位置将其插入。

直接插入排序

直接插入排序是基于插入排序基本原理的最基础的一种排序。

java 复制代码
public class Sort {

    public void inertSort(int[] arr){
        int size=arr.length;
        //外层循环更新要插入的元素
        for (int i = 1; i < size; i++) {
            int j=i-1;
            int tmp=arr[i];
            //内层循环处理每次的插入逻辑
            while (j>=0){
                //也就是谁大谁往空位上放
                if (arr[j]>tmp) {
                    arr[j+1]=arr[j];
                }else{
                    arr[j+1]=tmp;
                    break;
                }
                j--;
            }
            if(j<0){
                arr[0]=tmp;
            }
        }
    }
}

直接插入排序的特性总结:

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高。

最理想的情况是数据已经完全有序,这时每次比较都发现无需移动数据,算法只需要进行一轮线性扫描即可,这种情况时间复杂度为O(N)

  1. 时间复杂度: O(N^2)
  2. 空间复杂度: O(1)
  3. 稳定性:稳定

希尔排序( 缩小增量排序 )

希尔排序法又称缩小增量法。

  • 希尔排序法的基本思想是: 先选定一个整数gap,把待排序集合中所有数据分成多个组,所有距离为gap的数据分在同一组内。先对每一组内的数据进行排序(使用直接插入排序)。
  • 然后缩小分组数目,重复排序的工作。当到达gap=1时,就是所有数据为一组,对所有数据进行最后一次直接插入排序,这样也就完成了排序。(这样的优化好处见稍后下面的总结)

为什么要像上面这样分组?

  • 我们对每组数据进行排序时,会对组中数据移动,那如果每组中各数据都相距较远(我们采用相距gap-1,便于处理),当我们移动数据时,移动的跨度就会很大。这样的数据迁移会使当前数据集合整体的有序性大幅提高。这样以来,后续的每次排序的效率都会愈来愈高。

这里博主gap取值使用的是Knuth序列(gap=gap/3+1)。加1操作确保了序列最后一步的gap值必定为1,从而防止出现死循环或排序未完成的情况。

java 复制代码
public class Sort {

    public void shellSort(int[] arr){
        int gap=arr.length;
        //按照Knuth序列对gap进行取值,对于每个取到的gap值,我们对数据进行插入排序
        while (gap>1){
            gap=gap/3+1;
            shell(arr,gap);
        }
    }

    private void shell(int[] arr, int gap) {
        for (int i = gap; i < arr.length; i++) {
            int j=i-gap;                //参照上面的数据分组方法,可以发现外层循环逻辑
            int tmp=arr[i];             //同时完成了每组数据中需插入元素的更新和分组
            while(j>=0){                //进行排序的需求。 具体是:先分别处理每组首个需插入元素(1下标)
                if(arr[j]>tmp){         //再处理分别处理每组第二个需插入元素(2下标),以此类推.....
                    arr[j+gap]=arr[j];
                }else{                      //内层循环:完成对每个取到的arr[i]的插入排序逻辑
                    arr[j+gap]=tmp;
                    break;
                }
                j=j-gap;
            }
            arr[j+gap]=tmp;
        }
    }


}

希尔排序的特性总结:

  1. 希尔排序是对直接插入排序的优化。
  2. 当gap > 1时都是预排序,目的是让数组宏观上更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定。博主这里是按照Knuth提出的方式对gap取值的,而且Knuth进行了大量的试验统计,所以可以参考这种方法。这种取值的时间复杂度范围是O(N^1.3)~
    O(N^1.5)
  4. 稳定性:不稳定(由于将数据分组进行了排序,组内排序可能会破坏稳定性)。

选择排序

基本思想:

  • 每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。

直接选择排序

算法思想:

  • 在数据集合中找出合适的元素(最大或最小的元素),放到合适的位置(一般是数据集合开头位置)。这样数据集合不断向后缩小,我们不断在其中寻找最值。
  • 可以与插入排序做个对比,插入排序是将元素放入数据集合中,而这里的选择排序是从数据集合中挑选出一个元素来

直接选择排序有两种,第一种是从前往后遍历选最小值,第二种是遍历过程中同时选出最小值和最大值分别放到数组的两头。

method one

java 复制代码
public class Sort {
    public void selectSort(int[] arr){
        for (int i = 0; i < arr.length; i++) {//外层循环更新数据集合的边界,也就是每次更新的数据集合的起始位置
            int minIndex=i;//先假设起始位置就是最小值位置,也以便于遍历
            for (int j=i+1; j < arr.length; j++) {//内层循环来寻找集合中是否还有比起始位置元素更小的元素
                if(arr[j]<arr[minIndex]){
                    minIndex=j;
                }
            }
            swap(arr,i,minIndex);//将最小元素放到起始位置,这样完成了排序
        }
    }

    private void swap(int[] arr, int i, int minIndex) {
        int tmp=arr[i];
        arr[i]=arr[minIndex];
        arr[minIndex]=tmp;
    }


}

method two

java 复制代码
public class Sort {
    public void selectSort(int[] arr){
        int left=0;//集合的左边界(存放最小值)
        int right=arr.length-1;//集合的右边界(存放最大值)
        for (int i = 0; i <= right; i++) {//外层循环更新数据集合的边界,也就是每次把找到的最值放到边界之后,
            int minIndex=i;               //需要更新数据集合的边界(见最后的left、right的更新)
            int maxIndex=i;

            int j=i+1;
            while (j<=right){//内层循环来寻找集合中此时的min、max两个最值
                if(arr[j]<arr[minIndex]){
                    minIndex=j;
                }
                if(arr[j]>arr[maxIndex]){
                    maxIndex=j;
                }
                j++;
            }
            //再将最值放到边界
            swap(arr,left,minIndex);
            if(maxIndex==left){     //最大值可能正好在边界left上,这时swap(arr,left,minIndex)操作就会把max移走,
                maxIndex=minIndex;  //我们需要将max重新找回来
            }
            swap(arr,right,maxIndex);
            //更新边界
            left++;
            right--;
        }
    }


    private void swap(int[] arr, int i, int minIndex) {
        int tmp=arr[i];
        arr[i]=arr[minIndex];
        arr[minIndex]=tmp;
    }
}

直接选择排序的特性总结:

  1. 直接选择排序思路非常好理解,但是效率不是很好,实际中很少使用
  2. 时间复杂度: O(N^2)

尽管第二种方法同时找min和max的版本有优化,但它并没有改变直接选择排序算法时间复杂度为O(n²)的本质。当数据量较大时,其效率依然会远低于快速排序、归并排序或堆排序(O(n log n))等更高级的算法。

  1. 空间复杂度: O(1)
  2. 稳定性:不稳定

堆排序

堆排序即利用堆的思想来进行排序,总共分为两个步骤:

1.建堆

升序:建大根堆

降序:建小根堆

2.利用堆删除思想来进行排序建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。

java 复制代码
//堆排序(默认升序)
public class HeapSort {
    public void heapSort(int[] arr){
        creatHeap(arr);//建大根堆

        int end=arr.length-1;//注意end不是数组边界,而是数组的最后一个索引
        //通过循环每次将堆顶元素放到末尾,
        while (end>0) {
            swap(arr, 0, end);
            end--;//堆边界及时调整,避免后续操作再影响到已经排好序的元素
            siftDown(arr,end,0);//每次操作完就立即对因此而变化的堆进行调整(使用siftDown)
            
        }
    }

//建大根堆
    private void creatHeap(int []arr){
        if(arr.length==0) {
            return;
        }
        int child=arr.length-1;
        int parent=(child-1)/2;
        for(;parent>=0;parent--){
            siftDown(arr,arr.length-1,parent);
        }
    }

    private void siftDown(int []arr,int end,int parent){
        int child=parent*2+1;
        while (child<=end){
            if(child+1<=end&&arr[child]<arr[child+1]){
                child++;
            }
            if(arr[child]>arr[parent]){
                swap(arr,child,parent);
                parent=child;
                child=parent*2+1;
            }else {
                break;
            }
        }
    }


    private void swap(int[] arr,int i,int j){
        int tmp=arr[i];
        arr[i]=arr[j];
        arr[j]=tmp;
    }
}

堆排序的特性总结:

  1. 堆排序使用堆来选数,效率就高了很多。
  2. 时间复杂度: O(N*logN)
  3. 空间复杂度: O(1)
  4. 稳定性:不稳定

交换排序

基本思想:所谓交换,就是根据数据集合中两个元素值的比较结果来对换这两个元素在序列中的位置。

交换排序的特点是:将值较大的元素向序列的尾部移动,值较小的元素向序列的前部移动。

冒泡排序

java 复制代码
public class Sort {
    public void bubbleSort(int[] arr){
        for (int i = 0; i < arr.length-1; i++) {//外层循环:趟数,为length-1,每趟都会找出一个最大值并将它放到末尾
            boolean flag=false;//标记一下这趟有没有执行交换的操作
            for(int j=0;j<arr.length-1-i;j++){//内层循环负责找出最大值并将其放到末尾
                if(arr[j+1]<arr[j]){
                    swap(arr,j,j+1);
                    flag=true;
                }
            }
            if(!flag){//这趟没有交换任何元素,说明数据已经有序
                break;
            }
        }
    }
    private void swap(int[] arr, int i, int j) {

        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }
}

冒泡排序的特性总结:

  1. 冒泡排序是一种非常容易理解的排序
  2. 时间复杂度: O(N^2)
  3. 空间复杂度: O(1)
  4. 稳定性:稳定

快速排序

  • 快速排序是Hoare于1962年提出的基于二叉搜索树思想的一种交换排序方法。
  • 其基本思想为: 任取待排序元素序列中的某元素作为基准值,依照该基准值将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右两个子序列重复该过程,直到所有元素都排列在相应位置上为止。

主框架

java 复制代码
public class Sort {
    public void quickSort(int[] arr){//再封装一层,统一下方法的arr数组接口
        Qsort(arr,0,arr.length-1);

    }

    private void Qsort(int[] arr, int begin, int end) {
        if(begin>=end){
            return;
        }
        int divIndex=patition(arr,begin,end);
        //核心框架为递归:
        Qsort(arr,begin,divIndex-1);
        Qsort(arr,divIndex+1,end);
    }
}

上面为快速排序递归实现的主框架。

partition方法的实现

partition方法的算法实现有一下几种:

  1. Hoare版(也就是上述介绍的基础版本)
java 复制代码
private int patition(int[] arr, int left, int right) {
        int tmp=arr[left];
        int leftIndex=left;

        while (right>left) {

            while (right > left) {
                if (arr[right] < tmp) {
                    break;
                } else {
                    right--;
                }
            }
            while (right > left) {
                if (arr[left] > tmp) {
                    break;
                } else {
                    left++;
                }
            }

            swap(arr,right,left);
        }
        swap(arr,leftIndex,right);

        return right;
    }

    private void swap(int[] arr, int i, int j) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }
  1. 挖坑法

与Hoare版思想基本一致,还是遍历找到对于基准值的比较值,交换比较值,最后得到基准值两边分别是相较于其本身的较大较小值的数据呈现(也就是二叉搜索树的结构)。但是挖坑法不是直接交换两个比较值,而是留出一个元素空位,将对应比较值放入对应的空位中。

java 复制代码
        private int patition(int[] arr, int left, int right) {
            int tmp=arr[left];

            while (right>left) {

                //right找到较小值
                while (right > left) {
                    if (arr[right] < tmp) {
                        break;
                    } else {
                        right--;
                    }
                }
                arr[left]=arr[right];//将较小值放到left位置(空位)
                //left找到较大值
                while (right > left) {
                    if (arr[left] > tmp) {
                        break;
                    } else {
                        left++;
                    }
                }
                arr[right]=arr[left];//将较大值放到right位置(空位)

            }
            arr[left]=tmp;//最后再将基准值放到left、right相遇位置

            return right;
        }
  1. 前后指针法
  • 前后两个指针:cur和prve
  • 基本思想是:cur指针来遍历找到小于基准值的数据将它放到前面来,这样最后数据集合就变成了前面部分是较于基准值的较小值,后面部分是较大值,也就达到了我们的要求。
  • prve指针的作用:当cur再当前趟的遍历没有找到较小值还在往前走时,prve始终坚守在较小值集合的右边界。当cur找到较小值时,prve++,与cur位置的数据交换,这样prve有成了较小值集合的右边界。最后cur遍历完,再将基准值与prve交换,自此基准值左右数据分别为较小值、较大值。
java 复制代码
private int patition(int[] arr, int left, int right) {
        int prve=left;
        int cur=prve+1;

        while (cur<=right){
            //找到比基准值小的数据,就让prv++,交换数据
            if(arr[cur]<arr[left]&&arr[++prve]!=arr[cur]){//这里arr[++prve]!=arr[cur]做了一个优化,当prve下一个就是cur的话,
                swap(arr,prve,cur);                       //就只让prve++,此时与cur重合,就不用swap()了,开辟方法栈帧、执行语句
            }                                             //都耗费时间
            cur++;//注意这里,即使if语句没有执行,cur也要往前走一步,不然在"prve的下一个就是cur"的情况下会造成逻辑混乱
        }

        swap(arr,prve,left);

        return prve;
    }

    private void swap(int[] arr, int i, int j) {
        int tmp=arr[i];
        arr[i]=arr[j];
        arr[j]=tmp;
    }

遇选择题优先考虑使用挖坑法来推导

三种 partition() 所遇到的问题

以上快速排序方法缺点总结:

  1. 最坏情况时间复杂度为O(n²)

当输入数组已基本有序或逆序,且基准选择不当时,性能严重退化。时间复杂度从O(n*logn)退化到O(n²),性能变得和简单的冒泡排序、插入排序一样差。

常见优化方法:随机选择基准、三数取中法(更推荐)。

  1. 递归深度与栈空间问题

最坏情况下递归深度为O(n),很容易导致栈溢出。

常见优化方法:尾递归优化、对小规模子数组改用插入排序、非递归(迭代)实现等等。

  1. 并非总是最快

在某些特定场景下(如小数组或元素取值范围很小),其他算法可能更快。

常见优化方法:小数组时切换至插入排序。

关于快排的退化:

快速排序的理想情况是每次分区都能将数组均匀地分成两半。此时递归树的深度是 logn,每层处理的数据总和是 n,所以复杂度是 O(n*logn)。

退化发生在每次分区都极不均匀的时候,最经典的例子是:

1.数组已经有序(完全正序或完全逆序)。

2.如果我们简单地选择第一个元素(或最后一个元素)作为基准。

那么退化后:

1.算法的时间复杂度从高效的 O(nlogn) 降级为极低效的O(n²)。

2.由于递归深度变大,甚至达到n,就很容易出现栈溢出风险。

下面我们针对以上问题对快排进行优化

快速排序的优化

1.三数取中法

2.递归到小规模子数组时改用插入排序

对于以上两方法我们两种一块使用

java 复制代码
public class Sort {
    public void quickSort(int[] arr){//再封装一层,统一下方法的arr数组接口
        Qsort(arr,0,arr.length-1);

    }

    private void Qsort(int[] arr, int begin, int end) {
        if(begin>=end){
            return;
        }

        //若此次递归处理的区间规模不大于7,我们直接改用插入排序提升效率
        if(end-begin+1<=7){
            intervalInertSort(arr, begin,end);
            return;//别忘了已经使用插入排序处理过后,此时不用再往下进行了
        }

        //三数取中法:
        int middleNum=getMiddle(arr,begin,end);//找出left、right、它俩的中点位置的值,这三者中的中位数
        swap(arr,begin,middleNum);//更换基准值后再使用partition()处理

        int divIndex=patition(arr,begin,end);
        //核心框架为递归:
        Qsort(arr,begin,divIndex-1);
        Qsort(arr,divIndex+1,end);
    }

    private int getMiddle(int[] arr, int left, int right) {
        int middleIndex=(left+right)/2;
        if(arr[left]>arr[right]){
            if(arr[middleIndex]>arr[left]){
                return arr[left];
            }else if(arr[middleIndex]<arr[right]){
                return arr[right];
            }else{
                return arr[middleIndex];
            }
        }else{
            if(arr[middleIndex]<arr[left]){
                return arr[left];
            }else if(arr[middleIndex]>arr[right]){
                return arr[right];
            }else{
                return arr[middleIndex];
            }
        }
    }

    private void swap(int[] arr, int i, int j) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }

    private int patition(int[] arr, int left, int right) {
        int tmp=arr[left];

        while (right>left) {

            //right找到较小值
            while (right > left) {
                if (arr[right] < tmp) {
                    break;
                } else {
                    right--;
                }
            }
            arr[left]=arr[right];//将较小值放到left位置(空位)
            //left找到较大值
            while (right > left) {
                if (arr[left] > tmp) {
                    break;
                } else {
                    left++;
                }
            }
            arr[right]=arr[left];//将较大值放到right位置(空位)

        }
        arr[left]=tmp;//最后再将基准值放到left、right相遇位置

        return right;
    }


	//对于插入排序我们只是对它加了边界left、right
    public void intervalInertSort(int[] arr,int left,int right){
        //外层循环更新要插入的元素
        for (int i = left+1; i <=right; i++) {
            int j=i-1;
            int tmp=arr[i];
            //内层循环处理每次的插入逻辑
            while (j>=0){
                //也就是谁大谁往空位上放
                if (arr[j]>tmp) {
                    arr[j+1]=arr[j];
                }else{
                    arr[j+1]=tmp;
                    break;
                }
                j--;
            }
            if(j<0){
                arr[0]=tmp;
            }
        }
    }
}

三数取中法到底起了什么作用?

  • 三数取中法就是为了对抗快排的退化而设计的一种优化策略。我们通过这个方法取得的数据有很大概率接近数据集合的中位数,这样使得分区后两个子数组的大小尽量均衡,从而避免递归深度过深。对于完全随机的数组,三数取中法也不会增加太多成本,且能略微提升平均性能。

快速排序非递归实现

基本思想不变,只不过这次我们要用迭代的方法来完成上述递归方法过程中所处理数据的过程。

java 复制代码
import java.util.Deque;
import java.util.PriorityQueue;
import java.util.Queue;
import java.util.Stack;

public class Sort {

    public void quickSort(int[] arr){
        Stack<Integer> stack=new Stack<>();
        //第一遍先用partition()处理一下
        int start=0;
        int end=arr.length-1;
        int divIndex=patition(arr,start,end);
        //将左右子数组的边界压入栈
        if(end-divIndex>1){//若子数组规模比2小,就不需要再处理了,则不压入栈
            stack.push(divIndex+1);
            stack.push(end);
        }
        if(divIndex-start>1){
            stack.push(start);
            stack.push(divIndex-1);
        }
        //此时可以循环进行操作,从栈中取出子数组边界,使用partition()处理数据,再视情况对此趟数组的左右子数组边界压栈
        while (!stack.empty()){
            end=stack.pop();
            start=stack.pop();
            divIndex=patition(arr,start,end);

            if(end-divIndex>1){
                stack.push(divIndex+1);
                stack.push(end);
            }
            if(divIndex-start>1){
                stack.push(start);
                stack.push(divIndex-1);
            }
        }
    }



    //patition()依然使用挖坑法这个版本
    private int patition(int[] arr, int left, int right) {
        int tmp=arr[left];

        while (right>left) {

            //right找到较小值
            while (right > left) {
                if (arr[right] < tmp) {
                    break;
                } else {
                    right--;
                }
            }
            arr[left]=arr[right];//将较小值放到left位置(空位)
            //left找到较大值
            while (right > left) {
                if (arr[left] > tmp) {
                    break;
                } else {
                    left++;
                }
            }
            arr[right]=arr[left];//将较大值放到right位置(空位)

        }
        arr[left]=tmp;//最后再将基准值放到left、right相遇位置

        return right;
    }
}

快速排序总结:

  1. 快速排序整体的综合性能和使用场景都是比较好的。
  2. 时间复杂度:O(N*logN)
  3. 空间复杂度: O(logN)
  4. 稳定性:不稳定

归并排序

归并排序(mergsort)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。

归并排序的递归实现

基本思想:

  • 将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
  • 具体做法就是不断的递归均分数组(更新数组的边界),直到得到了最小的数组单元。此时我们就可以以数组规模从小到大的过程进行合并同时排序,最终得到了有序的数组序列。
  • 核心关键就是抓住"有序数组合并问题"。
java 复制代码
public class Sort {
    public void mergeSort(int[] arr){//还是先套一层,统一参数接口---int[] arr
        int begin=0;
        int end=arr.length-1;
        mergeSortTmp(arr,begin,end);
    }

    /*
    其实mergeSortTmp()递归的过程就是不断更新数组边界的过程,直到我们得到了最小的数组单元。
    此时我们就可以以数组规模从大到小的过程进行合并同时排序,最终得到了有序的数据序列
     */
    private void mergeSortTmp(int[] arr, int begin, int end) {
        if(begin==end){
            return;
        }
        int midIndex=(begin+end)/2;//将数组分为两半,接下来继续往下分
        mergeSortTmp(arr,begin,midIndex);
        mergeSortTmp(arr,midIndex+1,end);
        //将两个数组拿来进行合并同时排序,此时最底层情况是两个单元素数组合并(begin==end这种情况已返回)
        merge(arr,begin,midIndex,end);
    }

    //merge()的逻辑就是经典的合并两个有序数组的方法
    private void merge(int[] arr, int begin, int midIndex, int end) {
        int l1=begin;
        int r1=midIndex;
        int l2=midIndex+1;
        int r2=end;
        int[] tmp=new int[end-begin+1];
        int k=0;

        while (l1<=r1&&l2<=r2){
            if(arr[l1]<=arr[l2]){   //归并排序之所以为稳定的排序就是因为这里比较取等了,也就是当遇到两个相同的元素时,即使前者与后者相同,
                tmp[k++]=arr[l1++]; //我们依然会把前面的元素放入已排序序列,这样就不会造成两者的相对位置变化
            }else{
                tmp[k++]=arr[l2++];
            }
        }

        while (l1<=r1){
            tmp[k++]=arr[l1++];
        }
        while (l2<=r2){
            tmp[k++]=arr[l2++];
        }

        for (int i = 0; i <tmp.length ; i++) {
            arr[begin+i]=tmp[i];
        }
    }
}

归并排序总结:

  1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的应用更多的是解决在磁盘中的外部排序问题。
  2. 时间复杂度: O(N*logN)
  3. 空间复杂度: O(N)
  4. 稳定性:稳定

归并排序的非递归实现

java 复制代码
public class Sort {
    public void mergeSortNor(int[] arr){
        int size=1;//每组完整数组的元素个数
        while (size<arr.length){//外层循环每趟对于当前的size值,多次对数据集合进行合并,每次取两个数组进行合并
            for (int i = 0; i < arr.length-1; i+=size*2) {//内层循环则对该趟遍历每次取两个数组进行合并
                int left=i;
                int midIndex=left+size-1;
                if(midIndex>=arr.length){
                    midIndex=arr.length-1;
                }
                //int right=(left+size*2)-1;(1)    //对于注释部分(1)(2),我们的核心思路原本是"原本是合并两个有序数组",这两处错误的
                int right=midIndex+size;           //原因在于当遍历到最后一部分数据时,剩下的可能是"一组完整的有序数组+不完整的(<size的)
                if(right>=arr.length){             //有序数组,但注释处却想当然地将其转变为了"两组均分的数组",这样就将原本独立有序的数组
                    right=arr.length-1;            //全部打乱了,违背了核心思路。
                }
                //int midIndex=(left+right)/2;(2)

                merge(arr,left,midIndex,right);
            }

            size*=2;
        }
    }

    //merge()的逻辑就是经典的合并两个有序数组的方法
    private void merge(int[] arr, int begin, int midIndex, int end) {
        int l1=begin;
        int r1=midIndex;
        int l2=midIndex+1;
        int r2=end;
        int[] tmp=new int[end-begin+1];
        int k=0;

        while (l1<=r1&&l2<=r2){
            if(arr[l1]<=arr[l2]){   //归并排序之所以为稳定的排序就是因为这里比较取等了,也就是当遇到两个相同的元素时,即使前者与后者相同,
                tmp[k++]=arr[l1++]; //我们依然会把前面的元素放入已排序序列,这样就不会造成两者的相对位置变化
            }else{
                tmp[k++]=arr[l2++];
            }
        }

        while (l1<=r1){
            tmp[k++]=arr[l1++];
        }
        while (l2<=r2){
            tmp[k++]=arr[l2++];
        }

        for (int i = 0; i <tmp.length ; i++) {
            arr[begin+i]=tmp[i];
        }
    }
}

海量数据的排序问题

外部排序:排序过程需要在磁盘等外部存储进行的排序

比如:

内存只有 1G,而需要排序的数据有 100G。因为内存中因为无法把所有数据全部放下,所以需要外部排序,而归并排序是最常用的外部排序:

  1. 先把大文件切分成 200 份子文件,每个 512 M
  2. 分别对每个子文件排序,因为此时内存已经可以放的下每个子文件的全部数据,所以任意排序方式都可以
  3. 进行 2路归并,同时对 200 份有序文件做归并过程,最终结果就有序了。这还是经典的有序数组合并问题,我们就每次取两个已有序的子文件,一边读取数据并比较、一边将处理完的数据写入到一个大文件中,这样一直从小规模文件到大规模文件。

排序算法复杂度及稳定性总结表

According to 博主的上课笔记,及AI总结

其他非基于"比较"的排序

计数排序

计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。

操作步骤:

  1. 遍历待排序数组,将其值Val作为另一个计数数组count的下标Index,在count的Index位置实现count[Index]++,来记录此Val出现的次数。
  2. 再遍历计数数组count,根据统计的结果将被记录的数据再按顺序一一放回原数组。由于此算法利用了count数组下标天然的"顺序"特性,所以当对应地记录下原数组数据时,在count数组中它们也就有序了。
java 复制代码
public class Sort {
    public void countSort(int[] arr){
        //先找出arr中的最大、最小值
        int maxVal=arr[0];
        int minVal=arr[0];
        for (int i = 1; i <arr.length ; i++) {
            if(arr[i]<minVal){
                minVal=arr[i];
            }
            if(arr[i]>maxVal){
                maxVal=arr[i];
            }
        }
        //由minVal和maxVal来决定count数组的大小,不浪费空间
        int len=maxVal-minVal+1;
        int[] count=new int[len];
        //计数
        for (int i = 0; i < arr.length; i++) {
            int Index=arr[i]-minVal;
            count[Index]++;
        }
        //将计数结果再放回原数组,排序完成
        int k=0;
        for (int i = 0; i < count.length; i++) {
            while (count[i]>0){
                arr[k]=i+minVal;
                k++;
                count[i]--;
            }
        }
    }
}

计数排序的特性总结:

  1. 计数排序在数据范围(range)集中时,效率很高。因为count数组的大小就取决于minVal和maxVal,如果数据太过分散,就会有大量空间浪费且因此会有很多无效的count数组遍历,所以适用范围及场景有限。
  2. 时间复杂度: O(n+range)
  3. 空间复杂度: O(range)
  4. 稳定性:可实现为稳定算法(但博主的这个是不稳定的)

桶排序

  • 桶排序的基本思想是:将数据范围分成若干个区间(桶),将数据分配到对应的桶中,然后对每个桶内部排序,最后合并。

  • 桶排序是计数排序的扩展版本,计数排序可以看成每个桶只存储相同元素,而桶排序每个桶存储一定范围的元素,将数据分配到对应的桶中,对每个桶中的元素进行排序,最后将非空桶中的元素逐个放入原序列中。

  • 桶排序需要尽量保证元素分散均匀,否则当所有数据集中在同一个桶中时,桶排序失效。

java 复制代码
import java.util.ArrayList;
import java.util.Collections;

public class Sort {
    public void bucketSort(int[] arr){
        int maxVal=0;
        int minVal=0;
        for (int i = 0; i < arr.length; i++) {
            if(arr[i]<minVal){
                minVal=arr[i];
            }
            if(arr[i]>maxVal){
                maxVal=arr[i];
            }
        }
        /*
        计算桶的数量:
        为了使数据在各桶平均分配,使用使用数据总跨度(maxVal-minVal)/数据个数(arr.length),
        得出数据间的平均跨度(变化量),反过来以这个平均跨度作为桶数量,那么每个桶的跨度就是确定的,
        每个桶所储存的数据量也就更接近平均
         */
        int bucketNum=(maxVal-minVal)/arr.length+1;
        //分配桶
        ArrayList<ArrayList<Integer>> bucketArr=new ArrayList<>();
        for (int i = 0; i < bucketNum; i++) {
            bucketArr.add(new ArrayList<>());
        }
        //将数据放入对应的桶中储存
        for (int i = 0; i < arr.length; i++) {
            int j=(arr[i]-minVal)/arr.length;//找到元素所对应的桶,将其放入
            bucketArr.get(j).add(arr[i]);
        }
        //对每个桶中元素进行排序
        for (int i = 0; i < bucketNum; i++) {
            Collections.sort(bucketArr.get(i));//若使用Arrays类则需要传int[] 类型,不能直接传ArrayList
        }
        //将排好序的数据再放回原数组
        int Index=0;
        for (int i = 0; i < bucketArr.size(); i++) {
            for (int j = 0; j < bucketArr.get(i).size(); j++) {
                arr[Index++]=bucketArr.get(i).get(j);
            }
        }

    }
}

桶排序复杂度分析:

  1. 时间复杂度:O(N + C)

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

1.N 次循环,将每个元素装入对应的桶中

2.M 次循环,对每个桶中的数据进行排序

(平均每个桶有N/M个元素)

桶内排序一般使用较为快速的排序算法,时间复杂度为O(NlogN)。

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

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

当N = M时,复杂度为O(N)

  1. 额外空间复杂度:O(N + M)
  2. 稳定性:桶排序的稳定性取决于桶内排序使用的算法。

基数排序

  • 基数排序也用到了计数排序原理,或者说是计数排序和桶排序的结合,它的基本思想是:对于数据集合,按照计数排序的方法处理,但是要以对于每位数从低位到高位上的数字为依据进行排序,多位数就要进行多轮排序。(如,352,先按个位数字为依据,数字为2,就将352先放到"桶2"中,以此类推)
  • 例,若352在一个集合中是最多位数,是个三位数,那该数据集合就要就行三轮排序。
  • 那对于数字而言,就只需要准备0~9这10个桶就可以完成所有范围的整数集合的排序,也可以看做对计数排序的优化。
java 复制代码
import java.util.ArrayList;

public class Sort {
    public void radixSort(int[] arr){
        //准备10个桶(因为数字从0~9)
        ArrayList<ArrayList<Integer>> bucketArr=new ArrayList<>();
        for (int i = 0; i < 10; i++){
            bucketArr.add(new ArrayList<>());
        }
        //计算数据中的最大位数,确定外层循环次数
        int max=0;
        for (int i = 0; i < arr.length; i++) {
            if(arr[i]>max){
                max=arr[i];
            }
        }
        int count=(max+"").length();//得出最大值的位数

        //核心思路:
        for (int i = 0; i < count; i++) {//外层循环:从最低位开始对每个数据的每一位进行单独比较,最大有count位
            //分桶
            int div=1;
            for (int j = 0; j < arr.length; j++) {
                int num=(arr[j]/div)%10;
                bucketArr.get(num).add(arr[j]);
            }
            //取出并放回原数组
            int Index=0;
            for (int j = 0; j < bucketArr.size(); j++) {
                for (int k = 0; k < bucketArr.get(j).size(); k++) {
                    arr[Index++]=bucketArr.get(j).get(k);
                }
            }

            div*=10;//本轮排序完成,将div*10,下一轮将按下一位排序

        }
    }
}

基数排序是一种稳定的排序算法

觉得文章对你有帮助的话就点个赞,收藏起来这份免费的资料吧!也欢迎大家在评论区讨论技术、经验

相关推荐
a努力。2 小时前
得物Java面试被问:Kafka的零拷贝技术和PageCache优化
java·开发语言·spring·面试·职场和发展·架构·kafka
专家大圣2 小时前
Tomcat+cpolar 让 Java Web 应用跨越局域网随时随地可访问
java·前端·网络·tomcat·内网穿透·cpolar
予枫的编程笔记2 小时前
【Java进阶】深度解析Canal:从原理到实战,MySQL增量数据同步的利器
java·开发语言·mysql
Filotimo_2 小时前
在java后端开发中,LEFT JOIN的用法
java·开发语言·windows
2301_797312262 小时前
学习Java43天
java·开发语言
程序员老徐2 小时前
Spring Security 是如何注入 Tomcat Filter 链的 —— 启动与请求源码分析
java·spring·tomcat
充值修改昵称4 小时前
数据结构基础:B树磁盘IO优化的数据结构艺术
数据结构·b树·python·算法
Leo July10 小时前
【Java】Spring Security 6.x 全解析:从基础认证到企业级权限架构
java·spring·架构