数据结构与算法(十二):八大经典排序算法再回顾

时间:2024-01-22 19:28:50

文章出自汪磊的博客,未经允许不得转载

一、排序的理解

提到排序大部分同学肯定第一时间想到int数组的排序,简单啊,所谓排序不就是将int数组按照从大到小或者从小到大排序吗,如果我有个数组存放的不是int数据,而是一个个对象呢?你怎么排序?所以我们首先要明确排序的定义:

排序指的是将一个数据元素的任意序列,重新排列成一个按照关键字有序的序列。

所谓排序最重要的是按照什么排序,就是定义中的关键字,上面说的对象数组排序,我们得明确按照对象的哪个关键字排序,否则就无法排序,好了,这里比较简单,只是提一下,不要说道排序就是int数组排序,有关键字的数据序列都可以排序。

二、排序算法中术语了解

在我们学习排序算法的时候经常听到一些术语,比如:排序分外部排序,内部排序,也有稳定不稳定之分,相信很多同学看到都是一带而过,很多也根本就没有讲,只是告诉你这个排序是稳定,那个不稳定,这个外部,那个内部,那到底这些都是什么玩意?到底怎么区分的?首先我们先了解这些概念都具体指的是什么,其实都很简单。

排序方法的稳定与不稳定之分

假设数组中有两个数据a与b,a与b是相等的(排序的关键字相等),经过某一排序算法排序后a与b的相对位置没变(比如排序前a在b的前面,排序后a依然在b的前面)则这个排序算法是稳定的,否则就是不稳定的。

很好理解,我就不用多余废话解释了。

排序方法的内部与外部之分

内部排序指的是排序记录存放在计算机内存中进行的排序。
外部排序指的是待排序数据量很大,以致内存一次不能容纳全部数据,在排序过程中尚需对外存访问的排序过程。

好了以上了解了一些基本术语,起码以后说起来你应该知道具体指的是什么,很多文章上来给你一张表直接告诉你这个排序是稳定的还是不稳定的,内部还是外部,我觉得毫无意义,因为你记不住,也完全没必要记,但是理解了这些基本概念,你自己可以分析稳不稳定,外部还是内部,重要的是自己会分析。

以下我们进入具体的排序算法学习。

三、具体排序算法

冒泡排序

冒泡排序的思想比较简单:比如有n个数据需要从小到大排列,第一轮从头到尾两两比较,如果不符合规则则调换位置,比较n-1次后所有数据已经都比较过了,第一轮后最大的数据就位于最尾部了,接下来只需要对其余n-1个数据进行两两比较,依然从头开始比较,第二轮下来第二大的数据就尾部倒数第二的位置了,重复上述过程直到数据有序为止。

举例:如下就是冒泡排序的大体过程
在这里插入图片描述

这里有个问题,如上图从第二轮开始数据就已经是有序的了,以下的比较就已经没有意义了,所以冒泡排序可以添加一个标记,如果一个比较过程中发现数据已经是有序的了,那么后续的比较就没有必要了,这里可以优化以下。

冒泡排序代码实现

    /**
     * 冒泡排序:普通数组的排序
     * @param array
     */
    public static void bubbleSort(int[] array) {
        //
        for (int i = array.length - 1; i > 0; i--) {
            boolean flag = true;//优化:如果已经有序减少不必要的比较
            for (int j = 0; j < i; j++) {
                if (array[j] > array[j + 1]) {
                    int temp = array[j];
                    array[j] = array[j + 1];
                    array[j + 1] = temp;
                    flag = false;
                }
            }
            if (flag) {
                break;
            }
        }
    }

 

冒泡排序试用场景
经过上述过程我们了解到冒泡排序的过程存在大量的比较,即使经过上面一个小小的优化,如果数据量特别大并且大部分无序的,同样需要大量的两两比较,时间复杂度最好最坏均为O(n2),所以冒泡排序适用于数据量非常小的排序

选择排序

选择排序大体思路:同样有n个数据需要从小到大排列,第一轮,固定角标为0的数据,然后遍历其余数据,选出最小的数据与角标为0数据互换,第二轮,固定角标为1数据,然后遍历其余数据,选出最小的数据与角标为1数据互换,依次类推。

选择排序大体过程:


选择排序实现依然有可以优化的地方,比如上面第二轮查找比数据4小的数据,显然没有查到,所以就没必要执行数据交换的代码。

选择排序代码实现

/**
     * 选择排序
     * @param array
     */
    public static void selectSort(int[] array) {
        for (int i = 0; i < array.length - 1; i++) {
            int index = i;
            //遍历余下数据找出最小的数据
            for (int j = i + 1; j < array.length; j++) {
                if (array[j] < array[index]) {
                    index = j;
                }
            }

            if (index != i) {//如果已经是最小的,就不需要交换
                int temp = array[index];
                array[index] = array[i];
                array[i] = temp;
            }
        }
    }

 

选择排序试用场景
同冒泡排序一样,适用数据规模非常小的情况。

快速排序

快速排序大体思路:快排就是通过一趟排序将原数据分成两部分,其中一部分关键字都比另一部分小,接下来再对这两部分分别使用快速排序,这里有递归的思想。

快速排序大体过程:



第一趟排序后,10的左侧都是小于10的数据,10的右侧都是大于10的数据,接下来分别对左右侧数据在进行快速排序即可。

快速排序代码实现

/**
     * 快速排序
     * @param array 排序的数组
     * @param begin 开始的位置
     * @param end 结束的位置
     */
    public static void quickSort(int[] array,int begin,int end){
        if(end-begin<=0) return;
        int x=array[begin];
        int low=begin;//0
        int high=end;//5
        //由于会从两头取数据,需要一个方向
        boolean direction=true;
        WangLei:
        while(low<high){
            if(direction){//从右往左找
                for(int i=high;i>low;i--){
                    if(array[i]<=x){
                        array[low++]=array[i];
                        high=i;
                        direction=!direction;
                        continue WangLei;//跳转到WangLei处,从WangLei处开始执行
                    }
                }
                high=low;//如果上面的if从未进入,让两个指针重合
            }else{
                for(int i=low;i<high;i++){
                    if(array[i]>=x){
                        array[high--]=array[i];
                        low=i;
                        direction=!direction;
                        continue WangLei;
                    }
                }
                low=high;
            }
        }
        //把最后找到的值 放入中间位置
        array[low]=x;//array[high]=x同样可以
        //左右两侧进行快排
        quickSort(array,begin,low-1);
        quickSort(array,low+1,end);
    }

 

快速排序试用场景


快速排序的平均时间复杂度为O(nlgn),所以其适用于数据量大的情况,但是快速排序实现需要很多次对数据位置的操作,这里想一下如果排序之前数据是链式存储的会怎么样?还记得本系列文章开始讲解数组,链表的区别吗?这里,如果链式存储频繁对位置操作效率会下降很多,有大量重复数据的时候,性能同样不好,也就是说快速排序适用于数据量大重复数据少数据是顺序存储结构的情况,不适用与链式存储结构

很多同学刚工作的时候遇到排序上来就用Arrays.sort(…),其实其内部实现就是快速排序,但是你有没有发现只适用于数组类型数据,链表是不适用的,原因就是上面说的,并且强烈建议不要使用JDK中的排序,JDK中就拿Arrays.sort(…)来说实现100多行,自己实现就简单多了,因为JDK会照顾所有开发者情况,效率难免会差一些,自己实现不用考虑那么多特殊情况。

基数排序

基数排序大体思路:基数排序是按照多种关键字排序,关键字之间有优先级别,先按照低优先级排序,收集,然后按照高优先级排序,收集,这样高优先级的就在前面,高优先级相同而低优先级高的在前面。

基数排序大体过程:
这里我们用麻将游戏举例:玩游戏麻将的排序就可以用基数排序,麻将有两个重要属性:花色与点数,优先按照花色排序,然后按照点数排序。

基数排序代码实现

Majiang.java
public class Majiang {

    public int suit;//花色一到三
    public int rank;//点数一到九

    public Majiang(int suit, int rank) {
        this.suit = suit;
        this.rank = rank;
    }

    @Override
    public String toString() {
        return "("+this.suit+" "+this.rank+")";
    }
}

 

核心算法实现:这里只是一种举例,针对麻将的排序,重点是理解思想,用到的时候根据自己需求改造。

    public static void radixSort(LinkedList<Majiang> list){
        //先对点数进行分组
        LinkedList[] rankList=new LinkedList[9];
        for (int i=0;i<rankList.length;i++){
            rankList[i]=new LinkedList();
        }
        //把数据一个一个的放入到对应的组中
        while(list.size()>0){
            Majiang m=list.remove();
            rankList[m.rank-1].add(m);
        }
        //收集数据
        for (int i = 0; i < rankList.length; i++) {
            list.addAll(rankList[i]);
        }

        //然后按照花色数进行分组
        LinkedList[] suitList=new LinkedList[3];
        for (int i=0;i<suitList.length;i++){
            suitList[i]=new LinkedList();
        }
        //把数据一个一个的放入到对应的组中
        while(list.size()>0){
            Majiang m=list.remove();
            suitList[m.suit-1].add(m);
        }
        //再收集数据
        for (int i = 0; i < suitList.length; i++) {
            list.addAll(suitList[i]);
        }
    }

 

基数排序适用场景
显然基数排序适用于多关键字的排序,但是如果数据量很小,比如就7,8个数据同样需要多关键字排序,这时候我们完全可以用冒泡排序,下面看一下针对数据量小的多关键字排序:

排序对象类:

public class Cards implements Comparable{
    public int pokerColors;//花色
    public int cardPoints;//点数

    public Cards(int pokerColors, int cardPoints) {
        this.pokerColors = pokerColors;
        this.cardPoints = cardPoints;
    }
    //用来比较对象的大小:先比较花色,再比较点数
    @Override
    public int compareTo(Object o) {
        Cards c=(Cards)o;
        if(this.pokerColors>c.pokerColors){
            return 1;
        }else if(this.pokerColors<c.pokerColors){
            return -1;
        }
        if(this.cardPoints>c.cardPoints){
            return 1;
        }else if(this.cardPoints<c.cardPoints){
            return -1;
        }
        return 0;
    }

    @Override
    public String toString() {
        return "Cards{" +
                "pokerColors=" + pokerColors +
                ", cardPoints=" + cardPoints +
                \'}\';
    }
}

 

排序方法:

    /**
     * 冒泡排序:对象排序
     * @param array
     */
    public static void bubbleSort(Cards[] array) {  //3-5个数据  78
        //1 2 3 4 5 9 4 6 7    n*(n-1)/2   n
        for (int i = array.length - 1; i > 0; i--) {
            boolean flag = true;
            for (int j = 0; j < i; j++) {
                if (array[j].compareTo(array[j + 1]) > 0) {
                    Cards temp = array[j];
                    array[j] = array[j + 1];
                    array[j + 1] = temp;
                    flag = false;
                }
            }
            if (flag) {
                break;
            }
        }
    }

 

是不是很简单,不过多解释

直接插入排序

直接插入排序大体思路:直接插入排序是将一个记录插入到已排好序的的有序表中,从而得到一个新的,记录数增1的有序表。

** 直接插入排序举例 **:
在这里插入图片描述

直接插入排序实现 :

    /**
     * 直接插入排序
     * @param array
     */
    public static void insertSort(int[] array){
        for(int i=1;i<array.length;i++){
            int j=i;
            int target=array[i];//表示想插入的数据
            while(j > 0  && target<array[j-1]){//如果插入的数据小于数组的前一个时
                array[j]=array[j-1];
                j--;
            }
            array[j]=target;
        }
    }

 

直接插入排序适用场景:
直接插入排序插入有序序列中需要从后向前挨个扫描数据,并且还要将数据向后移为新数据腾出位置,显然当数据量大的时候效率很低,直接插入排序适用数据量小的情况。

希尔排序

希尔排序大体思路:希尔排序又称“缩小增量排序”,它也是一种属插入排序类的方法,但在时间效率上相比直接插入排序好很多,基本思想为:先将整个待排记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行一次直接插入排序。

希尔排序举例
在这里插入图片描述

从上述排序过程可见,希尔排序的一个特点是:子序列的构成不是简单的“逐段分割”,而是将相隔某个“增量”的记录组成一个子序列。如上例中,第一趟排序时增量为5,第二趟排序时增量为3,由于在前两趟的插入排序中记录的关键字是和同一子序列中的前一个纪录的关键字进行比较,因此关键字较小的记录就不是一步一步往前移动,而是跳跃式的往前移动,从而使得在进行最后一趟增量为1的插入排序时,序列已基本有序,只要做记录的少量比较和移动即可完成排序,因此希尔排序的时间复杂度较直接插入排序低。

希尔排序增量的取值
已知的最好增量序列由Marcin Ciura设计(1,4,10,23,57,132,301,701,1750,…)
这项研究也表明“比较在希尔排序中是最主要的操作,而不是交换。” 用这样步长序列的希尔排序比插入排序和堆排序都要快,甚至在小数组中比快速排序还快, 但是在涉及大量数据时希尔排序还是比快速排序慢。

** 希尔排序实现 **:

    /**
     * 希尔排序  step表示的是步长
     * @param array
     * @param step
     */
    public static void shellSort(int[] array,int step){
        for(int k=0;k<step;k++) {//对步长的定位,选择每次操作的开始位置
            for(int i=k+step;i<array.length;i=i+step){//i表示从第2个数开始插入
                int j=i;
                int target=array[i];//表示想插入的数据
                while(j>step-1  && target<array[j-step]){//如果插入的数据小于数组的前一个时
                    array[j]=array[j-step];
                    j=j-step;
                }
                array[j]=target;
            }
        }
    }

 

希尔排序测试

    public void test(){
        int[] array=new int[]{2,3,4,5,6,7,1,8,9};
        shellSort(array,4);//先以步长4排序
        //2 3 1 5 6 7 4 8 9
        shellSort(array,1);//最后必须以步长1排序
        for (int i = 0; i < array.length; i++) {
            System.out.print(array[i]+" ");
        }
    }

 

希尔排序适用场景
合适数据量中等情况,几十个到几万个。

归并排序

归并排序是一种新思路的排序方法,“归并”的含义是将两个或两个以上的有序表组合成一个新的有序表。无论是顺序存储结构还是链式存储结构都可在O(m+n)的时间量级上实现(假设两个有序表长度分别为m和n)。

假设初始序列含有n个记录,则可看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到n/2个长度为2的有序子序列;再两两归并,…,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2-路归并排序。

归并排序举例

可以看到归并排序是先拆分后合并,在代码中也有体现

归并排序代码实现

    //归并排序
    public static void mergeSort(int array[],int left,int right){
        if(left==right){
            return;
        }else{
            int mid=(left+right)/2;
            //拆分过程
            mergeSort(array,left,mid);
            mergeSort(array,mid+1,right);
            //合并过程
            merge(array,left,mid+1,right);
        }
    }

    private static void merge(int[] array,int left,int mid,int right){
        int leftSize=mid-left;
        int rightSize=right-mid+1;
        //生成数组
        int[] leftArray=new int[leftSize];
        int[] rightArray=new int[rightSize];
        //填充数据
        for(int i=left;i<mid;i++){
            leftArray[i-left]=array[i];
        }
        for(int i=mid;i<=right;i++){
            rightArray[i-mid]=array[i];
        }
        //合并
        int i=0;
        int j=0;
        int k=left;
        //合并数组使其有序
        while(i<leftSize && j<rightSize){
            if(leftArray[i]<rightArray[j]){
                array[k]=leftArray[i];
                k++;i++;
            }else{
                array[k]=rightArray[j];
                k++;j++;
            }
        }
        //填充上面过程未被合并的余下数据
        while(i<leftSize){
            array[k]=leftArray[i];
            k++;i++;
        }
        while(j<rightSize){
            array[k]=rightArray[j];
            k++;j++;
        }
    }

 

归并排序适用场景
归并排序适用于数据量大,同时解决了快速排序的痛点,大量重复数据并且链式结构同样适用(链式结构需要自己修改上述代码),但是归并排序同样也有问题就是需要开辟额外空间。

堆排序

理解堆排序我们首先需要了解一下什么是堆,堆都不理解何谈什么堆排序。

堆的定义
n个元素的序列{k1,k2,k3,…,kn}当且仅当满足一下关系时,称之为堆。

ki<=k2i并且ki<=k2i+1

或者ki>=k2i并且ki>=k2i+1 (i=1,2,3,…,n/2)

如序列{96,83,27,38,11,09}就是堆,同样{12,36,24,85,47,30,53,91}也是堆。注意角标从1开始取啊,不是0,用惯数组别看到就从0开始。

若将和此序列对应的一维数组(即以一维数组作此序列的存储结构)看成是一个完全二叉树,则堆的含义表明,完全二叉树中所有的非叶子节点的值均不大于(或不小于)其左右孩子节点的值。(完全二叉树不了解的可以看我之前文章)

例如:序列{96,83,27,38,11,09} 对应完全二叉树如下:
在这里插入图片描述

序列转换成完全二叉树对应关系
比如元素A在序列中位置为i,则转换为完全二叉树其两个子孩子是序列中位置为2i与2i+1位置的元素。

明白以上概念后我们再来看一下堆排序的定义:

若在输出堆顶元素后,使得剩余n-1个元素的序列又重新建成一个堆,则得到n个元素中次小值,如此反复执行,便能得到一个有序序列,这个过程称之为堆排序

实现堆排序面临的问题

在实现堆排序前我们需要解决两个问题:
(1):如何将一个无序序列建成一个堆?
(2):如何在输出堆顶元素之后,调整剩余元素成为一个新的堆?

我们先讨论第2个问题:

如下图是一个堆(此堆父节点均比左右孩子小):

假设输出堆顶元素13后,以最后一个元素替代,如图:



显然此时已经不是堆了,需要自上而下进行调整,首先将堆顶元素97与左右两个孩子38,27比较选取最小的27与97互换,如下图:


此时右子树又不满足条件了,需要继续调整右子树,显然需要49与97互换:


此时就是一个标准的堆了,调整后堆顶27为原序列次小的值,再将27输出用最后一个元素97替换,继续上述调整为一个新的堆,我们称这个自堆顶至叶子的调整过程称为筛选

我们再看问题1:
其实从一个无序序列建堆的过程中就是一个反复**“筛选”的过程,若将此序列看成是一个完全二叉树,则只需从最后一个非叶子节点**(在序列中位置为n/2)开始调整。

例如,如下初始无序序列:
{49,38,65,97,76,13,27,49}

对应完全二叉树:

从最后一个非叶子节点97开始调整,显然49与97互换,然后调整下一个非叶子节点65,显然13与65互换,继续调整38节点,无需调整。

调整后如下图:

 

最后调整堆顶49节点,显然13与49互换,调整后如图:


调整后,红框内又不满足条件了,需要进一步调整,27与49互换,最终如图:


在这里插入图片描述

到此,建堆完成。

其实无论建堆还是输出数据后的调整都是一个不断筛选的过程,这个思想必须理解,这也是堆排序的核心了,至于代码只是思路的实现。

堆排序代码实现

/**
     * 堆排序
     * @param array
     * @param len
     */
    public static void heapSort(int array[],int len){
        //建堆  len/2-1最后一个非叶子节点
        for(int i=len/2-1;i>=0;i--){
            maxHeapify(array,i,len-1);
        }
        //排序,根节点和最后一个节点交换
        //换完以后,取走根,重新建堆
        //len-1 最后一个节点
        for(int i=len-1;i>0;i--){
            int temp=array[0];
            array[0]=array[i];
            array[i]=temp;
            maxHeapify(array,0,i-1);
        }
    }

    /**
     * 调整堆
     */
    private static void maxHeapify(int array[],int start,int end){
        //父亲的位置
        int dad=start;
        //儿子的位置
        int son=dad*2+1;
        while(son<=end){//如果子节点下标在可以调整的范围内就一直调整下去
            //如果没有右孩子就不用比,有的话,比较两个儿子,选择最大的出来
            if(son+1 <= end && array[son]<array[son+1]){
                son++;
            }
            //和父节点比大小
            if(array[dad]>array[son]){
                return;
            }else{//父亲比儿子小,就要对整个子树进行调整
                int temp=array[son];
                array[son]=array[dad];
                array[dad]=temp;
                //递归下一层
                dad=son;
                son=dad*2+1;
            }
        }
    }

 

堆排序适用场景
堆排序同样适用于数据量大的情况,小数据量不值得提倡,相对于快速排序其在最坏情况下时间复杂度依然优于快排,这是堆排序的最大优点,此外堆排序只需要一个记录大小的辅助控件,用于数据交换。

四、八大内部排序算法总结

在上面讨论的算法中没有哪一种是绝对具有优势的,有的适合大量数据,有的适合少量数据等等,在实际使用中需要我们自己选择一种最合适的排序算法,,甚至在有些情况下需要多种排序算法配合使用。

本篇到此为止,希望对你有用。

 

第一时间获取最新文章,请关注个人公众号: