手把手教你刷搜索

大話搜索

搜索通常指在有限的狀態空間中進行枚舉,經過窮盡全部的可能來找到符合條件的解或者解的個數。根據搜索方式的不一樣,搜索算法能夠分爲 DFS,BFS,A*算法等。這裏只介紹 DFS 和 BFS,以及發生在 DFS 上一種技巧-回溯。前端

搜索問題覆蓋面很是普遍,而且在算法題中也佔據了很高的比例。我甚至還在公開演講中提到了 前端算法面試中搜索類佔據了很大的比重,尤爲是國內公司git

搜索專題中的子專題有不少,而你們所熟知的 BFS,DFS 只是其中特別基礎的內容。除此以外,還有狀態記錄與維護,剪枝,聯通份量,拓撲排序等等。這些內容,我會在這裏一一給你們介紹。github

另外即便僅僅考慮 DFS 和 BFS 兩種基本算法,裏面能玩的花樣也很是多。好比 BFS 的雙向搜索,好比 DFS 的前中後序,迭代加深等等。面試

關於搜索,其實在二叉樹部分已經作了介紹了。而這裏的搜索,其實就是進一步的泛化。數據結構再也不侷限於前面提到的數組,鏈表或者樹。而擴展到了諸如二維數組,多叉樹,圖等。不過核心仍然是同樣的,只不過數據結構發生了變化而已。算法

搜索的核心是什麼?

實際上搜索題目本質就是將題目中的狀態映射爲圖中的點,將狀態間的聯繫映射爲圖中的邊。根據題目信息構建狀態空間,而後對狀態空間進行遍歷,遍歷過程須要記錄和維護狀態,並經過剪枝和數據結構等提升搜索效率。api

狀態空間的數據結構不一樣會致使算法不一樣。好比對數組進行搜索,和對樹,圖進行搜索就不太同樣。數組

再次強調一下,我這裏講的數組,樹和圖是狀態空間的邏輯結構,而不是題目給的數據結構。好比題目給了一個數組,讓你求數組的搜索子集。雖然題目給的線性的數據結構數組,然而實際上咱們是對樹這種非線性數據結構進行搜索。這是由於這道題對應的狀態空間是非線性的數據結構

對於搜索問題,咱們核心關注的信息有哪些?又該如何計算呢?這也是搜索篇核心關注的。而市面上不少資料講述的不是很詳細。搜索的核心須要關注的指標有不少,好比樹的深度,圖的 DFS 序,圖中兩點間的距離等等。這些指標都是完成高級算法必不可少的,而這些指標能夠經過一些經典算法來實現。這也是爲何我一直強調必定要先學習好基礎的數據結構與算法的緣由。app

不過要講這些講述完整並不是容易,以致於若是完整寫完可能須要花不少的時間,所以一直沒有動手去寫。svg

另外因爲其餘數據結構均可以看作是圖的特例。所以研究透圖的基本思想,就很容易將其擴展到其餘數據結構上,好比樹。所以我打算圍繞圖進行講解,並逐步具象化到其餘特殊的數據結構,好比樹。

狀態空間

結論先行:狀態空間其實就是一個圖結構,圖中的節點表示狀態,圖中的邊表示狀態以前的聯繫,這種聯繫就是題目給出的各類關係

搜索題目的狀態空間一般是非線性的。好比上面提到的例子:求一個數組的子集。這裏的狀態空間實際上就是數組的各類組合。

對於這道題來講,其狀態空間的一種可行的劃分方式爲:

  • 長度爲 1 的子集
  • 長度爲 2 的子集
  • 。。。
  • 長度爲 n 的子集(其中 n 爲數組長度)

而如何肯定上面全部的子集呢。

一種可行的方案是能夠採起相似分治的方式逐一肯定。

好比咱們能夠:

  • 先肯定某一種子集的第一個數是什麼
  • 再肯定第二個數是什麼
  • 。。。

如何肯定第一個數,第二個數。。。呢?

暴力枚舉全部可能就能夠了。

這就是搜索問題的核心,其餘都是輔助,因此這句話請務必記住。

所謂的暴力枚舉全部可能在這裏就是嘗試數組中全部可能的數字。

  • 好比第一個數是什麼?很明顯多是數組中任意一項。ok,咱們就枚舉 n 種狀況。
  • 第二個數呢?很明顯能夠是除了上面已經被選擇的數以外的任意一個數。ok,咱們就枚舉 n - 1 種狀況。

據此,你能夠畫出以下的決策樹。

(下圖描述的是對一個長度爲 3 的數組進行決策的部分過程,樹節點中的數字表示索引。即肯定第一個數有三個選擇,肯定第二個數會根據上次的選擇變爲剩下的兩個選擇)

決策過程動圖演示:

搜索-決策樹.svg

一些搜索算法就是基於這個樸素的思想,本質就是模擬這個決策樹。這裏面其實也有不少有趣的細節,後面咱們會對其進行更加詳細的講解。而如今你們只須要對解空間是什麼以及如何對解空間進行遍歷有一點概念就好了。 後面我會繼續對這個概念進行加深。

這裏你們只要記住狀態空間就是圖,構建狀態空間就是構建圖。如何構建呢?固然是根據題目描述了

DFS 和 BFS

DFS 和 BFS 是搜索的核心,貫穿搜索篇的始終,所以有必要先對其進行講解。

DFS

DFS 的概念來自於圖論,可是搜索中 DFS 和圖論中 DFS 仍是有一些區別,搜索中 DFS 通常指的是經過遞歸函數實現暴力枚舉。

若是不使用遞歸,也可使用棧來實現。不過本質上是相似的。

首先將題目的狀態空間映射到一張圖,狀態就是圖中的節點,狀態之間的聯繫就是圖中的邊,那麼 DFS 就是在這種圖上進行深度優先的遍歷。而 BFS 也是相似,只不過遍歷的策略變爲了廣度優先,一層層鋪開而已。因此BFS 和 DFS 只是遍歷這個狀態圖的兩種方式罷了,如何構建狀態圖纔是關鍵

本質上,對上面的圖進行遍歷的話會生成一顆搜索樹。爲了避免重複訪問,咱們須要記錄已經訪問過的節點。這些是全部的搜索算法共有的,後面再也不贅述。

若是你是在樹上進行遍歷,是不會有環的,也天然不須要爲了避免環的產生記錄已經訪問的節點,這是由於樹本質上是一個簡單無環圖。

算法流程

  1. 首先將根節點放入stack中。
  2. stack中取出第一個節點,並檢驗它是否爲目標。若是找到目標,則結束搜尋並回傳結果。不然將它某一個還沒有檢驗過的直接子節點加入stack中。
  3. 重複步驟 2。
  4. 若是不存在未檢測過的直接子節點。將上一級節點加入stack中。
    重複步驟 2。
  5. 重複步驟 4。
  6. stack爲空,表示整張圖都檢查過了——亦即圖中沒有欲搜尋的目標。結束搜尋並回傳「找不到目標」。
這裏的 stack 能夠理解爲自實現的棧,也能夠理解爲調用棧

算法模板

下面咱們藉助遞歸來完成 DFS。

const visited = {}
function dfs(i) {
    if (知足特定條件){
        // 返回結果 or 退出搜索空間
    }

    visited[i] = true // 將當前狀態標爲已搜索
    for (根據i能到達的下個狀態j) {
        if (!visited[j]) { // 若是狀態j沒有被搜索過
            dfs(j)
        }
    }
}

經常使用技巧

前序遍歷與後序遍歷

DFS 常見的形式有前序和後序。兩者的使用場景也是大相徑庭的。

上面講述了搜索本質就是在狀態空間進行遍歷,空間中的狀態能夠抽象爲圖中的點。那麼若是搜索過程當中,當前點的結果須要依賴其餘節點(大多數狀況都會有依賴),那麼遍歷順序就變得重要。

好比當前節點須要依賴其子節點的計算信息,那麼使用後序遍歷自底向上遞推就顯得必要了。而若是當前節點須要依賴其父節點的信息,那麼使用先序遍歷進行自頂向下的遞歸就不難想到。

好比下文要講的計算樹的深度。因爲樹的深度的遞歸公式爲: $f(x) = f(y) + 1$。其中 f(x) 表示節點 x 的深度,而且 x 是 y 的子節點。很明顯這個遞推公式的 base case 就是根節點深度爲一,經過這個 base case 咱們能夠遞推求出樹中任意節點的深度。顯然,使用先序遍歷自頂向下的方式統計是簡單而又直接的。

再好比下文要講的計算樹的子節點個數。因爲樹的子節點遞歸公式爲: $f(x) = sum_{i=0}^{n}{f(a_i)}$ 其中 x 爲樹中的某一個節點,$a_i$ 爲樹中節點的子節點。而 base case 則是沒有任何子節點(也就是葉子節點),此時 $f(x) = 1$。 所以咱們能夠利用後序遍歷自底向上來完成子節點個數的統計。

關於從遞推關係分析使用何種遍歷方法, 我在《91 天學算法》中的基礎篇中的《模擬,枚舉與遞推》子專題中對此進行了詳細的描述。91 學員能夠直接進行查看。關於樹的各類遍歷方法,我在樹專題中進行了詳細的介紹。

迭代加深

迭代加深本質上是一種可行性的剪枝。關於剪枝,我會在後面的《回溯與剪枝》部分作更多的介紹。

所謂迭代加深指的是在遞歸樹比較深的時候,經過設定遞歸深度閾值,超過閾值就退出的方式主動減小遞歸深度的優化手段。這種算法成立的前提是題目中告訴咱們答案不超過 xxx,這樣咱們能夠將 xxx 做爲遞歸深度閾值,這樣不只不會錯過正確解,還能在極端狀況下有效減小沒必要須的運算。

具體地,咱們可使用自頂向下的方式記錄遞歸樹的層次,和上面介紹如何計算樹深度的方法是同樣的。接下來在主邏輯前增長當前層次是否超過閾值的判斷便可。

主代碼:

MAX_LEVEL = 20
def dfs(root, level):
    if level > MAX_LEVEL: return
    # 主邏輯
dfs(root, 0)

這種技巧在實際使用中並不常見,不過在某些時候能發揮意想不到的做用。

雙向搜索

有時候問題規模很大,直接搜索會超時。此時能夠考慮從起點搜索到問題規模的一半。而後將此過程當中產生的狀態存起來。接下來目標轉化爲在存儲的中間狀態中尋找知足條件的狀態。進而達到下降時間複雜度的效果。

上面的說法可能不太容易理解。 接下來經過一個例子幫助你們理解。

題目地址

https://leetcode-cn.com/probl...

題目描述
給你一個整數數組 nums 和一個目標值 goal 。

你須要從 nums 中選出一個子序列,使子序列元素總和最接近 goal 。也就是說,若是子序列元素和爲 sum ,你須要 最小化絕對差 abs(sum - goal) 。

返回 abs(sum - goal) 可能的 最小值 。

注意,數組的子序列是經過移除原始數組中的某些元素(可能所有或無)而造成的數組。

 

示例 1:

輸入:nums = [5,-7,3,5], goal = 6
輸出:0
解釋:選擇整個數組做爲選出的子序列,元素和爲 6 。
子序列和與目標值相等,因此絕對差爲 0 。
示例 2:

輸入:nums = [7,-9,15,-2], goal = -5
輸出:1
解釋:選出子序列 [7,-9,-2] ,元素和爲 -4 。
絕對差爲 abs(-4 - (-5)) = abs(1) = 1 ,是可能的最小值。
示例 3:

輸入:nums = [1,2,3], goal = -7
輸出:7
 

提示:

1 <= nums.length <= 40
-10^7 <= nums[i] <= 10^7
-10^9 <= goal <= 10^9
思路

從數據範圍能夠看出,這道題大機率是一個 $O(2^m)$ 時間複雜度的解法,其中 m 是 nums.length 的一半。

爲何?首先若是題目數組長度限制爲小於等於 20,那麼大機率是一個 $O(2^n)$ 的解法。

若是這個也不知道,建議看一下這篇文章 https://lucifer.ren/blog/2020... 另外個人刷題插件 leetcode-cheatsheet 也給出了時間複雜度速查表供你們參考。

將 40 砍半剛好就能夠 AC 了。實際上,40 這個數字就是一個強有力的信號。

回到題目中。咱們能夠用一個二進制位表示原數組 nums 的一個子集,這樣用一個長度爲 $2^n$ 的數組就能夠描述 nums 的全部子集了,這就是狀態壓縮。通常題目數據範圍是 <= 20 都應該想到。

這裏 40 折半就是 20 了。

若是不熟悉狀態壓縮,能夠看下個人這篇文章 狀壓 DP 是什麼?這篇題解帶你入門

接下來,咱們使用動態規劃求出全部的子集和。

這也不難求出,轉移方程爲 : dp[(1 << i) + j] = dp[j] + A[i],其中 j 爲 i 的子集,i 和 j 都是數字,i 和 j 的二進制表示的是 nums 的選擇狀況。

動態規劃求子集和代碼以下:

def combine_sum(A):
    n = len(A)
    dp = [0] * (1 << n)
    for i in range(n):
        for j in range(1 << i):
            dp[(1 << i) + j] = dp[j] + A[i]
    return dp

接下來,咱們將 nums 平分爲兩部分,分別計算子集和:

n = len(nums)
c1 = combine_sum(nums[: n // 2])
c2 = combine_sum(nums[n // 2 :])

其中 c1 就是前半部分數組的子集和,c2 就是後半部分的子集和。

接下來問題轉化爲:在兩個數組 c1 和 c2中找兩個數,其和最接近 goal。而這是一個很是經典的雙指針問題,邏輯相似兩數和。

只不過兩數和是一個數組挑兩個數,這裏是兩個數組分別挑一個數罷了。

這裏其實只須要一個指針指向一個數組的頭,另一個指向另一個數組的尾便可。

代碼不難寫出:

def combine_closest(c1, c2):
    # 先排序以便使用雙指針
    c1.sort()
    c2.sort()
    ans = float("inf")
    i, j = 0, len(c2) - 1
    while i < len(c1) and j >= 0:
        _sum = c1[i] + c2[j]
        ans = min(ans, abs(_sum - goal))
        if _sum > goal:
            j -= 1
        elif _sum < goal:
            i += 1
        else:
            return 0
    return ans

上面這個代碼不懂的多看看兩數和。

代碼

代碼支持:Python3

Python3 Code:

class Solution:
    def minAbsDifference(self, nums: List[int], goal: int) -> int:
        def combine_sum(A):
            n = len(A)
            dp = [0] * (1 << n)
            for i in range(n):
                for j in range(1 << i):
                    dp[(1 << i) + j] = dp[j] + A[i]
            return dp

        def combine_closest(c1, c2):
            c1.sort()
            c2.sort()
            ans = float("inf")
            i, j = 0, len(c2) - 1
            while i < len(c1) and j >= 0:
                _sum = c1[i] + c2[j]
                ans = min(ans, abs(_sum - goal))
                if _sum > goal:
                    j -= 1
                elif _sum < goal:
                    i += 1
                else:
                    return 0
            return ans

        n = len(nums)
        return combine_closest(combine_sum(nums[: n // 2]), combine_sum(nums[n // 2 :]))

複雜度分析

令 n 爲數組長度, m 爲 $\frac{n}{2}$。

  • 時間複雜度:$O(m*2^m)$
  • 空間複雜度:$O(2^m)$

相關題目推薦:

這道題和雙向搜索有什麼關係呢?

回一下開頭個人話:有時候問題規模很大,直接搜索會超時。此時能夠考慮從起點搜索到問題規模的一半。而後將此過程當中產生的狀態存起來。接下來目標轉化爲在存儲的中間狀態中尋找知足條件的狀態。進而達到下降時間複雜度的效果。

對應這道題,咱們若是直接暴力搜索。那就是枚舉全部子集和,而後找到和 goal 最接近的,思路簡單直接。但是這樣會超時,那麼就搜索到一半, 而後將狀態存起來(對應這道題就是存到了 dp 數組)。接下來問題轉化爲兩個 dp 數組的運算。該算法,本質上是將位於指數位的常數項挪動到了係數位。這是一種常見的雙向搜索,我姑且稱爲 DFS 的雙向搜索。目的是爲了和後面的 BFS 雙向搜索進行區分。

BFS

BFS 也是圖論中算法的一種。不一樣於 DFS, BFS 採用橫向搜索的方式,從初始狀態一層層展開直到目標狀態,在數據結構上一般採用隊列結構。

具體地,咱們不斷從隊頭取出狀態,而後將此狀態對應的決策產生的全部新的狀態推入隊尾,重複以上過程直至隊列爲空便可。

注意這裏有兩個關鍵點:

  1. 將此狀態對應的決策。 實際上這句話指的就是狀態空間中的圖的邊,而無論是 DFS 和 BFS 邊都是肯定的。也就是說無論是 DFS 仍是 BFS 這個決策都是同樣的。不一樣的是什麼?不一樣的是進行決策的方向不一樣。
  2. 全部新的狀態推入隊尾。上面說 BFS 和 DFS 是進行決策的方向不一樣。這就能夠經過這個動做體現出來。因爲直接將全部狀態空間中的當前點的鄰邊放到隊尾。由隊列的先進先出的特性,當前點的鄰邊訪問完成以前是不會繼續向外擴展的。這一點你們能夠和 DFS 進行對比。

最簡單的 BFS 每次擴展新的狀態就增長一步,經過這樣一步步逼近答案。其實也就等價於在一個權值爲 1 的圖上進行 BFS。因爲隊列的單調性和二值性,當第一次取出目標狀態時就是最少的步數。基於這個特性,BFS 適合求解一些最少操做的題目。

關於單調性和二值性,我會在後面的 BFS 和 DFS 的對比那塊進行講解。

前面 DFS 部分提到了無論是什麼搜索都須要記錄和維護狀態,其中一個就是節點訪問狀態以防止環的產生。而 BFS 中咱們經常用來求點的最短距離。值得注意的是,有時候咱們會使用一個哈希表 dist 來記錄從源點到圖中其餘點的距離。這個 dist 也能夠充當防止環產生的功能,這是由於第一次到達一個點後再次到達此點的距離必定比第一次到達大,利用這點就可知道是不是第一次訪問了。

算法流程

  1. 首先將根節點放入隊列中。
  2. 從隊列中取出第一個節點,並檢驗它是否爲目標。

    • 若是找到目標,則結束搜索並回傳結果。
    • 不然將它全部還沒有檢驗過的直接子節點加入隊列中。
  3. 若隊列爲空,表示整張圖都檢查過了——亦即圖中沒有欲搜索的目標。結束搜索並回傳「找不到目標」。
  4. 重複步驟 2。

算法模板

const visited = {}
function bfs() {
    let q = new Queue()
    q.push(初始狀態)
    while(q.length) {
        let i = q.pop()
        if (visited[i]) continue
        for (i的可抵達狀態j) {
            if (j 合法) {
                q.push(j)
            }
        }
    }
    // 找到全部合法解
}

經常使用技巧

雙向搜索
題目地址(126. 單詞接龍 II)

https://leetcode-cn.com/probl...

題目描述
按字典 wordList 完成從單詞 beginWord 到單詞 endWord 轉化,一個表示此過程的 轉換序列 是形式上像 beginWord -> s1 -> s2 -> ... -> sk 這樣的單詞序列,並知足:

每對相鄰的單詞之間僅有單個字母不一樣。
轉換過程當中的每一個單詞 si(1 <= i <= k)必須是字典 wordList 中的單詞。注意,beginWord 沒必要是字典 wordList 中的單詞。
sk == endWord

給你兩個單詞 beginWord 和 endWord ,以及一個字典 wordList 。請你找出並返回全部從 beginWord 到 endWord 的 最短轉換序列 ,若是不存在這樣的轉換序列,返回一個空列表。每一個序列都應該以單詞列表 [beginWord, s1, s2, ..., sk] 的形式返回。

 

示例 1:

輸入:beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"]
輸出:[["hit","hot","dot","dog","cog"],["hit","hot","lot","log","cog"]]
解釋:存在 2 種最短的轉換序列:
"hit" -> "hot" -> "dot" -> "dog" -> "cog"
"hit" -> "hot" -> "lot" -> "log" -> "cog"


示例 2:

輸入:beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log"]
輸出:[]
解釋:endWord "cog" 不在字典 wordList 中,因此不存在符合要求的轉換序列。


 

提示:

1 <= beginWord.length <= 7
endWord.length == beginWord.length
1 <= wordList.length <= 5000
wordList[i].length == beginWord.length
beginWord、endWord 和 wordList[i] 由小寫英文字母組成
beginWord != endWord
wordList 中的全部單詞 互不相同
思路

這道題就是咱們平常玩的成語接龍遊戲。即讓你從 beginWord 開始, 接龍的 endWord。讓你找到最短的接龍方式,若是有多個,則所有返回

不一樣於成語接龍的字首接字尾。這種接龍鬚要的是下一個單詞和上一個單詞僅有一個單詞不一樣。

咱們能夠對問題進行抽象:即構建一個大小爲 n 的圖,圖中的每個點表示一個單詞,咱們的目標是找到一條從節點 beginWord 到節點 endWord 的一條最短路徑。

這是一個徹徹底底的圖上 BFS 的題目。套用上面的解題模板能夠輕鬆解決。惟一須要注意的是如何構建圖。更進一步說就是如何構建邊

由題目信息的轉換規則:每對相鄰的單詞之間僅有單個字母不一樣。不難知道,若是兩個單詞的僅有單個字母不一樣 ,就說明二者之間有一條邊。

明白了這一點,咱們就能夠構建鄰接矩陣了。

核心代碼:

neighbors = collections.defaultdict(list)
for word in wordList:
    for i in range(len(word)):
        neighbors[word[:i] + "*" + word[i + 1 :]].append(word)

構建好了圖。 BFS 剩下要作的就是明確起點和終點就行了。對於這道題來講,起點是 beginWord,終點是 endWord。

那咱們就能夠將 beginWord 入隊。不斷在圖上作 BFS,直到第一次遇到 endWord 就行了。

套用上面的 BFS 模板,不難寫出以下代碼:

這裏我用了 cost 而不是 visitd,目的是爲了讓你們見識多種寫法。下面的優化解法會使用 visited 來記錄。
class Solution:
    def findLadders(self, beginWord: str, endWord: str, wordList: List[str]) -> List[List[str]]:
        cost = collections.defaultdict(lambda: float("inf"))
        cost[beginWord] = 0
        neighbors = collections.defaultdict(list)
        ans = []

        for word in wordList:
            for i in range(len(word)):
                neighbors[word[:i] + "*" + word[i + 1 :]].append(word)
        q = collections.deque([[beginWord]])

        while q:
            path = q.popleft()
            cur = path[-1]
            if cur == endWord:
                ans.append(path.copy())
            else:
                for i in range(len(cur)):
                    for neighbor in neighbors[cur[:i] + "*" + cur[i + 1 :]]:
                        if cost[cur] + 1 <= cost[neighbor]:
                            q.append(path + [neighbor])
                            cost[neighbor] = cost[cur] + 1
        return ans

當終點能夠逆向搜索的時候,咱們也能夠嘗試雙向 BFS。更本質一點就是:若是你構建的狀態空間的邊是雙向的,那麼就可使用雙向 BFS。

和 DFS 的雙向搜索思想是相似的。咱們只須要使用兩個隊列分別存儲中起點和終點進行擴展的節點(我稱其爲起點集與終點集)便可。當起點和終點在某一時刻交匯了,說明找到了一個從起點到終點的路徑,其路徑長度就是兩個隊列擴展的路徑長度和。

以上就是雙向搜索的大致思路。用圖來表示就是這樣的:

如上圖,咱們從起點和重點(A 和 Z)分別開始搜索,若是起點的擴展狀態和終點的擴展狀態重疊(本質上就是隊列中的元素重疊了),那麼咱們就知道了一個從節點到終點的最短路徑。

動圖演示:

雙向搜索.svg

看到這裏有必要暫停一下插幾句話。


爲何雙向搜索就快了?什麼狀況都會更快麼?那爲何不都用雙向搜索?有哪些使用條件?

咱們一個個回答。

  • 爲何雙向搜索更快了?經過上面的圖咱們發現一般剛開始的時候邊比較少,隊列中的數據也比較少。而隨着搜索的進行,搜索樹愈來愈大, 隊列中的節點隨之增多。和上面雙向搜索相似,這種增加速度不少狀況下是指數級別的,而雙向搜索能夠將指數的常係數移動到多項式係數。若是不使用雙向搜索那麼搜索樹大概是這樣的:

能夠看出搜索樹大了不少,以致於不少點我都畫不下,只好用 」。。。「 來表示。

  • 什麼狀況下更快?相比於單向搜索,雙向搜索一般更快。固然也有例外,舉個極端的例子,假如從起點到終點只要一條路徑,那麼不管使用單向搜索仍是雙向搜索結果都是同樣。

如圖使用單向搜索仍是雙向搜索都是同樣的。

  • 爲何不都用雙向搜索?實際上作題中,我建議你們儘可能使用單向搜索,由於寫起來更節點,而且大多數均可以經過全部的測試用例。除非你預估到可能會超時或者提交後發現超時的時候再嘗試使用雙向搜索。
  • 有哪些使用條件?正如前面所說:」終點能夠逆向搜索的時候,能夠嘗試雙向 BFS。更本質一點就是:若是你構建的狀態空間的邊是雙向的,那麼就可使用雙向 BFS。

讓咱們繼續回到這道題。爲了可以判斷二者是否交匯,咱們可使用兩個 hashSet 分別存儲起點集合終點集。當一個節點既出現起點集又出如今終點集,那就說明出現了交匯。

爲了節省代碼量以及空間消耗,我沒有使用上面的隊列,而是直接使用了哈希表來代替隊列。這種作法可行的關鍵仍然是上面提到的隊列的二值性和單調性

因爲新一輪的出隊列前,隊列中的權值都是相同的。所以從左到右遍歷或者從右到左遍歷,甚至是任意順序遍歷都是無所謂的。(不少題都無所謂)所以使用哈希表而不是隊列也是能夠的。這點須要引發你們的注意。但願你們對 BFS 的本質有更深的理解。

那咱們是否是不須要隊列,就用哈希表,哈希集合啥的存就好了?非也!我會在雙端隊列部分爲你們揭曉。

這道題的具體算法:

  • 定義兩個隊列:q1 和 q2 ,分別從起點和終點進行搜索。
  • 構建鄰接矩陣
  • 每次都嘗試從 q1 和 q2 中的較小的進行擴展。這樣能夠達到剪枝的效果。

  • 若是 q1 和 q2 交匯了,則將二者的路徑拼接起來便可。
代碼
  • 語言支持:Python3

Python3 Code:

class Solution:
    def findLadders(self, beginWord: str, endWord: str, wordList: list) -> list:
        #  剪枝 1
        if endWord not in wordList: return []
        ans = []
        visited = set()
        q1, q2 = {beginWord: [[beginWord]]}, {endWord: [[endWord]]}
        steps = 0
        # 預處理,空間換時間
        neighbors = collections.defaultdict(list)
        for word in wordList:
            for i in range(len(word)):
                neighbors[word[:i] + "*" + word[i + 1 :]].append(word)
        while q1:
            # 剪枝 2
            if len(q1) > len(q2):
                q1, q2 = q2, q1
            nxt = collections.defaultdict(list)
            for _ in range(len(q1)):
                word, paths = q1.popitem()
                visited.add(word)
                for i in range(len(word)):
                    for neighbor in neighbors[word[:i] + "*" + word[i + 1 :]]:
                        if neighbor in q2:
                            # 從 beginWord 擴展過來的
                            if paths[0][0] == beginWord:
                                ans += [path1 + path2[::-1] for path1 in paths for path2 in q2[neighbor]]
                            # 從 endWord 擴展過來的
                            else:
                                ans += [path2 + path1[::-1] for path1 in paths for path2 in q2[neighbor]]
                        if neighbor in wordList and neighbor not in visited:
                            nxt[neighbor] += [path + [neighbor] for path in paths]
            steps += 1
            # 剪枝 3
            if ans and steps + 2 > len(ans[0]):
                break
            q1 = nxt
        return ans
總結

我想經過這道題給你們傳遞的知識點不少。分別是:

  • 隊列不必定非得是常規的隊列,也能夠是哈希表等。不過某些狀況必須是雙端隊列,這個等會講雙端隊列給你們講。
  • 雙向 BFS 是隻適合雙向圖。也就是說從終點也往前推。
  • 雙向 BFS 從較少狀態的一端進行擴展能夠起到剪枝的效果
  • visitd 和 dist/cost 均可以起到記錄點訪問狀況以防止環的產生的做用。不過 dist 的做用更多,相應空間佔用也更大。
雙端隊列

上面提到了 BFS 本質上能夠看作是在一個邊權值爲 1 的圖上進行遍歷。實際上,咱們能夠進行一個簡單的擴展。若是圖中邊權值不全是 1,而是 0 和 1 呢?這樣其實咱們用到雙端隊列。

雙端隊列能夠在頭部和尾部同時進行插入和刪除,而普通的隊列僅容許在頭部刪除,在尾部插入。

使用雙端隊列,當每次取出一個狀態的時候。若是咱們能夠無代價的進行轉移,那麼就能夠將其直接放在隊頭,不然放在隊尾。由前面講的隊列的單調性和二值性不可貴出算法的正確性。而若是狀態轉移是有代價的,那麼就將其放到隊尾便可。這也是不少語言提供的內置數據結構是雙端隊列,而不是隊列的緣由之一。

以下圖:

上面的隊列是普通的隊列。 而下面的雙端隊列,能夠看出咱們在隊頭插隊了一個 B。

動圖演示:

雙端隊列.svg

思考:若是圖對應的權值不出 0 和 1,而是任意正整數呢?

前面咱們提到了是否是不須要隊列,就用哈希表,哈希集合啥的存就好了? 這裏爲你們揭祕。不能夠的。由於哈希表沒法處理這裏的權值爲 0 的狀況。

DFS 和 BFS 的對比

BFS 和 DFS 分別處理什麼樣的問題?二者究竟有什麼樣的區別?這些都值得咱們認真研究。

簡單來講,無論是 DFS 仍是 BFS 都是對題目對應的狀態空間進行搜索

具體來講,兩者區別在於:

  • DFS 在分叉點會任選一條深刻進入,遇到終點則返回,再次返回到分叉口後嘗試下一個選擇。基於此,咱們能夠在路徑上記錄一些數據。由此也能夠衍生出不少有趣的東西。

以下圖,咱們遍歷到 A,有三個選擇。此時咱們能夠任意選擇一條,好比選擇了 B,程序會繼續往下進行選擇分支 2,3 。。。

以下動圖演示了一個典型的 DFS 流程。後面的章節,咱們會給你們帶來更復雜的圖上 DFS。

binary-tree-traversal-dfs

  • BFS 在分叉點會選擇搜索的路徑各嘗試一次。使用隊列來存儲待處理的元素時,隊列中最多只會有兩層的元素,且知足單調性,即相同層的元素在一塊兒。基於這個特色有不少有趣的優化。

以下圖,廣度優先遍歷會將搜索的選擇所有選擇一遍會纔會進入到下一層。和上面同樣,我給你們標註了程序執行的一種可能的順序。

能夠發現,和我上面說的同樣。右側的隊列始終最多有兩層的節點,而且相同層的總在一塊兒,換句話說隊列的元素在層上知足單調性

以下動圖演示了一個典型的 BFS 流程。後面的章節,咱們會給你們帶來更復雜的圖上 BFS。

binary-tree-traversal-bfs

總結

以上就是《搜索篇(上)》的全部內容了。總結一下搜索篇的解題思路:

  • 根據題目信息構建狀態空間(圖)。
  • 對圖進行遍歷(BFS 或者 DFS)
  • 記錄和維護狀態。(好比 visited 維護訪問狀況, 隊列和棧維護狀態的決策方向等等)

咱們花了大量的篇幅對 BFS 和 DFS 進行了詳細的講解,包括兩個的對比。

核心點須要你們注意:

  • DFS 一般都是有遞推關係的,而遞歸關係就是圖的邊。根據遞歸關係你們能夠選擇使用前序遍歷或者後序遍歷。
  • BFS 因爲其單調性,所以適合求解最短距離問題。
  • 。。。

雙向搜索的本質是將複雜度的常數項從一個影響較大的位置(好比指數位)移到了影響較小的位置(好比係數位)。

搜索篇知識點比較密集,但願你們多多總結複習。

下一節,咱們介紹:

  • 回溯與剪枝。
  • 經常使用的指標與統計方法。具體包括:

    1. 樹的深度與子樹大小
    2. 圖的 DFS 序
    3. 圖的拓撲序
    4. 圖的聯通份量
下節內容會首發在《91 天學算法》。想參加的能夠戳這裏瞭解詳情: https://lucifer.ren/blog/2021...
相關文章
相關標籤/搜索