回溯法解決全排列問題總結

CodeTiger發表於2021-06-24

1、瞭解全排列和回溯

所謂全排列就是從n個元素中取出n個元素按照一定的順序進行排列,所有的排列情況叫做全排列

這n個元素又分為兩種情況,一種是n個元素存在重複元素,一種是n個元素不存在重複元素。不存在重複元素的好辦,關鍵是存在重複元素的,我們在求解過程中需要進行處理。

回溯法,名字很高大上,其實本質就是窮舉。這裡我們結合三道題來理解如何使用回溯法解決全排列問題。

(1)46. 全排列
(2)47. 全排列 II
(3)劍指 Offer 38. 字串的排列

2、全排列問題分析

比如給定陣列[1, 2, 3],求所有可能的全排列。

如果讓我們在紙上寫的話,很容易可以寫出來[1, 2, 3],[1, 3, 2],[2, 1, 3],[2, 3, 1],[3, 1, 2],[3, 2, 1]

不妨抽象成下面這棵樹

在這裡插入圖片描述
那麼只需要從根節點開始遍歷,記錄路徑上的數字,到葉子節點就得到了一個排序,遍歷完這棵樹,就得到了全排列。我們可以定義下面幾個概念:

  • 已選擇列表:就是已經選擇的元素。
  • 可選擇列表:就是可以選擇的元素。

在這裡插入圖片描述
那麼在這裡,到葉子節點其實就是可選擇列表為空的時候,此時就得到一個排列。就跟二叉樹的遍歷一樣,到了葉子節點後,我們需要回到它的父節點,去走它的同胞節點。所以我們在得到一個全排列之後,再把已選擇列表的元素一個個彈出來放到未選擇列表,重新進行選擇。

在這裡插入圖片描述
那麼可以總結出回溯法的虛擬碼如下

if (已選擇列表的長度 == 元素列表長度)
	得到一個全排列
for 元素 in 元素列表
	判斷元素是否在可選列表
	# 做選擇
	已選列表.add(元素)
	backTrace(元素列表, 已選擇列表)
	# 撤銷選擇
	已選列表.remove(元素)

3、例項分析

3.1 不含重複元素的全排列

首先看看不含重複元素的全排列。46. 全排列

根據上面的思路,其實很快就可以寫出來

class Solution {
    List<List<Integer>> res = new LinkedList<>();
    public List<List<Integer>> permute(int[] nums) {
        LinkedList<Integer> track = new LinkedList<>();
        backTrace(nums, track);
        return res;
    }

    private void backTrace(int[] nums, LinkedList<Integer> track) {
        // 相等的時候,說明得到了一個全排列
        if (track.size() == nums.length) {
            res.add(new LinkedList(track));
            return;
        }

        for (int i = 0; i < nums.length; i++) {
            // 如果已經存在該元素,就不新增
            if (track.contains(nums[i])) {
                continue;
            }

            // 選擇元素
            track.add(nums[i]);
            backTrace(nums, track);
            // 撤銷選擇
            track.removeLast();
        }
    }
}

是不是和模板大差不差。

3.2 含重複元素的全排列

47. 全排列 II劍指 Offer 38. 字串的排列

一道是數字,一道是字串。

先看數字的。有了上面這題的基礎,這道題其實也不難了,在求解的過程中,有兩個點需要注意:

  • 不能用contains方法去判斷list中是否存在重複元素了,這樣勢必會得不到一個排列。因為我們需要的是不重複的排列而不是不重複的元素,所以我們需要一個boolean陣列通過下標去判斷某個元素是否已經被加入已選擇列表。
  • 得到全部排列後,我們需要去掉重複的排序,這裡可以把資料結構換成Set進行去重,然後再把Set換成List。不過這種方式是十分低效的,因為有很多無效的狀態會被計算。最好的方法就是在回溯過程中進行剪枝,無效的狀態直接跳過不計算。

Set去重

class Solution {
    Set<List<Integer>> temp = new HashSet<>();
    public List<List<Integer>> permuteUnique(int[] nums) {
        LinkedList<Integer> list = new LinkedList<>();
        // 記錄已經訪問過的元素
        boolean[] visited = new boolean[nums.length];
        backTrace(nums, list, visited);
        List<List<Integer>> res = new LinkedList<>(temp);
        return res;
    }

    private void backTrace(int[] nums, LinkedList<Integer> list, boolean[] visited) {
        if (list.size() == nums.length) {
            temp.add(new LinkedList(list));
            return;
        }

        for (int i = 0; i < nums.length; i++) {
            if (visited[i]) {
                continue;
            }

            // 下標為i的元素已經訪問過
            visited[i] = true;
            list.add(nums[i]);
            backTrace(nums, list, visited);
            // 移除list的元素同時將下標為i的元素置為未訪問狀態
            list.removeLast();
            visited[i] = false;
        }
    }
}

可以發現和上一題不一樣的地方,就是使用了一個boolean[] visited陣列去記錄哪些元素被訪問哪些沒有被訪問,而不是通過contains方法去判斷。另外就是使用了Set去儲存排列結果,這樣就能去掉重複結果,但效率不太行。

在這裡插入圖片描述

可以發現用Set去重效率十分的低。需要考慮在回溯過程中進行剪枝,去掉一些無效的中間狀態。可以參考題解:https://leetcode-cn.com/problems/permutations-ii/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liwe-2/。圖文並茂,這裡直接上程式碼

class Solution {
    Set<List<Integer>> temp = new HashSet<>();
    public List<List<Integer>> permuteUnique(int[] nums) {
        LinkedList<Integer> list = new LinkedList<>();
        // 記錄已經訪問過的元素
        boolean[] visited = new boolean[nums.length];
        // 排序,方便剪枝
        Arrays.sort(nums);
        backTrace(nums, list, visited);
        List<List<Integer>> res = new LinkedList<>(temp);
        return res;
    }

    private void backTrace(int[] nums, LinkedList<Integer> list, boolean[] visited) {
        if (list.size() == nums.length) {
            temp.add(new LinkedList(list));
            return;
        }

        for (int i = 0; i < nums.length; i++) {
            if (visited[i]) {
                continue;
            }

            // 剪枝
            if (i > 0 && nums[i] == nums[i - 1] && !visited[i - 1]) {
                continue;
            }
            
            // 下標為i的元素已經訪問過
            visited[i] = true;
            list.add(nums[i]);
            backTrace(nums, list, visited);
            // 移除list的元素同時將下標為i的元素置為未訪問狀態
            list.removeLast();
            visited[i] = false;
        }
    }
}

在這裡插入圖片描述

可以發現時間從90ms -> 4ms,但這個擊敗率。。。


接下來看看重複字串的, 劍指 Offer 38. 字串的排列

其實這道和重複數字的大差不差,只是資料型別不一樣罷了。。不信看程式碼

class Solution {
    public String[] permutation(String s) {
        // Set去重
        Set<String> set = new HashSet<>();
        char[] chs = s.toCharArray();
        // 記錄已經訪問過的字元
        boolean[] visited = new boolean[s.length()];
        char[] temp = new char[s.length()];
        backTrace(0, chs, set, visited, temp);
        StringBuilder sb = new StringBuilder();
        set.stream().forEach(str -> {
            sb.append(str + ",");
        });
        return sb.substring(0, sb.length() - 1).toString().split(",");
    }

    private void backTrace(int index, char[] chs, Set<String> set, boolean[] visited, char[] con) {
        if (index == chs.length) {
            set.add(new String(con));
            return;
        }

        for (int i = 0; i < chs.length; i++) {
            if (!visited[i]) {
                visited[i] = true;
                con[index] = chs[i];
                backTrace(index + 1, chs, set, visited, con);
                visited[i] = false;
            }
        }
    }
}

4、總結

全排列問題,其實只要記住了這個思路和套路,基本上要寫出來都沒問題,萬變不離其宗,多刷幾道題就可以了。

相關文章