做者:Rhythm_2019java
建立時間:2021.04.04面試
修改時間:2021.04.05算法
Email:rhythm_2019@163.com數組
導讀:本文主要講述利用回溯算法生成數組的子集、組合和排列,三者都是在數組中不斷作「選擇元素」、「遞歸」、「取消選擇」三件事,根據題目的不一樣要求加入一些條件約束(深度限制、判重)便可解決問題。文章最後總結了本身在寫代碼時所犯下的錯誤,經過本文本身也對回溯算法有了信的理解函數
經過閱讀本文您能夠解答下面算法題:學習
固然啦,最重要的是你在寫回溯代碼的時候的那種感受,我在寫代碼的時候犯了不少錯誤,都放在文章的最後啦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]
四個,你們重點留意一下回溯的解法,其餘解法你們看看就好
給定一個數組nums
,生成其全部子集,我想到的思路有:
[0, i]
的全部子集,當前數字2
能夠選擇加入他們,選擇不加入他們,這樣便可生成全部子集,咱們把這個方法稱爲選擇加入吧先是第一種選擇加入的思路
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); } }
將其狀態樹畫出來,大概是這樣子的。途中一行表明遞歸的層級,黃色表示須要被保存的結果
我一開始以位直接加一個哈希表對重複元素進行剪枝便可,折騰了半天才發現問題
圖上淺灰色方塊表示使用哈希表剪枝的元素,好比最右邊灰色的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)
個
子集包含全部組合的狀況,入參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. 電話號碼的字母組合
組合是子集的一部分,可是排列就不同了,每次選擇都能選前面的元素但又不能選到本身,因此咱們能夠建立一個哈希表來存儲選擇過的元素(這裏選擇保存其索引)
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); } }
畫一下圖大概就是這樣
如今包含重複元素,咱們能夠再建立一個哈希表用於記錄當前層已使用過的元素,這樣重複的元素就被剪枝
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); } }
這裏涉及兩個哈希表:
indexVisited
:用來記錄遞歸時走過的元素,他保證了下一次遞歸再也不訪問某元素,做用於全局,須要被一直傳遞的contentVisited
:用來保存某一層已遍歷的元素,這樣就能剪掉重複元素,做用域某一層級,因此不用傳遞這篇文章我大概寫了兩天,感受還有些地方沒說清楚,我以爲要把算法思想正確的表達出來是一件不容易的事,還須要多加理解和練習。對於上面這幾個題目其實套路都差很少
private void recursion() { // 終止條件,通常都是限定遞歸層數 // 循環作選擇 for (int i = start; i <= n; i++) { // 【選擇】 將結果放入數組 // 【遞歸】 Drill down // 【取消選擇】 將選擇移出數組 } }
總結一下須要注意的幾個點:
咱們寫for
循環的時候怎麼寫,應該從哪裏開始到哪裏結束:
若是是子集和組合,爲了避免重複選擇本身以前選擇的元素,咱們應該指定開始下標,每進入一曾遞歸下標日後移動。對於組合若是提米規定了輸出組合的長度咱們能夠適當剪枝。對於排列數,每次均可以選以前的元素,因此是從0開始
何時須要引入HashSet
判重:
不包含重複元素:子集和組合是不須要的,由於經過開始下標咱們已經排除了以前選擇過的元素。而排列數則須要記錄遞歸路上選擇過的索引(元素值也能夠),這樣就不會重複選到以前選過的元素,這個哈希表須要全局使用,須要被傳遞
包含重複元素:咱們須要使用HashSet
對當前層的重複元素進行剪枝,這個哈希表只做用於當前層,不須要傳遞
因此包含重複元素時須要使用哈希表,而排列數原本就必定須要哈希表
list
的傳遞需不須要clome
:固然須要,咱們最好使用構造方法建立新的,並且要注意建立的時機,必定是建立後立刻加入結果集,中間不能有任何add
和remove
操做最後想說的是注意【選擇】和【撤銷選擇】的方式,我曾經是這樣寫的代碼
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
,已經脫了好久了,後面會給你們帶來告計算機的小數和高精算法的內容,還有很是神奇的卡特蘭數,你們能夠期待一下。