公衆號關注 “GitHubDaily”
設爲 “星標”,每天帶你學編程!
我們都知道在數學領域,素數非常重要,有海量的公式和研究關於素數,比如那個非常著名至今沒有人解出來的哥德巴赫猜想。
和數學領域一樣,素數在信息領域也非常重要,有着大量的應用。
舉個簡單的例子,很多安全 加密算法 也是利用的質數。
我們想要利用素數去進行各種計算之前,總是要先找到素數。
所以這就有了一個最簡單也最不簡單的問題,我們怎麼樣來尋找素數呢?
一、判斷素數
尋找素數最樸素的方法當然是一個一個遍歷,我們依次遍歷每一個數,然後分別判斷是否是素數。
所以問題的核心又回到了判斷素數上,那麼怎麼判斷一個數是不是素數呢?
素數的性質只有一個,就是只有 1 和它本身這兩個因數,我們要判斷素數也只能利用這個性質。
所以可以想到,假如我們要判斷 n 是否是素數,可以從 2 開始遍歷到 n - 1 ,如果這 n - 1 個數都不能整除 n ,那麼說明 n 就是素數。這個我沒記錯在 C 語言的練習題當中出現過,總之非常簡單,可以說是最簡單的算法了。
def is_prime(n):
for i in range(2, n):
if n % i == 0:
return False
return n != 1
顯然,這個算法是可以優化的。
比如當 n 是偶數的時候,我們根本不需要循環,除了 2 以外的偶數一定是合數。
再比如,我們循環的上界其實也沒有必要到 n - 1 ,到就可以了。因爲因數如果存在一定是成對出現的,如果存在小於根號 n 的因數,那麼 n 除以它一定大於根號 n 。
這個改進也很簡單,稍作改動即可:
def is_prime(n):
if n % 2 == 0 and n != 2:
return False
for i in range(3, int(math.sqrt(n) + 1)):
if n % i == 0:
return False
return n != 1
這樣我們把 O (n) 的算法優化到了 O (sqrt (n)) 也算是有了很大的改進了,但是還沒有結束,我們還可以繼續優化。
數學上有一個定理,只有形如 6n - 1 和 6n + 1 的自然數可能是素數,這裏的 n 是大於等於 1 的整數。
這個定理乍一看好像很高級,但其實很簡單,因爲所有自然數都可以寫成 6n,6n + 1,6n + 2 ,6n + 3 ,6n + 4 ,6n + 5 這 6 種,其中 6n ,6n + 2,6n + 4 是偶數,一定不是素數。6n + 3 可以寫成 3 (2n + 1) ,顯然也不是素數,所以只有可能 6n + 1 和 6n + 5 可能是素數。6n + 5 等價於 6n-1,所以我們一般寫成 6n - 1 和 6n + 1 。
利用這個定理,我們的代碼可以進一步優化:
def is_prime(n):
if n % 6 not in (1, 5) and n not in (2, 3):
return False
for i in range(3, int(math.sqrt(n) + 1)):
if n % i == 0:
return False
return n != 1
雖然這樣已經很快了,但仍然不是最優的,尤其是當我們需要尋找大量素數的時候,仍會消耗大量的時間。那麼有沒有什麼辦法可以批量查找素數呢?
有,這個方法叫做埃拉託斯特尼算法。
這個名字念起來非常拗口,這是一個古希臘的名字。此人是個古希臘的大牛,是大名鼎鼎的 阿基米德 的好友。
他雖然沒有阿基米德那麼出名,但是也非常非常厲害,在數學、天文學、地理學、文學、歷史學等多個領域都有建樹,並且還自創方法測量了地球直徑、地月距離、地日距離以及黃赤交角等諸多數值。
要知道他生活的年代是兩千五百多年前,那時候中國還是春秋戰國時期,可以想見此人有多厲害。
二、埃式篩法
我們今天要介紹的埃拉託斯特尼算法就是他發明的用來篩選素數的方法,爲了方便我們一般簡稱爲埃式篩法或者篩法。
埃式篩法的思路非常簡單,就是用已經篩選出來的素數去過濾所有能夠被它整除的數。這些素數就像是篩子一樣去過濾自然數,最後被篩剩下的數自然就是不能被前面素數整除的數,根據素數的定義,這些剩下的數也是素數。
舉個例子,比如我們要篩選出 100 以內的所有素數,我們知道 2 是最小的素數,我們先用 2 可以篩掉所有的偶數。然後往後遍歷到 3 ,3 是被 2 篩剩下的第一個數,也是素數,我們再用 3 去篩除所有能被 3 整除的數。
篩完之後我們繼續往後遍歷,第一個遇到的數是 7 ,所以 7 也是素數,我們再重複以上的過程,直到遍歷結束爲止。結束的時候,我們就獲得了 100 以內的所有素數。
如果還不太明白,可以看下面這張動圖,非常清楚地還原了這整個過程。
這個思想非常簡單,理解了之後寫出代碼來真的很容易:
def eratosthenes(n):
primes = []
is_prime = [True] * (n + 1)
for i in range(2, n+1):
if is_prime[i]:
primes.append(i)
# 用當前素數i去篩掉所有能被它整除的數
for j in range(i * 2, n+1, i):
is_prime[j] = False
return primes
我們來分析一下篩法的複雜度,從代碼當中我們可以看到,我們一共有了兩層循環,最外面一層循環固定是遍歷 n 次。
而裏面的這一層循環遍歷的次數一直在變化,並且它的運算次數和素數的大小相關,看起來似乎不太方便計算。
實際上是可以的,根據素數分佈定理以及一系列複雜的運算(相信我,你們不會感興趣的),我們是可以得出篩法的複雜度是 O (N ln ln N)。
三、極致優化
篩法的複雜度已經非常近似 O (n) 了,因爲即使在 n 很大的時候,經過兩次 ln 的計算,也非常近似常數了,實際上在絕大多數使用場景當中,上面的算法已經足夠應用了。
但是仍然有大牛不知滿足,繼續對算法做出了優化,將其優化到了的複雜度 O (n)。
雖然從效率上來看並沒有數量級的提升,但是應用到的思想非常巧妙,值得我們學習。
在我們理解這個優化之前,先來看看之前的篩法還有什麼可以優化的地方。
比較明顯地可以看出來,對於一個合數而言,它可能會被多個素數篩去。比如 38 ,它有 2 和 19 這兩個素因數,那麼它就會被置爲兩次 False ,這就帶來了額外的開銷,如果對於每一個合數我們只更新一次,那麼是不是就能優化到了呢?
怎麼樣保證每個合數只被更新一次呢?這裏要用到一個定理,就是每個合數分解質因數只有的結果是唯一的。
既然是唯一的,那麼一定可以找到最小的質因數,如果我們能夠保證一個合數只會被它最小的質因數更新爲 False ,那麼整個優化就完成了。
那我們具體怎麼做呢?
其實也不難,我們假設整數 n 的最小質因數是 m ,那麼我們用小於 m 的素數 i 乘上 n 可以得到一個合數。
我們將這個合數消除,對於這個合數而言,i 一定是它最小的質因數。因爲它等於 i * n ,n 最小的質因數是 m , i 又小於 m ,所以 i 是它最小的質因數,我們用這樣的方法來生成消除的合數,這樣來保證每個合數只會被它最小的質因數消除。
根據這一點,我們可以寫出新的代碼:
def ertosthenes(n):
primes = []
is_prime = [True] * (n+1)
for i in range(2, n+1):
if is_prime[i]:
primes.append(i)
for j, p in enumerate(primes):
# 防止越界
if p > n // i:
break
# 過濾
is_prime[i * p] = False
# 當i % p等於0的時候說明p就是i最小的質因數
if i % p == 0:
break
return primes
四、總結
到這裏,我們關於埃式篩法的介紹就告一段落了。
埃式篩法的優化版本相對來說要難以記憶一些,如果記不住的話,可以就只使用優化之前的版本,兩者的效率相差並不大,完全在可以接受的範圍之內。
篩法看着代碼非常簡單,但是非常重要,有了它,我們就可以在短時間內獲得大量的素數,快速地獲得一個素數表。
有了素數表之後,很多問題就簡單許多了,比如因數分解的問題,比如信息加密的問題等等。我每次回顧篩法算法的時候都會忍不住感慨,這個兩千多年前被髮明出來的算法至今看來非但不過時,仍然還是那麼巧妙。
希望大家都能懷着崇敬的心情,理解算法當中的精髓。
---
由 GitHubDaily 原班人馬打造的公衆號:GitCube,現已正式上線!
接下來我們將會在該公衆號上,爲大家分享優質的計算機學習資源與開發者工具,堅持每天一篇原創文章的輸出,感興趣的小夥伴可以關注一下哈!