
В разработке ПО произведение двух целых чисел часто вычисляется до фиксированного количества битов с переполнением. Возьмём для примера 8-битные целые. Если умножить 127 на 127, то мы получим число 1 в виде 8-битного беззнакового целого с переполнением. Реальное полное произведение равно 16129. Для представления 16129 обычно используются 16 бит точности.
Таким образом у нас появляется понятие полного произведения. Полное произведение двух 32-битных чисел обычно представляется при помощи 64 бит. У меня возник вопрос, какую долю всех 64-битных чисел можно записать как произведение двух 32-битных целых.
Хотите знать, почему меня это интересует?
Мы часто проектируем хэш-функции: это особые функции, получающие входные данные и генерирующие случайно выглядящие выходные. Много лет назад я проектировал очень быструю хэш-функцию clhash. Эта сверхбыстрая функция предназначена для хэширования строк из сотен и более байт. Если вы не знаете про clhash, то можете почитать про неё, она интересна сама по себе.
В clhash используется тип умножения, характерный для криптографии. Я пытался показать, что такой подход выгоднее по сравнению с методиками, основанными на стандартном умножении. Попробую это проиллюстрировать: простая хэш-функция для 32-битных целых чисел может брать младшие биты и перемножать их со старшими.
// simpleHighLowHash - это простой (и слабый) 32-битный хэш, // умножающий старшие 16 бит на младшие 16 бит. func simpleHighLowHash(x uint32) uint32 { high := uint16(x >> 16) low := uint16(x & 0xFFFF) return uint32(high) * uint32(low) }
Вероятно, вам нужно, чтобы хэш-функция была равномерной: вероятность всех возможных 32-битных значений хэша должна быть одинаковой. В данном случае это возможно, только если хэш-функция может возвращать все 32-битные значения хэша, но это не так.
Великий математик Пал Эрдёш доказал, что доля всех 2n-битных значений, которую можно сгенерировать перемножением двух n-битных значений, при увеличении n стремится к нулю. Это значит, что если, например, вы умножаете 10000000-битные целые на 10000000-битные целые, то получится относительно мало 100000000000000-битных целых. Но как насчёт практических случаев, например, 32-битных или 64-битных целых?
Задачу легко можно решить перебором, перемножая 16-битные числа для получения 32-битных произведений. Там мы выясним, что произведением двух 16-битных целых оказываются чуть больше одного из пяти 32-битных чисел. Примерно 80% из всех 32-битных чисел эта хэш-функция никогда не вернёт. Однако время выполнения растёт экспоненциально, поэтому перебор для 32 бит масштабировать не получится.
Так что же нам делать в случае 32-битных множителей? То есть что делать, когда мы перемножаем два 32-битных целых, чтобы получить 64-битное произведение? Какую долю от всех 64-битных значений вернёт показанная ниже функция?
func simpleHighLowHash(x uint64) uint64 { high := uint32(x >> 16) low := uint32(x & 0xFFFFFFFF) return uint64(high) * uint64(low) }
Можно ли получить точный результат?
Да!
Джонатан Уэбстер с коллегами разработали математический аппарат, позволяющий нам масштабировать как раз такие вычисления. Он любезно опубликовал свой код.
Существует 3 215 709 724 700 470 902 64-битных (беззнаковых) целых чисел, которые можно записать как произведение двух 32-битных целых. Это примерно 17% от всех возможных значений.

А как насчёт реального вычисления пар целых чисел из их произведения? Одно из решений заключается в вычислении его полного разложения на простые числа с последующим получением всех возможных делителей меньше 2^32, начиная с множества кандидатов, содержащих только 1, и итеративно умножая имеющихся кандидатов на каждый простой делитель (оставляя при этом только произведения меньше 2^32). Мы можем избежать добавления во множество дубликатов, обрабатывая уникальные простые делители с кратными им числами. Затем мы выбираем такого максимального кандидата m в качестве наибольшего делителя меньше 2^32, вычисляем соответствующий сомножитель n / m и сообщаем, существует ли допустимое разбиение на два 32-битных множителя. В общем случае, ответ (если он существует) не уникален: он возвращает пару, в котором одно значение максимизировано. На Python код может выглядеть так:
for p in factor_multiplicities: new_candidates = [] for c in candidates: for i in range(factor_multiplicities[p] + 1): if c * (p ** i) < 2**32: new_candidates.append(c * (p ** i)) for new_c in new_candidates: candidates.append(new_c) m = max(candidates) print(f"Maximum candidate: {m}") leftover = n // m print(f"Leftover: {leftover}") if leftover >= 2**32: print("Leftover is too large, cannot find a suitable candidate.")
Вероятно, вы сможете придумать более эффективный алгоритм. Мне показалось интересным, что если выбрать значение случайным образом, то в большинстве случаев его не удастся разложить на два 32-битных множителя!
Комментарии (6)

Shalundrive
26.05.2026 12:02Интересная академическая гимнастика, но как это часто бывает с работами Даниэля Лемира, мы наблюдаем героическое решение проблемы сферического коня в вакууме. Автор искусственно загнал себя в рамки кастрированного сетапа ( 32*32=64), получил закономерные 83% брака (пустот в распределении), а затем мужественно пошёл изобретать сложные хэш-алгоритмы на безпереносном умножении вроде
clhash. Зачем городить эти сложности, если современные процессоры дают нам всё необходимое практически бесплатно? Вместо того чтобы латать ущербный триггер, который математически не вывозит, достаточно просто сменить архитектурный шаблон и развернуть вычисления в ширину. Давайте посмотрим, как устроен современный суперскалярный процессор (будь то Intel, AMD или ARM). Внутри каждого ядра простаивают по 4–6 независимых исполнительных блоков (ALU). Если мы заставляем CPU считать честное 128-битное число на одном конвейере, мы собираем цепочку зависимых инструкций с переносом (типаMUL+ADC), и конвейер встаёт в микрозадержку.Но если мы берём два независимых 64-битных потока данных в параллель, процессор раскидывает их по разным ALU и выполняет операции в один и тот же такт. Скорость выполнения два по 64 в железе равна скорости выполнения одного по 64. Мы получаем 128 бит результата вообще без накладных расходов.При этом математические дыры Лемира уничтожаются на корню:
Мы берём входные данные, разбиваем их на два независимых 64-битных куска (A и B).
Запускаем их через параллельные смесители (миксеры) с использованием огромных 64-битных простых констант (K_1 и K_2), а не 32-битных обрубков. Каждый поток внутри своего 64-битного пространства сразу работает со 100% плотностью.
В самом финале делаем перекрёстное скрещивание (cross-mixing): H_1 = H_1 + H_2; $H_2 = H_2 + H_1.
Энтропия лавиной размазывается между потоками. На выходе мы имеем идеальное, абсолютно равномерное заполнение 128-битного пространства хэшей. Вероятность коллизии — 1 на 2^{128} (скорее вселенная схлопнется, чем вылетит дубликат).
Именно так спроектированы по-настоящему быстрые промышленные хэши (например, 128-битная версия MurmurHash3 или гугловский FarmHash).
В реальных боевых low-latency системах никто в здравом уме не будет использовать вещественные числа из-за хаоса округлений IEEE 754 и капризов компилятора, но и платить 50–100 тактов за программную эмуляцию сверхточного float тоже никто не станет. Схема два по 64 в параллель идеально ложится на нативные регистры общего назначения (
rax,rcxи т.д.), компилируется в плоский ассемблер и утилизирует кремний на 100%.По сути статья Лемира полезна как напоминание о том, почему не надо делать хэши на коленке из 32-битных половинок. Но практический инженерный ответ на эту проблему лежит не в области усложнения математики, а в архитектурной симметрии и параллелизме, которые процессор отдаёт даром.

wataru
26.05.2026 12:02Фи, я-то думал, что там кто-то серьезно подсчитал, но в статье, на которую ссылается автор, нет вычисления M(2^32-1). Есть только алгоритм, который хоть ассимптотически и быстрее тупого перебора, на практике медленнее для вменяемых ограничений. Еще там есть монте-карло симуляция.
Код на гитхабе, судя по всему, будет считать это почти месяц для 2^32-1 на одном компьютере, и не очень-то параллелится.
Откуда взялись эти 17% пока не ясно.
Varim
А это точно так?
Код на c#
Вывел 18446744065119617025
То есть если перемножение максимальных 32 битных значений влезло в 64 бит, то очевидно что другие значения тоже влезут
Varim
А, у вас обратная задача. Тогда не понятно что у вас вызывает удивление.
kipar
Разумеется произведение двух 32 битных влезет в 64 бита. Но обратное неверно - делители большинства 64 битных не влезут в 32 бита.
Akina
Именно делители - у большинства влезут. А вот разложить 64-битное на два сомножителя, каждый из которых влезет в 32 бита - да, для большинства не получится.