前言
最近一段時間重(入)拾(門)演算法,演算法渣渣的我只有做筆記換來一絲絲心裡安慰,在這裡也記錄分享一下,後面將會歸納成一系列吧。比如「遞迴與回溯」、「深度與廣度優先」、「動態規劃」、「二分搜尋」和「貪婪」等。
氣泡排序(Bubble Sort)
氣泡排序基本思想
給定一個陣列,我們把陣列裡的元素通通倒入到水池中,這些元素將通過相互之間的比較,按照大小順序一個一個地像氣泡一樣浮出水面。
氣泡排序實現
每一輪,從雜亂無章的陣列頭部開始,每兩個元素比較大小並進行交換,直到這一輪當中最大或最小的元素被放置在陣列的尾部,然後不斷地重複這個過程,直到所有元素都排好位置。其中,核心操作就是元素相互比較。
氣泡排序例題分析
給定陣列 [2, 1, 7, 9, 5, 8]
,要求按照從左到右、從小到大的順序進行排序。
氣泡排序解題思路
從左到右依次冒泡,把較大的數往右邊挪動即可。
- 首先指標指向第一個數,比較第一個數和第二個數的大小,由於
2
比1
大,所以兩兩交換,[1, 2, 7, 9, 5, 8]
。 - 接下來指標往前移動一步,比較
2
和7
,由於2
比7
小,兩者保持不動,[1, 2, 7, 9, 5, 8]
。到目前為止,7
是最大的那個數。 - 指標繼續往前移動,比較
7
和9
,由於7
比9
小,兩者保持不動,[1, 2, 7, 9, 5, 8]
。現在,9
變成了最大的那個數。 - 再往後,比較
9
和5
,很明顯,9
比5
大,交換它們的位置,[1, 2, 7, 5, 9, 8]
。 - 最後,比較
9
和8
,9
比8
大,交換它們的位置,[1, 2, 7, 5, 8, 9]
。經過第一輪的兩兩比較,9
這個最大的數就像冒泡一樣冒到了陣列的最後面。 - 接下來進行第二輪的比較,把指標重新指向第一個元素,重複上面的操作,最後,陣列變成了:
[1, 2, 5, 7, 8, 9]
。 - 在進行新一輪的比較中,判斷一下在上一輪比較的過程中有沒有發生兩兩交換,如果一次交換都沒有發生,就證明其實陣列已經排好序了。
氣泡排序程式碼示例
// 氣泡排序演算法
const bubbleSort = function (arr) {
const len = arr.length
// 標記每一輪是否發生來交換
let hasChange = true
// 如果沒有發生交換則已經是排好序的,直接跳出外層遍歷
for (let i = 0; i < len && hasChange; i++) {
hasChange = false
for (let j = 0; j < len - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
let temp = arr[j]
arr[j] = arr[j + 1]
arr[j + 1] = temp
hasChange = true
}
}
}
}
const arr = [2, 1, 7, 9, 5, 8]
bubbleSort(arr)
console.log('arr: ', arr)
氣泡排序演算法分析
氣泡排序空間複雜度
假設陣列的元素個數是 n
,由於在整個排序的過程中,我們是直接在給定的陣列裡面進行元素的兩兩交換,所以空間複雜度是 O(1)
。
氣泡排序時間複雜度
給定的陣列按照順序已經排好
- 在這種情況下,我們只需要進行
n−1
次的比較,兩兩交換次數為0
,時間複雜度是O(n)
。這是最好的情況。 - 給定的陣列按照逆序排列。在這種情況下,我們需要進行
n(n-1)/2
次比較,時間複雜度是O(n2)
。這是最壞的情況。 - 給定的陣列雜亂無章。在這種情況下,平均時間複雜度是
O(n2)
。
由此可見,氣泡排序的時間複雜度是 O(n2)
。它是一種穩定的排序演算法。(穩定是指如果陣列裡兩個相等的數,那麼排序前後這兩個相等的數的相對位置保持不變。)
插入排序(Insertion Sort)
插入排序基本思想
不斷地將尚未排好序的數插入到已經排好序的部分。
插入排序特點
在氣泡排序中,經過每一輪的排序處理後,陣列後端的數是排好序的;而對於插入排序來說,經過每一輪的排序處理後,陣列前端的數都是排好序的。
插入排序例題分析
對陣列 [2, 1, 7, 9, 5, 8]
進行插入排序。
插入排序解題思路
- 首先將陣列分成左右兩個部分,左邊是已經排好序的部分,右邊是還沒有排好序的部分,剛開始,左邊已排好序的部分只有第一個元素
2
。接下來,我們對右邊的元素一個一個進行處理,將它們放到左邊。
- 先來看
1
,由於1
比2
小,需要將1
插入到2
的前面,做法很簡單,兩兩交換位置即可,[1, 2, 7, 9, 5, 8]
。 - 然後,我們要把
7
插入到左邊的部分,由於7
已經比2
大了,表明它是目前最大的元素,保持位置不變,[1, 2, 7, 9, 5, 8]
。 - 同理,
9
也不需要做位置變動,[1, 2, 7, 9, 5, 8
]。 - 接下來,如何把
5
插入到合適的位置。首先比較5
和9
,由於5
比9
小,兩兩交換,[1, 2, 7, 5, 9, 8
],繼續,由於5
比7
小,兩兩交換,[1, 2, 5, 7, 9, 8]
,最後,由於5
比2
大,此輪結束。 - 最後一個數是
8
,由於8
比9
小,兩兩交換,[1, 2, 5, 7, 8, 9]
,再比較7
和8
,發現8
比7
大,此輪結束。到此,插入排序完畢。
插入排序程式碼示例
// 插入排序
const insertionSort = function (arr) {
const len = arr.length
for (let i = 1; i < len; i++) {
let current = arr[i]
for (let j = i - 1; j >= 0; j--) {
// current 小於 j 指向的左側值,將 j 指向左側值右移一位
if (current < arr[j]) {
arr[j + 1] = arr[j]
} else {
// 否則將 current 插入到 j 位置,跳出內迴圈
arr[j] = current
break
}
}
}
}
const arr = [2, 1, 7, 9, 5, 8]
insertionSort(arr)
console.log('arr: ', arr)
插入排序演算法分析
插入排序空間複雜度
假設陣列的元素個數是 n
,由於在整個排序的過程中,是直接在給定的陣列裡面進行元素的兩兩交換,空間複雜度是 O(1)
。
插入排序時間複雜度
- 給定的陣列按照順序已經排好。只需要進行
n-1
次的比較,兩兩交換次數為0
,時間複雜度是O(n)
。這是最好的情況。 - 給定的陣列按照逆序排列。在這種情況下,我們需要進行
n(n-1)/2
次比較,時間複雜度是O(n2)
。這是最壞的情況。 - 給定的陣列雜亂無章。在這種情況下,平均時間複雜度是
O(n2)
。
由此可見,和氣泡排序一樣,插入排序的時間複雜度是 O(n2)
,並且它也是一種穩定的排序演算法。
歸併排序(Merge Sort)
歸併排序基本思想
核心是分治,就是把一個複雜的問題分成兩個或多個相同或相似的子問題,然後把子問題分成更小的子問題,直到子問題可以簡單的直接求解,最原問題的解就是子問題解的合併。歸併排序將分治的思想體現得淋漓盡致。
歸併排序實現
一開始先把陣列從中間劃分成兩個子陣列,一直遞迴地把子陣列劃分成更小的子陣列,直到子陣列裡面只有一個元素,才開始排序。
排序的方法就是按照大小順序合併兩個元素,接著依次按照遞迴的返回順序,不斷地合併排好序的子陣列,直到最後把整個陣列的順序排好。
歸併排序程式碼示例
// 歸併排序
const mergeSort = function (arr, lo, hi) {
if (lo === undefined) {
lo = 0
}
if (hi === undefined) {
hi = arr.length - 1
}
// 判斷是否剩下最後一個元素
if (lo >= hi) return
// 從中間將陣列分成兩部分
let mid = lo + Math.floor((hi - lo) / 2)
console.log('mid', mid)
// 分別遞迴將左右兩邊排好序
mergeSort(arr, lo, mid)
mergeSort(arr, mid + 1, hi)
// 將排好序的左右兩半合併
merge(arr, lo, mid, hi)
}
const merge = function (arr, lo, mid, hi) {
// 複製一份原來的陣列
const copy = [...arr]
// 定義一個 k 指標表示從什麼位置開始修改原來的陣列,
// i 指標表示左邊半的起始位置
// j 指標便是右半邊的其實位置
let k = lo
let i = lo
let j = mid + 1
while (k <= hi) {
if (i > mid) {
arr[k++] = copy[j++]
} else if (j > hi) {
arr[k++] = copy[i++]
} else if (copy[j] < copy[i]) {
arr[k++] = copy[j++]
} else {
arr[k++] = copy[i++]
}
}
}
const arr = [2, 1, 7, 9, 5, 8]
mergeSort(arr)
console.log('arr: ', arr)
其中,While
語句比較,一共可能會出現四種情況。
- 左半邊的數都處理完畢,只剩下右半邊的數,只需要將右半邊的數逐個拷貝過去。
- 右半邊的數都處理完畢,只剩下左半邊的數,只需要將左半邊的數逐個拷貝過去就好。
- 右邊的數小於左邊的數,將右邊的數拷貝到合適的位置,
j
指標往前移動一位。 - 左邊的數小於右邊的數,將左邊的數拷貝到合適的位置,
i
指標往前移動一位。
歸併排序例題分析
利用歸併排序演算法對陣列 [2, 1, 7, 9, 5, 8]
進行排序。
歸併排序解題思路
首先不斷地對陣列進行切分,直到各個子陣列裡只包含一個元素。
接下來遞迴地按照大小順序合併切分開的子陣列,遞迴的順序和二叉樹裡的前向遍歷類似。
- 合併
[2]
和[1]
為[1, 2]
。 - 子陣列
[1, 2]
和[7]
合併。 - 右邊,合併
[9]
和[5]
。 - 然後合併
[5, 9]
和[8]
。 - 最後合併
[1, 2, 7]
和[5, 8, 9]
成[1, 2, 5, 8, 9]
,就可以把整個陣列排好序了。
合併陣列 [1, 2, 7]
和 [5, 8, 9]
的操作步驟如下。
- 把陣列
[1, 2, 7]
用L
表示,[5, 8, 9]
用R
表示。 - 合併的時候,開闢分配一個新陣列
T
儲存結果,陣列大小應該是兩個子陣列長度的總和 - 然後下標
i
、j
、k
分別指向每個陣列的起始點。 - 接下來,比較下標 i 和 j 所指向的元素
L[i]
和R[j]
,按照大小順序放入到下標k
指向的地方,1
小於5
。 - 移動
i
和k
,繼續比較L[i]
和R[j]
,2
比5
小。 i
和k
繼續往前移動,5
比7
小。- 移動
j
和k
,繼續比較L[i]
和 R[j],7
比8
小。 - 這時候,左邊的陣列已經處理完畢,直接將右邊陣列剩餘的元素放到結果陣列裡就好。
合併之所以能成功,先決條件必須是兩個子陣列都已經分別排好序了。
歸併排序演算法分析
歸併排序空間複雜度
由於合併 n 個元素需要分配一個大小為 n
的額外陣列,合併完成之後,這個陣列的空間就會被釋放,所以演算法的空間複雜度就是 O(n)
。歸併排序也是穩定的排序演算法。
歸併排序時間複雜度
歸併演算法是一個不斷遞迴的過程。
舉例:陣列的元素個數是 n
,時間複雜度是 T(n)
的函式。
解法:把這個規模為 n
的問題分成兩個規模分別為 n/2
的子問題,每個子問題的時間複雜度就是 T(n/2)
,那麼兩個子問題的複雜度就是 2×T(n/2)
。當兩個子問題都得到了解決,即兩個子陣列都排好了序,需要將它們合併,一共有 n 個元素,每次都要進行最多 n-1
次的比較,所以合併的複雜度是 O(n)
。由此我們得到了遞迴複雜度公式:T(n) = 2×T(n/2) + O(n)
。
對於公式求解,不斷地把一個規模為 n
的問題分解成規模為 n/2
的問題,一直分解到規模大小為 1
。如果 n
等於 2
,只需要分一次;如果 n
等於 4
,需要分 2
次。這裡的次數是按照規模大小的變化分類的。
以此類推,對於規模為 n
的問題,一共要進行 log(n)
層的大小切分。在每一層裡,我們都要進行合併,所涉及到的元素其實就是陣列裡的所有元素,因此,每一層的合併複雜度都是 O(n)
,所以整體的複雜度就是 O(nlogn)
。
快速排序(Quick Sort)
快速排序基本思想
快速排序也採用了分治的思想。
快速排序實現
把原始的陣列篩選成較小和較大的兩個子陣列,然後遞迴地排序兩個子陣列。
舉例:把班級裡的所有同學按照高矮順序排成一排。
解法:老師先隨機地挑選了同學 A,讓所有其他同學和同學 A 比高矮,比 A 矮的都站在 A 的左邊,比 A 高的都站在 A 的右邊。接下來,老師分別從左邊到右邊的同學裡選擇了同學 B 和同學 C,然後不斷的篩選和排列下去。
在分成較小和較大的兩個子陣列過程中,如何選定一個基準值(也就是同學 A、B、C 等)尤為關鍵。
快速排序實現例題分析
對陣列[2,1,7,9,5,8]
進行排序。
快速排序解題思路
- 按照快速排序的思想,首先把陣列篩選成較小和較大的兩個子陣列。
- 隨機從陣列裡選取一個數作為基準值,比如
7
,於是原始的陣列就被分成裡兩個子陣列。注意:快速排序是直接在原始陣列裡進行各種交換操作,所以當子陣列被分割出去的時候,原始陣列裡的排列也被改變了。 - 接下來,在較小的子陣列裡選
2
作為基準值,在較大的子陣列裡選8
作為基準值,繼續分割子陣列。 - 繼續將元素個數大於
1
的子陣列進行劃分,當所有子陣列裡的元素個數都為1
的時候,原始陣列也被排好序了。
快速排序程式碼示例
// 快速排序
const quickSort = function (arr, lo, hi) {
if (lo === undefined) {
lo = 0
}
if (hi === undefined) {
hi = arr.length - 1
}
// 判斷是否只剩下一個元素,是,則直接返回
if (lo >= hi) return
// 利用 partition 函式找到一個隨機的基準點
const p = partition(arr, lo, hi)
// 遞迴對基準點左半邊和右半邊的數進行排序
quickSort(arr, lo, p - 1)
quickSort(arr, p + 1, hi)
}
// 交換陣列位置
const swap = function (arr, i, j) {
let temp = arr[i]
arr[i] = arr[j]
arr[j] = temp
}
// 隨機獲取位置索引
const randomPos = function (lo, hi) {
return lo + Math.floor(Math.random() * (hi - lo))
}
const partition = function (arr, lo, hi) {
const pos = randomPos(lo, hi)
console.log('pos: ', pos)
swap(arr, pos, hi)
let i = lo
let j = lo
// 從左到右用每個數和基準值比較,若比基準值小,則放在指標 i 指向的位置
// 迴圈完畢後,i 指標之前的數都比基準值小
while (j < hi) {
if (arr[j] <= arr[hi]) {
swap(arr, i++, j)
}
j++
}
// 末尾的基準值放置到指標 i 的位置, i 指標之後的數都比基準值大
swap(arr, i, j)
// 返回指標 i,作為基準點的位置
return i
}
const arr = [2, 1, 7, 9, 5, 8]
quickSort(arr)
console.log(arr)
快速排序演算法分析
快速排序時間複雜度
1、最優情況:被選出來的基準值都是當前子陣列的中間數。
這樣的分割,能保證對於一個規模大小為 n
的問題,能被均勻分解成兩個規模大小為 n/2
子問題(歸併排序也採用了相同的劃分方法),時間複雜度就是: T(n)=2xT(n/2) + O(n)
。
把規模大小為 n
的問題分解成 n/2
的兩個子問題時,和基準值進行了 n-1
次比較,複雜度就是 O(n)
。很顯然,在最優情況下,快速排序的複雜度也是 O(nlogn)
。
2、最壞情況:基準值選擇了子陣列裡的最大後者最小值。
每次都把子陣列分成了兩個更小的子陣列,其中一個的長度為 1
,另外一個的長度只比原子陣列少 1
。
舉例:對於陣列來說,每次挑選的基準值分別是 9、8、7、5、2
。
解法:劃分過程和氣泡排序的過程類似。
演算法複雜度為 O(n^2)
。
提示:可以通過隨機地選取基準值來避免出現最壞的情況。
快速排序空間複雜度
和歸併排序不同,快速排序在每次遞迴的過程中,只需要開闢 O(1)
的儲存空間來完成交換操作實現直接對陣列的修改,又因為遞迴次數為 logn
,所以它的整體空間複雜度完全取決於壓堆疊的次數,因此,它的空間複雜度是 O(logn)
。