代码随想录训练营day27| 39. 组合总和 40.组合总和II 131.分割回文串

136 阅读2分钟

@TOC


前言

代码随想录算法训练营day27


一、Leetcode 39. 组合总和

1.题目

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。

candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。

对于给定的输入,保证和为 target 的不同组合数少于 150 个。

示例 1:

输入:candidates = [2,3,6,7], target = 7 输出:[[2,2,3],[7]] 解释: 2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。 7 也是一个候选, 7 = 7 。 仅有这两种组合。

示例 2:

输入: candidates = [2,3,5], target = 8 输出: [[2,2,2,2],[2,3,3],[3,5]]

示例 3:

输入: candidates = [2], target = 1 输出: []

提示:

1 <= candidates.length <= 30
2 <= candidates[i] <= 40
candidates 的所有元素 互不相同
1 <= target <= 40

来源:力扣(LeetCode) 链接:leetcode.cn/problems/co…

2.解题思路

方法一:搜索回溯

对于这类寻找所有可行解的题,我们都可以尝试用「搜索回溯」的方法来解决。

回到本题,我们定义递归函数 dfs(target,combine,idx)dfs(target,combine,idx) 表示当前在 candidatescandidates 数组的第 idxidx 位,还剩 targettarget 要组合,已经组合的列表为 combinecombine。递归的终止条件为 target≤0target≤0 或者 candidatescandidates 数组被全部用完。那么在当前的函数中,每次我们可以选择跳过不用第 idxidx 个数,即执行 dfs(target,combine,idx+1)dfs(target,combine,idx+1)。也可以选择使用第 idxidx 个数,即执行 dfs(target−candidates[idx],combine,idx)dfs(target−candidates[idx],combine,idx),注意到每个数字可以被无限制重复选取,因此搜索的下标仍为 idxidx。

更形象化地说,如果我们将整个搜索过程用一个树来表达,即如下图呈现,每次的搜索都会延伸出两个分叉,直到递归的终止条件,这样我们就能不重复且不遗漏地找到所有可行解:

fig1

当然,搜索回溯的过程一定存在一些优秀的剪枝方法来使得程序运行得更快,而这里只给出了最朴素不含剪枝的写法,因此欢迎各位读者在评论区分享自己的见解。

3.代码实现

class Solution {
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        List<List<Integer>> ans = new ArrayList<List<Integer>>();
        List<Integer> combine = new ArrayList<Integer>();
        dfs(candidates, target, ans, combine, 0);
        return ans;
    }

    public void dfs(int[] candidates, int target, List<List<Integer>> ans, List<Integer> combine, int idx) {
        if (idx == candidates.length) {
            return;
        }
        if (target == 0) {
            ans.add(new ArrayList<Integer>(combine));
            return;
        }
        // 直接跳过
        dfs(candidates, target, ans, combine, idx + 1);
        // 选择当前数
        if (target - candidates[idx] >= 0) {
            combine.add(candidates[idx]);
            dfs(candidates, target - candidates[idx], ans, combine, idx);
            combine.remove(combine.size() - 1);
        }
    }
}



二、Leetcode 40.组合总和II

1.题目

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用 一次 。

注意:解集不能包含重复的组合。

示例 1:

输入: candidates = [10,1,2,7,6,1,5], target = 8, 输出: [ [1,1,6], [1,2,5], [1,7], [2,6] ]

示例 2:

输入: candidates = [2,5,2,1,2], target = 5, 输出: [ [1,2,2], [5] ]

提示:

1 <= candidates.length <= 100
1 <= candidates[i] <= 50
1 <= target <= 30

来源:力扣(LeetCode) 链接:leetcode.cn/problems/co…

2.解题思路

方法一:回溯

由于我们需要求出所有和为 targettarget 的组合,并且每个数只能使用一次,因此我们可以使用递归 + 回溯的方法来解决这个问题:

我们用 dfs(pos,rest)dfs(pos,rest) 表示递归的函数,其中 pospos 表示我们当前递归到了数组 candidatescandidates 中的第 pospos 个数,而 restrest 表示我们还需要选择和为 restrest 的数放入列表作为一个组合;

对于当前的第 pospos 个数,我们有两种方法:选或者不选。如果我们选了这个数,那么我们调用 dfs(pos+1,rest−candidates[pos])dfs(pos+1,rest−candidates[pos]) 进行递归,注意这里必须满足 rest≥candidates[pos]rest≥candidates[pos]。如果我们不选这个数,那么我们调用 dfs(pos+1,rest)dfs(pos+1,rest) 进行递归;

在某次递归开始前,如果 restrest 的值为 00,说明我们找到了一个和为 targettarget 的组合,将其放入答案中。每次调用递归函数前,如果我们选了那个数,就需要将其放入列表的末尾,该列表中存储了我们选的所有数。在回溯时,如果我们选了那个数,就要将其从列表的末尾删除。

上述算法就是一个标准的递归 + 回溯算法,但是它并不适用于本题。这是因为题目描述中规定了解集不能包含重复的组合,而上述的算法中并没有去除重复的组合。

例如当 candidates=[2,2]candidates=[2,2],target=2target=2 时,上述算法会将列表 [2][2] 放入答案两次。

因此,我们需要改进上述算法,在求出组合的过程中就进行去重的操作。我们可以考虑将相同的数放在一起进行处理,也就是说,如果数 xx 出现了 yy 次,那么在递归时一次性地处理它们,即分别调用选择 0,1,⋯ ,y0,1,⋯,y 次 xx 的递归函数。这样我们就不会得到重复的组合。具体地:

我们使用一个哈希映射(HashMap)统计数组 candidatescandidates 中每个数出现的次数。在统计完成之后,我们将结果放入一个列表 freqfreq 中,方便后续的递归使用。
    列表 freqfreq 的长度即为数组 candidatescandidates 中不同数的个数。其中的每一项对应着哈希映射中的一个键值对,即某个数以及它出现的次数。

在递归时,对于当前的第 pospos 个数,它的值为 freq[pos][0]freq[pos][0],出现的次数为 freq[pos][1]freq[pos][1],那么我们可以调用

dfs(pos+1,rest−i×freq[pos][0])dfs(pos+1,rest−i×freq[pos][0])

即我们选择了这个数 ii 次。这里 ii 不能大于这个数出现的次数,并且 i×freq[pos][0]i×freq[pos][0] 也不能大于 restrest。同时,我们需要将 ii 个 freq[pos][0]freq[pos][0] 放入列表中。

这样一来,我们就可以不重复地枚举所有的组合了。

我们还可以进行什么优化(剪枝)呢?一种比较常用的优化方法是,我们将 freqfreq 根据数从小到大排序,这样我们在递归时会先选择小的数,再选择大的数。这样做的好处是,当我们递归到 dfs(pos,rest)dfs(pos,rest) 时,如果 freq[pos][0]freq[pos][0] 已经大于 restrest,那么后面还没有递归到的数也都大于 restrest,这就说明不可能再选择若干个和为 restrest 的数放入列表了。此时,我们就可以直接回溯。

3.代码实现

class Solution {
    List<int[]> freq = new ArrayList<int[]>();
    List<List<Integer>> ans = new ArrayList<List<Integer>>();
    List<Integer> sequence = new ArrayList<Integer>();

    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        Arrays.sort(candidates);
        for (int num : candidates) {
            int size = freq.size();
            if (freq.isEmpty() || num != freq.get(size - 1)[0]) {
                freq.add(new int[]{num, 1});
            } else {
                ++freq.get(size - 1)[1];
            }
        }
        dfs(0, target);
        return ans;
    }

    public void dfs(int pos, int rest) {
        if (rest == 0) {
            ans.add(new ArrayList<Integer>(sequence));
            return;
        }
        if (pos == freq.size() || rest < freq.get(pos)[0]) {
            return;
        }

        dfs(pos + 1, rest);

        int most = Math.min(rest / freq.get(pos)[0], freq.get(pos)[1]);
        for (int i = 1; i <= most; ++i) {
            sequence.add(freq.get(pos)[0]);
            dfs(pos + 1, rest - i * freq.get(pos)[0]);
        }
        for (int i = 1; i <= most; ++i) {
            sequence.remove(sequence.size() - 1);
        }
    }
}


三、Leetcode 131.分割回文串

1.题目

给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。

回文串 是正着读和反着读都一样的字符串。

示例 1:

输入:s = "aab" 输出:[["a","a","b"],["aa","b"]]

示例 2:

输入:s = "a" 输出:[["a"]]

提示:

1 <= s.length <= 16
s 仅由小写英文字母组成

来源:力扣(LeetCode) 链接:leetcode.cn/problems/pa…

2.解题思路

方法一:回溯 + 动态规划预处理

思路与算法

由于需要求出字符串 ss 的所有分割方案,因此我们考虑使用搜索 + 回溯的方法枚举所有可能的分割方法并进行判断。

假设我们当前搜索到字符串的第 ii 个字符,且 s[0..i−1]s[0..i−1] 位置的所有字符已经被分割成若干个回文串,并且分割结果被放入了答案数组 ansans 中,那么我们就需要枚举下一个回文串的右边界 jj,使得 s[i..j]s[i..j] 是一个回文串。

因此,我们可以从 ii 开始,从小到大依次枚举 jj。对于当前枚举的 jj 值,我们使用双指针的方法判断 s[i..j]s[i..j] 是否为回文串:如果 s[i..j]s[i..j] 是回文串,那么就将其加入答案数组 ansans 中,并以 j+1j+1 作为新的 ii 进行下一层搜索,并在未来的回溯时将 s[i..j]s[i..j] 从 ansans 中移除。

如果我们已经搜索完了字符串的最后一个字符,那么就找到了一种满足要求的分割方法。

细节

当我们在判断 s[i..j]s[i..j] 是否为回文串时,常规的方法是使用双指针分别指向 ii 和 jj,每次判断两个指针指向的字符是否相同,直到两个指针相遇。然而这种方法会产生重复计算,例如下面这个例子:

当 s=aabas=aaba 时,对于前 22 个字符 aaaa,我们有 22 种分割方法 [aa][aa][a,a][a,a],当我们每一次搜索到字符串的第 i=2i=2 个字符 bb 时,都需要对于每个 s[i..j]s[i..j] 使用双指针判断其是否为回文串,这就产生了重复计算。

因此,我们可以将字符串 ss 的每个子串 s[i..j]s[i..j] 是否为回文串预处理出来,使用动态规划即可。设 f(i,j)f(i,j) 表示 s[i..j]s[i..j] 是否为回文串,那么有状态转移方程:

f(i,j)={True,i≥jf(i+1,j−1)∧(s[i]=s[j]),otherwisef(i,j)={True,f(i+1,j−1)∧(s[i]=s[j]),​i≥jotherwise​

其中 ∧∧ 表示逻辑与运算,即 s[i..j]s[i..j] 为回文串,当且仅当其为空串(i>ji>j),其长度为 11(i=ji=j),或者首尾字符相同且 s[i+1..j−1]s[i+1..j−1] 为回文串。

预处理完成之后,我们只需要 O(1)O(1) 的时间就可以判断任意 s[i..j]s[i..j] 是否为回文串了。

3.代码实现

class Solution {
    boolean[][] f;
    List<List<String>> ret = new ArrayList<List<String>>();
    List<String> ans = new ArrayList<String>();
    int n;

    public List<List<String>> partition(String s) {
        n = s.length();
        f = new boolean[n][n];
        for (int i = 0; i < n; ++i) {
            Arrays.fill(f[i], true);
        }

        for (int i = n - 1; i >= 0; --i) {
            for (int j = i + 1; j < n; ++j) {
                f[i][j] = (s.charAt(i) == s.charAt(j)) && f[i + 1][j - 1];
            }
        }

        dfs(s, 0);
        return ret;
    }

    public void dfs(String s, int i) {
        if (i == n) {
            ret.add(new ArrayList<String>(ans));
            return;
        }
        for (int j = i; j < n; ++j) {
            if (f[i][j]) {
                ans.add(s.substring(i, j + 1));
                dfs(s, j + 1);
                ans.remove(ans.size() - 1);
            }
        }
    }
}