學習算法和刷題的思路指南

一、數據結構的存儲方式

數據結構的存儲方式只有兩種:數組(順序存儲)和鏈表(鏈式存儲)。

數組由於是緊湊連續存儲,可以隨機訪問,通過索引快速找到對應元素,而且相對節約存儲空間。但正因爲連續存儲,內存空間必須一次性分配夠,所以說數組如果要擴容,需要重新分配一塊更大的空間,再把數據全部複製過去,時間複雜度 O(N);而且你如果想在數組中間進行插入和刪除,每次必須搬移後面的所有數據以保持連續,時間複雜度 O(N)。

鏈表因爲元素不連續,而是靠指針指向下一個元素的位置,所以不存在數組的擴容問題;如果知道某一元素的前驅和後驅,操作指針即可刪除該元素或者插入新元素,時間複雜度 O(1)。但是正因爲存儲空間不連續,你無法根據一個索引算出對應元素的地址,所以不能隨機訪問;而且由於每個元素必須存儲指向前後元素位置的指針,會消耗相對更多的儲存空間。

二、數據結構的基本操作

基本操作:增刪改查

數據結構種類很多,但它們存在的目的都是在不同的應用場景,儘可能高效地增刪查改。

如何遍歷 + 訪問?我們仍然從最高層來看,各種數據結構的遍歷 + 訪問無非兩種形式:線性的和非線性的。

線性就是 for/while 迭代爲代表,非線性就是遞歸爲代表。再具體一步,無非以下幾種框架:

數組遍歷框架,典型的線性迭代結構:

void traverse(int[] arr) {
    for (int i = 0; i < arr.length; i++) {
        // 迭代訪問 arr[i]
    }
}

鏈表遍歷框架,兼具迭代和遞歸結構:

class ListNode {
    int val;
    ListNode next;
}

void traverse(ListNode head) {
    for (ListNode p = head; p != null; p = p.next) {
        // 迭代訪問 p.val
    }
}

void traverse(ListNode head) {
    // 遞歸訪問 head.val
    traverse(head.next)
}

二叉樹遍歷框架,典型的非線性遞歸遍歷結構:

/* 基本的二叉樹節點 */
class TreeNode {
    int val;
    TreeNode left, right;
}

void traverse(TreeNode root) {
    traverse(root.left)
    traverse(root.right)
}

你看二叉樹的遞歸遍歷方式和鏈表的遞歸遍歷方式,相似不?再看看二叉樹結構和單鏈表結構,相似不?如果再多幾條叉,N 叉樹你會不會遍歷?

二叉樹框架可以擴展爲 N 叉樹的遍歷框架:

/* 基本的 N 叉樹節點 */
class TreeNode {
    int val;
    TreeNode[] children;
}

void traverse(TreeNode root) {
    for (TreeNode child : root.children)
        traverse(child)
}

N 叉樹的遍歷又可以擴展爲圖的遍歷,因爲圖就是好幾 N 叉棵樹的結合體。

你說圖是可能出現環的?這個很好辦,用個布爾數組 visited 做標記就行了,這裏就不寫代碼了。

所謂框架,就是套路。不管增刪查改,這些代碼都是永遠無法脫離的結構,你可以把這個結構作爲大綱,根據具體問題在框架上添加代碼就行了,下面會具體舉例。

三、算法刷題指南

首先要明確的是,數據結構是工具,算法是通過合適的工具解決特定問題的方法。也就是說,學習算法之前,最起碼得了解那些常用的數據結構,瞭解它們的特性和缺陷。

那麼該如何在 LeetCode 刷題呢?

先刷二叉樹,先刷二叉樹,先刷二叉樹!

爲什麼要先刷二叉樹呢,因爲二叉樹是最容易培養框架思維的,而且大部分算法技巧,本質上都是樹的遍歷問題。

刷二叉樹看到題目沒思路?根據很多讀者的問題,其實大家不是沒思路,只是沒有理解我們說的「框架」是什麼。

不要小看這幾行破代碼,幾乎所有二叉樹的題目都是一套這個框架就出來了。

void traverse(TreeNode root) {
    // 前序遍歷
    traverse(root.left)
    // 中序遍歷
    traverse(root.right)
    // 後序遍歷
}

比如說我隨便拿幾道題的解法出來,不用管具體的代碼邏輯,只要看看框架在其中是如何發揮作用的就行。

LeetCode 124 題,難度 Hard,讓你求二叉樹中最大路徑和,主要代碼如下:

int ans = INT_MIN;
int oneSideMax(TreeNode* root) {
    if (root == nullptr) return 0;
    int left = max(0, oneSideMax(root->left));
    int right = max(0, oneSideMax(root->right));
    ans = max(ans, left + right + root->val);
    return max(left, right) + root->val;
}

先感受下結構,我讀到這裏沒看這個題,爲的是完整的完成我的計劃。對這個題真感興趣,可以先打個標記,之後利用其他空餘時間看,或者用你玩手機、刷抖音、打遊戲的時間看。

你看,這就是個後序遍歷嘛。

LeetCode 105 題,難度 Medium,讓你根據前序遍歷和中序遍歷的結果還原一棵二叉樹,很經典的問題吧,主要代碼如下:

TreeNode buildTree(int[] preorder, int preStart, int preEnd, 
    int[] inorder, int inStart, int inEnd, Map<Integer, Integer> inMap) {

    if(preStart > preEnd || inStart > inEnd) return null;

    TreeNode root = new TreeNode(preorder[preStart]);
    int inRoot = inMap.get(root.val);
    int numsLeft = inRoot - inStart;

    root.left = buildTree(preorder, preStart + 1, preStart + numsLeft, 
                          inorder, inStart, inRoot - 1, inMap);
    root.right = buildTree(preorder, preStart + numsLeft + 1, preEnd, 
                          inorder, inRoot + 1, inEnd, inMap);
    return root;
}

不要看這個函數的參數很多,只是爲了控制數組索引而已,本質上該算法也就是一個前序遍歷。

LeetCode 99 題,難度 Hard,恢復一棵 BST,主要代碼如下:

void traverse(TreeNode* node) {
    if (!node) return;
    traverse(node->left);
    if (node->val < prev->val) {
        s = (s == NULL) ? prev : s;
        t = node;
    }
    prev = node;
    traverse(node->right);
}

這不就是個中序遍歷嘛,對於一棵 BST 中序遍歷意味着什麼,應該不需要解釋了吧。

你看,Hard 難度的題目不過如此,而且還這麼有規律可循,只要把框架寫出來,然後往相應的位置加東西就行了,這不就是思路嗎。

對於一個理解二叉樹的人來說,刷一道二叉樹的題目花不了多長時間。那麼如果你對刷題無從下手或者有畏懼心理,不妨從二叉樹下手,前 10 道也許有點難受;結合框架再做 20 道,也許你就有點自己的理解了;刷完整個專題,再去做什麼回溯動規分治專題,你就會發現只要涉及遞歸的問題,都是樹的問題。

再舉例吧,說幾道我們之前文章寫過的問題。

動態規劃詳解說過湊零錢問題,暴力解法就是遍歷一棵 N 叉樹:

def coinChange(coins: List[int], amount: int):

    def dp(n):
        if n == 0: return 0
        if n < 0: return -1

        res = float('INF')
        for coin in coins:
            subproblem = dp(n - coin)
            # 子問題無解,跳過
            if subproblem == -1: continue
            res = min(res, 1 + subproblem)
        return res if res != float('INF') else -1

    return dp(amount)

這麼多代碼看不懂咋辦?直接提取出框架,就能看出核心思路了:

# 不過是一個 N 叉樹的遍歷問題而已
def dp(n):
    for coin in coins:
        dp(n - coin)

其實很多動態規劃問題就是在遍歷一棵樹,你如果對樹的遍歷操作爛熟於心,起碼知道怎麼把思路轉化成代碼,也知道如何提取別人解法的核心思路。

再看看回溯算法,前文回溯算法詳解乾脆直接說了,回溯算法就是個 N 叉樹的前後序遍歷問題,沒有例外。

比如 N 皇后問題吧,主要代碼如下:

void backtrack(int[] nums, LinkedList<Integer> track) {
    if (track.size() == nums.length) {
        res.add(new LinkedList(track));
        return;
    }

    for (int i = 0; i < nums.length; i++) {
        if (track.contains(nums[i]))
            continue;
        track.add(nums[i]);
        // 進入下一層決策樹
        backtrack(nums, track);
        track.removeLast();
    }

/* 提取出 N 叉樹遍歷框架 */
void backtrack(int[] nums, LinkedList<Integer> track) {
    for (int i = 0; i < nums.length; i++) {
        backtrack(nums, track);
}

N 叉樹的遍歷框架,找出來了把~你說,樹這種結構重不重要?

綜上,對於畏懼算法的朋友來說,可以先刷樹的相關題目,試着從框架上看問題,而不要糾結於細節問題。

糾結細節問題,就比如糾結 i 到底應該加到 n 還是加到 n - 1,這個數組的大小到底應該開 n 還是 n + 1 ?

從框架上看問題,就是像我們這樣基於框架進行抽取和擴展,既可以在看別人解法時快速理解核心邏輯,也有助於找到我們自己寫解法時的思路方向。

當然,如果細節出錯,你得不到正確的答案,但是只要有框架,你再錯也錯不到哪去,因爲你的方向是對的。

但是,你要是心中沒有框架,那麼你根本無法解題,給了你答案,你也不會發現這就是個樹的遍歷問題。

這種思維是很重要的,動態規劃詳解中總結的找狀態轉移方程的幾步流程,有時候按照流程寫出解法,說實話我自己都不知道爲啥是對的,反正它就是對了。。。

這就是框架的力量,能夠保證你在快睡着的時候,依然能寫出正確的程序;就算你啥都不會,都能比別人高一個級別。

四、總結幾句

數據結構的基本存儲方式就是鏈式和順序兩種,基本操作就是增刪查改,遍歷方式無非迭代和遞歸。

刷算法題建議從「樹」分類開始刷,結合框架思維,把這幾十道題刷完,對於樹結構的理解應該就到位了。這時候去看回溯、動規、分治等算法專題,對思路的理解可能會更加深刻一些。

參考閱讀

學習算法和刷題的思路指南

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章