如何使用递归改写 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')。
备注:内容仅供参考。
