组合问题

简介: 组合问题是从给定的N个数中找出任意K个数的所有组合。由于组合内元素无顺序,因此[1,2]和[2,1]视为同一组合。解决组合问题常用的方法包括嵌套循环和回溯算法。嵌套循环适用于K值较小的情况,而回溯算法更适合K值较大的情况,能有效避免多层循环带来的代码复杂性和低效率问题。回溯算法通过递归实现,能动态选择元素并逐步构建组合,最终输出所有符合条件的组合结果。

组合问题指的是从给定的 N 个数中找出任意 K 个数的所有组合。例如,从 [1, 2, 3, 4] 这 4 个数中找到任意 2 个数的所有组合,它们是:

1 2

1 3

1 4

2 3

2 4

3 4

注意,组合内的元素是没有顺序的,这就意味着 [1, 2] 和 [2, 1] 是同一个组合。因此从 [1, 2, 3, 4] 中找到任意 2 个数的组合,一共有 6 组。


再比如,从 [1,2,3,4] 这 4 个数中找到任意 3 个数的所有组合,它们是:

1 2 3

1 2 4

1 3 4

2 3 4


编程解决组合问题,多数读者最先想到的思路是用嵌套的循环结构。比如从 [1, 2, 3, 4] 中找到任意 2 个数的组合,对应的 C 语言实现代码为:

  1. #include <stdio.h>
  2. int main()
  3. {
  4. int nums[] = { 1,2,3,4 };
  5. // i 的值作为选择的第一个数
  6. for (int i = 0; i < 4; i++) {
  7. // j 的值作为选择的第二个数
  8. for (int j = i + 1; j < 4; j++) {
  9. printf("%d %d\n", nums[i], nums[j]);
  10. }
  11. }
  12. return 0;
  13. }

类似的,查找任意 3 个数的组合,需要用 3 个嵌套的循环结构实现。也就是说,从 N 个数中找到任意 K 个数的所有组合,用循环结构实现的话,需要用 K 个嵌套的循环结构。


嵌套的循环结构解决组合问题,只适合 K 值较小的情况,比如 K 是 2 或者 3。当 K 值较大时,比如 10、20 甚至更大,循环嵌套的层数非常多,代码变得复杂、难以维护,算法的时间复杂度也会呈指数级增长,导致效率极低,这种情况下更推荐用回溯算法解决组合问题。

回溯算法解决组合问题

假设在 {0, 1, 2, 3} 这 4 个数中找到任意 3 个数的所有组合,下面是回溯算法的整个实现思路:

从 {0,1,2,3} 里,选择元素 0;

   从 {1,2,3} 里,选择元素 1;

       从 {2,3} 里,选择元素 2,找到了 {0,1,2} 组合;

       回溯到 {2,3},选择元素 3,找到了 {0,1,3} 组合;

       回溯到 {2,3},没有元素可以选择,再次回溯;

   回溯到 {1,2,3},选择元素 2;

       从 {3} 里选择元素 3,找到了 {0,2,3} 组合;

       回溯到 {3},没有元素可以选择,再次回溯;

   回溯到 {1,2,3},只剩 3 可以选择,无法构成一个新的组合,直接回溯;

回溯到 {0,1,2,3},选择元素 1;

   从 {2,3} 里,选择元素 2;

       从 {3} 里选择元素 3,找到了 {1,2,3} 组合;

   回溯到 {2,3},只剩 3 可以选择,无法构成一个新的组合,直接回溯;

回溯到 {0,1,2,3},只剩 2 和 3 可以选择,无法构成一个新的组合,算法执行结束。

仔细观察整个过程不难发现,每选择一个元素 e,下次选择是在 e 之后的元素里进行。比如选择了元素 1 和 2,下次选择就是在 {3} 中进行,而不是 {0, 3}。这样做的好处是,可以确保每种组合只找到一次,不会重复。


实现回溯算法,通常会采用递归的方式,下面是用回溯算法解决组合问题的 C 语言代码:

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #define N 4  // 定义数组的大小
  4. #define K 2  // 定义组合的大小

  5. // 为实现回溯算法做前期准备的函数
  6. void printCombination(int arr[], int n, int k);
  7. // 实现用回溯算法解决组合问题的递归函数
  8. void combinationUtil(int arr[], int data[], int start, int end, int index, int k);

  9. int main() {
  10. int arr[N];  // 声明一个大小为 N 的数组

  11. // 初始化数组为 {1, 2, 3, 4}
  12. for (int i = 0; i < N; i++) {
  13.        arr[i] = i + 1;
  14. }
  15. // 调用 printCombination 函数来打印所有可能的组合
  16. printCombination(arr, N, K);
  17. return 0;
  18. }

  19. // 用于初始化临时数组并开始组合过程
  20. void printCombination(int arr[], int n, int k) {
  21. // 动态创建一个数组,用于存储选择了的元素
  22. int* data = (int*)malloc(k * sizeof(int));

  23. // 使用回溯生成所有组合
  24. combinationUtil(arr, data, 0, n, 0, k);

  25. free(data);  // 释放之前分配的内存
  26. }

  27. // 在 arr 数组中的[start,end)下标范围内,找到 k 个可选择的元素
  28. // data[] 数组用于存储被选择了的元素
  29. // index 参数用来统计被选择的元素个数
  30. void combinationUtil(int arr[], int data[], int start, int end, int index, int k) {
  31. // 如果已找到一个完整的组合
  32. if (index == k) {
  33. // 打印当前组合
  34. for (int j = 0; j < k; j++) {
  35. printf("%d ", data[j]);
  36. }
  37. printf("\n");
  38. return;
  39. }

  40. // 递归生成所有可能的组合
  41. for (int i = start; (i < end) && (end - i + 1 >= k - index); i++) {
  42.        data[index] = arr[i];  // 将当前元素加入到当前组合中
  43. combinationUtil(arr, data, i + 1, end, index + 1, k);  // 递归调用以选择下一个元素
  44. }
  45. }

程序中自定义了两个函数,真正实现“回溯算法解决组合问题”的是 combinationUtil() 函数,而 printCombination() 函数存在的意义是创建一个数组,用来存储回溯过程被选择的元素,以便后续输出符合要求的组合。


虽然 combinationUtil() 函数的参数数量比较多,但非常容易理解:

  • [start, end):start 和 end 用来表示 arr 数组中的下标范围,也是可选择的元素范围。递归过程中,end 的值不变,但 start 的值是变化的;
  • arr 和 k:arr 数组用于存储所有的元素;k 用于指定要选择的元素数量。整个递归过程中,arr 和 k 都是不变的。
  • data 和 index:data 数组用来存储被选择了的元素;index 用来记录已经选择了的元素数量。


程序中,需要重点解释的是第 49 行的循环条件(i < end) && (end - i + 1 >= k - index),其中 (end - i + 1 >= k - index) 的含义是:

  • end - i + 1:计算的是从当前元素(包括当前元素)到数组末尾的元素数量,也就是从当前元素开始,数组中还剩下多少个元素可以被选择。
  • k - index:表示为了达到指定的组合大小,还需要选择多少个元素。


如果可选元素的数量能满足需求,即end - i + 1 >= k - index成立,可以继续筛选;反之,如果可选元素的数量无法满足需求,说明当前情况下已经无法找到符合条件的组合,直接回溯即可。


通常情况下,多数读者只会想到i < end作为循环条件,程序也是能正常运行的。添加end - i + 1 >= k - index作为循环条件,可以过滤掉一些无效的、没必要存在的递归过程,进一步优化程序的运行效率。


下面是回溯算法解决组合问题的 Python 程序:

  1. def print_combination(arr, n, k):
  2.    """
  3.    用于初始化临时列表并开始组合过程
  4.    """
  5. # 创建一个列表,用于存储选择的元素
  6.    data = [0] * k
  7. # 使用回溯生成所有组合
  8. combination_util(arr, data, 0, n, 0, k)

  9. def combination_util(arr, data, start, end, index, k):
  10.    """
  11.    在 arr 列表中 [start, end) 下标范围内,找到 k 个可选择的元素
  12.    data 列表用于存储被选择的元素
  13.    index 参数用来统计被选择的元素个数
  14.    """
  15. # 如果已找到一个完整的组合
  16. if index == k:
  17. # 打印当前组合
  18. for i in data:
  19. print(i, end=' ')
  20. print()
  21. return

  22. # 递归生成所有可能的组合
  23. for i in range(start, end):
  24. if end - i >= k - index:
  25.            data[index] = arr[i]  # 将当前元素加入到当前组合中
  26. combination_util(arr, data, i + 1, end, index + 1, k)  # 递归调用以选择下一个元素

  27. # 定义列表的大小和组合的大小
  28. N = 4
  29. K = 2

  30. if __name__ == "__main__":
  31. # 声明一个大小为 N 的列表并初始化为 {1, 2, 3, 4}
  32.    arr = [i + 1 for i in range(N)]
  33. # 调用 print_combination 函数来打印所有可能的组合
  34. print_combination(arr, N, K)


下面是回溯算法解决组合问题的 Java 程序:

  1. public class Combination {

  2. public static void main(String[] args) {
  3. final int N = 4; // 定义数组的大小
  4. final int K = 2; // 定义组合的大小
  5. int[] arr = new int[N];

  6. // 初始化数组为 {1, 2, 3, 4}
  7. for (int i = 0; i < N; i++) {
  8.            arr[i] = i + 1;
  9. }

  10. // 调用 printCombination 函数来打印所有可能的组合
  11. printCombination(arr, N, K);
  12. }

  13. // 用于初始化临时数组并开始组合过程
  14. public static void printCombination(int[] arr, int n, int k) {
  15. int[] data = new int[k];

  16. // 使用回溯生成所有组合
  17. combinationUtil(arr, data, 0, n, 0, k);
  18. }

  19. // 在 arr 数组中的[start, end)下标范围内,找到 k 个可选择的元素
  20. // data 数组用于存储被选择了的元素
  21. // index 参数用来统计被选择的元素个数
  22. public static void combinationUtil(int[] arr, int[] data, int start, int end, int index, int k) {
  23. // 如果已找到一个完整的组合
  24. if (index == k) {
  25. // 打印当前组合
  26. for (int i = 0; i < k; i++) {
  27.                System.out.print(data[i] + " ");
  28. }
  29.            System.out.println();
  30. return;
  31. }

  32. // 递归生成所有可能的组合
  33. for (int i = start; (i < end) && (end - i + 1 >= k - index); i++) {
  34.            data[index] = arr[i]; // 将当前元素加入到当前组合中
  35. combinationUtil(arr, data, i + 1, end, index + 1, k); // 递归调用以选择下一个元素
  36. }
  37. }
  38. }

运行结果,结果为:

1 2

1 3

1 4

2 3

2 4

3 4

相关文章
|
3月前
|
算法 Java C语言
弗洛伊德算法求最短路径
弗洛伊德算法用于寻找加权图中各顶点间的最短路径,适用于无向图和有向图。算法基于动态规划思想,通过枚举中间顶点来更新路径,确保最终得到最短路径。该算法要求路径权值非负,否则可能出错。
292 0
|
Unix Linux 编译器
Linux创建临时文件mkstemp()tmpfile()
有些程序需要创建一些临时文件,仅供其在运行期间使用,程序终止后即行删除。 很多编译器程序会在编译过程中创建临时文件。GNU C 语言函数库为此而提供了一系列库函数。(之所以有“一系列”的库函数,部分原因是由于这些函数分别继承自各种 UNIX 实现。)本节将介绍其中的两个函数:mkstemp()和 tmpfile()。
280 0
Linux创建临时文件mkstemp()tmpfile()
|
3月前
|
算法 Java 定位技术
迷宫问题
迷宫问题是指在给定区域内寻找从起点到终点的可行路径。可以使用回溯算法解决,通过不断尝试四个方向(上下左右)移动,若无法前进则回退,直到找到终点或遍历所有可能路径。文中还给出了C语言、Java和Python的实现代码,并展示了运行结果。
121 0
|
3月前
|
存储 算法
最小生成树的概念与思想
数据结构中的图存储结构包含顶点和边,分为连通图与非连通图。生成树是包含所有顶点、任意两点间仅有一条通路的极小连通图。最小生成树则是权值总和最小的生成树,常用于解决道路建设等实际问题,常用算法有普里姆算法和克鲁斯卡尔算法。
66 0
|
3月前
|
算法 程序员
时间复杂度和空间复杂度的概念
本文介绍了如何评估算法的执行效率和内存占用,重点讲解了时间复杂度和空间复杂度的概念及其计算方法。通过大O记法,可以量化算法的运行时间和内存使用情况,从而在不同算法间做出合理选择。
90 0
|
3月前
|
存储 算法 Java
寻找图中是否存在路径
本节介绍了如何使用并查集判断图中两个顶点之间是否存在有效路径。通过将图的边信息存储到并查集中,同一连通区域的顶点会处于同一集合。只需比较两个顶点的根节点是否相同,即可判断它们是否连通。文中提供了C语言、Java和Python的实现代码,并通过示例验证了算法的正确性。
63 0
|
3月前
|
算法 搜索推荐
分治算法的基本思想
分治算法通过将复杂问题拆分成多个独立的小问题,逐一解决后再合并结果。适合处理大规模数据,常见应用包括排序、查找最大值/最小值及汉诺塔等问题。
85 0
|
3月前
|
算法 Java C语言
汉诺塔问题
汉诺塔问题源自印度古老传说,涉及将一组圆盘从一根柱子移动到另一根,遵循特定规则。文章详细介绍了该问题的背景、解决思路以及如何使用分治算法实现,同时提供了C语言、Java和Python的代码示例。
184 0
|
3月前
|
算法
回溯算法的基本思想
本节介绍回溯算法,通过图1中从A到K的路径查找示例,说明其与穷举法的异同。回溯算法通过“回退”机制高效试探各种路径,适用于决策、优化和枚举问题。
76 0