一文秒殺所有排列組合子集問題

labuladong發表於2022-03-07

讀完本文,你不僅學會了演算法套路,還可以順便去 LeetCode 上拿下如下題目:

78. 子集(中等)

90. 子集 II(中等)

77. 組合(中等)

39. 組合總和(中等)

40. 組合總和 II(中等)

216. 組合總和 III(中等)

46. 全排列(中等)

47. 全排列 II(中等)

-----------

雖然這幾個問題是高中就學過的,但如果想編寫演算法決這幾類問題,還是非常考驗計算機思維的,本文就講講程式設計解決這幾個問題的核心思路,以後再有什麼變體,你也能手到擒來,以不變應萬變。

無論是排列、組合還是子集問題,簡單說無非就是讓你從序列 nums 中以給定規則取若干元素,主要有以下幾種變體:

形式一、元素無重不可複選,即 nums 中的元素都是唯一的,每個元素最多隻能被使用一次,這也是最基本的形式

以組合為例,如果輸入 nums = [2,3,6,7],和為 7 的組合應該只有 [7]

形式二、元素可重不可複選,即 nums 中的元素可以存在重複,每個元素最多隻能被使用一次

以組合為例,如果輸入 nums = [2,5,2,1,2],和為 7 的組合應該有兩種 [2,2,2,1][5,2]

形式三、元素無重可複選,即 nums 中的元素都是唯一的,每個元素可以被使用若干次

以組合為例,如果輸入 nums = [2,3,6,7],和為 7 的組合應該有兩種 [2,2,3][7]

當然,也可以說有第四種形式,即元素可重可複選。但既然元素可複選,那又何必存在重複元素呢?元素去重之後就等同於形式三,所以這種情況不用考慮。

上面用組合問題舉的例子,但排列、組合、子集問題都可以有這三種基本形式,所以共有 9 種變化。

除此之外,題目也可以再新增各種限制條件,比如讓你求和為 target 且元素個數為 k 的組合,那這麼一來又可以衍生出一堆變體,怪不得面試筆試中經常考到排列組合這種基本題型。

但無論形式怎麼變化,其本質就是窮舉所有解,而這些解呈現樹形結構,所以合理使用回溯演算法框架,稍改程式碼框架即可把這些問題一網打盡

具體來說,你需要先閱讀並理解前文 回溯演算法核心套路,然後記住如下子集問題和排列問題的回溯樹,就可以解決所有排列組合子集相關的問題:

為什麼只要記住這兩種樹形結構就能解決所有相關問題呢?

首先,組合問題和子集問題其實是等價的,這個後面會講;至於之前說的三種變化形式,無非是在這兩棵樹上剪掉或者增加一些樹枝罷了

那麼,接下來我們就開始窮舉,把排列/組合/子集問題的 9 種形式都過一遍,學學如何用回溯演算法把它們一套帶走。

子集(元素無重不可複選)

力扣第 78 題「子集」就是這個問題:

題目給你輸入一個無重複元素的陣列 nums,其中每個元素最多使用一次,請你返回 nums 的所有子集。

函式簽名如下:

List<List<Integer>> subsets(int[] nums)

比如輸入 nums = [1,2,3],演算法應該返回如下子集:

[ [],[1],[2],[3],[1,2],[1,3],[2,3],[1,2,3] ]

好,我們暫時不考慮如何用程式碼實現,先回憶一下我們的高中知識,如何手推所有子集?

首先,生成元素個數為 0 的子集,即空集 [],為了方便表示,我稱之為 S_0

然後,在 S_0 的基礎上生成元素個數為 1 的所有子集,我稱為 S_1

接下來,我們可以在 S_1 的基礎上推匯出 S_2,即元素個數為 2 的所有子集:

為什麼集合 [2] 只需要新增 3,而不新增前面的 1 呢?

因為集合中的元素不用考慮順序, [1,2,3]2 後面只有 3,如果你向前考慮 1,那麼 [2,1] 會和之前已經生成的子集 [1,2] 重複。

換句話說,我們通過保證元素之間的相對順序不變來防止出現重複的子集

接著,我們可以通過 S_2 推出 S_3,實際上 S_3 中只有一個集合 [1,2,3],它是通過 [1,2] 推出的。

整個推導過程就是這樣一棵樹:

注意這棵樹的特性:

如果把根節點作為第 0 層,將每個節點和根節點之間樹枝上的元素作為該節點的值,那麼第 n 層的所有節點就是大小為 n 的所有子集

你比如大小為 2 的子集就是這一層節點的值:

PS:注意,本文之後所說「節點的值」都是指節點和根節點之間樹枝上的元素,且將根節點認為是第 0 層

那麼再進一步,如果想計算所有子集,那隻要遍歷這棵多叉樹,把所有節點的值收集起來不就行了?

直接看程式碼:

List<List<Integer>> res = new LinkedList<>();
// 記錄回溯演算法的遞迴路徑
LinkedList<Integer> track = new LinkedList<>();

// 主函式
public List<List<Integer>> subsets(int[] nums) {
    backtrack(nums, 0);
    return res;
}

// 回溯演算法核心函式,遍歷子集問題的回溯樹
void backtrack(int[] nums, int start) {

    // 前序位置,每個節點的值都是一個子集
    res.add(new LinkedList<>(track));
    
    // 回溯演算法標準框架
    for (int i = start; i < nums.length; i++) {
        // 做選擇
        track.addLast(nums[i]);
        // 通過 start 引數控制樹枝的遍歷,避免產生重複的子集
        backtrack(nums, i + 1);
        // 撤銷選擇
        track.removeLast();
    }
}

看過前文 回溯演算法核心框架 的讀者應該很容易理解這段程式碼把,我們使用 start 引數控制樹枝的生長避免產生重複的子集,用 track 記錄根節點到每個節點的路徑的值,同時在前序位置把每個節點的路徑值收集起來,完成回溯樹的遍歷就收集了所有子集:

最後,backtrack 函式開頭看似沒有 base case,會不會進入無限遞迴?

其實不會的,當 start == nums.length 時,葉子節點的值會被裝入 res,但 for 迴圈不會執行,也就結束了遞迴。

組合(元素無重不可複選)

如果你能夠成功的生成所有無重子集,那麼你稍微改改程式碼就能生成所有無重組合了。

你比如說,讓你在 nums = [1,2,3] 中拿 2 個元素形成所有的組合,你怎麼做?

稍微想想就會發現,大小為 2 的所有組合,不就是所有大小為 2 的子集嘛。

所以我說組合和子集是一樣的:大小為 k 的組合就是大小為 k 的子集

比如力扣第 77 題「組合」:

給定兩個整數 nk,返回範圍 [1, n] 中所有可能的 k 個數的組合。

函式簽名如下:

List<List<Integer>> combine(int n, int k)

比如 combine(3, 2) 的返回值應該是:

[ [1,2],[1,3],[2,3] ]

這是標準的組合問題,但我給你翻譯一下就變成子集問題了:

給你輸入一個陣列 nums = [1,2..,n] 和一個正整數 k,請你生成所有大小為 k 的子集

還是以 nums = [1,2,3] 為例,剛才讓你求所有子集,就是把所有節點的值都收集起來;現在你只需要把第 2 層(根節點視為第 0 層)的節點收集起來,就是大小為 2 的所有組合

反映到程式碼上,只需要稍改 base case,控制演算法僅僅收集第 k 層節點的值即可:

List<List<Integer>> res = new LinkedList<>();
// 記錄回溯演算法的遞迴路徑
LinkedList<Integer> track = new LinkedList<>();

// 主函式
public List<List<Integer>> combine(int n, int k) {
    backtrack(1, n, k);
    return res;
}

void backtrack(int start, int n, int k) {
    // base case
    if (k == track.size()) {
        // 遍歷到了第 k 層,收集當前節點的值
        res.add(new LinkedList<>(track));
        return;
    }
    
    // 回溯演算法標準框架
    for (int i = start; i <= n; i++) {
        // 選擇
        track.addLast(i);
        // 通過 start 引數控制樹枝的遍歷,避免產生重複的子集
        backtrack(i + 1, n, k);
        // 撤銷選擇
        track.removeLast();
    }
}

這樣,標準的子集問題也解決了。

排列(元素無重不可複選)

排列問題在前文 回溯演算法核心框架 講過,這裡就簡單過一下。

力扣第 46 題「全排列」就是標準的排列問題:

給定一個不含重複數字的陣列 nums,返回其所有可能的全排列

函式簽名如下:

List<List<Integer>> permute(int[] nums)

比如輸入 nums = [1,2,3],函式的返回值應該是:

[
    [1,2,3],[1,3,2],
    [2,1,3],[2,3,1],
    [3,1,2],[3,2,1]
]

剛才講的組合/子集問題使用 start 變數保證元素 nums[start] 之後只會出現 nums[start+1..] 中的元素,通過固定元素的相對位置保證不出現重複的子集。

但排列問題的本質就是窮舉元素的位置,nums[i] 之後也可以出現 nums[i] 左邊的元素,所以之前的那一套玩不轉了,需要額外使用 used 陣列來標記哪些元素還可以被選擇

標準全排列可以抽象成如下這棵二叉樹:

我們用 used 陣列標記已經在路徑上的元素避免重複選擇,然後收集所有葉子節點上的值,就是所有全排列的結果:

List<List<Integer>> res = new LinkedList<>();
// 記錄回溯演算法的遞迴路徑
LinkedList<Integer> track = new LinkedList<>();
// track 中的元素會被標記為 true
boolean[] used;

/* 主函式,輸入一組不重複的數字,返回它們的全排列 */
public List<List<Integer>> permute(int[] nums) {
    used = new boolean[nums.length];
    backtrack(nums);
    return res;
}

// 回溯演算法核心函式
void backtrack(int[] nums) {
    // base case,到達葉子節點
    if (track.size() == nums.length) {
        // 收集葉子節點上的值
        res.add(new LinkedList(track));
        return;
    }

    // 回溯演算法標準框架
    for (int i = 0; i < nums.length; i++) {
        // 已經存在 track 中的元素,不能重複選擇
        if (used[i]) {
            continue;
        }
        // 做選擇
        used[i] = true;
        track.addLast(nums[i]);
        // 進入下一層回溯樹
        backtrack(nums);
        // 取消選擇
        track.removeLast();
        used[i] = false;
    }
}

這樣,全排列問題就解決了。

但如果題目不讓你算全排列,而是讓你算元素個數為 k 的排列,怎麼算?

也很簡單,改下 backtrack 函式的 base case,僅收集第 k 層的節點值即可:

// 回溯演算法核心函式
void backtrack(int[] nums, int k) {
    // base case,到達第 k 層
    if (track.size() == k) {
        // 第 k 層節點的值就是大小為 k 的排列
        res.add(new LinkedList(track));
        return;
    }

    // 回溯演算法標準框架
    for (int i = 0; i < nums.length; i++) {
        // ...
        backtrack(nums, k);
        // ...
    }
}

子集/組合(元素可重不可複選)

剛才講的標準子集問題輸入的 nums 是沒有重複元素的,但如果存在重複元素,怎麼處理呢?

力扣第 90 題「子集 II」就是這樣一個問題:

給你一個整數陣列 nums,其中可能包含重複元素,請你返回該陣列所有可能的子集。

函式簽名如下:

List<List<Integer>> subsetsWithDup(int[] nums)

比如輸入 nums = [1,2,2],你應該輸出:

[ [],[1],[2],[1,2],[2,2],[1,2,2] ]

當然,按道理說集合不應該包含重複元素的,但既然題目這樣問了,我們就忽略這個細節吧,仔細思考一下這道題怎麼做才是正事。

就以 nums = [1,2,2] 為例,為了區別兩個 2 是不同元素,後面我們寫作 nums = [1,2,2']

按照之前的思路畫出子集的樹形結構,顯然,兩條值相同的相鄰樹枝會產生重複:

[ 
    [],
    [1],[2],[2'],
    [1,2],[1,2'],[2,2'],
    [1,2,2']
]

所以我們需要進行剪枝,如果一個節點有多條值相同的樹枝相鄰,則只遍歷第一條,剩下的都剪掉,不要去遍歷:

體現在程式碼上,需要先進行排序,讓相同的元素靠在一起,如果發現 nums[i] == nums[i-1],則跳過

List<List<Integer>> res = new LinkedList<>();
LinkedList<Integer> track = new LinkedList<>();

public List<List<Integer>> subsetsWithDup(int[] nums) {
    // 先排序,讓相同的元素靠在一起
    Arrays.sort(nums);
    backtrack(nums, 0);
    return res;
}

void backtrack(int[] nums, int start) {
    // 前序位置,每個節點的值都是一個子集
    res.add(new LinkedList<>(track));
    
    for (int i = start; i < nums.length; i++) {
        // 剪枝邏輯,值相同的相鄰樹枝,只遍歷第一條
        if (i > start && nums[i] == nums[i - 1]) {
            continue;
        }
        track.addLast(nums[i]);
        backtrack(nums, i + 1);
        track.removeLast();
    }
}

這段程式碼和之前標準的子集問題的程式碼幾乎相同,就是新增了排序和剪枝的邏輯。

至於為什麼要這樣剪枝,結合前面的圖應該也很容易理解,這樣帶重複元素的子集問題也解決了。

我們說了組合問題和子集問題是等價的,所以我們直接看一道組合的題目吧,這是力扣第 40 題「組合總和 II」:

給你輸入 candidates 和一個目標和 target,從 candidates 中找出中所有和為 target 的組合。

candidates 可能存在重複元素,且其中的每個數字最多隻能使用一次。

說這是一個組合問題,其實換個問法就變成子集問題了:請你計算 candidates 中所有和為 target 的子集。

所以這題怎麼做呢?

對比子集問題的解法,只要額外用一個 trackSum 變數記錄回溯路徑上的元素和,然後將 base case 改一改即可解決這道題:

List<List<Integer>> res = new LinkedList<>();
// 記錄回溯的路徑
LinkedList<Integer> track = new LinkedList<>();
// 記錄 track 中的元素之和
int trackSum = 0;

public List<List<Integer>> combinationSum2(int[] candidates, int target) {
    if (candidates.length == 0) {
        return res;
    }
    // 先排序,讓相同的元素靠在一起
    Arrays.sort(candidates);
    backtrack(candidates, 0, target);
    return res;
}

// 回溯演算法主函式
void backtrack(int[] nums, int start, int target) {
    // base case,達到目標和,找到符合條件的組合
    if (trackSum == target) {
        res.add(new LinkedList<>(track));
        return;
    }
    // base case,超過目標和,直接結束
    if (trackSum > target) {
        return;
    }

    // 回溯演算法標準框架
    for (int i = start; i < nums.length; i++) {
        // 剪枝邏輯,值相同的樹枝,只遍歷第一條
        if (i > start && nums[i] == nums[i - 1]) {
            continue;
        }
        // 做選擇
        track.add([i]);
        trackSum += nums[i];
        // 遞迴遍歷下一層回溯樹
        backtrack(nums, i + 1, target);
        // 撤銷選擇
        track.removeLast();
        trackSum -= nums[i];
    }
}

排列(元素可重不可複選)

排列問題的輸入如果存在重複,比子集/組合問題稍微複雜一點,我們看看力扣第 47 題「全排列 II」:

給你輸入一個可包含重複數字的序列 nums,請你寫一個演算法,返回所有可能的全排列,函式簽名如下:

List<List<Integer>> permuteUnique(int[] nums)

比如輸入 nums = [1,2,2],函式返回:

[ [1,2,2],[2,1,2],[2,2,1] ]

先看解法程式碼:

List<List<Integer>> res = new LinkedList<>();
LinkedList<Integer> track = new LinkedList<>();
boolean[] used;

public List<List<Integer>> permuteUnique(int[] nums) {
    // 先排序,讓相同的元素靠在一起
    Arrays.sort(nums);
    used = new boolean[nums.length];
    backtrack(nums, track);
    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 (used[i]) {
            continue;
        }
        // 新新增的剪枝邏輯,固定相同的元素在排列中的相對位置
        if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
            continue;
        }
        track.add(nums[i]);
        used[i] = true;
        backtrack(nums);
        track.removeLast();
        used[i] = false;
    }
}

你對比一下之前的標準全排列解法程式碼,這段解法程式碼只有兩處不同:

1、對 nums 進行了排序。

2、新增了一句額外的剪枝邏輯。

類比輸入包含重複元素的子集/組合問題,你大概應該理解這麼做是為了防止出現重複結果。

但是注意排列問題的剪枝邏輯,和子集/組合問題的剪枝邏輯略有不同:新增了 !used[i - 1] 的邏輯判斷。

這個地方理解起來就需要一些技巧性了,且聽我慢慢到來。為了方便研究,依然把相同的元素用上標 ' 以示區別。

假設輸入為 nums = [1,2,2'],標準的全排列演算法會得出如下答案:

[
    [1,2,2'],[1,2',2],
    [2,1,2'],[2,2',1],
    [2',1,2],[2',2,1]
]

顯然,這個結果存在重複,比如 [1,2,2'][1,2',2] 應該只被算作同一個排列,但被算作了兩個不同的排列。

所以現在的關鍵在於,如何設計剪枝邏輯,把這種重複去除掉?

答案是,保證相同元素在排列中的相對位置保持不變

比如說 nums = [1,2,2'] 這個例子,我保持排列中 2 一直在 2' 前面。

這樣的話,你從上面 6 個排列中只能挑出 3 個排列符合這個條件:

[ [1,2,2'],[2,1,2'],[2,2',1] ]

這也就是正確答案。

進一步,如果 nums = [1,2,2',2''],我只要保證重複元素 2 的相對位置固定,比如說 2 -> 2' -> 2'',也可以得到無重複的全排列結果。

仔細思考,應該很容易明白其中的原理:

標準全排列演算法之所以出現重複,是因為把相同元素形成的排列序列視為不同的序列,但實際上它們應該是相同的;而如果固定相同元素形成的序列順序,當然就避免了重複

那麼反映到程式碼上,你注意看這個剪枝邏輯:

// 新新增的剪枝邏輯,固定相同的元素在排列中的相對位置
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
    // 如果前面的相鄰相等元素沒有用過,則跳過
    continue;
}
// 選擇 nums[i]

當出現重複元素時,比如輸入 nums = [1,2,2',2'']2' 只有在 2 已經被使用的情況下才會被選擇,2'' 只有在 2' 已經被使用的情況下才會被選擇,這就保證了相同元素在排列中的相對位置保證固定

好了,這樣包含重複輸入的排列問題也解決了。

子集/組合(元素無重可複選)

終於到了最後一種型別了:輸入陣列無重複元素,但每個元素可以被無限次使用。

直接看力扣第 39 題「組合總和」:

給你一個無重複元素的整數陣列 candidates 和一個目標和 target,找出 candidates 中可以使數字和為目標數 target 的所有組合。candidates 中的每個數字可以無限制重複被選取。

函式簽名如下:

List<List<Integer>> combinationSum(int[] candidates, int target)

比如輸入 candidates = [1,2,3], target = 3,演算法應該返回:

[ [1,1,1],[1,2],[3] ]

這道題說是組合問題,實際上也是子集問題:candidates 的哪些子集的和為 target

想解決這種型別的問題,也得回到回溯樹上,我們不妨先思考思考,標準的子集/組合問題是如何保證不重複使用元素的

答案在於 backtrack 遞迴時輸入的引數:

// 回溯演算法標準框架
for (int i = start; i < nums.length; i++) {
    // ...
    // 遞迴遍歷下一層回溯樹,注意引數
    backtrack(nums, i + 1, target);
    // ...
}

這個 istart 開始,那麼下一層回溯樹就是從 start + 1 開始,從而保證 nums[start] 這個元素不會被重複使用:

那麼反過來,如果我想讓每個元素被重複使用,我只要把 i + 1 改成 i 即可:

// 回溯演算法標準框架
for (int i = start; i < nums.length; i++) {
    // ...
    // 遞迴遍歷下一層回溯樹
    backtrack(nums, i, target);
    // ...
}

這相當於給之前的回溯樹新增了一條樹枝,在遍歷這棵樹的過程中,一個元素可以被無限次使用:

當然,這樣這棵回溯樹會永遠生長下去,所以我們的遞迴函式需要設定合適的 base case 以結束演算法,即路徑和大於 target 時就沒必要再遍歷下去了。

這道題的解法程式碼如下:

List<List<Integer>> res = new LinkedList<>();
// 記錄回溯的路徑
LinkedList<Integer> track = new LinkedList<>();
// 記錄 track 中的路徑和
int trackSum = 0;

public List<List<Integer>> combinationSum(int[] candidates, int target) {
    if (candidates.length == 0) {
        return res;
    }
    backtrack(candidates, 0, target);
    return res;
}

// 回溯演算法主函式
void backtrack(int[] nums, int start, int target) {
    // base case,找到目標和,記錄結果
    if (trackSum == target) {
        res.add(new LinkedList<>(track));
        return;
    }
    // base case,超過目標和,停止向下遍歷
    if (trackSum > target) {
        return;
    }

    // 回溯演算法標準框架
    for (int i = start; i < nums.length; i++) {
        // 選擇 nums[i]
        trackSum += nums[i];
        track.add(nums[i]);
        // 遞迴遍歷下一層回溯樹
        // 同一元素可重複使用,注意引數
        backtrack(nums, i, target);
        // 撤銷選擇 nums[i]
        trackSum -= nums[i];
        track.removeLast();
    }
}

排列(元素無重可複選)

力扣上沒有類似的題目,我們不妨先想一下,nums 陣列中的元素無重複且可複選的情況下,會有哪些排列?

比如輸入 nums = [1,2,3],那麼這種條件下的全排列共有 3^3 = 27 種:

[
  [1,1,1],[1,1,2],[1,1,3],[1,2,1],[1,2,2],[1,2,3],[1,3,1],[1,3,2],[1,3,3],
  [2,1,1],[2,1,2],[2,1,3],[2,2,1],[2,2,2],[2,2,3],[2,3,1],[2,3,2],[2,3,3],
  [3,1,1],[3,1,2],[3,1,3],[3,2,1],[3,2,2],[3,2,3],[3,3,1],[3,3,2],[3,3,3]
]

標準的全排列演算法利用 used 陣列進行剪枝,避免重複使用同一個元素。如果允許重複使用元素的話,直接放飛自我,去除所有 used 陣列的剪枝邏輯就行了

那這個問題就簡單了,程式碼如下:

List<List<Integer>> res = new LinkedList<>();
LinkedList<Integer> track = new LinkedList<>();

public List<List<Integer>> permuteRepeat(int[] nums) {
    backtrack(nums);
    return res;
}

// 回溯演算法核心函式
void backtrack(int[] nums) {
    // base case,到達葉子節點
    if (track.size() == nums.length) {
        // 收集葉子節點上的值
        res.add(new LinkedList(track));
        return;
    }

    // 回溯演算法標準框架
    for (int i = 0; i < nums.length; i++) {
        // 做選擇
        track.add(nums[i]);
        // 進入下一層回溯樹
        backtrack(nums);
        // 取消選擇
        track.removeLast();
    }
}

至此,排列/組合/子集問題的九種變化就都講完了。

最後總結

來回顧一下排列/組合/子集問題的三種形式在程式碼上的區別。

由於子集問題和組合問題本質上是一樣的,無非就是 base case 有一些區別,所以把這兩個問題放在一起看。

形式一、元素無重不可複選,即 nums 中的元素都是唯一的,每個元素最多隻能被使用一次backtrack 核心程式碼如下:

/* 組合/子集問題回溯演算法框架 */
void backtrack(int[] nums, int start) {
    // 回溯演算法標準框架
    for (int i = start; i < nums.length; i++) {
        // 做選擇
        track.addLast(nums[i]);
        // 注意引數
        backtrack(nums, i + 1);
        // 撤銷選擇
        track.removeLast();
    }
}

/* 排列問題回溯演算法框架 */
void backtrack(int[] nums) {
    for (int i = 0; i < nums.length; i++) {
        // 剪枝邏輯
        if (used[i]) {
            continue;
        }
        // 做選擇
        used[i] = true;
        track.addLast(nums[i]);

        backtrack(nums);
        // 取消選擇
        track.removeLast();
        used[i] = false;
    }
}

形式二、元素可重不可複選,即 nums 中的元素可以存在重複,每個元素最多隻能被使用一次,其關鍵在於排序和剪枝,backtrack 核心程式碼如下:

Arrays.sort(nums);
/* 組合/子集問題回溯演算法框架 */
void backtrack(int[] nums, int start) {
    // 回溯演算法標準框架
    for (int i = start; i < nums.length; i++) {
        // 剪枝邏輯,跳過值相同的相鄰樹枝
        if (i > start && nums[i] == nums[i - 1]) {
            continue;
        }
        // 做選擇
        track.addLast(nums[i]);
        // 注意引數
        backtrack(nums, i + 1);
        // 撤銷選擇
        track.removeLast();
    }
}


Arrays.sort(nums);
/* 排列問題回溯演算法框架 */
void backtrack(int[] nums) {
    for (int i = 0; i < nums.length; i++) {
        // 剪枝邏輯
        if (used[i]) {
            continue;
        }
        // 剪枝邏輯,固定相同的元素在排列中的相對位置
        if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
            continue;
        }
        // 做選擇
        used[i] = true;
        track.addLast(nums[i]);

        backtrack(nums);
        // 取消選擇
        track.removeLast();
        used[i] = false;
    }
}

形式三、元素無重可複選,即 nums 中的元素都是唯一的,每個元素可以被使用若干次,只要刪掉去重邏輯即可,backtrack 核心程式碼如下:

/* 組合/子集問題回溯演算法框架 */
void backtrack(int[] nums, int start) {
    // 回溯演算法標準框架
    for (int i = start; i < nums.length; i++) {
        // 做選擇
        track.addLast(nums[i]);
        // 注意引數
        backtrack(nums, i);
        // 撤銷選擇
        track.removeLast();
    }
}


/* 排列問題回溯演算法框架 */
void backtrack(int[] nums) {
    for (int i = 0; i < nums.length; i++) {
        // 做選擇
        track.addLast(nums[i]);

        backtrack(nums);
        // 取消選擇
        track.removeLast();
    }
}

只要從樹的角度思考,這些問題看似複雜多變,實則改改 base case 就能解決,這也是為什麼我在 學習演算法和資料結構的框架思維手把手刷二叉樹(綱領篇) 中強調樹型別題目重要性的原因。

如果你能夠看到這裡,真得給你鼓掌,相信你以後遇到各種亂七八糟的演算法題,也能一眼看透它們的本質,以不變應萬變。

_____________

點選我的頭像 檢視更多優質演算法文章,手把手帶你刷力扣,致力於把演算法講清楚!我的 演算法教程 已經獲得 100k star,歡迎點贊!

相關文章