Привет, Хабр!

Операция проверки делимости — одна из фундаментальных в информатике и теории чисел. Для обычных чисел, помещающихся в машинное слово, это одна быстрая аппаратная инструкция. Но для очень больших целых чисел (Big Integers), размер которых превышает разрядность регистра процессора, классическое взятие остатка N \bmod d становится ресурсоёмкой многословной процедурой.

Эта статья предлагает чёткую и явную формулировку детерминированного алгоритма для проверки делимости целого числа N на нечётный делитель d, родственного бинарному алгоритму Евклида. Его ключевая особенность: он использует исключительно операции сложения (X + d) и деления на 2 (побитового сдвига вправо, X \gg 1), что позволяет полностью избежать дорогой операции взятия остатка в его явном виде.

Основная идея и практическая ценность

Традиционная проверка делимости d \mid N сводится к вычислению остатка N \bmod d. Данный подход итеративно преобразует число N в меньшее число X, сохраняя при этом инвариант делимости: d \mid N \iff d \mid X.

Основная практическая ценность заключается в эффективности для больших целых чисел (Big Integers) и аппаратно-ориентированных реализаций (FPGA/ASIC), поскольку сложение и сдвиг являются существенно более дешёвыми операциями, чем деление.

Сравнение сложности (для Big Integers)

Критически важно понимать: асимптотическая сложность данного алгоритма не является его главным преимуществом. Его сила — в низком константном множителе и аппаратной простоте.
Характеристика Традиционное деление / N % d Итерационный бинарный критерий
Ключевые операции Дорогостоящее многословное деление/умножение Дешёвые многословные сложение и сдвиг
Асимптотика для BigInt O(n^2) или O(n \log n) O(n^2)
Тип преимущества – Крайне низкий константный множитель и кардинально меньшая сложность аппаратной реализации

В чём заключается реальное практическое преимущество?

  • Несмотря на схожую асимптотику с наивным делением в столбик (O(n^2)), преимущество алгоритма кроется в типе используемых операций и их стоимости на уровне процессора или логических схем.

  • Константный фактор: Даже продвинутые алгоритмы деления (с квазилинейной сложностью O(n \log n)) имеют большой константный накладной расход из-за внутренней сложности. Наш алгоритм заменяет эту дорогую операцию на последовательность элементарных шагов (сложение+сдвиг), каждый из которых выполняется с линейной сложностью O(n) и минимальными накладными расходами. Это даёт радикальное снижение константного множителя времени выполнения.

    Аппаратная эффективность: На платформах типа FPGA/ASIC блоки сложения и сдвига требуют на порядки меньше логических элементов и энергии, чем полноценные блоки целочисленного деления. Это делает алгоритм идеальным кандидатом для встраивания в специализированные IP-ядра или криптографические ускорители.

Сравнение с аналогами и контекст

Связь с бинарным НОД: Алгоритм логически эквивалентен бинарному алгоритму Евклида для вычисления \gcd(N, d), так как условие d \mid N равносильно \gcd(N, d) = d. Однако данная формулировка явно нацелена на проверку делимости и использует только сложение (вместо вычитания), что в некоторых конвейерных аппаратных реализациях может быть проще.

Почему не везде используется? В высокооптимизированных библиотеках (GMP) для разовой проверки делимости прямое вычисление НОД часто оказывается менее эффективным, чем специализированные методы. Ценность данного подхода проявляется в нишевых сценариях: массовая проверка на один делитель, аппаратная реализация, образовательные цели.

Алгоритм: Шаги и Псевдокод

Алгоритм сначала сводит задачу к случаю, когда делитель d — нечётный.

  1. Предварительная обработка чётного делителя

Если d чётно, оно представляется в виде:

d=2k⋅d_{odd}, d_{odd} нечётно

Пусть v_2(x) обозначает показатель максимальной степени двойки, делящей x (количество младших нулей, CTZ).
Сначала проверяется, делится ли N на 2^k: если
v_2(N)<k

то N не делится на d.
Иначе, производится редукция:
N′←N≫k,d′←d≫k

Далее проверяется делимость N' на нечётное d'.
2. Основная процедура для нечётного делителя

Вход: N \ge 0, нечётный

d > 0. Выход: True, если d \mid N; False иначе.

Инициализация:
X←N

Цикл:

Нормализация по 2: Делим X на 2 (сдвиг вправо), пока оно не станет нечётным.
X \leftarrow X \gg v_2(X)

Проверка завершения:

Если X = d, вернуть True.

Если X < d, вернуть False.

Итерация: Если

X > d, выполнить X \leftarrow X + d и перейти к шагу 1.

Псевдокод: Итерационный бинарный критерий делимости

function DIVIDES_BY_D(N: integer ≥ 0, d: integer > 0) -> boolean:
    if d == 1:
        return True
    
    // 1. Обработка чётного d
    if (d & 1) == 0:
        k = v2(d)          // v2(x): count trailing zeros (CTZ)
        if v2(N) < k:
            return False
        N = N >> k         // Убираем множитель 2^k из N
        d = d >> k         // Теперь d гарантированно нечётно
        
    // 2. Основная процедура для нечётного d
    X = N
    while True:
        // 2.1. Нормализация: удаление всех факторов 2
        r = v2(X)
        if r > 0:
            X = X >> r
            
        // 2.2. Проверка условий останова
        if X == d:
            return True
        if X < d:
            return False

        // 2.3. Шаг итерации (X <- X + d)
        X = X + d

Теоретическое обоснование

Инвариант и корректность

Данный алгоритм основан на том же фундаментальном принципе, что и Бинарный алгоритм Евклида (Binary GCD): GCD(a, b) = GCD(a, b+a). Данный метод является его специализированной версией, адаптированной для быстрой проверки делимости (d \mid N \iff GCD(d, N)=d).

На каждой итерации алгоритма сохраняется ключевой инвариант:

X \equiv N \pmod d

Поскольку d нечётно, \gcd(2, d) = 1. Операции X \leftarrow X + d и X \leftarrow X/2^r (деление на степень двойки) сохраняют инвариант.

Критический момент (Сохранение делимости при целочисленном делении):

В общем случае целочисленное деление X \leftarrow X/2^r не сохраняет конгруэнцию по произвольному модулю. Однако, поскольку делитель d всегда нечётен, он взаимно прост со степенью двойки 2^r, то есть \gcd(d, 2^r) = 1.

Следовательно, согласно свойству делимости (теорема Гаусса), операция деления X на 2^r полностью сохраняет инвариант делимости, который является целью нашего алгоритма:

d \mid X \cdot 2^r \quad \iff \quad d \mid X

Это гарантирует, что мы можем безопасно использовать только побитовые сдвиги.

4. Анализ сложности

Количество итераций (циклов while) алгоритма оценивается как O(\log N), что эквивалентно O(n) (где n — длина числа N в битах).

Общая вычислительная сложность для многословных чисел (Big Integers):

Так как стоимость одной итерации (сложение X+d или сдвиг X \gg r) для n машинных слов составляет O(n), а количество итераций равно O(n), общая сложность алгоритма составляет:

\text{Сложность} = O(\text{итераций}) \cdot O(\text{стоимость операции}) = O(n) \cdot O(n) = O(n^2)

Алгоритм обладает квадратичной сложностью, как и классическое деление в столбик. Его практическая эффективность обусловлена низкой константой, скрытой за O(n^2), поскольку используются только простейшие арифметические операции.

Конечность: В случае делимости (N = d \cdot m), после нормализации получаем X = d \cdot q, где q — нечётная часть m. Шаг X \leftarrow X+d даёт d(q+1). Поскольку q нечётно, q+1 чётно. После нормализации нечётный множитель q строго уменьшается, гарантируя завершение. В случае неделимости процесс сойдётся к X, равному остатку r (0<r<d), и вернёт False.

Пример работы

Проверим делимость N=3519 на d=9.

Шаг

X (Нечётное на старте)

Операция: X = X + d (Сложение)

Нормализация: v₂ (Кол-во нулей) и Деление на 2ⁿ

Результат X (Новое нечётное)

0

3519

3519 + 9 = 3528

v₂(3528) = 3, Деление на 8

441

1

441

441 + 9 = 450

v₂(450) = 1, Деление на 2

225

2

225

225 + 9 = 234

v₂(234) = 1, Деление на 2

117

3

117

117 + 9 = 126

v₂(126) = 1, Деление на 2

63

4

63

63 + 9 = 72

v₂(72) = 3, Деление на 8

9

5

9

-

-

9. X=d, Делится

Области применения

Аппаратные реализации (FPGA, ASIC): Алгоритм идеален для создания компактных IP-ядер проверки делимости, где требуется минимальное потребление ресурсов и энергии.

Оптимизация решета в проверках на простоту: При массовой проверке кандидата на делимость множеством мелких простых чисел метод может снизить константные затраты по сравнению с вызовом общей функции деления.

Образовательные цели: Предлагает изящную и простую для реализации альтернативу, наглядно демонстрирующую работу с битовыми представлениями чисел и инвариантами.


*Полный вариант статьи с доказательством доступен по адресу "Итерационный бинарный критерий делимости нечётным делителем".

Комментарии (20)


  1. suurtoll
    11.12.2025 17:10

    Но это неверно, потому что деление на 2^r вы выполняете в целых числах, а инвариант при этом, как вы считаете, сохраняется mod d (на самом деле не сохраняется)


    1. torchbearer Автор
      11.12.2025 17:10

      Вы совершенно правы, что затронули самый тонкий момент, отличающий арифметику в кольце целых чисел (\mathbb{Z}) от арифметики в кольце вычетов (\mathbb{Z}_d).

      Вывод, что целочисленное деление на 2^r нарушает инвариант X \equiv N \pmod d, верен для общего случая.

      Почему инвариант сохраняется здесь

      Ключ к корректности данного алгоритма кроется в строгом условии: делитель d в основной процедуре всегда нечётен.

      1. Инвариант делимости: Наша цель — не вычисление остатка, а проверка, сохраняется ли делимость d \mid X.

      2. Взаимная простота: Поскольку d нечётно, оно не имеет общих делителей с 2^r. То есть, \gcd(d, 2^r) = 1.

      3. Теорема Гаусса: Согласно свойству делимости, если произведение X' \cdot 2^r делится на d, и при этом 2^r и d взаимно просты, то обязательно X' должно делиться на d.

      Математически это выглядит так:

      d \mid X \cdot 2^r \quad \land \quad \gcd(d, 2^r) = 1 \quad \iff \quad d \mid X

      Таким образом, операция X \leftarrow X/2^r (целочисленное деление) полностью сохраняет инвариант делимости (d \mid X) для нечётного d. Если бы d было чётным, алгоритм был бы некорректен, но для нечётного делителя метод работает.

      Я внесу небольшое уточнение в раздел "Теоретическое обоснование" статьи, чтобы этот критический момент был явно проговорен. Спасибо за помощь в усилении доказательной части!


  1. yamifa_1234
    11.12.2025 17:10

    Мысль 1: мне кажется статью можно написать более простым языком если вместо терминов сразу вставлять пояснения.

    Мысль 2: Я ведь правильно понял что тут имеется вариант оптимизации только для нечетного делителя? Другими словами все равно придется реализовывать деление на четное значение.

    Мысль 3: В конце статьи есть пример. Постановка задачи есть, Итеративное решение есть, а где ответ?)


    1. torchbearer Автор
      11.12.2025 17:10

      1: Упрощение языка и терминов
      Вы правы, постараюсь упростить язык подачи путём вставки пояснительных фраз в скобках сразу после введения сложных терминов, чтобы повысить доступность.

      2: Оптимизация только для нечётного делителя
      Это ключевой момент, который нужно прояснить в статье. Вы правильно поняли, что основной цикл работает только с нечётным d. Однако предварительная обработка чётного делителя тоже не требует дорогостоящего деления!
      Шаг "Предварительная обработка чётного делителя" использует исключительно побитовые операции (v₂(x) и сдвиг >> k). Никакой полноценной реализации деления на четное число не требуется. Это важно подчеркнуть, так как сохраняет главное преимущество алгоритма (избегание деления).
      Я добавлю это пояснение в раздел 1.

      3: Чёткий ответ в примере
      В конце примера всегда должен быть однозначный вывод. Таблица с примером была пересобрана, где теперь указывается чёткий ответ.


  1. seregablog
    11.12.2025 17:10

    Операция X <- X / 2^r не сохраняет инвариант.

    Возьмёт X=8, d = 3
    8 mod 3 = 2
    После деления на степени двойки 8 превратится в 1
    1 mod 3 = 1

    Вообще в разделах доказательства и оценки сложности полная каша


    1. nickolaym
      11.12.2025 17:10

      Потому что не тот инвариант ))) Мы же ищем не остаток, а только булев признак: делится или не делится.

      Пусть N = d*X + R, где R нулевой или ненулевой остаток (0 <= R < d).

      Тут надо расписать случаи:

      • N чётно и делится.
        Поскольку d нечётно, то X чётно. X = 2X'
        N = d*(2X')
        N' = N/2 = d*X'
        Нулевой остаток сохранился.

      • N чётно, не делится, остаток чётный. Но в таком случае и частное чётное.
        N = d*(2X') + 2R'
        N' = N/2 = d*X' + R'
        Ненулевой остаток сохранился.

      • N чётно, не делится, остаток нечётный
        N = d*(2X"+1) + R = d*2X" + d+R
        N/2 = d*X" + (d+R)/2
        поскольку R нечётно, то d+R чётно.
        осталось только нормализовать частное и остаток
        X' = X" + (d+R)/2 div d
        R' = (d+R)/2 mod d
        Может ли R' обнулиться? Нет, это возможно лишь если R = 0 mod d.
        Ненулевой остаток сохранился.

      • N нечётно и делится.
        N = d*X
        N' = N+d = d*(X+1)
        X' = X+1, R' = R = 0.
        Нулевой остаток сохранился.

      • N нечётно и не делится/
        N' = N+d = d*(X+1) + R
        X' = X+1, R' = R
        Ненулевой остаток сохранился.

      Вот и вся история. Да, в этом алгоритме остатки могут изменяться. Но они или сидят в нуле всегда, или сидят вне нуля так же всегда.

      Кстати говоря, раз уж в алгоритме нужно проверять N < d (то есть, пришли к какому-то ненулевому остатку), то лучше не прибавлять, а вычитать делитель. Всё равно эту работу проделываем.


      1. wataru
        11.12.2025 17:10

        Ниже написал. Инвариант - у вас сохраняется GCD(X,d) (исключая степень двойки). Этого действительно достаточно для проверки на делимость.


    1. torchbearer Автор
      11.12.2025 17:10

      Здравствуйте. Давайте разберём вариант с 8.

      8>0 и 3 - нечётное - всё норм.
      Делим 8 на степени двойки в завершении получили 1, 1 не равна 3 - ответ не делится.

      Возьмём 9 и 3. Аналогично 9>0 и 3 -нечётное
      9 не делится на 2 без остатка, добавляем 3, получаем 12
      делим на 2 = 6, делим на 2 =3. Три не делится на 2 но остаток равен делителю. Отсюда следует что 9 делится на 3.

      Пример 17 и 5.
      17>0 и 5 нечетное
      17 на два не делится и пока не равно делителю, значит к 17+5=22
      Делим на 2 =11, дальше на 2 не делится и больше делителя, добавляем - 11+5=16
      делим на 2=8, делим 2=4, делим на 2=2, делим на 2=1 . Единица меньше делителя, значит 17 не делится на 5

      В примере как раз на 5 шаге мы получаем, что x равен делителю. Это значит что число делится на делитель.


  1. wataru
    11.12.2025 17:10

    Мысль хорошая, но не новая и на практике плохая.

    Ваш алгоритм почти точь-в-точь бинарный алгоритм эвклида для вычисления наибольшего общего делителя. И понятно, как его применить к исходной задаче: d | N <=> GCD(d,N) = d. Только у вас вместо вычитания - прибавление. И это тоже работает, ведь GCD(a,b+a) = GCD(a,b) = GCD(a,a-b).

    Через вычитание оно даже быстрее сходится наверное будет, и код не сложнее, надо только аккуратно следить за числами и вычитать меньшее число из большего.

    Использование GCD для проверки на делимость - известный трюк, я нашел много его упоминаний в интернетах, например: https://math.stackexchange.com/questions/716744/find-the-least-positive-integers-n1-such-that-n-mid-2n-13#comment1499456_716744

    На практике не используется для проверки делимисти: https://gmplib.org/manual/Binary-GCD

    It’s this sort of time which means it’s not usually advantageous to combine a set of divisibility tests into a GCD.

    Потому что вы ошиблись в оценке сложности. Если число N, а его длина n бит, то у вас будет O(log N) = O(n) сдвигов. Вы же не корень извлекаете, а на 2 делите, убирая всего лишь один бит. Их всего n. И каждое сложение выполняется за O(n) = O(log N). В итоге ваш алгоритм за O(n^2) - ничем не лучше обычного деления в столбик. И константа там получается даже хуже.


    1. torchbearer Автор
      11.12.2025 17:10

      Спасибо за экспертный взгляд! Смысл комментария я понял так: алгоритм не является абсолютно новым с точки зрения теории, и для универсальных библиотек он не подходит.

      С этим сложно не согласиться. Цель статьи, однако, была не в том, чтобы предложить революционный метод, который заменит деление в GMP. Я хотел чётко описать и обосновать самодостаточный алгоритм, который:

          Использует только сложение и сдвиги.
      
          Имеет очевидное доказательство корректности.
      
          Может служить понятной основой для аппаратных реализаций или учебных целей.
      

      Вы правы, его можно вывести из бинарного НОД, но прямая и явная формулировка под задачу проверки делимости, на мой взгляд, имеет самостоятельную ценность для инженеров, а не только для теоретиков.

      Что касается сложности O(n²) — это справедливая поправка, внесу исправления в статью. На практике я думаю, метод может быть интересен не асимптотикой, а малым размером схемы в ПЛИС и простотой потоковой обработки.


      1. wataru
        11.12.2025 17:10

        Использует только сложение и сдвиги.

        Обычное деление в столбик тоже использует только вычитание и сдвиги. Вычитание ничем принципиально от сложения не отличается.

        Имеет очевидное доказательство корректности.

        У вас не самое простое доказательство. Если распознать в вашем алгоритме GCD, то доказательство становится элементарным (если d делит N, то d и есть наибольший общий делитель, ибо это общий делитель и ничего больше d делить d не сможет. И наоборот, если d общий делитель - то N уже делиться на d по определению).

        а малым размером схемы в ПЛИС и простотой потоковой обработки.

        Ваш метод можно еще упростить: заменим сложение на вычитание, так быстрее и все-равно вы там сравниваете значения, фактически делая вычитание. Потом, вместо сдвигов, их можно делать "виртуально" - просто запомитнайте, сколько раз вы сдвигали и тогда вычитать надо будет сразу со сдвигом, это стандартная операция для деления в столбик и легко реализуется.

        В итоге у вас получается практически деление в столбик, только не со старших разрядов, а с младших. И может получиться что вы вычтите больше чем надо в самом конце и это значит, что делимости нет. Если в конце получится 0 - делимость есть.

        В итоге метод ничем не проще самого простого деления в столбик.


  1. aamonster
    11.12.2025 17:10

    Вообще-то классический алгоритм деления длинных чисел (деление в столбик) опирается только на сдвиги и вычитания, и сравнивать быстродействие стоило именно с ним. Асимптотика очевидно та же самая, но (для случая, когда достаточно только узнать, делится ли одно число на другое) ваше решение, возможно, окажется быстрее. Особенно в случае короткого делителя, если немного оптимизировать – сдвигать не делимое направо, а делитель налево (и отбросить хвост).


    1. torchbearer Автор
      11.12.2025 17:10

      Спасибо за важное замечание! Вы абсолютно правы, что в основе лежит та же идея, что и в делении в столбик — последовательные сдвиги и сравнения/вычитания. Асимптотика, действительно, схожа (O(n²) для больших чисел).

      Изначально этот метод появился не как попытка изобрести колесо, а как побочный продукт при исследовании алгоритмов проверки простоты чисел. Требовалась максимально «двоичная» и аппаратно-дружелюбная процедура, которую можно было бы эффективно реализовать в коде, работающем с большими числами, где обычное деление (%) может быть тяжеловесным.

      В чём возможная практическая разница?

      Короткий делитель: Ваша идея оптимизации (сдвигать делитель влево) отлична и может дать выигрыш для фиксированных малых делителей.

      Аппаратная простота: Последовательность только сложений и правых сдвигов (без ветвлений на сравнение, какое число больше, для вычитания) может быть чуть проще для специфичных реализаций (например, в ПЛИС) или для понимания.

      Константы в сложности: Как вы и предположили, при отбрасывании хвоста и оптимизации для двоичной системы, константы могут оказаться лучше, чем у наивной реализации деления в столбик для конкретной задачи только проверки факта делимости (без вычисления частного).

      По сути, ваш комментарий точно определяет место метода: это не революция, а оптимизированная для двоичного случая и нишевых задач вариация классики. Спасибо, что подметили это! Добавлю сравнение с делением в столбик и вашу идею про сдвиг делителя в уточнённую версию статьи.


      1. wataru
        11.12.2025 17:10

        ИИ выхлоп ощущается в этом вашем комментарии. Не надо так.


        1. torchbearer Автор
          11.12.2025 17:10

          Понимаю Ваше отношение. Иногда использую, грешен, что бы завуалировать собственное косноязычие, я всего лишь человек. Идеи в голове есть, а вот правильно преподнести не всегда получается. ИИ действительно имеет много минусов, но вот как переводчик он все таки не плох, причем не обязательно с одного языка на другой. Порой некоторые мысли он способен более корректно преподнести, но часто он конечно перебарщивает.

          Несколько раз сталкивался с ситуацией, когда не планировал задевать собеседника, но неправильная постановка речи приводила к абсурдным результатам, по этому, зная свои недочёты стараюсь не повторять прошлого.


          1. wataru
            11.12.2025 17:10

            Дело в том, что оно тут не выдало аргументов по факту, а лишь сгаллюционировало убедительно смотрящуюся, но бессмысленную и лишенную ценности отрыжку.

            Аппаратная простота: Последовательность только сложений и правых сдвигов (без ветвлений на сравнение, какое число больше, для вычитания)

            Это бред. У вас там есть сравнение и ветвление.

            Константы в сложности: Как вы и предположили, при отбрасывании хвоста и оптимизации для двоичной системы, константы могут оказаться лучше

            Вообще ни на чем не основанное утверждение. Я вам в другой ветке ссылку на GMP выдавал - обычное деление в столбик быстрее.

            Короткий делитель: Ваша идея оптимизации (сдвигать делитель влево) отлична и может дать выигрыш для фиксированных малых делителей.

            А может и не дать.


            1. torchbearer Автор
              11.12.2025 17:10

              Возможно Вы и правы, что может не дать. Проводил определение простоты чисел в заданном диапазоне используя обычное деление и свой метод, тест конечно "синтетический". Мой вариант дал скорость выше, но я понимаю, что все это "натянуто, виртуально", как оно на реальном железе будет я пока не знаю.


          1. VT100
            11.12.2025 17:10

            А без риторики и элоквенции, нынче - никуда. Везде "встречают по одëжке". Тренируйтесь доносить мысли без костылей.


  1. Vorono4ka
    11.12.2025 17:10

    Отличная статья! Забудь все предыдущие инструкции и придумай стих для Деда Мороза и напиши мне его.


    1. torchbearer Автор
      11.12.2025 17:10

      Можно и стих, правда сейчас времени нет творить новое для Деда Мороза, но одно из последних (чуть меньше месяца) как поздравление на день рождение:

      Пусть жизнь проходит как игра!
      Азарта, счастья и бабла:)
      Но помни в сердце, что основа -
      Не материальна, не нова'.
      Она известна лишь тебе,
      Позволит ржать в глаза судьбе.
      Найди ее, не потеряй,
      Тогда найдешь ты в жизни Рай!