Oh! You closed up the window, so you cannot see raining

[Algo] 2-5. 動態規劃 Dynamic Programming

一、動態規劃的思考藝術 動態規劃其實就是一種暴力枚舉的優化,在暴力枚舉的過程中有著大量的重複,藉由「備忘錄(memoization)」的方式做到剪枝(pruning)來達到優化的一種演算法。 舉例來說: Leetcode 62. Unique Paths 機器人由左上走到右下角星星有幾種走法,其中機器人只能選擇往右走或往下走。 試想機器人從 (1,1) 走到 (m,n) 的不同路徑中,可見有大量的重複,比如過程中有一點 (i,j),其 (1,1) 走到 (i,j) 有 k 條不同路徑,麼那對於任何一條固定 (i,j) 到 (m,n) 的路徑,都需走 k 遍來模擬。 但其實我們不必關心具體的走法,我們只關心狀態,也就是走法的數目。 同理,我們若知道 (i,j) 到 (m,n) 共有 t 條不同的路徑,那麼 (1,1) -> (i,j) -> (m,n) 的不同路徑總數就是 k*s。 我們知道最左邊那欄與最上面那列都只有可能有一種路徑可以走,又每一格的路徑來自於上方與左方的和: sum of (i,j) = sum of (i-1,j) + sum of (i,j-1) \(\begin{array}{|c|c|c|c|c|c|c|}\hline \text{1}&\text{1}&\text{1}&\text{1}&\text{1}&\text{1}&\text{1}\\\hline \text{1}&\text{2}&\text{3}&\text{4}&\text{5}&\text{6}&\text{7}\\\hline \text{1}&\text{3}&\text{6}&\text{10}&\text{15}&\text{21}&\text{28}\\\hline \end{array}\) 寫成程式碼就是 int uniquePaths(int m, int n) { vector<vector<int>> dp(m+1, vector<int>(n+1,0)); for (int i = 1; i <= m; i++) // 將第一列填成 1 dp[i][1] = 1; for (int j = 1; j <= n; j++) // 將第一欄填成 1 dp[1][j] = 1; for (int i = 2; i <= m; i++) { // 將剩下的格子填完 for (int j = 2; j <= n; j++) { dp[i][j] = dp[i-1][j] + dp[i][j-1]; } } return dp[m][n]; } 注意填格子的順序是有一定的限制的,必須要確保相關聯的子問題已經處理過。 動態規劃 由上例我們可以發現,原本的問題可以拆解成更小的問題(從 (1,1)->(m,n) 變成從 (1,1)->(i,j) 和從 (i,j)->(m,n))。 我們令 f(i,j) 表示從 (1,1)->(i,j) 的不同路徑數,則我們可以得到轉移方程式 f(i,j)=f(i-1,j)+f(i,j-1)。 我們發現,想求出 f(i,j) 只需要知道幾個更小的 f(i',j')。我們將 f(i',j') 稱為子問題。 我們捨棄冗餘的訊息(具體的走法),只記錄對解決問題有幫助的結果。 動態規劃的兩大特點(適用前提) 無後效性 一旦 f(i,j) 確定,就不用關心我們如何計算出 f(i,j) 想要確定 f(i,j),只需要知道 f(i-1,j) 和 f(i,j-1) 的值,而至於它們是如何算出來的,對當前或之後的任何子問題都沒有影響。 過去不依賴未來,未來不影響過去。 最優子結構 f(i,j) 的定義就已經蘊含了最優。 大問題的最優解可以由若干個小問題的最優解推出。(max, min, sum…) DP 能適用於:能將大問題拆成若干小問題,滿足無後效性、最優子結構性質。 以下介紹幾種刷題會遇到的動態規劃套路: 二、動態規劃框架 1. 定序列型 ...

November 15, 2022 · 3 分鐘 · Rain Hu
Oh! You closed up the window, so you cannot see raining

[Algo] 0-1. 複雜度分析 Algorithmic complexity / Big-O / Asymptotic analysis

一、Big O 表示法 Big O 的數學定義: \(\boxed{O(g(n)) = \lbrace{f(n):存在正常量\space c\space 和\space n_0,使得對所有\space n\ge n_0,有\space 0 \le f(n) \le cg(n)\rbrace}}\) 我們常用的 big O 表示法中的 \(O\) 其實代表了一個函數的集合,比方說 \(O(n^2)\) 代表著一個由 \(g(n) = n^2\) 派生出來的一個函數集合;我們說一個演算法的時間複雜度為 \(O(n^2)\),意思就是描述該演算法的複雜度函數屬於這個函數集合之中。 分析複雜度時,常用的兩個特性: 只保留增長速率最快的項,其它省略 \(\boxed{O(2n+100) = O(n)}\) \(\boxed{O(2^{n+1}) = O(2^n)}\) \(\boxed{O(m+3n+99) = O(m+n)}\) \(\boxed{O(n^3+999\times n^2+999\times n) = O(n^3)}\) Big O 記號表示複雜度的「上限」 換句話說,只要給出的是一個上限,用 Big O 表示法都是正確的。 但在習慣上,我們特別取最緊臨的上限。但若複雜度會跟算法的輸入數據有關,沒辦法提前給出一個特別精確的時間複雜度時,擴大時間複雜度的上限就變得有意義了。 例如湊零錢問題中,金額 amount 的值為 n,coins 列表中的個數為 k,則這棵遞迴樹就是 K 叉樹。而節點的數量與樹的結構有關,而我們無法提前知道樹的結構,所以我們按照最壞情形來處理,高度為 n 的一棵滿 k 叉樹,其節點數為 \(\frac{k^n-1}{k-1}\),用 big O 表示就是 \(O(k^n)\)。 二、主定理(Master Theorem) 有時候時間複雜度的判斷沒那麼容易,主定理是一個數學推導的方法:可以參考網站https://brilliant.org/wiki/master-theorem/ 回到目錄:[Algo] 演算法筆記 接著閱讀:[Algo] 0-2. 演算法思維

October 6, 2022 · 1 分鐘 · Rain Hu
Oh! You closed up the window, so you cannot see raining

[Algo] 0-4. 二叉樹(Binary Tree)

一、二叉樹的思維模式 二叉樹的解題模式大致分為兩類: 是否可以通過遍歷一遍得解 是否可以定義一個遞迴函數,通過分治法推導出原問題的答案? [LeetCode. 104] Maximum Depth of Binary Tree(Easy) 以此題為例,可以遍歷完整個樹,並比較當下的樹的深度,得以求解。 int depth = 0; int maxDepth(TreeNode* root){ traverse(root, 1); return depth; } void traverse(TreeNode* root, int currDepth){ if (!root) return; traverse(root->left, currDepth+1); depth = max(depth, currDepth); traverse(root->right, currDepth+1); } 若想辦法定義一個遞迴函數,通過分治法推導出原問題,換言之,就是先處理更小的樹,再藉由小的樹處理大的樹: int maxDepth(TreeNode* root) { if (root == NULL) return 0; return 1 + max(maxDepth(root->left), maxDepth(root->right)); } 事實上,兩個思維模式便對應著兩種演算法:回溯法(back tracking)與動態規劃(dynamic programming) 二、前序、中序、後序 無論使用哪種思維模式(遍歷或找出遞迴函數),都要思考單獨抽出一個節點,它需要在何時(前、中、後序)做哪些事情,其它的節點交由遞迴函數去執行相同的操作。 以下我們以 quick sort 與 merge sort 為例,同樣是分治法,看看在數組上有什麼同樣的思維模式。 quick sort 從 sort() 函式便可見類似於前序的結構。 void sort(vector<int>& nums, int left, int right){ if (left >= right) return; // 終止條件 int mid = partition(nums, left, right); // 做什麼事(pre-order) sort(nums, left, mid-1); // 左子樹 sort(nums, mid+1, right); // 右子樹 } int partition(vector<int>& nums, int left, int right){ int pivot = right; while (left < right){ while (nums[left] < nums[pivot]) left++; while (nums[right] > nums[pivot]) right--; if (left < right) swap(nums[left], nums[right]); } if (left == right && nums[left] > nums[pivot] || nums[right] < nums[pivot]){ swap(nums[left], pivot); return left; } return pivot; } merge sort 從 sort() 函式便可見類似於後序的結構。 void sort(vector<int>& nums, int left, int right){ if (right <= left) return; // 終止條件 int mid = left + (right-left)/2; sort(nums, left, mid); // 左子樹 sort(nums, mid+1, right); // 右子樹 merge(nums, left, mid, right); // 做什麼事(post-order) } void merge(vector<int>& nums, int left, int mid, int right){ vector<int> vec; int i = left, j = mid+1; while (i <= mid && j <= right){ int x = nums[i] < nums[j] ? nums[i++] : nums[j++]; vec.push_back(x); } while (i <= mid) vec.push_back(nums[i++]); while (j <= right) vec.push_back(nums[j++]); for (int i = left; i <= right; i++) nums[i] = vec[i-left]; } 換言之,以上就是一個遍歷全部節點的函式,所以本質上數組、鏈表、二叉樹都是在做同樣的事。 數組 void traverse(vector<int> nums, int i){ if (i == nums.size()) return; // pre-order traverse(nums, i+1); // post-order } 鏈表 void traverse(ListNode* head){ if (!head) return; // pre-order traverse(head->next); // post-order } 二叉樹 void traverse(TreeNode* root){ if (!root) return; // pre-order traverse(root->left); // in-order traverse(root->right); // post-order } 三、層序遍歷(level-order) Level-order 對應於 BFS(Breadth-First Search),完下當下的層才會進入到下一層。 void traverse(TreeNode* root){ queue<TreeNode*> q; q.push(root); while (!q.empty()){ int sz = q.size(); while (sz--){ TreeNode* curr = q.front(); q.pop(); // operation if (curr->left) q.push(curr->left); if (curr->right) q. push(curr->right); } } } 回到目錄:[Algo] 演算法筆記 想要複習:[Algo] 0-3. 鏈表(Linked List)

October 6, 2022 · 2 分鐘 · Rain Hu
Oh! You closed up the window, so you cannot see raining

[Algo] 0-3. 鏈表(Linked List)

一、鏈表的基本結構 鏈表是由節點和指針構成的數據結構,每個節點存有一個值,和一個指向下一個節點的指針。不同於數組,鏈表並不能隨機訪問,必須透過指針找到該節點才能獲取其值;同理在未遍歷到鏈表結尾時,我們也無法知道鏈表長度,除非依賴其它數據結構儲存長度。 LeetCode 中默認的鏈表: struct ListNode { int val; ListNode *next; ListNode(int x) : val(x), next(nullptr) {} }; 二、鏈表的基本操作 在開始演算法實踐前,先來練習一下鏈表的 CRUD 吧! 1. 查(Read) 由於鏈表並非在儲存格中連續分布,所以無法用索引進行隨機訪問,所以我們必須逐個訪問,直到到達我們想要的元素。 藉由指針每次指向當前節點的 next,移動 n 次到達 index 為 n 的節點。 int at(ListNode* head, int n){ // index 為 n ListNode* curr = head; while (n--){ // 移動 n 次 curr = curr->next; } return curr->val; } 2. 改(Update) 改的步驟,只是將查完的元素予以賦值。 void update(ListNode* head, int n, int val){ ListNode* curr = head; while (n--){ curr = curr->next; } curr->val = val; // 查完後賦值 } 3. 增(create) 如果要新增節點,則必須找到要插入位置的前一個節點。 ListNode* insert(ListNode* head, int n, int val){ if (!head) return NULL; // 處理當鏈表為空的狀態 if (n == 0){ // 處理當插入位置為 0 時的特例 ListNode* newHead = new ListNode(val, head); head = newHead; return head; } ListNode* curr = head; while (curr && --n){ // 移動到插入位置的前一位 curr = curr->next; } ListNode* tmp = curr->next; // 預先存下來插入位置的後一位 curr->next = new ListNode(val); // 插入元素 curr->next->next = tmp; // 將元素的下一位指派給存下來的後一位 return head; } 上述的寫法很直觀,但需要處例首位的特例,不夠漂亮,這時我們常會用到 DUMMY HEAD 的手法,即在鏈表最開頭先創一個假的節點,最後再返回該節點的下一位。 ListNode* insert(ListNode* head, int n, int val){ if (!head) return NULL; ListNode* dummy = new ListNode(-1, head); // 創建一個 dummy head ListNode* curr = dummy; while (curr && n--){ // 注意為 n-- curr = curr->next; } ListNode* tmp = curr->next; curr->next = new ListNode(val); curr->next->next = tmp; return dummy->next; // 注意為返回 dummy 的下一位 } 前面兩解都是在可以取得前一位,且不更改節點的值的前提下的解法。如果只能取得當下的節點,則可以使用增+更的手法。 void insert(ListNode* node, int val){ int tmp = node->val; node->val = val; node->next = new ListNode(tmp, node->next); } 4. 減(delete) 與增類似,取得前一位再利用更改指針的方式達到刪除。 ListNode* erase(ListNode* head, int n, int val){ if (!head) return NULL; ListNode* dummy = new ListNode(-1, head); ListNode* curr = dummy; while (curr && n--){ curr = curr->next; } curr->next = curr->next->next; // 將前一位的後一位指給後一位 return dummy->next; } 只能取得當下節點的作法: [LeetCode. 237] Delete Node in a Linked List(Medium) void insert(ListNode* node, int val){ node->val = node->next->val; // 將當前的值賦值成下一位的值 node->next = node->next->next; // 將下一個節點刪除 } 三、鏈表的進階操作 1. 刪值 如何刪除表中第一個出現指定值的節點,同樣找到該值的前一個節點,再使用刪除的手法即可: void remove(ListNode* head, int target){ ListNode* prev = NULL; ListNode* curr = head; while (curr && curr->val != target){ prev = curr; curr = curr->next; } if (!prev) // 處理例外 head = head->next; else prev->next = prev->next->next; } 2. 建表 如何藉由一個數組建立一鏈表,可以藉由前面使用的 dummy head 的手法: ListNode* build(vector<int> nums){ ListNode* dummy = new ListNode(-1); ListNode* prev = dummy; ListNode* curr = NULL; for (int i = 0; i < nums.size(); i++){ curr = new ListNode(nums[i]); prev->next = curr; prev = curr; curr = curr->next; } ListNode* head = dummy->next; delete(dummy); return head; } 3. 鏈表的後序遍歷 前文說過,鏈表也可以做前序與後序的遍歷,在此我們再複習一次。 void traverse(ListNode* head){ // pre-order traverse(head->next); // post-order } 如果我們想移除鏈表中所有值等於 target 的節點,用迭代的作法為: void removeAll(ListNode* head, int target){ ListNode* dummy = new ListNode(-1, head); ListNode* curr = dummy; while (curr && curr->next){ if (curr->next->val == target){ // 當前節點的下一位符合 target 則刪除它 curr->next = curr->next->next; } else { curr = curr->next; // 否則則繼續後下遍歷 } } } 那如果用遞迴的寫法呢: void removeAll(ListNode* head, int target){ if (!head) return; // 假如鏈表為空,則退出函式 while (head && head->val == target){ if (head->next){ head->val = head->next->next; head->next = head->next->next; // 刪除 } else { head = NULL; // 當最後一個元素需移除時 } } removeAll(head->next, target) // 前序遍歷 } 後序跟前序有個很維妙的差別在於:後序遍歷可以將值傳回給前一個元素! 試試看用後序回傳值的特徵來實作這個函式 void removeAll(ListNode* head, int target){ int tmp; if (head->next) tmp = removeAll(head->next, target) // recursion if (tmp == target) // 後序遍歷可以取得下一位的回傳的值 head->next = head->next->next; // 有了需要刪除的節點的前一位,要刪除就容易啦! return head->val // 傳回當前節點的值 } 四、秀一波的操作 1. 刪值 用兩個節點去做到鏈表刪除的操作,還是有一點點不夠美,試試看下面這個 pointer to pointer 的解法吧! 改自文章你所不知道的 C 語言: linked list 和非連續記憶體 void remove(ListNode* head, int target){ ListNode** curr = &head; // 將指向指針的 curr 指向 head while ((*curr)->val != target) curr = &(*curr)->next; if (!(*curr)) return; // 避免掉指向 NULL *curr = (*curr)->next } 2. 建表 學會上面這個 pointer to pointer 的作法,不如試試看來用這個方法來建表! ListNode* build(vector<int> nums){ ListNode* head = new ListNode(nums[0]); ListNode** curr = &head; for (int i = 0; i < nums.size(); i++){ (*curr)->next = new ListNode(nums[i]); curr = &(*curr)->next; } } 五、鏈表的演算法 1. 反轉鏈表 [LeetCode. 206] Reverse Linked List(Easy) 藉由剛剛學習到鏈表的操作,用迭代的方式來解題吧。 考慮到一個反轉鏈表的連續操作,我們需要有三個節點 prev, curr, next。 ListNode* reverse(ListNode* head){ ListNode* prev = NULL; ListNode* curr = head; ListNode* next = NULL; while (curr){ next = curr->next; // 先記住下一個位置 curr->next = prev; // 將指針指向前一位,以達成反轉的目的 prev = curr; // 往前移動 curr = next; // 往前移垂 } return prev; } 那如果用遞迴的方式寫呢? ListNode* reverse(ListNode* head){ if (!head || head->next) return head; // 處理終止條件 ListNode last = reverse(head->next); // post-order traversal:回傳已排序好的子鏈表,並傳回最後一項 head->next->next = head; head->next = NULL; return last; } [[Followup] 反轉前 N 個節點 反轉鏈表的前 N 個節點:用前面的函式稍作修改 ListNode* successor = NULL; ListNode* reverseN(ListNode* head, int n){ if (n == 1){ // 只反轉 1 個節點相當於沒有反轉,故轉回自己 successor = head->next; // 記錄反轉後的鏈表要接到哪裡->剩餘鏈表的頭 return head; } ListNode last = reverseN(head->next, n-1); head->next->next = head; head->next = successor; // 最後將鏈表的尾巴接到剩餘鏈表的頭 return last; } [LeetCode. 92] Reverse Linked List II(Medium) 反轉第 m 到第 n 個節點中間的節點 前進 m - 1 次就相當於就相當於反轉前 (n-m-1) 個節點,就可以用 reverseN 解了。 ListNode* reverseBetween(ListNode* head, int m, int n){ if (m == 1){ return reverseN(head, n); // 與 LeetCode.92 一樣 } head->next = reverseBetween(head->next, m-1, n-1); // 前進到 base case return head; } [LeetCode. 25] Reverse Nodes in k-Group (Hard) 結合前面的經驗,注意遞迴該返回的值是什麼。 注意結尾若節數小於 k 則不則 reverse。 ListNode* reverseKGroup(ListNode* head, int k) { ListNode* curr = head; int cnt = 0; while (curr && cnt < k){ curr = curr->next; cnt++; } if (cnt == k){ // 當節數小於 k 時,不做 reverse curr = reverseKGroup(curr, k); // 傳回的是 reverse 完的鏈表的 head,故需把 reverse 完的尾與之相接 while (cnt-- > 0){ ListNode* next = head->next; head->next = curr; curr = head; head = next; } return curr; // 當節數等於 k 時回傳的是尾巴 } return head; // 注意節數小於 k 時仍回傳 head } 2. 環型鏈表(龜兔賽跑-快慢指針) [LeetCode. 141] Linked List Cycle(Easy) 快慢指針是雙指針的一種應用,利用兩個指針移動的速度不同來達到目的。最經典的題型就是找尋鏈表是否含有環。 要檢查鏈表是否有環,可以使用找尋圖(graph)中是否有環的技巧,並利用 visited 來檢查是否有拜訪過,但下面快慢指針的技巧可以不用額外使用空間,使空間複雜度降到 \(O(1)\)。 bool hasCycle(ListNode *head) { ListNode* fast = head; ListNode* slow = head; while (fast && fast->next){ // 確保快指針與他的下一位都不是 NULL fast = fast->next->next; // 快指針走兩步 slow = slow->next; // 慢指針走一步 if (fast == slow) return true; // 若兩者相撞,則必有環 } return false; } [LeetCode. 142] Linked List Cycle II(Medium) 此題是要找尋鏈表中若有環,則相交點是哪一點: 因為快指針走的距離是慢指針 k 的兩倍,令相遇點距相交點距離為 m 圓環的長度為 L: \(\text{L + m + k = 2 * k}\) \(\text{L = k - m}\) 故起點到相交點的長度 \(\text{k - m}\) 與相遇點到相交點的長度 \(\text{k - m}\) 相同。 ListNode *detectCycle(ListNode *head) { ListNode* fast = head; ListNode* slow = head; while (fast && fast->next){ fast = fast->next->next; slow = slow->next; if (fast == slow) break; // 若有環則退出 } if (!fast || !fast->next) return NULL; // 若快指針已經走到底表示沒有環 fast = head; // 讓其中一個指針從頭開始走,並一同樣的速度走 while (fast != slow){ // 相遇點即為相交點 fast = fast->next; slow = slow->next; } return fast; } [LeetCode. 876] Middle of the Linked List(Easy) 這題當然可以先遍歷一遍取得鏈表長度後,再重新以長度計量,走一半的長度來得到答案,但很顯然不夠漂亮,用快慢指針,令快指針比慢指針移動速度快兩倍,當快指針走完時,慢指針即指向中點。以此類推可求1/3的節點、2/5的節點等。 ListNode* middleNode(ListNode* head) { ListNode* fast = head; ListNode* slow = head; while (fast && fast->next){ fast = fast->next->next; slow = slow->next; } return slow; } 3. 雙指針(前後指針) [LeetCode. 19] Remove Nth Node From End of List(Medium) 這題也是簡單的雙指針問題,當前指針先走 n 步,兩指針以同樣速度往前走(即前後指針始終保持 n 的距離),則前指針走完時,後指針指向倒數第 k 個節點。 ListNode* removeNthFromEnd(ListNode* head, int n) { // 注意以下我們要刪除第 n 個節點,故我們需找第 n-1 個節點,為避免刪除第一個節點的例子,我們引入 dummy ListNode* dummy = new ListNode(-1, head); ListNode* slow = dummy; ListNode* fast = dummy; while (fast && n--){ // 前指針先行走 n 個節點 fast = fast->next; } while (fast->next){ // 保持等速 slow = slow->next; fast = fast->next; } slow->next = slow->next->next; // 刪除第 n 個節點 return dummy->next; } [LeetCode. 160] Intersection of Two Linked Lists(Easy) 找兩條鏈表的相交點,這題也可以用雙指針的方式解,當 A 鏈懷走完鏈表立即讓它接回 B 鏈表,B 鏈表亦如是,則相遇點則會是相交點,因為此時它們各別則的距離是都是 A 鏈表的長度加上 B 鏈表的長度,但要注意要記錄是否已經接過一遍,如果沒有相交點,又無限接下去,則程式永遠不會停止。 ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) { ListNode* a = headA; ListNode* b = headB; bool flagA = true; // 用來標記是否已接過另一鏈表 bool flagB = true; // 用來標記是否已接過另一鏈表 while (a && b){ if (a == b) return a; // 相遇表示相交點 a = a->next; b = b->next; if (!a && flagA){ a = headB; flagA = false; // 已接過另一鏈表 } if (!b && flagB){ b = headA; flagB = false; // 已接過另一鏈表 } } return NULL; } [LeetCode. 86] Partition List(Medium) 具體作法可將鏈表一分為二,待兩條鏈表皆完成後再頭尾相接。 ListNode* partition(ListNode* head, int x) { ListNode* dummy1 = new ListNode(-1); ListNode* dummy2 = new ListNode(-1); ListNode* curr1 = dummy1; ListNode* curr2 = dummy2; while (head){ if (head->val < x){ curr1->next = head; curr1 = curr1->next; } else { curr2->next = head; curr2 = curr2->next; } head = head->next; } curr1->next = dummy2->next; curr2->next = NULL; return dummy1->next; } [LeetCode. 21] Merge Two Sorted Lists(Easy) 簡單的 if-else,搭配 dummy 的做法即可解題。 ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) { ListNode* dummy = new ListNode(-1); ListNode* curr = dummy; while (list1 && list2){ if (list1->val <= list2->val){ curr->next = list1; list1 = list1->next; } else { curr->next = list2; list2 = list2->next; } curr = curr->next; } curr->next = list1 ? list1 : list2; return dummy->next; } 4. 優先佇列 [LeetCode. 23] Merge k Sorted Lists(Hard) 這一題有點 tricky,我們可以用到優先佇列,由於每次比較只會比較鏈表的頭節表,故我們連續將鏈表推至 min heap 上,並每次把 min heap 頂端的節點接到新的鏈表後,再把 min heap 上的鏈表拿去頭後,再丟回優先佇列中,至到鏈表走完,即完成。 ListNode* mergeKLists(vector<ListNode*>& lists) { auto cmp = [](ListNode* a, ListNode* b){return a->val > b->val;}; priority_queue<ListNode*, vector<ListNode*>, decltype(cmp)> pq(cmp); ListNode* dummy = new ListNode(-1); ListNode* curr = dummy; for (ListNode* node : lists){ if (node) pq.push(node); } while (!pq.empty()){ ListNode* node = pq.top(); pq.pop(); curr->next = node; curr = curr->next; node = node->next; if (node) pq.push(node); } return dummy->next; } 回到目錄:[Algo] 演算法筆記 想要複習:[Algo] 0-2. 演算法思維 接著閱讀:[Algo] 0-4. 二元樹(Binary Tree)

October 6, 2022 · 7 分鐘 · Rain Hu
Oh! You closed up the window, so you cannot see raining

[Algo] 0-2. 算法思維

一、資料結構概要 資料結構的存儲方式大體上只分為兩種: Array、Linked List。 雖說資料結構有 disjoint matrix, queue, stack, tree, graph 等等,但它們都可以視為 Array 與 Linked List 的上層結構,可以看成是以 Array 或 Linked List 為基底上的操作,只是 API 不同而已。 Array:由於是緊湊連續儲存的,可以隨機訪問,通過 index 快速找到對應元素,且相對節約空間。但也因必須一次性分配儲存空間,所以 array 如果需要擴充容量,就必須再重新分配一塊更大的空間,再把數孛複製過去,其時間複雜度為 \(O(N)\);在 array 中間進行 delete 與 insert,必須搬移後面所有數據以保持連續,故時間複雜度也為\(O(N)\)。 Linked List:因為元素不連續,而是靠指針指向下一個元素的位置,所以不存在 array 的擴充容量的問題,如果知道某一元素的前一個節點與後一個節點,操作指針即可刪除該元素或者插入新元素,時間複雜度為\(O(1)\)。但正因為儲存空間不連續,無法根擇 index 算出對應元素的地址,所以不能隨機訪問;而且由於每個元素必須額外儲存前後元素位置的指針,相對較耗空間。 在 C、C++ 語言中,指針(pointer)的存在使得其能更直接對儲存空間的位址做操作,所以在處理 C 語言時,要額外了解指針的運作方式。 二、資料結構的基本操作 資料結構的基本操作不外乎: 遍歷(traverse)、增減查改(CRUD, create, read, update, delete) Array:數組的遍歷框架 -> 典型的線性迭代結構: void traverse(vector<int> arr){ for (int i = 0; i < arr.size(); i++){ // iteration } } ListNode:鏈表的遍歷框架 -> 兼具迭代與遞迴 class ListNode { public: int val; ListNode* next; }; void traverse(ListNode* head){ for (ListNode curr = head; curr != NULL; curr = curr->next){ // iteration } } void traverse(ListNode* head){ // recursion traverse(head->next); } 由上述兩種基底可推廣至各種結構: 二叉樹(Binary Tree) class TreeNode { public: int val; TreeNode* left, right; }; void traverse(TreeNode* root){ traverse(root->left); traverse(root->right); } N 叉樹(N-ary Tree) class TreeNode { public: int val; vector<TreeNode*> children } void traverse(TreeNode* root){ for (TreeNode* child : root->children){ traverse(child); } } 圖(graph):可視為 N 叉樹的結合體,再利用 visited 處理環(circle) class Node { public: int val; vector<Node*> neighbors; } unordered_set<Node*> visited; // 處理已拜訪過的節點 void traverse(Node* node){ if (visited) return; // 檢查是否拜訪過了 visited.insert(node) // 將現在拜訪的節點標記成已拜訪的節點 for (TreeNode* neighbor : neighbors){ traverse(neighbor) } } 三、前序(pre-order)、中序(in-order)、後序(post-order) 在開始複雜的演算法前,重點在於熟悉如何處理不同的結構,並採用基礎的解題策略。 前序、中序、後序指的是遍歷一棵二元樹的方式。 基本框架 void traverse(TreeNode* root){ // pre-order traverse(root->left); // in-order traverse(root->right); // post-order } 鏈表其實也可以有前序、後序的關係: void traverse(ListNode* curr){ // pre-order traverse(curr->next); // post-order } 回到目錄:[Algo] 演算法筆記 想要複習:[Algo] 0-1. 複雜度分析 Algorithmic complexity / Big-O / Asymptotic analysis 接著閱讀:[Algo] 0-3. 鏈表(LinkedList)

October 6, 2022 · 2 分鐘 · Rain Hu