深蓝黑背景封面:左侧大标题「合数不用算,标记就行」,副标题「埃氏筛、欧拉筛与位压缩的提速路径」;右侧原创示意图——一排数字格子里,2 的倍数、3 的倍数被逐格打上叉,留下的空格自然露出素数,旁边一个小表标注「每个合数只被标一次」;左上标注「面试经典 · 算法」,右上标注「Day 09 · 10-27」 图 1:本文封面,合数不用算,标记就行。

先说明属性:这是一篇历史题目复盘,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面朴素:输出 2 到 2000 之间的所有素数,怎么写得尽量快?

先把结论立住:**别逐个验算素数,反过来标记合数——从小到大每遇到一个没被标记的数就是素数,把它的倍数划掉,剩下的就是答案。**这就是埃氏筛;再进一步,让每个合数只被它的最小质因子划一次,就是严格线性的欧拉筛;最后用位压缩把标记数组缩成一个比特一个数,内存与缓存命中双赢。三种写法加上试除基准,下面全部实测。

**为什么试除法慢?**对每个数 x 都从 2 试到 √x,2000 个数要做上万次除法;更根本的问题是它在「重复发现同一个事实」——6 是合数这件事,试除 6 时发现一次,试除 12、18 时又一遍遍重新发现。筛法把这个事实只算一次:遇到 2 就一次性标记 4、6、8、10……答案不在计算里,在标记里。

**埃氏筛的两个加速点。**一是划倍数从 i² 开始而不是 2i——更小的倍数早被更小的素数划过了;二是外层循环遇到已标记的数直接跳过。它的复杂度约为 O(n log log n),已经足够快,但存在重复标记:6 会被 2 划一次、被 3 再划一次,12 被 2 和 3 各划一次。

**欧拉筛消掉重复标记。**维护一张素数表,遍历到 i 时只用素数表里的素数 p 去标记 i×p,一旦 i 能被 p 整除立即停手——因为此时 i×(更大的素数) 的最小质因子都是 p,往后标也是重复劳动。这样每个合数恰好被它的最小质因子划掉一次,复杂度严格 O(n)。

**位压缩是工程层的最后一挤。**布尔标记改用一个比特存一个数,2000 个数只需 251 字节左右,标记数组全在一两 cache line 里,连内存带宽都省了。

对照加优化图:左侧埃氏筛的工作方式——从小到大每遇到一个未标记的数就是素数,把它的所有倍数划掉;2 划掉 4、6、8、10,3 划掉 6、9、12,可以看到 6、12 被重复划了多次,这就是它略慢的原因,复杂度约 O(n log log n)。右侧欧拉筛——每个合数只由它的最小质因子划掉一次:维护素数表,遍历到 i 时只用素数表里的素数乘 i 标记乘积,遇到整除立即停手,保证每个合数恰好被标记一次,复杂度严格 O(n)。下方位压缩卡片:布尔标记改用一个比特存一个数,2000 个数只需 250 字节,省内存同时提升缓存命中。底部提示:筛法的思想是「答案在标记里而不在计算里」——与缓存预计算、倒排索引一脉相承 图 2:埃氏筛会重复标记,欧拉筛每个合数只标一次。

三种写法加试除基准,用 Node.js 22.15.0 直接运行(node sieve.js),先对齐 2 到 2000 的正确性,再放大到一千万看速度差:

'use strict';

// 试除法:每个数都从头验算,作为正确性基准
function trialDivision(n) {
  const primes = [];
  outer: for (let x = 2; x <= n; x++) {
    for (let d = 2; d * d <= x; d++) if (x % d === 0) continue outer;
    primes.push(x);
  }
  return primes;
}

// 埃氏筛:每遇到一个未标记的数就是素数,把它的所有倍数划掉
function eratosthenes(n) {
  const composite = new Array(n + 1).fill(false);
  const primes = [];
  for (let i = 2; i <= n; i++) {
    if (composite[i]) continue;
    primes.push(i);
    for (let j = i * i; j <= n; j += i) composite[j] = true;
  }
  return primes;
}

// 欧拉筛:每个合数只被它的最小质因子划掉一次
function euler(n) {
  const composite = new Array(n + 1).fill(false);
  const primes = [];
  for (let i = 2; i <= n; i++) {
    if (!composite[i]) primes.push(i);
    for (const p of primes) {
      const x = i * p;
      if (x > n) break;
      composite[x] = true;
      if (i % p === 0) break; // i 含因子 p,后面的素数乘 i 的最小质因子都是 p,停手
    }
  }
  return primes;
}

// 位压缩埃氏筛:一个比特存一个数,Uint8Array 当位图用
function eratosthenesBits(n) {
  const bits = new Uint8Array(Math.ceil((n + 1) / 8));
  const mark = (x) => { bits[x >>> 3] |= 1 << (x & 7); };
  const isMarked = (x) => (bits[x >>> 3] >> (x & 7)) & 1;
  const primes = [];
  for (let i = 2; i <= n; i++) {
    if (isMarked(i)) continue;
    primes.push(i);
    for (let j = i * i; j <= n; j += i) mark(j);
  }
  return { primes, bytes: bits.length };
}

const N = 2000;
const base = trialDivision(N);
const e1 = eratosthenes(N);
const e2 = euler(N);
const e3 = eratosthenesBits(N);
console.log(`2 到 ${N} 的素数共 ${base.length} 个`);
console.log(`前 10 个:${base.slice(0, 10).join(', ')}`);
console.log(`最后 5 个:${base.slice(-5).join(', ')}`);
console.log(`埃氏筛与试除一致:${e1.join() === base.join()}`);
console.log(`欧拉筛与试除一致:${e2.join() === base.join()}`);
console.log(`位压缩筛与试除一致:${e3.primes.join() === base.join()},位图占 ${e3.bytes} 字节`);

// 放大到一千万,看重复标记的代价与位压缩的收益
const BIG = 10_000_000;
let t0 = performance.now();
const bigE = eratosthenes(BIG);
const tE = performance.now() - t0;
t0 = performance.now();
const bigL = euler(BIG);
const tL = performance.now() - t0;
t0 = performance.now();
const bigB = eratosthenesBits(BIG);
const tB = performance.now() - t0;
console.log(`n=${BIG.toLocaleString('en-US')}:埃氏筛 ${tE.toFixed(0)}ms,欧拉筛 ${tL.toFixed(0)}ms,位压缩埃氏筛 ${tB.toFixed(0)}ms`);
console.log(`三种算法结果一致:${bigE.length === bigL.length && bigL.length === bigB.primes.length}(共 ${bigE.length} 个素数)`);

实测输出:

2 到 2000 的素数共 303 个
前 10 个:2, 3, 5, 7, 11, 13, 17, 19, 23, 29
最后 5 个:1979, 1987, 1993, 1997, 1999
埃氏筛与试除一致:true
欧拉筛与试除一致:true
位压缩筛与试除一致:true,位图占 251 字节
n=10,000,000:埃氏筛 93ms,欧拉筛 66ms,位压缩埃氏筛 57ms
三种算法结果一致:true(共 664579 个素数)

一千万规模下欧拉筛比埃氏筛快约三成——这就是「重复标记」的真实代价;位压缩再快一截,靠的是标记数组缩进缓存。注意耗时是本机单次运行的量级参考,重点在相对差距而非绝对数字。

**常见误区。**一是埃氏筛内层从 2i 开始划,白做一半功;二是欧拉筛忘记 i % p === 0 就停手,退化成重复标记;三是标记数组开成 new Array(n).fill(false) 还嫌不够,上对象或 Map,内存和速度双输;四是把「尽量快」理解成上多线程,2000 这个量级启动开销都比计算贵。

跳出题目看,筛法「用空间换时间、标记代替重算」的思想和缓存预计算、倒排索引一脉相承,位压缩技巧在特征工程与布隆过滤器里同样常用。你第一次写筛法时踩的是哪个坑?欢迎分享。