多达1000亿个素数的数据库

图片

查找所有素数不大于给定素数的最著名算法是Eratosthenes筛子。如果写得整整齐齐,它适用于高达数十亿甚至数百亿的数字。但是,每个喜欢玩质数的人都知道,您总是想拥有尽可能多的手数。有一次,要解决一个关于hackerrank的问题,我需要一个内存中最多1000亿个素数的数据库。通过最大程度地优化内存,如果您在带有位阵列的Eratosthenes筛子中表示奇数,则其大小约为6 GB,这不适合我的笔记本电脑的内存。该算法的一种修改对内存的要求要低得多(将原始数字范围分成几部分并一次处理一个部分)-分割的Eratosthenes筛子,但实施起来较为困难,而且整个结果无论如何都无法放入内存中。下面,我提醒您注意一种算法,该算法几乎与Eratosthenes筛网一样简单,但是可以通过内存进行双重优化(也就是说,一个容量为1000亿的素数数据库将占用大约3 GB的空间,这应该已经适合标准笔记本电脑的内存了)。

首先,让我们回顾一下Eratosthenes筛子的工作原理:在从1到N的数字数组中,将被2整除的数字相除,然后再将3整除的数字,依此类推。删除后剩下的数字很简单:

2 3 . 5 . 7 . 9 . 11 . 13 . 15 . 17 . 19 . 21 . 23 . 25 . 27  . 29  ...
2 3 . 5 . 7 . . . 11 . 13 .  . . 17 . 19 .  . . 23 . 25 .  .  . 29  ...
2 3 . 5 . 7 . . . 11 . 13 .  . . 17 . 19 .  . . 23 .  . .  .  . 29  ...

C#代码
class EratospheneSieve
{
    private bool[] Data;

    public EratospheneSieve(int length)
    {
        Data = new bool[length];

        for (int i = 2; i < Data.Length; i++) Data[i] = true;

        for (int i = 2; i * i < Length; i++)
        {
            if (!Data[i]) continue;
            for (int d = i * i; d < Length; d += i) Data[d] = false;
        }
    }

    public int Length => Data.Length;

    public bool IsPrime(int n) => Data[n];
}


在我的笔记本电脑上,它最多可计数150亿秒,并消耗1 GB的内存。

我们将使用的算法称为Wheel Factorization为了理解其本质,我们用30个连续的数位板写自然数:

 0  1  2  3  4  5 ... 26 27 28 29
30 31 32 33 34 35 ... 56 57 58 59
60 61 62 63 64 65 ... 86 87 88 89
...

然后划掉所有可被2、3和5整除的数字:

.  1 . . . . .  7 . . . 11 . 13 . . . 17 . 19 . . . 23 . . . . . 29
. 31 . . . . . 37 . . . 41 . 43 . . . 47 . 49 . . . 53 . . . . . 59
. 61 . . . . . 67 . . . 71 . 73 . . . 77 . 79 . . . 83 . . . . . 89
...

可以看出,在每一行中,数字在相同位置被划掉。由于我们只删除了复合数字(实际上是数字2、3、5的例外),因此素数只能处于未标记的位置。

由于每行中有八个这样的数字,因此我们想到了用一个字节表示每个三十个数字,其八个位中的每一个将指示相应位置中数字的简单性。这种巧合不仅美观,而且大大简化了算法的实现!

图片

技术实施


首先,我们的目标是达到1000亿,因此不再需要四个字节的数字。因此,我们的算法将如下所示:

class Wheel235
{
    private byte[] Data;

    public Wheel235(long length)
    {
        ...
    }
    public bool IsPrime(long n)
    {
        ...
    }
    ...
}

为了便于实现,我们将长度四舍五入到最接近的数字除以30。

class Wheel235
{
    private byte[] Data;

    public Wheel235(long length)
    {
        // ensure length divides by 30
        length = (length + 29) / 30 * 30; 
        Data = new byte[length/30];
        ...
    }

    public long Length => Data.Length * 30L;

    ...
}

接下来,我们需要两个数组:一个将索引0..29转换为位数,第二个则相反,将位数转换为30位。使用这些数组和一点位算术,我们在自然数和Data数组中的位之间建立了一对一的对应关系。为简单起见,我们仅使用两种方法:IsPrime(n)(可让您确定数字是否为质数)和ClearPrime(n)(将对应的位设置为0,将数字n标记为复合):

class Wheel235
{
    private static int[] BIT_TO_INDEX = new int[] { 1, 7, 11, 13, 17, 19, 23, 29 };

    private static int[] INDEX_TO_BIT = new int[] {
        -1, 0,
        -1, -1, -1, -1, -1, 1,
        -1, -1, -1, 2,
        -1, 3,
        -1, -1, -1, 4,
        -1, 5,
        -1, -1, -1, 6,
        -1, -1, -1, -1, -1, 7,
    };

    private byte[] Data;

    ...

    public bool IsPrime(long n)
    {
        if (n <= 5) return n == 2 || n == 3 || n == 5;
        int bit = INDEX_TO_BIT[n % 30];
        if (bit < 0) return false;
        return (Data[n / 30] & (1 << bit)) != 0;
    }

    private void ClearPrime(long n)
    {
        int bit = INDEX_TO_BIT[n % 30];
        if (bit < 0) return;
        Data[n / 30] &= (byte)~(1 << bit);
    }
}

请注意,如果该数字可被2、3或5整除,则位数将为-1,这意味着该数字显然是合成的。好吧,当然,我们正在处理n <= 5的特例。

现在,实际上,该算法本身几乎重复了Eratosthenes的筛选:

    public Wheel235(long length)
    {
        length = (length + 29) / 30 * 30;
        Data = new byte[length / 30];
        for (long i = 0; i < Data.Length; i++) Data[i] = byte.MaxValue;

        for (long i = 7; i * i < Length; i++)
        {
            if (!IsPrime(i)) continue;
            for (long d = i * i; d < Length; d += i) ClearPrime(d);
        }
    }

因此,整个结果类:

看起来像
class Wheel235
{
    private static int[] BIT_TO_INDEX = new int[] { 1, 7, 11, 13, 17, 19, 23, 29 };

    private static int[] INDEX_TO_BIT = new int[] {
        -1, 0,
        -1, -1, -1, -1, -1, 1,
        -1, -1, -1, 2,
        -1, 3,
        -1, -1, -1, 4,
        -1, 5,
        -1, -1, -1, 6,
        -1, -1, -1, -1, -1, 7,
    };

    private byte[] Data;

    public Wheel235(long length)
    {
        // ensure length divides by 30
        length = (length + 29) / 30 * 30;
        Data = new byte[length / 30];
        for (long i = 0; i < Data.Length; i++) Data[i] = byte.MaxValue;

        for (long i = 7; i * i < Length; i++)
        {
            if (!IsPrime(i)) continue;
            for (long d = i * i; d < Length; d += i) ClearPrime(d);
        }
    }

    public long Length => Data.Length * 30L;

    public bool IsPrime(long n)
    {
        if (n >= Length) throw new ArgumentException("Number too big");
        if (n <= 5) return n == 2 || n == 3 || n == 5;
        int bit = INDEX_TO_BIT[n % 30];
        if (bit < 0) return false;
        return (Data[n / 30] & (1 << bit)) != 0;
    }

    private void ClearPrime(long n)
    {
        int bit = INDEX_TO_BIT[n % 30];
        if (bit < 0) return;
        Data[n / 30] &= (byte)~(1 << bit);
    }
}


仅存在一个小问题:此代码不适用于大于约650亿的数字!当您尝试使用此类数字启动它时,程序崩溃并显示错误:

Unhandled Exception: System.OverflowException: Array dimensions exceeded supported range.

问题在于,在c#数组中,元素不能超过2 ^ 31个(显然是因为内部实现使用四字节数组索引)。例如,一个选择是创建一个长[]数组,而不是一个字节[]数组,但这会使位算术有点复杂。为简单起见,我们将采用另一种方法,即创建一个模拟所需数组的特殊类,其中包含两个短数组。同时,我们将给他机会将自己保存到磁盘,以便您可以一次计算素数,然后再次使用数据库:

    class LongArray
    {
        const long MAX_CHUNK_LENGTH = 2_000_000_000L;
        private byte[] firstBuffer;
        private byte[] secondBuffer;

        public LongArray(long length)
        {
            firstBuffer = new byte[Math.Min(MAX_CHUNK_LENGTH, length)];
            if (length > MAX_CHUNK_LENGTH) secondBuffer = new byte[length - MAX_CHUNK_LENGTH];
        }

        public LongArray(string file)
        {
            ...
        }

        public long Length => firstBuffer.LongLength + (secondBuffer == null ? 0 : secondBuffer.LongLength);

        public byte this[long index]
        {
            get => index < MAX_CHUNK_LENGTH ? firstBuffer[index] : secondBuffer[index - MAX_CHUNK_LENGTH];
            set
            {
                if (index < MAX_CHUNK_LENGTH) firstBuffer[index] = value; 
                else secondBuffer[index - MAX_CHUNK_LENGTH] = value;
            }
        }

        public void Save(string file)
        {
            ...
        }

    }


最后,结合所有步骤

算法的最终版本
class Wheel235
{
    class LongArray
    {
        const long MAX_CHUNK_LENGTH = 2_000_000_000L;
        private byte[] firstBuffer;
        private byte[] secondBuffer;

        public LongArray(long length)
        {
            firstBuffer = new byte[Math.Min(MAX_CHUNK_LENGTH, length)];
            if (length > MAX_CHUNK_LENGTH) secondBuffer = new byte[length - MAX_CHUNK_LENGTH];
        }

        public LongArray(string file)
        {
            using(FileStream stream = File.OpenRead(file))
            {
                long length = stream.Length;
                firstBuffer = new byte[Math.Min(MAX_CHUNK_LENGTH, length)];
                if (length > MAX_CHUNK_LENGTH) secondBuffer = new byte[length - MAX_CHUNK_LENGTH];
                stream.Read(firstBuffer, 0, firstBuffer.Length);
                if(secondBuffer != null) stream.Read(secondBuffer, 0, secondBuffer.Length);
            }
        }

        public long Length => firstBuffer.LongLength + (secondBuffer == null ? 0 : secondBuffer.LongLength);

        public byte this[long index]
        {
            get => index < MAX_CHUNK_LENGTH ? firstBuffer[index] : secondBuffer[index - MAX_CHUNK_LENGTH];
            set
            {
                if (index < MAX_CHUNK_LENGTH) firstBuffer[index] = value; 
                else secondBuffer[index - MAX_CHUNK_LENGTH] = value;
            }
        }

        public void Save(string file)
        {
            using(FileStream stream = File.OpenWrite(file))
            {
                stream.Write(firstBuffer, 0, firstBuffer.Length);
                if (secondBuffer != null) stream.Write(secondBuffer, 0, secondBuffer.Length);
            }
        }

    }

    private static int[] BIT_TO_INDEX = new int[] { 1, 7, 11, 13, 17, 19, 23, 29 };

    private static int[] INDEX_TO_BIT = new int[] {
        -1, 0,
        -1, -1, -1, -1, -1, 1,
        -1, -1, -1, 2,
        -1, 3,
        -1, -1, -1, 4,
        -1, 5,
        -1, -1, -1, 6,
        -1, -1, -1, -1, -1, 7,
    };

    private LongArray Data;

    public Wheel235(long length)
    {
        // ensure length divides by 30
        length = (length + 29) / 30 * 30;
        Data = new LongArray(length / 30);
        for (long i = 0; i < Data.Length; i++) Data[i] = byte.MaxValue;

        for (long i = 7; i * i < Length; i++)
        {
            if (!IsPrime(i)) continue;
            for (long d = i * i; d < Length; d += i) ClearPrime(d);
        }
    }

    public Wheel235(string file)
    {
        Data = new LongArray(file);
    }

    public void Save(string file) => Data.Save(file);

    public long Length => Data.Length * 30L;

    public bool IsPrime(long n)
    {
        if (n >= Length) throw new ArgumentException("Number too big");
        if (n <= 5) return n == 2 || n == 3 || n == 5;
        int bit = INDEX_TO_BIT[n % 30];
        if (bit < 0) return false;
        return (Data[n / 30] & (1 << bit)) != 0;
    }

    private void ClearPrime(long n)
    {
        int bit = INDEX_TO_BIT[n % 30];
        if (bit < 0) return;
        Data[n / 30] &= (byte)~(1 << bit);
    }

}


结果,我们得到一个可以计算素数不超过1000亿并将结果保存到磁盘的类(我在笔记本电脑上将这段代码保存了50分钟;所创建文件的大小约为3 GB,与预期的一样):

        long N = 100_000_000_000L;
        Wheel235 wheel = new Wheel235(N);
        wheel.Save("./primes.dat");

然后从磁盘读取并使用:

        DateTime start = DateTime.UtcNow;
        Wheel235 loaded = new Wheel235("./primes.dat");
        DateTime end = DateTime.UtcNow;
        Console.WriteLine($"Database of prime numbers up to {loaded.Length:N0} loaded from file to memory  in {end - start}");
        long number = 98_000_000_093L;
        Console.WriteLine($"{number:N0} is {(loaded.IsPrime(number) ? "prime" : "not prime")}!");

All Articles