回溯算法——子集、組合、排列問題

做者:Rhythm_2019java

建立時間:2021.04.04面試

修改時間:2021.04.05算法

Email:rhythm_2019@163.com數組

導讀:本文主要講述利用回溯算法生成數組的子集、組合和排列,三者都是在數組中不斷作「選擇元素」、「遞歸」、「取消選擇」三件事,根據題目的不一樣要求加入一些條件約束(深度限制、判重)便可解決問題。文章最後總結了本身在寫代碼時所犯下的錯誤,經過本文本身也對回溯算法有了信的理解函數

經過閱讀本文您能夠解答下面算法題:學習

  1. LeetCode78. 子集
  2. LeetCode90. 子集 II
  3. LeetCode77. 組合
  4. LeetCode46. 全排列
  5. LeeCode47. 全排列 II

固然啦,最重要的是你在寫回溯代碼的時候的那種感受,我在寫代碼的時候犯了不少錯誤,都放在文章的最後啦spa

<hr/>code

咱們先回顧一下什麼是遞歸,簡單來講就是本身調用本身,爲何本身調用本身就能解決問題呢?是由於問題存在重複的子問題,並且規模縮小到最小時問題的解是已知的。排序

沒必要將分治、遞歸、回溯華清界限,就當本身在寫遞歸便可

下面是遞歸的模板遞歸

private void recursion(int level, int maxLevel, int param) {
    // Terminator
    
    // Process
    
    // Drill down
    
    // Restore
}

回溯算法其實就是再下探Drill down後對數據進行恢復,也就是Restore,從而達到不斷試探但效果。在平時的算法練習中我常寫遞歸,可是須要回溯的狀況比較少(多是我寫的很少),比較常見的就是走迷宮(DFS染色)、生成子集、N皇后、數獨等

子集

數組[1, 2]的子集有[]、[1]、[2]、[1, 2]四個,你們重點留意一下回溯的解法,其餘解法你們看看就好

LeetCode 78 子集

給定一個數組nums,生成其全部子集,我想到的思路有:

  1. 自頂向下:已知[0, i]的全部子集,當前數字2能夠選擇加入他們,選擇不加入他們,這樣便可生成全部子集,咱們把這個方法稱爲選擇加入吧
  2. 對於任意元素都有兩種選擇,出如今集合裏,不出如今集合裏,這樣就能生成全部子集,這個方法稱之爲選擇出現吧
  3. 回溯算法,分別對數組中的元素作選自、遞歸、取消選擇

先是第一種選擇加入的思路

public List<List<Integer>> subsets(int[] nums) {
    if (nums.length == 0) {
        return new ArrayList<>();
    }
    return _subsets(nums.length - 1, nums);

}

public List<List<Integer>> _subsets(int level, int[] nums) {
    // Terminator
    if (level < 0) {
        List<List<Integer>> ans = new ArrayList<>();
        ans.add(new ArrayList<>());
        return ans;
    }
    // Process & Drill dwon
    // 得到前面的全部子集
    List<List<Integer>> subsets = _subsets(level - 1, nums);

    int size = subsets.size();
    for (int i = 0; i < size; i++) {
        List<Integer> list = new ArrayList<>(subsets.get(i));
        list.add(nums[level]);
        subsets.add(list);
    }

    return subsets;
}

第二種選擇出現的思路

private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
    if (nums.length == 0) {
        return ans;
    }
    Arrays.sort(nums);
    _subsets(0, nums, new ArrayList<>(), new HashSet<>());
    return ans;
}
public void _subsets(int level, int[] nums, ArrayList<Integer> list) {
    // Terminator
    if (level == nums.length) {
        ans.add(list);
        return;
    }
    // Process & Drill down
    ArrayList<Integer> list1 = (ArrayList<Integer>) list.clone();
    ArrayList<Integer> list2 = (ArrayList<Integer>) list.clone();
    
    // 出現
    list1.add(nums[level]);
    _subsets(level + 1, nums, list1, res);
    // 不出現
    _subsets(level + 1, nums, list2, res);
}

<hr/>

最後就是回溯算法,咱們使用循環分別對數組中的元素作選擇,調用遞歸函數,取消選擇

private List<List<Integer>> ans = new ArrayList<>();

private List<List<Integer>> subsets(int[] nums) {
    if (nums.length == 0) {
        return ans;
    }
    _subsets(0, nums, new ArrayList<>());
    return ans;
}

private void _subsets(int level, int[] nums, ArrayList<Integer> list) {
    // 每一步都要保存結果
    ans.add(new ArrayList<>(list));

    for (int i = level; i < nums.length; i++) {
        // 【選擇】
        list.add(nums[i]);
        // 【遞歸】
        _subsets(i + 1, nums, list);
        // 【取消選擇】
        list.remove(level);
    }
}

將其狀態樹畫出來,大概是這樣子的。途中一行表明遞歸的層級,黃色表示須要被保存的結果

子集狀態樹

LeetCode 90 子集 II

子集II

我一開始以位直接加一個哈希表對重複元素進行剪枝便可,折騰了半天才發現問題

子集不排序的後果

圖上淺灰色方塊表示使用哈希表剪枝的元素,好比最右邊灰色的1因爲和最前面的1重複了,咱們就不對他進行遞歸了。黃色表示結果,你們能夠看到深灰色的兩個元素重複了,這時因爲包含重複元素,很天然的[1, 2][2, 1]是重複的只能保留一個

若是我對數組進行排序,問題就會獲得解決

排序後的子集

排序可以將重複元素放在一塊兒,後面的元素就不會生成和前面數字相關的子集

private List<List<Integer>> ans = new ArrayList<>();

public List<List<Integer>> subsetsWithDup(int[] nums) {
    if (nums.length == 0) {
        return ans;
    }
    Arrays.sort(nums);
    _subsets(0, nums, new ArrayList<>(), new HashSet<>());
    return ans;
}

private void _subsets(int level, int[] nums, ArrayList<Integer> list, Set<Integer> visited) {
    ans.add(new ArrayList<>(list));
    for (int i = level; i < nums.length; i++) {
        if (visited.contains(nums[i])) {
            continue;
        }
        visited.add(nums[i]);
        list.add(nums[i]);
        _subsets(i + 1, nums, list, new HashSet<>());
        list.remove(level);
    }
}

這裏介紹了回溯的方法,咱們也可使用選擇出現現的思路,不過也是須要先排序,而後計算重複元素個數,若是就一個,按照之前的邏輯兵分兩路,若是超過n個(n > 1),說明存在重複元素,正確的邏輯應該是讓數字出現0 ~ n

private List<List<Integer>> ans = new ArrayList<>();

public List<List<Integer>> subsetsWithDup(int[] nums) {
    if (nums.length == 0) {
        return ans;
    }
    Arrays.sort(nums);
    _subsets(0, nums, new ArrayList<>(), new HashSet<>());
    return ans;
}

private void _subsets(int level, int[] nums, ArrayList<Integer> list, Set<Integer> visited) {
    // Terminator
    if (level >= nums.length) {
        ans.add(list);
        return;
    }
    // Process & Drill down
    int count = 1, index = level + 1;
    while (index < nums.length && nums[level] == nums[index]) {
        index++;
        count++;
    }
    if (count == 1) {
        // 沒有重複
        ArrayList<Integer> list1 = (ArrayList<Integer>) list.clone();
        ArrayList<Integer> list2 = (ArrayList<Integer>) list.clone();
        // 出現
        list1.add(nums[level]);
        _subsets(level + 1, nums, list1, visited);
        // 不出現
        _subsets(level + 1, nums, list2, visited);
    } else {
        // 存在重複
        ArrayList<Integer> list1 = (ArrayList<Integer>) list.clone();
        // 不出現
        _subsets(level + count, nums, (ArrayList<Integer>) list1.clone(), visited);
        for (int i = 1; i <= count; i++) {
            list1.add(nums[level]);
            // 出現i次
            _subsets(level + count, nums, (ArrayList<Integer>) list1.clone(), visited);
        }
    }
}

組合

之前看動態規劃的時候一直沒弄明白排列和組合的區別,那題Coin Change i/ii記憶猶新。對於一個數組c長度爲n,其組合有C(n, m)個,而排列數則有A(n, m)

LeetCode 77 組合

組合

子集包含全部組合的狀況,入參k其實表示的是遞歸層數,因此咱們只須要在子集的基礎上添加一個終止條件便可

private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
    if (n < 0) {
        return ans;
    }

    _combine(0, 1, n, k, new ArrayList<>());
    return ans;
}

private void _combine(int level, int start, int n, int k, List<Integer> list) {
    if (level == k) {
        ans.add(list);
        return;
    }

    for (int i = start; i <= n - (k - list.size()) + 1; i++) {
        list.add(i);
        _combine(level + 1,  i + 1, n, k, new ArrayList<>(list));
        list.remove(level);
    }
}

你們看圖

組合

其實和子集的一毛同樣,最右邊灰色的3被剪枝的主要是由於他湊不到2個。

數組和數字的生成方式很相似,若是數組中存在重複元素相信你們都會處理了

不那麼相關的題目還有: 17. 電話號碼的字母組合

排列

LeetCode 46 全排列

全排列

組合是子集的一部分,可是排列就不同了,每次選擇都能選前面的元素但又不能選到本身,因此咱們能夠建立一個哈希表來存儲選擇過的元素(這裏選擇保存其索引)

private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> permute(int[] nums) {
    if (nums == null || nums.length == 0) {
        return new ArrayList<>();
    }

    _permute(0,nums, new ArrayList<>(), new HashSet<>());
    return ans;
}

private void _permute(int level, int[] nums, List<Integer> List, Set<Integer> visited) {
    if (level == nums.length) {
        ans.add(List);
        return;
    }

    for (int i = 0; i < nums.length; i++) {
        if (visited.contains(i)) {
            continue;
        }
        visited.add(i);
        List.add(nums[i]);
        _permute(level + 1, nums, new ArrayList<>(List), visited);
        List.remove(nums[i]);
        visited.remove(i);
    }
}

畫一下圖大概就是這樣

全排列

LeetCode 47 全排列 II

全排列II

如今包含重複元素,咱們能夠再建立一個哈希表用於記錄當前層已使用過的元素,這樣重複的元素就被剪枝

private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> permuteUnique(int[] nums) {
    if (nums == null || nums.length == 0) {
        return new ArrayList<>();
    }
    Arrays.sort(nums);
    _permute(0,nums, new ArrayList<>(), new HashSet<>());
    return ans;
}

private void _permute(int level, int[] nums, List<Integer> List, Set<Integer> indexVisited) {
    if (level == nums.length) {
        ans.add(List);
        return;
    }

    Set<Integer> contentVisited = new HashSet<>();
    for (int i = 0; i < nums.length; i++) {
        if (indexVisited.contains(i) || contentVisited.contains(nums[i])) {
            continue;
        }
        contentVisited.add(nums[i]);
        indexVisited.add(i);

        List.add(nums[i]);
        _permute(level + 1, nums, new ArrayList<>(List), indexVisited);
        List.remove(level);

        indexVisited.remove(i);
    }
}

這裏涉及兩個哈希表:

  1. indexVisited:用來記錄遞歸時走過的元素,他保證了下一次遞歸再也不訪問某元素,做用於全局,須要被一直傳遞的
  2. contentVisited:用來保存某一層已遍歷的元素,這樣就能剪掉重複元素,做用域某一層級,因此不用傳遞

總結

這篇文章我大概寫了兩天,感受還有些地方沒說清楚,我以爲要把算法思想正確的表達出來是一件不容易的事,還須要多加理解和練習。對於上面這幾個題目其實套路都差很少

private void recursion() {
    // 終止條件,通常都是限定遞歸層數
    
    // 循環作選擇
    for (int i = start; i <= n; i++) {
        // 【選擇】 將結果放入數組
        // 【遞歸】 Drill down
        // 【取消選擇】 將選擇移出數組
    }
}

總結一下須要注意的幾個點:

  1. 咱們寫for循環的時候怎麼寫,應該從哪裏開始到哪裏結束:

    若是是子集和組合,爲了避免重複選擇本身以前選擇的元素,咱們應該指定開始下標,每進入一曾遞歸下標日後移動。對於組合若是提米規定了輸出組合的長度咱們能夠適當剪枝。對於排列數,每次均可以選以前的元素,因此是從0開始

  2. 何時須要引入HashSet判重:

    不包含重複元素:子集和組合是不須要的,由於經過開始下標咱們已經排除了以前選擇過的元素。而排列數則須要記錄遞歸路上選擇過的索引(元素值也能夠),這樣就不會重複選到以前選過的元素,這個哈希表須要全局使用,須要被傳遞

    包含重複元素:咱們須要使用HashSet對當前層的重複元素進行剪枝,這個哈希表只做用於當前層,不須要傳遞

    因此包含重複元素時須要使用哈希表,而排列數原本就必定須要哈希表

  3. 對於結果list的傳遞需不須要clome:固然須要,咱們最好使用構造方法建立新的,並且要注意建立的時機,必定是建立後立刻加入結果集,中間不能有任何addremove操做
  4. 最後想說的是注意【選擇】和【撤銷選擇】的方式,我曾經是這樣寫的代碼

    private void _combine(int level, int start, int n, int k, List<Integer> list) {
       // ...
    
        for (int i = start; i <= n - (k - list.size()) + 1; i++) {
            list.add(i);
            _combine(level + 1,  i + 1, n, k, new ArrayList<>(list));
            // 注意這裏
            list.remove(new Integer(i));
        }
    }

    這樣寫在元素不重複的狀況下是沒什麼問題的,可是若是存在重複元素的話遞歸時會刪掉第一個重複的元素,因此咱們直接這樣寫

    list.add(i);
    _combine(level + 1,  i + 1, n, k, new ArrayList<>(list));
    // 注意這裏
    list.remove(level);

    這樣刪除元素才正確

<hr/>

我我的以爲算法挺有意思的,本身也聽喜歡寫題,細想一下多是由於這些題目規模較小,但很是奇妙,你的代碼讓計算機變得靈活,學習成本也不會過高,因此我更喜歡算法。

以前面試遇到的一個題目,如何正確計算0.3 / 0.2 ,已經脫了好久了,後面會給你們帶來告計算機的小數和高精算法的內容,還有很是神奇的卡特蘭數,你們能夠期待一下。

相關文章
相關標籤/搜索