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

Операция проверки делимости — одна из самых фундаментальных в информатике и теории чисел. Для обычных чисел, помещающихся в машинное слово, это одна быстрая аппаратная инструкция. Но для очень больших целых чисел (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 \bmod d

Итерационный бинарный критерий

Ключевые операции

Деление (дорого)

Сложение и Сдвиг (дёшево)

Сложность для Big Int

O(n²) или O(n log n)

O( n log n)

Эффективность

Низкая для Big Integers

Асимптотически выше для больших N

В чем практическое преимущество?

Несмотря на схожесть асимптотических оценок сложности, преимущество алгоритма кроется в типе используемых операций.

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

  • Данный алгоритм заменяет эту дорогую операцию на O(\log N) итераций, каждая из которых состоит только из сложения и побитового сдвига.Это самые быстрые и простые операции с линейной сложностью O(n). *Это обеспечивает радикальное снижение константного фактора времени выполнения, делая алгоритм особенно эффективным для аппаратных платформ (FPGA/ASIC), где блоки сложения и сдвига требуют меньше логических элементов и энергии, чем блоки деления.

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

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

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

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

d=2^k \cdot d_{\text{odd}}, \quad d_{\text{odd}} \text{ нечётно}

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

v_2(N) < k

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

N' \leftarrow N \gg k, \quad d' \leftarrow d \gg k

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

2. Основная процедура для нечётного делителя

Входные параметры:

N \ge 0, \quad d \text{ нечётное}, \quad d > 0

Инициализация:

X \leftarrow N

В цикле выполняются:

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

  2. Шаг итерации (если

    X > d):

X \leftarrow X + d

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

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): количество младших нулей (CTZ)
        if v2(N) < k:      // Проверка делимости N на 2^k
            return False
        N = N >> k         // N = N / 2^k
        d = d >> k         // d = d / 2^k (Теперь d нечётно)
        
    // 2. Основная процедура для нечётного d
    X = N                  // Инициализация
    while True:
        // 2.1. Нормализация (Удаление всех факторов 2 из X)
        r = v2(X)          // Подсчёт младших нулей
        if r > 0:
            X = X >> r     // X = X / 2^r
            
        // 2.2. Проверка условий останова
        if X == d:         // Если X = d, возвращаем True
            return True
        if X < d:          // Если X < d, возвращаем False
            return False

        // 2.3. Выполнение шага итерации (X <- X + d)
        X = X + d          // Переход к шагу 2.1.

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

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

X \equiv N \pmod d

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

Сложность алгоритма составляет

O(n \cdot \log N)

где n — количество машинных слов в числе, а \log N — количество итераций цикла.

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

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

Шаг

X (Нечётное)

Итерация: X \leftarrow X+d

Нормализация (X \leftarrow X \gg v_2(X))

Результат X

0

3519

3519+9 = 3528

v_2(3528)=3

3528 / 8 = 441

1

441

441+9 = 450

v_2(450)=1

450 / 2 = 225

2

225

225+9 = 234

v_2(234)=1

234 / 2 = 117

3

117

117+9 = 126

v_2(126)=1

126 / 2 = 63

4

63

63+9 = 72

v_2(72)=3

72 / 8 = 9

5

9

-

-

9


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

  • Библиотеки для работы с большими числами (GMP, OpenSSL): Замена многословного деления на многословное сложение и сдвиг.

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


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

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


  1. suurtoll
    11.12.2025 17:10

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


  1. yamifa_1234
    11.12.2025 17:10

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

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

    Мысль 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. 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. aamonster
    11.12.2025 17:10

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