1. 程式人生 > >PHP面試:儘可能多的說出你知道的排序演算法

PHP面試:儘可能多的說出你知道的排序演算法

預警

本文適合對於排序演算法不太瞭解的新手同學觀看,大佬直接忽略即可。因為考慮到連貫性,所以篇幅較長。老鐵們看完需要大概一個小時,但是從入門到完全理解可能需要10個小時(哈哈哈,以我自己的經歷來計算的),所以各位老鐵可以先收藏下來,同步更新在Github,本文引用到的所有演算法的實現在這個地址,每天抽點時間理解一個排序演算法即可。

排序和他們的型別

我們的資料大多數情況下是未排序的,這意味著我們需要一種方法來進行排序。我們通過將不同元素相互比較並提高一個元素的排名來完成排序。在大多數情況下,如果沒有比較,我們就無法決定需要排序的部分。在比較之後,我們還需要交換元素,以便我們可以對它們進行重新排序。良好的排序演算法具有進行最少的比較和交換的特徵。除此之外,還存在基於非比較的排序,這類排序不需要比較資料來進行排序。我們將在這篇文章中為各位老鐵介紹這些演算法。以下是本篇文章中我們將要討論的一些排序演算法:

  • Bubble sort
  • Insertion sort
  • Selection sort
  • Quick sort
  • Merge sort
  • Bucket sort

以上的排序可以根據不同的標準進行分組和分類。例如簡單排序,高效排序,分發排序等。我們現在將探討每個排序的實現和複雜性分析,以及它們的優缺點。

時間空間複雜度以及穩定性

我們先看下本文提到的各類排序演算法的時間空間複雜度以及穩定性。各位老鐵可以點選這裡瞭解更多。

clipboard.png

氣泡排序

氣泡排序是程式設計世界中最常討論的一個排序演算法,大多數開發人員學習排序的第一個演算法。氣泡排序是一個基於比較的排序演算法,被認為是效率最低的排序演算法之一。氣泡排序總是需要最大的比較次數,平均複雜度和最壞複雜度都是一樣的。

氣泡排序中,每一個待排的專案都會和剩下的專案做比較,並且在需要的時候進行交換。下面是氣泡排序的虛擬碼。

procedure bubbleSort(A: list of sortable items)
n = length(A)
for i = 0 to n inclusive do
 for j = 0 to n - 1 inclusive do
    if A[j] > A[j + 1] then
        swap(A[j + 1], A[j])
    end if
  end for
end for
end procedure

正如我們從前面的虛擬碼中看到的那樣,我們首先執行一個外迴圈以確保我們迭代每個數字,內迴圈確保一旦我們指向某個專案,我們就會將該數字與資料集合中的其他專案進行比較。下圖顯示了對列表中的一個專案進行排序的單次迭代。假設我們的資料包含以下專案:20,45,93,67,10,97,52,88,33,92。第一次迭代將會是以下步驟:

clipboard.png

有背景顏色的專案顯示的是我們正在比較的兩個專案。我們可以看到,外部迴圈的第一次迭代導致最大的專案儲存在列表的最頂層位置。然後繼續,直到我們遍歷列表中的每個專案。現在讓我們使用PHP實現氣泡排序演算法。

我們可以使用PHP陣列來表示未排序的數字列表。由於陣列同時具有索引和值,我們根據位置輕鬆迭代每個專案,並將它們交換到合適的位置。

function bubbleSort(&$arr) : void
{
    $swapped = false;
    for ($i = 0, $c = count($arr); $i < $c; $i++) {
        for ($j = 0; $j < $c - 1; $j ++) {
            if ($arr[$j + 1] < $arr[$j]) {
                list($arr[$j], $arr[$j + 1]) = array($arr[$j + 1], $arr[$j]);
            }
        }
    }
}

氣泡排序的複雜度分析

對於第一遍,在最壞的情況下,我們必須進行n-1比較和交換。 對於第2次遍歷,在最壞的情況下,我們需要n-2比較和交換。 所以,如果我們一步一步地寫它,那麼我們將看到:複雜度= n-1 + n-2 + ..... + 2 + 1 = n *(n-1)/ 2 = O(n2)。因此,氣泡排序的複雜性是O(n2)。 分配臨時變數,交換,遍歷內部迴圈等需要一些恆定的時間,但是我們可以忽略它們,因為它們是不變的。以下是氣泡排序的時間複雜度表,適用於最佳,平均和最差情況:

best time complexity Ω(n)
worst time complexity O(n2)
average time complexity Θ(n2)
space complexity (worst case) O(1)

儘管氣泡排序的時間複雜度是O(n2),但是我們可以使用一些改進的手段來減少排序過程中對資料的比較和交換次數。最好的時間複雜度是O(n)是因為我們至少要一次內部迴圈才可以確定資料已經是排好序的狀態。

氣泡排序的改進

氣泡排序最重要的一個方面是,對於外迴圈中的每次迭代,都會有至少一次交換。如果沒有交換,則列表已經排序。我們可以利用它改進我們的虛擬碼

procedure bubbleSort(A: list of sortable items)
    n = length(A)
    for i = 1 to n inclusive do
        swapped = false
        for j = i to n - 1 inclusive do
            if A[j] > A[j + 1] then
                swap(A[j], A[j + 1])
                swapped = true
            endif
        end for
        if swapped = false
            break
        endif
    end for
end procedure
    

正如我們所看到的,我們現在為每個迭代設定了一個標誌為false,我們期望在內部迭代中,標誌將被設定為true。如果內迴圈完成後標誌仍然為假,那麼我們可以打破外迴圈。

function bubbleSort(&$arr) : void
{
    for ($i = 0, $c = count($arr); $i < $c; $i++) {
        $swapped = false;
        for ($j = 0; $j < $c - 1; $j++) {
            if ($arr[$j + 1] < $arr[$j]) {
                list($arr[$j], $arr[$j + 1]) = array($arr[$j + 1], $arr[$j]);
                $swapped = true;
            }
        }

        if (!$swapped) break; //沒有發生交換,演算法結束
    }
}

我們還發現,在第一次迭代中,最大項放置在陣列的右側。在第二個迴圈,第二大的項將位於陣列右側的第二個。我們可以想象出來在每次迭代之後,第i個單元已經儲存了已排序的專案,不需要訪問該索引和做比較。因此,我們可以從內部迭代減少迭代次數並減少比較。這是我們的第二個改進的虛擬碼

procedure bubbleSort(A: list of sortable items)
    n = length(A)
    for i = 1 to n inclusive do
        swapped = false
        for j = 1 to n - i - 1 inclusive do
            if A[j] > A[j + 1] then
                swap(A[j], A[j + 1])
                swapped = true
            endif
        end for
        if swapped = false
            break
        end if
    end for
end procedure
   

下面的是PHP的實現

function bubbleSort(&$arr) : void
{
    
    for ($i = 0, $c = count($arr); $i < $c; $i++) {
        $swapped = false;
        for ($j = 0; $j < $c - $i - 1; $j++) {
            if ($arr[$j + 1] < $arr[$j]) {
                list($arr[$j], $arr[$j + 1]) = array($arr[$j + 1], $arr[$j]);
                $swapped = true;
            }

            if (!$swapped) break; //沒有發生交換,演算法結束
        }
    }
}

我們檢視程式碼中的內迴圈,唯一的區別是$j < $c - $i - 1;其他部分與第一次改進一樣。因此,對於20、45、93、67、10、97、52、88、33、92, 我們可以很認為,在第一次迭代之後,頂部數字97將不被考慮用於第二次迭代比較。同樣的情況也適用於93,將不會被考慮用於第三次迭代。

clipboard.png

我們看看前面的圖,腦海中應該馬上想到的問題是“92不是已經排序了嗎?我們是否需要再次比較所有的數字?是的,這是一個好的問題。我們完成了內迴圈中的最後一次交換後可以知道在哪一個位置,之後的陣列已經被排序。因此,我們可以為下一個迴圈設定一個界限,虛擬碼是這樣的:

procedure bubbleSort(A: list of sortable items)
    n = length(A)
    bound = n - 1
    for i = 1 to n inclusive do
        swapped = false
        bound = 0
        for j = 1 to bound inclusive do
            if A[j] > A[j + 1] then
                swap(A[j], A[j + 1])
                swapped = true
                newbound = j
            end if
        end for
        bound = newbound
        if swapped = false
            break
        endif
    end for
end procedure
   

這裡,我們在每個內迴圈完成之後設定邊界,並且確保我們沒有不必要的迭代。下面是PHP程式碼:

function bubbleSort(&$arr) : void
{
    $swapped = false;
    $bound = count($arr) - 1;
    for ($i = 0, $c = count($arr); $i < $c; $i++) {
        for ($j = 0; $j < $bound; $j++) {
            if ($arr[$j + 1] < $arr[$j]) {
                list($arr[$j], $arr[$j + 1]) = array($arr[$j + 1], $arr[$j]);
                $swapped = true;
                $newBound = $j;
            }
        }
        $bound = $newBound;
        if (!$swapped) break; //沒有發生交換,演算法結束
    }
}

選擇排序

選擇排序是另一種基於比較的排序演算法,它類似於氣泡排序。最大的區別是它比氣泡排序需要更少的交換。在選擇排序中,我們首先找到陣列的最小/最大項並將其放在第一位。如果我們按降序排序,那麼我們將從陣列中獲取的是最大值。對於升序,我們獲取的是最小值。在第二次迭代中,我們將找到陣列的第二個最大值或最小值,並將其放在第二位。持續到我們把每個數字放在正確的位置。這就是所謂的選擇排序,選擇排序的虛擬碼如下:

procedure  selectionSort( A : list of sortable items)
    n = length(A)
    for i = 1 to n inclusive do
        min  =  i
        for j = i + 1 to n inclusive do
            if  A[j] < A[min] then
                min = j 
            end if
        end  for

        if min != i
            swap(a[i], a[min])
        end if
    end  for
end procedure

看上面的演算法,我們可以發現,在外部迴圈中的第一次迭代之後,第一個最小項被儲存在第一個位置。在第一次迭代中,我們選擇第一個專案,然後從剩下的專案(從2到n)找到最小值。我們假設第一個專案是最小值。我們找到另一個最小值,我們將標記它的位置,直到我們掃描了剩餘的列表並找到新的最小最小值。如果沒有找到最小值,那麼我們的假設是正確的,這確實是最小值。如下圖:

clipboard.png

正如我們在前面的圖中看到的,我們從列表中的第一個專案開始。然後,我們從陣列的其餘部分中找到最小值10。在第一次迭代結束時,我們只交換了兩個地方的值(用箭頭標記)。因此,在第一次迭代結束時,我們得到了的陣列中得到最小值。然後,我們指向下一個數字45,並開始從其位置的右側找到下一個最小的專案,我們從剩下的專案中找到了20(如兩個箭頭所示)。在第二次迭代結束時,我們將第二個位置的值和從列表的剩餘部分新找到的最小位置交換。這個操作一直持續到最後一個元素,在過程結束時,我們得到了一個排序的列表,下面是PHP程式碼的實現。

function selectionSort(&$arr)
{
    $count = count($arr);

    //重複元素個數-1次
    for ($j = 0; $j <= $count - 1; $j++) {
        //把第一個沒有排過序的元素設定為最小值
        $min = $arr[$j];
        //遍歷每一個沒有排過序的元素
        for ($i = $j + 1; $i < $count; $i++) {
            //如果這個值小於最小值
            if ($arr[$i] < $min) {
                //把這個元素設定為最小值
                $min = $arr[$i];
                //把最小值的位置設定為這個元素的位置
                $minPos = $i;
            }
        }
        //內迴圈結束把最小值和沒有排過序的元素交換
        list($arr[$j], $arr[$minPos]) = [$min, $arr[$j]];
    }
    
}

選擇排序的複雜度

選擇排序看起來也類似於氣泡排序,它有兩個for迴圈,從0到n。氣泡排序和選擇排序的區別在於,在最壞的情況下,選擇排序使交換次數達到最大n - 1,而氣泡排序可以需要 n * n 次交換。在選擇排序中,最佳情況、最壞情況和平均情況具有相似的時間複雜度。

best time complexity Ω(n2)
worst time complexity O(n2)
average time complexity Θ(n2)
space complexity (worst case) O(1)

插入排序

到目前為止,我們已經看到了兩種基於比較的排序演算法。現在,我們將探索另一個排序演算法——插入排序。與剛才看到的其他兩個排序演算法相比,它有最簡單的實現。如果專案的數量較小,插入排序優於氣泡排序和選擇排序。如果資料集很大,就像氣泡排序一樣就變得效率低下。插入排序的工作原理是將數字插入到已排序列表的正確位置。它從陣列的第二項開始,並判斷該項是否小於當前值。如果是這樣,它將專案轉移,並將較小的專案儲存在其正確的位置。然後,它移動到下一項,並且相同的原理繼續下去,直到整個陣列被排序。

procedure insertionSort(A: list of sortable items)
    n length(A)
    for i=1 to n inclusive do
        key = A[i]
        j = i - 1
        while j >= 0 and A[j] > key do
            A[j+1] = A[j]
            j--
        end while
        A[j + 1] = key
    end for
end procedure

假如我們有下列陣列,元素是:20 45 93 67 10 97 52 88 33 92。我們從第二個專案45開始。現在我們將從45的左邊第一個專案開始,然後到陣列的開頭,看看左邊是否有大於45的值。由於只有20,所以不需要插入,目前兩項(20, 45)被排序。現在我們將指標移到93,從它再次開始,比較從45開始,由於45不大於93,我們停止。現在,前三項(20, 45, 93)已排序。接下來,對於67,我們從數字的左邊開始比較。左邊的第一個數字是93,它較大,所以必須移動一個位置。我們移動93到67的位置。然後,我們移動到它左邊的下一個專案45。45小於67,不需要進一步的比較。現在,我們先將93移動到67的位置,然後我們插入67的到93的位置。繼續如上操作直到整個陣列被排序。下圖說明在每個步驟中使用插入排序的直到完全排序過程。

clipboard.png

function insertionSort(array &$arr)
{
    $len = count($arr);
    for ($i = 1; $i < $len; $i++) {
        $key = $arr[$i];
        $j = $i - 1;

        while ($j >= 0 && $arr[$j] > $key) {
            $arr[$j + 1] = $arr[$j];
            $j--;
        }
        $arr[$j + 1] = $key;
    }
}

插入排序的複雜度

插入排序具有與氣泡排序相似的時間複雜度。與氣泡排序的區別是交換的數量遠低於氣泡排序。

best time complexity Ω(n)
worst time complexity O(n2)
average time complexity Θ(n2)
space complexity (worst case) O(1)

排序中的分治思想

到目前為止,我們已經瞭解了每次對完整列表進行排序的一些排序演算法。我們每次都需要應對一個比較大的數字集合。我們可以設法使資料集合更小,從而解決這個問題。分治思想對我們有很大幫助。用這種方法,我們將一個問題分成兩個或多個子問題或集合,然後在組合子問題的所有結果以獲得最終結果。這就是所謂的分而治之方法,分而治之方法可以讓我們有效地解決排序問題,並降低演算法的複雜度。最流行的兩種排序演算法是合併排序和快速排序,它們應用分治演算法對資料進行排序,因此被認為是最好的排序演算法。

歸併排序

正如我們已經知道的,歸併排序應用分治方法來解決排序問題,我們用法兩個過程來解決這個問題。第一個是將問題集劃分為足夠小的問題,以便容易地求解,然後將這些結果結合起來。我們將用遞迴方法來完成分治部分。下面的圖顯示瞭如何採用分治的方法。

clipboard.png

基於前面的影象,我們現在可以開始準備我們的程式碼,它將有兩個部分。


/**
 * 歸併排序
 * 核心:兩個有序子序列的歸併(function merge)
 * 時間複雜度任何情況下都是 O(nlogn)
 * 空間複雜度 O(n)
 * 發明人: 約翰·馮·諾伊曼
 * 速度僅次於快速排序,為穩定排序演算法,一般用於對總體無序,但是各子項相對有序的數列
 * 一般不用於內(記憶體)排序,一般用於外排序
 */

function mergeSort($arr)
{
    $lenght = count($arr); 
    if ($lenght == 1) return $arr;
    $mid = (int)($lenght / 2);

    //把待排序陣列分割成兩半
    $left = mergeSort(array_slice($arr, 0, $mid));
    $right = mergeSort(array_slice($arr, $mid));

    return merge($left, $right);
}

function merge(array $left, array $right)
{
    //初始化兩個指標
    $leftIndex = $rightIndex = 0;
    $leftLength = count($left);
    $rightLength = count($right);
    //臨時空間
    $combine = [];

    //比較兩個指標所在的元素
    while ($leftIndex < $leftLength && $rightIndex < $rightLength) {
        //如果左邊的元素大於右邊的元素,就將右邊的元素放在單獨的陣列,並將右指標向後移動
        if ($left[$leftIndex] > $right[$rightIndex]) {
            $combine[] = $right[$rightIndex];
            $rightIndex++;
        } else {
            //如果右邊的元素大於左邊的元素,就將左邊的元素放在單獨的陣列,並將左指標向後移動
            $combine[] = $left[$leftIndex];
            $leftIndex++;
        }
    }

    //右邊的陣列全部都放入到了返回的陣列,然後把左邊陣列的值放入返回的陣列
    while ($leftIndex < $leftLength) {
        $combine[] = $left[$leftIndex];
        $leftIndex++;
    }

    //左邊的陣列全部都放入到了返回的陣列,然後把右邊陣列的值放入返回的陣列
    while ($rightIndex < $rightLength) {
        $combine[] = $right[$rightIndex];
        $rightIndex++;
    }

    return $combine;
}

我們劃分陣列,直到它達到1的大小。然後,我們開始使用合併函式合併結果。在合併函式中,我們有一個數組來儲存合併的結果。正因為如此,合併排序實際上比我們迄今所看到的其他演算法具有更大的空間複雜度。

歸併排序的複雜度

由於歸併排序遵循分而治之的方法,所以我們必須解決這兩個複雜問題。對於n個大小的陣列,我們首先需要將陣列分成兩個部分,然後合併它們以得到n個大小的陣列。我們可以看下面的示意圖

clipboard.png

解決每一層子問題需要的時間都是cn,假設一共有l層,那麼總的時間複雜度會是ln。因為一共有logn + 1層,那麼結果就是 cn(logn + 1)。我們刪除常數階和線性階,最後的結果可以得出時間複雜度就是O(nlog2n)。

best time complexity Ω(nlogn)
worst time complexity O(nlogn)
average time complexity Θ(nlogn)
space complexity (worst case) O(n)

快速排序

快速排序是對氣泡排序的一種改進。它的基本思想是:通過一趟排序將要排序的資料分割成獨立的兩部分,其中一部分的所有資料都比另外一部分的所有資料都要小,然後再按此方法對這兩部分資料分別進行快速排序,整個排序過程可以遞迴進行,以此達到整個資料變成有序序列。

function qSort(array &$arr, int $p, int $r)
{
    if ($p < $r) {
        $q = partition($arr, $p, $r);
        qSort($arr, $p, $q);
        qSort($arr, $q + 1, $r);
    }
}

function partition(array &$arr, int $p, int $r)
{
    $pivot = $arr[$p];
    $i = $p - 1;
    $j = $r + 1;

    while (true) {
        do {
            $i++;
        } while ($arr[$i] < $pivot);

        do {
            $j--;
        } while ($arr[$j] > $pivot);

        if ($i < $j) {
            list($arr[$i], $arr[$j]) = [$arr[$j], $arr[$i]];
        } else {
            return $j;
        }

    }
}

快速排序的複雜度

最壞情況下快速排序具有與氣泡排序相同的時間複雜度,pivot的選取非常重要。下面是快速排序的複雜度分析。

best time complexity Ω(nlogn)
worst time complexity O(n2)
average time complexity Θ(nlogn)
space complexity (worst case) O(logn)

對於快速排序的優化,有興趣的老鐵可以點選這裡檢視。

桶排序

桶排序 (Bucket sort)或所謂的箱排序,工作的原理是將陣列分到有限數量的桶裡。每個桶再分別排序(有可能再使用別的排序演算法或是以遞迴方式繼續使用桶排序進行排序)。

/**
 * 桶排序
 * 不是一種基於比較的排序
 * T(N, M) = O(M + N) N是帶排序的資料的個數,M是資料值的數量
 * 當 M >> N 時,需要考慮使用基數排序
 */

function bucketSort(array &$data)
{
    $bucketLen = max($data) - min($data) + 1;
    $bucket = array_fill(0, $bucketLen, []);

    for ($i = 0; $i < count($data); $i++) {
        array_push($bucket[$data[$i] - min($data)], $data[$i]);
    }

    $k = 0;

    for ($i = 0; $i < $bucketLen; $i++) {
        $currentBucketLen = count($bucket[$i]);

        for ($j = 0; $j < $currentBucketLen; $j++) {
            $data[$k] = $bucket[$i][$j];
            $k++;
        }
    }
}

基數排序的PHP實現,有興趣的同學同樣可以訪問這個頁面來檢視。

快速排序的複雜度

桶排序的時間複雜度優於其他基於比較的排序演算法。以下是桶排序的複雜性

best time complexity Ω(n+k)
worst time complexity O(n2)
average time complexity Θ(n+k)
space complexity (worst case) O(n)

PHP內建的排序演算法

PHP有豐富的預定義函式庫,也包含不同的排序函式。它有不同的功能來排序陣列中的專案,你可以選擇按值還是按鍵/索引進行排序。在排序時,我們還可以保持陣列值與它們各自的鍵的關聯。下面是這些函式的總結

函式名 功能
sort() 升序排列陣列。value/key關聯不保留
rsort() 按反向/降序排序陣列。index/key關聯不保留
asort() 在保持索引關聯的同時排序陣列
arsort() 對陣列進行反向排序並維護索引關聯
ksort() 按關鍵字排序陣列。它保持資料相關性的關鍵。這對於關聯陣列是有用的
krsort() 按順序對陣列按鍵排序
natsort() 使用自然順序演算法對陣列進行排序,並保持value/key關聯
natcasesort() 使用不區分大小寫的“自然順序”演算法對陣列進行排序,並保持value/key關聯。
usort() 使用使用者定義的比較函式按值對陣列進行排序,並且不維護value/key關聯。第二個引數是用於比較的可呼叫函式
uksort() 使用使用者定義的比較函式按鍵對陣列進行排序,並且不維護value/key關聯。第二個引數是用於比較的可呼叫函式
uasort() 使用使用者定義的比較函式按值對陣列進行排序,並且維護value/key關聯。第二個引數是用於比較的可呼叫函式

對於sort()、rsort()、ksort()、krsort()、asort()以及 arsort()下面的常量可以使用

  • SORT_REGULAR - 正常比較單元(不改變型別)
  • SORT_NUMERIC - 單元被作為數字來比較
  • SORT_STRING - 單元被作為字串來比較
  • SORT_LOCALE_STRING - 根據當前的區域(locale)設定來把單元當作字串比較,可以用 setlocale() 來改變。
  • SORT_NATURAL - 和 natsort() 類似對每個單元以“自然的順序”對字串進行排序。 PHP 5.4.0 中新增的。
  • SORT_FLAG_CASE - 能夠與 SORT_STRING 或 SORT_NATURAL 合併(OR 位運算),不區分大小寫排序字串。

完整內容

本文引用到的所有演算法的實現在這個地址,主要內容是使用PHP語法總結基礎的資料結構和演算法。歡迎各位老鐵收藏~