java實現排序(6)-快速排序

引言

快速排序,作爲一個編程人員來說,肯定都是接觸過的。那麼,你還記得怎麼去實現麼,怎麼優化呢?在本篇博文中,會詳細介紹快速排序的過程,對於不是唯一的過程(可變或者可選),我們討論各種優化的方法。筆者目前整理的一些blog針對面試都是超高頻出現的。大家可以點擊鏈接:http://blog.csdn.net/u012403290

快速排序

在開始之前,我們先介紹一下快速排序的基本思想:
我們要對S數組進行排序,那麼
①如果S數組中的元素個數是0或者1,那麼排序結束。單一元素本身就是有序的。
②在S中隨機選取一個元素作爲樞紐元
③將S除卻樞紐元之外的集合遍歷與樞紐元進行比較,把比樞紐元小的放在左邊,比樞紐元大的放在右邊。
④遍歷結束之後,就是完成了一趟快速排序。我們遞歸③步驟中生成的2組數據,重複①②③步驟,直到滿足①條件退出爲止

下面是對上面步驟的圖示:
這裏寫圖片描述

細心的小夥伴會發現,這個隨機選取的樞紐元將會決定你下次分組的複雜程度。比如說在7、8、9這個組中,如果選取了7或者9爲樞紐元呢?那是不是還需要更多的一步遞歸?所以說,樞紐元的選取將會直接影響到你的算法的效率,在後面,我們會着重討論關於樞紐元的選取。

快速排序,和上一篇博文介紹的歸併排序,都是一種分治策略的體現,我們都是把一個大的數據拆分成一個更小的數據,直到不能拆分爲止,再把所有的結果進行整合,得到最終的結果。

快速排序最容易理解的實現

如果你還是對快速排序不是很理解,那麼我們用一組代碼實現來進一步幫助你的理解,在該實現中,借用了list集合天然的方法。我們不去考慮這一套算法的性能和效率,因爲它必然是低效的。

package com.brickworkers;

import java.util.ArrayList;
import java.util.List;

/**
 * 
 * @author Brickworker
 * Date:2017年5月9日下午3:24:56 
 * 關於類QuickSort.java的描述:快速排序
 * Copyright (c) 2017, brcikworker All Rights Reserved.
 */
public class QuickSort {

    //最容易理解的快速排序
    //我們以int排序爲例,方便起見,就不像以前一樣實現對象的compareTo方法了
    public static void sort(List<Integer> list){
        //遞歸結束條件
        if(list.size() > 1){//上面說的,當數據量爲1或者0的時候結束遞歸
            //建立三個集合,分別表示小於樞紐元,大於樞紐元和等於樞紐元
            List<Integer> smallerList = new ArrayList<Integer>();
            List<Integer> largerList = new ArrayList<Integer>();
            List<Integer> sameList = new ArrayList<Integer>();


            //選取一個隨機值作爲樞紐元,在我們學習過程中,我們通常把第一個數作爲樞紐元
            Integer pivot = list.get(0);

            //遍歷list,把比pivot小的放smallerList中,比pivot大的放largerList中,相等的放sameList中
            for (Integer integer : list) {
                if(integer < pivot){
                    smallerList.add(integer);
                }
                else if(integer > pivot){
                    largerList.add(integer);
                }else{
                    sameList.add(integer);
                }
            }

            //遞歸實現分組後的子數據進行上面同樣的操作
            sort(smallerList);
            sort(largerList);

            //對排序好的數據進行整合
            list.clear();//清楚原本的數據,用於存放有序的數據
            list.addAll(smallerList);
            list.addAll(sameList);
            list.addAll(largerList);
        }
    }

    public static void main(String[] args) {
        Integer[] target = {4,3,6,7,1,9,5,2,3,3};
        List<Integer> list = new ArrayList<>();
        for (int i = 0; i < target.length; i++) {
            list.add(target[i]);
        }
        sort(list);
        //查看排序結果
        for (Integer integer : list) {
            System.out.print(integer + " ");
        }
    }

}

//輸出結果:
//1 2 3 3 3 4 5 6 7 9 
//

細心的小夥伴可能心裏有疑惑了,爲什麼我要用一個List來存儲一樣大小的呢?這裏就暴露出了快速排序算法中對相同數據的處理方式。在上述的實現中爲什麼不直接把相等的放入smallerList或者放入largerList呢?舉個例子:假如說你選取的樞紐元是最小值,那麼是不是可能發生每次遞歸的數據都是一樣的?因爲所有的數據都比你的樞紐元大,而且這個時候恰好你把相等的數據都放入了比樞紐元大的部分,那麼就會造成棧溢出了。所以在這個過程中,我們需要對相等的數據單獨存儲起來。

如何選取樞紐元

經過前面的概念分析與最傻的實現之後,我們討論一下與快速排序效率息息相關的樞紐元選取。其實樞紐元選取的核心問題是,我們要把原本的待排序數據合理、平均的劃分爲兩部分,我們就像要求平衡二叉樹保持平衡一樣。

1、以第一個值爲樞紐元
在大學期間,我們學習到快速排序,告知我們的一般都是以第一個值爲樞紐元。對於這種默認的選取方式,我們對他進行剖析:
①如果待排序的數據是隨機的,那麼如此選擇樞紐元是可以接受的,因爲在概率上來說,隨機的情況下,在第一次快速排序之後,會分爲兩個差不多相等的新的數據。
②如果待排序的數據是有序的,那麼這種情況下,就不能以第一個值爲樞紐元了,因爲它會產生一種噁心分割,直接導致所有的元素都被劃分到左邊子數據或者右邊子數據。
所以這種辦法是不可取的,也儘量不去實現它。也有人說可以選取第一個和第二個數據做比較,比較大的作爲樞紐元。這種方式只是簡單的規避了劃分爲空的情況,這種噁心的劃分還是存在的。

2、隨機選擇一個樞紐元
在待排序的數據中隨機選取一個數據爲樞紐元會顯得安全很多。它的隨機可以保證在分割的過程中可以合理、平均的進行劃分。但是我們要考慮隨機數產生的開銷,每趟分割之前還需要隨機出一個隨機數,那麼開銷會變得非常巨大。所以這種方式雖然比較安全,但是性能仍舊是不可取的。

3、三數中值分割法
三數中值分割法是目前比較高效的一種選取樞紐元的方式。按照選取樞紐元的要求:要儘可能合理、平均的劃分爲兩部分。那麼最好的是選取一個中值,那麼就可以精準的分割兩部分了。但是我們不可能劃分這個開銷去尋找中值,我們做的只是:
我們從待排序的數據中選取3個位置的值,分別是第一位置、最後位置、中間位置。然後我們用這3個數據中排序中間的數據作爲樞紐元。

在這裏,又會有細心的小夥伴有疑惑了,爲什麼三數中值分割法會更優秀?它需要選取3個值,還要判斷拿到3個值中不大不小的那個。這麼一來難道不會影響效率嗎?
當然會影響效率,但是這個效率的開銷是值當的,我們在選取好樞紐元之後仍舊需要遍歷3個之中剩餘的兩個值,因爲這裏已經比較了一輪,我們只需要在樞紐元選取的時候就把剩餘兩個進行排序了,比樞紐元小的放在最前面,比樞紐元大的放在最後面,且在遍歷的時候跳過這兩個值。所以說三數中值分割法並沒有白白花費這個效率開銷。

快速排序核心思想

或許,對我上面的描述不是很理解,我們接下來已目前最好的快熟排序遍歷的方式來說明這種情況:
在目前主流的快速排序中,我們把樞紐元與最後一個數據進行位置交換,也就是說把樞紐元分離出要進行數據交換的區段,然後通常是定義了2個指針,一個從開頭往後比較,一個從後往前比較。進行大小比較和位置轉換,且流行的情況是這樣,下面我們用圖解來說明情況:

我們要對數據:3,2,5,7,1,8,9進行快速排序。
我們隨機選取一個值爲樞紐元:5,那麼我們就把5與9進行位置交換,把樞紐元獨立到數據的邊緣,避免它參與數據交換,所以初始情況就是這樣:
3,2,9,7,1,8,5
我們定義兩個指針,這兩個指針我們稱爲頭指針和尾指針,分別指向第一個元素和倒數第二個元素(倒數第一個元素是樞紐元)。接着就需要開始移動指針:
在頭指針指向的位置小於尾指針指向的位置時:
①我們將頭指針向右移動,遇到比樞紐元小的元素直接移動到下一個數據,直到遇到一個數據大於樞紐元,則頭指針停止運動。
②相同的,移動尾指針向前移動,遇到比樞紐大的元素直接移動到下一個數據,直到遇到一個數據小於樞紐元。
③等兩個指針都停止下來的時候,就需要把兩個指針所指向的數據進行交換。並繼續重複①②③步驟。
④直到尾指針指向的位置小於頭指針指向的位置,通俗來說就是兩個指針交錯了就結束遍歷。

下面是對於上面數據快速排序的一趟圖解:
這裏寫圖片描述

在此基礎上,我們再來說說爲什麼三數中值分割法並沒有浪費額外的開銷:用三數中值分割法獲取到樞紐元,那麼其實比這個樞紐元小的數據可以放在最左邊,比這個樞紐元大的數據放在最右邊。那麼這樣一來,頭指針就可以從第二個開始,尾指針就可以從倒數第二個開始,這樣一來,一定程度上效率是有回升的。

代碼實現標準快速排序

思考:
1、快速排序是分治策略的實現,所以遞歸必不可少。
2、採用最好的三數中值分割法選取樞紐值,並對選取後的數據進行合理存放,在上面已經描述過了。頭指針可以跳過首數字,尾指針也可以跳過倒數第二個數字。但是對於子數組的大小我們要注意最小是2了,不然不滿足三數中值分割法。
3、我們不用新的數組來參與遞歸和存儲,所以我們定義好對於數組描述的下標left與right,儘可能的數組複用。

以下是代碼實現:

package com.brickworkers;

/**
 * 
 * @author Brickworker
 * Date:2017年5月9日下午3:24:56 
 * 關於類QuickSort.java的描述:快速排序
 * Copyright (c) 2017, brcikworker All Rights Reserved.
 */
public class QuickSort {

    //暴露給外部的接口,對一個數組進行排序
    public static void quicksort(Integer [] target){
        quicksoort(target, 0, target.length - 1);
    }

    //具體實現
    //用left與right的方式,儘可能的實現數組複用
    private static void quicksoort(Integer[] target, int left, int right){
        if(left + 2 < right){//遞歸結束條件,之所以+2是因爲三數中值分割法最起碼需要兩個數據
            //尋找樞紐元
            int pivot = findPivot(target, left, right, false);

            //定義頭指針與尾指針
            int i = left + 1, j = right - 2; //因爲三數中值分割法導致最前數據和最後數據不用判斷

            for( ; ; ){
                //兩個指針開始運動,直到兩者都停止
                while(target[i] <= pivot){i++;}//如果頭指針遍歷到小於樞紐元的數據直接跳過
                while(target[j] >= pivot){j--;}//如果尾指針遍歷到大於樞紐元的數據直接跳過
                //判斷兩個指針是否交錯
                if(i < j){
                    //沒有交錯,且指針停止,那麼進行數據交換
                    swap(target, i, j);
                }else{
                    break;//指針交錯,那麼結束循環
                }
            }

            //也就是上面描述的指針交錯之後,需要把樞紐元交換到頭指針的位置
            swap(target, i, right - 1);

            //繼續遞歸子數組
            quicksoort(target, left, i - 1);
            quicksoort(target, i + 1, right);
        }else{
            //當數據少於2個的時候,直接用三數中值分割法進行排序
            findPivot(target, left, right, true);
        }

    }

    //三數中值分割法
    //這個判斷用於說明是否最後的操作,最後的操作不需要把樞紐值放到最後
    private static Integer findPivot(Integer[] target, int left, int right, boolean end){
        int mid = (left + right) / 2;//獲取中間值的位置
        //比較開始數據與中間數據
        if(target[left] > target[mid]){
            //如果開始數據比中間數據大,那麼位置進行交換
            swap(target, left, mid);
        }
        if(target[left] > target[right]){
            //如果開始的數據比最後數據大,那麼交換位置
            swap(target, left, right);
        }
        if(target[mid] > target[right]){
            //如果中間的數據比最後的數據大,那麼交換位置
            swap(target, mid, right);
        }

        if(!end){
            //按照前面說的,把樞紐元放到最後面
            swap(target, mid, right - 1);

            //返回樞紐元
            return target[right - 1];
        }
        return null;

    }


    //交換數組中兩個下標的數據
    private static final void swap(Integer[] target, int one, int anthor){
        int temp = target[one];
        target[one] = target[anthor];
        target[anthor] = temp;
    }

    public static void main(String[] args) {
        Integer[] target = {4,3,6,7,1,9,5,2,3,3};
        quicksort(target);
        for (Integer integer : target) {
            System.out.print(integer + " ");
        }
    }

}
//輸出結果:
//1 2 3 3 3 4 5 6 7 9 
//

上面的實現加入了三數中值分割法,它所造成的影響就是元素判斷基準變成了最起碼子數組要有2個元素,同時,在最後一趟中,只需要用三數中值分割法進行排序就可以結束了。但是個人覺得這樣實現有點傻傻的,其實更好的解決方式是在最後放入一個插入排序,因爲在數據量很小的情況下,插入排序的效率十分高,兩者排序算法結合用肯定比上面直接用三數中值分割法會好得多。

希望對你有所幫助

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章