Leetcode刷题总结——排列组合问题

859 阅读8分钟

排列组合问题最通用的解法就是回溯法,其实也就是深度优先搜索
这里将最基础的排列组合题目做一个整理。

1. 排列问题:

排列问题是对n个位置来进行填充,每个元素都会被用到。
因此每次递归时,都要从头考虑所有的元素。

(1) 无重复数字的排列

给定一个不含重复数字的数组 nums,返回其所有可能的全排列 。
你可以 按任意顺序 返回答案。
leetcode-cn.com/problems/pe…

public class Solution {
    List<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();

    public List<List<Integer>> permute(int[] nums) {
        dfs(nums);
        return ans;
    }

    private void dfs(int[] nums) {
        // 如果已经排列完了所有的数字,则记录结果
        if (path.size() == nums.length) {
            ans.add(new LinkedList<>(path));
            return;
        }

        // 排列问题需要考虑所有的数
        for (int num : nums) {
            // 如果重复了,则跳过
            if (path.contains(num)) {
                continue;
            }
            // 加入当前结果
            path.offer(num);
            // 进行回溯
            dfs(nums);
            // 从当前结果撤销
            path.removeLast();
        }
    }
}

这里巧妙地使用了path.contains(num)来判断当前数num是否已经被使用。

(2) 有重复数字的排列

给定一个可包含重复数字的序列 nums按任意顺序 返回所有不重复的全排列。
leetcode-cn.com/problems/pe…

public class Solution {
    List<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();

    // 是否访问过该索引
    boolean[] visit;

    public List<List<Integer>> permuteUnique(int[] nums) {
        // 把所有的数字进行排列,使得可以跳过相同的数字
        Arrays.sort(nums);
        // 初始化visit数组
        visit = new boolean[nums.length];
        dfs(nums);
        return ans;
    }

    private void dfs(int[] nums) {
        // 如果已经排列完所有的数字,则记录结果
        if (path.size() == nums.length) {
            ans.add(new ArrayList<>(path));
            return;
        }

        // 排列问题需要考虑所有的数
        for (int i = 0; i < nums.length; i++) {
            // 如果当前数被访问过,或者与他相同的前一个数被访问过,则跳过
            if (visit[i] || i > 0 && nums[i] == nums[i - 1] && visit[i - 1]) {
                continue;
            }
            // 加入当前结果
            path.add(nums[i]);
            // 设置为已访问
            visit[i] = true;
            // 进行回溯
            dfs(nums);
            // 设置为未访问
            visit[i] = false;
            // 从当前结果撤销
            path.removeLast();
        }
    }
}

这个题和上个题的不同之处是,nums数组中会有重复的数字,但是要求全排列的结果没有重复。
而代码中最明显的差异是:

// 如果当前数被访问过,或者与他相同的前一个数被访问过,则跳过
if (visit[i] || i > 0 && nums[i] == nums[i - 1] && visit[i - 1]) {
    continue;
}

使用visit数组来保证每个数只会被访问一次,后边的判断条件是保证全排列的结果没有重复。

为什么nums[i]只用和nums[i - 1]做判断呢? 因为在前边我们对数组进行了排序,相同的数被排列在了一起。

// 把所有的数字进行排列,使得可以跳过相同的数字
Arrays.sort(nums);

2. 组合问题:

组合问题,有两种思考方式:

  • 需要依次考虑每个位置,看使用哪个元素来填充它。
  • 需要依次考虑每个元素,看是否选择它;

第一种方式类似于排列问题的解法,都需要关注于哪些元素能试着放在当前位置;
只不过排列问题需要填充多少个位置是确定的,所以位置被填满则结束;
而组合问题不知道有多少位置需要被填充,所以要根据其他条件来结束。

第二种方式类似于01背包问题的思想,对于每一个元素判断选或者不选,然后考虑下一个元素。

选过的元素是否需要再考虑,这需要根据题目要求,看是否能再次选择同样的元素来决定。
但是,肯定不能再去考虑之前的元素,因为已经考虑过了,否则会有重复。

(1) 无重复数字的组合,不能重复选

给你一个整数数组 nums ,数组中的元素 互不相同 ,返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集,你可以按 任意顺序 返回解集。
leetcode-cn.com/problems/su…

先用第一种方式,考虑每个位置:

public class Solution {
    LinkedList<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();

    public List<List<Integer>> subsets(int[] nums) {
        // 第二个参数表示从哪个元素开始是可以考虑的,即可选范围
        dfs(nums, 0);
        return ans;
    }

    private void dfs(int[] nums, int start) {
        // 所有情况都满足,无需判断,直接记录结果
        ans.add(new LinkedList<>(path));

        // 整个循环代表依次把可选范围内的元素放在当前位置上考虑
        for (int i = start; i < nums.length; i++) {
            // 放上去试试,加入当前结果
            path.offer(nums[i]);
            // 进行回溯,因为不能重复选,把可选范围缩小
            dfs(nums, i + 1);
            // 从当前结果撤销
            path.removeLast();
        }
    }
}

这个题有个隐含的条件,就是不能重复选元素,所以每次递归时i都需要加1。

可能有同学就要问了,假如我位置0放入的是值nums[1],那下层递归从i + 1开始的话,不是把nums[0]漏掉了么?
别忘了,假如 num[1]num[0] 是合适的组合,那在考虑位置0时,已经把该答案加入ans中了,因为组合是无序的。

再来使用第二种方式,考虑每个元素:
由于每个数字只有 或者 不选 两种情况,所以也可以这么做:

public class Solution {
    LinkedList<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();

    public List<List<Integer>> subsets(int[] nums) {
        // 第二个参数表示考虑到哪个元素了
        dfs(nums, 0);
        return ans;
    }

    private void dfs(int[] nums, int i) {
        // 结束条件:如果已经考虑到了最后一个元素,则记录结果
        if (nums.length == i) {
            ans.add(new LinkedList<>(path));
            return;
        }

        // 选当前元素
        // 加入当前结果
        path.offer(nums[i]);
        // 进行回溯,从下一个元素开始考虑
        dfs(nums, i + 1);
        // 从当前结果撤销
        path.removeLast();

        // 不选当前元素,从下一个元素开始考虑
        dfs(nums, i + 1);
    }
}

(2) 无重复数字的组合,可以重复选

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target所有不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
leetcode-cn.com/problems/co…

先用第一种方式,考虑每个位置:

public class Solution {
    List<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();

    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        // 把所有的数字进行排序,使得可以进行剪枝判断
        Arrays.sort(candidates);
        // 第三个参数表示当前的和,第四个参数表示从哪个元素开始是可以考虑的,即可选范围
        dfs(candidates, target, 0, 0);
        return ans;
    }

    private void dfs(int[] candidates, int target, int sum, int start) {
        // 结束条件:如果满足了条件,则记录结果
        if (sum == target) {
            ans.add(new LinkedList<>(path));
            return;
        }
        // 整个循环代表依次把可选范围内的元素放在当前位置上考虑
        for (int i = start; i < candidates.length; i++) {
            // 剪枝:因为之前已经排序过了
            if (sum + candidates[i] > target) {
                return;
            }
            sum += candidates[i];
            path.add(candidates[i]);
            // 进行回溯,可以重复选,所以可选范围不变
            dfs(candidates, target, sum, i);
            path.removeLast();
            sum -= candidates[i];
        }
    }
}

再来使用第二种方式,考虑每个元素:

public class Solution {
    List<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();

    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        // 把所有的数字进行排序,使得可以进行剪枝判断
        Arrays.sort(candidates);
        // 第三个参数表示 当前和,第四个参数表示当前考虑到哪个元素了
        dfs(candidates, target, 0, 0);
        return ans;
    }

    private void dfs(int[] candidates, int target, int sum, int i) {
        // 结束条件:如果满足了条件,则记录结果
        if (sum == target) {
            ans.add(new LinkedList<>(path));
            return;
        }

        // 结束条件:如果考虑到了最后一个元素,或者sum已经比target大了,则返回
        if (i == candidates.length || sum > target) {
            return;
        }

        // 选当前元素
        // 加入当前结果
        path.offer(candidates[i]);
        // 计算sum
        sum += candidates[i];
        // 进行回溯,因为可以重复选,所以还是考虑当前元素
        dfs(candidates, target, sum, i);
        // 回退sum
        sum -= candidates[i];
        // 从当前结果撤销
        path.removeLast();

        // 不选当前索引,开始考虑下一个元素
        dfs(candidates, target, sum, i + 1);
    }
}

这里要注意这个结束条件,避免无限递归:

// 如果考虑到了最后一个元素,或者sum已经比target大了,则返回
if (i == candidates.length || sum > target) {
    return;
}

那对于这两种方式应该怎么选择呢?

回溯算法比较难以判断它的时间复杂度,但是理论上考虑每个位置的写法应当会有更少的递归次数。
重要的是,需要清楚它们的 结束条件 ,对于存在重复数字的题目,还需要考虑 去重条件

(3) 有重复数字的组合,不能重复选

给你一个整数数组 nums ,其中可能包含 重复元素,请你返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。
leetcode-cn.com/problems/su…

用第一种方式,考虑每个位置:

public class Solution {
    LinkedList<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();

    public List<List<Integer>> subsetsWithDup(int[] nums) {
        // 把所有的数字进行排序,使得可以进行跳过重复元素
        Arrays.sort(nums);
        // 第二个参数表示从哪个元素开始是可以考虑的,即可选范围
        dfs(nums, 0);
        return ans;
    }

    private void dfs(int[] nums, int start) {
        // 所有情况都满足,无需判断,直接记录结果
        ans.add(new LinkedList<>(path));

        // 整个循环代表依次把可选范围内的元素放在当前位置上考虑
        for (int i = start; i < nums.length; i++) {
            // 如果当前数与他前一个数相同,则跳过,不用再考虑了
            if (i > start && nums[i] == nums[i - 1]) {
                continue;
            }
            // 放上去试试,加入当前结果
            path.offer(nums[i]);
            // 进行回溯,因为不能重复选,把可选范围缩小
            dfs(nums, i + 1);
            // 从当前结果撤销
            path.removeLast();
        }
    }
}

用第二种方式,考虑每个元素:

public class Solution {
    LinkedList<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> list = new LinkedList<>();

    public List<List<Integer>> subsetsWithDup(int[] nums) {
        // 把所有的数字进行排序,使得可以进行跳过重复元素
        Arrays.sort(nums);
        // 第二个参数表示考虑到哪个元素了
        dfs(nums, 0);
        return ans;
    }

    private void dfs(int[] nums, int i) {
        // 考虑到了最后的元素,记录结果
        if (i == nums.length) {
            ans.add(new LinkedList<>(list));
            return;
        }

        // 选当前元素
        list.offer(nums[i]);
        // 考虑下一个元素
        dfs(nums, i + 1);
        // 撤销当前元素
        list.removeLast();
        
        // 跳过所有相同的元素
        while (i < nums.length - 1 && nums[i] == nums[i + 1]) {
            i++;
        }
        
        // 不选当前的元素,直接考虑下一个元素
        dfs(nums, i + 1);
    }
}

再来一个例子: 给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。 candidates 中的每个数字在每个组合中只能使用 一次 。
leetcode-cn.com/problems/co…

用第一种方式,考虑每个位置:

class Solution {
    List<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();

    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        // 把所有的数字进行排序,可以跳过重复的元素
        Arrays.sort(candidates);
        // 第三个参数表示 当前和,第四个参数表示从第几个元素开始考虑,即可选范围
        dfs(candidates, target, 0, 0);
        return ans;
    }

    private void dfs(int[] candidates, int target, int sum, int start) {
        if (sum == target) {
            ans.add(new LinkedList<>(path));
            return;
        }

        // 整个循环代表依次把可选范围内的元素放在当前位置上考虑
        for (int i = start; i < candidates.length; i++) {
            // 如果当前元素和前一个元素相同,则属于重复,跳过
            if (i > start && candidates[i] == candidates[i - 1]) {
                continue;
            }

            if (sum + candidates[i] > target) {
                return;
            }

            sum += candidates[i];
            path.offer(candidates[i]);
            // 因为不能重复选,所以可选范围缩小
            dfs(candidates, target, sum, i + 1);
            path.removeLast();
            sum -= candidates[i];
        }
    }
}

用第二种方式,考虑每个元素:

public class Solution {
    List<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();

    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        // 把所有的数字进行排序,使得可以进行剪枝判断
        Arrays.sort(candidates);
        // 第三个参数表示 当前和,第四个参数表示当前考虑到哪个元素了
        dfs(candidates, target, 0, 0);
        return ans;
    }

    private void dfs(int[] candidates, int target, int sum, int i) {
        // 结束条件:如果满足了条件,则记录结果
        if (sum == target) {
            ans.add(new LinkedList<>(path));
            return;
        }

        // 结束条件:如果考虑到了最后一个元素,或者sum已经比target大了,则返回
        if (i == candidates.length || sum > target) {
            return;
        }

        // 选当前元素
        // 加入当前结果
        path.offer(candidates[i]);
        // 计算sum
        sum += candidates[i];
        // 进行回溯,因为不能重复选,所以考虑下一个元素
        dfs(candidates, target, sum, i + 1);
        // 回退sum
        sum -= candidates[i];
        // 从当前结果撤销
        path.removeLast();

        // 跳过所有相同的元素
        while (i < candidates.length - 1 && candidates[i] == candidates[i + 1]) {
            i++;
        }

        // 不选当前索引,开始考虑下一个元素
        dfs(candidates, target, sum, i + 1);
    }
}

(4) 有重复数字的组合,可以重复选

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。

用第一种方式,考虑每个位置:

public class Solution {
    List<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();

    public List<List<Integer>> combinationSum3(int[] candidates, int target) {
        // 把所有的数字进行排序,可以跳过重复的元素
        Arrays.sort(candidates);
        // 第三个参数表示 当前和,第四个参数表示从第几个元素开始考虑,即可选范围
        dfs(candidates, target, 0, 0);
        return ans;
    }

    private void dfs(int[] candidates, int target, int sum, int start) {
        if (sum == target) {
            ans.add(new LinkedList<>(path));
            return;
        }

        // 整个循环代表依次把可选范围内的元素放在当前位置上考虑
        for (int i = start; i < candidates.length; i++) {
            // 如果当前元素和前一个元素相同,则属于重复,跳过
            if (i > start && candidates[i] == candidates[i - 1]) {
                continue;
            }

            if (sum + candidates[i] > target) {
                return;
            }

            sum += candidates[i];
            path.offer(candidates[i]);
            // 因为可以重复选,所以可选范围不变
            dfs(candidates, target, sum, i);
            path.removeLast();
            sum -= candidates[i];
        }
    }
}

用第二种方式,考虑每个元素:

public class CombinationSum3 {
    List<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> path = new LinkedList<>();

    public List<List<Integer>> combinationSum3(int[] candidates, int target) {
        // 把所有的数字进行排序,使得可以进行剪枝判断
        Arrays.sort(candidates);
        // 第三个参数表示 当前和,第四个参数表示当前考虑到哪个元素了
        dfs(candidates, target, 0, 0);
        return ans;
    }

    private void dfs(int[] candidates, int target, int sum, int i) {
        // 结束条件:如果满足了条件,则记录结果
        if (sum == target) {
            ans.add(new LinkedList<>(path));
            return;
        }

        // 结束条件:如果考虑到了最后一个元素,或者sum已经比target大了,则返回
        if (i == candidates.length || sum > target) {
            return;
        }

        // 选当前元素
        // 加入当前结果
        path.offer(candidates[i]);
        // 计算sum
        sum += candidates[i];
        // 进行回溯,因为可以重复选,所以继续考虑当前元素
        dfs(candidates, target, sum, i);
        // 回退sum
        sum -= candidates[i];
        // 从当前结果撤销
        path.removeLast();

        // 跳过所有相同的元素
        while (i < candidates.length - 1 && candidates[i] == candidates[i + 1]) {
            i++;
        }

        // 不选当前索引,开始考虑下一个元素
        dfs(candidates, target, sum, i + 1);
    }
}