除了時間複雜度,也會順便簡單說明相關東西,如下:
相關演算法:
經典問題:
其他相關知識:
演算法的執行時間隨輸入資料規模成正比。例如,for-loop 一個長度為 n 的陣列,每個值都訪問一次,這就是 O(n)。
這種複雜度表示演算法的執行時間不會隨輸入資料規模改變。例如,從陣列中取得某個值
let array = [5, 2, 10]
array[1] // 2
array[index]
的操作是 O(1)。 還有像是 hash table 的讀取,平均起來也是 O(1),只不過在最壞的情況,設計不良,導致大量碰撞,那麼所有的值可能被存放在同一個桶(bucket)中,哈希表的時間複雜度就會退化到 O(n),這與線性結構(如鏈表)相似。
Hash Table(哈希表)
簡單說明什麼 Hash Table(哈希表),它是一種用來快速查找數據的資料結構。它能夠在幾乎時間(O(1))內完成插入、刪除和查找操作,這讓它非常高效,特別是在需要頻繁存取的情況下。 如何運作?
所以可以想像,假設今天想在電話簿搜尋某個人的電話,一個一個查找一定是最慢的,假設我們直接輸入一些名字關鍵字,例如要找的人叫做 Tim,我們搜尋 T,就會馬上篩選出 T 開頭的人,這樣查找下來一定是比一個一個找還快。
這種複雜度表示每次迭代或操作都能將問題規模縮小一半,常見於二分搜尋演算法。例如,搜尋一個已排序的陣列中的某個值就是 O(log n)。
假設你有一個已經按大小排序的陣列,比如說 [2, 4, 6, 8, 10, 12, 14]
,你想要找出 10
的位置。二分搜尋的步驟如下:
左邊界
指向數列的起始位置,右邊界
指向數列的結尾位置。mid = (左邊界 + 右邊界) / 2
,然後檢查中點的值。左邊界
移到 mid + 1
。右邊界
移到 mid - 1
。4. 重複步驟 2 和 3,直到找到目標值或搜尋範圍變空(即 左邊界
超過 右邊界
),代表目標值不在數列中。
轉換成程式碼的話
func binarySearch(array: [Int], target: Int) -> Int? {
var left = 0
var right = array.count - 1
while left <= right {
let mid = (left + right) / 2
let midValue = array[mid]
if midValue == target {
return mid // 找到目標值,返回索引
} else if midValue < target {
left = mid + 1 // 目標在右邊,縮小搜尋範圍
} else {
right = mid - 1 // 目標在左邊,縮小搜尋範圍
} } return nil // 找不到目標值
}// 使用範例
let numbers = [2, 4, 6, 8, 10, 12, 14]
if let index = binarySearch(array: numbers, target: 10) {
print("找到了!數字 10 在索引 \(index) 上。")
} else {
print("找不到目標數字。")
}
常見於高效排序演算法,如快速排序(Quick Sort)和合併排序(Merge Sort)。這種複雜度表示執行的操作數量大約是 n 個值進行 log n 次處理。這兩個排序法都分而治之(Divide and Conquer) 的策略來解決排序問題。
原理
快速排序通過選擇一個「基準點(pivot)」來將陣列劃分成兩個部分:
這樣將陣列分割成較小的部分後,對左右部分遞迴地進行快速排序,直到每個部分只有一個或零個值,排序完成。
步驟
時間複雜度
優點
缺點
原理
合併排序將陣列分成兩個部分,遞迴地將每個部分繼續分割,直到每個部分只有一個值。然後將這些部分進行合併,將兩個已排序的小陣列合併成一個較大的已排序陣列,直到合併完成。
步驟
時間複雜度
優點
缺點
比較兩個排序法
空間複雜度:
穩定性:
性能:
這種複雜度通常來自巢狀迴圈(nested loops)。例如,對一個 n 值的陣列執行兩層巢狀迴圈,每個值都與其他值配對,這就是 O(n²)。像是氣泡排序(Bubble Sort)和插入排序(Insertion Sort)這類排序演算法。
原理
氣泡排序透過不斷比較和交換相鄰的值,將較大的值「冒泡」到陣列的末端。這個過程會多次重複,直到整個陣列排序完成。
步驟
時間複雜度
優點
缺點
原理
插入排序通過逐個處理每個值,將其插入到前面已排序部分的適當位置。每次處理一個新的值時,會從後往前逐一比較,將該值插入到正確位置。
步驟
時間複雜度
優點
缺點
比較兩個排序法
簡單性:
效率:
穩定性:
什麼時候使用?
這種複雜度非常高,代表問題的規模每增加一個單位,執行時間會指數增長。例如,解決所有可能的子集問題(Subset Problem)、背包問題(Knapsack Problem)、費氏數列(Fibonacci Sequence)或某些遞迴演算法,在實際應用中,通常需要使用更高效的演算法,例如,動態規劃(Dynamic Programming)或貪婪演算法(Greedy Algorithm)來替代指數時間複雜度的解法,以便處理更大規模的問題。
假設你有一個包含 n 個值的集合,並且你想找到這個集合的所有子集。每個值都有兩種狀態:包含在子集中或不包含在子集中。這樣就有 2^n 個可能的子集。當集合的大小增長時,計算所有子集的時間將以 2^n 的速度增長。
舉例來說,假設有集合 {A, B, C},我們希望找出這個集合的所有子集。
這樣等於 1 + 3 + 3 + 1 = 8 ,也就是有 2³ = 8,共有8種。
背包問題是一個經典的優化問題,問題是這樣的:
假設你有一個背包,這個背包有一個最大容量(如重量限制)。
你有 n 個物品,每個物品都有自己的重量和價值。
目標是選擇一些物品放入背包,使得在不超過背包容量的情況下,選中的物品總價值最大。
假設你有一個背包,最大承重為 4 公斤,你有以下物品可以選擇:
你需要找到一個選擇方案,使得放入背包的物品總價值最大且不超過 4 公斤。例如,選擇餅乾和 蘋果可以獲得總價值 3 + 4 = 7,而總重量剛好是 1 + 3 = 4 公斤。
我們先用圖表來呈現,會比較好理解,用二維分別代表物品和重量,程式裡我們常會用 dp[i][j]
,i 通常代表物品而 j 代表重量,當然想用其他的詞代替也可以。
我們先把最簡單的填完,假設背包只有0公斤的時候,所有物品都不能裝,所以0公斤那行全部為價值0。
再來想像現在就只有餅乾這個物品,所以餅乾那列全部只能得到價值3,就算背包有2公斤以上都一樣,因為現在能選的物品只有餅乾。
再來就有趣了,因為現在出現了新的物品可以選擇,所以我們就一格一格講解
dp[1][4]
。最後是加入了水這個物品,它有4公斤價值6,但我相信這列一定很好填,背包4公斤前都和上面那列一樣,因為水太重了,不能裝,而只有到4公斤的時候要考慮是否要裝水還是選上列 (dp[1][4]
) ,但很明顯只選水只有6的價值,所以要選 dp[1][4]
價值7才是最佳解。
所以當背包有4公斤時最佳選擇就是選餅乾和蘋果,價值最大化7,用程式表示就是 dp[1][4]
。
因為這邊物品很少,所以要推理出來很快,假如遇到物品很多的時候可能就很難那麼直覺知道答案,所以可以用推理的方式,要取得 dp[1][4]
,有兩個情況:
如果選 1 不放蘋果,表示背包價值是 dp[0][4]
價值3,因為只能放餅乾了。
如果選 2 放蘋果,表示要把背包的重量扣掉蘋果的重量,那就是 4kg — 3kg = 1kg,表示還有一公斤的重量可以放東西,那麼我們前面就推算出來剩一公斤時且只能選餅乾的 dp[0][1]
的最大價值是3,所以最後就變成放入蘋果的情況: dp[0][1]
+ 蘋果的價值。
因為我們要取得最大值,最後會變成這樣:
dp[1][4] = max(dp[0][4], dp[0][1] + 蘋果的價值)
以上過程在抽象化會變成:
dp[i - 1][j]
。j - weight[i]
。 dp[i - 1][j - weight[i]]
為背包重量為 j - weight[i]
且不放物品 i 的最大價值,那麼 dp[i - 1][j - weight[i]] + value[i]
(物品 i 的價值),就是背包放物品 i 得到的最大價值。最後就會得到 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
這問題的優化方法就是使用 動態規劃(Dynamic Programming) 可以將問題的時間複雜度降到 O(n * W),其中 W 是背包的容量。這個方法更高效,適用於大多數實際情況,後續會再說明清楚如何用動態規劃解決這類型問題。
費氏數列是一個著名的數學序列,定義如下:
數列的表達式可以用遞迴公式表示為:
這樣,我們得到費氏數列的前幾個數字:
最簡單但效率不高的方法是用遞迴實作費氏數列。
遞迴解法直接依照定義來計算:
func fibonacci(_ n: Int) -> Int {
if n <= 1 {
return n
} return fibonacci(n - 1) + fibonacci(n - 2)
}
fibonacci(5)
時,函數會展開成:fibonacci(5) = fibonacci(4) + fibonacci(3)
fibonacci(4) = fibonacci(3) + fibonacci(2)
fibonacci(3) = fibonacci(2) + fibonacci(1)
fibonacci(2) = fibonacci(1) + fibonacci(0)
因為每次都會重複計算,導致非常多的冗餘運算,像 fibonacci(3)
會被計算多次。這種遞迴方法的時間複雜度是 O(2^n),因為每次呼叫都會分裂成兩個新的遞迴呼叫。隨著 n 增加,遞迴樹的節點數量以指數速度增長,導致計算非常低效。
動態規劃是一種將問題拆解成重複子問題,並通過儲存每個子問題的解來避免重複計算的技術。這種方法非常適合解決具有 重疊子問題(Overlapping Subproblems) 和 最優子結構(Optimal Substructure) 性質的問題。
解決思路
動態規劃通常使用一個表格來儲存子問題的解。可以有兩種方式來實作動態規劃:
常見例子
用費氏數列來舉例,態規劃方法可以儲存之前計算過的結果,避免重複計算,從而顯著提高效率。
func fibonacci(_ n: Int) -> Int {
if n <= 1 {
return n
} var fib = [0, 1]
for i in 2...n {
fib.append(fib[i - 1] + fib[i - 2])
} return fib[n]
}
概念介紹
貪婪演算法是一種在每一步選擇中都作出當前看起來最好的選擇,希望這樣能夠導致全局最優解。貪婪演算法不會回溯或考慮所有可能的情況,它依賴於當前的選擇,因此通常更高效但不一定總能得到最優解。
關鍵點:貪婪演算法的應用場景通常要求問題具有 貪婪選擇性質(Greedy Choice Property),也就是說,局部最優選擇能導致全局最優解。
解決思路
常見例子
階乘複雜度意味著所有排列組合都必須被計算和處理,常見於暴力解法解決排列問題。例如,求解一個 n 個城市的旅行推銷員問題。
階乘時間複雜度是指演算法的執行時間隨輸入大小 n 的階乘(n!)數量級增長。數學上,階乘 n! 定義為:
n!=n×(n−1)×(n−2)×⋯×2×1
對於較小的 n,n! 的增長速度非常快。例如:
旅行推銷員問題(Travelling Salesman Problem, TSP)
是這樣一個問題:假設你是一個推銷員,必須去不同的城市做生意。你的任務是設計一條路線,從某個城市出發,拜訪所有的城市一次,最後回到出發城市,而且希望總行程最短。
關鍵點
解法