PAYATHON 2026

如何使用递归改写 Java 11 字符排列方法,使其支持更长的序列?

技术老齐

可以用递归回溯替代多层嵌套循环。每次调用选择一个字符,选够指定长度后保存结果,再撤销选择,继续尝试下一个字符。长度从 3 改成 10 时,只需调整参数。

首位限制只在选择第一个字符时生效,索引为 0 的字符仍然可以出现在后续位置。

递归需要维护哪些状态

生成排列需要记录:

  • 已选字符,用 current 保存。
  • 字符是否已使用,用 boolean[] used 标记。
  • 目标长度,用 length 指定。

当 current.size() == length 时,一个排列就完成了。否则,遍历字符列表,选择尚未使用的字符,再递归生成后续位置。

递归返回后,删除刚加入的字符,并清除对应的使用标记。恢复到选择前的状态,就是回溯。

Java 11 完整实现

five6789Scramble 使用默认字符集;需要指定字符列表时,可以调用 permutations:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;

public class CharacterPermutations {

    public static ArrayList<ArrayList<Character>> five6789Scramble(
            int length) {
        return permutations(
                Arrays.asList(
                        'a', 'b', 'c', 'd', 'e',
                        'f', 'g', 'h', 'i', 'j'
                ),
                length
        );
    }

    public static ArrayList<ArrayList<Character>> permutations(
            List<Character> charSet, int length) {

        // 固定本次调用使用的字符及其索引,同时拒绝 null 元素。
        List<Character> chars = List.copyOf(charSet);

        if (length < 0 || length > chars.size()) {
            throw new IllegalArgumentException(
                    "length must be between 0 and " + chars.size()
            );
        }

        // used 按索引记录状态,因此要求输入字符互不相同。
        if (new HashSet<>(chars).size() != chars.size()) {
            throw new IllegalArgumentException(
                    "charSet must contain distinct characters"
            );
        }

        ArrayList<ArrayList<Character>> result = new ArrayList<>();
        ArrayList<Character> current = new ArrayList<>(length);
        boolean[] used = new boolean[chars.size()];

        backtrack(chars, length, used, current, result);
        return result;
    }

    private static void backtrack(
            List<Character> chars,
            int length,
            boolean[] used,
            ArrayList<Character> current,
            ArrayList<ArrayList<Character>> result) {

        if (current.size() == length) {
            // 必须保存副本,current 随后还会被回溯修改。
            result.add(new ArrayList<>(current));
            return;
        }

        // 首位跳过索引 0;后续位置可以使用索引 0。
        int startIndex = current.isEmpty() ? 1 : 0;

        for (int i = startIndex; i < chars.size(); i++) {
            if (used[i]) {
                continue;
            }

            used[i] = true;
            current.add(chars.get(i));

            backtrack(chars, length, used, current, result);

            current.remove(current.size() - 1);
            used[i] = false;
        }
    }
}

长度参数使用 int,与集合大小和索引的类型一致。原来传入 byte 变量的调用仍然可用,Java 会自动将它拓宽为 int。

首位限制和回溯如何配合

用三个字符生成长度为 2 的排列:

ArrayList<ArrayList<Character>> result =
        CharacterPermutations.permutations(
                Arrays.asList('a', 'b', 'c'), 2
        );

System.out.println(result);

输出为:

[[b, a], [b, c], [c, a], [c, b]]

第一次调用时,current 为空,循环从索引 1 开始,首位只能选择 'b' 或 'c'。

选中 'b' 后,used[1] 为 true。下一层从索引 0 开始遍历,可以选择 'a' 或 'c',同时跳过已经使用的 'b'。

保存 [b, a] 后,回溯代码移除 'a',将它标记为未使用,再尝试 'c'。每一层都执行相同的操作,无需为不同长度添加分支。

保存排列时必须复制 current:

result.add(new ArrayList<>(current));

如果直接执行 result.add(current),结果列表中的各项都会引用同一个可变对象。后续回溯会继续修改这个对象,无法保留每次生成的排列。

长度边界与输入约定

  • length < 0 或大于字符数量时,抛出 IllegalArgumentException。
  • length == 0 时,返回包含一个空排列的列表,即 [[]]。空排列没有首位,因此不违反首位限制。
  • 输入字符必须互不相同,否则抛出 IllegalArgumentException。按索引标记使用状态,无法阻止两个不同索引上的相同字符重复出现。
  • charSet 本身或其中的元素为 null 时,List.copyOf 会抛出 NullPointerException。

原代码在长度为 0 时会进入生成单字符结果的分支。这里的递归终止条件修正了这一行为。

获取集合长度直接使用 size() 即可,无需调用 toArray().length。

长度为 10 时,要考虑结果的存储方式

对于 n 个互不相同的字符,生成长度为 k、首位不能使用索引 0 的排列,在 1 <= k <= n 时,结果数量为:

[ M=(n-1)\times\frac{(n-1)!}{(n-k)!} ]

因此,n = 10、k = 10 时:

[ M=9\times9!=3,265,920 ]

递归可以支持这个长度。若一次性返回全部结果,就需要保存 3,265,920 个内部 ArrayList 及其中的字符引用。实际内存占用取决于 JVM 的对象布局和引用大小,不能只按字符数量估算。

仅复制和存储这些结果,就需要与 k × M 成正比的时间和空间。回溯使用的状态数组、当前路径和递归调用栈需要 O(n + k) 的额外空间。使用递归可以减少重复代码,枚举全部排列的工作量仍然存在。

如果最终只是比较各个数字的因数数量,可以在递归到达目标长度时直接处理当前排列,更新最小值、最大值及对应数字,然后继续搜索,避免保存数百万个排列。

实际使用数字时,将输入列表设为:

Arrays.asList('0', '1', '2', '3', '4',
              '5', '6', '7', '8', '9')

此时索引 0 对应零,首位限制就能排除前导零。构造十位数应使用 long,因为部分结果超过了 int 的最大值。逐位累积可以使用 value = value * 10 + (digit - '0')。

备注:内容仅供参考。