子集问题(非常详细,附带源码)
简单理解,集合指的就是一组元素,比如 {1,2,3,4} 是一个包含 4 个元素的集合。集合中的元素是没有顺序的,比如 {1, 2} 和 {2, 1} 是同一个集合。
子集是数学里的概念,用于描述两个集合之间包含与被包含的关系。假设有两个集合 A 和 B,A 是
子集问题指的是找出给定集合的所有子集。比如 {1, 2, 3} 是一个集合,它的所有子集包括:
严格意义上,数学中的集合是不允许包含重复元素的。但有些编程题目允许集合里包含重复元素,本节带大家解决子集问题,也会涉及到包含重复元素的集合。比如集合 {1, 1, 2},它的所有子集包括:
编程解决子集问题,可以用回溯算法实现,接下来给大家讲解具体的实现思路。
回溯算法查找 {1, 2, 3} 所有子集的过程是:
下面是回溯算法查找 {1, 2, 3} 所有子集的 C 语言程序:
subsetBacktrack() 函数的执行过程,和前面讲回溯算法查找 {1, 2, 3} 所有子集的过程完全一致,这里不再赘述。程序的执行结果为:
注意,此程序只适用于无重复元素的集合。如果将 main() 函数里的 arr[] 数组改成:
修改程序之前,首先要搞清楚重复找到子集的原因。把集合 {1, 2, 3} 替换成 {1, 1, 2},观察程序的执行过程:
观察两种情况下的可选元素范围,前者完全包含后者,所以第二种情况找到的所有子集都是重复的。也就是说,从第一个元素 1 出发找完子集之后,就没必要再重新从第二个元素 1 开始查找了。在上面的执行过程中,跳过(过滤掉)字体加粗的部分,程序的执行过程就正确了。
对于包含相同元素的集合,分享一种去重的方法:先把集合中的元素排好序(升序或者降序),然后从第一个元素开始查找子集,对于尚未被选中的元素,如果它和前一个元素相等,并且前一个元素处于未选中状态,就跳过这次查找过程。
下面是按照此方法在 {1, 1, 2} 中查找子集的过程:
下面是修改后的 C 语言程序,它既能满足在无重复元素的集合中查找所有的子集,也能满足在有重复元素的集合中查找所有的子集:
下面是解决子集问题的 Java 程序:
下面是解决子集问题的 Python 程序:
以上程序的执行结果均为:
子集是数学里的概念,用于描述两个集合之间包含与被包含的关系。假设有两个集合 A 和 B,A 是
{1, 2}
,B 是{1, 2, 3, 4}
,集合 B 包含集合 A 中所有的元素,集合 A 就叫做集合 B 的子集。子集问题指的是找出给定集合的所有子集。比如 {1, 2, 3} 是一个集合,它的所有子集包括:
{ }、{1}、{2}、{3}、{1, 2}、{1, 3}、{2, 3}、{1, 2, 3}
{ }
是空集合,它可以是任何集合的子集。经过统计,集合 {1,2,3} 的子集一共有 8 种。 严格意义上,数学中的集合是不允许包含重复元素的。但有些编程题目允许集合里包含重复元素,本节带大家解决子集问题,也会涉及到包含重复元素的集合。比如集合 {1, 1, 2},它的所有子集包括:
{ }、{1}、{2}、{1, 1}、{1, 2},{1, 1, 2}
编程解决子集问题,可以用回溯算法实现,接下来给大家讲解具体的实现思路。
无重复元素集合的子集问题
首先通过一个实例,带大家了解回溯算法解决子集问题的整个过程。回溯算法查找 {1, 2, 3} 所有子集的过程是:
从空的集合 { } 开始,找到子集 { }; 将元素 1 添加到集合里,找到子集 {1}; 将元素 2 添加到集合里,找到子集 {1,2}; 将元素 3 添加到集合里,找到子集 {1,2,3}; 没有元素可以选择,回溯到 {1,2} 的状态; 没有元素可以选择,回溯到 {1} 的状态; 将元素 3 添加到集合里,找到子集 {1,3}; 没有元素可以选择,回溯到 {1} 的状态; 没有元素可以选择,回溯到 { } 空集合的状态; 将元素 2 添加到集合,找到子集 {2}; 将元素 3 添加到集合里,找到子集 {2,3}; 没有元素可以选择,回溯到 {2} 状态; 没有元素可以选择,回溯到 { } 空集合的状态; 将元素 3 添加到集合,找到子集 {3}; 没有元素可以选择,回溯到 { } 空集合的状态; 没有可以选择的元素,算法执行结束。整个过程中,为了确保找到的子集是不重复的,每选择一个元素 e,紧接着下次只能选择 e 后面的元素。比如将元素 2 添加到空集合之后,下次只能选择 2 后面的元素 3,而不能选择元素 1,因为 {2, 1} 和前面找到的子集 {1, 2} 是重复的。
下面是回溯算法查找 {1, 2, 3} 所有子集的 C 语言程序:
#include <stdio.h> #include <stdlib.h> // 打印一个子集 void printSubset(int subset[], int size) { printf("{ "); for (int i = 0; i < size; i++) { printf("%d ", subset[i]); } printf("}\n"); } // 回溯函数,找到所有子集 void subsetBacktrack(int arr[], int subset[], int size, int startIndex, int subsetIndex) { // 打印当前的子集 printSubset(subset, subsetIndex); // 遍历剩余的元素 for (int i = startIndex; i < size; i++) { // 将当前元素添加到子集中 subset[subsetIndex++] = arr[i]; // 递归调用,用于生成包含当前元素的所有子集 subsetBacktrack(arr, subset, size, i + 1, subsetIndex); // 回溯:移除最近添加的元素,以便于下一个迭代 subsetIndex--; } } // 为执行subsetBacktrack()函数做前期准备和扫尾工作 void findAllSubsets(int arr[], int n) { // 动态分配数组以存储临时子集 int* subset = (int*)malloc(n * sizeof(int)); // 开始回溯过程 subsetBacktrack(arr, subset, n, 0, 0); // 释放分配的内存 free(subset); } int main() { int arr[] = { 1, 2, 3 }; // 初始集合 int n = sizeof(arr) / sizeof(arr[0]); // 计算集合的大小 // 调用函数,打印所有子集 findAllSubsets(arr, n); return 0; }程序中自定义了 3 个函数,真正实现回溯算法的是 subsetBacktrack() 函数,它有 5 个参数,各自的含义是:
- arr[]:存储集合的数组;
- subset[]:存储子集的数组,定义在 findAllSubsets() 函数里;
- size:arr[] 和 subset[] 数组的长度;
- startIndex:每次调用函数,可选元素的范围是不断变化的,比如刚开始的可选范围是整个集合 {1, 2, 3},而选择完元素 1 之后,可选范围变成了 {2, 3}。为了表示可选范围,特意用 startIndex 参数表示可选范围的起始位置。
- subsetIndex:记录 subset[] 数组中已经选择好的元素数量。
subsetBacktrack() 函数的执行过程,和前面讲回溯算法查找 {1, 2, 3} 所有子集的过程完全一致,这里不再赘述。程序的执行结果为:
{ } { 1 } { 1 2 } { 1 2 3 } { 1 3 } { 2 } { 2 3 } { 3 }
注意,此程序只适用于无重复元素的集合。如果将 main() 函数里的 arr[] 数组改成:
int arr[] = { 1, 1, 2 };再次执行程序,结果为:
{ } { 1 } { 1 1 } { 1 1 2 } { 1 2 } { 1 } { 1 2 } { 2 }原本
{1, 1, 2}
的子集有 6 种,输出结果中也全部包含了,只是个别子集重复输出了多次。也就是说,对于存在相同元素的集合,程序中缺少查重的机制。
有重复元素集合的子集
对于有重复元素的集合,前面的程序也能找到所有的子集,只不过缺少查重的机制,导致有些子集找到了多次。接下来尝试修改前面的程序,让它也适用于有重复元素的集合。修改程序之前,首先要搞清楚重复找到子集的原因。把集合 {1, 2, 3} 替换成 {1, 1, 2},观察程序的执行过程:
从空的集合 { } 开始,找到子集 { }; 将元素 1 添加到集合里,找到子集 {1}; 将元素 1 添加到集合里,找到子集 {1,1}; 将元素 2 添加到集合里,找到子集 {1,1,2}; 没有元素可以选择,回溯到 {1,1} 的状态; 没有元素可以选择,回溯到 {1} 的状态; 将元素 2 添加到集合里,找到子集 {1,2}; 没有元素可以选择,回溯到 {1} 的状态; 没有元素可以选择,回溯到 { } 空集合的状态; 将元素 1 添加到集合,找到子集 {1}; 将元素 2 添加到集合里,找到子集 {1,2}; 没有元素可以选择,回溯到 {1} 状态; 没有元素可以选择,回溯到 { } 空集合的状态; 将元素 2 添加到集合,找到子集 {2}; 没有元素可以选择,回溯到 { } 空集合的状态; 没有可以选择的元素,算法执行结束。注意看字体加粗部分,子集 {1} 和 {1, 2} 又重复找了一遍。因为集合中有两个相同的元素 1,先后从它们出发查找子集:
- 从第一个元素 1 出发查找子集:可以选择的元素范围是 {1,1,2};
- 从第二个元素 1 出发查找子集:可以选择的元素范围是 {1,2}。
观察两种情况下的可选元素范围,前者完全包含后者,所以第二种情况找到的所有子集都是重复的。也就是说,从第一个元素 1 出发找完子集之后,就没必要再重新从第二个元素 1 开始查找了。在上面的执行过程中,跳过(过滤掉)字体加粗的部分,程序的执行过程就正确了。
对于包含相同元素的集合,分享一种去重的方法:先把集合中的元素排好序(升序或者降序),然后从第一个元素开始查找子集,对于尚未被选中的元素,如果它和前一个元素相等,并且前一个元素处于未选中状态,就跳过这次查找过程。
下面是按照此方法在 {1, 1, 2} 中查找子集的过程:
对 {1, 1, 2} 进行升序排序; 从空的集合 { } 开始,找到子集 { }; 从 {1,1,2} 中选择第一个元素 1 添加到集合里,找到子集 {1}; 从 {1,2} 中选择第二个元素 1,由于前一个元素 1 处于选中状态,因此可以添加到集合里,找到子集 {1,1}; 从 {2} 中选择元素 2 添加到集合里,找到子集 {1,1,2}; 没有元素可以选择,回溯到 {1,1} 的状态; 没有元素可以选择,回溯到 {1} 的状态; 从 {1,2} 中选择元素 2 添加到集合里,找到子集 {1,2}; 没有元素可以选择,回溯到 {1} 的状态; 没有元素可以选择,回溯到 { } 空集合的状态; 从 {1,1,2} 中选择第二个元素 1,由于前一个元素 1 处于未选中状态,因此直接回溯,跳过这次查找; 从 {1,1,2} 中选择第三个元素 2,找到子集 {2}; 没有元素可以选择,回溯到 { } 空集合的状态; 没有可以选择的元素,算法执行结束。可以看到,成功跳过了之前字体加粗的部分。注意,之所以要判断前一个相同元素是否处于选中状态,是为了区分以下两种情况:
- 从 {1, 1, 2} 中选择第一个元素 1 添加到空集合 { } 后,紧接着选择第二个元素 1,从而找到子集 {1,1},这种情况是不能跳过的,此时前一个元素 1 处于选中状态;
- 从 {1, 1, 2} 中选择第二个元素 1 添加到空集合 { } 时,找到的子集会和选择第一个元素 1 时重复,这种情况是应该跳过的,此时前一个元素 1 处于未选中状态。
下面是修改后的 C 语言程序,它既能满足在无重复元素的集合中查找所有的子集,也能满足在有重复元素的集合中查找所有的子集:
#include <stdio.h> #include <stdlib.h> typedef enum { false, true } bool; //快排cmp函数 int cmp(const void* a, const void* b) { return *((int*)a) - *((int*)b); } // 打印一个子集 void printSubset(int subset[], int size) { printf("{ "); for (int i = 0; i < size; i++) { printf("%d ", subset[i]); } printf("}\n"); } // 回溯函数,找到所有子集 void subsetBacktrack(int arr[], int subset[], bool used[], int size, int startIndex, int subsetIndex) { // 打印当前的子集 printSubset(subset, subsetIndex); // 遍历剩余的元素 for (int i = startIndex; i < size; i++) { // 判断当前元素是否和前一个元素相同,如果相同,并且前一个元素是未选中状态,则直接跳过本次循环 if (i > 0 && (arr[i] == arr[i - 1] && used[i - 1] == false)) { continue; } // 将当前元素添加到子集中 subset[subsetIndex++] = arr[i]; // 标记当前元素已经被选中 used[i] = true; // 递归调用,用于生成包含当前元素的所有子集 subsetBacktrack(arr, subset, used, size, i + 1, subsetIndex); // 回溯:移除最近添加的元素,同时修改它的选中状态,以便于下一次迭代 used[i] = false; subsetIndex--; } } // 为执行subsetBacktrack()函数做前期准备和扫尾工作 void findAllSubsets(int arr[], int n) { // 动态分配数组以存储临时子集 int* subset = (int*)malloc(n * sizeof(int)); // 动态分配数组以存储各个元素的选中状态 bool* used = (int*)malloc(n * sizeof(bool)); //对集合进行排序 qsort(arr, n, sizeof(int), cmp); // 开始回溯过程 subsetBacktrack(arr, subset, used, n, 0, 0); // 释放分配的内存 free(subset); free(used); } int main() { int arr[] = { 1, 2, 1 }; // 初始集合 int n = sizeof(arr) / sizeof(arr[0]); // 计算集合的大小 // 调用函数,打印所有子集 findAllSubsets(arr, n); return 0; }和先前的程序相比,主要的改动有三处,分别是:
- 回溯之前,先对集合进行了排序;
- subsetBacktrack() 函数添加了一个名为 used[] 的数组,用来实时记录每个元素的选中状态,值为 false 表示尚未选择,值为 true 表示被选中;
- subsetBacktrack() 函数内部的 for 循环里添加了 if 判断语句,如果当前元素与前一个元素相同,且前一个元素处于未选中状态,就直接跳过本次循环,开始下一次循环。
下面是解决子集问题的 Java 程序:
import java.util.Arrays; public class SubsetFinder { // 打印一个子集 private void printSubset(int[] subset, int size) { System.out.print("{ "); for (int i = 0; i < size; i++) { System.out.print(subset[i] + " "); } System.out.println("}"); } // 回溯函数,找到所有子集 private void subsetBacktrack(int[] arr, int[] subset, boolean[] used, int size, int startIndex, int subsetIndex) { // 打印当前的子集 printSubset(subset, subsetIndex); // 遍历剩余的元素 for (int i = startIndex; i < size; i++) { // 判断当前元素是否和前一个元素相同,如果相同,并且前一个元素是未选中状态,则直接跳过本次循环 if (i > 0 && arr[i] == arr[i - 1] && !used[i - 1]) { continue; } // 将当前元素添加到子集中 subset[subsetIndex] = arr[i]; // 标记当前元素已经被选中 used[i] = true; // 递归调用,用于生成包含当前元素的所有子集 subsetBacktrack(arr, subset, used, size, i + 1, subsetIndex + 1); // 回溯:移除最近添加的元素,同时修改它的选中状态,以便于下一次迭代 used[i] = false; } } // 为执行subsetBacktrack()函数做前期准备和扫尾工作 public void findAllSubsets(int[] arr) { int n = arr.length; int[] subset = new int[n]; boolean[] used = new boolean[n]; Arrays.sort(arr); // 对集合进行排序 subsetBacktrack(arr, subset, used, n, 0, 0); } public static void main(String[] args) { SubsetFinder finder = new SubsetFinder(); int[] arr = { 1, 2, 1 }; // 初始集合 finder.findAllSubsets(arr); // 调用函数,打印所有子集 } }
下面是解决子集问题的 Python 程序:
# 打印一个子集 def print_subset(subset, size): print("{", " ".join(str(subset[i]) for i in range(size)), "}") # 回溯函数,用于找到所有子集 def subset_backtrack(arr, subset, used, size, start_index, subset_index): # 打印当前的子集 print_subset(subset, subset_index) # 遍历剩余的元素 for i in range(start_index, size): # 如果当前元素与前一个元素相同,并且前一个元素未被使用,则跳过 if i > 0 and arr[i] == arr[i - 1] and not used[i - 1]: continue # 将当前元素添加到子集中 subset[subset_index] = arr[i] subset_index += 1 used[i] = True # 标记当前元素已被使用 # 递归调用,继续添加下一个元素 subset_backtrack(arr, subset, used, size, i + 1, subset_index) # 回溯,移除最近添加的元素,并取消元素的使用标记 used[i] = False subset_index -= 1 # 函数用于初始化并开始回溯过程 def find_all_subsets(arr): n = len(arr) subset = [0] * n # 初始化子集数组 used = [False] * n # 初始化元素使用标记数组 arr.sort() # 对数组进行排序 subset_backtrack(arr, subset, used, n, 0, 0) if __name__ == "__main__": arr = [1, 2, 1] # 初始集合 find_all_subsets(arr) # 调用函数,打印所有子集
以上程序的执行结果均为:
{ }
{ 1 }
{ 1 1 }
{ 1 1 2 }
{ 1 2 }
{ 2 }