第六章 基于组合的DFS
在非二叉树上的深度优先搜索(Depth-first Search)中,90%的问题,不是求组合(Combination)就是求排列(Permutation)。特别是组合类的深度优先搜索的问题特别的多。
- 通过全子集问题 Subsets 了解组合类搜索的两种形式
- 通过全子集问题 II 了解如何在搜索中去重
- 使用非递归的方法实现全子集问题
全子集问题
题目的意思就是求出一个集合的所有子集。假设这个集合中是没有重复元素的。你可能已经会做这个问题,但是你知道么,这个问题存在 4 种解法么?
- 使用比较通用的深度优先搜索方法
- 使用组合类搜索的专用深度优先搜索算法。(一层一层的决策每个数要不要放到最后的集合里。)
- 使用宽度优先搜索算法的做法(BFS)(一层一层的找到所有的子集)
- 递归版本,利用二进制的方式逐个枚举 subsets。
我们将从下面的 3 个方面来讲解这个问题:
- 如何用最简单的递归方式来实现?
- 答案在leaf上
- 如何用可以推广到排列类搜索问题的递归方式来实现?
- 模板记牢
- 如果集合中有重复元素如何处理?
- 找完所有可能性再去重,复杂度高
- 当前数字和前一个数字一样而且没被放到集合中,忽略
全子集 Follow up II: 如何非递归?
用非递归(Non-recursion / Iteration)的方式实现全子集问题,有两种方式:
- 进制转换(binary)
- 宽度优先搜索(Breadth-first Search)
基于 BFS 的方法
在 BFS 那节课的讲解中,我们很少提到用 BFS 来解决找所有的方案的问题。事实上 BFS 也是可以用来做这件事情的。
用 BFS 来解决该问题时,层级关系如下:
第一层: []
第二层: [1] [2] [3]
第三层: [1, 2] [1, 3], [2, 3]
第四层: [1, 2, 3]
每一层的节点都是上一层的节点拓展而来。
什么是 Deep Copy?
在 Subsets 的实现中,我们用到了如下的代码记录每一个找到的集合
results.add(new ArrayList<Integer>(subset));
事实上,这句话是调用了 ArrayList 的一个构造函数(Constructor),这个构造函数可以接受另外一个 ArrayList 作为其初始化的状态。
这种方式,我们叫它深度拷贝
(Deep Copy),又叫做硬拷贝(Hard Copy)或者克隆(Clone,名字多得老纸记不住啊)。与之对应的就有 软拷贝(Soft copy),又名引用拷贝(Reference Copy)。
不使用 Deep copy 会怎样呢?
我们来看看不使用 Deep copy 会怎样:
List<Integer> subset = new ArrayList<>();
subset.add(1); // 此时 subset 是 [1]
List<List<Integer>> results = new ArrayList<>();
results.add(subset); // 此时 results 是 [[1]]
subset.add(2); // 此时 subset 是 [1,2]
results.add(subset); // 此时你以为 results 是 [[1], [1,2]] 而事实上他是[[1,2], [1,2]]
subset.add(3); // 此时 results 里是 [[1,2,3], [1,2,3]]
我们看到由于每一次 results.add 都是加入了相同的变量 subset,因此如果 subset 有变化,那么 result 里的记录就会同步的发生变化。原因是 results.add(subset) 加入的是 subset 的 reference,也就是 subset 在内存中的地址。那么事实上,当 results 里有两个 subset 的时候,相当于存储的是两个内存地址,而这两个内存地址又是一样的,才会导致如果这个内存地址里存的东西发生了变化,results 看起来就每个元素都发生了变化。
参数中引用传递
来看这段代码
public void func(List<Integer> subset) {
subset.add(1);
}
public void main() {
List<Integer> subset = new ArrayList<>();
// 此时 subset 是 []
func(subset);
// 此时 subset 就是 [1] 了
}
可能你会奇怪,不是说修改参数不会影响到函数之外的参数么?也就是:
public void func(int x) {
x = x + 1;
}
public void main() {
int x = 0;
func(x);
// 此时 x 仍然是 0
}
上面两者的区别在于,人们习惯性的认为 subset.add
和 x = x + 1
都是对参数进行了修改
。而事实上,x = x + 1 确实是对参数进行了修改,这个修改只在函数func的局部有效,出了func回到main就失效了。而 subset.add 并没有修改 subset 这个参数本身,而只是在 subset 所指向的内存空间中增加了一个新的元素,这个操作是永久性的,不是临时的,是全局有效的,不是局部有效的。那么怎么样才是对 subset 这个参数进行了修改呢?比如:
public void func(List<Integer> subset) {
subset = new ArrayList<Integer>();
subset.add(1);
}
public void main() {
List<Integer> subset = new ArrayList<>();
// 此时 subset 是 []
func(subset);
// 此时 subset 还是 []
}
我们可以看到如果你的修改操作是 参数x = ...
那么这才是对参数x的修改,而 参数x.call_method()
并不是对参数 x 本身的修改。