Elliott 在他的评论中是正确的,即多线程不是优化阶乘计算的好方法。当然,除非您计算出非常大的阶乘,否则它不会有效(例如与记忆化相比)。
在简单的递归分治策略中,伪代码为
factorial(N) =
return factorialHelper(set_of(1, N))
factorialHelper(set) =
if set.size == 1
return set.first
else
let set1, set2 = split_into_subsets(set, 2)
return factorialHelper(set1) * factorialHelper(set2)
现在我们可以像这样天真地使用线程:
factorialHelper(set) =
if set.size == 1
return set.first
else
let set1, set2 = split_into_subsets(set, 2)
return fork(lambda factorialHelper(set1)).join() *
fork(lambda factorialHelper(set2)).join()
(解释:fork(lambda factorialHelper(set1)).join() 应该意味着我们是:
- 创建一个新线程来计算factorialHelper(set1),
- 启动线程,
- 等待它提供结果。)
这就是问题所在。每个线程都在做一堆家务(例如拆分集合),然后是一个乘法。如果你看大图,那意味着 N!大约需要 N 个线程。
我们可以做得更好。而不是等待两个子线程完成。 “当前”线程可以完成其中之一的工作;例如
factorialHelper(set) =
if set.size == 1
return set.first
else
let set1, set2 = split_into_subsets(set, 2)
let child = fork(lanbda factorialHelper(set1))
return factorialHelper(set2) * child.join()
但这仍然需要大约 N / 2 个线程。还有更大的问题:
- 创建线程非常昂贵。
- 线程切换/等待另一个线程完成的成本相对较高。
- 在任何给定时间,您的计算机将只运行与其物理(或超线程)内核一样多的线程。
因此,如果您要在 Java 中按字面意思编写上述代码,它会像狗一样运行。线程太多。每个线程的线程创建/切换开销过多而有用工作量太少。
如果您使用Java Fork / Join Pool,您可以做得更好,但您还需要对split_into_subsets(set, 2) 步骤做一些事情。这会做很多多余的工作复制集合元素。
经典方法是使用单个共享数组(1 到 N)并传递“低”和“高”索引。但在这种情况下,我们甚至不需要数组,因为我们知道array[i] 与i 相同。 (忽略一个。我还在谈论伪代码!)
但接下来我们会遇到最后一个问题。平衡工作量。将两个大数(例如BigInteger)相乘的工作不是一个常数。这实际上取决于数字的大小。 (我认为对于M * N 它是 O(log2M * log2N)。如果你使用天真的分治法,乘法“左端”比“右端”快得多。解决这个问题很棘手,我怀疑分而治之是处理它的错误方法。
我实际上会考虑一种混合方法:
- 创建
T工作线程,其中T对应于可用内核的数量。
- 以循环方式将数字
1 到 N 分配到 T 子集中。
- 让每个线程(串行)计算其子集中所有数字的乘积。
- 使用尽可能多的线程对子集产品进行最终乘法。
在计算结束时,无论你怎么做,都会有一个使用一个线程完成的最终乘法。而之前的两次计算最多可以由2个线程完成。所以最后,一些线程“饥饿”将是不可避免的。
最后是乘法本身。显然,如果我们计算阶乘 (N) 以使 N 足够大以使多个线程值得,那么结果将大于 Long.MAX_VALUE。使用BigInteger 将是一个显而易见的选择。但是,BigInteger.multiply(BigInteger) 上有一点问号。研究高性能 N 位乘法算法和可以并行化的乘法算法可能是值得的。从这里开始:
郑重声明,BigInteger.multiply(BigInteger) 对于两个 n 位数字的复杂度如下: