-----------
兩年前剛開這個公眾號的時候,我寫了一篇 學習資料結構和演算法的框架思維,現在已經 5w 多閱讀了,這對於一篇純技術文來說是很牛逼的資料。
這兩年在我自己不斷刷題,思考和寫公眾號的過程中,我對演算法的理解也是在逐漸加深,所以今天再寫一篇,把我這兩年的經驗和思考濃縮成 4000 字,分享給大家。
本文主要有兩部分,一是談我對演算法本質的理解,二是概括各種常用的演算法。全文沒有什麼硬核的程式碼,都是我的經驗之談,也許沒有多麼高大上,但肯定能幫你少走彎路,更透徹地理解和掌握演算法。
另外,本文包含大量歷史文章連結,結合本文閱讀歷史文章也許可以更快培養出學習演算法的框架思維和知識體系。
演算法的本質
如果要讓我一句話總結,我想說演算法的本質就是「窮舉」。
這麼說肯定有人要反駁了,真的所有演算法問題的本質都是窮舉嗎?沒有一個例外嗎?
例外肯定是有的,比如前幾天我還發了 一行程式碼就能解決的演算法題,這些題目都是通過觀察,發現規律,然後找到最優解法。
再比如數學相關的演算法,很多都是數學推論,然後用程式設計的形式表現出來了,所以它本質是數學,不是計算機演算法。
從計算機演算法的角度,結合我們大多數人的需求,這種秀智商的純技巧題目絕對佔少數,雖然很容易讓人大呼精妙,但不能提煉出思考演算法題的通用思維,真正通用的思維反而大道至簡,就是窮舉。
我記得自己一開始學習演算法的時候,也覺得演算法是一個很高大上的東西,每見到一道題,就想著能不能推匯出一個什麼數學公式,啪的一下就能把答案算出來。
比如你和一個沒學過(計算機)演算法的人說你寫了個計算排列組合的演算法,他大概以為你發明了一個公式,可以直接算出所有排列組合。但實際上呢?沒什麼高大上的公式,前文 回溯演算法秒殺排列組合子集問題 寫了,還是得用回溯演算法暴力窮舉。
對計算機演算法的誤解也許是以前學數學留下的「後遺症」,數學題一般都是你仔細觀察,找幾何關係,列方程,然後算出答案。如果說你需要進行大規模窮舉來尋找答案,那大概率是你的解題思路出問題了。
而計算機解決問題的思維恰恰相反,有沒有什麼數學公式就交給你們人類去推導吧,但如果推導不出來,那就窮舉唄,反正只要複雜度允許,沒有什麼答案是窮舉不出來的。
技術崗筆試面試考的那些演算法題,求個最大值最小值什麼的,你怎麼求?必須得把所有可行解窮舉出來才能找到最值對吧,說白了不就這麼點事兒麼。
「窮舉」具體來說可以分為兩點,看到一道演算法題,可以從這兩個維度去思考:
1、如何窮舉?
2、如何聰明地窮舉?
不同型別的題目,難點是不同的,有的題目難在「如何窮舉」,有的題目難在「如何聰明地窮舉」。
什麼演算法的難點在「如何窮舉」呢?一般是遞迴類問題,最典型的就是動態規劃系列問題。
前文 動態規劃核心套路 闡述了動態規劃系列問題的核心原理,無非就是先寫出暴力窮舉解法(狀態轉移方程),加個備忘錄就成自頂向下的動態規劃解法了,再改一改就成自底向上的迭代解法了,動態規劃的降維打擊 裡也講過如何分析優化動態規劃演算法的空間複雜度。
上述過程就是在不斷優化演算法的時間、空間複雜度,也就是所謂「如何聰明地窮舉」,這些技巧一聽就會了。但很多讀者留言說明白了這些原理,遇到動態規劃題目還是不會做,因為第一步的暴力解法都寫不出來。
這很正常,因為動態規劃型別的題目可以千奇百怪,找狀態轉移方程才是難點,所以才有了 動態規劃設計方法:最長遞增子序列 這篇文章,告訴你遞迴窮舉的核心是數學歸納法,明確函式的定義,然後利用這個定義寫遞迴函式,就可以窮舉出所有可行解。
什麼演算法的難點在「如何聰明地窮舉」呢?一些耳熟能詳的非遞迴演算法技巧,都可以歸在這一類。
比如前文 Union Find 並查集演算法詳解 告訴你一種高效計算連通分量的技巧,理論上說,想判斷兩個節點是否連通,我用 DFS/BFS 暴力搜尋(窮舉)肯定可以做到,但人家 Union Find 演算法硬是用陣列模擬樹結構,給你把連通性相關的操作複雜度給幹到 O(1)
了。
這就屬於聰明地窮舉,你學過就會用,沒學過恐怕很難想出這種思路。
再比如貪心演算法技巧,前文 當老司機學會貪心演算法 就告訴你,所謂貪心演算法就是在題目中發現一些規律(專業點叫貪心選擇性質),使得你不用完整窮舉所有解就可以得出答案。
人家動態規劃好歹是無冗餘地窮舉所有解,然後找一個最值,你貪心演算法可好,都不用窮舉所有解就可以找到答案,所以前文 貪心演算法解決跳躍遊戲 中貪心演算法的效率比動態規劃還高。
再比如大名鼎鼎的 KMP 演算法,你寫個字串暴力匹配演算法很容易,但你發明個 KMP 演算法試試?KMP 演算法的本質是聰明地快取並複用一些資訊,減少了冗餘計算,前文 KMP 字元匹配演算法 就是使用狀態機的思路實現的 KMP 演算法。
下面我概括性地列舉一些常見的演算法技巧,供大家學習參考。
陣列/單連結串列系列演算法
單連結串列常考的技巧就是雙指標,前文 單連結串列六大技巧 全給你總結好了,這些技巧就是會者不難,難者不會。
比如判斷單連結串列是否成環,拍腦袋的暴力解是什麼?就是用一個 HashSet
之類的資料結構來快取走過的節點,遇到重複的就說明有環對吧。但我們用快慢指標可以避免使用額外的空間,這就是聰明地窮舉嘛。
當然,對於找連結串列中點這種問題,使用雙指標技巧只是顯示你學過這個技巧,和遍歷兩次連結串列的常規解法從時間空間複雜度的角度來說都是差不多的。
陣列常用的技巧有很大一部分還是雙指標相關的技巧,說白了是教你如何聰明地進行窮舉。
首先說二分搜尋技巧,可以歸為兩端向中心的雙指標。如果讓你在陣列中搜尋元素,一個 for 迴圈窮舉肯定能搞定對吧,但如果陣列是有序的,二分搜尋不就是一種更聰明的搜尋方式麼。
前文 二分搜尋框架詳解 給你總結了二分搜尋程式碼模板,保證不會出現搜尋邊界的問題。前文 二分搜尋演算法運用 給你總結了二分搜尋相關題目的共性以及如何將二分搜尋思想運用到實際演算法中。
類似的兩端向中心的雙指標技巧還有力扣上的 N 數之和系列問題,前文 一個函式秒殺所有 nSum 問題 講了這些題目的共性,甭管幾數之和,解法肯定要窮舉所有的數字組合,然後看看那個數字組合的和等於目標和嘛。比較聰明的方式是先排序,利用雙指標技巧快速計算結果。
再說說 滑動視窗演算法技巧,典型的快慢雙指標,快慢指標中間就是滑動的「視窗」,主要用於解決子串問題。
文中最小覆蓋子串這道題,讓你尋找包含特定字元的最短子串,常規拍腦袋解法是什麼?那肯定是類似字串暴力匹配演算法,用巢狀 for 迴圈窮舉唄,平方級的複雜度。
而滑動視窗技巧告訴你不用這麼麻煩,可以用快慢指標遍歷一次就求出答案,這就是教你聰明的窮舉技巧。
但是,就好像二分搜尋只能運用在有序陣列上一樣,滑動視窗也是有其限制的,就是你必須明確的知道什麼時候應該擴大視窗,什麼時候該收縮視窗。
比如前文 最大子陣列問題 面對的問題就沒辦法用滑動視窗,因為陣列中的元素存在負數,擴大或縮小視窗並不能保證視窗中的元素之和就會隨著增大和減小,所以無法使用滑動視窗技巧,只能用動態規劃技巧窮舉了。
還有迴文串相關技巧,如果判斷一個串是否是迴文串,使用雙指標從兩端向中心檢查,如果尋找回文子串,就從中心向兩端擴散。前文 最長迴文子串 使用了一種技巧同時處理了迴文串長度為奇數或偶數的情況。
當然,尋找最長迴文子串可以有更精妙的馬拉車演算法(Manacher 演算法),不過,學習這個演算法的價效比不高,沒什麼必要掌握。
如果頻繁地讓你計運算元陣列的和,每次用 for 迴圈去遍歷肯定沒問題,但字首和技巧預計算一個 preSum
陣列,就可以避免迴圈。
類似的,如果頻繁地讓你對子陣列進行增減操作,也可以每次用 for 迴圈去操作,但差分陣列技巧維護一個 diff
陣列,也可以避免迴圈。
陣列連結串列的技巧差不多就這些了,都比較固定,只要你都見過,運用出來的難度不算大,下面來說一說稍微有些難度的演算法。
二叉樹系列演算法
老讀者都知道,二叉樹的重要性我之前說了無數次,因為二叉樹模型幾乎是所有高階演算法的基礎,尤其是那麼多人說對遞迴的理解不到位,更應該好好刷二叉樹相關題目。
我之前說過,二叉樹題目的遞迴解法可以分兩類思路,第一類是遍歷一遍二叉樹得出答案,第二類是通過分解問題計算出答案,這兩類思路分別對應著 回溯演算法核心框架 和 動態規劃核心框架。
什麼叫通過遍歷一遍二叉樹得出答案?
就比如說計算二叉樹最大深度這個問題讓你實現 maxDepth
這個函式,你這樣寫程式碼完全沒問題:
// 記錄最大深度
int res = 0;
int depth = 0;
// 主函式
int maxDepth(TreeNode root) {
traverse(root);
return res;
}
// 二叉樹遍歷框架
void traverse(TreeNode root) {
if (root == null) {
// 到達葉子節點
res = Math.max(res, depth);
return;
}
// 前序遍歷位置
depth++;
traverse(root.left);
traverse(root.right);
// 後序遍歷位置
depth--;
}
這個邏輯就是用 traverse
函式遍歷了一遍二叉樹的所有節點,維護 depth
變數,在葉子節點的時候更新最大深度。
你看這段程式碼,有沒有覺得很熟悉?能不能和回溯演算法的程式碼模板對應上?
不信你照著 回溯演算法核心框架 中全排列問題的程式碼對比下:
// 記錄所有全排列
List<List<Integer>> res = new LinkedList<>();
LinkedList<Integer> track = new LinkedList<>();
/* 主函式,輸入一組不重複的數字,返回它們的全排列 */
List<List<Integer>> permute(int[] nums) {
backtrack(nums);
return res;
}
// 回溯演算法框架
void backtrack(int[] nums) {
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.removeLast();
}
}
前文講回溯演算法的時候就告訴你回溯演算法本質就是遍歷一棵多叉樹,連程式碼實現都如出一轍有沒有?
而且我之前經常說,回溯演算法雖然簡單粗暴效率低,但特別有用,因為如果你對一道題無計可施,回溯演算法起碼能幫你寫一個暴力解撈點分對吧。
那什麼叫通過分解問題計算答案?
同樣是計算二叉樹最大深度這個問題,你也可以寫出下面這樣的解法:
// 定義:輸入根節點,返回這棵二叉樹的最大深度
int maxDepth(TreeNode root) {
if (root == null) {
return 0;
}
// 遞迴計算左右子樹的最大深度
int leftMax = maxDepth(root.left);
int rightMax = maxDepth(root.right);
// 整棵樹的最大深度
int res = Math.max(leftMax, rightMax) + 1;
return res;
}
你看這段程式碼,有沒有覺得很熟悉?有沒有覺得有點動態規劃解法程式碼的形式?
不信你看 動態規劃核心框架 中湊零錢問題的暴力窮舉解法:
// 定義:輸入金額 amount,返回湊出 amount 的最少硬幣個數
int coinChange(int[] coins, int amount) {
// base case
if (amount == 0) return 0;
if (amount < 0) return -1;
int res = Integer.MAX_VALUE;
for (int coin : coins) {
// 遞迴計算湊出 amount - coin 的最少硬幣個數
int subProblem = coinChange(coins, amount - coin);
if (subProblem == -1) continue;
// 湊出 amount 的最少硬幣個數
res = Math.min(res, subProblem + 1);
}
return res == Integer.MAX_VALUE ? -1 : res;
}
這個暴力解法加個 memo
備忘錄就是自頂向下的動態規劃解法,你對照二叉樹最大深度的解法程式碼,有沒有發現很像?
如果你感受到最大深度這個問題兩種解法的區別,那就趁熱打鐵,我問你,二叉樹的前序遍歷怎麼寫?
我相信大家都會對這個問題嗤之以鼻,毫不猶豫就可以寫出下面這段程式碼:
List<Integer> res = new LinkedList<>();
// 前序遍歷函式
List<Integer> preorder(TreeNode root) {
traverse(root);
return res;
}
// 二叉樹遍歷函式
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序遍歷位置
res.addLast(root.val);
traverse(root.left);
traverse(root.right);
}
但是,你結合上面說到的兩種不同的思維模式,二叉樹的遍歷是否也可以通過分解問題的思路解決呢?
我們前文 手把手刷二叉樹(第二期) 說過前中後序遍歷結果的特點:
你注意前序遍歷的結果,根節點的值在第一位,後面接著左子樹的前序遍歷結果,最後接著右子樹的前序遍歷結果。
有沒有體會出點什麼來?其實完全可以重寫前序遍歷程式碼,用分解問題的形式寫出來,避免外部變數和輔助函式:
// 定義:輸入一棵二叉樹的根節點,返回這棵樹的前序遍歷結果
List<Integer> preorder(TreeNode root) {
List<Integer> res = new LinkedList<>();
if (root == null) {
return res;
}
// 前序遍歷的結果,root.val 在第一個
res.add(root.val);
// 後面接著左子樹的前序遍歷結果
res.addAll(preorder(root.left));
// 最後接著右子樹的前序遍歷結果
res.addAll(preorder(root.right));
}
你看,這就是用分解問題的思維模式寫二叉樹的前序遍歷,如果寫中序和後序遍歷也是類似的。
當然,動態規劃系列問題有「最優子結構」和「重疊子問題」兩個特性,而且大多是讓你求最值的。很多演算法雖然不屬於動態規劃,但也符合分解問題的思維模式。
比如 分治演算法詳解 中說到的運算表示式優先順序問題,其核心依然是大問題分解成子問題,只不過沒有重疊子問題,不能用備忘錄去優化效率罷了。
當然,除了動歸、回溯(DFS)、分治,還有一個常用演算法就是 BFS 了,前文 BFS 演算法核心框架 就是根據下面這段二叉樹的層序遍歷程式碼改裝出來的:
// 輸入一棵二叉樹的根節點,層序遍歷這棵二叉樹
void levelTraverse(TreeNode root) {
if (root == null) return 0;
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
int depth = 1;
// 從上到下遍歷二叉樹的每一層
while (!q.isEmpty()) {
int sz = q.size();
// 從左到右遍歷每一層的每個節點
for (int i = 0; i < sz; i++) {
TreeNode cur = q.poll();
if (cur.left != null) {
q.offer(cur.left);
}
if (cur.right != null) {
q.offer(cur.right);
}
}
depth++;
}
}
更進一步,圖論相關的演算法也是二叉樹演算法的延續。
比如 圖論基礎 和 環判斷和拓撲排序 就用到了 DFS 演算法;再比如 Dijkstra 演算法模板,就是改造版 BFS 演算法加上一個類似 dp table 的陣列。
好了,說的差不多了,上述這些演算法的本質都是窮舉二(多)叉樹,有機會的話通過剪枝或者備忘錄的方式減少冗餘計算,提高效率,就這麼點事兒。
最後總結
上週在視訊號直播的時候,有讀者問我什麼刷題方式是正確的,我說正確的刷題方式應該是刷一道題能獲得刷十道題的效果,不然力扣現在 2000 道題目,你都打算刷完麼?
那麼怎麼做到呢?學習資料結構和演算法的框架思維 說了,要有框架思維,學會提煉重點,一個演算法技巧可以包裝出一百道題,如果你能一眼看穿它的本質,那就沒必要浪費時間刷了嘛。
同時,在做題的時候要思考,聯想,進而培養舉一反三的能力。
前文 Dijkstra 演算法模板 並不是真的是讓你去背程式碼模板,不然的話直接甩出來那一段程式碼不就行了,我從層序遍歷講到 BFS 講到 Dijkstra,說這麼多廢話幹什麼?
說到底我還是希望愛思考的讀者能培養出成體系的演算法思維,最好能愛上演算法,而不是單純地看題解去做題,授人以魚不如授人以漁嘛。
本文就到這裡吧,演算法真的沒啥難的,只要有心,誰都可以學好。分享是一種美德,如果本文對你有啟發,歡迎分享給需要的朋友。
_____________
檢視更多優質演算法文章 點選我的頭像,手把手帶你刷力扣,致力於把演算法講清楚!我的 演算法教程 已經獲得 90k star,歡迎點贊!