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

Факторизація цілих чисел

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

Зауважимо: якщо число, яке ви хочете розкласти на множники, насправді є простим, більшість алгоритмів працюватимуть дуже повільно. Особливо це стосується методу Ферма, методу Полларда p−1 та ро-алгоритму Полларда. Тому має сенс спершу виконати ймовірнісний (або швидкий детермінований) тест на простоту, перш ніж намагатися розкласти число на множники.

Коли підходить цей алгоритм?
  • Чи потрібно знайти прості множники одного (чи кількох) великих чисел? (якщо потрібно лише перевірити, просте число чи ні → Тести простоти)
  • Якщо n1012n \lesssim 10^{12} — чи влаштовує просте пробне ділення за O(n)O(\sqrt{n}), а для більших чисел потрібен ро-алгоритм Полларда за O(n4)O(\sqrt[4]{n})?
  • Якщо потрібні розклади всіх чисел від 11 до NN(→ Лінійне решето чи Решето Ератосфена)?

Пробне ділення

Це найбазовіший алгоритм для пошуку розкладу на прості множники.

Ми ділимо число на кожен можливий дільник dd. Можна помітити, що неможливо, щоб усі прості множники складеного числа nn були більшими за n\sqrt{n}. Тому нам достатньо перевірити лише дільники 2dn2 \le d \le \sqrt{n}, що дає нам розклад на прості множники за O(n)O(\sqrt{n}). (Це псевдополіноміальний час, тобто поліноміальний відносно значення входу, але експоненційний відносно кількості бітів входу.)

Найменший дільник обов'язково є простим числом. Ми прибираємо знайдений множник і продовжуємо процес. Якщо ми не можемо знайти жодного дільника в діапазоні [2;n][2; \sqrt{n}], то саме число має бути простим.

vector<long long> trial_division1(long long n) {
vector<long long> factorization;
for (long long d = 2; d * d <= n; d++) {
while (n % d == 0) {
factorization.push_back(d);
n /= d;
}
}
if (n > 1)
factorization.push_back(n);
return factorization;
}

Факторизація з колесом

Це оптимізація пробного ділення. Коли ми вже знаємо, що число не ділиться на 2, нам не потрібно перевіряти інші парні числа. Це залишає нам лише 50%50\% чисел для перевірки. Після того як ми винесли 2 й отримали непарне число, ми можемо просто почати з 3 і враховувати лише інші непарні числа.

vector<long long> trial_division2(long long n) {
vector<long long> factorization;
while (n % 2 == 0) {
factorization.push_back(2);
n /= 2;
}
for (long long d = 3; d * d <= n; d += 2) {
while (n % d == 0) {
factorization.push_back(d);
n /= d;
}
}
if (n > 1)
factorization.push_back(n);
return factorization;
}

Цей метод можна розширити далі. Якщо число не ділиться на 3, ми також можемо ігнорувати всі інші кратні 3 в подальших обчисленнях. Отже, нам потрібно перевіряти лише числа 5,7,11,13,17,19,23,5, 7, 11, 13, 17, 19, 23, \dots. Ми можемо помітити закономірність у цих числах, що залишилися. Нам потрібно перевіряти всі числа з dmod6=1d \bmod 6 = 1 та dmod6=5d \bmod 6 = 5. Отже, це залишає нам лише 33.3%33.3\% відсотків чисел для перевірки. Ми можемо це реалізувати, спершу винісши прості множники 2 і 3, після чого починаємо з 5 і враховуємо лише остачі 11 та 55 за модулем 66.

Ось реалізація для простих чисел 2, 3 і 5. Зручно зберігати кроки пропуску в масиві.

vector<long long> trial_division3(long long n) {
vector<long long> factorization;
for (int d : {2, 3, 5}) {
while (n % d == 0) {
factorization.push_back(d);
n /= d;
}
}
static array<int, 8> increments = {4, 2, 4, 2, 4, 6, 2, 6};
int i = 0;
for (long long d = 7; d * d <= n; d += increments[i++]) {
while (n % d == 0) {
factorization.push_back(d);
n /= d;
}
if (i == 8)
i = 0;
}
if (n > 1)
factorization.push_back(n);
return factorization;
}

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

Попередньо обчислені прості числа

Розширюючи метод факторизації з колесом нескінченно, ми зрештою залишимося лише з простими числами для перевірки. Хороший спосіб це зробити — попередньо обчислити всі прості числа за допомогою решета Ератосфена до n\sqrt{n} і перевіряти їх по черзі.

vector<long long> primes;

vector<long long> trial_division4(long long n) {
vector<long long> factorization;
for (long long d : primes) {
if (d * d > n)
break;
while (n % d == 0) {
factorization.push_back(d);
n /= d;
}
}
if (n > 1)
factorization.push_back(n);
return factorization;
}

Метод факторизації Ферма

Ми можемо записати непарне складене число n=pqn = p \cdot q як різницю двох квадратів n=a2b2n = a^2 - b^2:

n=(p+q2)2(pq2)2n = \left(\frac{p + q}{2}\right)^2 - \left(\frac{p - q}{2}\right)^2

Метод факторизації Ферма намагається використати цей факт, вгадуючи перший квадрат a2a^2 і перевіряючи, чи є квадратом і друга частина, b2=a2nb^2 = a^2 - n. Якщо так, то ми знайшли множники aba - b та a+ba + b числа nn.

int fermat(int n) {
int a = ceil(sqrt(n));
int b2 = a*a - n;
int b = round(sqrt(b2));
while (b * b != b2) {
a = a + 1;
b2 = a*a - n;
b = round(sqrt(b2));
}
return a - b;
}

Цей метод факторизації може бути дуже швидким, якщо різниця між двома множниками pp та qq мала. Алгоритм працює за час O(pq)O(|p - q|). Проте на практиці цей метод використовують рідко. Щойно множники віддаляються один від одного, він стає вкрай повільним.

Однак щодо цього підходу все ще існує велика кількість варіантів оптимізації. Розглядаючи квадрати a2a^2 за модулем фіксованого малого числа, можна помітити, що певні значення aa не потрібно розглядати, оскільки вони не можуть дати квадрат a2na^2 - n.

Метод Полларда p1p - 1

Дуже ймовірно, що число nn має принаймні один простий множник pp такий, що p1p - 1 є B\mathrm{B}-степенево-гладким для малого B\mathrm{B}. Кажуть, що ціле число mm є B\mathrm{B}-степенево-гладким, якщо кожен простий степінь, що ділить mm, не перевищує B\mathrm{B}. Формально, нехай B1\mathrm{B} \geqslant 1 і нехай mm — довільне додатне ціле число. Припустимо, що розклад mm на прості множники є m=qieim = \prod {q_i}^{e_i}, де кожне qiq_i — просте число, а ei1e_i \geqslant 1. Тоді mm є B\mathrm{B}-степенево-гладким, якщо для всіх ii виконується qieiB{q_i}^{e_i} \leqslant \mathrm{B}. Наприклад, розклад числа 48171914817191 на прості множники — це 130336971303 \cdot 3697. А значення 130311303 - 1 та 369713697 - 1 є відповідно 3131-степенево-гладким і 1616-степенево-гладким, оскільки 13031=237311303 - 1 = 2 \cdot 3 \cdot 7 \cdot 31 та 36971=2437113697 - 1 = 2^4 \cdot 3 \cdot 7 \cdot 11. У 1974 році Джон Поллард винайшов метод для виділення зі складеного числа множників pp таких, що p1p-1 є B\mathrm{B}-степенево-гладким.

Ідея походить із малої теореми Ферма. Нехай розклад nn буде n=pqn = p \cdot q. Вона стверджує, що якщо aa взаємно просте з pp, то виконується таке твердження:

ap11(modp)a^{p - 1} \equiv 1 \pmod{p}

Це також означає, що

(a(p1))kak(p1)1(modp).{\left(a^{(p - 1)}\right)}^k \equiv a^{k \cdot (p - 1)} \equiv 1 \pmod{p}.

Отже, для будь-якого MM з p1  Mp - 1 ~|~ M ми знаємо, що aM1a^M \equiv 1. Це означає, що aM1=pra^M - 1 = p \cdot r, а через це також p  gcd(aM1,n)p ~|~ \gcd(a^M - 1, n).

Тому, якщо p1p - 1 для множника pp числа nn ділить MM, ми можемо виділити множник за допомогою алгоритму Евкліда.

Зрозуміло, що найменше MM, яке є кратним кожному B\mathrm{B}-степенево-гладкому числу, — це lcm(1, 2 ,3 ,4 , , B)\text{lcm}(1,~2~,3~,4~,~\dots,~B). Або, як альтернатива:

M=prime qBqlogqBM = \prod_{\text{prime } q \le B} q^{\lfloor \log_q B \rfloor}

Зауважимо: якщо p1p-1 ділить MM для всіх простих множників pp числа nn, то gcd(aM1,n)\gcd(a^M - 1, n) просто дорівнюватиме nn. У цьому випадку ми не отримуємо множника. Тому ми намагатимемося виконувати gcd\gcd кілька разів, поки обчислюємо MM.

Деякі складені числа не мають множників pp таких, що p1p-1 є B\mathrm{B}-степенево-гладким для малого B\mathrm{B}. Наприклад, для складеного числа 100 000 000 000 000 493=763 013131 059 365 961100~000~000~000~000~493 = 763~013 \cdot 131~059~365~961 значення p1p-1 є відповідно 190 753190~753-степенево-гладким і 1 092 161 3831~092~161~383-степенево-гладким. Нам доведеться обрати B190 753B \geq 190~753, щоб розкласти це число на множники.

У наведеній нижче реалізації ми починаємо з B=10\mathrm{B} = 10 і збільшуємо B\mathrm{B} після кожної ітерації.

long long pollards_p_minus_1(long long n) {
int B = 10;
long long g = 1;
while (B <= 1000000 && g < n) {
long long a = 2 + rand() % (n - 3);
g = gcd(a, n);
if (g > 1)
return g;

// обчислюємо a^M
for (int p : primes) {
if (p >= B)
continue;
long long p_power = 1;
while (p_power * p <= B)
p_power *= p;
a = power(a, p_power, n);

g = gcd(a - 1, n);
if (g > 1 && g < n)
return g;
}
B *= 2;
}
return 1;
}

Зауважте, що це ймовірнісний алгоритм. Наслідком цього є те, що існує можливість того, що алгоритм узагалі не зможе знайти множник.

Складність становить O(BlogBlog2n)O(B \log B \log^2 n) на одну ітерацію.

Ро-алгоритм Полларда

Ро-алгоритм Полларда — це ще один алгоритм факторизації від Джона Полларда.

Нехай розклад числа на прості множники буде n=pqn = p q. Алгоритм розглядає псевдовипадкову послідовність {xi}={x0, f(x0), f(f(x0)), }\{x_i\} = \{x_0,~f(x_0),~f(f(x_0)),~\dots\}, де ff — поліноміальна функція; зазвичай обирають f(x)=(x2+c)modnf(x) = (x^2 + c) \bmod n з c=1c = 1.

У цьому випадку нас цікавить не сама послідовність {xi}\{x_i\}. Нас більше цікавить послідовність {ximodp}\{x_i \bmod p\}. Оскільки ff — поліноміальна функція, а всі значення лежать у діапазоні [0; p)[0;~p), ця послідовність зрештою зійдеться в цикл. Парадокс днів народження насправді підказує, що очікувана кількість елементів до початку повторення становить O(p)O(\sqrt{p}). Якщо pp менше за n\sqrt{n}, повторення, ймовірно, почнеться за O(n4)O(\sqrt[4]{n}).

Ось візуалізація такої послідовності {ximodp}\{x_i \bmod p\} з n=2206637n = 2206637, p=317p = 317, x0=2x_0 = 2 та f(x)=x2+1f(x) = x^2 + 1. З форми послідовності ви можете дуже чітко побачити, чому алгоритм названо ро-алгоритмом Полларда (ρ\rho).

Pollard's rho visualization

Проте все ще залишається відкрите питання. Як ми можемо скористатися властивостями послідовності {ximodp}\{x_i \bmod p\} на свою користь, навіть не знаючи самого числа pp?

Насправді це досить просто. У послідовності {ximodp}ij\{x_i \bmod p\}_{i \le j} є цикл тоді й лише тоді, коли існують два індекси s,tjs, t \le j такі, що xsxtmodpx_s \equiv x_t \bmod p. Це рівняння можна переписати як xsxt0modpx_s - x_t \equiv 0 \bmod p, що те саме, що й p  gcd(xsxt,n)p ~|~ \gcd(x_s - x_t, n).

Тому, якщо ми знайдемо два індекси ss та tt з g=gcd(xsxt,n)>1g = \gcd(x_s - x_t, n) > 1, ми знайшли цикл, а також множник gg числа nn. Можливо, що g=ng = n. У цьому випадку ми не знайшли власного множника, тож маємо повторити алгоритм з іншим параметром (інше початкове значення x0x_0, інша константа cc у поліноміальній функції ff).

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

Алгоритм пошуку циклу Флойда

Цей алгоритм знаходить цикл, використовуючи два вказівники, що рухаються по послідовності з різними швидкостями. Під час кожної ітерації перший вказівник просувається на один елемент уперед, тоді як другий вказівник просувається через кожен другий елемент. Використовуючи цю ідею, легко помітити, що якщо цикл існує, то в певний момент під час проходження циклу другий вказівник наздожене перший. Якщо довжина циклу дорівнює λ\lambda, а μ\mu — це перший індекс, на якому цикл починається, то алгоритм працюватиме за час O(λ+μ)O(\lambda + \mu).

Цей алгоритм також відомий як алгоритм «черепаха і заєць», за казкою, в якій черепаха (повільний вказівник) і заєць (швидший вказівник) змагаються в забігу.

За допомогою цього алгоритму насправді можна визначити параметри λ\lambda та μ\mu (також за час O(λ+μ)O(\lambda + \mu) і пам'ять O(1)O(1)). Коли цикл виявлено, алгоритм поверне 'True'. Якщо послідовність не має циклу, то функція зациклиться нескінченно. Однак, використовуючи ро-алгоритм Полларда, цього можна уникнути.

function floyd(f, x0):
tortoise = x0
hare = f(x0)
while tortoise != hare:
tortoise = f(tortoise)
hare = f(f(hare))
return true

Реалізація

Спершу наведемо реалізацію за допомогою алгоритму пошуку циклу Флойда. Алгоритм загалом працює за час O(n4log(n))O(\sqrt[4]{n} \log(n)).

long long mult(long long a, long long b, long long mod) {
return (__int128)a * b % mod;
}

long long f(long long x, long long c, long long mod) {
return (mult(x, x, mod) + c) % mod;
}

long long rho(long long n, long long x0=2, long long c=1) {
long long x = x0;
long long y = x0;
long long g = 1;
while (g == 1) {
x = f(x, c, n);
y = f(y, c, n);
y = f(y, c, n);
g = gcd(abs(x - y), n);
}
return g;
}

Наведена нижче таблиця показує значення xx та yy під час роботи алгоритму для n=2206637n = 2206637, x0=2x_0 = 2 та c=1c = 1.

iximodnx2imodnximod317x2imod317gcd(xix2i,n)022221526526122645833026265136771671573433214458330641379265881511664123519371696716167157312646823216917219308020884707474317\newcommand\T{\Rule{0pt}{1em}{.3em}} \begin{array}{|l|l|l|l|l|l|} \hline i & x_i \bmod n & x_{2i} \bmod n & x_i \bmod 317 & x_{2i} \bmod 317 & \gcd(x_i - x_{2i}, n) \\ \hline 0 & 2 & 2 & 2 & 2 & - \\ 1 & 5 & 26 & 5 & 26 & 1 \\ 2 & 26 & 458330 & 26 & 265 & 1 \\ 3 & 677 & 1671573 & 43 & 32 & 1 \\ 4 & 458330 & 641379 & 265 & 88 & 1 \\ 5 & 1166412 & 351937 & 169 & 67 & 1 \\ 6 & 1671573 & 1264682 & 32 & 169 & 1 \\ 7 & 2193080 & 2088470 & 74 & 74 & 317 \\ \hline \end{array}

Реалізація використовує функцію mult, яка множить два цілих числа 1018\le 10^{18} без переповнення, застосовуючи тип __int128 з GCC для 128-бітних цілих. Якщо GCC недоступний, ви можете скористатися ідеєю, подібною до бінарного піднесення до степеня.

long long mult(long long a, long long b, long long mod) {
long long result = 0;
while (b) {
if (b & 1)
result = (result + a) % mod;
a = (a + a) % mod;
b >>= 1;
}
return result;
}

Як альтернатива, ви також можете реалізувати множення Монтгомері.

Як зазначено раніше, якщо nn складене, а алгоритм повертає nn як множник, вам доведеться повторити процедуру з іншими параметрами x0x_0 та cc. Наприклад, вибір x0=c=1x_0 = c = 1 не розкладе 25=5525 = 5 \cdot 5. Алгоритм поверне 2525. Однак вибір x0=1x_0 = 1, c=2c = 2 розкладе його.

Алгоритм Брента

Брент реалізує метод, подібний до методу Флойда, використовуючи два вказівники. Різниця полягає в тому, що замість просування вказівників відповідно на одну й дві позиції, їх просувають на степені двійки. Щойно 2i2^i стане більшим за λ\lambda та μ\mu, ми знайдемо цикл.

function floyd(f, x0):
tortoise = x0
hare = f(x0)
l = 1
while tortoise != hare:
tortoise = hare
repeat l times:
hare = f(hare)
if tortoise == hare:
return true
l *= 2
return true

Алгоритм Брента також працює за лінійний час, але загалом швидший за алгоритм Флойда, оскільки використовує менше обчислень функції ff.

Реалізація

Прямолінійну реалізацію алгоритму Брента можна прискорити, опускаючи члени xlxkx_l - x_k, якщо k<3l2k < \frac{3 \cdot l}{2}. Крім того, замість обчислення gcd\gcd на кожному кроці, ми перемножуємо члени й фактично перевіряємо gcd\gcd лише раз на кілька кроків, відкочуючись назад, якщо перестрибнули.

long long brent(long long n, long long x0=2, long long c=1) {
long long x = x0;
long long g = 1;
long long q = 1;
long long xs, y;

int m = 128;
int l = 1;
while (g == 1) {
y = x;
for (int i = 1; i < l; i++)
x = f(x, c, n);
int k = 0;
while (k < l && g == 1) {
xs = x;
for (int i = 0; i < m && i < l - k; i++) {
x = f(x, c, n);
q = mult(q, abs(y - x), n);
}
g = gcd(q, n);
k += m;
}
l *= 2;
}
if (g == n) {
do {
xs = f(xs, c, n);
g = gcd(abs(xs - y), n);
} while (g == 1);
}
return g;
}

Поєднання пробного ділення для малих простих чисел разом із версією Брента ро-алгоритму Полларда дає дуже потужний алгоритм факторизації.

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

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