Перейти до основного вмісту

Решето Ератосфена

Решето Ератосфена — це алгоритм для знаходження всіх простих чисел на відрізку [1;n][1;n] за O(nlog⁡log⁡n)O(n \log \log n) операцій.

Алгоритм дуже простий: на початку ми виписуємо всі числа від 2 до nn. Ми позначаємо всі власні кратні числа 2 (оскільки 2 — найменше просте число) як складені. Власне кратне числа xx — це число, більше за xx і таке, що ділиться на xx. Далі ми знаходимо наступне число, яке ще не позначене як складене, у цьому випадку це 3. Це означає, що 3 — просте, і ми позначаємо всі власні кратні числа 3 як складені. Наступне непозначене число — 5, це наступне просте число, і ми позначаємо всі його власні кратні. І ми продовжуємо цю процедуру, доки не опрацюємо всі числа в ряду.

На зображенні нижче ви можете побачити візуалізацію алгоритму для обчислення всіх простих чисел у діапазоні [1;16][1; 16]. Можна побачити, що досить часто ми позначаємо числа як складені кілька разів.

Sieve of Eratosthenes

Ідея, що стоїть за цим, така: число є простим, якщо жодне з менших простих чисел його не ділить. Оскільки ми перебираємо прості числа по порядку, ми вже позначили всі числа, які діляться щонайменше на одне з простих чисел, як такі, що діляться. Отже, якщо ми досягаємо клітинки і вона не позначена, то вона не ділиться на жодне менше просте число, а тому має бути простою.

Коли підходить цей алгоритм?
  • Чи потрібні всі прості числа (або ознака простоти) на відрізку [1;n][1; n] одразу, а не перевірка окремого числа? (якщо перевіряєте одне велике число → Тести простоти)
  • Чи межа nn вміщується в пам'ять (класичне решето — nn бітів, орієнтовно до n≈107 ⁣− ⁣108n \approx 10^7\!-\!10^8)? Для більших меж — сегментоване решето з цієї ж статті.
  • Чи потрібен також найменший простий дільник кожного числа для подальшої факторизації? Тоді розгляньте лінійне решето.

Реалізація​

int n;
vector<bool> is_prime(n+1, true);
is_prime[0] = is_prime[1] = false;
for (int i = 2; i <= n; i++) {
if (is_prime[i] && (long long)i * i <= n) {
for (int j = i * i; j <= n; j += i)
is_prime[j] = false;
}
}

Цей код спочатку позначає всі числа, крім нуля та одиниці, як потенційні прості числа, а потім починає процес просіювання складених чисел. Для цього він перебирає всі числа від 22 до nn. Якщо поточне число ii є простим, він позначає всі числа, кратні ii, як складені, починаючи з i2i^2. Це вже оптимізація порівняно з наївним способом реалізації, і вона допустима, оскільки всі менші числа, кратні ii, обов'язково також мають простий множник, менший за ii, тож усі вони вже були просіяні раніше. Оскільки i2i^2 легко може переповнити тип int, перед другим вкладеним циклом виконується додаткова перевірка з використанням типу long long.

При такій реалізації алгоритм споживає O(n)O(n) пам'яті (очевидно) і виконує O(nlog⁡log⁡n)O(n \log \log n) операцій (див. наступний розділ).

Асимптотичний аналіз​

Час роботи O(nlog⁡n)O(n \log n) легко довести, не знаючи нічого про розподіл простих чисел: якщо знехтувати перевіркою is_prime, внутрішній цикл виконується (щонайбільше) n/in/i разів для i=2,3,4,…i = 2, 3, 4, \dots, через що загальна кількість операцій у внутрішньому циклі утворює гармонічну суму на кшталт n(1/2+1/3+1/4+⋯ )n(1/2 + 1/3 + 1/4 + \cdots), яка обмежена O(nlog⁡n)O(n \log n).

Доведемо, що час роботи алгоритму становить O(nlog⁡log⁡n)O(n \log \log n). Алгоритм виконає np\frac{n}{p} операцій у внутрішньому циклі для кожного простого p≤np \le n. Отже, нам потрібно оцінити такий вираз:

∑p≤n, p primenp=n⋅∑p≤n, p prime1p.\sum_{\substack{p \le n, \\\ p \text{ prime}}} \frac n p = n \cdot \sum_{\substack{p \le n, \\\ p \text{ prime}}} \frac 1 p.

Згадаймо два відомих факти.

  • Кількість простих чисел, менших або рівних nn, приблизно дорівнює nln⁡n\frac n {\ln n}.
  • kk-те просте число приблизно дорівнює kln⁡kk \ln k (це випливає з попереднього факту).

Таким чином, ми можемо записати суму так:

∑p≤n, p prime1p≈12+∑k=2nln⁡n1kln⁡k.\sum_{\substack{p \le n, \\\ p \text{ prime}}} \frac 1 p \approx \frac 1 2 + \sum_{k = 2}^{\frac n {\ln n}} \frac 1 {k \ln k}.

Тут ми виокремили перше просте число 2 із суми, бо для k=1k = 1 наближення kln⁡kk \ln k дорівнює 00 і спричиняє ділення на нуль.

Тепер оцінимо цю суму за допомогою інтеграла тієї самої функції по kk від 22 до nln⁡n\frac n {\ln n} (ми можемо зробити таке наближення, бо насправді сума пов'язана з інтегралом як його наближення методом прямокутників):

∑k=2nln⁡n1kln⁡k≈∫2nln⁡n1kln⁡kdk.\sum_{k = 2}^{\frac n {\ln n}} \frac 1 {k \ln k} \approx \int_2^{\frac n {\ln n}} \frac 1 {k \ln k} dk.

Первісною для підінтегральної функції є ln⁡ln⁡k\ln \ln k. Застосувавши заміну та відкинувши члени нижчого порядку, отримаємо результат:

∫2nln⁡n1kln⁡kdk=ln⁡ln⁡nln⁡n−ln⁡ln⁡2=ln⁡(ln⁡n−ln⁡ln⁡n)−ln⁡ln⁡2≈ln⁡ln⁡n.\int_2^{\frac n {\ln n}} \frac 1 {k \ln k} dk = \ln \ln \frac n {\ln n} - \ln \ln 2 = \ln(\ln n - \ln \ln n) - \ln \ln 2 \approx \ln \ln n.

Тепер, повертаючись до початкової суми, отримаємо її приблизну оцінку:

∑p≤n, p is primenp≈nln⁡ln⁡n+o(n).\sum_{\substack{p \le n, \\\ p\ is\ prime}} \frac n p \approx n \ln \ln n + o(n).

Суворіше доведення (яке дає точнішу оцінку з точністю до сталих множників) можна знайти в книзі Hardy & Wright «An Introduction to the Theory of Numbers» (с. 349).

Різні оптимізації решета Ератосфена​

Найбільша слабкість алгоритму в тому, що він «прогулюється» по пам'яті кілька разів, маніпулюючи лише окремими елементами. Це не дуже дружнє до кешу. І через це стала, прихована в O(nlog⁡log⁡n)O(n \log \log n), є порівняно великою.

Окрім того, спожита пам'ять є вузьким місцем для великих nn.

Наведені нижче методи дозволяють нам зменшити кількість виконуваних операцій, а також помітно скоротити спожиту пам'ять.

Просіювання до кореня​

Очевидно, щоб знайти всі прості числа до nn, достатньо виконати просіювання лише за простими числами, які не перевищують кореня з nn.

int n;
vector<bool> is_prime(n+1, true);
is_prime[0] = is_prime[1] = false;
for (int i = 2; i * i <= n; i++) {
if (is_prime[i]) {
for (int j = i * i; j <= n; j += i)
is_prime[j] = false;
}
}

Така оптимізація не впливає на складність (справді, повторивши наведене вище доведення, отримаємо оцінку nln⁡ln⁡n+o(n)n \ln \ln \sqrt n + o(n), яка асимптотично така сама за властивостями логарифмів), хоча кількість операцій помітно зменшиться.

Просіювання лише за непарними числами​

Оскільки всі парні числа (крім 22) складені, ми можемо взагалі припинити перевірку парних чисел. Натомість нам потрібно оперувати лише непарними числами.

По-перше, це дозволить нам удвічі зменшити потрібну пам'ять. По-друге, це приблизно вдвічі скоротить кількість операцій, виконуваних алгоритмом.

Споживання пам'яті та швидкість операцій​

Слід зауважити, що ці дві реалізації решета Ератосфена використовують nn бітів пам'яті за допомогою структури даних vector<bool>. vector<bool> — це не звичайний контейнер, що зберігає послідовність значень bool (бо в більшості комп'ютерних архітектур bool займає один байт пам'яті). Це спеціалізація vector<T>, оптимізована за пам'яттю, яка споживає лише N8\frac{N}{8} байтів пам'яті.

Сучасні архітектури процесорів працюють набагато ефективніше з байтами, ніж з бітами, оскільки зазвичай не можуть звертатися до бітів безпосередньо. Тож «під капотом» vector<bool> зберігає біти у великому неперервному блоці пам'яті, звертається до пам'яті блоками по кілька байтів і витягує/встановлює біти за допомогою бітових операцій, таких як бітові маски та бітові зсуви.

Через це є певні накладні витрати, коли ви читаєте чи записуєте біти у vector<bool>, і досить часто використання vector<char> (який використовує 1 байт на кожен запис, тобто у 8 разів більше пам'яті) є швидшим.

Однак для простих реалізацій решета Ератосфена використання vector<bool> є швидшим. Ви обмежені тим, наскільки швидко можете завантажити дані в кеш, а тому використання меншого обсягу пам'яті дає велику перевагу. Замір швидкодії (посилання) показує, що використання vector<bool> у 1.4–1.7 раза швидше, ніж використання vector<char>.

Ті самі міркування стосуються й bitset. Це теж ефективний спосіб зберігання бітів, подібний до vector<bool>, тож він займає лише N8\frac{N}{8} байтів пам'яті, але дещо повільніший у доступі до елементів. У наведеному вище замірі bitset показує себе трохи гірше за vector<bool>. Ще один недолік bitset полягає в тому, що його розмір потрібно знати на етапі компіляції.

Блочне решето​

З оптимізації «просіювання до кореня» випливає, що немає потреби постійно тримати весь масив is_prime[1...n]. Для просіювання достатньо тримати лише прості числа до кореня з nn, тобто prime[1... sqrt(n)], розбити весь діапазон на блоки та просіювати кожен блок окремо.

Нехай ss — стала, яка визначає розмір блоку, тоді всього в нас ⌈ns⌉\lceil {\frac n s} \rceil блоків, і блок kk (k=0...⌊ns⌋k = 0 ... \lfloor {\frac n s} \rfloor) містить числа з відрізка [ks;ks+s−1][ks; ks + s - 1]. Ми можемо опрацьовувати блоки по черзі, тобто для кожного блоку kk ми пройдемо по всіх простих числах (від 11 до n\sqrt n) і виконаємо просіювання за їх допомогою. Варто зауважити, що нам доведеться трохи змінити стратегію при опрацюванні перших чисел: по-перше, усі прості числа з [1;n][1; \sqrt n] не повинні викреслювати самі себе; і по-друге, числа 00 та 11 слід позначити як непрості. Працюючи з останнім блоком, не слід забувати, що останнє потрібне число nn не обов'язково розташоване в кінці блоку.

Як обговорювалося раніше, типова реалізація решета Ератосфена обмежена тим, наскільки швидко можна завантажувати дані в кеші процесора. Розбиваючи діапазон потенційних простих чисел [1;n][1; n] на менші блоки, нам ніколи не доводиться тримати кілька блоків у пам'яті одночасно, і всі операції стають значно дружнішими до кешу. Оскільки тепер ми більше не обмежені швидкістю кешу, ми можемо замінити vector<bool> на vector<char> і отримати додаткову продуктивність, бо процесори можуть напряму обробляти читання та записи байтів і не мусять покладатися на бітові операції для витягування окремих бітів. Замір швидкодії (посилання) показує, що використання vector<char> у цій ситуації приблизно у 3 рази швидше, ніж використання vector<bool>. Слово застереження: ці числа можуть відрізнятися залежно від архітектури, компілятора та рівнів оптимізації.

Нижче наведено реалізацію, яка підраховує кількість простих чисел, менших або рівних nn, за допомогою блочного просіювання.

int count_primes(int n) {
const int S = 10000;

vector<int> primes;
int nsqrt = sqrt(n);
vector<char> is_prime(nsqrt + 2, true);
for (int i = 2; i <= nsqrt; i++) {
if (is_prime[i]) {
primes.push_back(i);
for (int j = i * i; j <= nsqrt; j += i)
is_prime[j] = false;
}
}

int result = 0;
vector<char> block(S);
for (int k = 0; k * S <= n; k++) {
fill(block.begin(), block.end(), true);
int start = k * S;
for (int p : primes) {
int start_idx = (start + p - 1) / p;
int j = max(start_idx, p) * p - start;
for (; j < S; j += p)
block[j] = false;
}
if (k == 0)
block[0] = block[1] = false;
for (int i = 0; i < S && start + i <= n; i++) {
if (block[i])
result++;
}
}
return result;
}

Час роботи блочного просіювання такий самий, як у звичайного решета Ератосфена (якщо розмір блоків не дуже малий), але потрібна пам'ять скоротиться до O(n+S)O(\sqrt{n} + S), і ми отримаємо кращі результати кешування. З іншого боку, на кожну пару «блок — просте число з [1;n][1; \sqrt{n}]» припадатиме одне ділення, і це буде набагато гірше для менших розмірів блоків. Отже, при виборі сталої SS необхідно дотримуватися балансу. Найкращих результатів ми досягли для розмірів блоків між 10410^4 та 10510^5.

Знаходження простих чисел на відрізку​

Іноді нам потрібно знайти всі прості числа на відрізку [L,R][L,R] малого розміру (наприклад, R−L+1≈1e7R - L + 1 \approx 1e7), де RR може бути дуже великим (наприклад, 1e121e12).

Щоб розв'язати таку задачу, ми можемо скористатися ідеєю блочного решета. Ми заздалегідь генеруємо всі прості числа до R\sqrt R і використовуємо ці прості числа, щоб позначити всі складені числа на відрізку [L,R][L, R].

vector<char> segmentedSieve(long long L, long long R) {
// генеруємо всі прості числа до sqrt(R)
long long lim = sqrt(R);
vector<char> mark(lim + 1, false);
vector<long long> primes;
for (long long i = 2; i <= lim; ++i) {
if (!mark[i]) {
primes.emplace_back(i);
for (long long j = i * i; j <= lim; j += i)
mark[j] = true;
}
}

vector<char> isPrime(R - L + 1, true);
for (long long i : primes)
for (long long j = max(i * i, (L + i - 1) / i * i); j <= R; j += i)
isPrime[j - L] = false;
if (L == 1)
isPrime[0] = false;
return isPrime;
}

Часова складність цього підходу становить O((R−L+1)log⁡log⁡(R)+Rlog⁡log⁡R)O((R - L + 1) \log \log (R) + \sqrt R \log \log \sqrt R).

Також можливо обійтися без попередньої генерації всіх простих чисел:

vector<char> segmentedSieveNoPreGen(long long L, long long R) {
vector<char> isPrime(R - L + 1, true);
long long lim = sqrt(R);
for (long long i = 2; i <= lim; ++i)
for (long long j = max(i * i, (L + i - 1) / i * i); j <= R; j += i)
isPrime[j - L] = false;
if (L == 1)
isPrime[0] = false;
return isPrime;
}

Очевидно, складність гірша й становить O((R−L+1)log⁡(R)+R)O((R - L + 1) \log (R) + \sqrt R). Проте на практиці це все одно працює дуже швидко.

Модифікація з лінійним часом роботи​

Ми можемо модифікувати алгоритм у такий спосіб, щоб він мав лише лінійну часову складність. Цей підхід описано в статті Лінійне решето. Однак цей алгоритм також має свої слабкі сторони.

Задачі для практики​

Відеоматеріали​