演算法與資料結構


資料結構

一般來講,電腦程式設計常見的資料結構有…

線性結構   陣列(Array)
       鏈結串列(Linked list)
       堆疊(Stack)
       佇列(Queue)
非線性結構  樹(Tree)
       圖(Graph)
表格結構   雜湊(Hash)


陣列

在記憶體的連續空間裡,存放一串資料,然後只要照著順序存取記憶體裡面的資料即可,因此是存取效率最高的資料結構!不過,要在資料中插入或刪除資料的話,必須搬動後面的資料,資料量大時,會有效率低落的問題。

這個資料結構正式名稱其實是「循序串列(Sequential list)」,但程式語言提供的「Array(陣列)」語法就是現成的「循序串列」,用久了便直接叫它「陣列」~


鏈結串列

如果資料筆數太長,就不見得能在記憶體裡找到足夠的連續空間。於是除了保存資料內容以外,還保存指向其它資料的記憶體位置,這樣就可將支離破碎的資料連接起來使用。

當然,必須額外透過指標來存取不同筆的資料,效率會降低。不過,中途插入或刪除資料的話,只要改變指向的記憶體位置即可,效率反而比陣列來得高!

只指向下一筆資料記憶體位置的,叫做單向鏈結串列,還指向上一筆資料記憶體位置的,叫做雙向鏈結串列。


堆疊

只從線性結構的頂部存取資料!不妨想像成堆高積木一樣,存入資料時不斷往上堆高,要取出資料時也是從最高位置著手,好像害怕從底部抽掉積木會垮掉一樣。這種結構的資料有「後進先出(Last in, first out. LIFO.)」的特性,存入資料的動作叫 push,取出資料的動作叫 pop。

雖然沒有強制規定,但適合使用陣列來設計堆疊。


佇列

從線性結構的尾部存入資料,再從頭部取出資料,也就是從線性結構的兩端來存取資料。這種結構的資料有「先進先出(First in, first out. FIFO.)」的特性,存入資料叫 enqueue 或 unshift,取出資料叫 dequeue 或 shift,或者跟堆疊一樣用 push 和 pop。

雖然沒有強制規定,但通常使用鏈結串列來設計佇列。


以「節點(node)」的觀念表示資料。通常由一個根節點開始,展開一或多個子節點,每個子節點又有一或多個子節點,枝枝繁生開來,整個資料結構就像一棵樹的形狀一樣。這種資料結構,可以用鏈結串列設計出來。

最簡單的樹,是每個節點只有左、右兩個子節點的樹,叫做「二元樹(Binary tree)」。你可以不斷將小於節點的值放在左邊子節點,大於節點的值放在右邊,左右節點滿了再開出分枝。比起資料筆數多少深度就是多少的串列,二元樹的深度非常淺,尋找資料時需要循訪的筆數少,所以能提升存取資料的效率。

除此之外,樹狀資料的特殊結構,還能套上奇奇怪怪的數學公式,像二元樹能以 2 為因子設計算式,更快找出節點!當你樂於此道,就表示你將從平庸的程式設計師成為高竿的程式設計師了!

由於所有樹都能轉為二元樹,所以是最基本的樹狀結構。有名的「堆積(Heap)」資料結構就是二元樹的一種。


像樹狀結構,但重點不是放在「節點」與其歸屬關係,而是放在連接節點的「邊(edge)」與其路徑關係來解決問題。由於節點與節點之間可以任意關聯,連接起來就像畫星座一樣出現各式圖形而得名。

可以使用二維陣列來設計圖的資料結構!節點一到節點二有連接關係,就將 [1][2] 設為 1,節點二到節點一有連接關係,就將 [2][1] 設為 1。

設為 0 或 1 的邊,可以表示方向性、單向或雙向,也可以在邊累加數字上去,當作尋訪次數,發揮這些特性,再對二維陣列套用數學的矩陣公式進行運算,可以解決其它資料結構難以處理的問題,像是最短路徑。更別說數學就有「圖論」這門分支,可以幫助我們進行更具深度的演算法設計,解決更多難題!

看起來很神奇,但圖狀結構只適合解決特殊問題,日常遇到的問題還是靠線性結構和樹狀結構來解決會表現得更好。其實圖狀結構能做的事不多、可以應用的層面不廣泛,並不是主要關注的焦點。


雜湊

可視為一種用關鍵字來儲存資料的結構,因此在取出資料時具有最快的效率,例如給予關鍵字直接就能取得資料,是設計「搜尋演算法」時非常受歡迎的資料結構。

但是在存入資料時,可能因為用關鍵字來保存資料的手法設計得很複雜,而影響效率。

不過,也因為保存資料的手法可能是透過某種數學計算技巧改良而來,所以有時候設計出來變成一種可以節省空間的資料結構,而被當作能夠壓縮資料的結構亂用。


演算法

本節將介紹設計演算法時常用的思維:遞歸、迭代、預處理、記憶、分治、貪食、隨機、窮舉。大多數演算法,都是基於這些思維或手法而設計~


遞歸法(Recursion)

重複使用同樣一套計算過程的手法,就叫遞歸法1

例如階乘 n!=1*2*3*..*n 的公式,就適合用遞歸法來設計:


24


迭代法(Iterative method)

重複使用計算出來的結果,就叫迭代法。

例如不斷用前兩個數值相加來做為下一個數值的費氏數列:0、1、1、2、3、5、8…,就適合用迭代法來設計:


144


預處理(Precalculation)

其實事先準備好答案,也是一種解題的手法,例如費氏數列就可以事先將答案準備好:


144
雖然寫死,但有時候設計演算法必須著重執行效率時,這招依然是絕活!


記憶法(Memoization)

將計算的結果保存起來,以備下次遇到同樣問題時使用。如此既能發揮「預處理」的執行效率,又沒有答案寫死無法套公式擴充的問題。再以費氏數列為例:


144


分治法(Divide and conquer algorithms)

將一個大問題分成多個小問題,然後逐一擊破解決。

例如「找出偽幣」的問題,有 12 枚銀幣,其中一枚是假的,重量較輕,這時決定用天平找出偽幣:「不斷將銀幣分成兩堆來比重量,直到挑出最輕的那個為止。」就可用分治法來設計2


10
另外有一種說法,出現兩次或以上遞迴的函式,就是分治法。從外表上來看確實是這樣沒錯,值得參考。


貪食法(Greedy algorithm)

一次給予最具突破性的答案,不斷用取巧的方式求最佳解。

底下以「找錢」為例,我們希望用面額最大的錢來找,例如找 50 元的話就給一枚 50 元硬幣,不要給五枚 10 元硬幣:


100
100
50
5
1

輸出的結果代表:兩張 100 元紙鈔,一枚 50 元硬幣,一枚 5 元硬幣,一枚 1 元硬幣。是最佳解沒錯!


隨機法(Randomized algorithm)

有名的「蒙地卡羅法」,是用隨機採樣的統計方式,來模擬出接近 Pi 的答案:


每次執行結果不同,例如 3.138376 或 3.141896。
雖然知道有這樣的思維方式,也很想試試看,但我從未成功設計出這類的演算法 Orz


窮舉法(Enumeration)

逐一嘗試所有可能性,最後總會解出答案或完成任務,這一千零一招可是有名稱的,叫窮舉法。

簡單的像是:


2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40
複雜的像是解雞兔同籠問題:


今有雉兔同籠,上有 35 頭,下有 94 足,問雉兔各幾何?
雉 23,兔 12。


估算程式

演算法的評估可是一門學問,甚至有些演算法該如何評估其成長率會引起一番在爭論。例如 Shell sort 這個演算法,在不同資料結構會有不同的表現,最後是經由反覆實驗才被估算為平均 Ο(N7/6) 的非典型案例,可見演算法分析有時候不是那麼簡單的事。

但簡單的估算還是要會,像是直接給陣列索引值:

array[x]

這只需要花費執行 1 次的時間,所以估算為 1

若在迴圈逐一存取陣列:

for(var n of array)

這需要花費執行 n 次的時間,就估算為 n

若把上面的程式改為二分搜尋法,讓執行次數砍半,就估算為 log n

還有像是分治法的快速排序,先分割兩個線性序列,再遞迴排列順序,兩個估算合併起來就是 n log n。有趣的是,這演算法若資料正好百分之百顛倒排序,也就是分治法的其中一個等於沒發揮作用,其實只有 n2

以上只是概念性的估算,實際估算時,要以真正影響性能的程式為基準!例如四層 for 迴圈就估算為 n4,迴圈裡面若有陣列 array[x] 就忽視不估算,因為存取陣列影響的性能,相較於多層迴圈,等於沒有影響,不需要估算在內。以四層迴圈為基準,想辦法從 n4 降為 n2 log n,即能優化性能,等於不影響性能的 array[x] 對優化起不了半點作用,沒必要估算在內。


時間與空間

以上介紹的都是「時間複雜度」的演算法。

然而,如果發明了時間很快的演算法,但必須用 512 GB 的 RAM 才能運作,那恐怕也稱不上好的演算法3

因此有時候「空間複雜度」也是演算法設計的焦點。

不過,「以空間換取時間」也是一種演算法的思維,它通常速度更快,卻又更容易設計出來!像是藉由大量使用記憶體或占用系統資源,來達到目的。雖然硬體資源寸土寸金,通常來講空間演算法不可行,但情況與條件允許你揮霍與浪費的話,不妨往「空間」方面思考解法。


排序演算法

常見的有四大類,以及各自的改良版本:

交換排序(Exchange sort)  快速排序(Quicksort)
插入排序(Insertion sort) 希爾排序(Shellsort)
選擇排序(Selection sort) 堆積排序(Heapsort)
合併排序(Merge sort)   串列排序(Strand sort)

標準 API 已經有專家們設計好的排序,一般而言我們不可能寫出比他們更快的排序演算法,所以寫程式很少會需要自己設計排序。

但身為程式設計師,能夠寫出應急用的簡單排序演算法是基本能力,至少要挑一個來熟記~


Exchange sort

一路比較相鄰兩資料的大小進行交換:


1234
為了提升效率,可將內層迴圈的條件句改為 n<array.length-1-m 逐步縮小比對的範圍。但這是應急用的,好背就好,省略無所謂。使用交換排序法,目的是會動就好,沒在講效率的~


Insertion sort

照順序將資料插入到適合它的位置:


1234


Selection sort

不斷挑出最小的值,把它丟到前頭,然後縮小範圍繼續挑:


1234