楔子
無論你使用哪種語言,從事哪個方向,在面試中算法基本上都是逃不掉的。也許你聽說過技術過時或者語言過時,但你絕對沒有聽過算法過時。這一次我們來了解一下常見的排序算法,以及它們的時間復雜度,並使用代碼實現它們。
冒泡排序
冒泡排序(Bubble Sort)是一種非常簡單直觀的排序算法,就是從左到右依次比較兩個相鄰元素,如果左邊元素大於右邊元素,就將兩者交換;如果左邊元素小於等於右邊元素,不進行任何操作。
可以看到思想還是非常簡單的,就是相鄰兩個元素挨個比大小,如果左邊大於右邊就進行交換。這樣走完一輪,我們能把最大的那個數放在最右邊。
然后重復上面的過程,不過當我們比較完一輪之后,第二輪只需要比較從左到右的 N - 1 個元素即可(N 為數組長度),因為數組中最大的元素已經被選出來了,就沒有必要在比了。
所以執行完第二輪就能把第二大的元素選出來,最終我們只需要執行 N - 1 輪即可,因為 N 個元素,走完 N - 1 輪之后剩余的那個一定是最小的。
動圖來自於菜鳥教程。
整個過程就像冒泡一樣,氣泡大的不斷往外竄,所以就叫冒泡排序。
代碼演示
我們看看如何使用 Go 來實現冒泡排序:
package main
import (
"fmt"
"math/rand"
"time"
)
func bubbleSort(s []int) {
// 總共有 len(s) 個元素,只需要將前 len(s) - 1 個最大的元素排好即可
// 因此只需要遍歷 len(s) - 1 次
for i := 0; i < len(s)-1; i++ {
// 外層循環是遍歷的次數,內層循環則是具體的比較邏輯
// 因為是 s[j] 和 s[j+1] 進行比較,所以 j 應該小於 len(s) - 1,不能是小於 len(s),否則索引越界了
// 更准確的說 j 應該小於 len(s)-1-i,因為對於那些已經排好的元素沒有必要再比了。當然你比了也沒事,只不過由於左邊一定小於右邊,會什么也不做罷了
for j := 0; j < len(s)-1-i; j++ {
// 如果左邊元素大於右邊元素,那么就將兩者進行交換
if s[j] > s[j+1] {
s[j], s[j+1] = s[j+1], s[j]
}
}
}
}
func main() {
var s = make([]int, 10)
rand.Seed(time.Now().UnixNano())
for i := 0; i < 10; i++ {
s[i] = rand.Intn(100)
}
fmt.Println(s) // [2 49 1 94 7 58 42 83 64 13]
bubbleSort(s)
fmt.Println(s) // [1 2 7 13 42 49 58 64 83 94]
}
但是上面這個程序還有一個可以優化的點,比如我們面對的是一個近乎有序的數組:
[1, 2, 3, 4, 5, 8, 7, 6, 9]
對於上面這個數組,我們在遍歷第一輪的時候,首先還是內層循環左右挨個比較,最終會將 8 放在 9 的左邊,數組變成如下:
[1, 2, 3, 4, 5, 7, 6, 8, 9]
然后再遍歷一輪的話,很明顯我們就把整個數組排好了。也就是說此時排好整個數組只需要兩輪的功夫,按照我們之前的邏輯需要進行 8 輪。只不過從第 3 輪開始,內層循環在比較的時候發現每一次左邊元素都小於等於右邊元素,所以 if 條件不滿足、什么也不做。那么這樣的話,我們就可以優化一下上面的程序:
func bubbleSort(s []int) {
for i := 0; i < len(s)-1; i++ {
// 每次進入內層循環之前,我們都設置一個標志位 flag
// 我們默認 flag = true,也就是假設當前數組已經排好序了
var flag = true
for j := 0; j < len(s)-1-i; j++ {
// 只要沒有排好,那么內層循環肯定會至少有一次成功進入這里的 if 語句
if s[j] > s[j+1] {
s[j], s[j+1] = s[j+1], s[j]
// 在里面我們將其設置為 false
flag = false
}
}
// 如果數組沒有排好序,那么進入上面的 if 語句之后,flag 會被設置成 false
// 如果數組已經排好序了,那么 flag 顯然為 true,這里就可以直接 break 了
// 因此可以看出,如果排好序花了 3 輪,那么第 4 輪的時候才能跳出循環
if flag {
break
}
}
}
此時程序依舊是可以正常執行的,只不過它只有在面對近乎有序的數組時才會具有明顯優勢。因為隨着輪數的增加,內層循環所需要比較數組中的元素的個數會越來越少,如果數組不是近乎有序,這種級別的優化實際上沒有太大意義。
所以我們可以得出,冒泡排序的時間復雜度在最好情況下是 \(O(n)\),此時數組本身就是有序的,外層循環只需要循環 1 次 即可;最壞時間復雜度是 \(O(n^2)\),此時數組恰好是逆序的。
至於平均時間復雜度也是 \(O(n^2)\),盡管我們知道里面的內層循環每一次會越跑越短,最終應該是 \(O(\frac{n^2}{2})\),但我們說 O 內的常數是不考慮的,所以平均時間復雜度依舊是 \(O(n^2)\)。
選擇排序
選擇排序同樣是一種非常簡單直觀的算法,它的思想如下:
假設數組中第 1 個元素最小,然后讓剩余的 N - 1 個元素依次和數組第 1 個元素進行比較,如果比第 1 個元素小,那么就進行交換。這樣就能把最小的元素放在第一個位置(索引為 0)
再假設數組中第 2 個元素是第 2 小,然后讓剩余的 N - 2 個元素依次和數組第 2 個元素進行比較,如果比第 2 個元素小,那么就進行交換。這樣就能把第 2 小的元素放在第 2 個位置
然后不斷重復,顯然跟冒泡一樣,我們只需要找到前 N - 1 個最小的元素排好序之后,剩余的那個元素一定是最大的。
當然其實更好的做法並不是每次都進行交換,而是記住最小元素對應的索引,最后只需要交換一次即可。
代碼演示
我們看看如何使用 Go 來實現選擇排序:
package main
import (
"fmt"
"math/rand"
"time"
)
func selectSort(s []int) {
// 循環依舊遍歷 len(s) - 1 次
for i := 0; i < len(s)-1; i++ {
// 內層循環從 i + 1 處開始
for j := i + 1; j < len(s); j++ {
// 然后讓 s[j] 和 s[i] 進行比較,如果 s[j] 小於 s[i],那么兩者進行交換
if s[j] < s[i] {
s[i], s[j] = s[j], s[i]
}
}
}
}
func main() {
var s = make([]int, 10)
rand.Seed(time.Now().UnixNano())
for i := 0; i < 10; i++ {
s[i] = rand.Intn(100)
}
fmt.Println(s) // [97 3 87 92 82 67 65 89 50 7]
selectSort(s)
fmt.Println(s) // [3 7 50 65 67 82 87 89 92 97]
}
然后我們繼續看看如何優化上面的代碼,我們上面是只要 s[j] < s[i]
,那么兩者就進行交換。但是實際上,我們可以單獨使用一個變量來維護這個最小元素對應的索引,循環結束是只需要做一次交換即可。
func selectSort(s []int) {
for i := 0; i < len(s)-1; i++ {
// 變量 minIndex 維護最小元素對應的索引,初始是 i
var minIndex = i
for j := i + 1; j < len(s); j++ {
// 只要 s[j] < s[minIndex],證明有更小的元素出現了,那么此時應該讓 minIndex 保存更小的元素對應的索引
if s[j] < s[minIndex] {
minIndex = j
}
}
// 循環結束后,minIndex 保存的就是數組從索引為 i 開始、到數組結束的整個區間中最小元素對應的索引
// 然后我們讓索引為 minIndex 和 索引為 i 的兩個元素進行交換即可
s[minIndex], s[i] = s[i], s[minIndex]
}
}
我們額外引入了一個變量,通過它來維護最小元素對應的索引,這樣最后只需要經過一次交換即可。
顯然選擇排序的時間復雜度無論好壞,都是 \(O(n^2)\),因為內外層循環都要全部跑完才可以。
插入排序
插入排序的代碼實現雖然沒有冒泡排序和選擇排序那么簡單粗暴,但它的原理應該是最容易理解的了,因為只要打過撲克牌的人都應該能夠秒懂。插入排序是一種最簡單直觀的排序算法,它的工作原理是通過構建有序序列,對於未排序數據,在已排序序列中從后向前掃描,找到相應位置並插入。
首先從索引為 1 的位置開始,比較 s[1] 和 s[0],再從索引為 2 的位置開始,比較 s[2]、s[1]、s[0];然后從索引為 3 的位置開始,總之就是不斷地從后往前,如果左邊元素比右邊元素大,那么兩者進行交換。
比如:從索引為 5 的位置開始,如果 s[4] > s[5],那么兩者進行交換;然后比較 s[3] 和 s[4],如果 s[3] > s[4] 那么繼續進行交換,然后索引繼續減 1 進行比較;但如果 s[3] <= s[4],那么就停止比較,此時索引從 0 到 5 這段區間就已經排好序了。可能有人會問,那 s[3] 之前的元素呢?因為我們的索引是 1 開始的,然后不斷地保證數組從 0 到 該索引 這段區間的元素是有序的。所以如果 s[3] <= s[4],那么兩者不交換,而 s[0]、s[1]、s[2] 顯然小於等於 s[3],因此此時索引從 0 到 5 這段區間就是有序的。
從動圖中,我們看到元素在比較的時候並沒有發生交換,而是不斷地向右平移,當找到應該插入的位置時,最終只需要交換一次即可。當然我們先嘗試一下交換,因為這樣最直觀。
代碼演示
我們看看如何使用 Go 來實現插入排序:
package main
import (
"fmt"
"math/rand"
"time"
)
func insertSort(s []int) {
// 外層循環從 1 開始,不斷遍歷
for i := 1; i < len(s); i++ {
// 內層循環從 i 開始,然后索引不斷自減,依次和前面的元素進行比較
// 所以這里的 j 到 1 就結束了,因為我們比較的是 s[j] 和 s[j-1] 的大小
for j := i; j >= 1; j-- {
// 如果 s[j - 1] > s[j],證明這兩個元素應該發生交換了
if s[j-1] > s[j] {
s[j], s[j-1] = s[j-1], s[j]
} else {
// 否則說明不需要發生交換,根據我們之前的結論,此時就已經排好了,應該直接將內層循環 break 掉
break
}
}
}
}
func main() {
var s = make([]int, 10)
rand.Seed(time.Now().UnixNano())
for i := 0; i < 10; i++ {
s[i] = rand.Intn(100)
}
fmt.Println(s) // [10 21 70 88 65 43 19 43 49 61]
insertSort(s)
fmt.Println(s) // [10 19 21 43 43 49 61 65 70 88]
}
但是上面的函數可以寫的更加精簡一些:
func insertSort(s []int) {
for i := 1; i < len(s); i++ {
for j := i; j >= 1 && s[j-1] > s[j]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
當然上面兩種寫法本質上沒有太大區別,甚至第一種還更加的直觀,當然這不是重點。我們說每一次比較都伴隨這元素之間的交換,但是最正確的做法應該是采用平移的策略:
func insertSort(s []int) {
for i := 1; i < len(s); i++ {
// 因為 Go 的變量如果在 for 循環中聲明,那么出了 for 循環就無法使用了,所以我們在外面定義
// 重點是 tmp,它保存了當前 s[i] 的值
var j, tmp = i, s[i]
// 然后我們讓 s[j-1] 和 tmp 進行比較,如果 s[j-1] 大於 tmp,那么就把 s[j-1] 賦值給 s[j],相當於將 j-1 的元素平移到了 j 的位置
for ; j >= 1 && s[j-1] > tmp; j-- {
s[j] = s[j-1]
}
// 接下來 j 不斷自減,只要 s[j-1] > tmp 就向右平移
// 如果 s[j-1] <= tmp,則說明 0 到 j-1 的元素都小於等於 tmp,那么此時位置 j 上的元素就應該設置成 tmp
// 因為 j 前面的元素是不斷地向右進行平移,而最終肯定要留出一個坑給 tmp、即最開始的 s[i],所以我們需要將 s[i] 提前保存起來
s[j] = tmp
}
}
元素平移盡管沒有兩個元素交換那么直觀,但它的性能明顯是更優的,所以可以結合動圖多觀察幾遍。
這里我們也可以看出插入排序的平均時間復雜度是 \(O(n^2)\),最好時間復雜度是 \(O(n^2)\)。這里和冒泡也是類似的,都是在數組已經有序的情況下,時間復雜度可以達到 \(O(n)\)。只不過冒泡排序是:外層循環遍歷 1 次,內層循環全部遍歷;而插入排序是:外層循環全部遍歷,內層循環遍歷 1 次。
希爾排序
希爾排序是插入排序的一個改進版本,也稱為縮小增量排序,既然它是插入排序的改進版,那就證明插入排序存在缺點。因為插入排序每次只能將數據移動一個位置,我們舉個栗子。
假設我們現在移動 3,首先 3 和 7 交換,再 3 和 6 交換、3 和 5、3 和 4,最終才能排好;那么問題來了,我們能不能加速這個過程呢?希爾排序就是為了實現這一點,它的做法是將整個待排序的記錄分隔成若干子序列分別排序,待整個序列基本有序時,再對全體記錄進行排序。文字說明不是很好理解,假設數組有 10 個數,索引是 0 到 9,那么希爾排序是怎么做的呢?
第一步: gap 等於 5(數組長度除以 2),相當於分成了 5 個子數組,分別是[0, 5]、[1, 6]、[2, 7]、[3, 8]、[4, 9](里面是元素對應的索引)
對這 5 個子數組使用插入排序,然后縮小 gap。
第二步:gap 等於 2(上一步的 gap 除以 2),此時相當於分成了兩個子數組,分別是[0, 2, 4, 6, 8]、[1, 3, 5, 7, 9]
對這兩個數組繼續使用插入排序,然后再縮小 gap,當 gap 等於 1 時,和原始的插入排序是一樣的。
第三步:gap 等於 1(上一步的 gap 除以 2),此時和原本的插入排序是一樣的了,就是整個數組。但是注意,此時的數組已經整體接近有序了,所以接下來再排序就簡單多了
所以邏輯不難理解,插入排序是將 gap 設置為 1,每次和前面的元素進行比較。而希爾排序是將 gap 的初始值設置的大一些(一般是數組長度除以 2),然后每一次元素都和前 gap 個元素進行比較,此時可以看成是粗粒度的排序。通過不斷縮小 gap,粒度不斷變細,最終當 gap 為 1 時完全等價於插入排序,只不過經過前幾輪的局部排序,此時的數組已經基本有序了。
代碼演示
我們看看如何使用 Go 來實現希爾排序:
package main
import (
"fmt"
"math/rand"
"time"
)
func shellSort(s []int) {
// gap 初始值為 len(s) / 2
gap := len(s) / 2
// 顯然外面要多一層循環,當 gap >= 1 時
for gap >= 1 {
// 此時 i 從 gap 開始
for i := gap; i < len(s); i++ {
j, tmp := i, s[i]
// 然后 j 每次和 j - gap 相比,j 每次遞減 gap
for ; j >= gap && s[j-gap] > tmp; j-=gap {
s[j] = s[j - gap]
}
// 可以看到整體和插入排序是一樣的
s[j] = tmp
}
// gap 每次除以 2
gap /= 2
}
}
func main() {
var s = make([]int, 10)
rand.Seed(time.Now().UnixNano())
for i := 0; i < 10; i++ {
s[i] = rand.Intn(100)
}
fmt.Println(s)
shellSort(s)
fmt.Println(s)
}
所以我們上面的分組跨度分別為 5、2、1,而它們也被成為希爾排序的增量,增量的選擇可以有多種,我們上面采用的增量逐步折半的方法正是希爾排序的作者提出的一種方法,因此也被成為希爾增量。
希爾排序利用分組粗調的方式減少了直接插入排序的工作量,使得算法的平均時間復雜度低於 \(O(n^2)\)。但是在某些極端情況下,希爾排序的最壞時間復雜度仍然是 \(O(n^2)\),甚至比直接插入排序更慢。
此時數組中有 8 個元素,按照我們之前的邏輯,希爾增量應該為 4、2、1,但當希爾增量為 4 和 2 的時候,每個子數組內部的所有元素都沒有進行任何的交換。直到我們將增量縮減為 1 的時候,數組才會按照插入排序的方式進行調整。
所以對於上面這樣的數組,希爾排序不僅沒有減少工作量,返回增加了分組操作的成本。因此對於希爾排序而言,關鍵是需要選擇一組合適的增量,最具代表性的是 和 Sedgewick 增量。
- Hibbard 增量可以使得希爾排序最壞時間復雜度為 \(O({n^{\frac 3 2}})\),Sedgewick 增量可以使得希爾排序最壞時間復雜度為 \(O({n^{\frac 4 3}})\)。
總之希爾排序不是一個穩定的算法。
歸並排序
在介紹歸並排序之前,我們先來看一個問題:有兩個各自排好序的數組,現在將它們合成一個整體有序的數組。
[1, 3, 4, 5, 7] 和 [2, 3, 6, 8, 9]
現在要將它們合在一起,得到:[1, 2, 3, 3, 4, 5, 6, 7, 8, 9]
,雖然很簡單,但如果我們要求時間復雜度為 \(O(n)\) 呢?會發現還是需要動一些腦子的。當然解決辦法也很簡單,采用雙指針即可:
這樣下來,平均是 \(O(n)\) 的時間復雜度,肯定比直接合並再整體排序要快,因為這兩個數組各自本身就是有序的。那么下面我們來看看如何使用 Go 來實現:
package main
import "fmt"
func merge(s1 []int, s2 []int) []int {
var s1Len, s2Len = len(s1), len(s2)
// 顯然我們需要新開一個數組,長度為兩個數組的長度之和
var mergeS = make([]int, s1Len+s2Len)
// 設置遍歷用的索引,i 負責遍歷 s1、j 負責遍歷 s2
var i, j int
for i, j = 0, 0; i < s1Len && j < s2Len; {
// 如果 s[i] <= s[j] 那么就把 s[i] 設置到 mergeS 當中
if s1[i] <= s2[j] {
// 這里為什么是 mergeS[i+j] 可以思考一下,當然最容易理解的方式是直接 append,但是效率肯定沒有這里高
mergeS[i+j] = s1[i]
// 然后 i 右移一位,j 保持不變
i += 1
} else {
// 否則將 s2[j] 設置進去
mergeS[i+j] = s2[j]
j += 1
}
}
// 這樣下去總有一方先遍歷結束,所以還要將剩余沒有遍歷的元素設置到 mergeS 當中
// 這里我們不需要判斷到底是哪一方先遍歷結束,直接 for 循環即可,因為 for 本身就帶有了 if 的語義
// 如果數組遍歷已經結束了,那么 for 循環就不走了
for ; i < s1Len; i++ {
mergeS[i+j] = s1[i]
}
for ; j < s2Len; j++{
mergeS[i+j] = s2[j]
}
return mergeS
}
func main() {
var s1 = []int{1, 3, 3, 5, 7, 9, 20, 30, 40, 200}
var s2 = []int{2, 2, 2, 3, 3, 4, 6, 8, 10, 13, 14, 15, 50, 60, 70}
fmt.Println(merge(s1, s2)) // [1 2 2 2 3 3 3 3 4 5 6 7 8 9 10 13 14 15 20 30 40 50 60 70 200]
}
而以上這個過程,我們就稱之為發生了一次歸並。因此你應該猜到歸並排序是怎么實現的了,采用的就是分治法。將一個數組分成多個小數組單獨進行排序,然后再將這些排序之后的小數組組合起來。
分治法不僅體現在歸並排序上,它也是解決問題的一種常用策略。將一個問題 "分" 成多個小問題遞歸求解,而 "治" 的階段則是將 "分" 的階段所得到的各個結果組合在一起。
對於歸並排序而言,使用靜態圖要比動圖更容易表達其含義:
整體看起來像是一棵樹,因此我們可以采用遞歸的方式去實現,當然也可以采用迭代的方式去實現,這里我們使用遞歸。而對於歸並排序而言,顯然我們需要實現兩步,一個是分、一個是合,而對於長度為 n 的數組,它們的遞歸深度都是 \(O(log n)\)。
下面我們來看看如何使用 Go 來實現歸並排序:
package main
import (
"fmt"
"math/rand"
"time"
)
// 這部分代碼保持不變
func merge(s1 []int, s2 []int) []int {
var s1Len, s2Len = len(s1), len(s2)
var mergeS = make([]int, s1Len+s2Len)
var i, j int
for i, j = 0, 0; i < s1Len && j < s2Len; {
if s1[i] <= s2[j] {
mergeS[i+j] = s1[i]
i += 1
} else {
mergeS[i+j] = s2[j]
j += 1
}
}
for ; i < s1Len; i++ {
mergeS[i+j] = s1[i]
}
for ; j < s2Len; j++ {
mergeS[i+j] = s2[j]
}
return mergeS
}
func mergeSort(s []int) []int {
var sLen = len(s)
// 如果長度為 1,直接返回,因為長度為 1 的數組顯然是有序的
if sLen == 1 {
return s
}
// 接下來就是切分,我們直接對半切即可
var middle = sLen / 2
// 將切成的兩半直接傳遞到 merge 函數中即可
// 注意:我們是要遞歸切分的,所以不要寫成了 merge(s[: middle], s[middle:])
return merge(mergeSort(s[: middle]), mergeSort(s[middle:]))
// 然后會不斷的切分,當不能再切的時候,長度為 1 直接返回,最后就是合並的過程了
// 所以這就是遞歸,確實很有效,只是不好理解。但是我們也不需要理解每一層都干了什么,因為每一層做的事情都是一樣的
// 所以我們只需要關注三點即可: 1. 遞歸的結束條件 2. 遞歸在某一層做了什么(其余層都是一樣的) 3. 遞歸的返回值(給上一層返回什么)
}
func main() {
var s = make([]int, 10)
rand.Seed(time.Now().UnixNano())
for i := 0; i < 10; i++ {
s[i] = rand.Intn(100)
}
fmt.Println(s) // [1 45 46 40 31 8 2 85 26 30]
s = mergeSort(s)
fmt.Println(s) // [1 2 8 26 30 31 40 45 46 85]
}
歸並排序是一種非常高效的排序,每次合並操作的平均時間復雜度是 \(O(n)\) ,而二叉樹的深度為 \(O(log n)\),所以整體的平均時間復雜度為 \(O(nlog n)\)。
快速排序
快速排序又是一種分而治之思想在排序算法上的典型應用,快速排序可以看成是在冒泡排序基礎上的遞歸分治法,從名字上來看就知道它的速度很快。那么快速排序是怎么做的呢?
1. 從數列中挑出一個元素,稱為 "基准"(pivot)
2. 重新排序數列,所有元素比基准值小的擺放在基准前面,所有元素比基准值大的擺在基准的后面(相同的數可以到任意一邊),該過程稱為分區(partition)操作
3. 遞歸地(recursive)把小於基准值元素的子數列和大於基准值元素的子數列重復第 2 步
然后來看看如何使用 Go 實現快速排序:
package main
import (
"fmt"
"math/rand"
"time"
)
func partition(s []int, left, right int) int {
// 設置基准值以及索引
var pivot, pivotIndex = s[left], left
// 要使得左邊的元素比基准值小,右邊的元素比基准值大
for left < right {
// 注意:因為我們是以 left 為基准值,那么首先要從右邊開始遍歷
for left < right && s[right] >= pivot {
// 如果 s[right] >= pivot,直接 right 左移
right--
}
for left < right && s[left] <= pivot {
// 如果 s[left] <= pivot,直接 left 右移
left++
}
// 此時 s[left] 大於 pivot,s[right] 小於 pivot,於是我們需要將其進行交換
// 因為我們要滿足 pivot 左邊的元素比它小,右邊的元素比它大,交換之后就滿足要求了
s[left], s[right] = s[right], s[left]
}
// 顯然兩個 for 循環結束之后,left 和 right 會相遇
// 而 left、right 相遇的位置就是基准值應該在的位置,因此最后再做一次交換即可
s[pivotIndex], s[left] = s[left], s[pivotIndex]
return left
}
func quickSort(s []int, left, right int) {
if left < right {
// 獲取基准值對應的索引,然后對兩邊再次執行快排,不斷遞歸
partitionIndex := partition(s, left, right)
quickSort(s, left, partitionIndex-1)
quickSort(s, partitionIndex+1, right)
}
}
func main() {
var s = make([]int, 10)
rand.Seed(time.Now().UnixNano())
for i := 0; i < 10; i++ {
s[i] = rand.Intn(100)
}
fmt.Println(s) // [83 30 65 40 0 98 65 26 80 7]
quickSort(s, 0, len(s) - 1)
fmt.Println(s) // [0 7 26 30 40 65 65 80 83 98]
}
快排的平均時間復雜度為 \(O(nlog n)\),但如果數組本身就是有序的、或者逆序的,那么會退化為 \(O(n^2)\),因為每一次 partition 之后一邊都是空的。