最近刷题时刷到 HJ140《小红的合数寻找》这道题,题目很短,却意外地把合数判断、区间统计、数位枚举这几个基础点全串起来了。再结合搜索时经常一起出现的“1143. 纯粹合数”来看,这其实是一条很典型的“数论入门到进阶”路线:先会判断单个数是不是合数,再学会用筛法批量预处理,最后用前缀和回答多组区间询问。如果你刚开始刷算法题,或者想复习质数筛和前缀和,这篇文章应该能帮你少走不少弯路。
我拿到的题面描述很简单:小红拿到了一个区间 [l, r],想知道这个区间里有多少个合数。部分版本还会追加一个条件,问其中有多少个“纯粹合数”。乍看是个再基础不过的题,但真正写起来,边界条件、筛法写法、输入输出性能都会影响你能不能一次通过。下面我就按自己的解题过程来拆一遍。
1. 题目在考什么:HJ140 的题意还原与核心考点
1.1 我拿到的题面和我对题意的解读
既然是“小红的合数寻找”,核心任务就是在一段连续整数区间里找出满足条件的合数。最常见的版式是:输入第一行一个整数 T,表示有 T 组询问,每组询问给两个正整数 l 和 r,要求输出区间 [l, r] 内合数的数量。
这里有一个必须提前盯死的定义:合数是大于 1 且不是质数的整数。换句话说,1 既不是质数也不是合数,0 也不是合数。很多新手第一次写这题,就是把 1 误判成合数,导致输出结果整体偏大。
至于“纯粹合数”,网上热词对应的是“1143. 纯粹合数”那道题。我见到的常见定义是:一个数 x 是纯粹合数,需要同时满足两个条件:
- x 本身是合数;
- x 的十进制表示的每一位都不是质数数字,也就是每一位只能来自 {0, 1, 4, 6, 8, 9}。
注意 0 和 1 本身不是质数,所以它们不算“质数数字”,在大部分题面里是允许出现的。比如 10、14、16 这些都是合数,而且数位里没有 2、3、5、7,所以算纯粹合数。这一点如果没读清楚,很容易把 10、14 这类数漏掉。
1.2 为什么这道题能串起一整套基础数论技能
这道题的精彩之处在于它一个题覆盖了三个层次的知识点:
第一个是质数/合数判断。单个数判断用试除法,批量判断用筛法,这是数论题的基本功。第二个是区间统计。如果不用前缀和,遇到多组询问基本必超时,这里考察的是“预处理换时间”的思维。第三个是数位拆分。纯粹合数要求检查每一位,这就涉及到十进制拆位,也是很多数位相关题目的雏形。
所以哪怕你只是想在 OJ 上把这道题的分数拿到,也建议把这三个层面的原理都搞清楚。我自己的感受是,做完这道题之后,再看“区间内统计某类特殊数”的题目,思路会清晰很多,因为它本质上就是“筛法/判断 + 前缀和/离线”的组合套路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 合数判断的两种姿势:试除法与筛法
2.1 试除法:判断单个数的合数/质数
判断一个数 n 是不是质数,最简单的办法是试除:枚举从 2 到 sqrt(n) 的所有整数,如果 n 能被其中任何一个整除,那它就是合数;否则就是质数。
为什么枚举到 sqrt(n) 就够了?因为如果 n 能分解成两个因子 a 和 b,也就是 n = a * b,那么 a 和 b 里至少有一个不超过 sqrt(n)。换句话说,如果 2 到 sqrt(n) 之间没有 n 的因子,那 n 一定是质数。这个结论虽然简单,却把 O(n) 的判断降到 O(sqrt(n)),在 n 比较小的时候非常实用。
python复制def is_prime(n):
if n < 2:
return False
i = 2
while i * i <= n:
if n % i == 0:
return False
i += 1
return True
这里有一个很多人忽略的坑:当 n 很大时,i * i 可能会溢出整数范围。比如在 C++ 里,n 接近 2^31 时,i*i 直接爆 int。保险的写法是改成 i <= n // i(Python)或 i <= n / i(C++)。虽然本题数据范围未必这么极限,但这个习惯最好从一开始就养成。
2.2 埃氏筛:批量预处理质数的首选
如果区间上限 n 是 1e6 甚至 1e7,而且有多组询问,再对每个数单独试除就不太合适了。这时候应该用筛法一次性把所有数的质数/合数状态算出来。
埃氏筛的思路很直白:准备一个布尔数组 is_prime,初始全部为 True,先把下标 0 和 1 设为 False。然后从 2 开始,遇到一个质数,就把它的所有倍数都标记为合数。比如 2 是质数,那 4、6、8、10……全是合数;3 是质数,那 9、12、15……全是合数。
python复制def eratosthenes(n):
is_prime = [True] * (n + 1)
is_prime[0] = is_prime[1] = False
i = 2
while i * i <= n:
if is_prime[i]:
for j in range(i * i, n + 1, i):
is_prime[j] = False
i += 1
return is_prime
注意内层循环从 i * i 开始,而不是从 i * 2 开始。原因很简单:i 乘以一个比 i 小的数,这个乘积一定已经被更小的质数筛过了。例如 i=5 时,52=10、53=15、54=20 都已经在 i=2、i=3、i=2 的处理中被标记成了合数。如果从 i2 开始,功能上没错,但会做大量重复标记,白白浪费时间。埃氏筛的时间复杂度是 O(n log log n),对 1e7 以内的数据范围完全够用。
2.3 线性筛:当 n 到 1e7 以上时的优化方案
埃氏筛虽然快,但同一个合数可能被多个质因子重复标记。例如 12 会被 2 标记一次,又会被 3 标记一次。如果要求极限性能,可以用线性筛,也叫欧拉筛,保证每个合数只被它的最小质因子筛掉一次。
python复制def linear_sieve(n):
is_prime = [True] * (n + 1)
primes = []
for i in range(2, n + 1):
if is_prime[i]:
primes.append(i)
for p in primes:
if p * i > n:
break
is_prime[p * i] = False
if i % p == 0:
break
return is_prime
核心在 if i % p == 0: break 这一行。它保证了当 p 已经是 i 的质因子时,就不再继续用更大的 p 去标记,因为更大的 p 不是 pi 这个合数的最小质因子,后面会由更合适的时机去筛掉。线性筛的时间复杂度是严格的 O(n),在只判断质数这个需求下,和埃氏筛的差距没有想象中大,但如果你后面还要算欧拉函数、莫比乌斯函数,线性筛可以直接扩展成配套模板。就 HJ140 这道题来说,用埃氏筛足够了,记住从 ii 开始能省下不少时间。
3. 区间统计:用前缀和把多组询问压到 O(1)
3.1 直接暴力为什么会超时
很多人第一反应是:每组询问从 l 到 r 遍历一遍,用上面写的 is_prime 函数判断每个数,是合数就计数。这个思路在单组询问、区间很短的时候没问题,但题目一般不会这么善良。
假设区间上限 n = 1e6,询问次数 T = 1e5,每次询问都遍历整个区间的极端情况,单次就接近 O(n),再乘以 T,直接到 1e11 级别。即使每段区间没那么长,总开销也扛不住。判断一个数要做 sqrt(n) 次取模,区间又长,询问又多,三重循环下来基本必超时。
所以正确的思路是:先花 O(n log log n) 的时间做一次预处理,把每个数是质数还是合数算出来,然后构造前缀和数组,让每次区间查询变成 O(1) 的减法。
3.2 前缀和数组的构造过程
前缀和数组 pre[i] 表示从 1 到 i 这 i 个数里合数的个数。构造方式很简单:
- pre[0] = 0;
- 对于 i 从 1 到 n,
pre[i] = pre[i-1] + (1 if i 是合数 else 0)。
这样区间 [l, r] 的合数数量就等于 pre[r] - pre[l-1]。为什么可以这样减?因为 pre[r] 是 1 到 r 的合数总数,pre[l-1] 是 1 到 l-1 的合数总数,两者相减剩下的正好是 l 到 r 这一段。
这里最容易出错的点有两个:一是合数的判断必须排除 1,因为 is_prime[1] = False,如果你直接写 not is_prime[i],1 会被误算成合数;二是 l 可能等于 1,这时候 pre[l-1] 是 pre[0],必须保证 pre 数组从 0 开始初始化,否则会越界。
3.3 边界条件与完整可提交代码
我再强调一下边界:1 不是合数,0 不是合数,如果区间下限 l 可能为 1,那么区间里第一个数要单独看待。另外,题目一般保证 l <= r,但仍然建议在代码里统一用 pre[r] - pre[l-1],因为在 pre 数组构建正确的前提下,这个公式天然处理了边界。
下面是完整可提交的 Python 代码,我按“先读所有询问,再找最大右端点,最后统一预处理”的方式写,这样筛法只需要做一次,而且只会筛到需要的最大值,不会平白无故多算。
python复制import sys
def build_prime_flag(n):
is_prime = [True] * (n + 1)
is_prime[0] = is_prime[1] = False
i = 2
while i * i <= n:
if is_prime[i]:
for j in range(i * i, n + 1, i):
is_prime[j] = False
i += 1
return is_prime
def main():
data = sys.stdin.buffer.read().split()
if not data:
return
t = int(data[0])
queries = []
max_r = 0
idx = 1
for _ in range(t):
l = int(data[idx])
r = int(data[idx + 1])
idx += 2
queries.append((l, r))
if r > max_r:
max_r = r
is_prime = build_prime_flag(max_r)
pre = [0] * (max_r + 1)
for i in range(1, max_r + 1):
if i >= 2 and not is_prime[i]:
pre[i] = pre[i - 1] + 1
else:
pre[i] = pre[i - 1]
out = []
for l, r in queries:
out.append(str(pre[r] - pre[l - 1]))
sys.stdout.write("\n".join(out))
if __name__ == "__main__":
main()
这段代码里有一个很关键的小优化:先遍历所有询问,记录下最大的 r,再根据 max_r 去建筛和前缀和。如果题目本身给的 n 范围很大,但实际询问只落在很小的区间里,这种做法能省不少内存和时间。如果你确定题目最大 n 固定,也可以直接定义为常数,但用 max_r 更稳妥。
4. 进阶玩法:1143 纯粹合数怎么统计
4.1 纯粹合数的定义与第一印象
回到热词“1143. 纯粹合数”。我第一次看到这个名词时,直觉以为它是“所有因子都是合数的数”,后来读题发现不是,而是“一个合数的十进制每一位都不是质数数字”。这个定义容易让人迷惑,所以先看几个例子。
从 1 到 50 里,纯粹合数有:4、6、8、9、10、14、16、18、40、44、46、48、49。为什么 10 算?因为 10 = 2 × 5 是合数,十进制表示是“1”和“0”,1 不是质数数字,0 也不是质数数字,所以满足。为什么 12 不算?因为 12 是合数,但数位里有 2,2 是质数数字,不满足。为什么 11 不算?虽然数位是 1 和 1,都不含质数数字,但 11 本身是质数,不是合数,所以不满足。
这样看下来,判断一个数是不是纯粹合数,本质上是“合数判断”和“数位合法判断”两个条件的叠加。只要你把筛法得到的 is_prime 数组准备好,剩下的就是拆位检查。
4.2 判断逻辑:先合数,再查数位
判断函数可以这样写:
python复制def is_pure_composite(x, is_prime):
if x < 4 or is_prime[x]:
return False
while x > 0:
d = x % 10
if d == 2 or d == 3 or d == 5 or d == 7:
return False
x //= 10
return True
两个细节我特意加了注释:
第一个,x < 4 直接排除。因为 0 和 1 不是合数,2 和 3 是质数,4 是最小的合数,所以小于 4 的整数直接 false,省去后面拆位的计算。
第二个,先判断合数,再判断数位,顺序不能反。如果先查数位,再判断合数,可能会把 11 这种数位合法但本身是质数的数误统计进去。逻辑顺序虽然看起来无所谓,但在实际编码时很容易因为先写数位循环、后补合数判断而出错,所以建议养成“先整体性质,再局部性质”的判断顺序。
4.3 合并到前缀和里的完整实现
统计纯粹合数数量和统计普通合数数量,思路完全一致,只是前缀和每一项的判定函数从“是不是合数”换成了“是不是纯粹合数”。
python复制import sys
def build_prime_flag(n):
is_prime = [True] * (n + 1)
is_prime[0] = is_prime[1] = False
i = 2
while i * i <= n:
if is_prime[i]:
for j in range(i * i, n + 1, i):
is_prime[j] = False
i += 1
return is_prime
def is_pure_composite(x, is_prime):
if x < 4 or is_prime[x]:
return False
while x > 0:
d = x % 10
if d == 2 or d == 3 or d == 5 or d == 7:
return False
x //= 10
return True
def main():
data = sys.stdin.buffer.read().split()
if not data:
return
t = int(data[0])
queries = []
max_r = 0
idx = 1
for _ in range(t):
l = int(data[idx])
r = int(data[idx + 1])
idx += 2
queries.append((l, r))
max_r = max(max_r, r)
is_prime = build_prime_flag(max_r)
pre = [0] * (max_r + 1)
for i in range(1, max_r + 1):
pre[i] = pre[i - 1] + (1 if is_pure_composite(i, is_prime) else 0)
out = []
for l, r in queries:
out.append(str(pre[r] - pre[l - 1]))
sys.stdout.write("\n".join(out))
if __name__ == "__main__":
main()
这个版本的代码和普通合数版本唯一的差别就在 pre 数组的构建。所以如果你已经吃透了合数统计,纯粹合数只是多加一个拆位判断而已。
说到拆位判断,有读者可能会问,能不能预处理“数字是否合法”来加速?其实没必要,因为十进制拆位的代价很小,一个数最多十几位,在 1e7 的数据范围内,每个数拆一次位,整体仍然很快。真正要注意的是不要在每个查询里重新拆一遍,而应该在前缀和构建时一次性处理。
4.4 如果数据范围扩大到 1e18,思路要怎么变
前缀和能跑的前提是数据范围可控。如果纯粹合数的区间上限变成 1e18,数组根本开不出来,这时候就需要换思路,往数位 DP 方向考虑。
数位 DP 的核心是“从高位到低位枚举数字,记录当前状态”。对于纯粹合数这种题,状态里至少需要记录:当前是否已经贴住上界、当前是否已经出现过非零数字、当前枚举到这个数字时整个数是否满足数位合法。可是要判断“整个数是不是合数”就很麻烦,因为合数取决于这个数的完整值,而不是某几位。
所以实际竞赛里,如果数据范围到 1e18,题面大概率不会让你同时判断“合数”,而是只统计“数位中不含质数数字的正整数个数”,或者换一种更数学化的统计方式。这也是我建议新手先掌握前缀和版本的原因,因为大部分同类题目的数据范围都支持开数组。等真需要数位 DP 时,你已经有了“先筛后前缀和”的思维底座,再去学 DP 状态设计会更容易理解。
5. 我踩过的坑:常见问题与调试实录
5.1 经典错误清单
刷题过程中最容易载跟头的地方,我整理成了一张表,每一条都是真实踩过的:
| 现象 | 可能原因 | 对策 |
|---|---|---|
| 输出比正确答案大 | 把 1 算成了合数 | 判断合数时额外加 i >= 2 条件,不能只看 not is_prime[i] |
| 区间左端点 l = 1 时数组越界 | 没有给 pre[0] 初始化 | pre = [0] * (max_r + 1),pre[0] 天然为 0,l-1=0 时合法 |
| 单纯判断每个数导致超时 | 多组询问每次都重复遍历区间 | 一次性预处理 max_r 的筛法,再用前缀和 O(1) 回答 |
| i * i 爆 int | 没有考虑边界 | 改用 i <= n // i |
| 纯粹合数统计多算了 11、41 等 | 只检查数位,没检查本身是不是合数 | 先判断 is_prime[x],再拆位检查 |
| 埃氏筛内层从 i*2 开始太慢 | 重复标记了大量合数 | 从 i*i 开始,减少无用操作 |
| 读入太慢导致超时 | 用 input() 一行行读,询问量很大 | 用 sys.stdin.buffer.read().split() 一次性读入 |
5.2 一个关于“0 和 1 算不算合数”的提醒
这个误区和纯粹合数绑定得很深。我最初提交纯粹合数版本时,答案总是比样例少,排查了半天发现是很多包含数字 0 的数被漏掉了。比如 10、40、60,这些都是合数,而且数位里有 0,0 不是质数数字,符合题面定义。
问题出在我潜意识里觉得 0 和 1 “不算数”,于是拆位时直接把 d = 0 或 d = 1 的情况当成非法值跳过。实际上在这道题的语境里,0 和 1 只是“不是质数数字”,是允许出现在纯粹合数里的。这一点必须回到题面确认,不同题可能有不同约定,但大多数版本是允许的。
另一个容易漏的是 0 作为个位的情况,例如 40、60,拆位循环里 while x > 0 会把 0 个位取出来,如果不小心用 if d < 2: continue 之类的逻辑,就会漏判。记住判断标准只有一个:d 是不是 2、3、5、7 这四个质数数字,不是就直接放行。
5.3 调优体验:从超时到 AC 的完整过程
我第一版写法很粗暴:每组询问都从 l 到 r 遍历,每个数都调用一次试除法。自测小数据没问题,一提交就超时。后来把 l 到 r 的遍历改成前缀和,但还是在每组询问里重新初始化筛法,结果没优化到点上,只快了一点点。
最后我改成“先离线读入所有询问,统计出 max_r,再把筛法和前缀和都只执行一次”,提交直接过了。回头复盘,这道题最核心的优化点不是某个数学公式,而是把重复计算从“每组询问”里挪到“全局预处理”里。这种思路在区间统计题里是通用的:只要数据范围允许,就尽量一次性把能算的都算出来,查询时只做减法。
另外,Python 写这类题时,输入输出用 sys.stdin.buffer 和 sys.stdout.write 能省下很多时间,尤其是 T 到 1e5 的时候。不要小看 IO 优化,有时候你算法复杂度没错,但就是卡在读入和 print 上。
这道题做完之后,我最大的收获倒不是会写筛法,而是意识到代码里的每个边界条件都值得较真。1 到底是不是合数,0 能不能出现在纯粹合数里,l=1 时 pre[l-1] 指向哪,这些细节单独拎出来都简单,但合在一起就是一道题的区分度。后来我刷类似题目的时候,也会先把数据范围、边界定义、询问形式这三件事写在草稿纸上,再动键盘,明显少了很多无意义的提交。
