Метод Мейсселя–Лемера
Мотивация и контекст
Функция — количество простых чисел, не превосходящих — является одной из центральных объектов аналитической теории чисел. По теореме о простых числах: . Но нас интересует точное значение.
Зачем нужен точный ?
- Задачи соревновательного программирования: "Найдите -е простое число по номеру" для .
- Криптография: оценки числа простых в диапазоне, проверка параметров схем.
- Теоретико-числовые задачи: подсчёт простых в арифметических прогрессиях.
- Верификация гипотезы Римана: сравнивается с .
Хронология алгоритмов подсчёта :
| Год | Автор | Сложность | (практика) |
|---|---|---|---|
| 1870 | Мейссель | ||
| 1959 | Лемер | , меньше памяти | |
| 1985 | Люти | , оптимизирован | |
| 1996 | Деланги–Риват | ||
| 2012 | Ким–Ших | + parallel |
Метод Мейсселя–Лемера в его классической форме позволяет вычислить при за несколько секунд с памятью . При правильной реализации это быстрее Min-25 Sieve для очень больших .
Теория
Формула Лежандра
Исторически первой явной формулой стала формула Лежандра (1808):
где — количество целых чисел в , не делящихся ни на одно из первых простых чисел.
вычисляется рекурсивно: с .
Проблема: рекурсия растёт экспоненциально, время неприемлемо.
Функция Мейсселя
Определение. = количество натуральных чисел , у которых наименьший простой делитель (либо ). Это та же Лежандра.
Ключевое разложение Мейсселя:
для любого . Выбирая , получаем управляемую рекурсию.
Классическая формула Мейсселя
Обозначим , , .
Теорема (Мейссель, 1870).
где — количество чисел , имеющих ровно два простых делителя, каждый из которых .
Доказательство. Разобьём числа по числу простых делителей (с учётом ):
- Числа с 0 такими делителями: это числа, у которых все делители . Их (вычитаем единицу для ).
- Числа с ровно 1 таким делителем: это простые , т.е. штук.
- Числа с ровно 2 такими делителями: .
- Числа с такими делителями: ... нет, если , то , и три таких делителя дают — таких чисел нет.
Итого: ... нет, перефразируем.
Числа от 1 до делятся на категории по числу простых делителей :
- Нет таких: штук.
- Ровно один: это простые из , т.е. штук.
- Ровно два: штук.
- Три и более: при произведение трёх таких простых — нет таких чисел в .
Сумма: (все числа, включая 1)? Нет: . Но включает 1, поэтому:
Точная форма (после перегруппировки):
Уточнение Лемера
Определение. — количество чисел , где — простые.
Вычисление :
Здесь , и для каждого простого мы считаем количество простых : .
Доказательство. Число с и оба . Перебираем меньший множитель : он пробегает простые от до (ведь значит ). Для фиксированного количество допустимых : это простые в , то есть (если ). Но , значит , значит штук.
Рекурсия
Для вычисления разобьём:
Основные случаи:
- — все числа.
- : тогда (все числа с наим. делителем — это простые , плюс 1).
- : тогда (только число 1).
Оптимизация — мемоизация: значения при малых и большом занимают много памяти. Лемер предложил остановить рекурсию при и использовать факт, что числа с наим. делителем в диапазоне — либо простые, либо имеют ровно два простых делителя, оба .
Полная формула Лемера
Упрощённее:
что совпадает с .
Сложность и оптимизация памяти
Вычисление для : используем либо предподсчитанное решето (для малых ), либо рекурсивно.
Решето до : хранится в памяти — слишком много. Оптимизация: хранить только префиксные суммы простых до , а для больших значений вычислять рекурсивно.
Итоговая сложность:
- Время: (с лучшими вариантами доходит до ).
- Память: — принципиально лучше, чем у Min-25 Sieve.
Ключевые формулы (сводная таблица)
| Обозначение | Определение | Вычисление |
|---|---|---|
| Четверть корня | Из решета | |
| Квадратный корень | Из решета | |
| Рекуррентно | ||
| Формула Лемера | ||
| Память | Решето до , далее рекурсия | |
| Время | — |
Реализация на C++20 (с анализом сложности)
#include <bits/stdc++.h> using namespace std; using ll = long long; using lll = __int128; // ============================================================ // Метод Мейсселя–Лемера: вычисление pi(x) // Время: O(x^{2/3}/log^2 x), Память: O(x^{1/3}) // ============================================================ struct MeisselLehmer { ll x; int cbrtx; // floor(x^{1/3}) int sqrtx; // floor(x^{1/2}) // Простые числа до sqrt(x), хранятся явно vector<int> primes; // pi_small[v] = pi(v) для v <= cbrtx*cbrtx (= x^{2/3}) // Хранится как prefix sum vector<int> pi_small; // sieve до x^{2/3} int sieve_limit; vector<bool> sieve; explicit MeisselLehmer(ll x_) : x(x_) { sqrtx = (int)sqrtl((long double)x_); while((ll)sqrtx * sqrtx > x_) --sqrtx; while((ll)(sqrtx+1)*(sqrtx+1) <= x_) ++sqrtx; cbrtx = (int)cbrtl((long double)x_); while((ll)cbrtx*cbrtx*cbrtx > x_) --cbrtx; while((ll)(cbrtx+1)*(cbrtx+1)*(cbrtx+1) <= x_) ++cbrtx; // sieve_limit = min(sqrtx * sqrtx, cbrtx * cbrtx * cbrtx) = примерно x^{2/3} // но мы решеваем до sqrtx для вычисления P2 sieve_limit = sqrtx; // Решето до sqrtx sieve.assign(sieve_limit + 1, true); sieve[0] = sieve[1] = false; for(int i = 2; i <= sieve_limit; i++){ if(!sieve[i]) continue; primes.push_back(i); for(ll j = (ll)i*i; j <= sieve_limit; j += i) sieve[j] = false; } // pi_small[v] = pi(v) для v <= sieve_limit pi_small.resize(sieve_limit + 1, 0); for(int v = 1; v <= sieve_limit; v++) pi_small[v] = pi_small[v-1] + (sieve[v] ? 1 : 0); } // pi(v) для v <= sqrtx: O(1) ll pi_fast(ll v) const { if(v <= sieve_limit) return pi_small[v]; // Не должно вызываться для v > sieve_limit в основном алгоритме return -1; } // Функция phi(v, a): количество n <= v с lpf(n) > p_a // Мемоизация для малых a (a <= 6 покрывает первые простые 2,3,5,7,11,13) // Для больших a — рекурсия unordered_map<ll, ll> cache; ll phi(ll v, int a) { if(a == 0) return v; if(v == 0) return 0; if(a == 1) return (v + 1) / 2; // нечётные числа <= v // Если v маленькое — используем pi_small if(v <= sieve_limit){ // phi(v, a) = pi(v) - a + 1 если p_a <= v < p_{a+1} // Точнее: phi(v, a) = #{1, и простые > p_a и <= v} // = 1 + #{простые в (p_a, v]} if(a >= (int)primes.size()) return 1; if(primes[a-1] >= v) return 1; // только 1 return 1 + pi_fast(v) - a; } // Кэш // ключ: (v << 10) | a, только для малых a if(a <= 7){ ll key = (v << 6) | a; auto it = cache.find(key); if(it != cache.end()) return it->second; } ll result = phi(v, a-1) - phi(v / primes[a-1], a-1); if(a <= 7) cache[(v << 6) | a] = result; return result; } // P2(x, a): количество n = p*q <= x, p_a < p <= q простые ll P2(ll v, int a) { // b = pi(sqrt(v)) ll sqrt_v = (ll)sqrtl((long double)v); while(sqrt_v * sqrt_v > v) --sqrt_v; int b = (int)pi_fast(sqrt_v); ll result = 0; // sum_{i=a+1}^{b} (pi(v / p_i) - (i-1)) for(int i = a; i < b; i++){ // i — 0-индекс, primes[i] = p_{i+1} ll pi_v_pi = pi_fast(v / primes[i]); if(pi_v_pi < i + 1) break; // pi(v/p_i) < i => нет вклада result += pi_v_pi - i; // pi(v/p_i) - i (в 0-индексации i = a, значит i+1 - 1 = i) } return result; } // Основная функция: pi(x) ll compute() { if(x < 2) return 0; if(x <= sieve_limit) return pi_fast(x); // a = pi(x^{1/4}) ll x14 = (ll)pow((double)x, 0.25); while(x14 * x14 * x14 * x14 > x) --x14; while((x14+1)*(x14+1)*(x14+1)*(x14+1) <= x) ++x14; int a = (int)pi_fast(x14); // phi(x, a) ll phi_val = phi(x, a); // P2(x, a) ll p2_val = P2(x, a); return phi_val + a - 1 - p2_val; } }; // -------------------------------------------------------- // Версия с полной поддержкой больших x через рекурсивный pi // -------------------------------------------------------- // Глобальные данные (для удобства рекурсии) namespace ML { ll X; int SQ; vector<int> primes; vector<int> prime_count; // prime_count[v] = pi(v) для v <= SQ void init(ll x) { X = x; SQ = (int)sqrtl((long double)x); while((ll)SQ*SQ > x) --SQ; while((ll)(SQ+1)*(SQ+1) <= x) ++SQ; vector<bool> isp(SQ + 1, true); isp[0] = isp[1] = false; for(int i = 2; i <= SQ; i++){ if(!isp[i]) continue; primes.push_back(i); for(ll j = (ll)i*i; j <= SQ; j += i) isp[j] = false; } prime_count.resize(SQ + 1, 0); for(int v = 1; v <= SQ; v++) prime_count[v] = prime_count[v-1] + (isp[v] ? 1 : 0); } ll pi_small(ll v){ return v <= SQ ? prime_count[v] : -1; } // phi рекурсивно с отсечкой ll phi_rec(ll v, int a){ if(a == 0) return v; if(v == 0) return 0; if(a == 1) return (v+1)/2; if(v <= SQ && primes[a-1] >= v) return 1; if(v <= SQ) return 1 + prime_count[v] - a; return phi_rec(v, a-1) - phi_rec(v / primes[a-1], a-1); } ll pi_rec(ll v); ll P2_val(ll v, int a){ ll sv = (ll)sqrtl((long double)v); while(sv*sv > v) --sv; int b = (int)pi_small(sv); ll res = 0; for(int i = a; i < b; i++){ ll quot = v / primes[i]; ll piv = (quot <= SQ) ? prime_count[quot] : pi_rec(quot); if(piv <= i) break; res += piv - i; } return res; } ll pi_rec(ll v){ if(v <= SQ) return prime_count[v]; ll sv = (ll)sqrtl((long double)v); while(sv*sv > v) --sv; ll v14 = (ll)pow((double)v, 0.25); while(v14*v14*v14*v14 > v) --v14; while((v14+1)*(v14+1)*(v14+1)*(v14+1) <= v) ++v14; int a = (int)pi_small(v14); return phi_rec(v, a) + a - 1 - P2_val(v, a); } } // namespace ML ll meissner_lehmer(ll x){ if(x < 2) return 0; ML::init(x); return ML::pi_rec(x); } int main(){ ios::sync_with_stdio(false); cin.tie(nullptr); ll x; cin >> x; cout << "pi(" << x << ") = " << meissner_lehmer(x) << "\n"; // Тесты // pi(10^6) = 78498 // pi(10^9) = 50847534 // pi(10^{12}) = 37607912018 return 0; }
Анализ сложности реализации:
| Компонент | Время | Память |
|---|---|---|
| Решето до | ||
| рекурсия | Стек | |
| Итого |
Разбор задачи 1 (средняя): -е простое число
Условие. Дано . Найти -е простое число .
Решение. По теореме о простых числах: . Бинарным поиском найдём .
#include <bits/stdc++.h> using namespace std; using ll = long long; // Используем meissner_lehmer(x) из реализации выше ll kth_prime(ll k){ if(k <= 6){ int small[] = {2, 3, 5, 7, 11, 13}; return small[k-1]; } // Оценка: p_k ~ k * ln(k) double est = (double)k * log((double)k); ll lo = (ll)(est * 0.9), hi = (ll)(est * 1.2 + 100); // Гарантируем корректность границ while(meissner_lehmer(hi) < k) hi *= 2; while(lo < hi){ ll mid = lo + (hi - lo) / 2; if(meissner_lehmer(mid) >= k) hi = mid; else lo = mid + 1; } return lo; } int main(){ ll k; cin >> k; cout << kth_prime(k) << "\n"; // k=1: 2, k=10: 29, k=10^6: 15485863 }
Сложность: вызовов , каждый за .
Практически для (т.е. ) время – секунд.
Разбор задачи 2 (сложная): — число простых в диапазоне
Условие. Даны , . Найти число простых в .
Решение 1: сегментированное решето — . Решение 2: формула — через метод Лемера, .
Для маленького промежутка сегментированное решето лучше. Для произвольного диапазона — Лемер.
// Сегментированное решето для [a, b], b - a <= 10^7 ll prime_count_range(ll a, ll b){ if(a > b) return 0; ll range = b - a + 1; vector<bool> is_prime(range, true); // Обработка a = 1 (1 — не простое) if(a == 1) is_prime[0] = false; // Решето до sqrt(b) ll sq = (ll)sqrtl((long double)b); vector<bool> small_sieve(sq + 1, true); small_sieve[0] = small_sieve[1] = false; for(ll i = 2; i <= sq; i++){ if(!small_sieve[i]) continue; for(ll j = i*i; j <= sq; j += i) small_sieve[j] = false; // Вычёркиваем в [a, b] ll start = max(i*i, (a + i - 1) / i * i); if(start == i) start += i; // не вычёркиваем само p for(ll j = start; j <= b; j += i) is_prime[j - a] = false; } ll cnt = 0; for(ll v = 0; v < range; v++) if(is_prime[v]) ++cnt; return cnt; } // Для произвольного диапазона ll prime_count_any_range(ll a, ll b){ // Используем два вызова pi return meissner_lehmer(b) - meissner_lehmer(a - 1); }
Оптимизация для соревнований: при всегда используйте сегментированное решето — это , что работает за доли секунды.
Разбор задачи 3 (очень сложная — ICPC WF уровень): Сумма по простым
Условие. Найти , где .
Анализ. Заметим:
Нет, это неверно. для -го простого равно . Поэтому:
Это тривиально! Ответ: .
ll solve(ll N){ const ll MOD = 1e9 + 7; ll pi_N = meissner_lehmer(N) % MOD; return pi_N * (pi_N + 1) % MOD * 500000004 % MOD; // 500000004 = inv(2) mod MOD }
Более нетривиальная версия: .
Это не упрощается тривиально. Решение через суммирование:
Используем факт, что принимает различных значений. Для каждого значения посчитаем число простых с таким значением:
ll solve2(ll N){ const ll MOD = 1e9 + 7; ll ans = 0; // Перебираем различные значения v = floor(N/p) // Для каждого v: число простых p с floor(N/p) = v // p в диапазоне (N/(v+1), N/v] // Количество простых: pi(N/v) - pi(N/(v+1)) // Но нам нужно суммировать pi(v) * (число простых p с floor(N/p)=v) // Это O(sqrt(N)) шагов, каждый за O(1) с предподсчитанными pi // Для N <= 10^11 используем Min-25 для предподсчёта pi всех floor(N/i) // (Метод Лемера менее удобен здесь — он не предподсчитывает все floor(N/i)) // Используем Min25: // (код аналогичен главе 14) return ans; }
Для этой задачи Min-25 Sieve удобнее, чем метод Лемера. Это иллюстрирует различие методов.
Сравнение с Min-25 Sieve
| Критерий | Min-25 Sieve | Мейссель–Лемер |
|---|---|---|
| Время | ||
| Память | ||
| Только | Да (Фаза 1 достаточна) | Да |
| Суммы по простым | Да | Нет (нужна адаптация) |
| Суммы по всем | Да (Фаза 2) | Нет |
| Реализация | Сложнее | Проще |
| Константа | Хуже | Лучше |
| Слишком медленно | Работает | |
| Быстро | Также быстро |
Вывод:
- Если нужен только и : оба подходят, Min-25 чуть быстрее на практике.
- Если нужны суммы мультипликативных функций: только Min-25.
- Если – и нужен только : предпочтительнее Лемер из-за меньшей памяти.
- Для : метод Деланги–Ривата или Lucy_Hedgehog с сегментацией.
Задачи для самостоятельного решения (таблица)
| # | Задача | Источник | Метод | Сложность |
|---|---|---|---|---|
| 1 | — точное значение | SPOJ PRIME1 | Лемер / Min-25 | Средняя |
| 2 | -е простое, | Codeforces | Лемер + бин. поиск | Средне-сложная |
| 3 | Простые в , , | Classic | Сегм. решето | Средняя |
| 4 | , | — | Min-25 Ф.2 | Сложная |
| 5 | для за 2 сек | — | Лемер оптим. | Очень сложная |
| 6 | Число простых-близнецов до | — | Адаптация Лемера | Сложная |
| 7 | Число простых вида до | — | Лемер с отбором | Сложная |
Типичные ошибки
1. Переполнение в промежуточных вычислениях.
При значение переполняет long long. Используйте __int128 или вычисляйте через модуль, если нужен ответ по модулю.
2. Неправильное вычисление . Из-за ошибок округления может быть на единицу больше или меньше истинного значения. Всегда проверяйте: .
3. Выход за границы primes[].
В цикле по от до убедитесь, что . При простых нет — граничный случай.
4. Некорректная остановка рекурсии для . Условие позволяет вернуть . Но это верно только если ; если , то возвращаем 1. Частая ошибка — упустить этот случай.
5. Двойной счёт в . В сумме перебираем до , и для каждого считаем количество . Не забыть вычесть (не ) при 0-индексации.
Совет профессионала
Для задач на при реализуйте Min-25 Sieve Фазу 1 — она проще метода Лемера и имеет то же асимптотическое время. Метод Лемера стоит реализовывать только если требуется или крайне ограниченная память. В соревновательном программировании большинство задач укладываются в , где Min-25 оптимален по соотношению простота/скорость.
При реализации Лемера используйте кэш для при малых : словарь
unordered_map<(v, a), result>со значениями даёт ускорение в 3–5 раз.
Детальный пример работы алгоритма Лемера
Вычислим шаг за шагом.
, , , .
Простые до : , их четыре: .
(простые : ).
Вычисление :
— числа , не кратные 2 и 3.
Через включение-исключение: числа, не кратные 2 и 3. Из 100 чисел: не кратные 2: 50; из них не кратные 3: -поправки...
Рекуррентно:
Проверка: числа , взаимно простые с 6 (): по формуле ... нет, это не то. . Числа от 1 до 6 с таким свойством: — два числа. В 100 числах: остаток = . Гм, расхождение.
Проблема: формула включения-исключения считает числа с для , включая 1. Наша рекуррентность считает правильно: (все от 1 до ). . . Верно.
(Исправление: я ошибся выше — ? Нет, это .)
Вычисление :
(): . (): .
(Нет : .)
.
Итоговая формула:
Верно: .
Проверка
считает числа с ( простые ).
: простое, : — 6 значений. ✓ : : — 3 значения. ✓ : ... нет (т.к. , значит ).
Итого . Числа: — девять чисел. ✓
Реализация с мемоизацией
Для ускорения кэшируем значения при малых (типично ):
// Мемоизация через плоский хэш // Ключ: (v * 8 + a) для a <= 7 // Для v <= sqrtx: используем 2D массив small_phi[v][a] // Для v > sqrtx: используем unordered_map struct PhiCache { int sqx; vector<array<ll, 8>> small_phi; // small_phi[v][a] для v <= sqx, a <= 7 unordered_map<ll, ll> large_phi; PhiCache(int sq) : sqx(sq), small_phi(sq + 1) { for(auto& arr : small_phi) arr.fill(-1); } ll& get_small(int v, int a){ return small_phi[v][a]; } ll& get_large(ll v, int a){ ll key = v * 8 + a; return large_phi[key]; } };
Для и это даёт кэшированных значений. Для и используем хэш: принимает значений, итого записей.
Суммарная память кэша: — пренебрежимо мало.
Итог
Метод Мейсселя–Лемера позволяет вычислять точно за времени с памятью . Три ключевых компонента:
- Функция : рекурсивно считает числа с наименьшим делителем ; остановка при попадании в предподсчитанный диапазон.
- Сумма : подсчёт чисел с ровно двумя простыми делителями ; вычисляется через в цикле.
- Формула: при .
Сравнение с Min-25 Sieve показывает, что каждый метод имеет свою нишу: Лемер лучше для больших и ограниченной памяти, Min-25 — для задач с суммами мультипликативных функций.