【從蛋殼到滿天飛】JAVA 數據結構解析和算法實現,所有文章大概的內容以下:
Arrays(數組)、Stacks(棧)、Queues(隊列)、LinkedList(鏈表)、Recursion(遞歸思想)、BinarySearchTree(二分搜索樹)、Set(集合)、Map(映射)、Heap(堆)、PriorityQueue(優先隊列)、SegmentTree(線段樹)、Trie(字典樹)、UnionFind(並查集)、AVLTree(AVL 平衡樹)、RedBlackTree(紅黑平衡樹)、HashTable(哈希表)html
源代碼有三個:ES6(單個單個的 class 類型的 js 文件) | JS + HTML(一個 js 配合一個 html)| JAVA (一個一個的工程)java
所有源代碼已上傳 github,點擊我吧,光看文章可以掌握兩成,動手敲代碼、動腦思考、畫圖才能夠掌握八成。node
本文章適合 對數據結構想了解而且感興趣的人羣,文章風格一如既往如此,就以爲手機上看起來比較方便,這樣顯得比較有條理,整理這些筆記加源碼,時間跨度也算將近半年時間了,但願對想學習數據結構的人或者正在學習數據結構的人羣有幫助。git
已經從底層完整實現了一個單鏈表這樣的數據結構,github
遞歸不光用於樹這樣的結構中還能夠用在鏈表這樣的結構中算法
經過 leetcode 上與鏈表相關的問題來學習遞歸數組
203 號問題:刪除鏈表中的元素數據結構
(class: ListNode, class: Solution)
ListNodeapp
// Definition for singly-linked list. public class ListNode { int val; ListNode next; ListNode(int x) { val = x; } }
Solutionide
/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; }
*/ class Solution { public static ListNode removeElements(ListNode head, int val) { // 第一種方式 作對頭節點作特殊處理 // while (head != null && head.val == val) { // ListNode delNode = head; // head = head.next; // delNode.next = null; // } // // if (head == null) { // return head; // } // // ListNode prev = head; // while (prev.next != null) { // if (prev.next.val == val) { // ListNode delNode = prev.next; // prev.next = delNode.next; // delNode.next = null; // } else { // prev = prev.next; // } // } // // return head; // 第二種方式:添加虛擬頭節點 ListNode dummyHead = new ListNode(0); dummyHead.next = head; ListNode node = dummyHead; while (node.next != null) { if (node.next.val == val) { node.next = node.next.next; } else { node = node.next; } } return dummyHead.next; } }
## 自定義 203 號問題測試用例 1. 將數組轉換爲鏈表 1. 鏈表的第一個節點就是你建立的這個節點, 2. 這個節點的值也是數組的第一個值, 3. 其它的節點經過第一個節點的 next 進行關聯, 4. 對應的值爲數組中的每一個值。 ### 代碼示例 1. `(class: ListNode, class: Solution,` 1. `class: Solution2, class: Main)` 2. ListNode
// Definition for singly-linked list. public class ListNode { int val; ListNode next; ListNode(int x) { val = x; } // 構造函數,傳入一個數組,轉換成一個鏈表。 public ListNode (int [] arr) { if (arr == null || arr.length == 0) { throw new IllegalArgumentException("arr can not be empty."); } this.val = arr[0]; ListNode cur = this; for (int i = 1; i < arr.length; i++) { cur.next = new ListNode(arr[i]); cur = cur.next; } } @Override public String toString () { StringBuilder sb = new StringBuilder(); sb.append("LinkedList:"); sb.append("[ "); for (ListNode cur = this; cur.next != null; cur = cur.next) { sb.append(cur.val); sb.append("->"); } sb.append("NULL"); sb.append(" ]"); return sb.toString(); } }
3. Solution
/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; } * } */ class Solution { public ListNode removeElements(ListNode head, int val) { // 第一種方式 作對頭節點作特殊處理 while (head != null && head.val == val) { head = head.next; } if (head == null) { return head; } ListNode prev = head; while (prev.next != null) { if (prev.next.val == val) { prev.next = prev.next.next; } else { prev = prev.next; } } return head; } }
4. Solution2
/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; } * } */ class Solution2 { public ListNode removeElements(ListNode head, int val) { // 第一種方式 作對頭節點作特殊處理 // while (head != null && head.val == val) { // ListNode delNode = head; // head = head.next; // delNode.next = null; // } // // if (head == null) { // return head; // } // // ListNode prev = head; // while (prev.next != null) { // if (prev.next.val == val) { // ListNode delNode = prev.next; // prev.next = delNode.next; // delNode.next = null; // } else { // prev = prev.next; // } // } // // return head; // 第二種方式:添加虛擬頭節點 ListNode dummyHead = new ListNode(0); dummyHead.next = head; ListNode node = dummyHead; while (node.next != null) { if (node.next.val == val) { node.next = node.next.next; } else { node = node.next; } } return dummyHead.next; } }
5. Main
public class Main { public static void main(String[] args) { int[] arr = new int[20]; for (int i = 0; i < 10 ; i++) { arr[i] = i; arr[10 + i] = 5; } ListNode node1 = new ListNode(arr); System.out.println(node1); Solution s1 = new Solution(); s1.removeElements(node1, 5); System.out.println(node1); ListNode node2 = new ListNode(arr); System.out.println(node2); Solution2 s2 = new Solution2(); s2.removeElements(node2, 5); System.out.println(node2); } }
## 鏈表和遞歸 1. 遞歸是極其重要的一種組建邏輯的機制 1. 尤爲是在計算機的世界中 2. 對於高級的排序算法一般都須要使用遞歸, 3. 對於計算機科學來講熟練的掌握遞歸是極其重要的, 4. 甚至能夠說初級水平與高級水平之間差距的關鍵分水嶺。 2. 遞歸能夠作 1. 分形圖形的繪製, 2. 各類高級排序算法的可視化。 ### 遞歸 1. 本質上就是將原來的問題,轉化爲更小的一樣的一個問題 1. 也就是將問題規模逐漸縮小,小到必定程度, 2. 一般在遞歸中都是小到不能再小的時候就能夠很容易的解決問題, 3. 這樣一來整個問題就能夠獲得解決。 ### 遞歸的使用例子 1. 數組求和:求數組中 n 個元素的和 1. `Sum(arr[0...n-1]) = arr[0] + Sum(arr[1...n-1])` 第一次, 2. `Sum(arr[1...n-1]) = arr[1] + Sum(arr[2...n-1])` 第二次, 3. `...` 若干次 4. `Sum(arr[n-1...n-1]) = arr[n-1] + Sum(arr[])` 最後一次` 5. 每一次都是將同一個問題慢慢更小化從而演化成最基本的問題, 6. 最基本的問題解決了,而後根據以前的一些邏輯,從而解決原問題, 7. 就像一個大問題,若是他能夠分解成無數個性質相同的小問題, 8. 而後對這些小問題以遞歸的形式進行處理,這樣一來就容易多了。 9. 代碼中`if (arr.length == cur) {return 0;}`就是解決最基本的問題 10. 代碼中`arr[cur] + sum(arr, cur+1);`就是在構建原問題的答案 11. 代碼中`sum(arr, cur+1);`就是不斷的將原問題轉化爲更小的問題, 12. 不少個小問題的解加到一塊兒,就構建出原問題的答案了。
// 計算 arr[cur...n] 這個區間內的全部數字之和。 public static int sum (int[] arr, int cur) { // 這個地方就是求解最基本問題 // 一般對於遞歸算法來講, // 最基本的問題就是極其簡單的, // 基本上都是這樣的一種形式 // 由於最基本的問題太過於平凡了 // 一眼就看出來那個答案是多少了 if (arr.length == cur) { return 0; } // 這部分就是遞歸算法最核心的部分 // 把原問題轉化成更小的問題的一個過程 // 這個過程是難的, // 這個轉化爲更小的問題並不簡單的求一個更小的問題的答案就行了, // 而是要根據這個更小的問題的答案構建出原問題的答案, // 這個構建 在這裏就是一個加法的過程。 return arr[cur] + sum(arr, cur+1); }
2. 對於一個複雜的遞歸算法來講, 1. 這個邏輯多是很是複雜的, 2. 雖說轉化問題是一個難點, 3. 實際上也並非那麼難, 4. 只不過不少在寫邏輯的時候把本身給繞暈了, 5. 函數本身調用本身,沒必要過於糾結這裏面程序執行的機制。 3. 寫遞歸函數的時候必定要注重遞歸函數自己的語意, 1. 例如上面的 sum 函數, 2. 它就是用來計算一個數組從索引 cur 開始 3. 到最後一個位置之間全部的數字之和, 4. 這個就是此遞歸函數的`「宏觀」語意`, 5. 在這樣的一個語意下, 6. 在涉及轉換邏輯的時候你要拋棄掉這是一個遞歸算法的這樣的想法, 7. 遞歸函數自己它也是一個函數,每一個函數其實就是完成一個功能。 8. 在函數 A 中調函數 B 你並不會暈,可是在函數 A 裏調用函數 A, 9. 也就是在遞歸函數中你可能就會暈, 10. 其實這和函數 A 裏調用函數 B 在本質上並無區別。 4. 你能夠當這是一個子邏輯,這個子邏輯裏面須要傳兩個參數, 1. 它作的事情就是求數組裏的從索引 cur 開始 2. 到最後一個位置之間全部的數字之和, 3. 你就僅僅是在用這個函數,至於或者函數是否是當前函數不必在乎, 4. 其實就是這麼簡單的一件事情。 5. 在寫遞歸算法的時候, 1. 有些時候不須要你特別微觀的 2. 陷進遞歸調用裏的去糾結這個遞歸是怎麼樣調用的, 3. 其實你能夠直接把這個遞歸函數當成一個子函數, 4. 這個子函數能夠完成特定的功能, 5. 而你要乾的事情就是利用這個子函數來構建你本身的邏輯, 6. 來解決上層的這一個問題就行了。 6. 注意遞歸函數的`宏觀語意` 1. 把你要調用的遞歸函數看成是一個子函數或者子邏輯或者子過程, 2. 而後去想這個子過程若是去幫你解決如今的這個問題就 ok, 3. 要想熟練的掌握就須要大量的練習。 ### 鏈表的自然遞歸結構性質 1. 代碼示例
public class RecursionSolution { public ListNode removeElements(ListNode head, int val) { if (head == null) { return null; } // 寫法一 // ListNode node = removeElements(head.next, val); // if (head.val == val) { // return node; // } else { // head.next = node; // return head; // } // 寫法二 // if (head.val == val) { // head = removeElements(head.next, val); // } else { // head.next = removeElements(head.next, val); // } // return head; // 寫法三 head.next = removeElements(head.next, val); if (head.val == val) { return head.next; } else { return head; } } public static void main(String[] args) { int[] arr = {1, 10, 7, 5, 1, 9, 1, 5, 1, 5, 6, 7}; ListNode node = new ListNode(arr); System.out.println(node); RecursionSolution s = new RecursionSolution(); s.removeElements(node, 1); System.out.println(node); } }
## 遞歸運行的機制及遞歸的「微觀」解讀 1. 雖然寫遞歸函數與遞歸算法時要注意遞歸算法的宏觀語意, 1. 可是站在一個更高的層面去思考這個函數它自己的功能做用是什麼, 2. 也許能夠幫助你更好的完成整個遞歸的邏輯, 3. 可是從另一方面遞歸函數想,遞歸函數究竟是怎樣運轉的, 4. 它內部的機制是怎樣的,因此遞歸的運行機制也是須要了解的。 2. 經過數組求和與刪除鏈表節點的遞歸實現來具體的觀察遞歸的運行機制 1. 棧的應用中說過程序調用的系統棧, 2. 子函數調用的位置會壓入到一個系統棧, 3. 當子函數調用完成的時候, 4. 程序就會從系統棧中找到上次父函數調用子函數的這個位置, 5. 而後再父函數中的子函數這個位置後續繼續執行, 6. 其實遞歸調用和子函數子過程的調用沒有區別, 7. 只不過遞歸調用的函數仍是這個函數自己而已 8. (本身調用本身,根據某些條件終止調用本身)。 3. 遞歸的調用和子過程的調用是沒有區別的 1. 就像程序調用的系統棧同樣。 2. 父函數調用子函數,子函數執行完畢以後, 3. 就會返回到上一層,也就是繼續執行父函數, 4. 這個執行並非從新執行, 5. 而是從以前那個子函數調用時的位置繼續往下執行, 6. 若是子函數有返回值,那麼接收一下也能夠, 7. 接收完了以後繼續往下執行。
A0(); function A0 () { ... A1(); ... } function A1 () { ... A2(); ... } function A2 () { ... ... ... }
4. 遞歸的調用時有代價的 1. 函數調用 + 系統棧空間。 2. 好比系統棧中會記錄這些函數調用的信息, 3. 如當前這個函數執行到哪兒了, 4. 當前的局部變量都是怎樣的一個狀態, 5. 而後給它壓入系統棧。, 6. 包括函數調用的自己在計算機的底層找到這個新的函數所在的位置, 7. 這些都是有必定的時間消耗的。 8. 遞歸調用的過程是很消耗系統棧的空間的, 9. 若是遞歸函數中沒有處理那個最基本的狀況, 10. 那麼遞歸將一直執行下去,不會正常終止, 11. 最終終止的結果確定就是異常報錯, 12. 由於系統棧佔滿了,空間不足。 13. 在線性的調用過程當中, 14. 當你遞歸的次數達到十萬百萬級別的話, 15. 系統佔仍是會被佔滿,由於存儲了太多函數調用的狀態信息。 5. 用遞歸書寫邏輯實際上是更簡單的 1. 這一點在線性的結構中看不出來, 2. 這是由於線性的結構很是好想, 3. 直接寫循環就能解決全部的線性問題, 4. 可是一旦進入非線性的結構 如樹、圖, 5. 不少問題其實使用遞歸的方式解決將更加的簡單。 ### 數組求和的遞歸解析 1. 原函數
// 計算 arr[cur...n] 這個區間內的全部數字之和。 public static int sum (int[] arr, int cur) { // 這個地方就是求解最基本問題 // 一般對於遞歸算法來講, // 最基本的問題就是極其簡單的, // 基本上都是這樣的一種形式 // 由於最基本的問題太過於平凡了 // 一眼就看出來那個答案是多少了 if (arr.length == cur) { return 0; } // 這部分就是遞歸算法最核心的部分 // 把原問題轉化成更小的問題的一個過程 // 這個過程是難的, // 這個轉化爲更小的問題並不簡單的求一個更小的問題的答案就行了, // 而是要根據這個更小的問題的答案構建出原問題的答案, // 這個構建 在這裏就是一個加法的過程。 return arr[cur] + sum(arr, cur+1); }
2. 解析原函數 1. 遞歸函數的調用,本質就是就是函數調用, 2. 只不過調用的函數就是本身而已。
// 計算 arr[cur...n] 這個區間內的全部數字之和。 public static int sum (int[] arr, int cur) { if (arr.length == cur) { return 0; } int temp = sum(arr, cur + 1); int result = arr[cur] + temp; return result; }
3. 原函數解析 2 1. 在 sum 函數中調用到了 sum 函數, 2. 其實是在一個新的 sum 函數中 調用邏輯, 3. 原來的 sum 函數中全部的變量保持不變, 4. 等新的 sum 函數執行完了邏輯, 5. 還會回到原來的 sum 函數中繼續執行餘下的邏輯。
// 計算 arr[cur...n] 這個區間內的全部數字之和。 // 代號 001 // 使用 arr = [6, 10] // 調用 sum(arr, 0) int sum (int[] arr, int cur) { if (cur == n) return 0; // n 爲數組的長度:2 int temp = sum(arr, cur + 1); // cur 爲 0 int result = arr[cur] + temp; return result; } // 代號 002 // 到了 上面的sum(arr, cur + 1)時 // 實際 調用了 sum(arr, 1) int sum (int[] arr, int cur) { if (cur == n) return 0; // n 爲數組的長度:2 int temp = sum(arr, cur + 1); // cur 爲 1 int result = arr[cur] + temp; return result; } // 代號 003 // 到了 上面的sum(arr, cur + 1)時 // 實際 調用了 sum(arr, 2) int sum (int[] arr, int cur) { // n 爲數組的長度:2,cur 也爲:2 // 因此sum函數到這裏就終止了 if (cur == n) return 0; int temp = sum(arr, cur + 1); // cur 爲 2 int result = arr[cur] + temp; return result; } // 上面的代號003的sum函數執行完畢後 返回 0。 // // 那麼 上面的代號002的sum函數中 // int temp = sum(arr, cur + 1),temp獲取到的值 就爲 0, // 而後繼續執行代號002的sum函數裏temp獲取值時中斷的位置 下面的邏輯, // 執行到了int result = arr[cur] + temp, // temp爲 0,cur 爲 1,arr[1] 爲 10,因此result 爲 0 + 10 = 10, // 這樣一來 代號002的sum函數執行完畢了,返回 10。 // // 那麼 代號001的sum函數中 // int temp = sum(arr, cur + 1),temp獲取到的值 就爲 10, // 而後繼續執行代號001的sum函數裏temp獲取值時中斷的位置 下面的邏輯, // 執行到了int result = arr[cur] + temp, // temp爲 10,cur 爲 0,arr[0] 爲 6,因此result 爲 6 + 10 = 16, // 這樣一來 代號001的sum函數執行完畢了,返回 16。 // // 代號001的sum函數沒有被其它代號00x的sum函數調用, // 因此數組求和的最終結果就是 16。
4. 調試遞歸函數的思路 1. 若是對遞歸函數運轉的機制不理解, 2. 不要對着遞歸函數去生看生想, 3. 在不少時候你確定會越想越亂, 4. 不如你用一個很是小的測試數據集直接扔進這個函數中, 5. 你可使用紙筆畫或者使用 IDE 提供的 debug 工具, 6. 一步一步的去看這個程序在每一步執行後計算的結果是什麼, 7. 一般使用這種方式可以幫助你更加清晰的理解程序的運轉邏輯, 8. 計算機是一門工科,和工程相關的科學, 9. 工程相關的科學雖然也注重理論它背後也有理論支撐, 10. 可是從工程的角度入手來實踐是很是很是重要的, 11. 不少時候你若是想理解它背後的理論, 12. 可能更好的方式不是去想這個理論, 13. 而是實際的去實踐一下看看這個過程究竟是怎麼回事兒。 ### 刪除鏈表節點的遞歸解析 1. 原函數
public ListNode removeElements(ListNode head, int val) { if (head == null) { return null; } head.next = removeElements(head.next, val); if (head.val == val) { return head.next; } else { return head; } }
2. 解析原函數 1. 遞歸調用的時候就是子過程的調用, 2. 一步一步的向下調用,調用完畢以後, 3. 子過程計算出結果後再一步一步的返回給上層的調用, 4. 最終獲得了最終的結果,6->7->8->null 刪除掉 7 以後就是 6->8->null, 5. 節點真正的刪除是發生在步驟 3 中, 6. 在使用解決了一個更小規模的問題相應的解以後, 7. 結合當前的調用,組織邏輯,組織出了當前這個問題的解, 8. 就是這樣的一個過程。
// 操做函數編號 001 ListNode removeElements(ListNode head, int val) { // head:6->7->8->null 步驟1. if (head == null) return null; 步驟2. head.next = removeElements(head.next, val); 步驟3. return head.val == val ? head.next : head; } // 模擬調用,對 6->7->8->null 進行7的刪除 // 調用 removeElments(head, 7); // 執行步驟1,head當前的節點爲6,既然不爲null,因此不返回null, // 繼續執行步驟2,head.next = removeElements(head.next, 7), // 求當前節點後面的一個節點,後面的一個節點目前不知道, // 可是能夠經過removeElements(head.next, 7)這樣的子過程調用求出來, // 此次傳入的是當前節點的next,也就是7的這個節點,7->8->null。 // 操做函數編號 002 ListNode removeElements(ListNode head, int val) { // head:7->8->null 步驟1. if (head == null) return null; 步驟2. head.next = removeElements(head.next, val); 步驟3. return head.val == val ? head.next : head; } // 模擬調用,對 7->8->null 進行7的刪除 // 調用 removeElements(head.next, 7); // head.next 會被賦值給 函數中的局部變量 head, // 也就是調用時被轉換爲 removeElements(head, 7); // 執行步驟1,head當前的節點爲7,不爲null,因此也不會返回null, // 繼續執行步驟2,head.next = removeElements(head.next, 7), // 求當前節點後面的一個節點,後面的一個節點目前不知道, // 可是能夠經過removeElements(head.next, 7)這樣的子過程調用求出來, // 此次傳入的也是當前節點的next,也就是8的這個節點,8->null。 // 操做函數編號 003 ListNode removeElements(ListNode head, int val) { // head:8->null 步驟1. if (head == null) return null; 步驟2. head.next = removeElements(head.next, val); 步驟3. return head.val == val ? head.next : head; } // 模擬調用,對 8->null 進行7的刪除 // 調用 removeElements(head.next, 7); // head.next 會被賦值給 函數中的局部變量 head, // 也就是調用時被轉換爲 removeElements(head, 7); // 執行步驟1,head當前的節點爲7,不爲null,因此也不會返回null, // 繼續執行步驟2,head.next = removeElements(head.next, 7), // 求當前節點後面的一個節點,後面的一個節點目前不知道, // 可是能夠經過removeElements(head.next, 7)這樣的子過程調用求出來, // 此次傳入的也是當前節點的next,也就是null的這個節點,null。 // 操做函數編號 004 ListNode removeElements(ListNode head, int val) { // head:null 步驟1. if (head == null) return null; 步驟2. head.next = removeElements(head.next, val); 步驟3. return head.val == val ? head.next : head; } // 模擬調用,對 null 進行7的刪除 // 調用 removeElements(head.next, 7); // head.next 會被賦值給 函數中的局部變量 head, // 也就是調用時被轉換爲 removeElements(head, 7); // 執行步驟1,head當前的節點爲null,直接返回null,不繼續向下執行了。 // 操做函數編號 003 ListNode removeElements(ListNode head, int val) { // head:8->null 步驟1. if (head == null) return null; 步驟2. head.next = removeElements(head.next, val); 步驟3. return head.val == val ? head.next : head; } // 這時候回到操做函數編號 004的上一層中來, // 操做函數編號 003 調用到了步驟2,而且head.next接收到的返回值爲null, // 繼續操做函數編號 003 的步驟3,判斷當前節點的val是否爲7, // 很明顯函數編號003裏的當前節點的val爲8,因此返回當前的節點 8->null。 // 操做函數編號 002 ListNode removeElements(ListNode head, int val) { // head:7->8->null 步驟1. if (head == null) return null; 步驟2. head.next = removeElements(head.next, val); 步驟3. return head.val == val ? head.next : head; } // 這時候回到操做函數編號 003的上一層中來, // 操做函數編號 002 調用到了步驟2,head.next接收到的返回值爲節點 8->null, // 繼續操做函數編號 002 的步驟3,判斷當前節點的val是否爲7, // 此時函數編號 002 的當前節點的val爲7,因此返回就是當前節點的next 8->null, // 也就是說不返回當前的節點 head:7->8->null ,改返回當前節點的下一個節點, // 這樣一來就至關於刪除了當前這個節點,改讓父節點的next指向當前節點的next。 // 操做函數編號 001 ListNode removeElements(ListNode head, int val) { // head:6->7->8->null 步驟1. if (head == null) return null; 步驟2. head.next = removeElements(head.next, val); 步驟3. return head.val == val ? head.next : head; } // 這時候回到操做函數編號 002的上一層中來, // 操做函數編號 001 調用到了步驟2,head.next接收到的返回值爲節點 8->null, // 繼續操做函數編號 001 的步驟3,判斷當前節點的val是否爲7, // 函數編號 001 中當前節點的val爲6,因此返回當前的節點 head:6->8->null, // 以前當前節點 爲head:6->7->8->null,因爲head.next在步驟2時發生了改變, // 原來老的head.next(head:7->8->null) 從鏈表中剔除了, // 因此當前節點 爲head:6->8->null。 // 鏈表中包含節點的val爲7的節點都被剔除,操做完畢。
## 遞歸算法的調試 1. 能夠以動畫的方式展現遞歸函數底層的運行機理, 1. 一幀一幀的動畫來展現遞歸函數的具體執行過程。 2. 可是在實際調試遞歸函數時 1. 很難畫出那麼詳細的動畫,相對也比較費時間, 2. 可是也能夠拿一張 A4 的白紙仔細的一下, 3. 例如 畫一個比較小的測試用例的執行過程是怎樣的, 4. 這樣對於理解你的程序或者找出你的程序中有錯誤, 5. 是很是有幫助的 3. 調試方法 1. 靠打印輸出, 2. 徹底可使用打印輸出的方式 3. 清楚的看出程序在執行過程當中是怎樣一步一步得到最終結果。 4. 單步跟蹤, 5. 也就是每個 IDE 中自帶的調試功能。 6. 視狀況來定。 4. 對於遞歸函數來講有一個很是重要的概念 1. 遞歸的深度, 2. 每個函數在本身的內部都會去調用了一下本身, 3. 那麼就表明每次調用本身時,整個遞歸的深度就多了 1, 4. 因此在具體的輸出可視化這個遞歸函數時, 5. 這個遞歸深度是能夠幫助你理解這個遞歸過程的一個變量, 6. 在原遞歸函數中新增一個參數`depth`, 7. 根據這個變量生成遞歸深度字符串`--`, 8. `--`相同則表明同一遞歸深度。 5. 不少時候要想真正理解一個算法或者理解一個函數 1. 其實並無什麼捷徑,確定是要費一些勁, 2. 若是你不想在紙上畫出來的話, 3. 那麼你就要用代碼畫出來, 4. 也就是要在代碼上添加不少的輔助代碼, 5. 這就是平時去理解程序或作練習時不要去犯懶, 6. 可能只要寫 4 行代碼就能解決問題, 7. 可是這背後頗有多是你寫了 8. 幾十行甚至上百行的代碼 9. 最終終於透徹的理解了這個程序, 10. 而後才能瀟灑的用四行代碼來解決這個問題。 6. 不停的練習如何寫一個遞歸的函數,才能理解理解這個遞歸的過程。 ### 代碼示例 `(class: ListNode, class: RecursionSolution)` 1. ListNode
// Definition for singly-linked list. public class ListNode { int val; ListNode next; ListNode(int x) { val = x; } // 構造函數,傳入一個數組,轉換成一個鏈表。 public ListNode (int [] arr) { if (arr == null || arr.length == 0) { throw new IllegalArgumentException("arr can not be empty."); } this.val = arr[0]; ListNode cur = this; for (int i = 1; i < arr.length; i++) { cur.next = new ListNode(arr[i]); cur = cur.next; } } @Override public String toString () { StringBuilder sb = new StringBuilder(); sb.append("[ "); for (ListNode cur = this; cur != null; cur = cur.next) { sb.append(cur.val); sb.append("->"); } sb.append("NULL"); sb.append(" ]"); return sb.toString(); } }
2. RecursionSolution
public class RecursionSolution { public ListNode removeElements(ListNode head, int val, int depth) { // if (head == null) return null; // head.next = removeElements(head.next, val, depth); // return head.val == val ? head.next : head; String depathString = generateDepathString(depth); System.out.print(depathString); System.out.println("Call: remove " + val + " in " + head); if (head == null) { System.out.print(depathString); System.out.println("Return :" + head); return null; } ListNode result = removeElements(head.next, val, depth + 1); System.out.print(depathString); System.out.println("After: remove " + val + " :" + result); ListNode ret; if (head.val == val) { ret = result; } else { head.next = result; ret = head; } System.out.print(depathString); System.out.println("Return :" + ret); return ret; } private String generateDepathString (int depath) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < depath; i++) { sb.append("-- "); // -- 表示深度,--相同則表明在同一遞歸深度 } return sb.toString(); } public static void main(String[] args) { int[] arr = {6, 7, 8}; // for (int i = 0; i < 10 ; i++) { // arr[i] = i; // arr[10 + i] = 5; // } ListNode node = new ListNode(arr); System.out.println(node); RecursionSolution rs = new RecursionSolution(); rs.removeElements(node, 7, 0); System.out.println(node); } }
## 更多與鏈表相關的問題 1. 關於遞歸 1. 鏈表有自然遞歸性質結構 2. 幾乎和鏈表相關的全部操做 1. 均可以使用遞歸的形式完成 3. 練習時能夠對鏈表的增刪改查進行遞歸實現 1. 以前鏈表的增刪改查使用了循環的方式進行了實現, 2. 如今能夠對鏈表的增刪改爲進行遞歸的方式實現, 3. 這個練習是很是有意義的,可以幫助你更好的理解遞歸。 4. 雖然實際使用鏈表時是不須要使用遞歸的, 5. 可是進行一下這種練習可讓你更好的對遞歸有更深入的理解。 4. 其它和鏈表相關的題目能夠到 leetcode 上查找 1. 鏈表:`https://leetcode-cn.com/tag/linked-list/`, 2. 不要完美主義,不要想着把這些問題一會兒所有作出來, 3. 根據本身的實際狀況來制定計劃,在本身處於什麼樣的水平的時候, 4. 完成怎樣的問題,可是這些問題一直都會在 leetcode 上, 5. 慢慢來,一點一點的實現。 5. 關於鏈表的技術研究,由斯坦福提出的問題研究 1. 文章地址:`https://max.book118.com/html/2017/0902/131359982.shtm`, 2. 都看懂了,那你就徹底的懂了鏈表。 6. 非線性數據結構 1. 如大名鼎鼎的二分搜索樹 2. 二分搜索樹也是一個動態的數據結構 3. 也是靠節點掛接起來的, 4. 只不過那些節點沒有排成一根線, 5. 而是排成了一顆樹, 6. 不是每個節點有指向下一個節點的 next, 7. 而是由指向左子樹和右子樹的兩個根節點而已。 ### 雙鏈表 1. 對於隊列來講須要對鏈表的兩端進行操做 1. 在兩端進行操做的時候就遇到了問題, 2. 在尾端刪除元素, 3. 即便在尾端有 tail 的變量指向鏈表的結尾, 4. 它依然是一個`O(n)`複雜度的,。 5. 對於這個問題其實有一個解決方案, 6. 這個問題的解決方案就是雙鏈表, 2. 所謂的雙鏈表就是在鏈表中每個節點包含兩個指針 1. 指針就表明着引用, 2. 有一個變量 next 指向這個節點的下一個節點, 3. 有一個變量 prev 指向這個節點的前一個節點, 4. 對於雙鏈表來講, 5. 你有了 tail 這個節點以後, 6. 刪除尾部的節點就很是簡單, 7. 並且這個操做會是`O(1)`級別的, 8. 可是代價是每個節點從原來只有一個指針變爲兩個指針, 9. 那麼維護起來就會相對複雜一些。
class Node { E e; Node next, prev; }
### 循環鏈表 1. 對於循環鏈表來講,也可使用雙鏈表的思路來實現, 1. 不過須要設立一個虛擬的頭節點, 2. 從而讓整個鏈表造成了一個環, 3. 這裏面最重要的是 尾節點不指向空而是指向虛擬頭節點, 4. 能夠很方便的判斷某一個節點的下一個節點是不是虛擬頭節點 5. 來肯定這個節點是否是尾節點, 6. 循環鏈表的好處是 進一步的把不少操做進行了統一, 7. 好比說在鏈表結尾添加元素只須要在 dummyHead 的前面 8. 添加要一個給元素,它就等因而在整個鏈表的結尾添加了一個元素, 9. 事實上循環鏈表自己是很是有用的, 10. Java 中的 LinkedList 類底層的實現,本質就是一個循環鏈表, 11. 更準確一些,就是循環雙向鏈表,由於每一個節點是有兩個指針的。 ### 鏈表也是使用數組來實現 1. 由於鏈表的 next 只是指向下一個元素, 2. 在數組中每個位置存儲的不只僅是有這個元素, 3. 再多存儲一個指向下一個元素的索引, 4. 這個鏈表中每個節點是這樣的, 5. Node 類中有一個 int 的變量 next 指向下一個元素的索引, 6. 在有一些狀況下,好比你明確的知道你要處理的元素有多少個, 7. 這種時候使用這種數組鏈表有多是更加方便的。
class Node {
E e; int next;
}