153. 研究高斯整数(Investigating Gaussian Integers)
众所周知,方程\(x^2=-1\)在实数\(x\)中没有解。不过,如果我们引入虚数\(i\),这个方程就有两个解:\(x=i\)和\(x=-i\)。如果再进一步,方程\((x-3)^2=-4\)有两个复数解:\(x=3+2i\)和\(x=3-2i\)。\(x=3+2i\)和\(x=3-2i\)被称为彼此的复共轭。形如\(a+bi\)的数称为复数。一般地,\(a+bi\)和\(a-bi\)互为复共轭。
高斯整数是一个形如\(a+bi\)的复数,其中\(a\)和\(b\)都是整数。普通整数也是高斯整数(此时\(b=0\))。为了把它们与\(b \ne 0\)的高斯整数区分开来,我们称这样的整数为“有理整数”。如果\(\dfrac n {a + bi}\)的结果也是一个高斯整数,则称高斯整数\(a+bi\)是有理整数\(n\)的一个除数。例如,当我们用\(1+2i\)去除\(5\)时,可以按如下方式化简\(\dfrac{5}{1 + 2i}\):将分子和分母都乘以\(1+2i\)的复共轭\(1-2i\)。结果为\(\dfrac{5}{1 + 2i} = \dfrac{5}{1 + 2i}\dfrac{1 - 2i}{1 - 2i} = \dfrac{5(1 - 2i)}{1 - (2i)^2} = \dfrac{5(1 - 2i)}{1 - (-4)} = \dfrac{5(1 - 2i)}{5} = 1 - 2i\)。因此,\(1+2i\)是\(5\)的一个除数。注意,\(1+i\)不是\(5\)的除数,因为\(\dfrac{5}{1 + i} = \dfrac{5}{2} - \dfrac{5}{2}i\)。还要注意,如果高斯整数\((a+bi)\)是有理整数\(n\)的除数,那么它的复共轭\((a-bi)\)也是\(n\)的除数。
实际上,\(5\)有六个实部为正的除数:\(\{1, 1 + 2i, 1 - 2i, 2 + i, 2 - i, 5\}\)。下面的表格列出了前五个正有理整数的所有除数:
\(n\) 实部为正的高斯整数除数 这些除数的和\(s(n)\) \(1\) \(1\) \(1\) \(2\) \(1, 1+i, 1-i, 2\) \(5\) \(3\) \(1, 3\) \(4\) \(4\) \(1, 1+i, 1-i, 2, 2+2i, 2-2i,4\) \(13\) \(5\) \(1, 1+2i, 1-2i, 2+i, 2-i, 5\) \(12\) 因此,对于实部为正的除数,有:\(\sum \limits_{n = 1}^{5} {s(n)} = 35\)。
\(\sum \limits_{n = 1}^{10^5} {s(n)} = 17924657155\)。
求\(\sum \limits_{n = 1}^{10^8} {s(n)}\)。
一、题意概述
对每个正整数\(n\),考虑所有实部为正的高斯整数\(a+bi\),若\(n/(a+bi)\)仍为高斯整数,则把\(a+bi\)计入\(n\)的除数集合。题目要求计算所有\(n \le N\)的除数和\(s(n)\)之和,其中目标规模为\(N=10^8\)。
由于共轭除数\(a+bi\)和\(a-bi\)总是同时出现,虚部在求和时相互抵消,所以最终结果是整数。我们只需要准确累计所有除数的实部贡献。
二、数学背景
先处理普通整数除数,即\(b=0\)。正整数\(d\)会作为所有\(d\)的倍数的除数出现,因此它在总和中的贡献为:
所有普通整数除数的总贡献记为:
接下来考虑非实高斯整数\(a+bi\),其中\(a>0\)且\(b \ne 0\)。由于共轭成对出现,只需令\(b>0\),最后把\(a+bi\)和\(a-bi\)的实部贡献合并。
设:
高斯整数的范数为:
根据复数除法:
要使结果仍为高斯整数,必须同时有:
代入\(a=gx\)、\(b=gy\),并利用\(\gcd(x,y)=1\)可知\(x\)和\(y\)都与\(x^2+y^2\)互素,因此条件等价于:
所以,固定一个原始方向\((x,y)\)后,缩放因子\(g\)对应的高斯整数\(g(x+yi)\)会整除所有\(g(x^2+y^2)\)的倍数。
共轭对\(g(x+yi)\)和\(g(x-yi)\)对每个可整除的\(n\)贡献\(2gx\)。在所有\(n \le N\)中,它们的贡献为:
对同一个原始方向\((x,y)\)累加所有可行的\(g\),令\(r=x^2+y^2\),得到:
三、算法分析
直接按\(n\)枚举高斯整数除数会非常慢,因为每个\(n\)都要检查大量候选\(a+bi\)。更好的方式是反向枚举除数,让每个高斯整数一次性贡献给所有可整除的\(n\)。
候选算法一是对每个\(n\)枚举\(a,b\)并做复除法整除性判断。这种方法适合小规模暴力校验,但目标规模\(10^8\)不可行。
候选算法二是枚举原始格点\((x,y)\),其中\(x,y>0\)、\(\gcd(x,y)=1\)、\(x^2+y^2 \le N\),再使用上面的公式一次性累计所有缩放因子\(g\)和所有倍数\(n\)的贡献。该方法避免了逐个\(n\)检查除数,是最终采用的方案。
为了减少枚举量,代码只遍历\(x \le y\):
- 当\(x=y\)时,唯一可能的原始格点是\((1,1)\),贡献系数为\(2\)
- 当\(x<y\)时,\((x,y)\)和\((y,x)\)都要计入,它们的合并贡献系数为\(2(x+y)\)
因此总公式可以写成:
这里第一项是普通整数除数,第二项对应\((1,1)\),最后一项对应其余非实原始方向。
函数\(F(q)\)可以用整除分块快速计算。对于一段连续的\(d\),若\(\lfloor q/d\rfloor\)相同,就可以一次性加上等差数列和:
实际枚举过程中,\(\left\lfloor N/(x^2+y^2)\right\rfloor\)的不同取值并不多,因此代码对\(F(q)\)做缓存,避免重复计算。
四、复杂度分析
时间复杂度主要来自两部分:
- 原始格点枚举需要检查\(x \le y\)且\(x^2+y^2\le N\)的格点,数量级为\(O(N)\),但目标\(N=10^8\)时坐标上界只有\(10^4\),实际循环规模可控
- 整除分块计算\(F(q)\)单次为\(O(\sqrt q)\),并通过缓存只对少量不同\(q\)计算一次
空间复杂度为\(O(\sqrt N)\),主要用于保存平方表和\(F(q)\)缓存。对于\(N=10^8\),平方表长度约为\(10^4\)。
五、代码实现与说明
核心脚本为scripts/pe153.py。代码中的注释、docstring、标识符和字符串均使用英文。
#!/usr/bin/env python3
"""Project Euler Problem 153: Investigating Gaussian Integers."""
from __future__ import annotations
import argparse
import math
import sys
import time
import unittest
DEFAULT_LIMIT = 100_000_000
SAMPLE_LIMIT = 5
SAMPLE_TOTAL = 35
CHECK_LIMIT = 100_000
CHECK_TOTAL = 17_924_657_155
这一段定义脚本入口所需的标准库导入和常量。DEFAULT_LIMIT是题目目标规模;SAMPLE_LIMIT和CHECK_LIMIT对应题面给出的两个公开校验值。
def weighted_floor_sum(limit: int) -> int:
"""Return sum(d * floor(limit / d) for 1 <= d <= limit)."""
total = 0
start = 1
while start <= limit:
quotient = limit // start
end = limit // quotient
total += quotient * (start + end) * (end - start + 1) // 2
start = end + 1
return total
weighted_floor_sum实现\(F(q)\)。变量start是当前分块左端点,quotient是\(\lfloor q/d\rfloor\)的当前值,end是保持同一商值的最远右端点。区间\([start,end]\)内的\(d\)可以用等差数列求和一次性累计。
def solve(limit: int = DEFAULT_LIMIT) -> int:
"""Return the cumulative sum of Gaussian integer divisors up to limit."""
if limit < 1:
return 0
total = weighted_floor_sum(limit)
floor_sum_cache = {limit: total}
squares = [n * n for n in range(math.isqrt(limit) + 1)]
max_small_coordinate = math.isqrt(limit // 2)
gcd = math.gcd
cache_get = floor_sum_cache.get
solve先加入普通整数除数的贡献\(F(N)\)。floor_sum_cache缓存不同参数下的\(F(q)\);squares预存平方以减少内层循环计算;因为只遍历\(x \le y\),最小坐标\(x\)最多到\(\sqrt{N/2}\)。
for x in range(1, max_small_coordinate + 1):
x_square = squares[x]
max_y = math.isqrt(limit - x_square)
if x == 1:
for y in range(1, max_y + 1):
radius_square = 1 + squares[y]
quotient = limit // radius_square
floor_sum = cache_get(quotient)
if floor_sum is None:
floor_sum = weighted_floor_sum(quotient)
floor_sum_cache[quotient] = floor_sum
coefficient = 2 if y == 1 else 2 * (1 + y)
total += coefficient * floor_sum
continue
当x == 1时,任意正整数y都与1互素,因此不需要调用gcd。y == 1对应原始方向\((1,1)\),只贡献一次;y > 1时同时合并\((1,y)\)和\((y,1)\),系数为2 * (1 + y)。
start_y = x + 1
step = 1
if x % 2 == 0:
step = 2
if start_y % 2 == 0:
start_y += 1
for y in range(start_y, max_y + 1, step):
if gcd(x, y) != 1:
continue
radius_square = x_square + squares[y]
quotient = limit // radius_square
floor_sum = cache_get(quotient)
if floor_sum is None:
floor_sum = weighted_floor_sum(quotient)
floor_sum_cache[quotient] = floor_sum
total += 2 * (x + y) * floor_sum
return total
一般情况下只枚举y > x,从而避免重复。若x为偶数,则y必须为奇数才可能互素,所以步长改为2。每个互素格点贡献2 * (x + y) * F(limit // (x*x + y*y))。
def is_gaussian_divisor(n: int, real: int, imaginary: int) -> bool:
"""Return whether real + imaginary*i divides the rational integer n."""
norm = real * real + imaginary * imaginary
return (n * real) % norm == 0 and (n * imaginary) % norm == 0
def brute_force_solve(limit: int) -> int:
"""Return the cumulative divisor sum by direct Gaussian division checks."""
total = 0
for n in range(1, limit + 1):
for real in range(1, n + 1):
for imaginary in range(-n, n + 1):
if is_gaussian_divisor(n, real, imaginary):
total += real
return total
这两个函数只用于小规模交叉检查。is_gaussian_divisor按复除法定义直接判断整除性,不使用原始格点公式;brute_force_solve则逐个\(n\)、逐个候选高斯整数直接求和,用作独立参照。
class GaussianDivisorTests(unittest.TestCase):
"""Regression tests for the published examples."""
def test_weighted_floor_sum_for_rational_divisors(self) -> None:
"""Check the ordinary integer divisor contribution for a small limit."""
self.assertEqual(weighted_floor_sum(5), 21)
def test_problem_sample(self) -> None:
"""Check the sum of s(n) for the first five positive integers."""
self.assertEqual(solve(SAMPLE_LIMIT), SAMPLE_TOTAL)
def test_published_check_value(self) -> None:
"""Check the published value for the first one hundred thousand integers."""
self.assertEqual(solve(CHECK_LIMIT), CHECK_TOTAL)
def test_small_limits_against_direct_gaussian_division(self) -> None:
"""Check several small limits against an independent brute-force method."""
for limit in range(1, 16):
with self.subTest(limit=limit):
self.assertEqual(solve(limit), brute_force_solve(limit))
测试类按从小到大的顺序检查关键行为:普通整数除数贡献、题面前五项样例、题面给出的\(10^5\)公开值,以及多个小规模结果与直接复除法暴力算法的一致性。
def run_tests() -> None:
"""Run the built-in regression tests."""
suite = unittest.defaultTestLoader.loadTestsFromTestCase(GaussianDivisorTests)
result = unittest.TextTestRunner(verbosity=2).run(suite)
if not result.wasSuccessful():
raise SystemExit(1)
def main() -> None:
"""Parse command line arguments and print the computed answer."""
parser = argparse.ArgumentParser(description="Solve Project Euler problem 153.")
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
parser.add_argument("--test", action="store_true")
args = parser.parse_args()
if args.test:
run_tests()
return
start = time.perf_counter()
answer = solve(args.limit)
elapsed = time.perf_counter() - start
print(f"Result: {answer}")
print(f"Time: {elapsed:.3f}s")
if __name__ == "__main__":
main()
最后一段提供命令行入口。使用--test运行内置测试;不加参数时计算目标规模,并输出计算结果与耗时。