「算法」| 回溯算法解題框架

點贊關(guān)注,不再迷路,你的支持對我意義重大!

?? Hi,我是丑丑。本文「數(shù)據(jù)結(jié)構(gòu) & 算法」| 導(dǎo)讀 —— 登高博見 已收錄,這里有 Android 進階成長路線筆記 & 博客,歡迎跟著彭丑丑一起成長。(聯(lián)系方式在 GitHub)

前言

  • 回溯算法的思想并不復(fù)雜,但是在回溯基礎(chǔ)上的不少變型題也是面試高頻考點,掌握基本的解題框架很重要。
  • 在這篇文章里,我將梳理回溯算法的基本概念 & 常考題型。如果能幫上忙,請務(wù)必點贊加關(guān)注,這真的對我非常重要。
排列 & 組合 & 子集問題 提示 & 題解
46. 全排列
Permutations
【題解】
47. 全排列 II
Permutations II
【題解】
39. 組合總和
Combination Sum
【題解】
40. 組合總和
Combination Sum II
【題解】
78.子集
Subsets
【題解】
90. 子集 II
Subsets II
【題解】
784. 字母大小寫全排列
Letter Case Permutation
【題解】
386. 字典序排數(shù)
Lexicographical Numbers
【題解】
17. 電話號碼的字母組合
Letter Combinations of a Phone Number
【題解】
22. 括號生成
Generate Parentheses
【題解】
字典序排列 提示 & 題解
31. 下一個排列
Next Permutation
【題解】
60. 第 k 個排列
Permutation Sequence
【題解】
440. 字典序的第K小數(shù)字
K-th Smallest in Lexicographical Order
二維平面搜索 提示 & 題解
79. 單詞搜索
Word Search
【題解】
212. 單詞搜索 II
Word Search II
Flood Fill 提示 & 題解
733. 圖像渲染
Flood Fill
【題解】
200. 島嶼數(shù)量
Number of Islands
【題解】
130. 被圍繞的區(qū)域
Surrounded Regions
【題解】
44. 通配符匹配
Wildcard Matching
10. 正則表達式匹配
Regular Expression Matching
488. 祖瑪游戲
Zuma Game
529. 掃雷游戲
Minesweeper
其他 提示 & 題解
51. N皇后
N-Queens
52. N皇后 II
N-Queens II
37. 解數(shù)獨
Sudoku Solver
301. 刪除無效括號
Remove Invalid Parentheses
1249. 最小數(shù)量的移除無效括號
Minimum Remove to Make Valid Parentheses

目錄


1. 概述

1.1 回溯思想

回溯算法(Backtrack)是一種試錯思想,本質(zhì)上是深度優(yōu)先搜索。即:從問題的某一種狀態(tài)出發(fā),依次嘗試現(xiàn)有狀態(tài)可以做出的選擇從而進入下一個狀態(tài)。遞歸這個過程,如果在某個狀態(tài)無法做更多選擇,或者已經(jīng)找到目標(biāo)答案時,則回退一步甚至多步重新嘗試,直到最終所有選擇都嘗試過。

整個過程就像走迷宮一樣,當(dāng)我們遇到一個分叉口時,可以選擇從一個方向進去嘗試。如果走到死胡同則返回上一個分叉口,選擇另外一條方向繼續(xù)嘗試,直到發(fā)現(xiàn)沒有出口,或者找到出口。

1.2 回溯的三要素

理解了回溯算法的思想,下面我們來分析回溯的關(guān)鍵點。在回溯算法中,需要考慮三個要素:路徑、選擇列表、結(jié)束條件,以走迷宮為例:

回溯就是“走回頭路”
  • 1. 路徑:已經(jīng)做出的選擇
  • 2. 選擇列表:當(dāng)前狀態(tài)可以做出的選擇
  • 3. 結(jié)束條件:選擇列表為空,或者找到目標(biāo)

要走出這個迷宮,我們需要重復(fù)做一件事:選擇從一個方向進去嘗試。如果走到死胡同則返回上一個分叉口,選擇另外一條方向繼續(xù)嘗試。用程序?qū)崿F(xiàn)出來,這個重復(fù)做的事就是遞歸函數(shù),實際中我們可以遵循一個解題模板 & 思路:

fun backtrack(){
    1. 判斷當(dāng)前狀態(tài)是否滿足終止條件
    if(終止條件){
        return solution
    }
    2. 否則遍歷選擇列表
    for(選擇 in 選擇列表){
        3. 做出選擇
        solution.push(選擇)
        4. 遞歸
        backtrack()
        5. 回溯(撤銷選擇)
        stack.pop(選擇)
    }
}

需要注意的是,解題框架 & 思路不是死板的,應(yīng)根據(jù)具體問題具體分析。例如:回溯(撤銷選擇)并不是必須的,第 3.2 節(jié)第 k 個排序、第 5 節(jié)島嶼數(shù)量問題中,它們在深層函數(shù)返回后沒有必要回溯。

1.3 回溯剪枝

由于回溯算法的時間復(fù)雜度非常高,當(dāng)我們遇到一個分支時,如果“有先見之明”,能夠知道某個選擇最終一定無法找到答案,那么就不應(yīng)該去嘗試走這條路徑,這個步驟叫作剪枝。

那么,怎么找到這個“先見之明”呢?經(jīng)過我的總結(jié),大概有以下幾種情況:

  • 重復(fù)狀態(tài)

例如:47. Permutations II 全排列 II(用重復(fù)數(shù)字) 這道題給定一個可包含重復(fù)數(shù)字的序列,要求返回所有不重復(fù)的全排列,例如輸入[1,1,2]預(yù)期的輸出為[1,1,2]、[1,2,1]、[2,1,1]。用我們前面介紹的解題模板,這道題并不難:

class Solution {
    fun permute(nums: IntArray): List<List<Int>> {
        val result = ArrayList<List<Int>>()

        // 選擇列表
        val useds = BooleanArray(nums.size) { false }
        // 路徑
        val track = LinkedList<Int>()

        fun backtrack() {
            // 終止條件
            if (track.size == nums.size) {
                result.add(ArrayList<Int>(track))
                return
            }
            for ((index, used) in useds.withIndex()) {
                if (!used) {
                    // 做出選擇
                    track.push(nums[index])
                    useds[index] = true
                    backtrack()
                    // 回溯
                    track.pop()
                    useds[index] = false
                }
            }
        }

        backtrack()

        return result
    }
}

然而,因為原數(shù)組中有兩個 1 ,所以結(jié)果中會存在一些重復(fù)排列,怎么去重呢?一種方法是在得到排列之后,檢查結(jié)果集合中是否已經(jīng)有相同的排列,這是一個O(n^2)的比較,顯然是不明智的。另一種方法就是尋找重復(fù)狀態(tài),打從一開始就“有先見之明”地避開一些選擇。

我們先說說什么是重復(fù)狀態(tài)?還記得我們說的回溯三要素嗎:路徑、選擇列表、結(jié)束條件。通常來說,在這三個要素里面結(jié)束條件是固定的,而路徑和選擇列表會隨著每次選擇 & 回溯而變化。

也就是說,當(dāng)我們發(fā)現(xiàn)兩個狀態(tài)的路徑和選擇列表完全相同時,說明這兩個狀態(tài)是完全重復(fù)的。以兩個重復(fù)狀態(tài)為起點進行遞歸,最終得到的結(jié)果也一定是重復(fù)的。在這道題里,我們先對輸入執(zhí)行 快速排序,在之后的每次選擇之前,先判斷當(dāng)前狀態(tài)是否與上一個選擇重復(fù),是則直接跳過。

[1]與[1`]重復(fù),[2,1]與[2`,1]重復(fù),最終得到重復(fù)結(jié)果
class Solution {
    fun permuteUnique(nums: IntArray): List<List<Int>> {

        val result = ArrayList<LinkedList<Int>>()
        if(nums.size <= 0){
            result.add(LinkedList<Int>())
            return result
        }

        // 排序
        Arrays.sort(nums)

        // 選擇列表
        val used = BooleanArray(nums.size){false}
        // 路徑
        val track = LinkedList<Int>()

        fun backtrack(){
            // 終止條件
            if(track.size >= nums.size){
                result.add(LinkedList<Int>(track))
                return
            }
            
            for((index,num) in nums.withIndex()){
                if(used[index]){
                    continue
                }
                if(index > 0 && !used[index - 1] && nums[index] == nums[index - 1]){
                    continue
                }
                // 做出選擇
                used[index] = true
                track.push(num)
                // 遞歸
                backtrack()
                // 回溯
                used[index] = false
                track.pop()
            }
        }

        backtrack()
        
        return result
    }
}
  • 最終解確定

當(dāng)我們在一個選擇的分支中確定了最終解后,就沒必要去嘗試其他的選擇了。例如在 79. Word Search 單詞搜索 中,當(dāng)確定單詞存在時,就沒必要繼續(xù)搜索了,在 第 4 節(jié) 會專門分析這道題。

fun backtrack(...){
    for (選擇 in 選擇列表) {
        1. 做出選擇
        2. 遞歸
        val match = backtrack(...)
        3. 回溯
        if (match) {
            return true
        }
    }
}
  • 無效選擇

當(dāng)我們可以根據(jù)已知條件判斷某個選擇無法找到最終解時,就沒必要去嘗試這個選擇了。例如:39. Combination Sum 組合總和60. Permutation Sequence 第 k 個排列


3. 排列 & 組合 & 子集問題

3.1 排列 & 組合問題

排列(permutation)& 組合(combination)& 子集(subset)可以說是回溯算法里最常規(guī)的問題了。其中,子集問題本質(zhì)上也是組合問題。我們先通過一個簡單的問題帶大家體會 排列 & 組合 的區(qū)別:

  • 排列問題:
    有 3 個不同的小球 A,B,C,從中取出 2 個放稱一排,有幾種方法?
  • 組合問題:
    有 3 個不同的小球 A,B,C,從中取出 2 個放成一堆,有幾種方法?

一排和一堆的區(qū)別是什么呢?很明顯,一排是有順序的,而一堆是無順序的。例如 [A B C] 和 [A C B] 是不同的,而 {A B C} 和 {A C B} 是相同的。

從三個球中取一個、兩個、三個的方法

從上面這張圖里,其實可以清楚地看到,組合問題是在排列問題的基礎(chǔ)上去除重復(fù)集合;子集問題是合并了多個不同規(guī)模的組合問題。

那么,如何排除元素相同,順序不同的集合呢?這里提一個很好理解的方法,相信一說出來很多同學(xué)都會煥然大悟:“我的初中數(shù)學(xué)老師就是這么教我的?!?/p>

可以看到,只要避免組合之前的元素,就可以避免重復(fù)。例如在選擇 {B, }之后,就不要組合之前的 A 元素了,否則會造成重復(fù)。因為在 {A, } 的分支里,已經(jīng)存在過 {A, B} 的組合了。因此,我們可以使用一個 from 變量來標(biāo)示當(dāng)前狀態(tài)允許的選擇列表范圍。

下面給出從 n 個數(shù)中取 k 的數(shù)的排列 & 組合代碼,由于遞歸解法代碼的可解釋性更強,讀者應(yīng)優(yōu)先保證可以熟練地寫出遞歸解法:

復(fù)雜度分析:

  • 全排列

總共有n!個子問題,每個子問題的時間復(fù)雜度 O(n),因此總的時間復(fù)雜度 n*n! 。除了輸出數(shù)組外,空間復(fù)雜度為 O(n)

  • 組合

總共有 2^n 個子問題,每個子問題的時間復(fù)雜度 O(n),因此總的時間復(fù)雜度 n*2^n。除了輸出數(shù)組外,空間復(fù)雜度為 O(n);

3.2 字典序排列問題

在排列問題中,還有一個字典序排列(Dictionary order)的概念,即:基于字母順序排列,就像單詞在字典中的排列順序一樣,例如 [A B C] 排在 [A C B] 之前。要得到字典序的全排列,遞歸解法和非遞歸解法都可以實現(xiàn)。

使用遞歸解法,只需要保證選擇列表按照字母排序,最終得到的全排列就是字典序排序,實現(xiàn)起來也比較簡單直觀,在 第 1.4 節(jié) 已經(jīng)給出答案了。

使用非遞歸解法的基本思想是:從當(dāng)前字符串出發(fā),直接找出字典序下一個排列,例如從 [A B C] 直接找到下一個排列 [A C B]。怎么找呢,可以先簡單思考下這個問題:

  • 下一個排列

31. Next Permutation 下一個排列 將給定數(shù)字序列重新排列成字典序中下一個更大的排列。

理解了下一個排列的算法之后,寫出全排列的算法就不難了。只需要從第一個排列開始依次輸出下一個排列,最終就得到了字典序全排列。有興趣可以到上一節(jié)的題解中查看,這里就不貼出來了。

  • 第 k 個排列

除了求下一個排列外,求第 k 個排列也是很常見了。例如:60. Permutation Sequence 第 k 個排列、440. K-th Smallest in Lexicographical Order 字典序的第K小數(shù)字。基本思想是:通過計算階乘,提前知道這一個分支的葉子節(jié)點個數(shù),如果 k 不在這個分支上則剪枝:


4. 二維平面搜索問題

文章開頭提到的走迷宮問題,其實就是在二維平面上的回溯算法問題,終止條件時當(dāng)前位置為終點。既然是回溯算法,怎么都逃不出我們反復(fù)強調(diào)的三要素:路徑 & 選擇列表 & 終止條件:

4.1 路徑 - 二維數(shù)組 useds

在全排列問題中,我們使用了一維布爾數(shù)組 used,用來標(biāo)記已經(jīng)走過的路徑。同樣地,在二維平面里,我們需要使用二維布爾數(shù)組,來標(biāo)記已經(jīng)走過的路徑。

1. 一維數(shù)組 int[] useds
2. 二維數(shù)組 int[][] useds

4.2 選擇列表 - 偏移量數(shù)組

在二維平面上搜索時,一個點的選擇列表就是它的上下左右方向(除了來的方向),為了方便編碼,可以使用一個偏移量數(shù)組,偏移量數(shù)組內(nèi)的 4 個偏移的順序是無關(guān)緊要;

int[][] direction = {{-1, 0}, {0, -1}, {0, 1}, {1, 0}};

4.3 檢查邊界

二維平面通常是有邊界的,因此可以寫一個函數(shù)判斷當(dāng)前輸入位置是否越界:

private boolean check(int row, int column) {
    return row >= 0 && row < m && column >= 0 && column < n;
}

有了前面的鋪墊,我們來看這道題:79. Word Search 單詞搜索 就比較好理解了。


5. Flood Fill 問題

Flood Fill(泛洪填充,或稱 Seed Fill 種子填充),是上一節(jié)二維平面搜索問題的進階版本。即:在二維平面上找出滿足條件的相連節(jié)點

所謂相連節(jié)點,指的是兩個節(jié)點上下左右 4 個方向存在相連的節(jié)點。有的題目會擴展到 8 個方向(斜對角的 4 個方向),不過只是多了幾個方向而已,沒有很大區(qū)別。例如,下面這幅圖將中心節(jié)點相連的白色方格上色:

填充顏色到連通的白色方格

整個解題框架與上一節(jié)的 二維平面搜索問題 大體相同,這里著重講解另一種解法:并查集,在這篇文章里,我們已經(jīng)詳細講解過并查集的使用場景與解題技巧:《數(shù)據(jù)結(jié)構(gòu)面試題 | 并查集 & 聯(lián)合 - 查找算法》

簡單來說:并查集適用于處理不相交集合的連通性問題,這正適用于解決 Flood Fill 的連通問題。我們可以將與中心節(jié)點相連的節(jié)點合并到同一個分量中,全部處理完成后,通過查詢并查集便可判斷兩個節(jié)點是否處于相連:

提示: 同時使用路徑壓縮和按秩合并,查詢的時間復(fù)雜度接近O(1)


6. 總結(jié)

回溯算法的思想并不復(fù)雜,但是確實高頻考點;熟練掌握回溯算法,對于理解動態(tài)規(guī)劃算法也很有幫助,學(xué)習(xí)優(yōu)先級較高。


參考資料


創(chuàng)作不易,你的「三連」是丑丑最大的動力,我們下次見!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容