面試必看算法題 | 回溯算法解題框架

目錄


1. 概述

1.1 回溯思想

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

整個過程就像走迷宮一樣,當我們遇到一個分叉口時,可以選擇從一個方向進去嘗試。如果走到死衚衕則返回上一個分叉口,選擇另外一條方向繼續嘗試,直到發現沒有出口,或者找到出口。

1.2 回溯的三要素

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

  • 1. 路徑:已經做出的選擇
  • 2. 選擇列表:當前狀態可以做出的選擇
  • 3. 結束條件:選擇列表爲空,或者找到目標

要走出這個迷宮,我們需要重複做一件事:選擇從一個方向進去嘗試。如果走到死衚衕則返回上一個分叉口,選擇另外一條方向繼續嘗試。用程序實現出來,這個重複做的事就是遞歸函數,實際中我們可以遵循一個解題模板 & 思路:

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

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

1.3 回溯剪枝

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

那麼,怎麼找到這個“先見之明”呢?經過我的總結,大概有以下幾種情況:

  • 重複狀態

例如:47. Permutations II 全排列 II(用重複數字) 這道題給定一個可包含重複數字的序列,要求返回所有不重複的全排列,例如輸入[1,1,2]預期的輸出爲[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
    }
}
複製代碼

然而,因爲原數組中有兩個 1 ,所以結果中會存在一些重複排列,怎麼去重呢?一種方法是在得到排列之後,檢查結果集合中是否已經有相同的排列,這是一個<math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>O</mi><mo stretchy="false">(</mo><msup><mi>n</mi><mn>2</mn></msup><mo stretchy="false">)</mo></mrow><annotation encoding="application/x-tex">O(n^2)</annotation></semantics></math>O(n2)的比較,顯然是不明智的。另一種方法就是尋找重複狀態,打從一開始就“有先見之明”地避開一些選擇。

我們先說說什麼是重複狀態?還記得我們說的回溯三要素嗎:路徑、選擇列表、結束條件。通常來說,在這三個要素裏面結束條件是固定的,而路徑和選擇列表會隨着每次選擇 & 回溯而變化。

也就是說,當我們發現兩個狀態的路徑和選擇列表完全相同時,說明這兩個狀態是完全重複的。以兩個重複狀態爲起點進行遞歸,最終得到的結果也一定是重複的。在這道題裏,我們先對輸入執行 快速排序,在之後的每次選擇之前,先判斷當前狀態是否與上一個選擇重複,是則直接跳過。

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
    }
}
  • 最終解確定

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

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

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


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

3.1 排列 & 組合問題

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

  • 排列問題:

有 3 個不同的小球 A,B,C,從中取出 2 個放稱一排,有幾種方法?

  • 組合問題:

有 3 個不同的小球 A,B,C,從中取出 2 個放成一堆,有幾種方法?

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

從上面這張圖裏,其實可以清楚地看到,組合問題是在排列問題的基礎上去除重複集合;子集問題是合併了多個不同規模的組合問題。

那麼,如何排除元素相同,順序不同的集合呢?這裏提一個很好理解的方法,相信一說出來很多同學都會煥然大悟:“我的初中數學老師就是這麼教我的。”

可以看到,只要避免組合之前的元素,就可以避免重複。例如在選擇 {B, }之後,就不要組合之前的 A 元素了,否則會造成重複。因爲在 {A, } 的分支裏,已經存在過 {A, B} 的組合了。因此,我們可以使用一個 from 變量來標示當前狀態允許的選擇列表範圍。

下面給出從 n 個數中取 k 的數的排列 & 組合代碼,由於遞歸解法代碼的可解釋性更強,讀者應優先保證可以熟練地寫出遞歸解法:

複雜度分析:

3.2 字典序排列問題

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

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

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

  • 下一個排列

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

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

  • 第 k 個排列

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


4. 二維平面搜索問題

文章開頭提到的走迷宮問題,其實就是在二維平面上的回溯算法問題,終止條件時當前位置爲終點。既然是回溯算法,怎麼都逃不出我們反覆強調的三要素:路徑 & 選擇列表 & 終止條件:

4.1 路徑 - 二維數組 useds

在全排列問題中,我們使用了一維布爾數組 used,用來標記已經走過的路徑。同樣地,在二維平面裏,我們需要使用二維布爾數組,來標記已經走過的路徑。

1\. 一維數組 int[] useds
2\. 二維數組 int[][] useds

4.2 選擇列表 - 偏移量數組

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

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

4.3 檢查邊界

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

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 種子填充),是上一節二維平面搜索問題的進階版本。即:在二維平面上找出滿足條件的相連節點

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

整個解題框架與上一節的 二維平面搜索問題 大體相同,這裏着重講解另一種解法:並查集,在這篇文章裏,我們已經詳細講解過並查集的使用場景與解題技巧:《數據結構面試題 | 並查集 & 聯合 - 查找算法》

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

提示: 同時使用路徑壓縮和按秩合併,查詢的時間複雜度接近<math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>O</mi><mo stretchy="false">(</mo><mn>1</mn><mo stretchy="false">)</mo></mrow><annotation encoding="application/x-tex">O(1)</annotation></semantics></math>O(1)


6. 總結

回溯算法的思想並不複雜,但是確實高頻考點;熟練掌握回溯算法,對於理解動態規劃算法也很有幫助,學習優先級較高。

本文在開源項目:https://github.com/xieyuliang/Note-Android 中已收錄,裏面包含不同方向的自學編程路線、面試題集合/面經、及系列技術文章等,資源持續更新中...

這次就分享到這裏吧,下篇見

參考資料

作者:彭醜醜
原文地址:https://juejin.cn/post/6882928981268496398

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