文章

算法刷题系列(四):数学问题中的整除、最大公约数与模运算

算法刷题系列(四):数学问题中的整除、最大公约数与模运算

算法刷题系列(四):数学问题中的整除、最大公约数与模运算

数学题常常不需要复杂的数据结构,关键是找到一个能缩小问题的性质:两个数是否有共同因子、一个余数如何随运算变化、计数结果是否会超过整数范围。

本篇承接“算法刷题系列路线图”中的基础工具阶段,围绕整除、最大公约数和模运算介绍几种常用的建模方法。例子和代码均为原创,分别提供 Java 与 Python 实现。

一、先把题目中的数学关系写出来

读到“整除”“周期”“最简比例”“循环计数”这类描述时,先不要急着写循环。可以先把它翻译成等式或条件:

  • a 能被 b 整除:存在整数 k,使 a = b × k。
  • a 与 b 的最大公约数:同时整除二者的最大正整数。
  • x 除以 m 的余数:用 x mod m 表示,结果用于描述周期或同余关系。

数学建模的作用,是把题目里可能很大的数或重复过程,转成更小的因子、余数或状态。

二、最大公约数:把共同因子问题缩小

给定两个正整数 a 和 b,最大公约数记为 gcd(a, b)。最直接的方法是从较小的数开始向下试除,但最坏需要检查许多候选值。

欧几里得算法使用下面这个性质:

1
gcd(a, b) = gcd(b, a mod b)

为什么可以这样替换?若 a = q × b + r,那么任何同时整除 a 和 b 的数,也会整除 r = a - q × b;反过来,同时整除 b 和 r 的数也会整除 a。因此两组数的公约数集合相同。

重复替换,直到余数为零。此时另一个数就是最大公约数。

手动推演

计算 gcd(84, 30):

1
2
3
4
5
6
gcd(84, 30)
= gcd(30, 84 mod 30)
= gcd(30, 24)
= gcd(24, 6)
= gcd(6, 0)
= 6

每一步都把第二个数变成更小的余数,因此算法会很快结束,时间复杂度为 O(log(min(a, b)))。

三、Java:实现 GCD 和最小公倍数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class NumberTheory {
    // This version accepts non-negative inputs.
    public static long gcd(long a, long b) {
        if (a < 0 || b < 0) {
            throw new IllegalArgumentException("Inputs must be non-negative");
        }

        while (b != 0) {
            long remainder = a % b;
            a = b;
            b = remainder;
        }
        return a;
    }

    public static long lcm(long a, long b) {
        if (a < 0 || b < 0) {
            throw new IllegalArgumentException("Inputs must be non-negative");
        }
        if (a == 0 || b == 0) {
            return 0;
        }

        return Math.multiplyExact(a / gcd(a, b), b);
    }
}

最小公倍数满足:

1
lcm(a, b) = a / gcd(a, b) × b

先除再乘比直接计算 a × b / gcd(a, b) 更稳妥,因为中间乘积较小。不过结果本身仍可能超过 long 的范围,所以示例使用 Math.multiplyExact:溢出时显式抛出异常,而不是返回错误数值。

本实现约定输入为非负数。若题目允许负数,应按题意明确 GCD/LCM 的符号约定,并处理 Long.MIN_VALUE 取绝对值无法用 long 表示的边界。

四、Python:同一算法,整数范围不同

1
2
3
4
5
6
7
8
9
10
11
12
13
def gcd(a: int, b: int) -> int:
    a = abs(a)
    b = abs(b)

    while b:
        a, b = b, a % b

    return a

def lcm(a: int, b: int) -> int:
    if a == 0 or b == 0:
        return 0
    return abs(a // gcd(a, b) * b)

Python 整数可以自动扩展精度,因此不会像固定宽度的 long 那样发生常规整数溢出;但数值越大,占用的时间和内存仍然越多。

五、应用一:把比例约成最简形式

假设一份饮料由 84 份果汁和 30 份水组成,想写成最简整数比例。两项都除以最大公约数:

1
2
gcd(84, 30) = 6
84 : 30 = 14 : 5

这类题的结构是:多个量按相同比例缩放,要求找到最小整数表示。最大公约数可以去掉所有共同因子。

如果比例中存在零或负数,应根据题意定义结果形式;不能在未确认约束前直接套用正整数示例。

六、模运算:关注余数而不是完整数值

a mod m 表示 a 除以正整数 m 的余数。模运算常见于:

  • 计算周期性状态,例如指针循环移动;
  • 只需要计数结果对某个数取余;
  • 判断两个整数是否同余;
  • 避免中间结果增长得过大。

对于整数 a、b 和正整数 m,有:

1
2
(a + b) mod m = ((a mod m) + (b mod m)) mod m
(a × b) mod m = ((a mod m) × (b mod m)) mod m

如果题目要求答案对 m 取余,可以在每次更新后取余,而不是等到最后再处理一个巨大中间值。

Java:避免加法先溢出

1
2
3
4
5
6
7
8
static long addMod(long a, long b, long mod) {
    if (mod <= 0 || a < 0 || b < 0 || a >= mod || b >= mod) {
        throw new IllegalArgumentException("Expected 0 <= a, b < mod");
    }

    // Preconditions keep a + b below Long.MAX_VALUE.
    return (a + b) % mod;
}

这里的前置条件保证 a、b 都已经处于 [0, mod)。实际使用时,还必须确认 2 × (mod - 1) 不会超过 long 上限;如果模数很大,简单的加法仍可能溢出,需要使用更安全的加法策略或更宽的整数类型。

对于常见的 1_000_000_007 模数,两个规范化余数相加不会超过 long 范围。若进一步做乘法,也应根据模数和语言整数宽度检查乘积是否会溢出。

Python:每步规范化余数

1
2
3
4
def add_mod(a: int, b: int, mod: int) -> int:
    if mod <= 0:
        raise ValueError("mod must be positive")
    return (a + b) % mod

Python 的 % 对正模数返回非负余数。因此即使 a 或 b 为负数,结果仍会规范到 [0, mod)。Java 对负数 % 的行为不同;若 Java 代码需要处理负值并要求非负结果,可使用 Math.floorMod(value, mod)。

七、边界与测试

GCD 中出现零

1
2
gcd(0, 30) = 30
gcd(0, 0) = 0

欧几里得算法本身可以处理这些情况。LCM 的代码也单独处理了零,因为任意整数与零的最小公倍数按常见约定为零。

两个数互质

若 gcd(a, b) = 1,说明二者没有大于 1 的共同因子。互质不要求两个数本身是质数,例如 8 和 15 互质。

模数必须为正

周期和同余问题通常要求模数 m > 0。模数为零时取余没有定义,因此实现应拒绝该输入。

结果可能比输入更大

最小公倍数可能大于 a 和 b,固定宽度整数还可能无法表示结果。复杂度分析之外也要检查数值范围。

八、常见误区

  1. 把互质误认为都是质数:互质是两个数之间的关系。
  2. 不检查零和负数约束:GCD、LCM 和取模的边界行为要按题意处理。
  3. 以为“先除再乘”能完全消除溢出:它能降低中间值,但最终结果仍可能超范围。
  4. Java 和 Python 的负数取模结果默认相同:不同语言的余数规则可能不同。
  5. 不说明模数范围就直接做乘法:(a % m) * (b % m) 仍可能在固定宽度类型中溢出。
  6. 看到数学标签就开始枚举:先找整除、周期、对称、单调或不变量关系,可能直接减少搜索量。

九、与后续主题的连接

最大公约数展示了如何用不变量把数值问题逐步缩小;模运算则把周期性状态规范到有限范围。这些思路会在后续内容中反复出现:

  • 排序与二分利用顺序或单调性缩小搜索空间;
  • 位运算用有限个二进制位表示状态;
  • 二进制枚举和状压 DP 用掩码表示集合;
  • 矩阵快速幂用模运算控制递推结果的范围。

下一篇将从数值关系转向二进制表示,讨论位运算如何直接操作一个整数中的状态位。

小结

数学题的第一步通常是把自然语言条件写成等式、整除关系或余数关系。欧几里得算法用 gcd(a, b) = gcd(b, a mod b) 快速求最大公约数;模运算则用余数描述周期并控制结果范围。实现时还要显式检查零、负数、模数和整数溢出的约束。

本文由作者按照 CC BY 4.0 进行授权