1. 简介

在本教程中,我们将讨论如何在 Java 中解决 k-combinations 问题。

首先,我们会介绍并实现递归和迭代两种算法来生成指定大小的所有组合。随后,我们还会回顾一些使用常见 Java 库的解决方案。

2. 组合概述

简单来说,组合是从给定集合中选取若干元素的子集

与排列不同的是,组合不关心元素的顺序,只关心某个元素是否被选中。

举个例子,在扑克牌游戏中,从一副 52 张牌中发 5 张牌给玩家,我们并不关心这 5 张牌的发牌顺序,而只关心最终手牌的内容。

有些问题需要我们遍历所有可能的组合。为了实现这一点,我们需要枚举出所有不同的组合情况。

从包含 “n” 个元素的集合中选择 “r” 个元素的不同方式数可以用以下数学公式表示:

因此,在最坏情况下,组合的数量会呈指数级增长。这意味着当集合较大时,可能无法枚举所有组合。

在这种情况下,我们可以随机选取部分代表性组合进行采样(sampling)。

接下来,我们将介绍生成组合的各种算法。

3. 递归算法生成组合

递归算法 通常将问题拆解为更小的同类子问题,直到达到终止条件(即基础情况),然后直接求解基础情况。

我们将介绍两种划分问题的方式:

  • 第一种方式基于集合中的元素进行划分;
  • 第二种方式则通过追踪已选元素来划分任务。

3.1. 基于整个集合中的元素划分

我们可以通过逐一检查集合中的每个元素,决定是否将其包含在组合中。

对于集合中的每个元素,我们可以选择包含它或排除它。

✅ 如果我们选择包含第一个元素,则需要从剩余的 “n – 1” 个元素中选出 “r – 1” 个元素。
❌ 如果我们排除第一个元素,则需要从剩余的 “n – 1” 个元素中选出 “r” 个元素。

这可以用数学方式表达为:

下面是该方法的递归实现:

private void helper(List<int[]> combinations, int data[], int start, int end, int index) {
    if (index == data.length) {
        int[] combination = data.clone();
        combinations.add(combination);
    } else if (start <= end) {
        data[index] = start;
        helper(combinations, data, start + 1, end, index + 1);
        helper(combinations, data, start + 1, end, index);
    }
}

helper 方法会进行两次递归调用:一次包含当前元素,一次排除当前元素。

接下来,我们编写一个组合生成器来调用这个 helper 方法:

public List<int[]> generate(int n, int r) {
    List<int[]> combinations = new ArrayList<>();
    helper(combinations, new int[r], 0, n-1, 0);
    return combinations;
}

然后我们调用该方法生成组合:

List<int[]> combinations = generate(N, R);
for (int[] combination : combinations) {
    System.out.println(Arrays.toString(combination));
}
System.out.printf("generated %d combinations of %d items from %d ", combinations.size(), R, N);

执行后输出如下:

[0, 1]
[0, 2]
[0, 3]
[0, 4]
[1, 2]
[1, 3]
[1, 4]
[2, 3]
[2, 4]
[3, 4]
generated 10 combinations of 2 items from 5

最后,编写测试用例:

@Test
public void givenSetAndSelectionSize_whenCalculatedUsingSetRecursiveAlgorithm_thenExpectedCount() {
    SetRecursiveCombinationGenerator generator = new SetRecursiveCombinationGenerator();
    List<int[]> selection = generator.generate(N, R);
    assertEquals(nCr, selection.size());
}

⚠️ 注意:该方法的调用栈深度等于集合中元素的个数。如果集合很大,可能会导致 StackOverflowError

因此,这种方法不适合处理大规模输入。

3.2. 基于组合中元素的划分

我们不再追踪输入集合中的元素,而是通过追踪已选元素来划分任务。

假设我们将输入集合的元素编号为 1 到 n。我们可以从第 1 到第 “n – r + 1” 个元素中选择第一个元素。

假设我们选择了第 k 个元素,那么我们需要从第 “k + 1” 到第 “n” 个元素中选择 “r – 1” 个元素。

这个过程的数学表达如下:

下面是实现该方法的递归代码:

private void helper(List<int[]> combinations, int data[], int start, int end, int index) {
    if (index == data.length) {
        int[] combination = data.clone();
        combinations.add(combination);
    } else {
        int max = Math.min(end, end + 1 - data.length + index);
        for (int i = start; i <= max; i++) {
            data[index] = i;
            helper(combinations, data, i + 1, end, index + 1);
        }
    }
}

在上面的代码中,for 循环负责选择下一个元素,然后递归调用 helper 方法继续选择剩余元素。

接着我们使用 helper 方法生成组合:

public List<int[]> generate(int n, int r) {
    List<int[]> combinations = new ArrayList<>();
    helper(combinations, new int[r], 0, n - 1, 0);
    return combinations;
}

最后,编写测试用例:

@Test
public void givenSetAndSelectionSize_whenCalculatedUsingSelectionRecursiveAlgorithm_thenExpectedCount() {
    SelectionRecursiveCombinationGenerator generator = new SelectionRecursiveCombinationGenerator();
    List<int[]> selection = generator.generate(N, R);
    assertEquals(nCr, selection.size());
}

该方法的调用栈深度等于组合中元素的个数。因此,只要组合大小不超过最大调用栈深度,该方法适用于大规模输入。

但如果组合本身也很大,该方法依然会失效。

4. 迭代算法

在迭代方法中,我们从一个初始组合开始,不断生成下一个组合,直到所有组合都被生成完毕

我们按照字典序生成组合。从字典序最小的组合开始。

为了生成下一个组合,我们找到当前组合中可以递增的最右边的位置。然后将其递增,并在该位置右侧生成字典序最小的组合。

下面是实现代码:

public List<int[]> generate(int n, int r) {
    List<int[]> combinations = new ArrayList<>();
    int[] combination = new int[r];

    // 初始化为字典序最小的组合
    for (int i = 0; i < r; i++) {
        combination[i] = i;
    }

    while (combination[r - 1] < n) {
        combinations.add(combination.clone());

        // 生成下一个字典序组合
        int t = r - 1;
        while (t != 0 && combination[t] == n - r + t) {
            t--;
        }
        combination[t]++;
        for (int i = t + 1; i < r; i++) {
            combination[i] = combination[i - 1] + 1;
        }
    }

    return combinations;
}

接着编写测试用例:

@Test
public void givenSetAndSelectionSize_whenCalculatedUsingIterativeAlgorithm_thenExpectedCount() {
    IterativeCombinationGenerator generator = new IterativeCombinationGenerator();
    List<int[]> selection = generator.generate(N, R);
    assertEquals(nCr, selection.size());
}

5. 使用 Java 库实现组合

在可能的情况下,我们应该优先使用现有的库实现,而不是自己造轮子。下面我们将介绍几个常用的 Java 库。

5.1. Apache Commons

Apache Commons 的 [CombinatoricsUtils](https://commons.apache.org/proper/commons-math/javadocs/api-3.6/org/apache/commons/math3/util/CombinatoricsUtils.html) 类提供了很多组合相关的工具方法。其中的 combinationsIterator 方法返回一个按字典序生成组合的迭代器。

首先添加 Maven 依赖:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-math3</artifactId>
    <version>3.6.1</version>
</dependency>

然后使用 combinationsIterator 方法打印组合:

public static void generate(int n, int r) {
    Iterator<int[]> iterator = CombinatoricsUtils.combinationsIterator(n, r);
    while (iterator.hasNext()) {
        final int[] combination = iterator.next();
        System.out.println(Arrays.toString(combination));
    }
}

5.2. Google Guava

Guava 的 [Sets](https://google.github.io/guava/releases/snapshot/api/docs/com/google/common/collect/Sets.html) 类提供了许多集合相关的工具方法。其中的 combinations 方法可以返回指定大小的所有子集。

添加 Maven 依赖:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>27.0.1-jre</version>
</dependency>

使用 combinations 方法生成组合:

Set<Set<Integer>> combinations = Sets.combinations(ImmutableSet.of(0, 1, 2, 3, 4, 5), 3);

5.3. CombinatoricsLib

CombinatoricsLib 是一个轻量级库,支持排列、组合、子集、整数划分和笛卡尔积等操作。

添加 Maven 依赖:

<dependency>
    <groupId>com.github.dpaukov</groupId>
    <artifactId>combinatoricslib3</artifactId>
    <version>3.3.0</version>
</dependency>

使用示例:

Generator.combination(0, 1, 2, 3, 4, 5)
  .simple(3)
  .stream()
  .forEach(System.out::println);

执行后输出如下:

[0, 1, 2]
[0, 1, 3]
[0, 1, 4]
[0, 1, 5]
[0, 2, 3]
[0, 2, 4]
[0, 2, 5]
[0, 3, 4]
[0, 3, 5]
[0, 4, 5]
[1, 2, 3]
[1, 2, 4]
[1, 2, 5]
[1, 3, 4]
[1, 3, 5]
[1, 4, 5]
[2, 3, 4]
[2, 3, 5]
[2, 4, 5]
[3, 4, 5]

更多示例请参考 combinatoricslib3-example

6. 总结

本文中我们实现了几种生成组合的算法,并介绍了几个常见的 Java 库实现。

✅ 在实际开发中,推荐优先使用成熟的库,而不是自己实现算法。

完整代码可以在 GitHub 上找到。


原始标题:Generate Combinations in Java