qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

12 能被 2 和 3 划掉,怎样让每个合数只被处理一次?

先给每个合数指定唯一的“最小质因子负责人”,再看线性筛的 break 为什么必须发生在标记之后。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 给合数指定唯一负责人:最小质因子
  4. 外层逐个往前走,内层只拿已经发现的质数
  5. 为什么不会漏掉,也不会由两组重复生成
  6. 删掉 break 会重复;把 break 提前会漏掉4
  7. 保存最小质因子,还能反复除出完整因数
  8. 用试除法核对,不用第二份筛法互相证明
  9. 把负责人画出来,再增加自己的检查
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 认识整除、余数与数组下标;知道质数只有 1 和自身两个正因数。
  • 能够用 for 和 break 逐步执行一个双层循环。
  • 先读 复杂度与操作次数:区分最终结果正确与工作次数保证。
遇到不熟悉的词,先看这 3 个解释
最小质因子
能够整除一个数的最小质数。
线性筛
让每个合数只由最小质因子对应的一组乘积标记一次的筛法。
整数平方根
不超过精确平方根的最大整数。
打开完整术语小抄 ↗

12=2×6,也等于3×4。筛质数时,从不同因子出发会反复划掉同一个合数;结果可能仍然正确,却重复做了工作。线性筛把每个合数交给一个唯一的组合处理。

它最容易抄错的地方只有一行:当当前质数能整除外层数字时,就退出内层循环。但先退出还是先标记,会把“少做重复工作”变成“漏掉合数”,甚至把4误认为质数。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压后,在含 demo.py 的目录打开终端。命令分别回车执行。额外拒绝负上界、布尔值、小数、字符串和超出教学上限的输入。预期输出来自这次运行;所有检查用显式 AssertionError,即使以 python -O 运行也不会关闭。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

看清每个合数到底由谁标记

正在准备实验控件…

尚未扫描;0 和 1 不属于质数。

外层 i
0
使用质数 p
0
合数标记累计次数
0
当前记录 / 全部记录
0 / 29

目前质数清单:空

2记录因子 未记录标记 0 次
3记录因子 未记录标记 0 次
4记录因子 未记录标记 0 次
5记录因子 未记录标记 0 次
6记录因子 未记录标记 0 次
7记录因子 未记录标记 0 次
8记录因子 未记录标记 0 次
9记录因子 未记录标记 0 次
10记录因子 未记录标记 0 次
11记录因子 未记录标记 0 次
12记录因子 未记录标记 0 次
13记录因子 未记录标记 0 次
14记录因子 未记录标记 0 次
15记录因子 未记录标记 0 次
16记录因子 未记录标记 0 次
17记录因子 未记录标记 0 次
18记录因子 未记录标记 0 次
19记录因子 未记录标记 0 次
20记录因子 未记录标记 0 次
21记录因子 未记录标记 0 次
22记录因子 未记录标记 0 次
23记录因子 未记录标记 0 次
24记录因子 未记录标记 0 次
25记录因子 未记录标记 0 次
26记录因子 未记录标记 0 次
27记录因子 未记录标记 0 次
28记录因子 未记录标记 0 次
29记录因子 未记录标记 0 次
30记录因子 未记录标记 0 次

上界包含 N;0 和 1 不算质数。两个错误模式分别演示重复标记和漏标。每格展示当前记录的因子与写入次数;正确模式下记录的是最小质因子;轨迹快照只用于教学,正文关闭观察回调时不保存整段历史。

给合数指定唯一负责人:最小质因子

质数是至少为2、只被1和自身整除的整数。0和1不属于质数。对于合数 q,取它最小的质因子 p,再令 i=q/p,就得到 q=i×p;p 和 i 都唯一。

例如12的最小质因子是2,所以应由 i=6、p=2 负责。虽然 i=4、p=3 也能算出12,但这不是它指定的那一组。目标不是让所有因子都来检查,而是只让负责人写入一次。

程序的 least[q] 保存 q 的最小质因子;质数自己的最小质因子就是自身。另存 marked[q] 只用于核对合数被写了几次,发现质数时给 least[i] 赋 i 不计作合数标记。

外层逐个往前走,内层只拿已经发现的质数

从 i=2 走到 N。若 least[i] 仍为0,说明没有更小数字生成过它,它就是新质数,把 i 加进按升序保存的 primes。随后依次取 primes 中的 p,准备标记 q=i×p。

若 q>N,内层直接结束,因为后面的 p 只会更大。否则写 least[q]=p,并把标记次数加1。最后检查 i%p==0;整除时结束本轮,开始下一个 i。N 是包含的上界,N=2 应当得到 [2]。

i=4 时先遇到 p=2,标记8,然后因为2整除4而停止,跳过 p=3 生成12的机会。等外层走到6,再由 p=2 标记12。这正是“只让最小质因子负责”的安排。

为什么不会漏掉,也不会由两组重复生成

先看不重复:内层处理到某个 p 时,在它之前没有更小的质数整除 i,否则早就退出。因此 p≤i 的最小质因子;q=i×p 的最小质因子一定是 p。每次写入都由正确负责人完成。

再看不遗漏:任取合数 q,令 p 是它的最小质因子,i=q/p。i 的质因子都不比 p 小,因此内层不会在 p 之前因整除而退出。又因为 i≥p,p 已经在质数清单里;循环最终会生成 q。

两点合起来:每个合数只对应 q/最小质因子 与该质因子这一组,所以恰好被标记一次。外层处理 N 个数,合数标记总共不到 N 次,算术操作按常数时间计时核心为 O(N)。Python 大整数的位成本与实际运行时间需要另算;本教学接口把 N 限制到100000。

删掉 break 会重复;把 break 提前会漏掉4

错误模式 no_break 仍使用质数清单,但不在整除后退出。N=30 时质数列表碰巧仍完全正确,12却被4×3和6×2写了两次;全部合数标记由19次增加到24次。因此只比最后的质数列表,抓不到这类性能性质退化。

另一个错误模式 early_break 把整除检查放在写入之前。i=2、p=2 时直接退出,没有标记4;外层到4时它仍是0,于是被加入质数清单。问题已经从重复工作变成错误分类。

这两个开关用于观察反例。正确顺序是先确认乘积在范围内,再标记,再在整除时停止;不能把“遇到整除就停”压缩成丢失顺序的口号。

保存最小质因子,还能反复除出完整因数

有了 least 表,分解12只需依次读 least[12]=2,除后得到6;再读 least[6]=2,得到3;最后读 least[3]=3,得到1。因此结果是 [2,2,3]。重复质因子必须保留,不能直接改成集合。

factor(1) 返回空列表,因为1没有质因子。输入必须在1到已经筛过的N之间,不能把表外的数直接交给它。每次至少除以2,所以分解一个 x 的循环次数不超过按二进制位数计的 O(log x)。

least、marked和质数清单都最多占O(N)空间。marked 是本实验的可观察检查表,业务只需要质数或最小质因子时可以省去它。更大范围是否该分段筛,需要根据内存和任务重新选择,本文未比较筛法实际速度。

用试除法核对,不用第二份筛法互相证明

保存完整代码为 demo.py,运行 python demo.py。本次已在 CPython 3.14.0 实跑,只用标准库;通过 Python 3.10 语法解析不代表在3.10实跑。

trial_least 从2到 isqrt(x) 逐个试除,找到第一个因子;若没有则 x 为质数。这与生成合数的筛法走不同路线。检查上界0到300,再加1000和10000,共303份范围、55848个整数,核对质数清单、最小因子、唯一标记次数及因子乘积。

额外拒绝负上界、布尔值、小数、字符串和超出教学上限的输入。预期输出来自这次运行;所有检查用显式 AssertionError,即使以 python -O 运行也不会关闭。

展开收起代码 · 小实验python · 100 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Every composite has exactly one owner: its smallest prime times the rest."""
from math import isqrt, prod


def require(ok, message):
    if not ok:
        raise AssertionError(message)


def linear_sieve(limit, mode="correct", emit=None):
    if type(limit) is not int or not 0 <= limit <= 100000:
        raise ValueError("integer 0<=limit<=100000 required; bool excluded")
    if mode not in ("correct", "no_break", "early_break"):
        raise ValueError("unknown teaching mode")
    least = [0] * (limit+1)
    marked = [0] * (limit+1)
    primes = []
    for i in range(2,limit+1):
        if least[i] == 0:
            least[i] = i
            primes.append(i)
            if emit:
                emit("prime", i, i, i, False)
        for p in primes:
            q = i*p
            if q > limit:
                break
            divides = i % p == 0
            if mode == "early_break" and divides:
                if emit:
                    emit("skipped", i, p, q, True)
                break
            least[q] = p
            marked[q] += 1
            if emit:
                emit("mark", i, p, q, mode == "correct" and divides)
            if mode == "correct" and divides:
                break
    return primes, least, marked


def trial_least(value):
    for divisor in range(2,isqrt(value)+1):
        if value % divisor == 0:
            return divisor
    return value


def factor(value, least):
    if type(value) is not int or not 1 <= value < len(least):
        raise ValueError("value must be in 1..limit")
    result = []
    while value > 1:
        p = least[value]
        if p < 2 or value % p:
            raise ValueError("invalid least-prime table")
        result.append(p)
        value //= p
    return result


def main():
    primes, least, marked = linear_sieve(30)
    print(f"primes_to_30={primes}")
    print(f"least[12]={least[12]}; factors[12]={factor(12,least)}; mark_count[12]={marked[12]}")
    print(f"composite_marks={sum(marked)}; composite_count={29-len(primes)}")
    redundant = linear_sieve(30,"no_break")
    premature = linear_sieve(30,"early_break")
    require(redundant[0] == primes and redundant[2][12] == 2, "remove-break counterexample")
    require(4 in premature[0], "early-break counterexample")
    print(f"without_break: primes_same={redundant[0]==primes}; marks[12]={redundant[2][12]}; total_marks={sum(redundant[2])}")
    print(f"break_before_mark: wrongly_lists_4_as_prime={4 in premature[0]}")
    limits = comparisons = 0
    for limit in list(range(301)) + [1000,10000]:
        primes, least, marked = linear_sieve(limit)
        expected = [x for x in range(2,limit+1) if trial_least(x)==x]
        require(primes == expected, "trial division prime list")
        for x in range(2,limit+1):
            want = trial_least(x)
            require(least[x] == want, "smallest divisor")
            require(marked[x] == int(want != x), "each composite exactly once")
            factors = factor(x,least)
            require(prod(factors) == x and all(trial_least(p)==p for p in factors), "factorization")
            comparisons += 1
        if limit >= 1:
            require(factor(1,least) == [], "one has no prime factors")
        limits += 1
    for invalid in (-1, True, 3.5, "30",100001):
        try:
            linear_sieve(invalid)
        except ValueError:
            pass
        else:
            raise AssertionError("invalid input accepted")
    print(f"limits_checked={limits}; number_checks={comparisons}; invalid_inputs=5")
    print("PASS: trial divisors, prime lists, unique marks and factor products agree")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-24 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。额外拒绝负上界、布尔值、小数、字符串和超出教学上限的输入。预期输出来自这次运行;所有检查用显式 AssertionError,即使以 python -O 运行也不会关闭。

运行后对照结果
primes_to_30=[2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
least[12]=2; factors[12]=[2, 2, 3]; mark_count[12]=1
composite_marks=19; composite_count=19
without_break: primes_same=True; marks[12]=2; total_marks=24
break_before_mark: wrongly_lists_4_as_prime=True
limits_checked=303; number_checks=55848; invalid_inputs=5
PASS: trial divisors, prime lists, unique marks and factor products agree

把负责人画出来,再增加自己的检查

练习不预先给答案:将上界换成42,挑一个含三个不同质因子的合数和一个质数平方。写下它们唯一应出现的 i、p,再运行正确模式观察。

接着分别打开两个错误模式,检查质数清单与标记次数。最后尝试在不保存 marked 数组的情况下累计写入总次数,并用“范围内整数个数减去质数个数”核对;明确0和1是否被排除。

记住这些要点

  • 每个合数的最小质因子和剩余因子形成唯一一组负责人。
  • 必须先标记 i×p,再在 p 整除 i 时退出。
  • 正确质数列表不足以证明每个合数只处理一次。
  • 试除法提供独立参考,最小质因子表还能用于因数分解。

别把例子用过头

  • 把1列为质数或把N写成不包含的上界。
  • 先执行整除 break,漏掉质数平方等合数。
  • 只验证最终集合,忽略重复标记破坏线性工作量。

自己试一小步

上界42时,怎样同时检查结果正确和每个合数只有一个负责人?

  1. 手写两个选定合数的最小质因子。
  2. 记录正确与错误模式的标记来源。
  3. 增加总标记次数检查,并处理0、1、2边界。

做到这些,就算完成

  • 每个标记来源满足乘积等于目标合数。
  • 试除参考不读取 least 表。
  • 练习不预填42范围的最终清单。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。