约数与因子枚举

1 问题:怎样找出一个数的全部因子

在 整除 中,我们已经知道:若正整数 dd 能整除正整数 nn,就称 dd 是 nn 的因数,也叫约数或因子。本文只讨论正约数,不枚举负数。

例如 3636 的全部正约数是:

1,2,3,4,6,9,12,18,36 1,2,3,4,6,9,12,18,36

注意不要把「约数」和「质因数」混淆:44、66、99 都是 3636 的约数,但它们不是质数。

接下来分别解决两个问题:

  1. 给一个正整数 nn,列出它的全部正约数。
  2. 给一个上界 NN,预处理 1∼N1\sim N 每个数的全部正约数。

2 先从朴素枚举开始

正约数不可能大于 nn,所以直接检查 1,2,…,n1,2,\ldots,n:若 n % i == 0,就找到了一个约数。

这需要 O(n)O(n) 次检查。若 n=1012n=10^{12},就要检查一万亿次,显然不适合直接做。

有没有办法,不检查所有候选,也能找到全部约数?

3 单个数:约数总是成对出现

3.1 一次整除,得到两个约数

如果 d∣nd\mid n,那么存在正整数 qq 满足

n=dq n=dq

这说明 q=n/dq=n/d 也能整除 nn。例如 3636 的约数可以这样配对:

较小的约数 配对的约数 乘积
1 36 36
2 18 36
3 12 36
4 9 36
6 6 36

当发现 22 是约数时,就不必再走到 1818 才发现另一个约数:计算 36/236/2 即可。

3.2 为什么只需要检查到平方根

一对约数 d,qd,q 满足 dq=ndq=n。若它们都大于 n\sqrt n,乘积就会大于 nn,产生矛盾。

因此,每对约数中至少有一个不超过 n\sqrt n。只要枚举

1≤d≤⌊n⌋ 1\le d\le\lfloor\sqrt n\rfloor

就能遇到每一对约数中的较小者,再通过 n/dn/d 找到较大者,不会漏掉任何约数。

3.3 完全平方数:平方根只记录一次

对于 3636,枚举到 d=6d=6 时,配对的约数也是 66,只能记录一次。

因此每次整除成功时:

  1. 记录 dd;
  2. 计算 q=n/dq=n/d;
  3. 仅当 d≠qd\ne q 时,才记录 qq。

d == n / d 是在已经确认整除的前提下,判断当前这一对约数是否重合,也就是当前是否正好枚举到平方根。不要理解成「只要 n 是完全平方数,每一对都只记录一个」。例如 3636 的 (2,18)(2,18) 仍然需要记录两个。

3.4 如何直接得到升序结果

如果每找到一对就立即输出,顺序会是 1 36 2 18 ...,并非升序。

可以先把全部结果放在一个数组里,最后排序;也可以利用约数对本身的顺序,不额外排序:

  • 小约数按枚举顺序进入 small,天然递增;
  • 大约数进入 large,因为 n/dn/d 随着 dd 增大而减小,所以天然递减;
  • 最后把 large 反转,接到 small 后面。

例如 n=36n=36:

small:1 2 3 4 6
large:36 18 12 9
反转 large:9 12 18 36
拼接结果:1 2 3 4 6 9 12 18 36

3.5 C++ / Python 实现

两份程序都读入一个正整数 nn,输出它的全部正约数,按升序排列。C++ 输入需要在 long long 的正数范围内;Python 使用整数运算,不依赖浮点平方根。

#include <iostream>
#include <vector>
using namespace std;

vector<long long> get_factors(long long n) {
    vector<long long> small, large;
    // d <= n / d 等价于 d * d <= n,但不会发生乘法溢出。
    for (long long d = 1; d <= n / d; d++) {
        if (n % d != 0) continue;
        small.push_back(d);
        long long other = n / d;
        if (d != other) large.push_back(other);
    }
    // 大约数原本递减,反向拼接后得到升序结果。
    for (int i = (int)large.size() - 1; i >= 0; i--) {
        small.push_back(large[i]);
    }
    return small;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    long long n;
    if (!(cin >> n)) return 0;
    vector<long long> factors = get_factors(n);
    for (int i = 0; i < (int)factors.size(); i++) {
        if (i > 0) cout << ' ';
        cout << factors[i];
    }
    cout << '\n';
    return 0;
}
import sys


def get_factors(n: int) -> list[int]:
    small = []
    large = []
    d = 1
    while d * d <= n:
        if n % d == 0:
            small.append(d)
            other = n // d
            if d != other:
                large.append(other)
        d += 1
    # 小约数递增,大约数递减;反转后拼接。
    small.extend(reversed(large))
    return small


def main():
    n = int(sys.stdin.buffer.readline())
    print(*get_factors(n))


if __name__ == '__main__':
    main()

输入:

36

输出:

1 2 3 4 6 9 12 18 36

C++ 用 d <= n / d 作为循环条件,而不是 d * d <= n,避免乘法溢出。对于正整数,这两个条件等价。Python 整数没有固定宽度溢出问题,可以直接使用 d * d <= n。

设 τ(n)\tau(n) 表示 nn 的正约数个数。按通常的整数运算计数方式:

  • 检查候选耗时 O(n)O(\sqrt n);
  • 拼接、输出耗时 O(τ(n))O(\tau(n));
  • 总时间为 O(n+τ(n))=O(n)O(\sqrt n+\tau(n))=O(\sqrt n),存储结果需要 O(τ(n))O(\tau(n)) 空间。

这里没有额外排序,因此不会多出 O(τ(n)log⁡τ(n))O(\tau(n)\log\tau(n)) 的排序开销。若输入是任意精度大整数,单次除法也有自身的位运算开销,上述复杂度不能理解成与整数位数无关的运行时间保证。

4 批量预处理:反过来枚举倍数

4.1 从「谁是它的约数」到「它是谁的约数」

如果需要得到 1∼N1\sim N 每个数的约数,逐个试除的总时间约为 O(NN)O(N\sqrt N)。

换一个枚举方向:

不再问「jj 的约数有哪些」,而是问「dd 是哪些数的约数」。

答案很直接:dd 是 d,2d,3d,…d,2d,3d,\ldots 的约数。

因此可以先建立 factors[0..N],然后:

  1. 从小到大枚举 d=1,2,…,Nd=1,2,\ldots,N;
  2. 枚举 dd 的所有倍数 j=d,2d,…≤Nj=d,2d,\ldots\le N;
  3. 把 dd 加入 factors[j]。

例如外层枚举到 d=3d=3 时,就把 33 放进 3,6,9,12,…3,6,9,12,\ldots 的列表里。

4.2 为什么不漏、不重,而且有序

如果 dd 是 jj 的约数,那么 jj 一定是 dd 的倍数,所以在枚举 dd 时必然访问到 jj,不会遗漏。

每个 dd 只枚举一次,它的每个倍数也只访问一次,因此同一个列表不会重复加入同一个约数。

由于外层 dd 从小到大,每个列表都按升序接收约数,结果天然有序,不需要再排序。

4.3 C++ / Python 实现

程序第一行读入 N,qN,q,随后读入 qq 个查询数 xx,要求 1≤x≤N1\le x\le N。每个查询输出一行,列出 xx 的全部正约数。这里的查询数可以分布在任意空白分隔的位置。

#include <iostream>
#include <vector>
using namespace std;

vector<vector<int>> precompute_factors(int n) {
    vector<vector<int>> factors(n + 1);
    for (int d = 1; d <= n; d++) {
        // 使用 long long 防止 j += d 时发生 int 溢出。
        for (long long j = d; j <= n; j += d) {
            factors[j].push_back(d);
        }
    }
    return factors;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, q;
    if (!(cin >> n >> q)) return 0;
    vector<vector<int>> factors = precompute_factors(n);
    for (int i = 0; i < q; i++) {
        int x;
        cin >> x;
        for (int j = 0; j < (int)factors[x].size(); j++) {
            if (j > 0) cout << ' ';
            cout << factors[x][j];
        }
        cout << '\n';
    }
    return 0;
}
import sys


def precompute_factors(n: int) -> list[list[int]]:
    # 为每个数创建独立列表,不能写成 [[]] * (n + 1)。
    factors = [[] for _ in range(n + 1)]
    for d in range(1, n + 1):
        for j in range(d, n + 1, d):
            factors[j].append(d)
    return factors


def main():
    # 按行读入并依次产生整数,避免一次保留所有查询和输出。
    data = (int(value) for line in sys.stdin.buffer for value in line.split())
    n = next(data)
    q = next(data)
    factors = precompute_factors(n)
    for _ in range(q):
        x = next(data)
        print(*factors[x])


if __name__ == '__main__':
    main()

输入:

12 3
1
6
12

输出:

1
1 2 3 6
1 2 3 4 6 12

下标 00 的列表保持为空,只是为了方便直接用正整数作为下标,不是在枚举零的约数。

4.4 为什么复杂度是 O(Nlog⁡N)O(N\log N)

外层枚举 dd 时,内层访问 ⌊N/d⌋\lfloor N/d\rfloor 个倍数,总次数为

S(N)=∑d=1N⌊Nd⌋≤N∑d=1N1d=O(Nlog⁡N) S(N)=\sum_{d=1}^{N}\left\lfloor\frac Nd\right\rfloor \le N\sum_{d=1}^{N}\frac1d =O(N\log N)

因此预处理时间为 O(Nlog⁡N)O(N\log N)。每次内层访问都存入一个约数,所以总共也需要存储 S(N)S(N) 个条目,连同 N+1N+1 个列表,空间为 O(N+S(N))=O(Nlog⁡N)O(N+S(N))=O(N\log N)。

查到 factors[x] 这个列表只需 O(1)O(1);但遍历或输出其全部元素仍需要 O(τ(x))O(\tau(x)),不能说整次输出查询是 O(1)O(1)。

若只需要每个数的约数个数或约数和,可以把“加入列表”改成累加计数或累加 dd,不保存全部约数,结果数组只需 O(N)O(N) 空间。枚举的总次数不变。

5 怎样选择方法

比较项 单个数的成对试除 全值域枚举倍数
适用情形 单次或少量查询,不必处理整个值域 值域不大,需要许多数的约数
时间 每个数 O(n)O(\sqrt n) 预处理 O(Nlog⁡N)O(N\log N)
存储 单个数 O(τ(n))O(\tau(n)) 全部列表 O(Nlog⁡N)O(N\log N)
升序结果 小约数与大约数分开记录后拼接 天然升序
输出查询 O(τ(n))O(\tau(n)) O(τ(x))O(\tau(x))

不能只看到「查询多」就一定预处理。例如只有几百个数,但最大值达到 101210^{12},建立整个值域的列表根本不可行。

反过来,即使 N=106N=10^6 看起来不算特别大,保存全部列表也已经是千万级约数条目。Python 还有列表对象、引用和整数对象的额外开销,C++ 也有容器与分配开销,都应先核对内存限制。若只有一次查询,不必预处理所有数。

单个 n≤1012n\le10^{12} 时试除最多检查约 10610^6 个候选,仍要结合查询次数与语言时限判断是否合适,不能把值域例子当成必过保证。

6 常见用途与边界

  • 缩小答案范围:若某个候选答案必须整除 nn,只枚举约数,再逐个验证,不必检查所有整数。
  • 统计整除关系:枚举一个数的约数,查询这些约数在频次表中出现了多少次。
  • 完整重复块:若字符串由同一个长度为 dd 的短串完整重复组成,则 d∣nd\mid n,可以只检查字符串长度的约数。注意一般意义下允许末尾不完整的“周期”不一定整除长度,例如 ababa 有周期 22,但 2∤52\nmid5。

实现时还要检查:

  1. n=1n=1:约数只有 11,不能因为配对而输出两次。
  2. nn 为质数:约数只有 1,n1,n。
  3. nn 为完全平方数:平方根只记录一次,例如 3636 的 66。
  4. 只枚举正约数:不把 00 当作除数,也不把 n=0n=0 套入本模板。
  5. 使用整数除法:Python 配对约数用 n // d,不是得到浮点数的 n / d。

7 总结

找一个数的约数:枚举较小的一半,用 n/dn/d 找到配对的另一半。

找一批数的约数:枚举约数 dd,把它送到每个倍数的列表里。

前者利用成对性减少候选,后者交换枚举方向复用整除关系。先确定需要处理多少个数、值域有多大、是否必须保存全部约数,再选择实现。

8 相关题解

暂无相关题解