素數篩 : Eratosthenes 篩法, 線性篩法

roccoshi發表於2020-06-15

這是兩種簡單的素數篩法, 好不容易理解了以後寫篇部落格加深下記憶

首先, 這兩種演算法用於解決的問題是 :

求小於n的所有素數 ( 個數 )

比如 這道題

在不瞭解這兩個素數篩演算法的同學, 可能會這麼寫一個isPrime, 然後遍歷每一個數, 挨個判斷 :

  1. 從2判斷到n-1
bool isPrime(int n) {
    for (int i = 2; i < n; i++)
        if (n % i == 0)
            return false;
    return true;
}
  1. 從2判斷到\(\sqrt{n}\)
bool isPrime(int n) {
    for (int i = 2; i <= sqrt(n); i++)
        if (n % i == 0)
            return false;
    return true;
}

然後再加上個count函式 :

int countPrimes2(int n) {
	int cnt = 0;
 	for(int i = 2; i < n; ++i) {
 		if(isPrime(i)) cnt++;
 	}
 	return  cnt;
}

這兩種演算法, 一個時間複雜度$ O(n^2) $ , 另一個 $ O(n \sqrt{n}) $ , 但凡出現這樣型別的題, 這麼寫一般都超時了

下面介紹第一種演算法

Eratosthenes 篩法 (厄拉多塞篩法)

核心思想 : 對於每一個素數, 它的倍數必定不是素數

我們通過直接標記, 可以大大減少操作量

比如從2開始遍歷, 則4, 6, 8, 10, 12, 14....都不是素數,

然後是3, 則3, 6, 9, 12, 15.....都不是素數

如圖更加直觀 :

我們可以在判斷小於等於數字n的時候增加一個vis陣列, 他的大小等於n, 預設vis初始化為全零, 假設2-n都是素數, 接下來每遇到一個素數i, 把2-n中vis[i的倍數] = 1, 在接下來遇到的時候直接跳過就可以.

時間複雜度為 : $ O(nloglogn) $

程式碼 :

 int countPrimes(int n) {
        vector<int> vis(n, 0); // all number : 0 means prime, 1 means not prime
        vector<int> prime(n, 0);
        int cnt = 0;
        for (int i = 2; i < n; ++i) {
            if (!vis[i]) {  // 如果vis[i]未被標記, 表明i是素數
                prime[++cnt] = i;
            	for (int j = i*2; j < n; j += i) {
                	vis[j] = 1; // 標記所有i的倍數
            	}
            }
        }
        return cnt;
    }

線性篩作為對Eratosthenes篩的改進, 能更大程度的減少時間複雜度:

O(n)的篩法----線性篩 ( 尤拉篩 )

在講這個篩法之前, 明確一個概念 :

每一個合數 ( 除了1和它本身以外,還能被其他正整數整除 ), 都可以表示成n個素數的乘積

\[\boldsymbol{X}=\boldsymbol{p}_1\times \boldsymbol{p}_2\times \boldsymbol{p}_3\times \boldsymbol{p}_4\times ...\times \boldsymbol{p}_{\boldsymbol{i}}\,\,, \boldsymbol{X}為\text{合數}, \boldsymbol{p}為\text{素數} \]

在Eratosthenes篩中, 我們可以發現, 在排除每一個質數的倍數時, 會有很多重複的操作 :

比如30 = 2x3x5, 那麼30將在對2,3,5倍數標記的時候反覆被處理3次

我們可以思考, 是否有一種方法, 可以僅僅將一個合數標記一次, 就可以打到篩選的目的呢 ?

可以設立一種規則 :

一個合數只能被他的最小素數因子篩去

比如30, 雖然他有2,3,5三個素因子, 我們只讓在對2的倍數作標記時標記30

這樣做, 可以保證我們對每一個數都僅僅只操作了一次, 時間複雜度也就變成了喜聞樂見的O(n) !

具體怎樣實現 ?

我們在遍歷2-n的過程中, 對每一個數i , 從當前已經找到的素數集中從2開始列出prime[j], 當什麼時候有:

\[i\,\,\% \,prime\left[ j \right] ==\,\,0 \]

那麼可以說明i含有prime[j]這個素因數, 也就是說, 對於比目前 i x prime[j]更大的數, prime[j]將不再是它的最小素因數, 因此到此這一輪篩可以停止了.

舉個例子 :

當 i = 9 時 :

我們可以篩出 :

9 x 2 = 18 ---- 然後進行判斷 9%2 != 0, 因此繼續篩

9 x 3 = 27 ---- 然後進行判斷 9%3 ==0, 因此break

假設我們繼續篩 9 x 5, 會發生什麼?

由於9 % 3 == 0了, 則可以知道 9含有3這個素因數, 如果繼續篩9x5, 可以知道5並不是9x5的最小素因數, 因此不應當在這一輪被篩去.

程式碼 :

int countPrimes(int n) {
        vector<int> vis(n, 0);  // all number : 0 means prime, 1 means not prime
        vector<int> prime(n, 0); 
        int cnt = 0;
        for (int i = 2; i < n; ++i) {
            if (!vis[i])
                prime[cnt++] = i;
            for(int j = 0; j < cnt && i * prime[j] < n; ++j)
            {
                vis[i*prime[j]] = 1;
                if (i % prime[j] == 0)  // if prime[j] is i*prime[j]'s minimum prime
                    break;
            }
        }
        return cnt;        
    }

時間比較

寫了個cpp計時, 來比較一下這幾種演算法的時間吧~

1592209327664

可以看到, Eratosthenes和線性篩的時間稍有區別, 加大資料量會放大這個區別, 但是麻瓜篩\(O(n^2)\) 在n=7000000的時候已經10s+了, 再加大資料量估計得等到明天了

在寫題的這段日子, 每天都在看別人程式碼的臥槽聲中度過


ref :

OI - wiki 篩法 : https://oi-wiki.org/math/sieve/

線性篩法求素數的原理與實現 : https://wenku.baidu.com/view/4881881daaea998fcc220e99.html

leetcode題解 : https://leetcode-cn.com/problems/count-primes/solution/

相關文章