Euler problem #16 已在这里讨论过很多次,但我找不到可以很好地概述可能的解决方案的答案,就像它的情况一样。这是我试图纠正的尝试。
本概述适用于已经找到解决方案并希望获得更全面了解的人。即使示例代码是 C#,它也基本上与语言无关。有一些在 C# 2.0 中不可用的功能的用法,但它们不是必需的 - 它们的目的只是让无聊的东西以最少的大惊小怪消失。
除了使用现成的 BigInteger 库(不计算在内)之外,Euler #16 的直接解决方案分为两个基本类别:本地执行计算 - 即在一个为 2 的幂的基础中 - 并转换为十进制以获取数字,或直接以十进制为基数执行计算,以便无需任何转换即可使用数字。
对于后者,有两个相当简单的选择:
原生计算 + 基数转换
这种方法是最简单的,它的性能超过了使用 .Net 的内置 BigInteger 类型的幼稚解决方案。
实际计算很容易实现:只需执行1 << 1000 的道德等价物,即存储 1000 个二进制零并附加一个单独的二进制 1。
转换也很简单,可以通过编写铅笔和纸的划分方法来完成,为了提高效率,可以选择适当大的“数字”。中间结果的变量需要能够容纳两个“数字”;将适合 long 的小数位数除以 2 得到 9 个小数位,表示最大元数字(或“肢体”,因为它通常在 bignum 传说中被称为)。
class E16_RadixConversion
{
const int BITS_PER_WORD = sizeof(uint) * 8;
const uint RADIX = 1000000000; // == 10^9
public static int digit_sum_for_power_of_2 (int exponent)
{
var dec = new List<int>();
var bin = new uint[(exponent + BITS_PER_WORD) / BITS_PER_WORD];
int top = bin.Length - 1;
bin[top] = 1u << (exponent % BITS_PER_WORD);
while (top >= 0)
{
ulong rest = 0;
for (int i = top; i >= 0; --i)
{
ulong temp = (rest << BITS_PER_WORD) | bin[i];
ulong quot = temp / RADIX; // x64 uses MUL (sometimes), x86 calls a helper function
rest = temp - quot * RADIX;
bin[i] = (uint)quot;
}
dec.Add((int)rest);
if (bin[top] == 0)
--top;
}
return E16_Common.digit_sum(dec);
}
}
我写了(rest << BITS_PER_WORD) | big[i] 而不是使用运算符+,因为这正是这里需要的;不需要进行带有进位传播的 64 位加法。这意味着这两个操作数可以直接写入寄存器对中它们各自的寄存器,或者写入像LARGE_INTEGER这样的等效结构中的字段。
在 32 位系统上,64 位除法不能内联为几条 CPU 指令,因为编译器无法知道算法保证商和余数适合 32 位寄存器。因此,编译器会调用一个可以处理所有可能性的辅助函数。
这些系统可能会从使用较小的肢体(即RADIX = 10000 和uint 而不是ulong 来保持中间(双肢体)结果)中受益。像 C/C++ 这样的语言的替代方法是调用合适的编译器内在函数,将原始 32 位乘以 32 位到 64 位乘法(假设除以常数基数是通过乘以逆来实现的) .相反,在 64 位系统上,如果编译器提供合适的 64×64 到 128 位乘法原语或允许内联汇编程序,则肢体大小可以增加到 19 位。
十进制加倍
重复加倍似乎是每个人的最爱,所以接下来让我们这样做。中间结果的变量需要保存一个“数字”加上一个进位位,对于long,每个肢体有 18 个数字。转到ulong 并不能改善情况(19 位数字加上进位缺少 0.04 位),所以我们不妨坚持使用long。
在二进制计算机上,小数边与计算机字边界不一致。这使得有必要在计算的每个步骤中对肢体执行模运算。在这里,这个模运算可以在进位的情况下减少为模的减法,这比执行除法要快。内部循环中的分支可以通过位旋转来消除,但这对于基本算法的演示来说是不必要的。
class E16_DecimalDoubling
{
const int DIGITS_PER_LIMB = 18; // == floor(log10(2) * (63 - 1)), b/o carry
const long LIMB_MODULUS = 1000000000000000000L; // == 10^18
public static int digit_sum_for_power_of_2 (int power_of_2)
{
Trace.Assert(power_of_2 > 0);
int total_digits = (int)Math.Ceiling(Math.Log10(2) * power_of_2);
int total_limbs = (total_digits + DIGITS_PER_LIMB - 1) / DIGITS_PER_LIMB;
var a = new long[total_limbs];
int limbs = 1;
a[0] = 2;
for (int i = 1; i < power_of_2; ++i)
{
int carry = 0;
for (int j = 0; j < limbs; ++j)
{
long new_limb = (a[j] << 1) | carry;
carry = 0;
if (new_limb >= LIMB_MODULUS)
{
new_limb -= LIMB_MODULUS;
carry = 1;
}
a[j] = new_limb;
}
if (carry != 0)
{
a[limbs++] = carry;
}
}
return E16_Common.digit_sum(a);
}
}
这与基数转换一样简单,但除了非常小的指数外,它的性能几乎没有(尽管它有 18 位小数的巨大元数字)。原因是代码必须执行 (exponent - 1) 加倍,并且每遍所做的工作对应于大约一半的数字(肢体)总数。
重复平方
通过重复平方进行幂运算的想法是用少量的乘法代替大量的加倍。
1000 = 2^3 + 2^5 + 2^6 + 2^7 + 2^8 + 2^9
x^1000 = x^(2^3 + 2^5 + 2^6 + 2^7 + 2^8 + 2^9)
x^1000 = x^2^3 * x^2^5 * x^2^6 * x^2^7 * x^2*8 * x^2^9
x^2^3 可以通过 x 3 次平方得到,x^2^5 可以通过 5 次平方得到,以此类推。在二进制计算机上,很容易将指数分解为 2 的幂,因为它是表示该数字的位模式。然而,即使是非二进制计算机也应该能够测试一个数字是奇数还是偶数,或者将一个数字除以二。
乘法可以用铅笔纸法编码;在这里,我使用了一个辅助函数,该函数计算一行乘积并将其添加到结果中适当移动的位置,以便以后不需要存储部分乘积的行以用于单独的加法步骤。计算过程中的中间值最多可以有两个“数字”大小,因此四肢的宽度只能是重复加倍时的一半(其中除了“数字”之外,只需要添加一个额外的位)。
注意:计算的基数不是 2 的幂,因此这里不能通过简单的移位来计算 2 的平方。从积极的方面来说,该代码可用于计算除 2 以外的碱基的能力。
class E16_DecimalSquaring
{
const int DIGITS_PER_LIMB = 9; // language limit 18, half needed for holding the carry
const int LIMB_MODULUS = 1000000000;
public static int digit_sum_for_power_of_2 (int e)
{
Trace.Assert(e > 0);
int total_digits = (int)Math.Ceiling(Math.Log10(2) * e);
int total_limbs = (total_digits + DIGITS_PER_LIMB - 1) / DIGITS_PER_LIMB;
var squared_power = new List<int>(total_limbs) { 2 };
var result = new List<int>(total_limbs);
result.Add((e & 1) == 0 ? 1 : 2);
while ((e >>= 1) != 0)
{
squared_power = multiply(squared_power, squared_power);
if ((e & 1) == 1)
result = multiply(result, squared_power);
}
return E16_Common.digit_sum(result);
}
static List<int> multiply (List<int> lhs, List<int> rhs)
{
var result = new List<int>(lhs.Count + rhs.Count);
resize_to_capacity(result);
for (int i = 0; i < rhs.Count; ++i)
addmul_1(result, i, lhs, rhs[i]);
trim_leading_zero_limbs(result);
return result;
}
static void addmul_1 (List<int> result, int offset, List<int> multiplicand, int multiplier)
{
// it is assumed that the caller has sized `result` appropriately before calling this primitive
Trace.Assert(result.Count >= offset + multiplicand.Count + 1);
long carry = 0;
foreach (long limb in multiplicand)
{
long temp = result[offset] + limb * multiplier + carry;
carry = temp / LIMB_MODULUS;
result[offset++] = (int)(temp - carry * LIMB_MODULUS);
}
while (carry != 0)
{
long final_temp = result[offset] + carry;
carry = final_temp / LIMB_MODULUS;
result[offset++] = (int)(final_temp - carry * LIMB_MODULUS);
}
}
static void resize_to_capacity (List<int> operand)
{
operand.AddRange(Enumerable.Repeat(0, operand.Capacity - operand.Count));
}
static void trim_leading_zero_limbs (List<int> operand)
{
int i = operand.Count;
while (i > 1 && operand[i - 1] == 0)
--i;
operand.RemoveRange(i, operand.Count - i);
}
}
这种方法的效率与基数转换大致相当,但这里有一些特定的改进。平方的效率可以通过编写一个特殊的平方例程来加倍,该例程利用ai*bj == aj*bi if a == b 的事实,它将乘法的数量减少了一半。
此外,与使用指数位来确定平方/乘法调度相比,计算加法链的方法总体上涉及更少的操作。
助手代码和基准
示例代码生成的元数字(十进制肢体)中的十进制数字求和的帮助代码是微不足道的,但为了您的方便,我还是把它贴在这里:
internal class E16_Common
{
internal static int digit_sum (int limb)
{
int sum = 0;
for ( ; limb > 0; limb /= 10)
sum += limb % 10;
return sum;
}
internal static int digit_sum (long limb)
{
const int M1E9 = 1000000000;
return digit_sum((int)(limb / M1E9)) + digit_sum((int)(limb % M1E9));
}
internal static int digit_sum (IEnumerable<int> limbs)
{
return limbs.Aggregate(0, (sum, limb) => sum + digit_sum(limb));
}
internal static int digit_sum (IEnumerable<long> limbs)
{
return limbs.Select((limb) => digit_sum(limb)).Sum();
}
}
这可以通过多种方式提高效率,但总体而言并不重要。
所有三个解决方案都需要 O(n^2) 时间,其中 n 是指数。换句话说,当指数增长十倍时,它们将花费一百倍的时间。通过采用分而治之的策略,基数转换和重复平方都可以提高到大约 O(n log n);我怀疑是否可以以类似的方式改进加倍计划,但从一开始就没有竞争力。
这里介绍的所有三种解决方案都可用于打印实际结果,方法是使用适当的填充对元数字进行字符串化并将它们连接起来。我将函数编码为返回数字总和而不是带小数的数组/列表,只是为了保持示例代码简单并确保所有函数具有相同的签名,以进行基准测试。
在这些基准测试中,.Net BigInteger 类型的包装如下:
static int digit_sum_via_BigInteger (int power_of_2)
{
return System.Numerics.BigInteger.Pow(2, power_of_2)
.ToString()
.ToCharArray()
.Select((c) => (int)c - '0')
.Sum();
}
最后,C# 代码的基准测试:
# testing decimal doubling ...
1000: 1366 in 0,052 ms
10000: 13561 in 3,485 ms
100000: 135178 in 339,530 ms
1000000: 1351546 in 33.505,348 ms
# testing decimal squaring ...
1000: 1366 in 0,023 ms
10000: 13561 in 0,299 ms
100000: 135178 in 24,610 ms
1000000: 1351546 in 2.612,480 ms
# testing radix conversion ...
1000: 1366 in 0,018 ms
10000: 13561 in 0,619 ms
100000: 135178 in 60,618 ms
1000000: 1351546 in 5.944,242 ms
# testing BigInteger + LINQ ...
1000: 1366 in 0,021 ms
10000: 13561 in 0,737 ms
100000: 135178 in 69,331 ms
1000000: 1351546 in 6.723,880 ms
如您所见,基数转换几乎与使用内置 BigInteger 类的解决方案一样慢。原因是运行时是较新的类型,它只对有符号整数类型执行某些标准优化,而不对无符号整数类型执行某些标准优化(这里:实现除以常数作为与逆的乘法)。
我还没有找到一种简单的方法来检查现有 .Net 程序集的本机代码,因此我决定采用不同的调查路径:我编写了 E16_RadixConversion 的一个变体,以便比较 ulong 和 uint分别被long和int替换,BITS_PER_WORD相应减少1。以下是时间安排:
# testing radix conv Int63 ...
1000: 1366 in 0,004 ms
10000: 13561 in 0,202 ms
100000: 135178 in 18,414 ms
1000000: 1351546 in 1.834,305 ms
比使用无符号类型的版本快三倍多!编译器中 numbskullery 的明显证据......
为了展示不同肢体大小的影响,我在 C++ 中对用作肢体的无符号整数类型的解决方案进行了模板化。计时以肢体的字节大小和肢体中的小数位数为前缀,用冒号分隔。对于在字符串中处理数字字符的常见情况没有时间安排,但可以肯定地说,这样的代码所花费的时间至少是在字节大小的肢体中使用两位数的代码的两倍。
# E16_DecimalDoubling
[1:02] e = 1000 -> 1366 0.308 ms
[2:04] e = 1000 -> 1366 0.152 ms
[4:09] e = 1000 -> 1366 0.070 ms
[8:18] e = 1000 -> 1366 0.071 ms
[1:02] e = 10000 -> 13561 30.533 ms
[2:04] e = 10000 -> 13561 13.791 ms
[4:09] e = 10000 -> 13561 6.436 ms
[8:18] e = 10000 -> 13561 2.996 ms
[1:02] e = 100000 -> 135178 2719.600 ms
[2:04] e = 100000 -> 135178 1340.050 ms
[4:09] e = 100000 -> 135178 588.878 ms
[8:18] e = 100000 -> 135178 290.721 ms
[8:18] e = 1000000 -> 1351546 28823.330 ms
对于 10^6 的指数,只有 64 位肢体的时间,因为我没有耐心等待几分钟才能获得完整的结果。图片与基数转换类似,只是没有 64 位肢体的行,因为我的编译器没有原生的 128 位整数类型。
# E16_RadixConversion
[1:02] e = 1000 -> 1366 0.080 ms
[2:04] e = 1000 -> 1366 0.026 ms
[4:09] e = 1000 -> 1366 0.048 ms
[1:02] e = 10000 -> 13561 4.537 ms
[2:04] e = 10000 -> 13561 0.746 ms
[4:09] e = 10000 -> 13561 0.243 ms
[1:02] e = 100000 -> 135178 445.092 ms
[2:04] e = 100000 -> 135178 68.600 ms
[4:09] e = 100000 -> 135178 19.344 ms
[4:09] e = 1000000 -> 1351546 1925.564 ms
有趣的是,简单地将代码编译为 C++ 并不会使其更快 - 即,优化器找不到 C# 抖动错过的任何低悬的果实,除了不遵守关于惩罚无符号整数。这就是我喜欢在 C# 中进行原型设计的原因——性能与(未优化的)C++ 相同,而且没有任何麻烦。
这里是 C++ 版本的精髓(没有大量无聊的东西,如帮助模板等),因此您可以看到我并没有为了让 C# 看起来更好而作弊:
template<typename W>
struct E16_RadixConversion
{
typedef W limb_t;
typedef typename detail::E16_traits<W>::long_t long_t;
static unsigned const BITS_PER_WORD = sizeof(limb_t) * CHAR_BIT;
static unsigned const RADIX_DIGITS = std::numeric_limits<limb_t>::digits10;
static limb_t const RADIX = detail::pow10_t<limb_t, RADIX_DIGITS>::RESULT;
static unsigned digit_sum_for_power_of_2 (unsigned e)
{
std::vector<limb_t> digits;
compute_digits_for_power_of_2(e, digits);
return digit_sum(digits);
}
static void compute_digits_for_power_of_2 (unsigned e, std::vector<limb_t> &result)
{
assert(e > 0);
unsigned total_digits = unsigned(std::ceil(std::log10(2) * e));
unsigned total_limbs = (total_digits + RADIX_DIGITS - 1) / RADIX_DIGITS;
result.resize(0);
result.reserve(total_limbs);
std::vector<limb_t> bin((e + BITS_PER_WORD) / BITS_PER_WORD);
bin.back() = limb_t(limb_t(1) << (e % BITS_PER_WORD));
while (!bin.empty())
{
long_t rest = 0;
for (std::size_t i = bin.size(); i-- > 0; )
{
long_t temp = (rest << BITS_PER_WORD) | bin[i];
long_t quot = temp / RADIX;
rest = temp - quot * RADIX;
bin[i] = limb_t(quot);
}
result.push_back(limb_t(rest));
if (bin.back() == 0)
bin.pop_back();
}
}
};
结论
这些基准测试还表明,与许多其他任务一样,这项 Euler 任务似乎旨在在 ZX81 或 Apple ][ 上解决,而不是在功能强大一百万倍的现代玩具上解决。除非限制大幅增加(10^5 或 10^6 的指数更合适),否则这里没有任何挑战。
可以从GMP's overview of algorithms 获得对实用最新技术状态的一个很好的概述。算法的另一个很好的概述是理查德布伦特和保罗齐默尔曼的“现代计算机算术”的第 1 章。它包含了一个人在编码挑战和竞赛中需要知道的内容,但不幸的是,深度不等于 Donald Knuth 在“计算机编程的艺术”中的处理。
基数转换解决方案为代码挑战工具箱添加了一项有用的技术,因为可以简单地扩展给定的代码以转换任何旧的大整数,而不仅仅是位模式1 << exponent。重复平方解也同样有用,因为将示例代码更改为除 2 以外的其他值也很简单。
直接以 10 的幂次执行计算的方法对于需要小数结果的挑战很有用,因为性能与本机计算在同一范围内,但不需要单独的转换步骤(可能需要类似的数量时间作为实际计算)。