數(shù)據(jù)結(jié)構(gòu)與算法——快速排序

數(shù)據(jù)結(jié)構(gòu)與算法——快速排序

快速排序,顧名思義,它速度很快,針對一般應(yīng)用中各種不同的輸入都要比其他排序算法快很多,因此在各種排序算法中,應(yīng)用最廣泛。

快速排序?qū)?shù)組排序的方式是 :先取數(shù)組中第一個元素作為切分元素,同時正向、反向遍歷數(shù)組,通過若干次交換元素,將處于數(shù)組第一個位置的切分元素交換到合適的位置,使得切分元素左邊的元素全小于等于它,切分元素右邊的元素全部大于等于它。此時切分元素已排定了,如果再將切分元素左邊的子數(shù)組和右邊子數(shù)組都排序,那么由切分元素左數(shù)組、切分元素、切分元素的右子數(shù)組組成的數(shù)組就是有序的。

示意圖如下,K是切分元素,放到合適位置后,分別將切分元素左右子數(shù)組都進行排序,之后數(shù)組變得有序。

左右數(shù)組的排序是通過遞歸調(diào)用切分來排序的。由歸納法不難證明遞歸能夠正確地將數(shù)組排序:如果左子數(shù)組和右子數(shù)組都是有序的,那么左子數(shù)組、切分元素、右子數(shù)組三者組成的結(jié)果數(shù)組也一定有序。所以通過遞歸不斷將數(shù)組從切分元素處(得先求得切分元素)分解成左右兩半,每次遞歸調(diào)用都會排定一個元素——就是切分元素,且保持著切分元素左邊的元素都小于等于它,切分元素右邊的元素都大于等于它這個關(guān)系。隨著遞歸的深入,數(shù)組被分解得很小,它們依然滿足前述關(guān)系,最后當數(shù)組被分解到最小時(即只有一個元素),已經(jīng)不能再切分,依然很好地維持著這個關(guān)系。

可以看到,快速排序的關(guān)鍵在于切分,整個算法自始至終滿足下面三個條件:

  • 對于某個切分元素a[j],它已經(jīng)排定;
  • a[low]a[j - 1]中所有元素都小于等于a[j];
  • a[j + 1]a[high]中的所有元素都大于等于a[j].

切分的一般做法是:隨意取a[low]作為切分元素,先從數(shù)組的左端開始向右掃描直到找到第一個大于等于切分元素的元素,然后從數(shù)組的右端開始向左掃描直到找到第一個小于等于切分元素的元素。這兩個元素對于切分元素,位置順序顯然是不對的,因此交換它們使得數(shù)組滿足上面的條件2、條件3;接著掃描、交換元素,直到從左到右的指針i大于等于從右到左的指針j(表示兩個掃描指針相遇),此時只需將a[low]a[j]交換位置,切分元素就被放到了合適的位置。最后返回j表示切分元素的位置,給下次遞歸排序調(diào)用。

下圖說明了切分前后的示意圖。

由上面的描述已經(jīng)可以寫出切分的代碼了

public class QuickSort {

    private static int partition(Comparable[] a, int low, int high) {
        // 下面使用++i和--j的形式,因此i和j的定義如下
        int i = low;
        int j = high + 1;
        // 切分元素保存下來
        Comparable v = a[low];

        while (true) {
            // 從左到右掃描,直到遇到大于等于v的元素為止
            while (less(a[++i], v)) {
                if (i == high) {
                    break;
                }
            }
            // 從右到左掃描,直到遇到小于等于v的元素為止
            while (less(v, a[--j])) {
                if (j == low) {
                    break;
                }
            }
            // 由于指針是先自增,所以先判斷指針是否相遇,相遇就退出while
            if (i >= j) {
                break;
            }
            // 若沒有相遇就交換元素
            swap(a, i, j);
        }
        // 切分元素交換到合適的位置
        swap(a, low, j);
        return j;
    }
}

最后為什么是low和j交換(而不是和i),切分元素就換到了合適的位置?

看圖說明一切

最后一次交換i = 5, j = 6,while循環(huán)繼續(xù),所以i變成6,j變成5,break跳出。將i處的L與切分元素K交換肯定是不對的(這樣比K大的L排在了切分元素的左邊),所以應(yīng)該用位置j處的E和切分元素交換,結(jié)果如上頭最后一行所示,是正確的。

接著寫快速排序的代碼就順理成章了。

public static void sort(Comparable[] a) {
    // 隨機打亂數(shù)組,大大減小最壞情況的概率
    shuffle(a);
    sort(a, 0, a.length - 1);
}

private static void shuffle(Comparable[] a) {
    // asList返回的是實際上是ArrayList,而ArrayList的底層是數(shù)組,所以打亂了b,a也被打亂了
    List<Comparable> b = Arrays.asList(a);
    Collections.shuffle(b);
}

private static void sort(Comparable[] a, int low, int high) {
    // 當只有一個元素時,不能再切分,直接返回
    if (high <= low) {
        return;
    }
    // 切分元素已經(jīng)排定
    int j = partition(a, low, high);
    // 對切分元素左數(shù)組排序
    sort(a, low, j - 1);
    // 對切分元素右數(shù)組排序
    sort(a, j + 1, high);
    // 三者結(jié)合起來的數(shù)組有序!
}

注意在排序之前,對數(shù)組進行了隨機打亂。這個操作是有必要的!雖然看似多了一兩步操作,但試想一種極端的情況:如果切分元素本來就是數(shù)組中最小或者最大的,每次調(diào)用只會有一個元素被交換,剩下的數(shù)組還是一個大數(shù)組;如果第二次切分元素依然是最小或者最大的元素....這將導(dǎo)致一個大子數(shù)組需要切分很多次,我們事先打亂數(shù)組就是為了規(guī)避這種情況。它能使產(chǎn)生糟糕的切分情況的可能性降到極低。

對一個數(shù)組的快速排序軌跡,見下圖

紅色圓圈的元素就是被換到合適位置后的切分元素

快速排序的效率依賴于切分數(shù)組的效果,而這依賴于切分元素的值,切分有可能發(fā)生在數(shù)組中的任何位置。如果每次切分都發(fā)生在數(shù)組的中間,即每次都能將數(shù)組對半分,這是最好情況。

快速排序的時間復(fù)雜度為O(Nlg N)

快速排序的改進

對于任何遞歸的排序算法,當數(shù)組規(guī)模較小時,切換到插入排序是個明智的選擇。因為

  • 對于小數(shù)組,快速排序比插入排序慢;
  • 因為遞歸,快速排序的sort方法在小數(shù)組中也會調(diào)用自己。
private static void sort(Comparable[] a, int low, int high) {
    // high = low說明數(shù)組被劃分到只有一個元素,不能再切分,直接返回
    // high <= low + 15 說明當數(shù)組長度不超過16時都換用插入排序

    if (high <= low + 15) {
        InsertSort.sort(a);
        return;
    }
    // 切分元素已經(jīng)排定
    int j = partition(a, low, high);
    // 對切分元素左數(shù)組排序
    sort(a, low, j - 1);
    // 對切分元素右數(shù)組排序
    sort(a, j + 1, high);
    // 三者結(jié)合起來的數(shù)組有序!
}

三向切分的快速排序

實際應(yīng)用中可能出現(xiàn)大量重復(fù)元素,最特殊的情況:一個數(shù)組中所有元素都相同,此時無需繼續(xù)排序了,但是上述算法還是會對數(shù)組進行切分。基于此可以將數(shù)組切分成三部分,分別對應(yīng)小于、等于、大于切分元素的數(shù)組元素。

我們來看這種被稱為三向切分的快速排序。它從左到右遍歷數(shù)組一次,維護一個指針lt使得a[low...lt-1]中的元素都小于v,一個指針gt使得a[gt + 1...high]中的元素都大于v,一個指針i使得a[lt..i-1]中的元素都等于v,a[i..gt]中的元素暫定。一開始i和low相等。隨著循環(huán),a[i...gt]越來越小,即gt-i不斷減小,當i > gt時循環(huán)結(jié)束。循環(huán)中進行下面的操作:

  • 如果a[i]小于v,將a[i]和a[lt]交換,lt和i都加1;
  • 如果a[i]大于v,將a[i]和a[gt]交換,gt減1;
  • 如果a[i]等于v,將i加1

上面的這些操作保證了最后i > gt可以推出循環(huán)。

三向切分的快速排序示意圖如下:

代碼如下

public class Quick3way {

    public static void sort(Comparable[] a) {
        shuffle(a);
        sort(a, 0, a.length - 1);
    }

    private static void sort(Comparable[] a, int low, int high) {
        if (high <= low) {
            return;
        }

        int lt = low;
        int gt = high;
        int i = low + 1;
        // 切分元素
        Comparable v = a[low];
        while (i <= gt) {
            int cmp = a[i].compareTo(v);
            if (cmp < 0) {
                swap(a, lt++, i++);
            } else if (cmp > 0) {
                swap(a, i, gt--);
            } else {
                i++;
            }
        }
        // 現(xiàn)在a[lo..lt-1] < v=a[lt..gt] < a[gt+1..high]成立
        // 切分元素相同的數(shù)組不會被遞歸算法訪問到,對其左右的子數(shù)組遞歸排序
        sort(a, low, lt - 1);
        sort(a, gt + 1, high);
    }
}

這段排序能夠?qū)⒑颓蟹衷叵嗟鹊脑鼐奂揭粔K兒,這樣它們就不會被包含在遞歸調(diào)用處理的子數(shù)組中了。對于存在大量重復(fù)元素的數(shù)組,這種方法比標準的快速排序要快。三向切分的最壞情況是所有元素各不相同,這時會比標準的快速排序要慢,因為比起標準的快速排序使用了更多的比較。

結(jié)合上圖來看,上面代碼做的事情是:

  • 在指針i的移動過程中,如果a[i]比切分元素v小,就將a[i]交換到左邊(具體做法是將a[i]于a[lt]交換,同時lt和i都要向右移動一格,相當于新元素插入進來了嘛,要騰出空間的)從而保證了a[low..lt-1]中的元素都比v小;
  • 如果a[i]比v大,將a[i]交換到右邊,具體做法是將a[i]和a[gt]交換,此時只需將gt向左移動一格,lt和i都無需移動(看圖可以很好理解),從而保證了a[gt+1..high]中的元素都比v要大;
  • 如果a[i]和v相等,只需將i向右移動一格,相當于將a[i]添加到相等切分元素集合的末尾,從而保證了a[lt...i-1]中的元素都等于v。

由于i和gt不能同時改變,最后退出循環(huán)時,必然有關(guān)系i = gt +1,所以最后a[lt...i-1] = a[lt...gt]中的元素都和v相等。這串元素都不會被包含在遞歸調(diào)用的排序中,排除掉它們后,在遞歸調(diào)用中自然是sort(a, low, lt - 1); sort(a, gt + 1, high);了。

三向切分的快速排序軌跡如下圖所示。

對于包含大量重復(fù)元素的數(shù)組,三向切分的快速排序算法將排序時間從線性對數(shù)級降低到線性級別,因此時間復(fù)雜度介于O(N)和O(Nlg N)之間,這依賴于輸入數(shù)組中重復(fù)元素的數(shù)量。

快速排序交換兩個元素跨度很大,是跳躍性的,可以想象這很容易造成等值元素相對位置改變。而且從代碼中可以更直觀的看出,左往右掃描時,是遇到大于等于切分元素停止,右往左掃描時是遇到小于等于切分元素停止,如果都是遇到等于切分元素時停止,切分中將會交換這兩個相等的元素,因此等值元素的相對位置改變,即快速排序不是穩(wěn)定的排序算法。


by @sunhaiyu

2017.10.30

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,739評論 6 534
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,634評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,653評論 0 377
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,063評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,835評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,235評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,315評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,459評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,000評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,819評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,004評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,560評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,257評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,676評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,937評論 1 288
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,717評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,003評論 2 374

推薦閱讀更多精彩內(nèi)容