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\)的倍数的除数出现,因此它在总和中的贡献为:

$$d\left\lfloor\frac{N}{d}\right\rfloor$$

所有普通整数除数的总贡献记为:

$$F(N)=\sum_{d=1}^{N}d\left\lfloor\frac{N}{d}\right\rfloor$$

接下来考虑非实高斯整数\(a+bi\),其中\(a>0\)\(b \ne 0\)。由于共轭成对出现,只需令\(b>0\),最后把\(a+bi\)\(a-bi\)的实部贡献合并。

设:

$$g=\gcd(a,b), \quad a=gx,\quad b=gy,\quad \gcd(x,y)=1$$

高斯整数的范数为:

$$a^2+b^2=g^2(x^2+y^2)$$

根据复数除法:

$$\frac{n}{a+bi}=\frac{n(a-bi)}{a^2+b^2}$$

要使结果仍为高斯整数,必须同时有:

$$a^2+b^2 \mid na,\quad a^2+b^2 \mid nb$$

代入\(a=gx\)\(b=gy\),并利用\(\gcd(x,y)=1\)可知\(x\)\(y\)都与\(x^2+y^2\)互素,因此条件等价于:

$$g(x^2+y^2)\mid n$$

所以,固定一个原始方向\((x,y)\)后,缩放因子\(g\)对应的高斯整数\(g(x+yi)\)会整除所有\(g(x^2+y^2)\)的倍数。

共轭对\(g(x+yi)\)\(g(x-yi)\)对每个可整除的\(n\)贡献\(2gx\)。在所有\(n \le N\)中,它们的贡献为:

$$2gx\left\lfloor\frac{N}{g(x^2+y^2)}\right\rfloor$$

对同一个原始方向\((x,y)\)累加所有可行的\(g\),令\(r=x^2+y^2\),得到:

$$2x\sum_{g=1}^{\lfloor N/r\rfloor}g\left\lfloor\frac{\lfloor N/r\rfloor}{g}\right\rfloor =2xF\left(\left\lfloor\frac{N}{r}\right\rfloor\right)$$

三、算法分析

直接按\(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\)

因此总公式可以写成:

$$ \sum_{n=1}^{N}s(n) =F(N) +2F\left(\left\lfloor\frac{N}{2}\right\rfloor\right) +\sum_{\substack{1\le x<y\\ \gcd(x,y)=1\\ x^2+y^2\le N}} 2(x+y)F\left(\left\lfloor\frac{N}{x^2+y^2}\right\rfloor\right) $$

这里第一项是普通整数除数,第二项对应\((1,1)\),最后一项对应其余非实原始方向。

函数\(F(q)\)可以用整除分块快速计算。对于一段连续的\(d\),若\(\lfloor q/d\rfloor\)相同,就可以一次性加上等差数列和:

$$ \sum_{d=L}^{R}d\left\lfloor\frac{q}{d}\right\rfloor =\left\lfloor\frac{q}{L}\right\rfloor\frac{(L+R)(R-L+1)}{2} $$

实际枚举过程中,\(\left\lfloor N/(x^2+y^2)\right\rfloor\)的不同取值并不多,因此代码对\(F(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_LIMITCHECK_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互素,因此不需要调用gcdy == 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运行内置测试;不加参数时计算目标规模,并输出计算结果与耗时。