《剑指 Offer》专项突破版 - 面试题 79 ~ 84 : 详解回溯法(C++ 实现)

目录

一、回溯法的基础知识

二、集合的排列、组合

面试题 79 : 所有子集

面试题 80 : 包含 k 个元素的组合

面试题 81 : 允许重复选择元素的组合

面试题 82 : 包含重复元素集合的组合

面试题 83 : 没有重复元素集合的全排列

面试题 84 : 包含重复元素集合的全排列


 


一、回溯法的基础知识

回溯法可以看作蛮力法的升级版本,它在解决问题时的每一步都尝试所有可能的选项,最终找出所有可行的解决方案。回溯法非常适合解决由多个步骤组成的问题,并且每个步骤都有多个选项。在某一步选择了其中一个选项之后,就进入下一步,然后会面临新的选项。就这样重复选择,直至到达最终的状态

用回溯法解决问题的过程可以形象地用一个树形结构表示,求解问题的每个步骤可以看作树中的一个节点。如果在某一步有 n 个可能的选项,每个选项是树中的一条边,经过这些边就可以到达该节点的 n 个子节点

例如,可以用回溯法求集合 [1, 2] 的所有子集,求解的过程可以用下图中的树形结构表示。树的根节点对应子集的初始状态,即子集是空的。求集合 [1, 2] 的子集分为两步,第一步决定是否在子集中添加数字 1,此时面临两个选择,添加 1 或不添加 1。如果选择不添加 1,那么子集仍然保持空集,前往第 2 层第 1 个节点。此时第二步再次面临两个选择,添加 2 或不添加 2。如果此时选择不添加 2,那么子集仍然是空的,如下图中第 3 层的第 1 个节点所示;如果此时选择添加 2,那么子集中有一个元素 2,如下图中第 3 层的第 2 个节点所示。

如果在根节点选择添加 1,那么在子集中添加元素 1,子集变成 [1],前往下图中第 2 层的第 2 个节点。此时第二步再次面临两个选择,添加 2 或不添加 2。如果此时选择不添加 2,那么最终子集中仍然只有元素 1,对应下图中第 3 层的第 3 个节点;如果此时选择添加 2,那么子集变成 [1, 2],对应下图中的第 3 层的第 4 个节点。

在采用回溯法解决问题时如果到达树形结构的叶节点,就找到了问题的一个解。如果希望找到更多的解,那么还可以回溯到它的父节点再尝试父节点其他的选项。如果父节点所有可能的选项都已经试过,那么再回溯到父节点的父节点以尝试它的其他选项,这样逐层回溯到树的根节点。因此,采用回溯法解决问题的过程实质上是在树形结构中从根节点开始进行深度优先遍历。通常,回溯法的深度优先遍历用递归代码实现。

如果在前往某个节点时对问题的解的状态进行了修改,那么在回溯到它的父节点时记得清除相应的修改。例如,在求集合 [1, 2] 的所有子集的过程中,当位于上图中树形结构第 2 层的第 1 个节点时,选择添加 2 前往第 3 层的第 2 个节点,此时在子集(这个问题的解)中添加元素 2。此时已经到达了树的叶节点。为了找出更多的子集(问题的其他解),可以回溯到它的父节点,即第 2 层的第 1 个节点。回溯到父节点时需要清除之前的修改,从子集中删除刚刚添加的元素 2,于是子集重新变成一个空集。由于此时已经尝试了第 2 层的第 1 个节点的所有选项,因此再次回溯到它的父节点,即根节点,再选择添加 1 前往第 2 层的第 2 个节点,在一个空集中添加元素 1,此时子集为 [1]。

由于回溯法是在所有选项形成的树上进行深度优先遍历,如果解决问题的步骤较多或每个步骤都面临多个选项,那么遍历整棵树将需要较多的时间。如果明确知道某些子树没有必要遍历,那么在遍历的时候应该避开这些子树以优化效率。通常将使用回溯法时避免遍历不必要的子树的方法称为剪枝


二、集合的排列、组合

  1. 从 n 个不同元素中,任取 m 个元素(0 <= m <= n)并按照某种顺序形成一个排列。m 等于 n 的排列又称为全排列。从 n 个不同元素中取出 m个元素的所有排列的个数叫做排列数,用符合 A(n, m) 或 A_n^m 表示,A_n^m = n(n-1)(n-2)···(n-m+1) = \dfrac{n!}{(n-m)!}

    从 n 个不同元素中取出 m 个元素排成一列,第 1 个位置有 n 个选择,第 2 个位置有 n - 1 个选择(因为已经有 1 个元素放在前一个位置),第 3 个位置有 n - 2 个选择,以此类推第 m 个位置有 n - (m - 1) 个选择,所以 A_n^m = n(n-1)(n-2)···(n-m+1) = \dfrac{n!}{(n-m)!}

    规定:0! = 1。

  2. 从 n 个不同元素中,任取 m 个元素形成一个组合。从 n 个不同元素中取出 m 个元素的所有组合的个数叫做组合数,用符合 C(n, m) 或 C_n^m 表示,C_n^m = \dfrac{A_n^m}{m!} = \dfrac{n!}{m!(n-m)!}

    计算组合数的公式是由计算排列数的公式去掉重复的部分推到而来的。从 n 个不同元素中取出 m 个元素组成一组,由于 m 个元素组成的一组有 m! 种不同的排列,所以 。

    注意:。

排列、组合是数学中很重要的两个概念,在编程面试中也经常遇到。接下来详细讨论如何采用回溯法求出集合中的子集(组合)和排列

面试题 79 : 所有子集

题目

输入一个不含重复数字的数据集合,请找出它的所有子集。例如,数据集合 [1, 2] 有 4 个子集,分别是 []、[1]、[2] 和 [1, 2]。

分析

所谓子集就是从一个集合中选出若干元素。如果集合中包含 n 个元素,那么生成子集可以分为 n 步,每一步从集合中取出一个数字,此时面临两个选择,将该数字添加到子集中或不将该数字添加到子集中。生成一个子集可以分为若干步,并且每一步都面临若干选择,这正是应用回溯法的典型场景。

代码实现

class Solution {
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        vector<vector<int>> result;
        vector<int> subset;
        dfs(nums, 0, result, subset);
        return result;
    }
private:
    void dfs(vector<int>& nums, int index, vector<vector<int>>& result, vector<int>& subset) {
        if (index == nums.size())
        {
            result.push_back(subset);
            return;
        }
​
        dfs(nums, index + 1, result, subset);  // 不将 nums[index] 添加到子集中
​
        subset.push_back(nums[index]);  // 将 nums[index] 添加到子集中
        dfs(nums, index + 1, result, subset);
        subset.pop_back();
    }
};

面试题 80 : 包含 k 个元素的组合

题目

输入 n 和 k,请输出从 1 到 n 中选取 k 个数字组成的所有组合。例如,如果 n 等于 3,k 等于 2,将组成 3 个组合,分别是 [1, 2]、[1, 3] 和 [2, 3]。

分析

集合的一个组合也是一个子集,因此求集合的组合的过程和求子集的过程是一样的。这个题目只是增加了一个限制条件,即只找出包含 k 个数字的组合。只需要在面试题 79 的代码的基础上稍加修改,就可以找出所有包含 k 个数字的组合。

代码实现

class Solution {
public:
    vector<vector<int>> combine(int n, int k) {
        vector<vector<int>> result;
        vector<int> combination;
        dfs(n, k, 1, result, combination);
        return result;
    }
private:
    void dfs(int n, int k, int num, vector<vector<int>>& result, vector<int>& combination) {
        if (combination.size() == k)
        {
            result.push_back(combination);
            return;
        }
​
        if (num == n + 1)
            return;
​
        dfs(n, k, num + 1, result, combination);
​
        combination.push_back(num);
        dfs(n, k, num + 1, result, combination);
        combination.pop_back();
    }
};

面试题 81 : 允许重复选择元素的组合

题目

给定一个没有重复数字的正整数集合,请找出所有元素之和等于某个给定值的所有组合。同一个数字可以在组合中出现任意次。例如,输入正整数集合 [2, 3, 5],元素之和等于 8 的组合有 3 个,分别是 [2, 2, 2, 2]、[2, 3, 3] 和 [3, 5]。

分析

这个题目仍然是关于组合的,但组合中的一个数字可以出现任意次。可以以不变应万变,用回溯法来解决这个问题。

能够用回溯法解决的问题都能够分成若干步来解决,每一步都面临若干选择。对于从集合中选取数字组成组合的问题而言,集合中有多少个数字,解决这个问题就需要多少步。每一步都从集合中取出一个下标为 index 的数字,此时面临两个选择。一个选择是跳过这个数字不将该数字添加到组合中,那么这一步实际上什么都不做,接下来处理下标为 index + 1 的数字。另一个选择是将数字添加到组合中,由于一个数字可以重复在组合中出现,也就是说,下一步可能再次选择同一个数字,因此下一步仍然处理下标为 index 的数字

代码实现

class Solution {
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        vector<vector<int>> result;
        vector<int> combination;
        dfs(candidates, target, 0, result, combination);
        return result;
    }
private:
    void dfs(vector<int>& candidates, int target, int index, 
        vector<vector<int>>& result, vector<int>& combination) {
        if (target == 0)
        {
            result.push_back(combination);
            return;
        }
        
        if (target < 0 || index == candidates.size())
            return;
        
        dfs(candidates, target, index + 1, result, combination);
​
        combination.push_back(candidates[index]);
        dfs(candidates, target - candidates[index], index, result, combination);
        combination.pop_back();
    }
};

应用回溯法解决问题时如果有可能应尽可能剪枝以优化时间效率。由于题目明确指出数组中的所有数字都是正整数,因此当组合中已有数字之和已经大于目标值时(即递归函数 dfs 的参数 target 的值小于 0 时)就没有必要再考虑数组中还没有处理的数字,因为再在组合中添加任意正整数元素之后和会更大,一定找不到新的符合条件的组合,也就没必要再继续尝试

面试题 82 : 包含重复元素集合的组合

题目

给定一个可能包含重复数字的整数集合,请找出所有元素之和等于某个给定值的所有组合。注意:整数集合中的每个数字在每个组合中只能使用一次,输出中不得包含重复的组合。例如,输入整数集合 [2, 2, 2, 4, 3, 3],元素之和等于 8 的组合有 2 个,分别是 [2, 2, 4] 和 [2, 3, 3]。

分析

这个题目和之前几个与组合相关的题目相比,最大的不同在于输入的集合中有重复的数字但输出不得包含重复的组合。如果输入的集合中有重复的数字,不经过特殊处理将产生重复的组合。例如,从集合 [2, 2, 2] 中选出 2 个数字本来能组成 3 个组合,但 3 个组合都是 [2, 2],因此它们是重复的组合,在本题中只能算 1 个。另外,组合不考虑数字的顺序,如组合 [2, 2, 4]、[2, 4, 2] 和 [4, 2, 2] 只能算一个组合

避免重复的组合的方法是当在某一步决定跳过某个值为 m 的数字时,跳过所有值为 m 的数字。例如,假设求 [2, 2, 2] 的组合,如果在处理第 1 个 2 时跳过它并跳过所有的 2,那么得到的是一个空的组合。如果在选择第 1 个 2 之后决定跳过第 2 个 2 并连带跳过后面的 2,那么得到的是组合 [2]。如果选择前两个 2 之后决定跳过第 3 个 2,那么得到的是组合 [2, 2]。如果 3 个 2 都被选择,则得到组合 [2, 2, 2]。采用这种方法就可以避免产生重复的组合,如避免了两种产生重复组合 [2, 2] 的情形,一种情形是跳过第 1 个 2 选择后面两个 2,另一种情形是跳过中间一个 2 选择前后两个 2。

采用这种方法可以避免产生重复的组合,如果组合中只有一个 2,那么选择的一定是第 1 个 2,而不是第 2 个或第 3 个 2;如果组合中有两个 2,那么选择的一定是第 1 个和第 2 个 2,而不是第 1 个和第 3 个,或第 2 个和第 3 个

为了方便跳过后面所有值相同的数字,可以将集合中的所有数字排序,把相同的数字放在一起,这样方便比较数字。当决定跳过某个值的数字时,可以按顺序扫描后面的数字,直到找到不同的值为止

代码实现

class Solution {
public:
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end());
​
        vector<vector<int>> result;
        vector<int> combination;
        dfs(candidates, target, 0, result, combination);
        return result;
    }
private:
    void dfs(vector<int>& candidates, int target, int index, 
        vector<vector<int>>& result, vector<int>& combination) {
        if (target == 0)
        {
            result.push_back(combination);
            return;
        }
​
        if (target < 0 || index == candidates.size())
            return;
​
        int nextIndex = index + 1;
        while (nextIndex < candidates.size() && candidates[nextIndex] == candidates[index])
        {
            ++nextIndex;
        }
        dfs(candidates, target, nextIndex, result, combination);
​
        combination.push_back(candidates[index]);
        dfs(candidates, target - candidates[index], index + 1, result, combination);
        combination.pop_back();
    }
};

面试题 83 : 没有重复元素集合的全排列

题目

给定一个没有重复数字的集合,请找出它的所有全排列。例如,集合 [1, 2, 3] 有 6 个全排列,分别是 [1, 2, 3]、[1, 3, 2]、[2, 1, 3]、[2, 3, 1]、[3, 1, 2] 和 [3, 2, 1]。

分析

排列和组合不同,排列与元素的顺序相关,交换数字能够得到不同的排列。生成全排列的过程,就是交换输入集合中元素的顺序以得到不同的排列

下面以输入集合 [1, 2, 3] 为例分析生成全排列的过程。先考虑排列的第 1 个数字。数字 1 可以是排列的第 1 个数字;同样,数字 2、3 也可以是排列的第 1 个数字。因此,当生成排列的第 1 个数字时会面临 3 个选项,即可以分别选择 1、2 或 3。选择某个数字作为排列的第 1 个数字之后接下来生成排列的第 2 个数字。假设选择数字 3 成为排列的第 1 个数字,那么生成第 2 个数字时就面临两个选项,即数字 1 或 2 都有可能成为排列的第 2 个数字。接下来生成排列的第 3 个数字。由于已经选择了两个数字作为排列的前两个数字,因此到第 3 个数字时只剩下 1 个数字,此时也就只有 1 个选项。

如果输入的集合中有 n 个元素,那么生成一个全排列需要 n 步。当生成排列的第 1 个数字时会面临 n 个选项,即 n 个数字都有可能成为排列的第 1 个数字。生成排列的第 1 个数字之后接下来生成第 2 个数字,此时面临 n - 1 个选项,即剩下的 n - 1 个数字都有可能成为第 2 个数字。然后以此类推,直到生成最后一个数字,此时只剩下 1 个数字,也就只有 1 个选项。看起来解决这个问题可以分成 n 步,而且每一步都面临若干选项,这是典型的适用回溯法的场景。

代码实现

class Solution {
public:
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int>> result;
        dfs(nums, 0, result);
        return result;
    }
private:
    void dfs(vector<int>& nums, int index, vector<vector<int>>& result) {
        if (index == nums.size())
        {
            result.push_back(nums);
            return;
        }

        for (int i = index; i < nums.size(); ++i)
        {
            if (index != i)
                swap(nums[index], nums[i]);  // 生成全排列的下标为 index 的数字
            
            dfs(nums, index + 1, result);

            if (index != i)
                swap(nums[index], nums[i]);
        }
    }
};

递归函数 dfs 生成输入数组 nums 的所有全排列,在函数执行过程中数组 nums 保存着当前排列的状态

当函数 dfs 生成排列的下标为 index 的数字时,排列的下标从 0 到 index - 1 的数字都已经选定,但数组 nums 中下标从 index 到 n - 1 的数字(假设数组的长度为 n)都有可能放到排列的下标为 index 的位置,因此函数 dfs 中有一个 for 循环逐一用下标 index 的数字交换它后面的数字。这个 for 循环包含下标为 index 的数字本身,这是因为它自己也能放到下标为 index 的位置。交换之后接着调用递归函数生成排列中下标为 index + 1 的数字。由于之前已经交换了数组中的两个数字,修改了排列的状态,在结束一次for 循环之前需要清除对排列状态的修改,因此再次交换之前交换的两个数字

当下标 index 等于数组 nums 的长度时,排列的每个数字都已经产生了,nums 中保存了一个完整的全排列,于是将该全排列添加到 result 中。最终 result 中包含所有的全排列。

假设数组 nums 的长度为 n,当 index 等于 0 时递归函数 dfs 中的 for 循环执行 n 次,当 index 等于 1 时 for 循环执行 n - 1 次,以此类推,当 index 等于 n - 1 时,for 循环执行 1 次。因此,全排列的时间复杂度是 O(n!)

面试题 84 : 包含重复元素集合的全排列

题目

给定一个包含重复数字的集合,请找出它的所有全排列。例如,集合 [1, 1, 2] 有 3 个全排列,分别是 [1, 1, 2]、[1, 2, 1] 和 [2, 1, 1]。

分析

下面的代码和面试题 83 的代码大同小异。不同点在于下面代码的递归函数 dfs 中使用了一个 unordered_set,用来保存已经交换到全排列的下标为 index 的所有数字。只有当一个数字之前没有被交换到下标为 index 的位置时才做交换,否则直接跳过

代码实现

class Solution {
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        vector<vector<int>> result;
        dfs(nums, 0, result);
        return result;
    }
private:
    void dfs(vector<int>& nums, int index, vector<vector<int>>& result) {
        if (index == nums.size())
        {
            result.push_back(nums);
            return;
        }

        unordered_set<int> us;
        for (int i = index; i < nums.size(); ++i)
        {
            if (us.count(nums[i]) == 0)
            {
                us.insert(nums[i]);
                if (index != i)
                    swap(nums[index], nums[i]);
                
                dfs(nums, index + 1, result);
            
                if (index != i)
                    swap(nums[index], nums[i]);
            }
        }
    }
};
  • 17
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值