Почему isqrt, а не int(n**0.5) фундамент всего параграфа
Во всех функциях ниже нам нужно вычислить целую часть квадратного корня — предельную границу перебора делителей. Есть два способа:
Способ 1 — через float: int(n**0.5). Сначала Python возводит n в степень 0.5 (получается число с плавающей точкой float), затем откусывает дробную часть через int.
Способ 2 — через целочисленную функцию: isqrt(n) из модуля math. Эта функция работает без вычислений с плавающей точкой: она сразу ищет максимальное целое k, для которого k² ≤ n, используя целочисленный алгоритм.
Что делает isqrtmath.isqrt(n) возвращает наибольшее целое k, такое что k*k <= n. Это целочисленный квадратный корень — без float, без потери точности, без округлений.
В чём разница на практике? Тип float в Python (это стандарт IEEE 754 двойной точности) хранит около 15–16 значащих десятичных цифр. Для чисел больше 10¹⁵ округления уже дают о себе знать. Возьмём конкретное число:
Контрпример: int(n**0.5) ошибаетсяn = 999 999 999 999 999 999 int(n**0.5) → 1 000 000 000 isqrt(n) → 999 999 999
Разница в единицу — казалось бы мелочь, но если цикл идёт for d in range(2, int(n**0.5) + 1), лишняя итерация может или пропустить корневой делитель, или наоборот заглянуть за границу — оба варианта дают неверный ответ.
Второе преимущество isqrt — скорость. Целочисленный алгоритм в C-реализации быстрее, чем вычисление **0.5 через плавающую точку плюс приведение к int. На миллионе вызовов разница ощутима.
Третье — корректная граница. При переборе делителей нужен диапазон range(1, isqrt(n) + 1): символ +1 стоит потому, что range не включает правую границу, а нам нужно дойти до самого isqrt(n).
ПравилоВезде, где нужна граница перебора до корня, пишем isqrt(n) + 1, не int(n**0.5) + 1. Один импорт — и гарантия корректности на любых числах.
Функция 1 — delitels все делители числа
Самая используемая функция в задании 25. Возвращает отсортированный список всех натуральных делителей числа n — от 1 до самого n.
Принцип работы разобран в §01: делители ходят парами, один из пары всегда не превосходит √n. Значит достаточно пробежать d от 1 до isqrt(n) и на каждом совпадении добавить оба делителя. Чтобы не получить дубликат при n = k² (когда d = n // d), используем set — он автоматически отбросит повтор.
from math import isqrt
def divs(n):
D = set()
for d in range(1, isqrt(n) + 1):
if n % d == 0:
D.add(d)
D.add(n // d)
return sorted(D)
Что возвращает и как использовать:
Примеры вызововdivs(12) → [1, 2, 3, 4, 6, 12] divs(36) → [1, 2, 3, 4, 6, 9, 12, 18, 36] divs(17) → [1, 17] divs(1) → [1] кол-во делителей: нетривиальные делители: мин. нетривиальный: макс. нетривиальный:
Срез divs(n)[1:-1] — чистый и короткий способ получить нетривиальные делители (без 1 и без самого n). Для простых n возвращает пустой список — удобно для проверок.
Функция 3 — sieve решето Эратосфена
Когда задача требует не проверить одно число, а получить список всех простых до некоторой границы N — решето Эратосфена на порядки быстрее, чем вызывать is_prime для каждого числа по отдельности.
Идея: создаём массив флагов длины N+1, где sieve[i] означает «число i — простое?». Изначально все единицы, кроме sieve[0] и sieve[1]. Затем для каждого простого p зачёркиваем все его кратные: 2p, 3p, 4p, .... В конце единицы в массиве стоят только на простых позициях.
Для хранения флагов используем bytearray — встроенный тип Python (как list или str, импорты не нужны, на ЕГЭ абсолютно легален). bytearray хранит байты, поэтому занимает в 8 раз меньше памяти, чем list из целых чисел. Для N = 10⁷ это разница между 10 МБ и 80 МБ — критично на ЕГЭ, где ограничения на память жёсткие.
bytearrayВстроенный тип Python, доступен везде без импортов. Как list, но элементы — только байты (0–255). Идеален для хранения флагов (0/1) и зачёркивания через срезы.
from math import isqrt
def sieve(N):
a = bytearray([1]) * (N + 1)
a[0] = a[1] = 0
for p in range(2, isqrt(N) + 1):
if a[p]:
a[p*p :: p] = bytearray(len(a[p*p :: p]))
return a
Главная тонкость: начинаем зачёркивание с p*p, а не с 2p. Все числа 2p, 3p, ..., (p-1)·p уже были зачёркнуты ранее — при обработке простых меньше p. Первое «новое» кратное, которое ещё не тронуто, — это p·p. Поэтому внешний цикл идёт только до isqrt(N): если p > isqrt(N), то p² > N и вычёркивать нечего.
Строка a[p*p :: p] = bytearray(len(a[p*p :: p])) обнуляет сразу все кратные p, начиная с p². Эта идиома выглядит непривычно — её подробный разбор в следующей секции.
Использование результатаa = sieve(100) a[17] → 1 a[18] → 0 a[1] → 0 a[2] → 1 все простые до 100: [i for i in range(len(a)) if a[i]] → [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, ...]
На каких размерах N имеет смысл звать sieve? Для N ≤ 10⁷ — точно, отработает за пару секунд и съест ~10 МБ. Для N = 10⁸ — уже на грани, 100 МБ может не хватить. Если задача просит простые в диапазоне до 10⁹, решето не подходит — лучше использовать is_prime для отдельных чисел.
Как работает массовое зачёркивание разбор самой хитрой строки
Самая непонятная строка в sieve — это a[p*p :: p] = bytearray(len(a[p*p :: p])). За одну операцию она обнуляет все элементы массива с индексами p², p²+p, p²+2p, … — то есть все кратные p, начиная с p². Разберём по кусочкам.
Возьмём крошечный пример для наглядности: a = bytearray([1] * 20) — массив из 20 единичек с индексами от 0 до 19. И пусть p = 3. Тогда p*p = 9, и мы хотим обнулить a[9], a[12], a[15], a[18] — все кратные 3, начиная с 9.
Шаг 1. Срез a[p*p :: p]a = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 срез a[9 :: 3] берёт элементы с индексами 9, 12, 15, 18 результат: [1, 1, 1, 1]
Шаг 2. len(a[p*p :: p])len(a[9 :: 3]) → 4
Шаг 3. bytearray(4) — что это создаёт?bytearray(4) → bytearray(b'\x00\x00\x00\x00') bytearray([1, 0, 1]) → bytearray(b'abc') → bytearray(4) →
Шаг 4. a[9 :: 3] = bytearray(4)до присваивания: a = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] ^ ^ ^ ^ 9 12 15 18 после присваивания: a = [1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1] ^ ^ ^ ^
В применении к решету Эратосфена: если p = 3 и N = 20, то после этой строки все числа 9, 12, 15, 18 помечены как составные. Внешний цикл перейдёт к следующему простому (p = 5), и снова одной строкой обнулит a[25], a[30], ... — и так далее.
КлючСинтаксис a[start :: step] = bytearray(len(...)) — Python-идиома для «обнулить все позиции среза одним махом». Главная выгода — это делается на уровне C, а не Python-циклом.
Почему не написать проще — через обычный цикл? Сравним два варианта:
# Вариант A — через Python-цикл (работает, но медленно)
for k in range(p*p, N + 1, p):
a[k] = 0
# Вариант B — через срез (быстро, идиома)
a[p*p :: p] = bytearray(len(a[p*p :: p]))
Оба варианта дают абсолютно одинаковый результат. Но вариант B работает в 10–20 раз быстрее. Причина проста.
В варианте A на каждую итерацию Python выполняет десятки внутренних операций: проверка условия цикла, вычисление следующего k, вызов a.__setitem__, инкремент счётчика. Всё это — шаги интерпретатора. Для N = 10⁷ и простого p = 2 нужно пройти 5 миллионов итераций. Добавьте сюда все остальные простые — получите десятки миллионов медленных операций.
В варианте B присваивание в срез обрабатывается одной функцией на уровне C: она напрямую пишет нули в память массива, без возврата в интерпретатор Python между элементами. Для того же N = 10⁷ вся операция занимает десятые доли секунды.
Практический замер: sieve до N = 10 000 000Вариант A (цикл): ~1.6 секунды Вариант B (срез): ~0.1 секунды Разница в ~16 раз — на одной строке кода
Почему вообще срез-идиома эффективна именно для bytearray, а не для list? Потому что bytearray хранит байты непрерывно в памяти (как массив в C), и CPython умеет копировать в него байты блоками через memmove. У list внутри — массив указателей на Python-объекты, и массовое присваивание там медленнее.
Итого: bytearray + срез-идиома — это не просто «короче написать», а принципиально более быстрый способ работы с массивом флагов. Для задания 25 запоминаем именно эту пару.
Функция 4 — factor факторизация
Раскладывает n на простые множители с учётом кратности. Возвращает список простых, где каждое простое повторяется столько раз, сколько оно входит в разложение.
Нужна в задачах, где требуется узнать структуру числа: «состоит из двух различных простых», «является степенью простого», «имеет три простых множителя». В §03 мы делали факторизацию через цикл с первым делителем — функция factor делает то же самое в общем виде.
Алгоритм: идём d от 2 и «откусываем» от n все вхождения d (в цикле while n % d == 0, добавляя d в список и деля n на d). Когда d*d > n, цикл заканчивается. Если после цикла n > 1 — остаток сам является простым и добавляется в список.
def factor(n):
f = []
d = 2
while d * d <= n:
while n % d == 0:
f.append(d)
n //= d
d += 1
if n > 1:
f.append(n)
return f
Примеры вызововfactor(12) → [2, 2, 3] factor(60) → [2, 2, 3, 5] factor(17) → [17] factor(100) → [2, 2, 5, 5] factor(1) → [] число различных простых: является ли p · q (различные): является ли p² · q:
Обратите внимание: factor возвращает множители с повторами. Чтобы получить уникальные простые — применяем set(factor(n)). Чтобы получить разложение в виде «простое → степень» — можно использовать collections.Counter, но для школьной задачи обычно достаточно factor + set.