点击关注上方“五分钟学算法”,

设为“置顶或星标”,第一时间送达干货。

转自面向大象编程

本期例题:LeetCode 46 - Permutations[1](Medium)

给定一个不重复的数字集合,返回其所有可能的全排列。例如:

  • 输入:[1, 2, 3]

  • 输出:

[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]
]

在第三讲中,我们就讲过了回溯法问题的基本思想。回溯法问题用递归求解,可以联系上树的遍历,我们可以将决策路径画成一棵树,回溯的过程就是这棵树的遍历过程。

不过在那篇文章中,我们只求解了一道非常简单的回溯法问题:子集(subset)问题。在面试中,我们需要有能力更加复杂的回溯法问题,并应对题目的各种变种。本篇以经典的排列(permutation)和组合(combination)问题为例,讲讲求解回溯法问题的要点:候选集合

这篇文章将会包含:

  • 回溯法的”选什么“问题与候选集合

  • 全排列、排列、组合问题的回溯法解法

  • 回溯法问题中,如何维护候选集合

  • 回溯法问题中,如何处理失效元素

回溯法的重点:“选什么”

我们说过,回溯法实际上就是在一棵决策树上做遍历的过程。那么,求解回溯法题目时,我们首先要思考所有决策路径的形状。例如,子集问题的决策树如下图所示:

子集问题的决策树

决策树形状主要取决于每个结点处可能的分支,换句话说,就是在每次做决策时,我们 “可以选什么”“有什么可选的”

对于子集问题而言,这个“选什么”的问题非常简单,每次只有一个元素可选,要么选、要么不选。不过,对于更多的回溯法题目,“选什么”的问题并不好回答。这时候,我们就需要分析问题的候选集合,以及候选集合的变化,以此得到解题的思路。

全排列问题:如何维护候选集合

让我们拿经典的全排列问题来讲解回溯法问题的候选集合概念。

在全排列问题中,决策树的分支数量并不固定。我们一共做  次决策,第  次决策会选择排列的第  个数。选择第一个数时,全部的  个数都可供挑选。而由于已选的数不可以重复选择,越往后可供选择的数越少。以  为例,决策树的形状如下图所示:

全排列问题的决策树

如果从候选集合的角度来思考,在进行第一次选择时,全部的 3 个数都可以选择,候选集合的大小为 3。在第二次选择时,候选集合的大小就只有 2 了;第三次选择时,候选集合只剩一个元素。可以看到,全排列问题候选集合的变化规律是:每做一次选择,候选集合就少一个元素,直到候选集合选完为止。我们可以在上面的决策树的每个结点旁画上候选集合的元素,这样看得更清晰。

全排列问题有候选集合的决策树

可以看到,已选集合候选集合是补集的关系,它们加起来就是全部的元素。而在回溯法的选择与撤销选择的过程中,已选集合和候选集合是此消彼长的关系。

如何根据这样的思路写出代码呢?当然可以用 HashSet 这样的数据结构来表示候选集合。但如果你这么去写的话,会发现代码写起来比较啰嗦,而且 set 结构的“遍历-删除”操作不太好写。在这里,我不展示使用 set 结构的代码。大家只要明白一条要点:在一般情况下,候选集合使用数组表示即可。 候选集合上需要做的操作并不是很多,使用数组简单又高效。

在子集问题中,我们定义了变量 k,表示当前要对第 k 个元素做决策。实际上,变量 k 就是候选集合的边界,指针 k 之后的元素都是候选元素,而 k 之前都是无效元素,不可以再选了。

用数组表示候选集合

而每次决策完之后将 k 加一,就是将第 k 个元素移出了候选集合。

将第 k 个元素移出候选集合

在全排列问题中,我们要处理的情况更难一些。每次做决策时,候选集合中的所有元素都可以选择,也就是有可能删除候选集合中间的元素,这样数组中会出现“空洞”。这种情况该怎么处理呢?我们可以使用一个巧妙的方法,先将要删除的元素与第 k 个元素交换,再将 k 加一,过程如下方动图所示:

从候选集合中部删除元素(动图)

不知道你有没有注意到,上图中候选集合之外的元素画成了蓝色,这些实际上就是已选集合。前面分析过,已选集合与候选集合是互补的。将蓝色部分看成已选集合的话,我们从候选集合中删除的元素,正好加入了已选集合中。也就是说,我们可以只用一个数组同时表示已选集合和候选集合!

理解了图中的关系之后,题解代码就呼之欲出了。我们只需使用一个 current 数组,左半边表示已选元素,右半边表示候选元素。指针 k 不仅是候选元素的开始位置,还是已选元素的结束位置。我们可以得到一份非常简洁的题解代码:

public List<List<Integer>> permute(List<Integer> nums) {List<Integer> current = new ArrayList<>(nums);List<List<Integer>> res = new ArrayList<>();backtrack(current, 0, res);return res;
}// current[0..k) 是已选集合, current[k..N) 是候选集合
void backtrack(List<Integer> current, int k, List<List<Integer>> res) {if (k == current.size()) {res.add(new ArrayList<>(current));return;}// 从候选集合中选择for (int i = k; i < current.size(); i++) {// 选择数字 current[i]Collections.swap(current, k, i);// 将 k 加一backtrack(current, k+1, res);// 撤销选择Collections.swap(current, k, i);}
}

注意写在递归函数上方的注释。在写回溯法问题的代码时,你需要时刻清楚什么是已选集合,什么是候选集合。注释中的条件叫做“不变式”。一方面,我们在函数中可以参考变量 k 的含义,另一方面,我们在做递归调用的时候,要保证这个条件始终成立。特别注意代码中递归调用传入的参数是 k+1 ,即删除一个候选元素,而不是传入 i+1

n 中取 k 的排列

全排列问题是  中取  的排列,可以记为 。在面试中,我们很可能会遇到各种各样的变种题,那么  中取  的排列 、组合  我们也要掌握。

问题非常简单,我们只需要在全排列的基础上,做完第  个决策后就将结果返回。也就是说,只遍历决策树的前  层。例如  时,决策树的第 2 层,已选集合中有两个元素,将这里的结果返回即可。

n 中取 k 的排列的决策树

题解代码如下所示,只需要修改递归结束的条件即可。

public List<List<Integer>> permute(List<Integer> nums, int k) {List<Integer> current = new ArrayList<>(nums);List<List<Integer>> res = new ArrayList<>();backtrack(k, current, 0, res);return res;
}// current[0..m) 是已选集合, current[m..N) 是候选集合
void backtrack(int k, List<Integer> current, int m, List<List<Integer>> res) {// 当已选集合达到 k 个元素时,收集结果并停止选择if (m == k) {res.add(new ArrayList<>(current.subList(0, k)));return;}// 从候选集合中选择for (int i = m; i < current.size(); i++) {// 选择数字 current[i]Collections.swap(current, m, i);backtrack(k, current, m+1, res);// 撤销选择Collections.swap(current, m, i);}
}

注意这里  是题目的输入,所以原先我们代码里的变量 k 重命名成了 m。此外,就是递归函数开头的 if 语句条件发生了变化,当已选集合达到  个元素时,就收集结果停止递归。

组合问题:失效元素

由于排列组合的密切联系,组合问题  ,即  中取  的组合,可以在  问题的解法上稍加修改而来。

我们先思考一下组合和排列的关系。元素相同,但顺序不同的两个结果视为不同的排列,例如  和 。但顺序不同的结果会视为同一组合。那么,我们只需要考虑  中所有升序的结果,就自然完成了组合的去重,得到 。

那么,如何让回溯只生成升序的排列呢?这需要稍微动点脑筋,但也不是很难,只需要做到:每当选择了一个数  时,将候选集合中的所有小于  的元素删除,不再作为候选元素。

再仔细想想的话,在排列问题为了维护候选集合而进行的交换操作,这里也不需要了。例如下面的例子,选择元素 6 之后,为了保持结果升序,前面的元素 4、5 也不能要了。不过,我们并不需要关注失效元素,我们只需要关注候选集合的变化情况。我们发现,剩下的候选集合仍然是数组中连续的一段,不会出现排列问题中的“空洞”情况。我们只用一个指针就能表示新的候选集合。

从候选集合中删除多个元素

下图是  的决策树,可以看到,候选集合都是连续的。已选集合不连续没有关系,我们可以另开一个数组保存已选元素。

组合问题的决策树

按照这个思路,我们可以写出  的代码。

public List<List<Integer>> combine(List<Integer> nums, int k) {Deque<Integer> current = new ArrayDeque<>();List<List<Integer>> res = new ArrayList<>();backtrack(k, nums, 0, current, res);return res;
}// current 是已选集合, nums[m..N) 是候选集合
void backtrack(int k, List<Integer> nums, int m, Deque<Integer> current, List<List<Integer>> res) {// 当已选集合达到 k 个元素时,收集结果并停止选择if (current.size() == k) {res.add(new ArrayList<>(current));return;}// 从候选集合中选择for (int i = m; i < nums.size(); i++) {// 选择数字 nums[i]current.addLast(nums.get(i));// 元素 nums[m..i) 均失效backtrack(k, nums, i+1, current, res);// 撤销选择current.removeLast();}
}

由于已选集合与候选集合并非互补,这里用单独的数组存储已选元素,这一点上与子集问题类似。

组合问题与子集问题的关系

也许是排列 & 组合的 CP 感太重,所以我们在思考组合问题的解法的时候会自然地从排列问题上迁移。其实,组合问题和子集问题有很密切的联系。

由子集问题求解组合问题

组合问题可以看成是子集问题的特殊情况。从  中取  个数的组合,实际上就是求  个元素的所有大小为  的子集。也就是说,组合问题的结果是子集问题的一部分。我们可以在子集问题的决策树的基础上,当已选集合大小为  的时候就不再递归,就可以得到组合问题的决策树。

在子集问题决策树基础上得到的组合问题决策树

具体的代码这里就不展示了,请读者自行在子集问题的题解代码的基础上修改得到  的代码。

由组合问题求解子集问题

对于子集问题,大小为  的集合共有  个可能的子集。对于组合问题 ,得到的结果个数是组合数 ,或者记为 。根据二项式定理:

要得到全部的  个子集,我们可以计算所有  中取  的组合,再把这些组合加起来。根据这个思路,我们可以在组合问题的题解代码上稍加修改得到子集问题的解:

public List<List<Integer>> subsets(List<Integer> nums) {Deque<Integer> current = new ArrayDeque<>();List<List<Integer>> res = new ArrayList<>();backtrack(nums, 0, current, res);return res;
}// current 是已选集合, nums[m..N) 是候选集合
void backtrack(List<Integer> nums, int m, Deque<Integer> current, List<List<Integer>> res) {// 收集决策树上每一个结点的结果res.add(new ArrayList<>(current));if (m == nums.size()) {// 当候选集合为空时,停止递归return;}// 从候选集合中选择for (int i = m; i < nums.size(); i++) {// 选择数字 nums[i]current.addLast(nums.get(i));// 元素 nums[m..i) 均失效backtrack(nums, i+1, current, res);// 撤销选择current.removeLast();}
}

可以看到,每次做决策都会增加一个已选元素。当递归到第  层时,计算的就是大小为  的子集。不过,这样写出的子集问题解法没有原解法易懂,我还是更推荐原解法。

总结

排列组合问题是回溯法中非常实际也非常典型的例题,可以通过做这些题目来体会回溯法的基本技巧。不过它们在 LeetCode 中没有完全对应的例题。文章开头的例题是全排列问题。对于组合问题,LeetCode 只有一个简化版 77. Combinations[2],其中数字固定为 1 到 n 的整数。

排列组合问题展示了在求解回溯法问题时,候选集合的概念对理清思路的重要性。实际上,回溯法中的“选择”与“撤销选择”,实际上就是从候选集合中删除元素与添加回元素的操作。而我们在写代码的时候要注意在递归函数上方写注释,明确数组的哪一部分是候选集合。

排列组合问题还存在着一些变种,例如当输入存在重复元素的时候,如何避免结果重复,就需要使用决策树的剪枝方法。下一篇文章将会讲解回溯法问题中如何正确地剪枝。

参考资料

[1]

LeetCode 46 - Permutations: https://leetcode.com/problems/permutations/

[2]

77. Combinations: https://leetcode.com/problems/combinations/


推荐阅读

•   C++是如何从代码到游戏的?•   告诉你一个学习编程的诀窍(建议收藏)•   自学编程的八大误区!克服它!•   新手如何有效的刷算法题(LeetCode)•   10款VS Code插件神器,第7款超级实用!•   在拼多多上班,是一种什么样的体验?我tm心态崩了呀!•   写给小白,从零开始拥有一个酷炫上线的网站!


欢迎关注我的公众号“五分钟学算法”,如果喜欢,麻烦点一下“在看”~

LeetCode 例题精讲 | 08 排列组合问题:回溯法的候选集合相关推荐

  1. LeetCode 例题精讲 | 05 双指针×链表问题:快慢指针

    点击关注上方"五分钟学算法", 设为"置顶或星标",第一时间送达干货. 转自面向大象编程 本期例题: LeetCode 876 - Middle of the ...

  2. l2-004 这是二叉搜索树吗?_LeetCode 例题精讲 | 11 二叉树转化为链表:二叉树遍历中的相邻结点...

    本期例题: LeetCode 98. Validate Binary Search Tree 验证二叉搜索树(Medium) LeetCode 426. Convert Binary Tree to ...

  3. acm新手小白必看系列之(5)——枚举进阶例题精讲

    acm新手小白必看系列之(5)--枚举进阶例题精讲 1.牛奶碑文(暴力枚举) 小伟暑假期间到大草原旅游,在一块石头上发现了一些有趣的碑文.碑文似乎是一个神秘古老的语言,只包括三个大写字母 C.O 和 ...

  4. led伏安特性实验误差分析_高中物理 | 电学实验满分知识点总结+拓展+例题精讲,罕见的好资料,收藏不亏!...

    在物理的学习过程中,电学实验是必考题,很多同学是"遇到就怕".而物理又是高中比较重要的学科,电学是物理一个重要的知识点,所以要想学好物理,电学部分就必须掌握好. 在高中的物理学习中 ...

  5. leetcode面试题 08.08. 有重复字符串的排列组合(回溯)

    有重复字符串的排列组合.编写一种方法,计算某字符串的所有排列组合. 示例1: 输入:S = "qqe" 输出:["eqq","qeq",&q ...

  6. 程序员面试金典 - 面试题 08.07. 无重复字符串的排列组合(回溯)

    1. 题目 无重复字符串的排列组合.编写一种方法,计算某字符串的所有排列组合,字符串每个字符均不相同. 示例1:输入:S = "qwe"输出:["qwe", & ...

  7. 面试题 08.08. 有重复字符串的排列组合-快速排序+回溯深度优先搜索

    面试题 08.08. 有重复字符串的排列组合+快速排序加回溯深度优先搜索 有重复字符串的排列组合.编写一种方法,计算某字符串的所有排列组合. 示例1: 输入:S = "qqe" 输 ...

  8. Leetcode 17.电话号码的组合(回溯法)

    Time: 20191005 Type: Medium 题目描述 给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合. 给出数字到字母的映射如下(与电话按键相同).注意 1 不对应任何字 ...

  9. 排列组合之插板法及变形

    主要用于"相同元素"分到"不同容器"的排列组合. [例1] 共有10本相同的书分到7个班里,每个班至少要分到一本书,问有几种不同分法? [解析]注意,这里面有个 ...

最新文章

  1. Ubuntu终止进程的方法(kill、pkill、killall)
  2. 深度学习(一)——MP神经元模型, BP算法, 神经元激活函数, Dropout
  3. 双时隙的工作原理_OFDM调制技术原理是什么 OFDM调制实现原理介绍【图文】
  4. 储粮过冬?消息称中芯国际大举向设备、零件商囤货
  5. 3月15日 卡尔曼与多元传感器融合
  6. LINUX 下 一些常用的信息显示命令:
  7. 关于oracle数据库高版本向低版本迁移的解决方法
  8. Qt实现窗口跳转(类似于看图软件中下一张和上一张)
  9. rpi4b引导ubuntu分析------distro_bootcmd
  10. linux下如何查看hdmi设备,如何在Linux中设置HDMI数字播放 | MOS86
  11. 宋星:金融行业数字营销的数据破局
  12. ABP VNext学习日记22
  13. 基本算法-回溯法(迷宫问题)
  14. linux c字符串-指针
  15. 怎样远程控制别人的电脑
  16. OTA法规及备案要求
  17. 阿里云域名解析与绑定过程
  18. Tushare财经数据接口(五)案例——优质基本面的股票池创建
  19. smb连接错误“请检查服务器名称或IP地址,然后再试一次,如果问题持续发生,请联系系统管理员“
  20. text-davinci-002与 text-davinci-003 有什么不同?

热门文章

  1. java中关于类描述错误的是什么,下面关于Java程序的描述中,错误的是()
  2. 华为od统一考试B卷【迷宫问题】Python 实现
  3. WinPE工具箱功能
  4. sigmoid和tanh求导的特殊技巧
  5. 易语言除了做点外挂,易语言还有多少发展前景
  6. 一个绝好的大型软件ISO下载FTP站!
  7. java天地劫_[修改]手机版回合制RPG游戏《殇界痕》吾爱版首发及PC修改方法
  8. 计算机桌面底部图标一直闪烁,Win10电脑桌面图标和任务栏图标一直闪烁不停刷新的解决方法...
  9. 田子坊行程5月11周六
  10. 【嵌入式基础】Keil下编译代码并生成HEX文件