數據結構與算法(十二):八大經典排序算法再回顧


文章出自汪磊的博客,未經允許不得轉載

一、排序的理解

提到排序大部分同學肯定第一時間想到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;
            }
        }
    }

 

堆排序適用場景
堆排序同樣適用於數據量大的情況,小數據量不值得提倡,相對於快速排序其在最壞情況下時間復雜度依然優於快排,這是堆排序的最大優點,此外堆排序只需要一個記錄大小的輔助控件,用於數據交換。

四、八大內部排序算法總結

在上面討論的算法中沒有哪一種是絕對具有優勢的,有的適合大量數據,有的適合少量數據等等,在實際使用中需要我們自己選擇一種最合適的排序算法,,甚至在有些情況下需要多種排序算法配合使用。

本篇到此為止,希望對你有用。

 

第一時間獲取最新文章,請關注個人公眾號:

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM