【PHP數據結構】交換排序:冒泡、快排

上篇文章中我們好好地學習了一下插入類相關的兩個排序,不過,和交換類的排序對比的話,它們真的只是弟弟。甚至可以說,在所有的排序算法中,最出名的兩個排序都在今天要介紹的交換排序中了。不管是冒泡、還是快排,都是面試中的常見排序算法,常見到什麼地步呢?但凡學習數據結構和算法,甚至是你完全沒有學習過,也多少都會聽說過這兩個排序算法。而一些大中型公司更是直接在面試題中指明不要使用這兩種算法來實現一些排序的題目,這又是爲什麼呢?那當然也是因爲這兩個算法實在是太出名了,很多人都隨便就能手寫出來。

當然,不管你面試的公司有什麼要求,只要是有志在編程開發這個行業裏發展的同學,冒泡和快排肯定會是面試中繞不開的一個坎。我們今天就來好好地學習一下這兩個排序算法。不過首先還是要搞明白這個“交換”指的是什麼意思。

上篇文章中的插入排序,指的是直接將數據插入到指定的位置。而交換的意思,則是讓兩個位置的數據在進行比對後直接交換。比如我們有 [3, 1, 2] 這樣一個數組,需要排列成 [1, 2, 3] 這種形式。那麼我們就可以先讓 3 和 1比較,發現 1 小,於是將 3 和 1 的位置進行交換,結果是 [1, 3, 2] 。然後再讓 3 和 2 比較,發現 2 小,再交換它們的位置,於是得到結果爲 [1, 2, 3] 的數組。

當然,這個示例只是簡單地說明了一下交換排序的原理。但萬變不離其宗,不管是冒泡還是快排,它們的基本原理和核心思想都是這樣的,讓兩個數據對比後根據規則交換位置。這裏其實從代碼中我們能夠從一個地方很快地分辨出一段排序代碼是否是交換排序,那就是他們會有一個對於兩個元素進行數據交換的過程,而且往往在普通情況下會使用一箇中間變量。這個我們一會看代碼就可以看到。

冒泡排序

冒泡排序,先從名字來理解一下,它的意思其實是讓數據像汽水中的泡泡一樣一個一個的浮上來。

直接上代碼了來看看,代碼其實非常簡單。

function BubbleSort($numbers)
{
    $n = count($numbers);

    for ($i = 0; $i < $n - 1; $i++) { // 外層循環 n - 1
        for ($j = 0; $j < $n - $i - 1; $j++) { // 內層循環 n - 1 - i
            if ($numbers[$j] > $numbers[$j + 1]) { // 兩兩相比來交換
                $temp = $numbers[$j + 1];
                $numbers[$j + 1] = $numbers[$j];
                $numbers[$j] = $temp;
            }
        }
    }

    print_r($numbers);
}

BubbleSort($numbers);
// Array
// (
//     [0] => 13
//     [1] => 27
//     [2] => 38
//     [3] => 49
//     [4] => 49
//     [5] => 65
//     [6] => 76
//     [7] => 97
// )

光看代碼自己推演的話其實還是不太好理解,那麼我們就還是使出終極殺器,也就是圖解步驟來看一下吧!

在代碼中可以看到,我們有兩層循環。所以這個圖片中我們也是展示了 i 和 j 的兩層循環情況。當然,限於篇幅,我們只展示了第一次 i 循環內部的 j 循環情況,也就是 i = 0 時,裏面的 j 循環執行的情況。

  • i = 0 是,內部的 j < n - 1 - i ,也就是內部的 j 要循環七次。我們直接就看右邊的 j 循環的步驟。

  • 冒泡排序其實就是利用 j 和 j + 1 來對比兩個相鄰的元素。從圖中我們就可以看出,每一次 j++ 都是在對當前 j 和下一個 j + 1 的元素進行比較。如果當前的這個 j 大於 j + 1 的話,就把它們交換位置。

  • 當 j = 0 時,第 0 個位置的 49 是大於第 1 個位置的 38 的,於是 49 和 38 交換了位置。

  • 當 j = 1 時,位置 1 的 49 和位置 2 的 65 相比,沒有達成條件,於是不會變動。同理,j = 2 時也是對比的 65 和 97 ,同樣不會發生交換。

  • 當 j = 3 時,97 比 76 要大,於是發生了交換,97 交換到 j + 1 也就是下標 4 的位置。同時,97 也是整個序列中最大的數,於是後面會一直交換到這次的 j 循環結束。

  • 最終的結果是 97 這個最大的數移動到了數據的最後一位。也就是說,最大的數已經放到了整個序列中的正確的位置上了。

  • 接着內層循環結束,i++ ,開始第二次 i = 1 的內部 j 循環。這裏需要注意的是,爲什麼我們要用 j < n - 1 - i 呢?因爲我們前面已經完成了一個最大數的排序,就是將 97 這個最大數放到了最後的位置上。所以在 i++ 的第二次循環時,我們就要將第二大的數放在倒數第二的位置上。這時的 j 也不需要循環到最後一位了,只需要循環到倒數第二位就可以了。

從上面的分步講解中,我們可以看到,外層的 i 每一次的循環其實就是通過內層的 j 循環來將一個最大的數按順序放到後面的位置上。就像汽水不斷地向上冒泡一樣,它就是傳說中的冒泡排序算法概念的由來。

其實關於冒泡排序的算法,還有一個口決是很多同學都知道的,也可以幫助我們記憶。

  • 外層循環 N 減一

  • 內層循環 N 減一減 I

  • 兩兩相比小靠前(正序)

爲什麼小的靠前是正序呢?在代碼中,我們 if 條件判斷是的 j > j+1 ,如果成立就交換它們,也就是讓大的數據放到了後面,小的數據放到了前面,這樣一輪過後,最大的數據放在了最後一位,也就是完成了一個最大數據的位置的確定。如果我們將條件反過來,也就是 j < j+1 的話,就會讓最大的數據放到最前面,也就是實現了倒序。是不是很神奇?小夥伴們可以試試哦,就改變一下 if 條件的大於號就可以了哦。

冒泡的時間複雜度其實很明顯地就能看出來,O(N2)。屬於效率一般但非常好理解的一種算法,而且它是一個穩定的排序算法。

快速排序

冒泡的感覺咋樣?不過冒泡有個問題,那就是它只能對相鄰的兩個數據進行比較,所以 O(N2) 這個時間複雜度基本也就不包含什麼最好最壞的情況了,不管怎麼它都得要達到這個 O(N2) 的水平。

那麼有沒有什麼別的方法能夠對冒泡進行優化呢?有大佬就發明出了優化冒泡的一種排序算法啦。那就是快速排序算法。還記得在學習查找的時候我們學習過的二分查找嗎?相對於線性查找來說,二分查找的效率是不是提升了很多。但快速排序的效率提升可達不到那麼高,畢竟排序還是比查找要複雜些。而且它是在冒泡的基礎上進行的改良,同樣也使用了二分的思想,也就是分而治之的一種理念。讓每次的比較不再只是兩個相鄰的元素一個一個地比較。所以它的平均時間複雜度可以提升到 O(NlogN) 的級別。相對於 O(N2) 來說,這個時間複雜度其實也有了不小的飛躍哦。特別是數據量越大的情況下越明顯。

同樣我們先來看看代碼,然後再來看圖分析這個算法。

function QSort(&$arr, $start, $end)
{
    if ($start > $end) {
        return;
    }
    $key = $arr[$start];
    $left = $start;
    $right = $end;
    
    while ($left < $right) {
        // 右邊下標確定
        while ($left < $right && $arr[$right] >= $key) {
            $right--;
        }
        // 左邊下標確定
        while ($left < $right && $arr[$left] <= $key) {
            $left++;
        }
        if ($left < $right) { // 交換步驟
            $tmp = $arr[$left];
            $arr[$left] = $arr[$right];
            $arr[$right] = $tmp;
        }
    }

    $arr[$start] = $arr[$left];
    $arr[$left] = $key;
    // 遞歸左右兩邊繼續
    QSort($arr, $start, $right - 1);
    QSort($arr, $right + 1, $end);
}

function QuickSort($numbers)
{
    QSort($numbers, 0, count($numbers) - 1);
    print_r($numbers);
}

QuickSort($numbers);
// Array
// (
//     [0] => 13
//     [1] => 27
//     [2] => 38
//     [3] => 49
//     [4] => 49
//     [5] => 65
//     [6] => 76
//     [7] => 97
// )

有沒有發現熟悉的身影?沒錯,快速排序使用到了遞歸。這個遞歸其實也包含着分治的思想,就像秦國統一六國一樣,分而治之。我們將某一個數據放到指定的位置之後再按左右分治的方式來繼續其它的數據的排序,而不用讓其它的數據再對整個序列進行完整的判斷,從而提高排序的效率。因此,快排的時間複雜度相對冒泡來說就好了很多。

同樣地,它表面上是不停地遞歸,其實遞歸也是一種循環,我們就可以看出來,它和冒泡一樣其實是有着兩層循環的概念的。這裏我們也是以第一次的外層循環爲例子來剖析它的內層循環都做了什麼。

  • 首先,我們確定了一個關鍵字 key ,這裏我們就直接指定第一個數據 49 。然後指定左右兩個指針,左指針 left 從 0 開始,右指針 right 從最右邊的下標開始。

  • 進入內層循環,條件是 left < right ,也就是左右兩個指針不能相遇!

  • 開始指針移動,先從右邊開始,如果 right 指向的數據大於等於 key ,right 就進行減減操作,否則,指針就停住。可以看到,我們的指針停在了 27 這個數據的位置,也就是倒數第二個數據這裏,第一個數據 49 和我們的 key 值 49 是一樣的,於是 right 就移動到倒數第二個數據了,27 是小於 key 值的。

  • 然後移動 left 指針,移動到符合條件的位置也就是值爲 65 的這個下標,然後交換 left 和 right 的值。

  • 繼續後續的操作,直到 left 和 right 相遇了,這時退出循環,並在循環外面再次交換 key 和 left 位置的值。這時,第一個下標的 49 這個值就已經放到了它所確定的位置。也就是說,這個值完成了排序。

  • 接着,以這個完成排序的值爲中心,切分左右兩個序列,繼續進入遞歸排序的過程,直到所有數據完成排序。

看出快速排序和冒泡排序的區別了吧?快排的每趟排序都會確定一個關鍵字的具體位置,它的比較除了第一次是每個數都和 key 兩兩比較之外,其它都是採用分治的思想來縮小 n 的大小進行小範圍的排序的。而且每次的循環都會將數據按針對 key 值的大小進行左右排列,這也是二叉搜索樹的核心思想。這個內容我們的系列文章中沒有講解,大家可以自行查閱相關的資料學習。

小彩蛋:交換兩個變量的值

今天學習的內容中都有一處核心的代碼,就是最開始我們說過的交換兩個變量值的代碼。

// 冒泡中
$temp = $numbers[$j + 1];
$numbers[$j + 1] = $numbers[$j];
$numbers[$j] = $temp;

// 快排中
$tmp = $arr[$left];
$arr[$left] = $arr[$right];
$arr[$right] = $tmp;

我們都使用到了一個臨時變量來進行交換。不過不少的面試題中經常會看到一種題目就是不使用第三個變量,也就是這個臨時變量來交換兩個變量的值。大家有沒有踫到過呢?其實有幾種方案都可以,我們就來簡單說兩個。

$a = 1;
$b = 2;
$a += $b; // a = 3
$b = $a - $b; // b = 3 - 2 = 1 
$a = $a - $b; // a = 3 - 1 = 2
echo $a, PHP_EOL; // 2
echo $b, PHP_EOL; // 1

$a = "a";
$b = "b";
$a .= $b; // a = "ab"
$b = str_replace($b, "", $a); // b = str_replace("b", "", "ab") = a
$a = str_replace($b, "", $a);// a = str_replace("a", "", "ab") = b
echo $a, PHP_EOL; // b
echo $b, PHP_EOL; // a

對於數字來說,直接使用第一段的加減操作就可以完成兩個變量的交換。而對於字符串來說,就可以使用 str_replace() 來實現。其實它們的思想都是一樣的,先合併到一個變量上,然後利用減法或者替換來讓某一個變量先變成另一個變量的值。然後再使用相同的方法將另一個變量的值也轉換成功。當然,這只是最簡單最基礎的一種算法,利用 PHP 的一些函數和特性,我們還可以更方便地實現這種功能。

$a = 1;
$b = 2;
list($a, $b) = [$b, $a];
echo $a, PHP_EOL; // 2
echo $b, PHP_EOL; // 1

list() 函數是將一個數組中的數據分別存入到指定的變量中,而在 PHP 中我們也可以直接 [x, x] 這樣來定義數組。所以不使用第三個臨時變量來交換兩個變量的功能我們只用這一行代碼就搞定了。 list(a,b) = [b,a] 。這裏不點贊可真對不起這道題咯!!

總結

交換排序的這兩種算法相當於數據結構與算法這門課程的門面擔當,但凡要講算法中的排序的,必然會有它們兩個的身影。畢竟太經典了,不過我們可是先學了兩個插入類的排序進行過了熱身才來學習這兩個經典算法的,相信大家進行對比之後就能更深入地理解這些算法的神奇和不同。

測試代碼:

https://github.com/zhangyue0503/Data-structure-and-algorithm/blob/master/7.排序/source/7.2交換排序:冒泡、快排.php

參考文檔:

本文示例選自 《數據結構》第二版,嚴蔚敏

《數據結構》第二版,陳越

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