資料結構
一般來講,電腦程式設計常見的資料結構有…
線性結構 陣列(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 的公式,就適合用遞歸法來設計:
迭代法(Iterative method)
重複使用計算出來的結果,就叫迭代法。
例如不斷用前兩個數值相加來做為下一個數值的費氏數列:0、1、1、2、3、5、8…,就適合用迭代法來設計:
預處理(Precalculation)
其實事先準備好答案,也是一種解題的手法,例如費氏數列就可以事先將答案準備好:
雖然寫死,但有時候設計演算法必須著重執行效率時,這招依然是絕活!
記憶法(Memoization)
將計算的結果保存起來,以備下次遇到同樣問題時使用。如此既能發揮「預處理」的執行效率,又沒有答案寫死無法套公式擴充的問題。再以費氏數列為例:
分治法(Divide and conquer algorithms)
將一個大問題分成多個小問題,然後逐一擊破解決。
例如「找出偽幣」的問題,有 12 枚銀幣,其中一枚是假的,重量較輕,這時決定用天平找出偽幣:「不斷將銀幣分成兩堆來比重量,直到挑出最輕的那個為止。」就可用分治法來設計2:
另外有一種說法,出現兩次或以上遞迴的函式,就是分治法。從外表上來看確實是這樣沒錯,值得參考。
貪食法(Greedy algorithm)
一次給予最具突破性的答案,不斷用取巧的方式求最佳解。
底下以「找錢」為例,我們希望用面額最大的錢來找,例如找 50 元的話就給一枚 50 元硬幣,不要給五枚 10 元硬幣:
輸出的結果代表:兩張 100 元紙鈔,一枚 50 元硬幣,一枚 5 元硬幣,一枚 1 元硬幣。是最佳解沒錯!
隨機法(Randomized algorithm)
有名的「蒙地卡羅法」,是用隨機採樣的統計方式,來模擬出接近 Pi 的答案:
雖然知道有這樣的思維方式,也很想試試看,但我從未成功設計出這類的演算法 Orz
不過,將執行結果交由運氣決定,也算隨機演算法!例如選擇題有四個答案,分別是 1、2、3、4,將亂數控制在 1 到 4 之間,隨機給予一個值做為答案,也算是隨機演算法,並不是非得像蒙地卡羅法這麼神奇。
窮舉法(Enumeration)
逐一嘗試所有可能性,最後總會解出答案或完成任務,這一千零一招可是有名稱的,叫窮舉法。
簡單的像是:
複雜的像是解雞兔同籠問題:
排序演算法
常見的有四大類,以及各自的改良版本:
交換排序(Exchange sort) 快速排序(Quicksort)
插入排序(Insertion sort) 希爾排序(Shellsort)
選擇排序(Selection sort) 堆積排序(Heapsort)
合併排序(Merge sort) 串列排序(Strand sort)
標準 API 已經有專家們設計好的排序,一般而言我們不可能寫出比他們更快的排序演算法,所以寫程式很少會需要自己設計排序。
但身為程式設計師,能夠寫出應急用的簡單排序演算法是基本能力,至少要挑一個來熟記~
Exchange sort
一路比較相鄰兩資料的大小進行交換:
為了提升效率,可將內層迴圈的條件句改為 n<array.length-1-m
逐步縮小比對的範圍。但這是應急用的,好背就好,省略無所謂。使用交換排序法,目的是會動就好,沒在講效率的~
Insertion sort
照順序將資料插入到適合它的位置:
Selection sort
不斷挑出最小的值,把它丟到前頭,然後縮小範圍繼續挑:
Merge sort
有點小複雜,不太可能記住,是追求高效率時複製貼上修改來用的。
這演算法是分治法的經典範例,而且是現代電腦架構之父 John von Neumann(馮紐曼)發明的!它時間複雜度穩定在 n log n,沒有最佳和最差的問題,很適合用在資料量大的場合,還適合針對現在多核心處理器改寫優化!Wikipedia 有詳細的介紹與說明,值得花時間研究這個演算法~
Index sort
前面提到過「空間複雜度」的演算法,如果要排序的資料正好是整數,就可以揮霍記憶體,用陣列索引值就是現成已排序整數的特性,對索引值標上資料出現次數,達到排序的效果:
這很蠢,沒幾筆資料卻動用成千上萬筆資料空間去處理!但臨時應急,卻又記不起來前面三種演算法的話,這是原理最簡單、能在最快時間內完成測試的偷吃撇步。