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

Лінійне решето

Дано число nn; знайти всі прості числа на відрізку [2;n][2;n].

Стандартний спосіб розв'язати цю задачу — скористатися решетом Ератосфена. Цей алгоритм дуже простий, але має час роботи O(nloglogn)O(n \log \log n).

Хоча відомо чимало алгоритмів із сублінійним часом роботи (тобто o(n)o(n)), описаний нижче алгоритм цікавий своєю простотою: він не складніший за класичне решето Ератосфена.

До того ж наведений тут алгоритм як побічний ефект обчислює розклади на множники всіх чисел на відрізку [2;n][2; n], що може стати в пригоді в багатьох практичних застосуваннях.

Слабке місце цього алгоритму — він використовує більше пам'яті, ніж класичне решето Ератосфена: йому потрібен масив із nn чисел, тоді як класичному решету Ератосфена достатньо nn бітів пам'яті (що у 32 рази менше).

Тому має сенс застосовувати описаний алгоритм лише для чисел порядку 10710^7 і не більших.

Алгоритм належить Полу Прітчарду (Paul Pritchard). Це варіант Algorithm 3.3 із (Pritchard, 1987: див. список літератури в кінці статті).

Коли підходить цей алгоритм?
  • Чи потрібен як побічний продукт найменший простий дільник lp[i]lp[i] кожного числа (для швидкої факторизації)? Якщо потрібен лише список простих — вистачить простішого решета Ератосфена.
  • Чи n107n \lesssim 10^7? Алгоритм тримає масив із nn int, а не nn бітів, тож для більших nn пам'яті не вистачить — беріть решето Ератосфена.
  • Чи важлива гарантія, що кожне число викреслюється рівно один раз (істинно лінійний час O(n)O(n)), а не O(nloglogn)O(n \log \log n)?

Алгоритм

Наша мета — обчислити найменший простий дільник lp[i]lp [i] для кожного числа ii на відрізку [2;n][2; n].

Окрім того, нам потрібно зберігати список усіх знайдених простих чисел — назвімо його pr[]pr [].

Значення lp[i]lp [i] ми ініціалізуємо нулями, що означає припущення, ніби всі числа прості. Під час виконання алгоритму цей масив поступово заповнюватиметься.

Тепер ми проходитимемо числа від 2 до nn. Для поточного числа ii маємо два випадки:

  • lp[i]=0lp[i] = 0 — це означає, що ii просте, тобто ми не знайшли для нього жодного меншого дільника.
    Тож ми присвоюємо lp[i]=ilp [i] = i і додаємо ii в кінець списку pr[]pr[].

  • lp[i]0lp[i] \neq 0 — це означає, що ii складене, а його найменший простий дільник — lp[i]lp [i].

В обох випадках ми оновлюємо значення lp[]lp [] для чисел, які діляться на ii. Однак наша мета — навчитися робити це так, щоб задавати значення lp[]lp [] щонайбільше один раз для кожного числа. Зробити це можна так:

Розгляньмо числа xj=ipjx_j = i \cdot p_j, де pjp_j — це всі прості числа, менші або рівні lp[i]lp [i] (саме тому нам потрібно зберігати список усіх простих чисел).

Для всіх чисел такого вигляду ми задамо нове значення lp[xj]=pjlp [x_j] = p_j.

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

Реалізація

const int N = 10000000;
vector<int> lp(N+1);
vector<int> pr;

for (int i=2; i <= N; ++i) {
if (lp[i] == 0) {
lp[i] = i;
pr.push_back(i);
}
for (int j = 0; i * pr[j] <= N; ++j) {
lp[i * pr[j]] = pr[j];
if (pr[j] == lp[i]) {
break;
}
}
}

Доведення коректності

Нам потрібно довести, що алгоритм задає всі значення lp[]lp [] правильно і що кожне значення буде задано рівно один раз. Тоді алгоритм матиме лінійний час роботи, бо всі інші дії алгоритму, очевидно, виконуються за O(n)O (n).

Зауважимо, що кожне число ii має рівно одне подання у вигляді:

i=lp[i]x,i = lp [i] \cdot x,

де lp[i]lp [i] — найменший простий дільник ii, а число xx не має жодних простих дільників, менших за lp[i]lp [i], тобто

lp[i]lp[x].lp [i] \le lp [x].

Тепер порівняймо це з діями нашого алгоритму: насправді для кожного xx він проходить усі прості числа, на які його можна було б помножити, тобто всі прості числа аж до lp[x]lp [x] включно, щоб отримати числа у наведеному вище вигляді.

Отже, алгоритм пройде кожне складене число рівно один раз, задаючи там правильні значення lp[]lp []. Що й треба було довести.

Час роботи та пам'ять

Хоча час роботи O(n)O(n) кращий за O(nloglogn)O(n \log \log n) класичного решета Ератосфена, різниця між ними не така вже й велика. На практиці лінійне решето працює приблизно так само швидко, як і типова реалізація решета Ератосфена.

Порівняно з оптимізованими версіями решета Ератосфена, наприклад із сегментованим решетом, воно значно повільніше.

Якщо взяти до уваги вимоги цього алгоритму до пам'яті — масив lp[]lp [] довжини nn та масив pr[]pr [] довжини nlnn\frac n {\ln n}, — то цей алгоритм видається гіршим за класичне решето в усьому.

Однак його рятівна риса в тому, що цей алгоритм обчислює масив lp[]lp [], який дозволяє нам знаходити розклад будь-якого числа на відрізку [2;n][2; n] за час порядку розміру цього розкладу. Більше того, лише за допомогою одного додаткового масиву ми можемо уникнути ділень під час пошуку розкладу.

Знання розкладів усіх чисел на множники дуже корисне для деяких задач, і цей алгоритм — один із небагатьох, що дозволяють знаходити їх за лінійний час.

Список літератури

  • Paul Pritchard, Linear Prime-Number Sieves: a Family Tree, Science of Computer Programming, vol. 9 (1987), pp.17-35.