--- title: 白話演算法!培養程式設計的邏輯思考 tags: Algorithm description: An illustrated guide for programmers and other curious people --- # 白話演算法!培養程式設計的邏輯思考 #### 培養程式設計的邏輯思考 --- ![](https://hackmd.io/_uploads/SJyaywh2h.png) --- # 章節架構 - Ch1 - 二元搜尋法(Binary Search)與演算法執行時間 - Ch2 - 選擇排序法(Selection Sort) - Ch3 - 遞迴(Recursion) - Ch4 - Divide-and-Conquer與快速排序法(Quicksort) - Ch5 - 雜湊表(Hash table) - Ch6 - 廣度優先搜尋(Breadth-First Search) - Ch7 - 戴克斯特拉(Dijkstra)演算法 - Ch8 - 貪婪演算法(Greedy Alogrithm) - Ch9 - 動態規劃演算法(Dynamic Programming Alogrithm) - Ch10 - K-最近鄰演算法(K-Nearest Neighbors Alogrithm) - Ch11 - 進階之路:推薦十種演算法 --- # Ch1 二元搜尋法(Binary Search)與演算法執行時間 ## 1-1 二元搜尋法(Binary Search) ### Example: 終極密碼(猜數字) 有玩過猜數字嗎?如果想要快速減少數字的範圍,以最少的次數猜到數字,你會怎麼做? ### 二元搜尋法(Binary Search) 二元搜尋法(Binary Search)是一種演算法,**但是被搜尋的清單內容必須先經過排序**。如果你要找的元素在清單內,二元搜尋就會回傳該元素的位置編號;若要找的元素不在清單內則會回傳null。 > 如果我享受遊戲的樂趣,喜歡從1開始慢慢猜,猜到數字99,那它有名字嗎? Ans: 簡易搜尋法 ### Example2: 查單字 如果字典裡有24萬個字,在**最差的情況下**,簡易搜尋法和二元搜尋法分別需要多少步驟呢? - 簡易搜尋:24w 次 - 二元搜尋:18 次 > 24w -> 12w -> 6w -> 3w -> 15k -> 7.5k -> 3750 -> 1875 -> 938 -> 469 -> 235 -> 118 -> 59 -> 30 -> 15 -> 8 -> 4 -> 2 -> 1 > 對數(logarithm)與指數(mathematical power, power) ![](https://hackmd.io/_uploads/r1S4QF22n.png) log2 240000 = 17.872674880270605 -> 18 Binary Search in Python: https://replit.com/@YuTingHuang2/algorithms https://www.geeksforgeeks.org/python-program-for-binary-search/ ```python= def binary_search(list, item): low = 0 high = len(list) - 1 while low <= high: mid = (low + high) // 2 # = (low + height)/2 答案無條件捨去 mid_num = list[mid] if mid_num == item: return mid elif mid_num > item: high = mid - 1 else: low = mid + 1 return None # null #my_list= [0, 1, 2, 3, 4, 5, 6, 7] my_list = [1, 3, 6, 7, 9, 13, 17, 18] print(binary_search(my_list, 9)) my_list = [1, 3, 6, 7, 9, 13, 17, 18] print(binary_search(my_list, 8)) ``` 演算法的執行時間 簡易搜尋法:線性時間(Linear time) 二元搜尋法:對數時間(Logarithmic time, 簡稱log time) ## 1-2 Big O notation (大O符號) 評估演算法效益的方法,通常是用來表示演算法的執行速度,代表的時最差的情況的執行時間 請注意!演算法的執行時間不會隨著元素的增加而等比增加 | | 簡易搜尋法 | 二元搜尋法 | | -------- | -------- | -------- | | 100個元素 | 100毫秒 | 7毫秒 | | 10,000個元素 | 10秒 | 14毫秒 | 1,000,000,000個元素 | 11天 | 30毫秒 Big O notation,用運算次數來計算執行時間 - 簡易搜尋法(每個元素都要執行一次): O(n) - 二元搜尋法: O(log n) ### 常見的Big O執行時間 - O(log n): 對數時間. ex: 二元搜尋法 - O(n): 線性時間. ex: 簡易搜尋法 - O(n*log n): ex: 快速排序法 - O(n^2): ex: 慢速排序法和選擇排序法 - O(n!): 非常慢的演算法 ex:旅行推銷員 # Ch2 選擇排序法(Selection Sort) ## 2-1 記憶體是如何運作的呢? 想像一下,我們旅行會想要寄放行李在置物櫃,每個格子都只能放一件物品,如果要寄放兩件物品,就需要兩個格子。 基本上,電腦的記憶體也是這樣運作的。電腦就像是一個巨大的櫃子,每個抽屜都有自己的位置(address)。 每次要將元素存入記憶體時,都必須要請電腦空出一些空間,而電腦給你的是一個可以儲存該元素的位置。如果要儲存多個元素時,可以使用 **`陣列(array)`** 或 **`鏈結串列(linked list)的方法`**。 ## 2-2 陣列(array) 與 鏈結串列(linked list) 如果要開發一個管理代辦事項的程式,就得將多個代辦事項存入記憶體。那此時該選陣列還是鏈結串列呢? ### 陣列(array) 所有代辦事項都連續(緊鄰著)存入記憶體。比如說自由入座的電影院位置,當朋友三人已入座,此時有第四個朋友臨時加入,我們就必須移動到有連續四個空位的位置,如果又有人要臨時加加,就得再次移動了。 缺點: - 浪費空間 - 超出預期得再次移動 ### 鏈結串列(linked list) 有點像尋寶的概念。你要先到第一個位址,上面寫著「去位址123可以找到下個元素」。不用堅持緊鄰相依,也不用大家一起搬來搬去,只要在記憶體內隨便找個空位把位址存到上一個元素就好了。 如果在電影院大爆滿的情況下,你們6個人要找位置,鏈結串列就會說:「我們分頭找座位看電影吧!」 ### 陣列與鏈結串列的優缺點 - 陣列:如果需要隨機讀取,陣列非常適合,因為可以瞬間讀取陣列內的任一元素 - 鏈結串列:如果要逐一讀取元素,那鏈結串列非常適合 ### 陣列的「索引」(index) 陣列內每個位置都有編號,這個編號(位置)叫做索引(index)。編號是**從0開始**,本書會用**索引**取代**位置** | | array | linked list | | -------- | -------- | -------- | | 讀取(read) | O(1) | O(n) | | 插入(insert) | O(n) | O(1) | | 刪除(delete) | O(n) | O(1) | ### 資料的存取方式 - 循序存取(Sequential access): 從第一個元素開始依序逐一存取 - 隨機存取(Random access): 可以依資料索引位置直接存取內容 ## 2-3 選擇排序法 (Selection sort) 假如電腦裡有一份紀錄每位歌手播放次數的記錄表,若想依播放次數的高低來排序,該怎麼做比較好? - Step 1. 找出播放次數最多的 - Step 2. 找出播放次數第二多的 - ......不斷重複上述步驟,就能得到一個排序好的表格 時間複雜度:O(n x n) || O(n^2) ```python= def find_smallest_index(arr): smallest = arr[0] smallest_index = 0 for i in range(1, len(arr)): if arr[i] < smallest: smallest_index = i smallest = arr[i] return smallest_index def selection_sort(arr): new_arr = [] for i in range(len(arr)): smallest = find_smallest_index(arr) new_arr.append(arr.pop(smallest)) return new_arr print(selection_sort([5, 3, 8, 4, 13])) # [3, 4, 5, 8, 13] ``` ### Camparsion between Array and Linked List | --- | array | linked list | | -------- | -------- | -------- | | 元素的存放 | 一個接一個連續排列 | 分散在各處 | | 存取方式 | 隨機存取 | 循序存取 | | 插入操作 | 需要搬移插入點之後的所有元素,若沒有空間會發生錯誤。執行時間: O(n) | 只需更改元素的指向位置。執行時間:O(1) | | 刪除操作 | 刪除元素後,後續的元素要往前搬動。執行時間: O(n) | 只需更改元素的指向位置。執行時間: O(1) | ## Ch3 遞迴 (Recursion) ### 認識遞迴 (Recursion) 想像有一個收納箱,裡面有很多大大小小的盒子,盒子的裡面還有多個小盒子,你要找一個鑰匙,就在其中一個盒子裡面 方法 1: ```mermaid flowchart TD Init[先將收納箱`Main Box`內的所有盒子拿出來排成一堆 `待檢查區`] --> A[只要待檢查區還有盒子, 就拿起一個盒子來檢查] A --> B[如果裡面還有盒子, 就放到待檢查的那堆盒子中] A --> C[如果找到鑰匙, 就完成了] B --> B2[回到盒子堆中拿下一個盒子來檢查] B2 --> A ``` ```python= # Pseudo code - Iteration def look_for_key(main_box): pile = main_box.make_a_pile_to_look_through() while pile is not empty: box = pile.grab_a_box() for item in box: if item.is_a_box(): pile.append(item) elif item.is_a_key(): print("find the key!") ``` 方法 2: ```mermaid flowchart TD Init[逐一拿起盒子檢查] --> A[如果裡面是盒子...] Init --> B[如果找到鑰匙, 就完成了] A --> Init ``` ```python= # Pseudo code - Recursion def look_for_key(box): for item in box: if item.is_a_box(): look_for_key(item) elif item.is_a_key(): print('find the key!') ``` > 迴圈可能會提升程式的效能,但遞迴可能會提升程式設計師的效益。請依不同情況做選擇! > > Leigh Caldwell `選多重要的以算法都使用Recusrion,所以理解遞迴的概念非常重要!` ### 遞迴的Base Case 與 Recursive Case 沒有加入停止遞迴的機制,你的遞迴就會是一個無窮迴圈 何時該停止遞迴? 遞迴的組合: - Base Case (基本情況): 當函式不再呼叫自己時間的情況 - Recursive Case (遞迴情況): 當函式呼叫自己本身的情況 ```python def countdown(i): print(i) if i <= i: # Base Case return else: # Recursive Case countdown(i - 1) ``` ### 3-3 堆疊(Stack) 在函式呼叫與遞迴的運用 堆疊(Stack) 一種資料結構,函式呼叫時會用到堆疊技術。堆疊是一種有順序性的資料結構,「最晚放入stack」的資料會「最先被取出」(LIFO, Last-In-First-Out);而「最早放入堆疊」的資料會「最後被取出」(FILO, First-In-Last-Out) ### 呼叫堆疊 Call Stack ```python def greet2(name): print("how are tou, ", name, "?") def bye(): print("ok, bye!") def greet(name): print("hello, ", name, "!") greet2(name) print("getting ready to say bye...") bye() greet("Yvonne") # output: # hello, Yvonne! # how are you, Yvonne? # getting ready to say bye... # ok bye! ``` ### Call Stack and Recursion ```python def factorial(n): if n == 1: return 1 else: return n * factorial(n - 1) print(factorial(6)) ``` 回到一開始的收納箱裡面一堆盒子的example (literation & Recursion),使用Recursion會透過stack的方式執行function,所以`不需要自己紀錄哪些盒子尚未檢查,因為堆疊已經幫你記錄好了` Stack的缺點: - 存放的資料很佔記憶體空間 處理過多Stack的方式: - 將程式碼以迴圈的方式改寫 - 使用`尾端遞迴(tail recursion)`。不過,這是比較進階的遞迴主題,不是在本書探討的範圍內,而且並非所有程式語言都支持尾端遞迴 > Question: 假設不小心寫了一個無限循環(沒有停止點)的遞迴函式,電腦會對堆疊內的每個函式呼叫分配記憶體。如果遞迴函式不斷執行,最後會變成怎樣? > Answer: 堆疊會無限增長,當空間用完時,程式就會因為堆疊溢位(stack overflow)而停止執行 ## 4 Divide-and-Conquer & Quicksort ### 4-1 Divide-and-Conquer Divide-and-Conquer(D&C) 的概念是:將一個複雜的問題拆解成多個子問題,再用`遞迴(Recursion)` 求出子問題的答案,最後將這些子問題的答案合併在一起,就可以得到原本複雜問題的答案了! 許多演算法都用到D&C的技巧,例如: - 二分搜尋法(Binary Search) - 合併搜尋法(Mergesort) - 快速排序法(Quicksort)