Перейти к содержимому
Topos Uranos

Краткое содержание

Предшествующие уроки раздела научили писать циклы и рассуждать о них с помощью инварианта и варианта цикла; этот урок применяет то же рассуждение к четырём классическим численным алгоритмам, которые старше компьютеров и до сих пор используются. Тест на простоту перебором делителей, который останавливается на n\sqrt{n} по доказываемой причине и чьё условие остановки переполняется, если записать его небрежно. Алгоритм Евклида для наибольшего общего делителя, с его инвариантом, его завершением, его ловушкой с отрицательными числами и его сравнением с std::gcd. Перевод в другую систему счисления последовательными делениями и схема Горнера, которая восстанавливает число по его цифрам и по той же схеме вычисляет значение многочлена. И метод бисекции, который приближает корень, деля отрезок пополам, гарантирован теоремой о промежуточном значении и имеет число итераций, вычисляемое заранее. Каждый алгоритм представлен как математический объект: с доказательством того, что он делает то, что обещает, доказательством того, что он завершается, и первым подсчётом его шагов.

Цели обучения

  1. Реализовывать тест на простоту делениями до n\sqrt{n} и обосновывать, почему достаточно дойти до n\sqrt{n}.
  2. Реализовывать алгоритм Евклида, формулировать его инвариант (наибольший общий делитель не меняется) и сравнивать его с std::gcd.
  3. Переводить число в другую систему счисления последовательными делениями и вычислять значение позиционной записи, или многочлена, по схеме Горнера.
  4. Приближать корень методом бисекции с заданным допуском и вычислять, сколько итераций для этого нужно.

Делители и простые числа

Перебор делителей

Целое число n≥2n \geq 2 называется простым, если его единственные положительные делители суть 1 и nn; если у него есть другой делитель, оно составное; 0 и 1 не относятся ни к тем, ни к другим. Определение сразу подсказывает алгоритм: пробовать каждого кандидата dd от 2 до n−1n - 1 и объявлять число составным, как только один из них его разделит. Это цикл наименьшего делителя из урока Циклы, и он правилен. Однако для простого числа из десяти цифр он выполняет почти десять миллиардов делений, и почти все они бесполезны, по следующей причине.

УтверждениеДостаточно пробовать до n\sqrt{n}

Если n≥2n \geq 2 составное, у него есть делитель dd, для которого 2≤d≤n2 \leq d \leq \sqrt{n}.

Доказательство

  1. n=a⋅b,2≤a≤bn = a \cdot b, \quad 2 \leq a \leq b

    Если nn составное, оно записывается как произведение двух множителей, ни один из которых не равен ни 1, ни nn. Обозначим через aa меньший из двух.

  2. a2≤a⋅b=na^2 \leq a \cdot b = n

    Умножая a≤ba \leq b на положительное aa, получаем a2≤ab=na^2 \leq ab = n, откуда a≤na \leq \sqrt{n}: меньший множитель любого разложения лежит слева от n\sqrt{n}.

  3. d≤n  ⟺  nd≥nd \leq \sqrt{n} \iff \frac{n}{d} \geq \sqrt{n}

    Итак, делители встречаются парами (d,n/d)(d, n/d), симметричными относительно n\sqrt{n}: при n=36n = 36 пары суть (2,18)(2, 18), (3,12)(3, 12), (4,9)(4, 9) и (6,6)(6, 6). Если ни один кандидат до n\sqrt{n} не делит nn, то и ни один дальше не делит, потому что его пара появилась бы раньше.

Отсюда следует, что число делений уменьшается примерно с nn примерно до n\sqrt{n}: для nn порядка двух миллиардов это с двух миллиардов до сорока шести тысяч. Программа использует шаблон флага из урока Проектирование правильных циклов: isPrime сначала истинна, и цикл сбрасывает её, если находит делитель.

Программа на C++
#include <iostream>#include <print>​int main(){    int n{};    std::cin >> n;    bool isPrime{n >= 2};    for (int d{2}; isPrime && d <= n / d; ++d) {        if (n % d == 0) {            isPrime = false;        }    }    if (isPrime) {        std::println("Число {} простое", n);    } else {        std::println("Число {} не простое", n);    }}
Ввод программы
2147483647
Вывод
Число 2147483647 простое

Три решения заслуживают комментария. Флаг инициализируется выражением n >= 2, так что 0, 1 и отрицательные числа объявляются непростыми без входа в цикл: забыть этот случай есть типичная ошибка, потому что при n=1n = 1 цикл не делает ни одной итерации и программа объявила бы 1 простым. Условие for состоит из двух частей: флага, который останавливает цикл на первом делителе, и границы. А граница записана как d <= n / d, что для положительных целых равносильно d2≤nd^2 \leq n (целочисленное деление отбрасывает дробную часть, но dd не превосходит усечённого частного ровно тогда, когда не превосходит n/dn/d, потому что dd целое). Число из входных данных, 231−12^{31} - 1, есть наибольшее значение int, и оно простое; программа устанавливает это за 46 33946\,339 остатков, остатков от деления на кандидатов от 2 до 46 34046\,340.

Инвариант цикла таков: ни одно целое от 2 до d−1d - 1 не делит nn, пока флаг остаётся истинным. Он верен при входе, когда промежуток от 2 до 1 пуст; он сохраняется, потому что если dd не делит nn, то промежуток пополняется числом, которое не делит; а при выходе с истинным флагом инвариант и ложное условие, d2>nd^2 > n, вместе говорят, что ни один кандидат до n\sqrt{n} не делит nn, а это и требует предложение. Вариант цикла есть количество кандидатов, которые осталось перебрать, то есть целых чисел между dd и n\sqrt{n}: оно уменьшается на единицу за итерацию и не опускается ниже нуля.

Условие, которое переполняется

Естественный способ записать границу есть d * d <= n, и это ошибка. С теми же входными данными цикл доходит до d=46 341d = 46\,341, и тогда произведение 46 3412=2 147 488 28146\,341^2 = 2\,147\,488\,281 превосходит максимум int.

Программа на C++
#include <iostream>#include <print>​int main(){    int n{};    std::cin >> n;    bool isPrime{n >= 2};    for (int d{2}; isPrime && d * d <= n; ++d) {        if (n % d == 0) {            isPrime = false;        }    }    std::println("Простое? {}", isPrime);}
Ввод программы
2147483647
Вывод
main.cpp:9:33: runtime error: signed integer overflow: 46341 * 46341 cannot be represented in type 'int'

Программа завершилась с кодом 1.

Санитайзер неопределённого поведения останавливает программу на произведении. Без него знаковое переполнение есть неопределённое поведение, и стандарт не говорит, что происходит. То, что наблюдается с GCC в Linux, с оптимизацией и без неё, поучительно: произведение «заворачивается» и оказывается отрицательным или меньшим nn, а поскольку ни одно значение int не превосходит максимума, условие d * d <= n больше никогда не становится ложным; цикл продолжает перебирать кандидатов около двух миллиардов итераций, пока не дойдёт до d=nd = n, которое делит nn, и программа объявляет составным простое число. Ошибка переполнения проявляется не как переполнение, а как неверный ответ. Форма d <= n / d переполниться не может, потому что делит вместо того, чтобы умножать; урок Биты и математическая библиотека предлагал другой выход, вычислить границу один раз с помощью std::sqrt, но тогда точность границы зависит от округления в арифметике с плавающей точкой, а у целочисленного деления этой проблемы нет.

Урок «Простые числа и основная теорема арифметики» курса «Алгебра и геометрия I» доказывает, что каждое целое число, большее 1, единственным образом раскладывается в произведение простых; перебор делителей есть самый прямой способ найти это разложение: делить nn на каждый найденный делитель и продолжать с частным.