feat: Traditional Chinese version (#1163)
* First commit * Update mkdocs.yml * Translate all the docs to traditional Chinese * Translate the code files. * Translate the docker file * Fix mkdocs.yml * Translate all the figures from SC to TC * 二叉搜尋樹 -> 二元搜尋樹 * Update terminology. * Update terminology * 构造函数/构造方法 -> 建構子 异或 -> 互斥或 * 擴充套件 -> 擴展 * constant - 常量 - 常數 * 類 -> 類別 * AVL -> AVL 樹 * 數組 -> 陣列 * 係統 -> 系統 斐波那契數列 -> 費波那契數列 運算元量 -> 運算量 引數 -> 參數 * 聯絡 -> 關聯 * 麵試 -> 面試 * 面向物件 -> 物件導向 歸併排序 -> 合併排序 范式 -> 範式 * Fix 算法 -> 演算法 * 錶示 -> 表示 反碼 -> 一補數 補碼 -> 二補數 列列尾部 -> 佇列尾部 區域性性 -> 區域性 一摞 -> 一疊 * Synchronize with main branch * 賬號 -> 帳號 推匯 -> 推導 * Sync with main branch * First commit * Update mkdocs.yml * Translate all the docs to traditional Chinese * Translate the code files. * Translate the docker file * Fix mkdocs.yml * Translate all the figures from SC to TC * 二叉搜尋樹 -> 二元搜尋樹 * Update terminology * 构造函数/构造方法 -> 建構子 异或 -> 互斥或 * 擴充套件 -> 擴展 * constant - 常量 - 常數 * 類 -> 類別 * AVL -> AVL 樹 * 數組 -> 陣列 * 係統 -> 系統 斐波那契數列 -> 費波那契數列 運算元量 -> 運算量 引數 -> 參數 * 聯絡 -> 關聯 * 麵試 -> 面試 * 面向物件 -> 物件導向 歸併排序 -> 合併排序 范式 -> 範式 * Fix 算法 -> 演算法 * 錶示 -> 表示 反碼 -> 一補數 補碼 -> 二補數 列列尾部 -> 佇列尾部 區域性性 -> 區域性 一摞 -> 一疊 * Synchronize with main branch * 賬號 -> 帳號 推匯 -> 推導 * Sync with main branch * Update terminology.md * 操作数量(num. of operations)-> 操作數量 * 字首和->前綴和 * Update figures * 歸 -> 迴 記憶體洩漏 -> 記憶體流失 * Fix the bug of the file filter * 支援 -> 支持 Add zh-Hant/README.md * Add the zh-Hant chapter covers. Bug fixes. * 外掛 -> 擴充功能 * Add the landing page for zh-Hant version * Unify the font of the chapter covers for the zh, en, and zh-Hant version * Move zh-Hant/ to zh-hant/ * Translate terminology.md to traditional Chinese
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,59 @@
|
||||
# 泡沫排序
|
||||
|
||||
<u>泡沫排序(bubble sort)</u>透過連續地比較與交換相鄰元素實現排序。這個過程就像氣泡從底部升到頂部一樣,因此得名泡沫排序。
|
||||
|
||||
如下圖所示,冒泡過程可以利用元素交換操作來模擬:從陣列最左端開始向右走訪,依次比較相鄰元素大小,如果“左元素 > 右元素”就交換二者。走訪完成後,最大的元素會被移動到陣列的最右端。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
=== "<7>"
|
||||

|
||||
|
||||
## 演算法流程
|
||||
|
||||
設陣列的長度為 $n$ ,泡沫排序的步驟如下圖所示。
|
||||
|
||||
1. 首先,對 $n$ 個元素執行“冒泡”,**將陣列的最大元素交換至正確位置**。
|
||||
2. 接下來,對剩餘 $n - 1$ 個元素執行“冒泡”,**將第二大元素交換至正確位置**。
|
||||
3. 以此類推,經過 $n - 1$ 輪“冒泡”後,**前 $n - 1$ 大的元素都被交換至正確位置**。
|
||||
4. 僅剩的一個元素必定是最小元素,無須排序,因此陣列排序完成。
|
||||
|
||||

|
||||
|
||||
示例程式碼如下:
|
||||
|
||||
```src
|
||||
[file]{bubble_sort}-[class]{}-[func]{bubble_sort}
|
||||
```
|
||||
|
||||
## 效率最佳化
|
||||
|
||||
我們發現,如果某輪“冒泡”中沒有執行任何交換操作,說明陣列已經完成排序,可直接返回結果。因此,可以增加一個標誌位 `flag` 來監測這種情況,一旦出現就立即返回。
|
||||
|
||||
經過最佳化,泡沫排序的最差時間複雜度和平均時間複雜度仍為 $O(n^2)$ ;但當輸入陣列完全有序時,可達到最佳時間複雜度 $O(n)$ 。
|
||||
|
||||
```src
|
||||
[file]{bubble_sort}-[class]{}-[func]{bubble_sort_with_flag}
|
||||
```
|
||||
|
||||
## 演算法特性
|
||||
|
||||
- **時間複雜度為 $O(n^2)$、自適應排序**:各輪“冒泡”走訪的陣列長度依次為 $n - 1$、$n - 2$、$\dots$、$2$、$1$ ,總和為 $(n - 1) n / 2$ 。在引入 `flag` 最佳化後,最佳時間複雜度可達到 $O(n)$ 。
|
||||
- **空間複雜度為 $O(1)$、原地排序**:指標 $i$ 和 $j$ 使用常數大小的額外空間。
|
||||
- **穩定排序**:由於在“冒泡”中遇到相等元素不交換。
|
||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,46 @@
|
||||
# 桶排序
|
||||
|
||||
前述幾種排序演算法都屬於“基於比較的排序演算法”,它們透過比較元素間的大小來實現排序。此類排序演算法的時間複雜度無法超越 $O(n \log n)$ 。接下來,我們將探討幾種“非比較排序演算法”,它們的時間複雜度可以達到線性階。
|
||||
|
||||
<u>桶排序(bucket sort)</u>是分治策略的一個典型應用。它透過設定一些具有大小順序的桶,每個桶對應一個數據範圍,將資料平均分配到各個桶中;然後,在每個桶內部分別執行排序;最終按照桶的順序將所有資料合併。
|
||||
|
||||
## 演算法流程
|
||||
|
||||
考慮一個長度為 $n$ 的陣列,其元素是範圍 $[0, 1)$ 內的浮點數。桶排序的流程如下圖所示。
|
||||
|
||||
1. 初始化 $k$ 個桶,將 $n$ 個元素分配到 $k$ 個桶中。
|
||||
2. 對每個桶分別執行排序(這裡採用程式語言的內建排序函式)。
|
||||
3. 按照桶從小到大的順序合併結果。
|
||||
|
||||

|
||||
|
||||
程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{bucket_sort}-[class]{}-[func]{bucket_sort}
|
||||
```
|
||||
|
||||
## 演算法特性
|
||||
|
||||
桶排序適用於處理體量很大的資料。例如,輸入資料包含 100 萬個元素,由於空間限制,系統記憶體無法一次性載入所有資料。此時,可以將資料分成 1000 個桶,然後分別對每個桶進行排序,最後將結果合併。
|
||||
|
||||
- **時間複雜度為 $O(n + k)$** :假設元素在各個桶內平均分佈,那麼每個桶內的元素數量為 $\frac{n}{k}$ 。假設排序單個桶使用 $O(\frac{n}{k} \log\frac{n}{k})$ 時間,則排序所有桶使用 $O(n \log\frac{n}{k})$ 時間。**當桶數量 $k$ 比較大時,時間複雜度則趨向於 $O(n)$** 。合併結果時需要走訪所有桶和元素,花費 $O(n + k)$ 時間。
|
||||
- **自適應排序**:在最差情況下,所有資料被分配到一個桶中,且排序該桶使用 $O(n^2)$ 時間。
|
||||
- **空間複雜度為 $O(n + k)$、非原地排序**:需要藉助 $k$ 個桶和總共 $n$ 個元素的額外空間。
|
||||
- 桶排序是否穩定取決於排序桶內元素的演算法是否穩定。
|
||||
|
||||
## 如何實現平均分配
|
||||
|
||||
桶排序的時間複雜度理論上可以達到 $O(n)$ ,**關鍵在於將元素均勻分配到各個桶中**,因為實際資料往往不是均勻分佈的。例如,我們想要將淘寶上的所有商品按價格範圍平均分配到 10 個桶中,但商品價格分佈不均,低於 100 元的非常多,高於 1000 元的非常少。若將價格區間平均劃分為 10 個,各個桶中的商品數量差距會非常大。
|
||||
|
||||
為實現平均分配,我們可以先設定一條大致的分界線,將資料粗略地分到 3 個桶中。**分配完畢後,再將商品較多的桶繼續劃分為 3 個桶,直至所有桶中的元素數量大致相等**。
|
||||
|
||||
如下圖所示,這種方法本質上是建立一棵遞迴樹,目標是讓葉節點的值儘可能平均。當然,不一定要每輪將資料劃分為 3 個桶,具體劃分方式可根據資料特點靈活選擇。
|
||||
|
||||

|
||||
|
||||
如果我們提前知道商品價格的機率分佈,**則可以根據資料機率分佈設定每個桶的價格分界線**。值得注意的是,資料分佈並不一定需要特意統計,也可以根據資料特點採用某種機率模型進行近似。
|
||||
|
||||
如下圖所示,我們假設商品價格服從正態分佈,這樣就可以合理地設定價格區間,從而將商品平均分配到各個桶中。
|
||||
|
||||

|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,84 @@
|
||||
# 計數排序
|
||||
|
||||
<u>計數排序(counting sort)</u>透過統計元素數量來實現排序,通常應用於整數陣列。
|
||||
|
||||
## 簡單實現
|
||||
|
||||
先來看一個簡單的例子。給定一個長度為 $n$ 的陣列 `nums` ,其中的元素都是“非負整數”,計數排序的整體流程如下圖所示。
|
||||
|
||||
1. 走訪陣列,找出其中的最大數字,記為 $m$ ,然後建立一個長度為 $m + 1$ 的輔助陣列 `counter` 。
|
||||
2. **藉助 `counter` 統計 `nums` 中各數字的出現次數**,其中 `counter[num]` 對應數字 `num` 的出現次數。統計方法很簡單,只需走訪 `nums`(設當前數字為 `num`),每輪將 `counter[num]` 增加 $1$ 即可。
|
||||
3. **由於 `counter` 的各個索引天然有序,因此相當於所有數字已經排序好了**。接下來,我們走訪 `counter` ,根據各數字出現次數從小到大的順序填入 `nums` 即可。
|
||||
|
||||

|
||||
|
||||
程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{counting_sort}-[class]{}-[func]{counting_sort_naive}
|
||||
```
|
||||
|
||||
!!! note "計數排序與桶排序的關聯"
|
||||
|
||||
從桶排序的角度看,我們可以將計數排序中的計數陣列 `counter` 的每個索引視為一個桶,將統計數量的過程看作將各個元素分配到對應的桶中。本質上,計數排序是桶排序在整型資料下的一個特例。
|
||||
|
||||
## 完整實現
|
||||
|
||||
細心的讀者可能發現了,**如果輸入資料是物件,上述步驟 `3.` 就失效了**。假設輸入資料是商品物件,我們想按照商品價格(類別的成員變數)對商品進行排序,而上述演算法只能給出價格的排序結果。
|
||||
|
||||
那麼如何才能得到原資料的排序結果呢?我們首先計算 `counter` 的“前綴和”。顧名思義,索引 `i` 處的前綴和 `prefix[i]` 等於陣列前 `i` 個元素之和:
|
||||
|
||||
$$
|
||||
\text{prefix}[i] = \sum_{j=0}^i \text{counter[j]}
|
||||
$$
|
||||
|
||||
**前綴和具有明確的意義,`prefix[num] - 1` 代表元素 `num` 在結果陣列 `res` 中最後一次出現的索引**。這個資訊非常關鍵,因為它告訴我們各個元素應該出現在結果陣列的哪個位置。接下來,我們倒序走訪原陣列 `nums` 的每個元素 `num` ,在每輪迭代中執行以下兩步。
|
||||
|
||||
1. 將 `num` 填入陣列 `res` 的索引 `prefix[num] - 1` 處。
|
||||
2. 令前綴和 `prefix[num]` 減小 $1$ ,從而得到下次放置 `num` 的索引。
|
||||
|
||||
走訪完成後,陣列 `res` 中就是排序好的結果,最後使用 `res` 覆蓋原陣列 `nums` 即可。下圖展示了完整的計數排序流程。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
=== "<7>"
|
||||

|
||||
|
||||
=== "<8>"
|
||||

|
||||
|
||||
計數排序的實現程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{counting_sort}-[class]{}-[func]{counting_sort}
|
||||
```
|
||||
|
||||
## 演算法特性
|
||||
|
||||
- **時間複雜度為 $O(n + m)$** :涉及走訪 `nums` 和走訪 `counter` ,都使用線性時間。一般情況下 $n \gg m$ ,時間複雜度趨於 $O(n)$ 。
|
||||
- **空間複雜度為 $O(n + m)$、非原地排序**:藉助了長度分別為 $n$ 和 $m$ 的陣列 `res` 和 `counter` 。
|
||||
- **穩定排序**:由於向 `res` 中填充元素的順序是“從右向左”的,因此倒序走訪 `nums` 可以避免改變相等元素之間的相對位置,從而實現穩定排序。實際上,正序走訪 `nums` 也可以得到正確的排序結果,但結果是非穩定的。
|
||||
|
||||
## 侷限性
|
||||
|
||||
看到這裡,你也許會覺得計數排序非常巧妙,僅透過統計數量就可以實現高效的排序。然而,使用計數排序的前置條件相對較為嚴格。
|
||||
|
||||
**計數排序只適用於非負整數**。若想將其用於其他型別的資料,需要確保這些資料可以轉換為非負整數,並且在轉換過程中不能改變各個元素之間的相對大小關係。例如,對於包含負數的整數陣列,可以先給所有數字加上一個常數,將全部數字轉化為正數,排序完成後再轉換回去。
|
||||
|
||||
**計數排序適用於資料量大但資料範圍較小的情況**。比如,在上述示例中 $m$ 不能太大,否則會佔用過多空間。而當 $n \ll m$ 時,計數排序使用 $O(m)$ 時間,可能比 $O(n \log n)$ 的排序演算法還要慢。
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,73 @@
|
||||
# 堆積排序
|
||||
|
||||
!!! tip
|
||||
|
||||
閱讀本節前,請確保已學完“堆積“章節。
|
||||
|
||||
<u>堆積排序(heap sort)</u>是一種基於堆積資料結構實現的高效排序演算法。我們可以利用已經學過的“建堆積操作”和“元素出堆積操作”實現堆積排序。
|
||||
|
||||
1. 輸入陣列並建立小頂堆積,此時最小元素位於堆積頂。
|
||||
2. 不斷執行出堆積操作,依次記錄出堆積元素,即可得到從小到大排序的序列。
|
||||
|
||||
以上方法雖然可行,但需要藉助一個額外陣列來儲存彈出的元素,比較浪費空間。在實際中,我們通常使用一種更加優雅的實現方式。
|
||||
|
||||
## 演算法流程
|
||||
|
||||
設陣列的長度為 $n$ ,堆積排序的流程如下圖所示。
|
||||
|
||||
1. 輸入陣列並建立大頂堆積。完成後,最大元素位於堆積頂。
|
||||
2. 將堆積頂元素(第一個元素)與堆積底元素(最後一個元素)交換。完成交換後,堆積的長度減 $1$ ,已排序元素數量加 $1$ 。
|
||||
3. 從堆積頂元素開始,從頂到底執行堆積化操作(sift down)。完成堆積化後,堆積的性質得到修復。
|
||||
4. 迴圈執行第 `2.` 步和第 `3.` 步。迴圈 $n - 1$ 輪後,即可完成陣列排序。
|
||||
|
||||
!!! tip
|
||||
|
||||
實際上,元素出堆積操作中也包含第 `2.` 步和第 `3.` 步,只是多了一個彈出元素的步驟。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
=== "<7>"
|
||||

|
||||
|
||||
=== "<8>"
|
||||

|
||||
|
||||
=== "<9>"
|
||||

|
||||
|
||||
=== "<10>"
|
||||

|
||||
|
||||
=== "<11>"
|
||||

|
||||
|
||||
=== "<12>"
|
||||

|
||||
|
||||
在程式碼實現中,我們使用了與“堆積”章節相同的從頂至底堆積化 `sift_down()` 函式。值得注意的是,由於堆積的長度會隨著提取最大元素而減小,因此我們需要給 `sift_down()` 函式新增一個長度參數 $n$ ,用於指定堆積的當前有效長度。程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{heap_sort}-[class]{}-[func]{heap_sort}
|
||||
```
|
||||
|
||||
## 演算法特性
|
||||
|
||||
- **時間複雜度為 $O(n \log n)$、非自適應排序**:建堆積操作使用 $O(n)$ 時間。從堆積中提取最大元素的時間複雜度為 $O(\log n)$ ,共迴圈 $n - 1$ 輪。
|
||||
- **空間複雜度為 $O(1)$、原地排序**:幾個指標變數使用 $O(1)$ 空間。元素交換和堆積化操作都是在原陣列上進行的。
|
||||
- **非穩定排序**:在交換堆積頂元素和堆積底元素時,相等元素的相對位置可能發生變化。
|
||||
@@ -0,0 +1,9 @@
|
||||
# 排序
|
||||
|
||||

|
||||
|
||||
!!! abstract
|
||||
|
||||
排序猶如一把將混亂變為秩序的魔法鑰匙,使我們能以更高效的方式理解與處理資料。
|
||||
|
||||
無論是簡單的升序,還是複雜的分類排列,排序都向我們展示了資料的和諧美感。
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,46 @@
|
||||
# 插入排序
|
||||
|
||||
<u>插入排序(insertion sort)</u>是一種簡單的排序演算法,它的工作原理與手動整理一副牌的過程非常相似。
|
||||
|
||||
具體來說,我們在未排序區間選擇一個基準元素,將該元素與其左側已排序區間的元素逐一比較大小,並將該元素插入到正確的位置。
|
||||
|
||||
下圖展示了陣列插入元素的操作流程。設基準元素為 `base` ,我們需要將從目標索引到 `base` 之間的所有元素向右移動一位,然後將 `base` 賦值給目標索引。
|
||||
|
||||

|
||||
|
||||
## 演算法流程
|
||||
|
||||
插入排序的整體流程如下圖所示。
|
||||
|
||||
1. 初始狀態下,陣列的第 1 個元素已完成排序。
|
||||
2. 選取陣列的第 2 個元素作為 `base` ,將其插入到正確位置後,**陣列的前 2 個元素已排序**。
|
||||
3. 選取第 3 個元素作為 `base` ,將其插入到正確位置後,**陣列的前 3 個元素已排序**。
|
||||
4. 以此類推,在最後一輪中,選取最後一個元素作為 `base` ,將其插入到正確位置後,**所有元素均已排序**。
|
||||
|
||||

|
||||
|
||||
示例程式碼如下:
|
||||
|
||||
```src
|
||||
[file]{insertion_sort}-[class]{}-[func]{insertion_sort}
|
||||
```
|
||||
|
||||
## 演算法特性
|
||||
|
||||
- **時間複雜度為 $O(n^2)$、自適應排序**:在最差情況下,每次插入操作分別需要迴圈 $n - 1$、$n-2$、$\dots$、$2$、$1$ 次,求和得到 $(n - 1) n / 2$ ,因此時間複雜度為 $O(n^2)$ 。在遇到有序資料時,插入操作會提前終止。當輸入陣列完全有序時,插入排序達到最佳時間複雜度 $O(n)$ 。
|
||||
- **空間複雜度為 $O(1)$、原地排序**:指標 $i$ 和 $j$ 使用常數大小的額外空間。
|
||||
- **穩定排序**:在插入操作過程中,我們會將元素插入到相等元素的右側,不會改變它們的順序。
|
||||
|
||||
## 插入排序的優勢
|
||||
|
||||
插入排序的時間複雜度為 $O(n^2)$ ,而我們即將學習的快速排序的時間複雜度為 $O(n \log n)$ 。儘管插入排序的時間複雜度更高,**但在資料量較小的情況下,插入排序通常更快**。
|
||||
|
||||
這個結論與線性查詢和二分搜尋的適用情況的結論類似。快速排序這類 $O(n \log n)$ 的演算法屬於基於分治策略的排序演算法,往往包含更多單元計算操作。而在資料量較小時,$n^2$ 和 $n \log n$ 的數值比較接近,複雜度不佔主導地位,每輪中的單元操作數量起到決定性作用。
|
||||
|
||||
實際上,許多程式語言(例如 Java)的內建排序函式採用了插入排序,大致思路為:對於長陣列,採用基於分治策略的排序演算法,例如快速排序;對於短陣列,直接使用插入排序。
|
||||
|
||||
雖然泡沫排序、選擇排序和插入排序的時間複雜度都為 $O(n^2)$ ,但在實際情況中,**插入排序的使用頻率顯著高於泡沫排序和選擇排序**,主要有以下原因。
|
||||
|
||||
- 泡沫排序基於元素交換實現,需要藉助一個臨時變數,共涉及 3 個單元操作;插入排序基於元素賦值實現,僅需 1 個單元操作。因此,**泡沫排序的計算開銷通常比插入排序更高**。
|
||||
- 選擇排序在任何情況下的時間複雜度都為 $O(n^2)$ 。**如果給定一組部分有序的資料,插入排序通常比選擇排序效率更高**。
|
||||
- 選擇排序不穩定,無法應用於多級排序。
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,73 @@
|
||||
# 合併排序
|
||||
|
||||
<u>合併排序(merge sort)</u>是一種基於分治策略的排序演算法,包含下圖所示的“劃分”和“合併”階段。
|
||||
|
||||
1. **劃分階段**:透過遞迴不斷地將陣列從中點處分開,將長陣列的排序問題轉換為短陣列的排序問題。
|
||||
2. **合併階段**:當子陣列長度為 1 時終止劃分,開始合併,持續地將左右兩個較短的有序陣列合併為一個較長的有序陣列,直至結束。
|
||||
|
||||

|
||||
|
||||
## 演算法流程
|
||||
|
||||
如下圖所示,“劃分階段”從頂至底遞迴地將陣列從中點切分為兩個子陣列。
|
||||
|
||||
1. 計算陣列中點 `mid` ,遞迴劃分左子陣列(區間 `[left, mid]` )和右子陣列(區間 `[mid + 1, right]` )。
|
||||
2. 遞迴執行步驟 `1.` ,直至子陣列區間長度為 1 時終止。
|
||||
|
||||
“合併階段”從底至頂地將左子陣列和右子陣列合併為一個有序陣列。需要注意的是,從長度為 1 的子陣列開始合併,合併階段中的每個子陣列都是有序的。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
=== "<7>"
|
||||

|
||||
|
||||
=== "<8>"
|
||||

|
||||
|
||||
=== "<9>"
|
||||

|
||||
|
||||
=== "<10>"
|
||||

|
||||
|
||||
觀察發現,合併排序與二元樹後序走訪的遞迴順序是一致的。
|
||||
|
||||
- **後序走訪**:先遞迴左子樹,再遞迴右子樹,最後處理根節點。
|
||||
- **合併排序**:先遞迴左子陣列,再遞迴右子陣列,最後處理合併。
|
||||
|
||||
合併排序的實現如以下程式碼所示。請注意,`nums` 的待合併區間為 `[left, right]` ,而 `tmp` 的對應區間為 `[0, right - left]` 。
|
||||
|
||||
```src
|
||||
[file]{merge_sort}-[class]{}-[func]{merge_sort}
|
||||
```
|
||||
|
||||
## 演算法特性
|
||||
|
||||
- **時間複雜度為 $O(n \log n)$、非自適應排序**:劃分產生高度為 $\log n$ 的遞迴樹,每層合併的總操作數量為 $n$ ,因此總體時間複雜度為 $O(n \log n)$ 。
|
||||
- **空間複雜度為 $O(n)$、非原地排序**:遞迴深度為 $\log n$ ,使用 $O(\log n)$ 大小的堆疊幀空間。合併操作需要藉助輔助陣列實現,使用 $O(n)$ 大小的額外空間。
|
||||
- **穩定排序**:在合併過程中,相等元素的次序保持不變。
|
||||
|
||||
## 鏈結串列排序
|
||||
|
||||
對於鏈結串列,合併排序相較於其他排序演算法具有顯著優勢,**可以將鏈結串列排序任務的空間複雜度最佳化至 $O(1)$** 。
|
||||
|
||||
- **劃分階段**:可以使用“迭代”替代“遞迴”來實現鏈結串列劃分工作,從而省去遞迴使用的堆疊幀空間。
|
||||
- **合併階段**:在鏈結串列中,節點增刪操作僅需改變引用(指標)即可實現,因此合併階段(將兩個短有序鏈結串列合併為一個長有序鏈結串列)無須建立額外鏈結串列。
|
||||
|
||||
具體實現細節比較複雜,有興趣的讀者可以查閱相關資料進行學習。
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,100 @@
|
||||
# 快速排序
|
||||
|
||||
<u>快速排序(quick sort)</u>是一種基於分治策略的排序演算法,執行高效,應用廣泛。
|
||||
|
||||
快速排序的核心操作是“哨兵劃分”,其目標是:選擇陣列中的某個元素作為“基準數”,將所有小於基準數的元素移到其左側,而大於基準數的元素移到其右側。具體來說,哨兵劃分的流程如下圖所示。
|
||||
|
||||
1. 選取陣列最左端元素作為基準數,初始化兩個指標 `i` 和 `j` 分別指向陣列的兩端。
|
||||
2. 設定一個迴圈,在每輪中使用 `i`(`j`)分別尋找第一個比基準數大(小)的元素,然後交換這兩個元素。
|
||||
3. 迴圈執行步驟 `2.` ,直到 `i` 和 `j` 相遇時停止,最後將基準數交換至兩個子陣列的分界線。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
=== "<7>"
|
||||

|
||||
|
||||
=== "<8>"
|
||||

|
||||
|
||||
=== "<9>"
|
||||

|
||||
|
||||
哨兵劃分完成後,原陣列被劃分成三部分:左子陣列、基準數、右子陣列,且滿足“左子陣列任意元素 $\leq$ 基準數 $\leq$ 右子陣列任意元素”。因此,我們接下來只需對這兩個子陣列進行排序。
|
||||
|
||||
!!! note "快速排序的分治策略"
|
||||
|
||||
哨兵劃分的實質是將一個較長陣列的排序問題簡化為兩個較短陣列的排序問題。
|
||||
|
||||
```src
|
||||
[file]{quick_sort}-[class]{quick_sort}-[func]{partition}
|
||||
```
|
||||
|
||||
## 演算法流程
|
||||
|
||||
快速排序的整體流程如下圖所示。
|
||||
|
||||
1. 首先,對原陣列執行一次“哨兵劃分”,得到未排序的左子陣列和右子陣列。
|
||||
2. 然後,對左子陣列和右子陣列分別遞迴執行“哨兵劃分”。
|
||||
3. 持續遞迴,直至子陣列長度為 1 時終止,從而完成整個陣列的排序。
|
||||
|
||||

|
||||
|
||||
```src
|
||||
[file]{quick_sort}-[class]{quick_sort}-[func]{quick_sort}
|
||||
```
|
||||
|
||||
## 演算法特性
|
||||
|
||||
- **時間複雜度為 $O(n \log n)$、自適應排序**:在平均情況下,哨兵劃分的遞迴層數為 $\log n$ ,每層中的總迴圈數為 $n$ ,總體使用 $O(n \log n)$ 時間。在最差情況下,每輪哨兵劃分操作都將長度為 $n$ 的陣列劃分為長度為 $0$ 和 $n - 1$ 的兩個子陣列,此時遞迴層數達到 $n$ ,每層中的迴圈數為 $n$ ,總體使用 $O(n^2)$ 時間。
|
||||
- **空間複雜度為 $O(n)$、原地排序**:在輸入陣列完全倒序的情況下,達到最差遞迴深度 $n$ ,使用 $O(n)$ 堆疊幀空間。排序操作是在原陣列上進行的,未藉助額外陣列。
|
||||
- **非穩定排序**:在哨兵劃分的最後一步,基準數可能會被交換至相等元素的右側。
|
||||
|
||||
## 快速排序為什麼快
|
||||
|
||||
從名稱上就能看出,快速排序在效率方面應該具有一定的優勢。儘管快速排序的平均時間複雜度與“合併排序”和“堆積排序”相同,但通常快速排序的效率更高,主要有以下原因。
|
||||
|
||||
- **出現最差情況的機率很低**:雖然快速排序的最差時間複雜度為 $O(n^2)$ ,沒有合併排序穩定,但在絕大多數情況下,快速排序能在 $O(n \log n)$ 的時間複雜度下執行。
|
||||
- **快取使用效率高**:在執行哨兵劃分操作時,系統可將整個子陣列載入到快取,因此訪問元素的效率較高。而像“堆積排序”這類演算法需要跳躍式訪問元素,從而缺乏這一特性。
|
||||
- **複雜度的常數係數小**:在上述三種演算法中,快速排序的比較、賦值、交換等操作的總數量最少。這與“插入排序”比“泡沫排序”更快的原因類似。
|
||||
|
||||
## 基準數最佳化
|
||||
|
||||
**快速排序在某些輸入下的時間效率可能降低**。舉一個極端例子,假設輸入陣列是完全倒序的,由於我們選擇最左端元素作為基準數,那麼在哨兵劃分完成後,基準數被交換至陣列最右端,導致左子陣列長度為 $n - 1$、右子陣列長度為 $0$ 。如此遞迴下去,每輪哨兵劃分後都有一個子陣列的長度為 $0$ ,分治策略失效,快速排序退化為“泡沫排序”的近似形式。
|
||||
|
||||
為了儘量避免這種情況發生,**我們可以最佳化哨兵劃分中的基準數的選取策略**。例如,我們可以隨機選取一個元素作為基準數。然而,如果運氣不佳,每次都選到不理想的基準數,效率仍然不盡如人意。
|
||||
|
||||
需要注意的是,程式語言通常生成的是“偽隨機數”。如果我們針對偽隨機數序列構建一個特定的測試樣例,那麼快速排序的效率仍然可能劣化。
|
||||
|
||||
為了進一步改進,我們可以在陣列中選取三個候選元素(通常為陣列的首、尾、中點元素),**並將這三個候選元素的中位數作為基準數**。這樣一來,基準數“既不太小也不太大”的機率將大幅提升。當然,我們還可以選取更多候選元素,以進一步提高演算法的穩健性。採用這種方法後,時間複雜度劣化至 $O(n^2)$ 的機率大大降低。
|
||||
|
||||
示例程式碼如下:
|
||||
|
||||
```src
|
||||
[file]{quick_sort}-[class]{quick_sort_median}-[func]{partition}
|
||||
```
|
||||
|
||||
## 尾遞迴最佳化
|
||||
|
||||
**在某些輸入下,快速排序可能佔用空間較多**。以完全有序的輸入陣列為例,設遞迴中的子陣列長度為 $m$ ,每輪哨兵劃分操作都將產生長度為 $0$ 的左子陣列和長度為 $m - 1$ 的右子陣列,這意味著每一層遞迴呼叫減少的問題規模非常小(只減少一個元素),遞迴樹的高度會達到 $n - 1$ ,此時需要佔用 $O(n)$ 大小的堆疊幀空間。
|
||||
|
||||
為了防止堆疊幀空間的累積,我們可以在每輪哨兵排序完成後,比較兩個子陣列的長度,**僅對較短的子陣列進行遞迴**。由於較短子陣列的長度不會超過 $n / 2$ ,因此這種方法能確保遞迴深度不超過 $\log n$ ,從而將最差空間複雜度最佳化至 $O(\log n)$ 。程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{quick_sort}-[class]{quick_sort_tail_call}-[func]{quick_sort}
|
||||
```
|
||||
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,41 @@
|
||||
# 基數排序
|
||||
|
||||
上一節介紹了計數排序,它適用於資料量 $n$ 較大但資料範圍 $m$ 較小的情況。假設我們需要對 $n = 10^6$ 個學號進行排序,而學號是一個 $8$ 位數字,這意味著資料範圍 $m = 10^8$ 非常大,使用計數排序需要分配大量記憶體空間,而基數排序可以避免這種情況。
|
||||
|
||||
<u>基數排序(radix sort)</u>的核心思想與計數排序一致,也透過統計個數來實現排序。在此基礎上,基數排序利用數字各位之間的遞進關係,依次對每一位進行排序,從而得到最終的排序結果。
|
||||
|
||||
## 演算法流程
|
||||
|
||||
以學號資料為例,假設數字的最低位是第 $1$ 位,最高位是第 $8$ 位,基數排序的流程如下圖所示。
|
||||
|
||||
1. 初始化位數 $k = 1$ 。
|
||||
2. 對學號的第 $k$ 位執行“計數排序”。完成後,資料會根據第 $k$ 位從小到大排序。
|
||||
3. 將 $k$ 增加 $1$ ,然後返回步驟 `2.` 繼續迭代,直到所有位都排序完成後結束。
|
||||
|
||||

|
||||
|
||||
下面剖析程式碼實現。對於一個 $d$ 進位制的數字 $x$ ,要獲取其第 $k$ 位 $x_k$ ,可以使用以下計算公式:
|
||||
|
||||
$$
|
||||
x_k = \lfloor\frac{x}{d^{k-1}}\rfloor \bmod d
|
||||
$$
|
||||
|
||||
其中 $\lfloor a \rfloor$ 表示對浮點數 $a$ 向下取整,而 $\bmod \: d$ 表示對 $d$ 取模(取餘)。對於學號資料,$d = 10$ 且 $k \in [1, 8]$ 。
|
||||
|
||||
此外,我們需要小幅改動計數排序程式碼,使之可以根據數字的第 $k$ 位進行排序:
|
||||
|
||||
```src
|
||||
[file]{radix_sort}-[class]{}-[func]{radix_sort}
|
||||
```
|
||||
|
||||
!!! question "為什麼從最低位開始排序?"
|
||||
|
||||
在連續的排序輪次中,後一輪排序會覆蓋前一輪排序的結果。舉例來說,如果第一輪排序結果 $a < b$ ,而第二輪排序結果 $a > b$ ,那麼第二輪的結果將取代第一輪的結果。由於數字的高位優先順序高於低位,因此應該先排序低位再排序高位。
|
||||
|
||||
## 演算法特性
|
||||
|
||||
相較於計數排序,基數排序適用於數值範圍較大的情況,**但前提是資料必須可以表示為固定位數的格式,且位數不能過大**。例如,浮點數不適合使用基數排序,因為其位數 $k$ 過大,可能導致時間複雜度 $O(nk) \gg O(n^2)$ 。
|
||||
|
||||
- **時間複雜度為 $O(nk)$**:設資料量為 $n$、資料為 $d$ 進位制、最大位數為 $k$ ,則對某一位執行計數排序使用 $O(n + d)$ 時間,排序所有 $k$ 位使用 $O((n + d)k)$ 時間。通常情況下,$d$ 和 $k$ 都相對較小,時間複雜度趨向 $O(n)$ 。
|
||||
- **空間複雜度為 $O(n + d)$、非原地排序**:與計數排序相同,基數排序需要藉助長度為 $n$ 和 $d$ 的陣列 `res` 和 `counter` 。
|
||||
- **穩定排序**:當計數排序穩定時,基數排序也穩定;當計數排序不穩定時,基數排序無法保證得到正確的排序結果。
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,58 @@
|
||||
# 選擇排序
|
||||
|
||||
<u>選擇排序(selection sort)</u>的工作原理非常簡單:開啟一個迴圈,每輪從未排序區間選擇最小的元素,將其放到已排序區間的末尾。
|
||||
|
||||
設陣列的長度為 $n$ ,選擇排序的演算法流程如下圖所示。
|
||||
|
||||
1. 初始狀態下,所有元素未排序,即未排序(索引)區間為 $[0, n-1]$ 。
|
||||
2. 選取區間 $[0, n-1]$ 中的最小元素,將其與索引 $0$ 處的元素交換。完成後,陣列前 1 個元素已排序。
|
||||
3. 選取區間 $[1, n-1]$ 中的最小元素,將其與索引 $1$ 處的元素交換。完成後,陣列前 2 個元素已排序。
|
||||
4. 以此類推。經過 $n - 1$ 輪選擇與交換後,陣列前 $n - 1$ 個元素已排序。
|
||||
5. 僅剩的一個元素必定是最大元素,無須排序,因此陣列排序完成。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
=== "<7>"
|
||||

|
||||
|
||||
=== "<8>"
|
||||

|
||||
|
||||
=== "<9>"
|
||||

|
||||
|
||||
=== "<10>"
|
||||

|
||||
|
||||
=== "<11>"
|
||||

|
||||
|
||||
在程式碼中,我們用 $k$ 來記錄未排序區間內的最小元素:
|
||||
|
||||
```src
|
||||
[file]{selection_sort}-[class]{}-[func]{selection_sort}
|
||||
```
|
||||
|
||||
## 演算法特性
|
||||
|
||||
- **時間複雜度為 $O(n^2)$、非自適應排序**:外迴圈共 $n - 1$ 輪,第一輪的未排序區間長度為 $n$ ,最後一輪的未排序區間長度為 $2$ ,即各輪外迴圈分別包含 $n$、$n - 1$、$\dots$、$3$、$2$ 輪內迴圈,求和為 $\frac{(n - 1)(n + 2)}{2}$ 。
|
||||
- **空間複雜度為 $O(1)$、原地排序**:指標 $i$ 和 $j$ 使用常數大小的額外空間。
|
||||
- **非穩定排序**:如下圖所示,元素 `nums[i]` 有可能被交換至與其相等的元素的右邊,導致兩者的相對順序發生改變。
|
||||
|
||||

|
||||
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,48 @@
|
||||
# 排序演算法
|
||||
|
||||
<u>排序演算法(sorting algorithm)</u>用於對一組資料按照特定順序進行排列。排序演算法有著廣泛的應用,因為有序資料通常能夠被更高效地查詢、分析和處理。
|
||||
|
||||
如下圖所示,排序演算法中的資料型別可以是整數、浮點數、字元或字串等。排序的判斷規則可根據需求設定,如數字大小、字元 ASCII 碼順序或自定義規則。
|
||||
|
||||

|
||||
|
||||
## 評價維度
|
||||
|
||||
**執行效率**:我們期望排序演算法的時間複雜度儘量低,且總體操作數量較少(時間複雜度中的常數項變小)。對於大資料量的情況,執行效率顯得尤為重要。
|
||||
|
||||
**就地性**:顧名思義,<u>原地排序</u>透過在原陣列上直接操作實現排序,無須藉助額外的輔助陣列,從而節省記憶體。通常情況下,原地排序的資料搬運操作較少,執行速度也更快。
|
||||
|
||||
**穩定性**:<u>穩定排序</u>在完成排序後,相等元素在陣列中的相對順序不發生改變。
|
||||
|
||||
穩定排序是多級排序場景的必要條件。假設我們有一個儲存學生資訊的表格,第 1 列和第 2 列分別是姓名和年齡。在這種情況下,<u>非穩定排序</u>可能導致輸入資料的有序性喪失:
|
||||
|
||||
```shell
|
||||
# 輸入資料是按照姓名排序好的
|
||||
# (name, age)
|
||||
('A', 19)
|
||||
('B', 18)
|
||||
('C', 21)
|
||||
('D', 19)
|
||||
('E', 23)
|
||||
|
||||
# 假設使用非穩定排序演算法按年齡排序串列,
|
||||
# 結果中 ('D', 19) 和 ('A', 19) 的相對位置改變,
|
||||
# 輸入資料按姓名排序的性質丟失
|
||||
('B', 18)
|
||||
('D', 19)
|
||||
('A', 19)
|
||||
('C', 21)
|
||||
('E', 23)
|
||||
```
|
||||
|
||||
**自適應性**:<u>自適應排序</u>的時間複雜度會受輸入資料的影響,即最佳時間複雜度、最差時間複雜度、平均時間複雜度並不完全相等。
|
||||
|
||||
自適應性需要根據具體情況來評估。如果最差時間複雜度差於平均時間複雜度,說明排序演算法在某些資料下效能可能劣化,因此被視為負面屬性;而如果最佳時間複雜度優於平均時間複雜度,則被視為正面屬性。
|
||||
|
||||
**是否基於比較**:<u>基於比較的排序</u>依賴比較運算子($<$、$=$、$>$)來判斷元素的相對順序,從而排序整個陣列,理論最優時間複雜度為 $O(n \log n)$ 。而<u>非比較排序</u>不使用比較運算子,時間複雜度可達 $O(n)$ ,但其通用性相對較差。
|
||||
|
||||
## 理想排序演算法
|
||||
|
||||
**執行快、原地、穩定、正向自適應、通用性好**。顯然,迄今為止尚未發現兼具以上所有特性的排序演算法。因此,在選擇排序演算法時,需要根據具體的資料特點和問題需求來決定。
|
||||
|
||||
接下來,我們將共同學習各種排序演算法,並基於上述評價維度對各個排序演算法的優缺點進行分析。
|
||||
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,47 @@
|
||||
# 小結
|
||||
|
||||
### 重點回顧
|
||||
|
||||
- 泡沫排序透過交換相鄰元素來實現排序。透過新增一個標誌位來實現提前返回,我們可以將泡沫排序的最佳時間複雜度最佳化到 $O(n)$ 。
|
||||
- 插入排序每輪將未排序區間內的元素插入到已排序區間的正確位置,從而完成排序。雖然插入排序的時間複雜度為 $O(n^2)$ ,但由於單元操作相對較少,因此在小資料量的排序任務中非常受歡迎。
|
||||
- 快速排序基於哨兵劃分操作實現排序。在哨兵劃分中,有可能每次都選取到最差的基準數,導致時間複雜度劣化至 $O(n^2)$ 。引入中位數基準數或隨機基準數可以降低這種劣化的機率。尾遞迴方法可以有效地減少遞迴深度,將空間複雜度最佳化到 $O(\log n)$ 。
|
||||
- 合併排序包括劃分和合並兩個階段,典型地體現了分治策略。在合併排序中,排序陣列需要建立輔助陣列,空間複雜度為 $O(n)$ ;然而排序鏈結串列的空間複雜度可以最佳化至 $O(1)$ 。
|
||||
- 桶排序包含三個步驟:資料分桶、桶內排序和合並結果。它同樣體現了分治策略,適用於資料體量很大的情況。桶排序的關鍵在於對資料進行平均分配。
|
||||
- 計數排序是桶排序的一個特例,它透過統計資料出現的次數來實現排序。計數排序適用於資料量大但資料範圍有限的情況,並且要求資料能夠轉換為正整數。
|
||||
- 基數排序透過逐位排序來實現資料排序,要求資料能夠表示為固定位數的數字。
|
||||
- 總的來說,我們希望找到一種排序演算法,具有高效率、穩定、原地以及正向自適應性等優點。然而,正如其他資料結構和演算法一樣,沒有一種排序演算法能夠同時滿足所有這些條件。在實際應用中,我們需要根據資料的特性來選擇合適的排序演算法。
|
||||
- 下圖對比了主流排序演算法的效率、穩定性、就地性和自適應性等。
|
||||
|
||||

|
||||
|
||||
### Q & A
|
||||
|
||||
**Q**:排序演算法穩定性在什麼情況下是必需的?
|
||||
|
||||
在現實中,我們有可能基於物件的某個屬性進行排序。例如,學生有姓名和身高兩個屬性,我們希望實現一個多級排序:先按照姓名進行排序,得到 `(A, 180) (B, 185) (C, 170) (D, 170)` ;再對身高進行排序。由於排序演算法不穩定,因此可能得到 `(D, 170) (C, 170) (A, 180) (B, 185)` 。
|
||||
|
||||
可以發現,學生 D 和 C 的位置發生了交換,姓名的有序性被破壞了,而這是我們不希望看到的。
|
||||
|
||||
**Q**:哨兵劃分中“從右往左查詢”與“從左往右查詢”的順序可以交換嗎?
|
||||
|
||||
不行,當我們以最左端元素為基準數時,必須先“從右往左查詢”再“從左往右查詢”。這個結論有些反直覺,我們來剖析一下原因。
|
||||
|
||||
哨兵劃分 `partition()` 的最後一步是交換 `nums[left]` 和 `nums[i]` 。完成交換後,基準數左邊的元素都 `<=` 基準數,**這就要求最後一步交換前 `nums[left] >= nums[i]` 必須成立**。假設我們先“從左往右查詢”,那麼如果找不到比基準數更大的元素,**則會在 `i == j` 時跳出迴圈,此時可能 `nums[j] == nums[i] > nums[left]`**。也就是說,此時最後一步交換操作會把一個比基準數更大的元素交換至陣列最左端,導致哨兵劃分失敗。
|
||||
|
||||
舉個例子,給定陣列 `[0, 0, 0, 0, 1]` ,如果先“從左向右查詢”,哨兵劃分後陣列為 `[1, 0, 0, 0, 0]` ,這個結果是不正確的。
|
||||
|
||||
再深入思考一下,如果我們選擇 `nums[right]` 為基準數,那麼正好反過來,必須先“從左往右查詢”。
|
||||
|
||||
**Q**:關於尾遞迴最佳化,為什麼選短的陣列能保證遞迴深度不超過 $\log n$ ?
|
||||
|
||||
遞迴深度就是當前未返回的遞迴方法的數量。每輪哨兵劃分我們將原陣列劃分為兩個子陣列。在尾遞迴最佳化後,向下遞迴的子陣列長度最大為原陣列長度的一半。假設最差情況,一直為一半長度,那麼最終的遞迴深度就是 $\log n$ 。
|
||||
|
||||
回顧原始的快速排序,我們有可能會連續地遞迴長度較大的陣列,最差情況下為 $n$、$n - 1$、$\dots$、$2$、$1$ ,遞迴深度為 $n$ 。尾遞迴最佳化可以避免這種情況出現。
|
||||
|
||||
**Q**:當陣列中所有元素都相等時,快速排序的時間複雜度是 $O(n^2)$ 嗎?該如何處理這種退化情況?
|
||||
|
||||
是的。對於這種情況,可以考慮透過哨兵劃分將陣列劃分為三個部分:小於、等於、大於基準數。僅向下遞迴小於和大於的兩部分。在該方法下,輸入元素全部相等的陣列,僅一輪哨兵劃分即可完成排序。
|
||||
|
||||
**Q**:桶排序的最差時間複雜度為什麼是 $O(n^2)$ ?
|
||||
|
||||
最差情況下,所有元素被分至同一個桶中。如果我們採用一個 $O(n^2)$ 演算法來排序這些元素,則時間複雜度為 $O(n^2)$ 。
|
||||