Java 8:掌握排列

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / Java 学习路线 / 一对一提问 / 学习打卡/ 赠书活动

目前,正在 星球 内带小伙伴们做第一个项目:全栈前后端分离博客项目,采用技术栈 Spring Boot + Mybatis Plus + Vue 3.x + Vite 4手把手,前端 + 后端全栈开发,从 0 到 1 讲解每个功能点开发步骤,1v1 答疑,陪伴式直到项目上线,目前已更新了 204 小节,累计 32w+ 字,讲解图:1416 张,还在持续爆肝中,后续还会上新更多项目,目标是将 Java 领域典型的项目都整上,如秒杀系统、在线商城、IM 即时通讯、权限管理等等,已有 870+ 小伙伴加入,欢迎点击围观

每天早上醒来时,您需要执行一组任务,然后才能上班或做其他您喜欢的事情。你有没有想过每一个可能的任务顺序,你可以开始新的一天?

使用 排列, 您可以尝试输入集的所有组合。正如您将在本文中了解到的那样,即使对于少量编号的集合,组合的数量也是惊人的。例如,如果您只有十件物品,您可以以超过三百万种方式组合它们!

在本文中,我们将构建一个名为 Permutations 的小型 Java 8 支持类,可用于单元测试、游戏策略评估、问题解决以及许多其他强大的应用程序。

数学

您不必阅读本章即可理解这篇文章。但是,它有帮助。所以,在跳到下一章之前,尽量坚持下去。

术语 排列 涉及按顺序排列集合的所有成员的过程,或者,如果集合已经排序,则重新排列(或从数学上讲 排列 )集合的顺序。例如,如果我们有一个集合 {1, 2, 3},我们可以用六种不同的方式排列该集合; {1, 2, 3}, {1, 3, 2}, {2, 1, 3}, {2, 3, 1}, {3, 1, 2}, {3, 2, 1}。

一个集合中 n 个不同对象的排列数称为 n 阶乘 ,数学上写为 n!。 n的价值!可以计算为 1 乘以所有小于或等于 n 的正整数。因此,如果我们采用之前的集合 {1, 2, 3},我们将看到它具有三个不同的成员。这样,就会有3个! = 1*1*2*3 = 6 种独特的组合。令人鼓舞的是,这与上面的例子一致,我们在上面列出了所有六种组合。细心的读者现在得出结论,可以忽略 1 的序列因数,因为 1 是 乘法恒等式 ,即对于任何数字 a,1*a = a 为真。在纯英语中,这意味着您可以跳过与 1 的乘法,因为无论如何您都会得到相同的结果。儿子!实际上是 1 乘以 2*3*....*n。

现在,如果我们生成一个集合中不同成员数量的列表并计算相应的 n 阶乘 ,我们将得到下表:

0! 1(根据定义,1 不乘以任何东西)

1! 1(显然你只能用一种方式排列单个项目)

2! 2个

3! 6(如上例)

4! 24

5! 120(一个字节可以容纳的最大 n 阶乘值(最大 2^7 = 127))

6! 720

7! 5040(可放入空头的最大 n 阶乘值(最大 2^15 = 32768))

8! 40320

9! 362880

10! 3628800

11! 39916800

12! 479001600(可以放入 int 的最大 n 阶乘值(最大 2^31 = 2147483647))

13! 6227020800

14! 87178291200

15! 1307674368000

16! 20922789888000

17! 355687428096000

18! 6402373705728000

19! 121645100408832000

20! 2432902008176640000(可以放入 long 的最大 n 阶乘值(最大 2^63))

21! 51090942171709440000

...

42! 1405006117752879898543142606244511569936384000000000(~地球上的原子数)

如您所见,在代码中应用排列时必须小心,因为即使是少量项目,您也可能会遇到极长的执行时间。例如,如果您可以在 1 毫秒内评估任何给定的组合并且您有 16 个项目,则单个线程的挂钟时间将为 16!/1000 秒 > 600 年!

排列类

有许多方法可以用编程语言实现置换器。在这篇文章中,我的目标是为 Java 8 中的置换器提供尽可能短的代码,因此如果您能想出更短的代码,请在下面发表评论。因此,我选择了简单性而不是性能。

其次,我想使用 Java 8 Streams,允许与可用的强大 Stream 函数集成。

执行

我们需要的第一个方法是 n 阶乘 函数,它计算不同集合的唯一组合的数量。我之前写过一篇关于此的文章,您可以 在此处 阅读详细信息。这是它的样子:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

请注意,该函数将仅处理 0 <= n <= 20 的输入范围,因为 0 是为 n 阶乘 定义的最低输入值,而 20 对应于 long 可以容纳的最大返回值。

接下来我们需要的是一种返回任何给定输入列表的编号排列的方法。因此,例如,如果我们有一个包含两个项目“A”和“B”的列表,则第一个排列 (0) 是 {A, B},第二个排列 (1) 是 {B, A}。如果我们有一个包含树项 {A, B, C} 的列表,那么我们有以下排列:

不。排列

-- --------------

0 {A, B, C}

1 {A, C, B}

2 {B, A, C}

3 {乙,丙,甲}

4 {C, A, B}

5 {C, B, A}

在我要利用的模式变得明显之前,我们需要制作另一个列表表。列表 {A, B, C, D} 的完整排列如下所示:

不。排列

-- --------------

0 {A, B, C, D}

1 {A, B, D, C}

2 {A, C, B, D}

3 {A, C, D, B}

4 {A, D, B, C}

5 {A, D, C, B}

6 {乙、甲、丙、丁}

7 {B, A, D, C}

8 {乙、丙、甲、丁}

9 {B, C, D, A}

10 {B, D, A, C}

11 {B, D, C, A}

12 {C, A, B, D}

13 {C, A, D, B}

14 {C, B, A, D}

15 {C, B, D, A}(在下面的示例中使用)

16 {C, D, A, B}

17 {C, D, B, A}

18 {D, A, B, C}

19 {D, A, C, B}

20 {D, B, A, C}

21 {D, B, C, A}

22 {D, C, A, B}

23 {D, C, B, A}

查看每行中的第一项,您会发现它按照输入列表的顺序变化。令 n 为输入列表中的项目数 (n = 4),subFactorial 为 (n-1)! (subFactorial = (4-1)!= 3!= 6) 那么列表中的第一项排列编号位于索引 no/subFactorial。在上面的示例中,排列 15 的第一项位于索引 no/subFactorial = 15/6 = 2,对应于“C”(请记住,“A”位于索引 0,“B”位于索引 1,因此向前)。

这意味着对于任何输入列表,我们可以通过根据上述算法选择第一个项目来减少问题,然后再次递归调用相同的方法,但现在输入列表中的项目数量减少了。经典的分而治之的方法。这是它的样子:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

这个辅助方法采用排列数 no,一个输入列表 in 和初始排序的项目,然后一个 out 列表用于在递归过程中构建结果。

正如常见的递归实践所指示的那样,我们从 基本情况 开始,在这种情况下我们处理递归将停止的微不足道的情况。如果 in List 为空,那么我们就准备好了,只需返回现在完成的 out 列表。

如果in List不为空,那么我们先计算前面提到的subFactorial是(in.size()-1)!如上所述。该值随后会在该方法中使用。

下一行实际上是方法中的所有逻辑。我们首先计算我们想要放在最前面的项目在 out List 中的索引。该项目的索引是 (int)(no/subFactorial),我们将其从列表中删除。由于remove方法方便返回我们刚刚删除的item,我们可以直接使用remove的返回值,一步到位到out List的末尾即可。

现在我们已经将问题从 n 减少到 n-1,我们需要以适当的方式递归。我们调用我们的 permutationHelper() 方法,使用我们作为输入参数获得的相同输入和输出列表,但排列数 no 现在是前一个除法的 余数 。这是合乎逻辑的,因为我们之前只处理了除法的相应整体部分。因此,我们使用 no%subFactorial 调用,这是剩下的要处理的部分。

这真的是所有要做的。根据您的输入,这五行无辜的行可以在不到 1 毫秒或 2000 多年内执行。

辅助方法只是用来帮助我们编写我们在类中公开的真实置换器。这是暴露的包装:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

我们提供排列数 no 和我们想要用作方法输入的列表,我们只需创建输入和输出列表并调用辅助方法。简单的。

所以现在我们可以计算给定列表的任何排列。但是 Stream 支持仍然存在,它可以为我们提供排列列表流。既然我们有了 permutation() 方法,提供这样一个方法真的很简单。这是可以做到的:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

所有艰苦的工作都由免费支持惰性、并行性等的 LongStream 完成,我们只将长 Stream 映射到我们想要的内容。长流提供输入 longs 0, 1, 2, ..., (items.lenght)!然后我们将这些多头映射到相应的排列。这真的很简单。

请注意,我选择返回一个 Stream of Stream 而不是一个 Stream of List 或类似的东西。如果你想要一个列表流,你只需更改返回类型和映射。

现在我们已经完成了所需的一切,在开始使用一些示例之前,我将提供整个 Permutations 类:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

例子

我们首先使用以下代码片段测试我们的 factorial() 方法:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

这将打印出:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

这看起来令人鼓舞。从上一章我们知道,可以用六种方式组合三个不同的对象。所以现在我们想看看桌面上的事实:这些组合是什么?查看它们的一种方法是使用我们的 permutation() 方法。如果我们将以下代码添加到现有代码段中:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

我们将得到以下输出:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

哇!有用。它看起来与上一章中的表格完全相同。现在让我们使用 Permutaions.of() 方法进行如下旋转:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

Permutations.of() 方法将提供 {A, B, C} 的所有排列,然后我们将这些排列收集到列表中,然后像这样打印列表:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

有时我们只想在所有可能的顺序中生成一个序列,这也可以通过类似的方式轻松完成:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

这将产生:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

平行度

正如我提到的,我们从 Permutations.of() 获得的 Stream 支持并行性,如果您有大量的排列并且您想使用所有 CPU:s,这很好。为了能够检查哪些线程正在执行什么,我们将使用一个小的支持方法:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

现在,让我们通过运行以下行来检查正在使用哪些线程:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

我们会看到这样的东西:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

这是合乎逻辑的。我们所有的排列都由同一个线程处理(因为我们没有要求并行流)。现在,让我们修改测试线,使其看起来像这样:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

这将产生类似的东西;


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

显然,在我的计算机上,两个组合继续在主线程上执行,因为有两个其他线程(不均匀地)共享其余的工作。

懒惰

应该注意的是,Permutations.of() 中的 Stream 懒惰地生成组合序列(或根据需要使用其他词)。因此,如果我们设置了大量的组合,但只需要一个,流将只产生一个也是唯一的组合。举个例子,输入项有16个,对应的排列数非常多,堪比国家部:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

该行将立即完成并返回 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],不出所料,这是第一个组合。

测试

排列非常适合测试!您问过自己多少次:“我是否尝试了所有可能的组合”。使用排列,您现在可以确定您已经测试了每个输入组合,就像在这个简单的大纲中一样(显然,您需要用更有用的东西替换 System.out.println):


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

早晨

那么,最佳的早晨是什么样的呢?好吧,如果你一个人在家,你的一天可能会以许多不同的方式开始,也许是这样的:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

所以现在你可以从中选择你最喜欢的:


 public long factorial(int n) {
    if (n > 20 || n < 0) throw new IllegalArgumentException(n + " is out of range");
    return LongStream.rangeClosed(2, n).reduce(1, (a, b) -> a * b);
}

通过一些准备,即使是最后的组合也确实可行......

结论

排列是一个值得掌握的强大工具。

在这篇文章中,我设计了一个非常简短但相当高效的 Java 8 置换支持类的实现。

如果您正在编写单元测试,您绝对应该知道如何使用排列。

小心,不要撞到“渐近墙”并限制生成排列时使用的项目数量。

生活就是做出正确的选择,不是吗?