--- title: Greedy Algorithm / Shortest Path tags: 演算法(交大OCW) --- # Shortest Path Shortest Path 有非常多的解法,這裡介紹的是第一個最簡單的版本 *Edsger W. Dijkstra 1959* :::success 老師說他覺得他比較像是數學家 他的板書跟筆記非常棒,研究也大多在紙上,每一頁還會編號 ::: 其實 Dijkstra 的這個解法同時解了另一個問題 Spanning Tree ,下一個章節會討論 *Robert C. Prim 1957* 但是因為有另一位 Prim 學者同時間也提出了解法(本質上跟 Dijkstra 是一樣的) 因此兩個問題的解法,前者以 Dijkstra 命名,後者就以 Prim 命名 :::info 圖靈獎得主獲獎時都要演說,講稿跟影片都找的到,可以去看 Dijkstra 在得獎時 (1972) 說過 *很多人寫了程式後就會遇到很長的 Debug 環節,浪費掉了很多時間 那應該做的就是不要讓 Bug 產生,也就是在設計時,就要確保邏輯是正確的* ::: # 應用 ## Google map 除了最短路徑,還有其他變形,像是最少油耗、最短時間(可能會塞車)等等 ## Shopping Mall 的路徑機器 台灣比較沒有(2015),但是外國的大型購物商城,要走到另一點會有點困難,於是就有這種機器幫你找出路徑 # 介紹 ## 輸入 ### 一個有方向性的 G(V,E) 當然也可以延伸出無方向性的版本 每個 E 上多了權重,在這裡代表距離 **注意,權重要大於等於 0** >所以變形就是權重的部分,可以改成時間、花費等等 每個 V 就是你的地點 :::danger 權重要大於等於 0 因為很重要,所以要再多說一次 ::: ### 一個起點 Source (s) 所以這個問題又叫做 **Single Source Shortest Path (SSSP)** ## 輸出 目標是給出,到達其他所有 V 的最短距離 原本只是想要得到兩點間最短距離,但是後來 Dijkstra 發現 他的解法可以得到全部點的最短距離,跟鬼一樣 --- # Dijkstra Algorithm ## Pseudo Code ```c // S:the set of explored nodes // for each u in S, we store a shortest path distance d(u) from s to u Dijkstra(G,L) 1. init S = {s}, d(s)=0 2. while S ≠ V do 3. select a node v not in S with at least one edge from S for which ``` $$ 4.\ d'(v)=min_{\theta=(u,v):u\in S}\{d(u)+L_{\theta}\} $$ ```c 5. add v to S and define d(v)=d'(v) ``` - S 是用來存放,已經算好最短距離的點 - d 這個變數是用來存放起點到達該點的最短距離 每個點都會有一個 - L 是某個 E 的長度函數,這裡的 E 是指 (u,v) - 其中 u 是 S 內的某一點 , v 是我要到達的點 也就是說,第四行目的是要找出 u 該點的最小距離,加上 (u,v) 這條 E 的距離的最小值 找到後就把 v 塞入 S ,並記錄他的 d value ## LINE 4 在做的事情 ![](https://drive.google.com/uc?id=1H9arsOP3cpkkEzPZouc085CfQ70gKZsO&export=download) 先列出 S 包含的點,也就是 s, u, v;以及以外的其他點,y, x, z 然後算出到達 y, x, z 的最短距離;不過由於已經知道到達 s, u, v 這三點的最短距離 所以我只要從這三點去看,有誰到達 y, x, z ,然後比較總距離的大小 例如要到達 x 的話,可以從 s, u, v,但是從 u 走的總距離是最短的,也就是 2 y 跟 z 兩點也一樣,可以分別得到 4 跟 5 從這三個數字中挑最小的,也就是到 x 的距離;把它存進 S ![](https://drive.google.com/uc?id=1U7fjzPfg7Sldk9mMVwfUwVuEJfNwWS-Z&export=download) 然後再做一樣的事情,從 s, u, v, x 這四點去看,到達 y, z 這兩點的最短距離 分別是 (x,y) 跟 (x,z),但是 (x,y)=3 比較小,所以先把 y 加進 S 最後就是加入 z ## Greedy 的部分 這個方法就是每次只走距離最短的點,也就是由近到遠推進 因此 S 存的點的 d 值會越來越大 # 正確性證明 Stays ahead ## Loop invariant property 由於 Greedy 的主結構就是跑一個迴圈,通常是藉由說明這個 Loop 具備一個不變的 property,這個「不變的特性」可以使我們得出一個結果,來證明 Greedy 是正確的 以上面的SSSP來說,「不變的特性」就是那個集合 S 的部分,因為 S 裡面存放的是已經找到最短距離的點 我們只要證明,點被放進去後,最短距離就再也不會被改變,那當我結束迴圈,也就是找完所有點後,裡面存放的就是所有點的最短距離 而證明方法就是使用數學歸納法 Induction ## induction on |S| 這裡歸納法要歸納的是 S 的長度,也就是其所包含的點的數量 會使用 S 的點的數量是因為,我可以放進 S 裡面,代表那個點是「對的」,是已經找到最小值的 Basic step:當我執行第 1 個迴圈,|S| = 1 >因為一開始就只有起點 source 一定可以放進去,一定是對的 Induction step:假設執行第 k 個迴圈,k ≧ 1,|S| = k 是正確的 也就是說 S 裡面存有了 k 個找到最小值的點 此時執行第 k + 1 個迴圈 ![](https://drive.google.com/uc?id=1yXz7U4GnL-HuuyL1W_h8qbo7vs52MVA9&export=download) 1. 上圖中,假設 v 是經過計算後,距離最短的點,為了方便說明,把跟他有 E 且在 S 內的點叫做 u 2. 假設有另一條 path 不是經由 u ,而是 S 內別的點到達 v,我們叫這連接 S 內部跟外部的兩點 x 跟 y;也就是說,我可以從 s 經由 x 到達 v,而 x 來開 S 後第一個連接的點叫 y 3. 由於程式碼在第 k + 1 次時選擇了 v ;也就是說,s 經過 x 到 y 的距離大於或等於 s 經過 u 到 v 的距離 5. 而從 S 內部, u 以外的其他點到達 v ,距離一定會比較長,因為多了 P 這條路 或是一樣長,也就是說 P 的長度是 0 6. 也就是說,s 經由u 到 v 一定是最小值,放進去以後就絕對不會再變動了 :::info 這裡也就是為何一開始的條件中,每條路的長度,也就是權重一定得要大於等於 0 如果可以為負數的話,上面第 4 點的推論就會出錯 而負數的權重,則要使用別的演算法,像是使用 Dynamic programming 的其他演算法 ::: --- # 實作的要點 ## 構想 S 以及 Line 3、4,需要搭配特殊的結構,避免讓「尋找最短距離的點」這個步驟變得複雜 想法跟上面的程式碼有些不同,上面是從 Edge 的角度出發,也就是對於每個 S 內的點,都去檢測跟他相連的且不在 S 內的點 但是這會讓複雜度飆升,所以就轉成從點的角度出發;因為每個點都持有一個 d value,那我每次就吐出一個 d value 最小的點進行分析 ## 實作 一開始 source 初始化,會把 d 值設為 0,而其他每個點都定為無限大 建立一個 min priority queue,裡面塞入每個點;每次執行迴圈時,吐出 d value 是最小的點 >因此, source 會是第一個被這個 queue 吐出來分析的點 將這個點塞入 S 裡面,表示他已經到達最小距離,並同時更新與他相連的點的最短距離;如果更新後的最短距離比原本持有的最短距離還要小,那就更新成新的最短距離 之後再一次從 queue 吐出 d value 最小的點 ,重複下去 ![](https://drive.google.com/uc?id=1ve0XyZeiI89onqBmecW_5pTQciADNtHg&export=download) :::info 至於要如何實作這個 min priority queue,底下會說明 ::: ## 時間複雜度 下面這張圖,Dijkstra 代表的是上面最一開始的構想所花費的時間 而左側的欄位代表演算法需要的操作 ![](https://drive.google.com/uc?id=1OuYfMeVV1jkRkDRFjsvZSRJVkjgKe-KG&export=download) 1. Insert - 一開始把每個人丟進 min priority queue 3. Extractmin - 把 d 最小值給抽出來 5. ChangeKey - 更新鄰居的 d value 7. IsEmpty - 檢查 queue 是否空了 :::success 上面的圖給出了四種實作 min priority queue 的方法 這堂課主要介紹使用 Binary heap,而且 heap 還可以拿來快速排序,好耶 ::: 在更新 d value 的部分,相較於最一開始的構想 (Dijkstra),也就是對 S 內每個點,都去更新他們的鄰居並找最小值; 上面藉由 queue 吐出一個點,並只更新他的鄰居的這個做法,可以降低所需時間,因為後者只要執行 「 Edge 數量的次數」 就好 ### Fibonacci heap 老師說這是一個研究很仔細後的結果,但是要注意一點是,他的係數很大 也就是說,在處理資料量較小的時候,用 Binary heap 就好 --- # Priority Queue 有兩種,一個是 index 最大優先,另一個是最小優先;但其實兩者是一樣的,只是順序顛倒而已 :::info - 最大的就叫做 Max Priority Queue;最小的就是 Min Priority Queue - 當資料伴隨著一個 key 的時候,通常就會採用 Priority Queue ::: 一個 Priority Queue 會有下面四種操作 ![](https://drive.google.com/uc?id=1hVZetxOhgbTUzUfZds64AfdCWrKCzJ2m&export=download) - FindExtreme - 圖片寫Min是因為他例子用 Min Priority Queue - 這個功能就是找出極值 - ExtractExtreme - 這個功能就是吐出極值,把他從 Queue 移除的意思 - Insert - 就是插入一筆資料,記得,要根據 Key 插入 - ChangeKey - 改變 Key 的大小,也就是改變重要性 而實作 Priority Queue 的方法,就是使用 Heap 這裡為了講排序,所以採用 Max Priority Queue # Heap ## 定義 1. A max/min heap:key[parent]≧/≦key[children] 2. A Complete Binary tree 所謂「完整」,如下圖,所有成員會依照編號排好;記住,由於他是二元樹,所以只有 Leaves 可能會殘缺,但是以上的部分一定是完好的 且殘缺一定是在右下角 ![](https://drive.google.com/uc?id=1d4LwU0HQmEe39iFoPWZqRkjQk_VM56Nr&export=download) :::info 所以可以知道,該 Heap 所代表的極值一定在 root,另一個極值會在 Leaves 的某處 ::: :::warning 要注意,Binary Heap 只確保父子間的大小關係,並沒有確保手足間的大小關係 因此,兄弟的孩子跟自己的關係也是沒有保證的,像是叔叔的值比姪子大之類的事不一定會發生 像上圖 Max heap 的第 3 個元素 7 ,他比他兄弟的孩子(也就是 12 底下的 10 跟 8)的值來的還要小 ::: ## 實作 - Array 是的,Heap 是用 Array 實作。下面為了方便,不用第 0 格 這樣一來,父元素跟第一個子元素的 index 關係就是 2 倍 像底下 14 的 index 是 1 , 12 的 index 是 2 12 的 index 是 2,10 的 index 是 4 ![](https://drive.google.com/uc?id=1uwbZiVDuDqq5Cn2x-qPZ4vHiYg8c0G3m&export=download) 然後一個 heap 還會記錄 1. Heapsize - 紀錄這個 Heap 目前的數量 <!-- - 但可能會算在 Array 裡面 --> 3. Capacity - 也就是 Heap 所在的 Array 最大的容量 ## Push Θ(logn) 以上面的例子。無論如何,先把他塞到最後面 :::info 其實這個例子已經先塞入" 1 "了 ::: >塞入 21 ![](https://drive.google.com/uc?id=1Dk10V3HzTKxUD1NOomJ_jAgAYq29WrSA&export=download) 這時候要做的事情就是,**跟他的爸爸比較** 根據這個 Heap 的種類,看是比較大就往上移,還是比較小就往上移 由於兄弟間沒有大小關係,所以就單單只要父子比較就好 因此,時間複雜度就是 Θ(logn)(上下界都是) ## Pop Θ(logn) 因為 Heap 的作用就是決定 Priority,因此要吐也是要吐出 Priority 最高的,也就是 Root 但是吐完後,要維持住大小關係。 1. 先把最後一個元素,給移到 Root O(1) 2. 之後開始糾正大小關係 Θ(logn) 1. 首先讓 Root 的兩個子元素比較大小 2. 比較出來後再跟 Root 交換 3. 持續執行值到正確的大小關係 ![](https://drive.google.com/uc?id=19DmGQTKHdK0JJlEeAa2m8NcO8EG-JWR5&export=download) --- # Max / Min Heapify O(nlogn) 也就是讓一堆沒有序列的數字,符合 Heap 的形式。最一開始的構想跟數學歸納法很像。 而我們有個循環的步驟: 1. 我每次就挑選一個子元素,然後去跟他的父元素比較大小關係 2. 假設子元素底下已經構成一個 Heap,那當我比較完大小關係後,就可以讓父元素準確的沉下去 那起始的步驟,也就是歸納法的第一步要怎麼挑呢?就是從 Leaves 開始 因為 Leaves 底下沒有任何元素,所以他自己就是一個 Heap ![](https://drive.google.com/uc?id=1PNqP9m5W5iBWouAuhz_23dwu_cFD8VOT&export=download) 上圖就是從 Leaves 開始,找其對應的 父元素 去比大小 而且由於 index 從 1 開始,所以我偶數位,也就是 2n,跟 2n + 1 的就會是兄弟 兄弟間比完大小,再用 2n/2 找到父元素,去跟比完大小的兄弟比大小,十分方便 由於會遍歷整個元素,且對每個元素都要移動父元素,所以會是 O(nlogn) :::info 其實跟上面的 Push 後要維持大小關係的方法很類似,不過由於無法確保其他部分的大小關係,所以只能從底部往上做 ::: ## Heap Sort 將 Heapify 好的 Heap 的 Root 的值跟最後一個元素交換,並改變 Heapsize,因此移動到底部的極值並不會被影響。 然後重整一次大小關係。 由於是已經 Heapify 過的,所以跟 Pop 一樣,每次只要 logn 的時間就可以調整好大小關係;共有 n 次,因此總共的時間複雜度就是 nlogn 而且因為從頭到尾都是對同一個陣列進行操作,所以空間使用非常有效率,很省空間 而且因為是當下馬上做,所以是 Impress sorter(? :::info 當然還有其他的方法時間複雜度一樣 merge sort :::