# 資料結構初探 [TOC] ## 一、複雜度分析 - 如果 $\dfrac{f(n)}{g(n)}$ 在 $n$ 趨近於無限大時趨近於0,則 $f(n)$ 比 $g(n)$ 大。 - 如果 $\dfrac{f(n)}{g(n)}$ 在 $n$ 趨近於無限大時趨近於無限大,則 $f(n)$ 比 $g(n)$ 小。 - 否則為一樣量級。 ### 複雜度的概念 - 假設所有操作都花費**一樣**的時間 - 把所有操作需要的「**次數**」都計算出來 - 看看量級的大小(**複雜度**),並且評估執行時間 ### Big-O (大O符號) 記為 $O(f(n))$,其中$f(n)$是複雜度量級的上界(Upper Bound)。假設實際運算時間為$g(n)$,那麼在$n$ 趨近於無限大時,有$\dfrac{f(n)}{g(n)}$趨近於某常數或者$\dfrac{f(n)}{g(n)}$趨近於無限大。 Ex. > - $3n^3 + 5n^2 + 10n + 3 \in O(n^3)$ > - $f(n) \in O(n^2)$,則$f(n) \in O(n^3)$ > - $1000 \in O(1)$ >==> 取最緊的 Upper Bound ### Little-o (小o符號) 記為 $o(f(n))$,其中$f(n)$是複雜度量級的**嚴格**上界。假設實際運算時間為$g(n)$,那麼在$n$ 趨近於無限大時,有$\dfrac{f(n)}{g(n)}$趨近於某常數或者$\dfrac{f(n)}{g(n)}$趨近於無限大。 Ex. > - $3n^3 + 5n^2 + 10n + 3 \notin o(n^3)$ > - $3n^3 + 5n^2 + 10n + 3 \in o(n^4)$ > - $f(n) \in o(n^2)$,則$f(n) \in o(n^3)$ ### Big-omega 記為 $\Omega(f(n))$,其中$f(n)$為複雜度量級的下界(Lower Bound)。 Ex. > - $3n^3 + 5n^2 + 10n + 3 \in \Omega(n^3)$ > - $f(n) \in \Omega(n^2)$,則$f(n) \in \Omega(n^3)$ ### Little-omega 記為 $\omega(f(n))$,其中$f(n)$為複雜度量級的**嚴格**下界(Lower Bound)。 Ex. > - $3n^3 + 5n^2 + 10n + 3 \in \omega(n^3)$ > - $f(n) \in \omega(n^2)$,則$f(n) \notin \omega(n^3)$ ### Big-theta 記為 $\Theta(f(n))$,其中$f(n)$為複雜度量級的**嚴格上下界**(完全相同)。 Ex. > - $3n^3 + 5n^2 + 10n + 3 \in \Theta(n^3)$ > - $3n^3 + 5n^2 + 10n + 3 \notin \Theta(n^4)$ > - $3n^3 + 5n^2 + 10n + 3 \notin \Theta(n^2)$ <hr> ## 二、基礎資料結構 資料結構是指如何在電腦中儲存資料,通常是(多種)演算法+(多種)資料結構。 對於不同用途選擇不同資料結構 : - 時間複雜度 - 空間複雜度 - coding複雜度 常見操作: push(放進一個元素)、pop(拿出一個元素)、query(各種查詢)。 <br> ### Stack (堆疊) 性質為 First In Last Out (FILO),資料存取同一方向。實作使用陣列或Linked list。 #### 陣列實作 - 用一個變數 top 紀錄頂端的位置 (堆疊個數) - top=-1 表示 stack 是空的 (有些初始值為0) - push: top+1 - pop: top-1 - 堆疊為滿時,則不可再加入 ; 為空時,則不可再取出 <br> ### Queue (佇列) 性質為 First In First Out (FIFO) 或是 First Come First Service (FCFS),資料存取不同方向,分類有單向或環狀。實作使用陣列或Linked list。 #### 陣列實作 - 用一個變數 front 紀錄最前面的元素的「前一格」位置 - 用一個變數 rear 紀錄最後面的元素的位置 - front == rear 表示 queue 是空的 - push: rear+1 - pop: front+1 產生問題: 一個元素一旦被pop,該位置無法再放新元素。 解決方法: - rear 到陣列最後時,將所有元素平移到前方 (不是好方法 -> 所有元素都需移動) - 使用環狀queue (也就是更改rear位置) - 使用Linked list <br> ### Stack vs Queue <img src="https://miro.medium.com/v2/resize:fit:1400/1*zKnDkJpL-4GQ36kzrDiODQ.png"><br> ### 佇列 vs 環狀佇列 #### 佇列 - front的初始值 = -1 , rear的初始值 = -1 - 判斷佇列為空 `rear == front` - 判斷佇列為滿 `rear = maxSize -1` #### 環形佇列 - front的初始值 = 0 , rear的初始值 = 0 - 需預留一個儲存單元來判斷佇列是否為空還是滿 - 判斷佇列為空 `rear == front` - 判斷佇列為滿 `(rear+1) % maxSize == front` - 判斷佇列的有效數據個數 `(rear + maxSzie - front) % maxSize` > Q : front = 0,rear = 5,maxSize = 7,共有幾個有效數據? > > A : (5+7-0) % 7 = 5 ,共有五個有效數據。 **為什麼不是6?** 因為rear會指向最後一個元素的後一個位置。 <br> ### Linked List (鏈結串列) 基本單元為 Node(data+pointer),將很多 Node 接起來,head指標指向第一個Node,最後一個指標指向 NULL。(也就是每個元素指紀錄它的下一個元素,外部指紀錄起點(head)) **動態宣告記憶體**的方式(與陣列不同的是,陣列一開始就宣告固定記憶體了,動態宣告的方式不會浪費記憶體,需要的時候再跟系統取用),不能隨機存取(random access) (也就是要從第一個開始走),分類有單向、雙向或環狀。 <img src='https://media.geeksforgeeks.org/wp-content/uploads/20220816144425/LLdrawio.png'><br> #### 實作 先定義一個 struct/class Node,作為linked list的節點,裡面存 data 和一個指向下一個 Node 的 pointer。 使用時只需要一個變數head紀錄linked list的起點就可以了。 ```c++= #include <studio.h> struct Node { int _data; Node* _next; }; int main() { Node* head; return 0; } ``` - **用 linked list 實做 stack** 用 count 來記錄節點數、top 指向排頭節點 - push: 在 top 前面插入一個節點 - pop: 刪除 top 指向的節點 <img src='https://abhinavranaweb.files.wordpress.com/2020/06/stack-ds.png?w=1067&h=566&crop=1'><br> - **用 linked list 實作 queue** 要多紀錄linked list的尾端(rear) - push: 在 rear 後面插入一個節點 - pop: 刪除 front 指向的節點 <img src='https://media.geeksforgeeks.org/wp-content/uploads/Operations-on-Circular-Queue.png'><br> - **Linked list v.s. Array v.s. Dynamic array** <img src="https://i.stack.imgur.com/Ly4Fp.jpg"> <hr> ## 三、Recursion 需指定終止條件,避免陷入無窮迴圈。 範例 : n! 實作 ```c++= int nLevel(n) { if (n == 0) return 1; // 設定條件 else return n * nLevel(n-1); } ``` <br> ### Iteration v.s. Recursion <img src='https://cdn-media-1.freecodecamp.org/images/1*QrQ5uFKIhK3jQSFYeRBIRg.png'><br><br> ### 費式數列 將大問題拆分成小問題 $\implies$ 分而治之(Divide and Conquer) <img src='https://learning.juice.codes/api/images/dvcNK'><br> ### 河內塔 將 $n$ 層積木從 $a$ 移動到 $c$ (左移到右),所需次數為 $n = 2^n - 1$ 圖片過程示例 : [[數學公式講解](https://youtu.be/0lDXv0Y6ClQ?si=xe7Ml0akNEzvIaK-)] [[程式算法講解](https://youtu.be/iN_U9ozdCD4?si=dzhboEUJRVC_PNQ1)] <img src='https://pic.pimg.tw/f74461036/1495536756-3594624128.png'> ```c++= #include <iostream> #include <iomanip> using namespace std; void Hanoi(int n, char A, char B, char C); // Hanoi( n 層積木, from, pass, to ) void Hanoi(int n, char X, char Y, char Z) { if (n == 1) { count << n << ":" << X << "-->" << Z << endl; return; } Hanoi( n-1, X, Z, Y); count << n << ":" << X << "-->" << Z << endl; Hanoi( n-1, Y, X, Z); return; } ``` <hr> ## 四、Flood Fill - 用 queue 來做,類似全部領域都同時涉略,並且一層一層學深入,被稱為廣度優先搜索 (Breath-first search, BFS) - 用 stack 來做,類似先一口氣專精某個領域,鑽研完才鑽研其他領域,被稱為深度優先搜索 (Depth-first search, DFS) <img src='https://upload.wikimedia.org/wikipedia/commons/8/89/Recursive_Flood_Fill_8_%28aka%29.gif' text-align:center> <hr> ## 五、Tree ### 性質 - 任何一點都可以當 root - 任兩點恰有一條不經過重複點的路徑 - 一棵有 $N$ 個節點的樹恰有 $N-1$ 條邊 ### 紀錄 利用 Linked List 或 Dynamic array 動態紀錄 : ```c++= #include <stdio.h> #include <vector> using namespace std; struct Node{ int _data; vector<Node*> _child; }; ``` 另一個常見的方法,當給定每個點的編號時 : ```c++= using namespace std; int data[SIZE]; vector<int> child[SIZE]; ``` 優點 : 只需要編號,不需要 Pointer ### Binary Tree - 每個節點最多只有兩個子節點 - 第 $k$ 層最多有 $2^k$ 個節點 - 一棵深度為 $k$ 的 binary tree 最多有 $2^{k+1}-1$ 個節點 ```c++= struct Node{ int _data; Node *_lchild, *_rchild; /*左子節點和右子節點*/ } ``` ### Binary Tree 遍歷 分為 : 前序、中序、後序 差別 : 在什麼時候印出一個node的值 - **前序** ```c++= void dfs(Node* node){ node -> printData(); if(node -> _lchild != NULL) dfs(node -> _lchild); if(node -> _rchild != NULL) dfs(node -> _rchild); } ``` ```c++= output: 1 2 4 5 3 6 ``` - 中序 ```c++= void dfs(Node* node){ if(node -> _lchild != NULL) dfs(node -> _lchild); node -> printData(); if(node -> _rchild != NULL) dfs(node -> _rchild); } ``` ```c++= output: 4 5 2 1 3 6 ``` - 後序 ```c++= void dfs(Node* node){ if(node -> _lchild != NULL) dfs(node -> _lchild); if(node -> _rchild != NULL) dfs(node -> _rchild); node -> printData(); } ``` ```c++= output: 4 5 2 6 3 1 ``` ### Complete binary tree - 除了最後一層,每一層都是填滿的 - 最後一層的元素盡量往左靠 - Complete Binary Tree 的時間效率會比 Non-Complete Binary Tree 的效率高 #### 儲存方式 - 編號為 $k$ 的兩個 child 編號分別為 ```2k``` 和 ```2k+1``` - 編號為 $k$ 的 parent 編號為 ```k/2``` - 一個有 $n$ 個元素的 complete binary tree 的深度約為 $log_2(n)$ <img src='https://miro.medium.com/v2/resize:fit:1200/1*CMGFtehu01ZEBgzHG71sMg.png'><br> <hr> ## 六、Heap ### Priority Queue 如果不希望 pop 出來的是最先進去或最後進去的,而是根據「權重」大小 #### 基本操作 - push : 將一個元素放進 priority queue 中 - top : 詢問現在 priority queue 中權重最大的元素 - pop : 將 priority queue 中權重最大的元素拿掉 陣列是沒有序的,top 找到最大值是 $O(n)$,pop 找到最大值是 $O(n)$,將最後一個元素補回來進空格是 $O(1)$,push 是 $O(1)$,整體複雜度是 $O(n)$。 <br> 微改良 : 保持陣列的第一個元素是權重最大的。 - push 放進去是 $O(1)$,和第一個元素(最大值)比較是 $O(1)$,複雜度是 $O(1)$。 - top : 陣列第一個元素就是最大值 $O(1)$。 - pop : 第一個元素最大的 pop,找到剩下元素中的最大值 $O(n)$。 整體複雜度還是 $O(n)$。 #### 分組作法 將 $k$ 個數字分成一組,並記錄這些數字中權重最大的 - top : 比較每組的最大值是 $O(\frac{n}{k})$。 - pop : 找到最大值 pop 掉是 $O(\frac{n}{k})$,最後一個元素補上是 $O(n)$,找到組中最大的元素是 $O(k)$。整體來看是 $O(\frac{n}{k}) + O(k)$,視k的大小而定。 選擇 $k=\sqrt{n}$ - push : $O(1)$、top : $O(\sqrt{n})$、pop : $O(\sqrt{n})$ ### Heap heap 其實就是一棵 complete binary tree,性質為**父節點的權重不小於子節點的權重**。 將元素放進 tree 是 $O(1)$,和父節點比較是 $O(1) * log(n)$ (較大元素往上放,直到權重不大於父節點,最多比較 $log(n)$ 次)。 - push : $O(log(n))$ - top : $O(1)$ - pop : $O(log(n))$ 整體複雜度是 $O(log(n))$。 <br><hr> ## 七、枚舉 ### BFS 過程中的所有節點都必須記錄下來。 - 方法一 : 每個節點都詳細記錄對應的路徑。 推入新節點和每個節點所需的空間為 $O(路徑長)$ - 方法二 : 在每個節點紀錄該節點的父親以及路徑上的最末元素,在輸出時直接順著父親指針走回去,就可以輸出解了。 推入新節點和每個節點所需空間為 $O(1)$ ### DFS 直接開一個 stack 紀錄當前節點的路徑。 **狀態空間很大的情況使用DFS** <br><hr> ## 八、貪心 ### 證明架構 1. 為了方便表示與比較,先用代數表達出問題與解答的數學模型 2. 假設我們的算法給出的解 $S$ 是錯的,那麼存在真正的最優解 $S'$,且 $|S'|<|S|$ 3. 如果有兩個編號分別為 $i,j$ (編號就代表事件的順序) 滿足 「 $i < j$ 且 $i$ 吃的比 $j$ 快」,我們就說這兩個形成一對「逆序對」。既然最優解 $S' \neq S$,那麼 $S'$ 裡面一定存在相鄰的兩元素形成逆序對 4. 透過把這兩個順序交換,獲得方案 $S'_1$,證明 $|S'_1| \leq |S|$ 5. 如果 $S'_1$ 中還存在相鄰逆序,則再把該對逆序元素交換形成 $S'_2$,類似 4 的證明可知 $|S'_2| \leq |S'_1|$ 6. 不斷重複類似 5 的步驟,直到當前的解 $S'_x$ 中部存在相鄰逆序。此時 $S'_x$ 必定不存在任何逆序對,從而有 $S'_x = S$ 7. 於是有 $|S| \leq |S'_x| \leq |S'_{x-1}| \leq ... \leq |S'_1| \leq |S'|$,即$|S|<|S'|$,與 $|S'|<|S|$ 的假設矛盾,從而證明 $S$ 是最佳解。 <br> ### 最優編碼樹 1. 一定不會只有一個子節點的節點 > 否則會直接用這個節點的子節點取代它自己,依舊是合法編碼樹,但空間消耗更小 2. 頻率越高的字元對應的葉節點深度越淺 > 否則交換兩者的位置,空間消耗更小 3. 必定存在一棵最優編碼樹,使得頻率最低的兩個字元對應的葉節點形成兄弟 > 由性質 1 可知深度最大的一層至少有兩個節點 > 由性質 2 可確定頻率最低的兩個字元皆位於深度最大的一層 > 如果兩者並非兄弟,則把他們位置換成兄弟,解的優劣不變 <br><hr> ## 九、Sort 介紹 ### A. 內部排序 vs 外部排序 #### 內部排序 (Internal Sort) 資料量少、可以將資料一次全部置於 memory 中進行排序。 > 例如: Bubble Sort, Insertion Sort, Quick Sort, Heap Sort, Radix Sort, Selection Sort #### 外部排序 (External Sort) 資料量大,無法一次全部置於 memory 中,需要藉助外部儲存體(ex. Disk)保存進行排序。 採用的策略為 **排序 - 合併**,在排序階段,先讀入能放入內存中的數據量,將其排序輸出到一個臨時文件,以此類推,整個數據形成多個有序的臨時文件,最後在合併階段將這些臨時文件排序為一個大的有序文件,即排序結果。 > 例如: Merge Sort (利用 Selection Tree 結構輔助)、M-way Search Tree、B Tree <br> ### B. Stable vs Unstable Sorting Method 在輸入資料中,有可能存在多筆相同的資料,例如: 100、23、54、data_1、data_2、39 (data_1 和 data_2 are the same)。經過某個排序演算法後,**若是能保證 data_1 仍在 data_2 之前,則這個演算法稱為 Stable** ; 反之若是無法保證 (data_2 有可能在 data_1) 之前,則稱為 **Unstable**。 **Unstable 的演算法代表會有不必要的 swap**,會造成多餘的計算,而 Unstable 排序演算法不一定比 Stable 慢 (例如 Quick Sort)。 <br> ### C. 初等排序 vs 高等排序 兩者差別在於**時間複雜度**。 #### 初等排序 在初等排序中的平均時間複雜度為 $O(n^2)$。 例如 : Selection Sort、Insertion Sort、Bubble Sort #### 高等排序 在高等排序中的平均時間複雜度為 $O(nlogn)$ 例如 : Quick Sort、Merge Sort、Heap Sort <br> ### D. Linear-Time Sorting Algorithm (線性時間排序) 與前面提到的演算法差別在於,排序技巧並非採用 Comparison-based (透過「小於或等於」的操作來確定兩個元素的序列前後)。 非比較排序的演算法像是 Radis Sort、Bucket Sort、Counting Sort。 <br> ### E. Selection Sort、Insertion Sort、Bubble Sort 這三種排序方法,都會將排序對象分為兩部分 : 已排序與未排序。 #### 實作範例的比較函數 ```c++= #define SWAP(x, y) { int t; t = x; x = y; y = t; } int ascending(int a, int b) return a - b; int descending(int a, int b) return -ascending(a, b); ``` - #### Selection Sort 如果排序是由小而大,則從**後端未排序部分選擇一個[最小值]**,並**放入前端已排序部分的[最後一個]** (若是由大而小,則反之)。 > 有一序列排序前為 70、80、31、37、10、1、48、60、33、85 > >\[1\] 80 31 37 10 70 48 60 33 85(選出最小值 1) \[1 10\] 31 37 80 70 48 60 33 85(選出最小值 10) \[1 10 31\] 37 80 70 48 60 33 85(選出最小值 31) \[1 10 31 33\] 80 70 48 60 37 85(選出最小值 33) \[1 10 31 33 37\] 70 48 60 80 85(選出最小值 37) \[1 10 31 33 37 48\] 70 60 80 85(選出最小值 48) \[1 10 31 33 37 48 60\] 70 80 85(選出最小值 60) \[1 10 31 33 37 48 60 70\] 80 85(選出最小值 70) \[1 10 31 33 37 48 60 70 80\] 85(選出最小值 80) \[1 10 31 33 37 48 60 70 80 85\](選出最小值 85) #### 範例實作 ```c++= // selectedIdx -> 從未排序陣列中選出最小的值 // selectedIdx(待排序陣列, 未排序的頭, 未排序的尾, 比較函數) int selectedIdx(int* arr, int from, int to, int(*compar)(int, int)) { int selected = from; for (int i=from+1; i<to; i++) { if (compar(arr[i], arr[selected]) < 0) selected = i; } return selected; } // Selection Sort(待排序陣列, 陣列長度, 比較函數) void selectionSort(int* arr, int len, int(*compar)(int, int)) { for (int i=0; i<len; i++) { int selected = selectedIdx(arr, i, len, compar); if (selected != i) SWAP(arr[i], arr[selected]); } } ``` <br> - #### Insertion Sort 從**後端未排序部分取得[最前端的值]**,**插入前端已排序部分的[適當位置]** > 有一序列排序前為 77、67、8、6、84、55、85、43、67 > >\[77 92\] 67 8 6 84 55 85 43 67(將 77 插入 92 前) \[67 77 92\] 8 6 84 55 85 43 67(將 67 插入 77 前) \[8 67 77 92\] 6 84 55 85 43 67(將 8 插入 67 前) \[6 8 67 77 92\] 84 55 85 43 67(將 6 插入 8 前) \[6 8 67 77 84 92\] 55 85 43 67(將 84 插入 92 前) \[6 8 55 67 77 84 92\] 85 43 67(將 55 插入 67 前) \[6 8 55 67 77 84 85 92\] 43 67(將 85 插入 92 前) \[6 8 43 55 67 77 84 85 92\] 67(將 43 插入 55 前) \[6 8 43 55 67 67 77 84 85 92\](將 67 插入 77 前) 如果資料量很小或是有大智的排序,插入排序經常作為快速排序(Quick Sort)的補充。 ##### 範例實作 ```c++= // insertedIdx -> 找出未排序的第一個元素位置應該放入的位置 // insertedIdx(待排序陣列, 未排序的第一個元素, 比較函數) int insertedIdx(int* arr, int eleIdx, int(*compar)(int, int)) { for (int i=0; i<eleIdx; i++) { if (compar(arr[i], arr[eleIdx]) > 0) break; } return i; } // insert -> 將比插入值大的元素,往後移一個位置,再把插入值放到適當位置 // insert(待排序陣列, 插入值當前的位置, 插入值應該放的位置) void insert(int* arr, int eleIdx, int inserted) { int ele = arr[eleIdx]; for (int i=eleIdx; i>inserted; i--) { arr[i] = arr[i-1]; } arr[inserted] = ele; } // Insertion Sort(待排序陣列, 陣列長度, 比較函數) void insertionSort(int* arr, int len, int(*compar)(int, int)) { for (int i=0; i<len; i++) { int inserted = insertedIdx(arr, i, compar); if (inserted != i) insert(arr, i, inserted); } } ``` <br> - #### Bubble Sort 若是由小到大,**以[比較相鄰元素]的方式,將較大元素交換至右端**,因此**較大元素會不斷往右移動**,直到適當位置。 > 有一序列排序前為 95、27、90、49、80、58、6、9、18、50 > > 27 90 49 80 58 6 9 18 50 \[95\](95 浮出) 27 49 80 58 6 9 18 50 \[90 95\](90 浮出) 27 49 58 6 9 18 50 \[80 90 95\](80 浮出) 27 49 6 9 18 50 \[58 80 90 95\](58 浮出) 27 6 9 18 49 \[50 58 80 90 95\](50 浮出) 6 9 18 27 \[49 50 58 80 90 95\](49 浮出) 6 9 18 \[27 49 50 58 80 90 95\](27 浮出) 6 9 \[18 27 49 50 58 80 90 95\](18 浮出) 6 \[9 18 27 49 50 58 80 90 95\](9 浮出) \[6 9 18 27 49 50 58 80 90 95\](6 浮出) #### 範例實作 ```c++= // bubbleTo -> 從未排序陣列中,找出最大元素,將其浮到最右方 // bubbleTo(待排序陣列, 未排序的最右方位置, 比較函數) void bubbleTo(int* arr, int to, int(*compar)(int, int)) { for (int i=0; i<to-1; i++) { if (compar(arr[i+1], arr[i]) < 0) SWAP(arr[i+1], arr[i]); } } // Bubble Sort(待排序陣列, 陣列長度, 比較函數) void bubbleSort(int* arr, int len, int(*compar)(int, int)) { for (int i=0; i<len; i++) bubbleTo(arr, len-i, compar); } ``` <br> #### 程式執行 需合併前面程式碼 ```c++= #include <bits/stdc++.h> #define LEN 8 void print(int* arr, int len) { for (int i=0; i<len; i++) cout << arr[i] << " "; cout << endl; } int main() { int number[LEN] = {10, 9, 1, 2, 5, 3, 8, 7}; selectionSort(number, LEN, ascending); print(number, LEN); insertionSort(number, LEN, descending); print(number, LEN); bubbleSort(number, LEN, ascending); print(number, LEN); return 0; } ``` #### 執行結果 : ```c++= 1 2 3 5 7 8 9 10 10 9 8 7 5 3 2 1 1 2 3 5 7 8 9 10 ```