子集是一个数学概念:如果集合A的任意一个元素都是集合B的元素,那么集合A称为集合B的子集。例如集合[1,2,3],那么它的子集有:[]、[1]、[2]、[3]、[1,2]、[1,3]、[2,3]、[1,2,3],注意空集也是包含在内的。那么如何用程序来实现求一个集合的所有子集呢?下面我会介绍常见的三种解法。
解法一:二进制
集合中的每个元素都有两种状态,出现在子集中和不出现在子集中,二进制的1、0可以分别表示这两种状态。我们可以使用集合容量为长度的二进制来表示集合中每个元素的状态。
例如集合:[1,2,3],可以使用二进制来表示所有子集,如下表所示:
子集 | 二进制数 | 十进制数 |
---|---|---|
[] | 000 | 0 |
[3] | 001 | 1 |
[2] | 010 | 2 |
[2,3] | 011 | 3 |
[1] | 100 | 4 |
[1,3] | 101 | 5 |
[1,2] | 110 | 6 |
[1,2,3] | 111 | 7 |
所以,对于容量为n的集合,它的所有子集的数量就为2的n次方,下面我们用代码来实现吧。
public static List<List<Integer>> getSubList1(List<Integer> nums) {
// 解集
List<List<Integer>> result = new ArrayList<>();
// 这是集合的容量
int size = nums.size();
// 所有可能子集的数量,这里用的位运算,结果为2的size次方
int count = 1 << size;
// 依次遍历可能出现的情况
for (int i = 0; i < count; i++) {
List<Integer> subList = new ArrayList<>();
// 把当前数字转换成二进制数字符串形式
String str = Integer.toBinaryString(i);
// 再把二进制字符串前面补0,使位数等于size
String binaryString = fillZero(str, size);
// 再依次遍历二进制数,如果为1则添加当前位置的元素到子集
for (int j = 0; j < size; j++) {
if (binaryString.charAt(j) == '1') {
subList.add(nums.get(j));
}
}
result.add(subList);
}
return result;
}
/**
* 这个方法为给字符串前面填充“0”到指定位数
*/
public static String fillZero(String source, Integer num) {
if (source == null || source.length() >= num) {
return source;
}
int length = source.length();
StringBuilder sourceBuilder = new StringBuilder(source);
for (int i = 0; i < (num - length); i++) {
sourceBuilder.insert(0, "0");
}
return sourceBuilder.toString();
}
上面的写法,完全是按照思路一比一还原写的。其实没必要真的要弄个二进制字符串,可以用按位&运算来判断集合中的某个元素是否应该出现。例如集合[1,2,3],当前遍历的二进制数为:010,要判断元素1,2,3是否出现在子集中,可以分别使用二进制数100(代表元素1),010(代表元素2),001(代表元素3) 来跟010做按位&运算,如果结果不为0,则表明当前元素出现在子集中。100,010,001直接使用数字1按位向左移动0,1,2位就可以得到了。
优化后的代码如下:
public static List<List<Integer>> getSubList1(List<Integer> nums) {
// 解集
List<List<Integer>> result = new ArrayList<>();
// 集合容量
int size = nums.size();
// 所有可能子集的数量,这里用的位运算,结果为2的size次方
int count = 1 << size;
// 依次遍历可能出现的情况
for (int i = 0; i < count; i++) {
List<Integer> subList = new ArrayList<>();
for (int j = 0; j < size; j++) {
if ((i & (1 << j)) != 0) {
subList.add(nums.get(j));
}
}
result.add(subList);
}
return result;
}
解法二:逐个枚举(动态规划)
我们用dp[i]表示前i个数的解集,那么dp[i+1]=dp[i] + f(i+1),f(i+1)表示把dp[i]的所有子集都加上第i+1个元素形成的新的解集,这其实是动态规划的思想。
空集为所有集合的子集,那么解集里面一定包含空集,所有我们先把空集加到解集中,然后遍历集合,每遍历一个元素,就把当前解集中的所有子集复制一份出来,并把当前元素添加进去,再添加到解集中。
- 例如集合[1,2,3],开始解集只有一个空集:[];
- 遍历到元素1,复制当前的解集:[],并把1添加进去,得到[1],再加到解集中,得到解集:[],[1];
- 遍历到元素2,复制当前的解集:[],[1],并把2添加进去,得到[2],[1,2],再加到解集中,得到解集:[],[1],[2],[1,2];
- 遍历到元素3,复制当前的解集:[],[1],[2],[1,2],并把3添加进去,得到[3],[1,3],[2,3],[1,2,3],再加到解集中,得到解集:[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]。
下面看下代码如何实现:
public static List<List<Integer>> getSubList2(List<Integer> nums) {
// 解集
List<List<Integer>> result = new ArrayList<>();
// 首先将空集添加的解集中
result.add(new ArrayList<>());
// 依次遍历所有元素
for (Integer num : nums) {
// 获取当前解集的数量,并进行遍历
int size = result.size();
for (int i = 0; i < size; i++) {
// 取当前已有的子集
List<Integer> presentSubList = result.get(i);
// 复制一份并把当前元素添加进去
ArrayList<Integer> copySubList = new ArrayList<>(presentSubList);
copySubList.add(num);
// 再添加到解集中
result.add(copySubList);
}
}
return result;
}
解法三:DFS+回溯
集合中的每个元素有两种状态,出现或不出现在子集中。可以使用二叉树的左右两个节点来分别表示,比如左节点表示出现,右节点表示不出现,那么从根节点到左右子节点的所有路径,就是所有的子集。
ROOT节点是个虚拟节点,不表示真实的元素,所以我把下标设置为-1。思路确定了,其实就是写个二叉树的深度优先遍历(DFS),每遍历到一个节点,判断该节点是否需要添加到子集,遍历到叶子节点时把当前子集添加到解集,整个遍历过程中使用的子集是同一个List,在递归返回时要恢复状态,这里用到的是递归回溯的思想。下面具体看代码如何实现:
public static List<List<Integer>> getSubList3(List<Integer> nums) {
// 解集
List<List<Integer>> result = new ArrayList<>();
// 子集,会在递归中重复使用该对象
List<Integer> subList = new ArrayList<>();
// 调用递归回溯
dfs(nums, -1, result, subList, true);
return result;
}
/**
* 递归回溯
* @param nums 源集合
* @param i 遍历的源集合的下标
* @param result 解集
* @param subList 子集
* @param status 状态,true表示出现在子集,false表示不出现在子集
*/
public static void dfs(List<Integer> nums, int i, List<List<Integer>> result, List<Integer> subList, boolean status) {
// 递归终止条件,遍历结束
if (i == nums.size()) {
return;
}
// 判断当前元素是否需要添加到子集中,
// 这里判断i>=0,因为根节点i=-1,根节点我们不需要做任何操作,直接往下遍历就行
if (status && i >= 0) {
subList.add(nums.get(i));
}
// 继续遍历左右节点,左节点出现,右节点不出现
dfs(nums, i + 1, result, subList, true);
dfs(nums, i + 1, result, subList, false);
// 判断是否已经是子节点,如果是子节点则直接将当前list复制一份添加到子集
if (i == nums.size() - 1) {
result.add(new ArrayList<>(subList));
}
// 回溯重置状态
if (status && i >= 0) {
subList.remove(subList.size() - 1);
}
}
上面的写法是完全模拟一颗二叉树来写的,但是里面有虚拟根节点,又有每个节点的状态,判断的有点啰嗦。当然我们熟悉了这种思路后,可以把代码优化一下:
private static List<List<Integer>> getSubList3(List<Integer> nums) {
// 解集
List<List<Integer>> result = new ArrayList<>();
// 子集,会在递归中重复使用该对象
List<Integer> subList = new ArrayList<>();
// 调用递归回溯
inner(nums, 0, result, subList);
return result;
}
/**
* 递归回溯
* @param nums 源集合
* @param i 遍历的源集合的下标
* @param result 解集
* @param subList 子集
*/
private static void dfs(List<Integer> nums, int i, List<List<Integer>> result, List<Integer> subList) {
// 递归到最后,把当前sublist添加到结果中
if (i == nums.size()) {
result.add(new ArrayList<>(subList));
return;
}
// 包含当前节点
subList.add(nums.get(i));
dfs(nums, i + 1, result, subList);
// 不包含当前节点
subList.remove(subList.size() - 1);
dfs(nums, i + 1, result, subList);
}