日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

算法小抄笔记

發布時間:2024/9/30 编程问答 26 豆豆
生活随笔 收集整理的這篇文章主要介紹了 算法小抄笔记 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

目錄

  • 必讀章
    • 學習算法和刷題的框架思維
      • 一、數據結構的存儲方式
      • 二、數據結構的基本操作
      • 三、算法刷題指南
      • 四、總結幾句
  • 動態規劃解題套路框架
    • 一、斐波那契數列
      • 1、暴力遞歸
      • 2、帶備忘錄的遞歸解法
      • 3、dp 數組的迭代解法
    • 二、湊零錢問題
      • 1、暴力遞歸
      • 偽碼框架
      • 2、帶備忘錄的遞歸
      • 3、dp 數組的迭代解法
    • 三、最后總結
  • 回溯算法解題套路框架
    • 一、全排列問題

必讀章

學習算法和刷題的框架思維

一、數據結構的存儲方式

只有兩種:數組(順序存儲)和鏈表(鏈式存儲)。

  • 隊列,棧:既可以使用鏈表也可以使用數組實現。用數組實現,就要處理擴容縮容的問題;用鏈表實現,沒有這個問題,但需要更多的內存空間存儲節點指針。
  • 圖:鄰接表就是鏈表,鄰接矩陣就是二維數組。鄰接矩陣判斷連通性迅速,并可以進行矩陣運算解決一些問題,但是如果圖比較稀疏的話很耗費空間。鄰接表比較節省空間,但是很多操作的效率上肯定比不過鄰接矩陣。
  • 散列表:通過散列函數把鍵映射到一個大數組里。對于解決散列沖突的方法,拉鏈法需要鏈表特性,操作簡單,但需要額外的空間存儲指針;線性探查法就需要數組特性,以便連續尋址,不需要指針的存儲空間,但操作稍微復雜些。
  • 樹:用數組實現就是「堆」,因為「堆」是一個完全二叉樹,用數組存儲不需要節點指針,操作也比較簡單;用鏈表實現就是很常見的那種「樹」,因為不一定是完全二叉樹,所以不適合用數組存儲。為此,在這種鏈表「樹」結構之上,又衍生出各種巧妙的設計,比如二叉搜索樹、AVL 樹、紅黑樹、區間樹、B 樹等。

二、數據結構的基本操作

無非遍歷 + 訪問,再具體一點就是:增刪查改。

遍歷 + 訪問無非兩種形式:線性的和非線性的。

線性就是 for/while 迭代為代表,非線性就是遞歸為代表。無非以下幾種框架:
1.數組遍歷框架,典型的線性迭代結構:

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

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

/* 基本的單鏈表節點 */ 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.valtraverse(head.next) }

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

/* 基本的二叉樹節點 */ class TreeNode {int val;TreeNode left, right; }void traverse(TreeNode root) {traverse(root.left)traverse(root.right) }

4.二叉樹框架可以擴展為 N 叉樹的遍歷框架:

/* 基本的 N 叉樹節點 */ class TreeNode {int val;TreeNode[] children; }void traverse(TreeNode root) {for (TreeNode child : root.children)traverse(child); }

N 叉樹的遍歷又可以擴展為圖的遍歷,因為圖就是好幾 N 叉棵樹的結合體。你說圖是可能出現環的?用個布爾數組 visited 做標記就行了。

三、算法刷題指南

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

幾乎所有二叉樹的題目都是一套這個框架就出來了。

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

對刷題無從下手或者有畏懼心理,不妨從二叉樹下手,前 10 道也許有點難受;

結合框架再做 20 道,也許你就有點自己的理解了;刷完整個專題,再去做什么回溯動規分治專題

只要涉及遞歸的問題,都是樹的問題。

四、總結幾句

數據結構的基本存儲方式就是鏈式和順序兩種,基本操作就是增刪查改,遍歷方式無非迭代和遞歸。
刷算法題建議從「樹」分類開始刷,結合框架思維,把這幾十道題刷完,對于樹結構的理解應該就到位了。這時候去看回溯、動規、分治等算法專題,對思路的理解可能會更加深刻一些。

動態規劃解題套路框架

首先,動態規劃問題的一般形式就是求最值。動態規劃其實是運籌學的一種最優化方法,只不過在計算機問題上應用比較多,比如說讓你求最長遞增子序列呀,最小編輯距離呀等等。

既然是要求最值,核心問題是什么呢?求解動態規劃的核心問題是窮舉。因為要求最值,肯定要把所有可行的答案窮舉出來,然后在其中找最值唄。

動態規劃這么簡單,就是窮舉就完事了?我看到的動態規劃問題都很難啊!
首先,動態規劃的窮舉有點特別,因為這類問題存在「重疊子問題」,如果暴力窮舉的話效率會極其低下,所以需要「備忘錄」或者「DP table」來優化窮舉過程,避免不必要的計算。
而且,動態規劃問題一定會具備「最優子結構」,才能通過子問題的最值得到原問題的最值。
另外,雖然動態規劃的核心思想就是窮舉求最值,但是問題可以千變萬化,窮舉所有可行解其實并不是一件容易的事,只有列出正確的「狀態轉移方程」,才能正確地窮舉。

以上提到的重疊子問題、最優子結構、狀態轉移方程就是動態規劃三要素。具體什么意思等會會舉例詳解,但是在實際的算法問題中,寫出狀態轉移方程是最困難的,這也就是為什么很多朋友覺得動態規劃問題困難的原因,我來提供我研究出來的一個思維框架,輔助你思考狀態轉移方程:
明確 base case -> 明確「狀態」-> 明確「選擇」 -> 定義 dp 數組/函數的含義。

按上面的套路走,最后的結果就可以套這個框架:

# 初始化 base case dp[0][0][...] = base # 進行狀態轉移 for 狀態1 in 狀態1的所有取值:for 狀態2 in 狀態2的所有取值:for ...dp[狀態1][狀態2][...] = 求最值(選擇1,選擇2...)

下面通過斐波那契數列問題和湊零錢問題來詳解動態規劃的基本原理。前者主要是讓你明白什么是重疊子問題(斐波那契數列沒有求最值,所以嚴格來說不是動態規劃問題),后者主要舉集中于如何列出狀態轉移方程。

一、斐波那契數列

請讀者不要嫌棄這個例子簡單,只有簡單的例子才能讓你把精力充分集中在算法背后的通用思想和技巧上,而不會被那些隱晦的細節問題搞的莫名其妙。

1、暴力遞歸

斐波那契數列的數學形式就是遞歸的,寫成代碼就是這樣:

int fib(int N) {if (N == 1 || N == 2) return 1;return fib(N - 1) + fib(N - 2); }

這個不用多說了,學校老師講遞歸的時候似乎都是拿這個舉例。我們也知道這樣寫代碼雖然簡潔易懂,但是十分低效,低效在哪里?假設 n = 20,請畫出遞歸樹:

PS:但凡遇到需要遞歸的問題,最好都畫出遞歸樹,這對你分析算法的復雜度,尋找算法低效的原因都有巨大幫助。

這個遞歸樹怎么理解?就是說想要計算原問題 f(20),我就得先計算出子問題 f(19) 和 f(18),然后要計算 f(19),我就要先算出子問題 f(18) 和 f(17),以此類推。最后遇到 f(1) 或者 f(2) 的時候,結果已知,就能直接返回結果,遞歸樹不再向下生長了。
遞歸算法的時間復雜度怎么計算?就是用子問題個數乘以解決一個子問題需要的時間。

首先計算子問題個數,即遞歸樹中節點的總數。顯然二叉樹節點總數為指數級別,所以子問題個數為 O(2^n)。
然后計算解決一個子問題的時間,在本算法中,沒有循環,只有 f(n - 1) + f(n - 2) 一個加法操作,時間為 O(1)。
所以,這個算法的時間復雜度為二者相乘,即 O(2^n),指數級別,爆炸。

觀察遞歸樹,很明顯發現了算法低效的原因:存在大量重復計算,比如 f(18) 被計算了兩次,而且你可以看到,以 f(18) 為根的這個遞歸樹體量巨大,多算一遍,會耗費巨大的時間。更何況,還不止 f(18) 這一個節點被重復計算,所以這個算法及其低效。

這就是動態規劃問題的第一個性質:重疊子問題。下面,我們想辦法解決這個問題。

2、帶備忘錄的遞歸解法

明確了問題,其實就已經把問題解決了一半。即然耗時的原因是重復計算,那么我們可以造一個「備忘錄」,每次算出某個子問題的答案后別急著返回,先記到「備忘錄」里再返回;每次遇到一個子問題先去「備忘錄」里查一查,如果發現之前已經解決過這個問題了,直接把答案拿出來用,不要再耗時去計算了。

一般使用一個數組充當這個「備忘錄」,當然你也可以使用哈希表(字典),思想都是一樣的。

int fib(int N) {if (N < 1) return 0;// 備忘錄全初始化為 0vector<int> memo(N + 1, 0);// 進行帶備忘錄的遞歸return helper(memo, N); }int helper(vector<int>& memo, int n) {// base caseif (n == 1 || n == 2) return 1;// 已經計算過if (memo[n] != 0) return memo[n];memo[n] = helper(memo, n - 1) + helper(memo, n - 2);return memo[n]; }

現在,畫出遞歸樹,你就知道「備忘錄」到底做了什么。

實際上,帶「備忘錄」的遞歸算法,把一棵存在巨量冗余的遞歸樹通過「剪枝」,改造成了一幅不存在冗余的遞歸圖,極大減少了子問題(即遞歸圖中節點)的個數。

遞歸算法的時間復雜度怎么計算?就是用子問題個數乘以解決一個子問題需要的時間。

子問題個數,即圖中節點的總數,由于本算法不存在冗余計算,子問題就是 f(1), f(2), f(3) … f(20),數量和輸入規模 n = 20 成正比,所以子問題個數為 O(n)。

解決一個子問題的時間,同上,沒有什么循環,時間為 O(1)。
所以,本算法的時間復雜度是 O(n)。比起暴力算法,是降維打擊。

至此,帶備忘錄的遞歸解法的效率已經和迭代的動態規劃解法一樣了。實際上,這種解法和迭代的動態規劃已經差不多了,只不過這種方法叫做「自頂向下」,動態規劃叫做「自底向上」。

啥叫「自頂向下」?注意我們剛才畫的遞歸樹(或者說圖),是從上向下延伸,都是從一個規模較大的原問題比如說 f(20),向下逐漸分解規模,直到 f(1) 和 f(2) 這兩個 base case,然后逐層返回答案,這就叫「自頂向下」。

啥叫「自底向上」?反過來,我們直接從最底下,最簡單,問題規模最小的 f(1) 和 f(2) 開始往上推,直到推到我們想要的答案 f(20),這就是動態規劃的思路,這也是為什么動態規劃一般都脫離了遞歸,而是由循環迭代完成計算。

3、dp 數組的迭代解法

有了上一步「備忘錄」的啟發,我們可以把這個「備忘錄」獨立出來成為一張表,就叫做 DP table 吧,在這張表上完成「自底向上」的推算豈不美哉!

int fib(int N) {if (N < 1) return 0;if (N == 1 || N == 2) return 1;vector<int> dp(N + 1, 0);// base casedp[1] = dp[2] = 1;for (int i = 3; i <= N; i++)dp[i] = dp[i - 1] + dp[i - 2];return dp[N]; }

畫個圖就很好理解了,而且你發現這個 DP table 特別像之前那個「剪枝」后的結果,只是反過來算而已。實際上,帶備忘錄的遞歸解法中的「備忘錄」,最終完成后就是這個 DP table,所以說這兩種解法其實是差不多的,大部分情況下,效率也基本相同。

這里,引出「狀態轉移方程」這個名詞,實際上就是描述問題結構的數學形式:

為啥叫「狀態轉移方程」?其實就是為了聽起來高端。你把 f(n) 想做一個狀態 n,這個狀態 n 是由狀態 n - 1 和狀態 n - 2 相加轉移而來,這就叫狀態轉移,僅此而已。

你會發現,上面的幾種解法中的所有操作,例如 return f(n - 1) + f(n - 2),dp[i] = dp[i - 1] + dp[i - 2],以及對備忘錄或 DP table 的初始化操作,都是圍繞這個方程式的不同表現形式。可見列出「狀態轉移方程」的重要性,它是解決問題的核心。而且很容易發現,其實狀態轉移方程直接代表著暴力解法。

千萬不要看不起暴力解,動態規劃問題最困難的就是寫出這個暴力解,即狀態轉移方程。只要寫出暴力解,優化方法無非是用備忘錄或者 DP table,再無奧妙可言。

這個例子的最后,講一個細節優化。細心的讀者會發現,根據斐波那契數列的狀態轉移方程,當前狀態只和之前的兩個狀態有關,其實并不需要那么長的一個 DP table 來存儲所有的狀態,只要想辦法存儲之前的兩個狀態就行了。所以,可以進一步優化,把空間復雜度降為 O(1):

int fib(int n) {if (n < 1) return 0;if (n == 2 || n == 1) return 1;int prev = 1, curr = 1;for (int i = 3; i <= n; i++) {int sum = prev + curr;prev = curr;curr = sum;}return curr; }

這個技巧就是所謂的「狀態壓縮」,如果我們發現每次狀態轉移只需要 DP table 中的一部分,那么可以嘗試用狀態壓縮來縮小 DP table 的大小,只記錄必要的數據,上述例子就相當于把DP table 的大小從 n 縮小到 2。后續的動態規劃章節中我們還會看到這樣的例子,一般來說是把一個二維的 DP table 壓縮成一維,即把空間復雜度從 O(n^2) 壓縮到 O(n)。
有人會問,動態規劃的另一個重要特性「最優子結構」,怎么沒有涉及?下面會涉及。斐波那契數列的例子嚴格來說不算動態規劃,因為沒有涉及求最值,以上旨在說明重疊子問題的消除方法,演示得到最優解法逐步求精的過程。下面,看第二個例子,湊零錢問題。

二、湊零錢問題

先看下題目:給你 k 種面值的硬幣,面值分別為 c1, c2 … ck,每種硬幣的數量無限,再給一個總金額 amount,問你最少需要幾枚硬幣湊出這個金額,如果不可能湊出,算法返回 -1 。算法的函數簽名如下:

// coins 中是可選硬幣面值,amount 是目標金額 int coinChange(int[] coins, int amount);

比如說 k = 3,面值分別為 1,2,5,總金額 amount = 11。那么最少需要 3 枚硬幣湊出,即 11 = 5 + 5 + 1。
你認為計算機應該如何解決這個問題?顯然,就是把所有可能的湊硬幣方法都窮舉出來,然后找找看最少需要多少枚硬幣。

1、暴力遞歸

首先,這個問題是動態規劃問題,因為它具有「最優子結構」的。要符合「最優子結構」,子問題間必須互相獨立。啥叫相互獨立?你肯定不想看數學證明,我用一個直觀的例子來講解。

比如說,假設你考試,每門科目的成績都是互相獨立的。你的原問題是考出最高的總成績,那么你的子問題就是要把語文考到最高,數學考到最高…… 為了每門課考到最高,你要把每門課相應的選擇題分數拿到最高,填空題分數拿到最高…… 當然,最終就是你每門課都是滿分,這就是最高的總成績。

得到了正確的結果:最高的總成績就是總分。因為這個過程符合最優子結構,“每門科目考到最高”這些子問題是互相獨立,互不干擾的。

但是,如果加一個條件:你的語文成績和數學成績會互相制約,數學分數高,語文分數就會降低,反之亦然。這樣的話,顯然你能考到的最高總成績就達不到總分了,按剛才那個思路就會得到錯誤的結果。因為子問題并不獨立,語文數學成績無法同時最優,所以最優子結構被破壞。

回到湊零錢問題,為什么說它符合最優子結構呢?比如你想求 amount = 11 時的最少硬幣數(原問題),如果你知道湊出 amount = 10 的最少硬幣數(子問題),你只需要把子問題的答案加一(再選一枚面值為 1 的硬幣)就是原問題的答案。因為硬幣的數量是沒有限制的,所以子問題之間沒有相互制,是互相獨立的。
PS:關于最優子結構的問題,后文動態規劃答疑篇 還會再舉例探討。

那么,既然知道了這是個動態規劃問題,就要思考如何列出正確的狀態轉移方程?
1、確定 base case,這個很簡單,顯然目標金額 amount 為 0 時算法返回 0,因為不需要任何硬幣就已經湊出目標金額了。
2、確定「狀態」,也就是原問題和子問題中會變化的變量。由于硬幣數量無限,硬幣的面額也是題目給定的,只有目標金額會不斷地向 base case 靠近,所以唯一的「狀態」就是目標金額 amount。
3、確定「選擇」,也就是導致「狀態」產生變化的行為。目標金額為什么變化呢,因為你在選擇硬幣,你每選擇一枚硬幣,就相當于減少了目標金額。所以說所有硬幣的面值,就是你的「選擇」。
4、明確 dp 函數/數組的定義。我們這里講的是自頂向下的解法,所以會有一個遞歸的 dp 函數,一般來說函數的參數就是狀態轉移中會變化的量,也就是上面說到的「狀態」;函數的返回值就是題目要求我們計算的量。就本題來說,狀態只有一個,即「目標金額」,題目要求我們計算湊出目標金額所需的最少硬幣數量。所以我們可以這樣定義 dp 函數:
dp(n) 的定義:輸入一個目標金額 n,返回湊出目標金額 n 的最少硬幣數量。
搞清楚上面這幾個關鍵點,解法的偽碼就可以寫出來了:

偽碼框架

def coinChange(coins: List[int], amount: int):# 定義:要湊出金額 n,至少要 dp(n) 個硬幣def dp(n):# 做選擇,選擇需要硬幣最少的那個結果for coin in coins:res = min(res, 1 + dp(n - coin))return res# 題目要求的最終結果是 dp(amount)return dp(amount)

根據偽碼,我們加上 base case 即可得到最終的答案。顯然目標金額為 0 時,所需硬幣數量為 0;當目標金額小于 0 時,無解,返回 -1:

def coinChange(coins: List[int], amount: int):def dp(n):# base caseif n == 0: return 0if n < 0: return -1# 求最小值,所以初始化為正無窮res = float('INF')for coin in coins:subproblem = dp(n - coin)# 子問題無解,跳過if subproblem == -1: continueres = min(res, 1 + subproblem)return res if res != float('INF') else -1return dp(amount)

至此,狀態轉移方程其實已經完成了,以上算法已經是暴力解法了,以上代碼的數學形式就是狀態轉移方程:

至此,這個問題其實就解決了,只不過需要消除一下重疊子問題,比如 amount = 11, coins = {1,2,5} 時畫出遞歸樹看看:

遞歸算法的時間復雜度分析:子問題總數 x 每個子問題的時間。
子問題總數為遞歸樹節點個數,這個比較難看出來,是 O(n^k),總之是指數級別的。每個子問題中含有一個 for 循環,復雜度為 O(k)。所以總時間復雜度為 O(k * n^k),指數級別。

2、帶備忘錄的遞歸

類似之前斐波那契數列的例子,只需要稍加修改,就可以通過備忘錄消除子問題:

def coinChange(coins: List[int], amount: int):# 備忘錄memo = dict()def dp(n):# 查備忘錄,避免重復計算if n in memo: return memo[n]# base caseif n == 0: return 0if n < 0: return -1res = float('INF')for coin in coins:subproblem = dp(n - coin)if subproblem == -1: continueres = min(res, 1 + subproblem)# 記入備忘錄memo[n] = res if res != float('INF') else -1return memo[n]return dp(amount)

不畫圖了,很顯然「備忘錄」大大減小了子問題數目,完全消除了子問題的冗余,所以子問題總數不會超過金額數 n,即子問題數目為 O(n)。處理一個子問題的時間不變,仍是 O(k),所以總的時間復雜度是 O(kn)。

3、dp 數組的迭代解法

當然,我們也可以自底向上使用 dp table 來消除重疊子問題,關于「狀態」「選擇」和 base case 與之前沒有區別,dp 數組的定義和剛才 dp 函數類似,也是把「狀態」,也就是目標金額作為變量。不過 dp 函數體現在函數參數,而 dp 數組體現在數組索引:
dp 數組的定義:當目標金額為 i 時,至少需要 dp[i] 枚硬幣湊出。
根據我們文章開頭給出的動態規劃代碼框架可以寫出如下解法:

public class Solution {public int coinChange(int[] coins, int amount) {int max = amount + 1;int[] dp = new int[amount + 1];Arrays.fill(dp, max);dp[0] = 0;for (int i = 1; i <= amount; i++) {for (int j = 0; j < coins.length; j++) {if (coins[j] <= i) {dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1);}}}return dp[amount] > amount ? -1 : dp[amount];} }

PS:為啥 dp 數組初始化為 amount + 1 呢,因為湊成 amount 金額的硬幣數最多只可能等于 amount(全用 1 元面值的硬幣),所以初始化為 amount + 1 就相當于初始化為正無窮,便于后續取最小值。

三、最后總結

第一個斐波那契數列的問題,解釋了如何通過「備忘錄」或者「dp table」的方法來優化遞歸樹,并且明確了這兩種方法本質上是一樣的,只是自頂向下和自底向上的不同而已。

第二個湊零錢的問題,展示了如何流程化確定「狀態轉移方程」,只要通過狀態轉移方程寫出暴力遞歸解,剩下的也就是優化遞歸樹,消除重疊子問題而已。

如果你不太了解動態規劃,還能看到這里,真得給你鼓掌,相信你已經掌握了這個算法的設計技巧。
計算機解決問題其實沒有任何奇技淫巧,它唯一的解決辦法就是窮舉,窮舉所有可能性。算法設計無非就是先思考“如何窮舉”,然后再追求“如何聰明地窮舉”。

列出動態轉移方程,就是在解決“如何窮舉”的問題。之所以說它難,一是因為很多窮舉需要遞歸實現,二是因為有的問題本身的解空間復雜,不那么容易窮舉完整。
備忘錄、DP table 就是在追求“如何聰明地窮舉”。用空間換時間的思路,是降低時間復雜度的不二法門,除此之外,試問,還能玩出啥花活?

之后我們會有一章專門講解動態規劃問題,如果有任何問題都可以隨時回來重讀本文,希望讀者在閱讀每個題目和解法時,多往「狀態」和「選擇」上靠,才能對這套框架產生自己的理解,運用自如。

回溯算法解題套路框架

本文解決幾個問題:

回溯算法是什么?解決回溯算法相關的問題有什么技巧?如何學習回溯算法?回溯算法代碼是否有規律可循?

其實回溯算法其實就是我們常說的 DFS 算法,本質上就是一種暴力窮舉算法。

廢話不多說,直接上回溯算法框架。解決一個回溯問題,實際上就是一個決策樹的遍歷過程。你只需要思考 3 個問題:
1、路徑:也就是已經做出的選擇。
2、選擇列表:也就是你當前可以做的選擇。
3、結束條件:也就是到達決策樹底層,無法再做選擇的條件。

如果你不理解這三個詞語的解釋,沒關系,我們后面會用「全排列」和「N 皇后問題」這兩個經典的回溯算法問題來幫你理解這些詞語是什么意思,現在你先留著印象。

代碼方面,回溯算法的框架:

result = [] def backtrack(路徑, 選擇列表):if 滿足結束條件:result.add(路徑)returnfor 選擇 in 選擇列表:做選擇backtrack(路徑, 選擇列表)撤銷選擇

其核心就是 for 循環里面的遞歸,在遞歸調用之前「做選擇」,在遞歸調用之后「撤銷選擇」,特別簡單。

什么叫做選擇和撤銷選擇呢,這個框架的底層原理是什么呢?下面我們就通過「全排列」這個問題來解開之前的疑惑,詳細探究一下其中的奧妙!

一、全排列問題

我們在高中的時候就做過排列組合的數學題,我們也知道 n 個不重復的數,全排列共有 n! 個。

PS:為了簡單清晰起見,我們這次討論的全排列問題不包含重復的數字。

那么我們當時是怎么窮舉全排列的呢?比方說給三個數 [1,2,3],你肯定不會無規律地亂窮舉,一般是這樣:
先固定第一位為 1,然后第二位可以是 2,那么第三位只能是 3;然后可以把第二位變成 3,第三位就只能是 2 了;然后就只能變化第一位,變成 2,然后再窮舉后兩位……

其實這就是回溯算法,我們高中無師自通就會用,或者有的同學直接畫出如下這棵回溯樹:

只要從根遍歷這棵樹,記錄路徑上的數字,其實就是所有的全排列。我們不妨把這棵樹稱為回溯算法的「決策樹」。

為啥說這是決策樹呢,因為你在每個節點上其實都在做決策。比如說你站在下圖的紅色節點上:

你現在就在做決策,可以選擇 1 那條樹枝,也可以選擇 3 那條樹枝。為啥只能在 1 和 3 之中選擇呢?因為 2 這個樹枝在你身后,這個選擇你之前做過了,而全排列是不允許重復使用數字的。

現在可以解答開頭的幾個名詞:[2] 就是「路徑」,記錄你已經做過的選擇;[1,3] 就是「選擇列表」,表示你當前可以做出的選擇;「結束條件」就是遍歷到樹的底層,在這里就是選擇列表為空的時候。

如果明白了這幾個名詞,可以把「路徑」和「選擇」列表作為決策樹上每個節點的屬性,比如下圖列出了幾個節點的屬性:

我們定義的 backtrack 函數其實就像一個指針,在這棵樹上游走,同時要正確維護每個節點的屬性,每當走到樹的底層,其「路徑」就是一個全排列。

再進一步,如何遍歷一棵樹?這個應該不難吧?;貞浺幌轮啊笇W習數據結構的框架思維」寫過,各種搜索問題其實都是樹的遍歷問題,而多叉樹的遍歷框架就是這樣:

void traverse(TreeNode root) {for (TreeNode child : root.childern)// 前序遍歷需要的操作traverse(child);// 后序遍歷需要的操作 }

而所謂的前序遍歷和后序遍歷,他們只是兩個很有用的時間點,我給你畫張圖你就明白了:

前序遍歷的代碼在進入某一個節點之前的那個時間點執行,后序遍歷代碼在離開某個節點之后的那個時間點執行。

回想我們剛才說的,「路徑」和「選擇」是每個節點的屬性,函數在樹上游走要正確維護節點的屬性,那么就要在這兩個特殊時間點搞點動作:

總結

以上是生活随笔為你收集整理的算法小抄笔记的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。