使第一个素数达到极限的最快方法

Fastest way to get the first prime numbers up to a limit

就CPU而言,使第一个素数达到极限的最快方法是什么?

在我的机器上计算前 1,000,000,000 个数字需要 4.8 秒。它甚至比从文件中读取它还要快!

    public static void main(String[] args) {
        long startingTime = System.nanoTime();
        
        int limit = 1_000_000_000;
        BitSet bitSet = findPrimes(limit);

        System.out.println(2);
        System.out.println(3);
        System.out.println(5);
        System.out.println(7);
        limit = limit / 2 + 1;
        for (int i = 6; i < limit; i++) {
            if (!bitSet.get(i)) {
                int p = i * 2 - 1;
                //System.out.println(p);
            }
        }

        System.out.println("Done in " + (System.nanoTime() - startingTime) / 1_000_000 + " milliseconds");
    }

    public static BitSet findPrimes(int limit) {
        BitSet bitSet = new BitSet(limit / 2 + 1);
        int size = (int) Math.sqrt(limit);
        size += size % 2;
        for (int prime = 3; prime < size; prime += 2) {
            if (bitSet.get(prime / 2 + 1)) {
                continue;
            }
            for (int i = prime / 2 + 1; i < bitSet.size(); i += prime) {
                bitSet.set(i);
            }
        }

        return bitSet;
    }

计算质数的最有效方法是使用 Sieve of Eratosthenes,它最早发现于千年前的古希腊。

然而,问题在于它需要大量内存。当我刚刚声明一个 1,000,000,000 的布尔数组时,我的 Java 应用程序崩溃了。

所以我做了两个内存优化来让它工作

  • 由于 2 之后的所有质数都是奇数,我通过将索引映射到 int p = i * 2 - 1;
  • 来将数组大小减半
  • 然后我没有使用布尔值数组,而是使用了 BitSet 对长整数数组进行位运算。

通过使用埃拉托色尼筛法进行的这两项优化,您可以在 4.8 秒内计算出 btSet。至于打印出来,那就另当别论了;)

有多种方法可以快速计算素数 -- 'fastest' 最终将取决于许多因素(硬件、算法、数据格式等的组合)。这已经在这里得到回答: Fastest way to list all primes below N 。然而,那是针对 Python,当我们考虑 C(或其他低级语言)

时,问题变得更有趣

您可以看到:

渐近地,阿特金筛法具有更好的计算复杂度(O(N) 或更低*——见注释),但实际上埃拉托色尼筛法 (O(N log log N)) 非常好,并且它的优点是非常容易实施:

/* Calculate 'isprime[i]' to tell whether 'i' is prime, for all 0 <= i < N */
void primes(int N, bool* isprime) {
    int i, j;
    /* first, set odd numbers to prime, evens to not-prime */
    for (i = 0; i < N; ++i) isprime[i] = i % 2 == 1;
    
    /* special cases for i<3 */
    isprime[0] = isprime[1] = false;

    /* Compute square root via newton's algorithm 
     * (this can be replaced by 'i * i <= N' in the foor loop)
     */
    int sqrtN = (N >> 10) + 1024;
    for (i = 0; i < 32; ++i) sqrtN = (N / sqrtN + sqrtN) / 2;

    /* iterate through all odd numbers */
    isprime[2] = true;
    for (i = 3; i <= sqrtN /* or: i*i <= N */; i += 2) {
        /* check if this odd number is still prime (i.e. if it has not been marked off) */
        if (isprime[i]) {

            /* mark off multiples of the prime */
            j = 2 * i;
            isprime[j] = false;
            for (j += i; j < N; j += 2 * i) {
                isprime[j] = false;
            }
        }
    }
}

该代码非常清晰简洁,但需要大约 6 秒来计算 < 1_000_000_000(10 亿)的素数,并使用 N * sizeof(bool) == N == 1000000000 字节的内存。不太好

我们可以使用 bit-fiddling 和 uint64_t 类型(使用 #include <stdint.h>)来生成以相同复杂度运行的代码,但希望速度更快并且使用更少的内存。我们可以立即将内存减少到 N/8(每个布尔值 1 位而不是 1 字节)。此外,我们只能计算奇数并削减更多的内存——2 倍,使我们的最终使用量达到 N/16 字节。

代码如下:


/* Calculate that the (i//2)%64'th bit of 'isprime[(i//2)/64]' to tell whether 'i' is prime, for all 0 <= i < N,
 *   with 'i % 2 == 0'
 * 
 * Since the array 'isprime' can be seen as a long bistring:
 * 
 * isprime:
 * [0]       [1]          ...
 * +---------+---------+
 * |01234....|01234....|  ...
 * +---------+---------+
 * 
 * Where each block is a 64 bit integer. Therefore, we can map the odd natural numbers to this with the following formula:
 * i -> index=((i / 2) / 64), bit=((i / 2) % 64)
 * 
 * So, '1' is located at 'primes[0][0]' (where the second index represnts the bit),
 *     '3' is at 'primes[0][1]', 5 at 'primes[0][2]', and finally, 129 is at 'primes[1][0]'
 * 
 * And so forth.
 * 
 * Then, we use that value as a boolean to indicate whether the 'i' that maps to it is prime
 * 
 */
void primes_bs(int N, uint64_t* isprime) {
    uint64_t i, j, b/* block */, c/* bit */, v, ii;
    /* length of the array is roundup(N / 128) */
    int len = (N + 127) / 128;
    /* set all to isprime */
    for (i = 0; i < len; ++i) isprime[i] = ~0;
    
    /* set i==1 to not prime */
    isprime[0] &= ~1ULL;

    /* Compute square root via newton's algorithm */
    int sqrtN = (N >> 10) + 1024;
    for (i = 0; i < 32; ++i) sqrtN = (N / sqrtN + sqrtN) / 2;

    /* Iterate through every word/chunk and handle its bits */
    uint64_t chunk;
    for (b = 0; b <= (sqrtN + 127) / 128; ++b) {
        chunk = isprime[b];
        c = 0;
        while (chunk && c < 64) {
            if (chunk & 1ULL) {
                /* hot bit, so is prime */
                i = 128 * b + 2 * c + 1;
                /* iterate 3i, 5i, 7i, etc... 
                 * BUT, j is the index, so is basically 3i/2, 5i/2, 7i/2,
                 *   that way we don't need as many divisions per iteration,
                 *   and we can use 'j += i' (since the index only needs 'i'
                 *   added to it, whereas the value needs '2i')
                 */
                for (j = 3 * i / 2; j < N / 2; j += i) {
                    isprime[j / 64] &= ~(1ULL << (j % 64));
                }
            }

            /* chunk will not modify itself */
            c++;
            chunk = isprime[b] >> c;
        }
    }

    /* set extra bits to 0 -- unused */
    if (N % 128 != 0) isprime[len - 1] &= (1ULL << ((N / 2) % 64)) - 1;
}

此代码在我的机器上用 ~2.7 秒计算所有小于 1000000000 (1_000_000_000) 的数字的素数,这是一个很大的改进!而且您使用的内存更少(其他方法使用的内存的 1/16。

但是,如果您在其他代码中使用它,界面会比较棘手——您可以使用一个函数来提供帮助:

/* Calculate if 'x' is prime from a dense bitset */
bool calc_is_prime(uint64_t* isprime, uint64_t x) {
  /**/ if (x == 2) return true; /* must be included, since 'isprime' only encodes odd numbers */
  else if (x % 2 == 0) return false;
  return (isprime[(x / 2) / 64] >> ((x / 2) % 64))) & 1ULL;
}

您可以走得更远,将这种映射索引的方法推广到 odd-only 值 (https://en.wikipedia.org/wiki/Wheel_factorization),但生成的代码可能会更慢——因为内部循环和操作是不再是 2 的幂,您要么必须进行 table 查找,要么需要一些非常聪明的技巧。您想要使用轮子分解的唯一情况是如果您的内存非常有限,在这种情况下 primorial(3) == 30 的轮子分解将允许您每 7 位存储 30 个数字(而不是每 7 位存储 2 个数字) 1 位)。或者,假设 primorial(5) == 2310,您可以为每 341

存储 2310 个数字

然而,大轮子分解将再次将一些指令从移位和 power-of-two 操作更改为 table 查找,对于大轮子,这实际上最终可能会慢得多

备注:

  • 从技术上讲,存在 O(N / (log log N)) 的阿特金筛法版本,但您不再使用相同的密集数组(如果是,复杂度必须至少为 O(N ))