В прошлой статье мы разработали следующее:

  1. Алгоритмы псевдослучайной генерации: xorshift64, lehmer64, xoshiro256pp;

  2. Алгоритмы Фибоначчи и конвертеры из миль в километры через них;

  3. Алгоритмы быстрого обратного квадратного корня;

  4. Алгоритм бинарного возведения в степень.

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

❯ Быстрое вычисление приближенного значения степени

В прошлой статье мне предложили разобрать быстрое вычисление приближённого значения степени:

inline double fastPow(double a, double b) {
    union {
        double d;
        int x[2];
    } u = { a };
    u.x[1] = (int)(b * (u.x[1] - 1072632447) + 1072632447);
    u.x[0] = 0;
    return u.d;
}

Данный алгоритм — это аппроксимация функции aᵇ, основанная на особенностях представления чисел с плавающей точкой в стандарте IEEE 754. Алгоритм использует тот факт, что возведение в степень можно выразить через экспоненту и логарифм: aᵇ = exp(b × ln(a)).

В стандарте IEEE 754 тип double имеет 53 бита мантиссы. Структура числа двойной точности: знак — 1 бит, порядок — 11 бит, мантисса — 52+1 бит. Число представляется как value = (-1)^sign × 2^(exponent - 1023) × (1 + mantissa/2^52)

Ошибка составляет примерно 1–10% в зависимости от значений a и b. Алгоритм работает значительно быстрее стандартной функции pow().

Сама функция сначала разбирает double на целочисленные компоненты через union. В старших 32 битах содержится порядок числа, который примерно равен логарифму от a по второй степени. Потом идет преобразование u.x[1], оно вычисляет u.x[1] - 1072632447 ≈ log₂a, затем умножение на b даёт b × log₂a, и в конце прибавление 1072632447 преобразует обратно в формат double. После преобразования идет обнуление младших 32 бит, устанавливая мантиссу в 0. Магическое число 1072632447 = 0x3FF00000 - 60701 — это смещённое представление логарифма.

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

Но у этого алгоритма есть более быстрая версия (из этой статьи):

float fastestPow(float a, float b) {
    union
    {
        float d;
        int x;
    } u = { a };

    u.x = (int)(b * (u.x - 1065353210) + 1065353210);

    return u.d;
}

Этот алгоритм работает за счёт манипуляции битовым представлением чисел float.

Число с плавающей точкой хранится в виде знака, порядка и мантиссы. Порядок по сути является логарифмом числа по основанию 2. Алгоритм использует это свойство: вычитание магического числа 1065353210 (которое соответствует числу 1.0 в битовом представлении) приближённо вычисляет логарифм исходного числа a. Умножение на b даёт b × log₂a. Затем добавление магического числа обратно преобразует результат в битовое представление числа с плавающей точкой.

Алгоритм быстрее предыдущего метода, но и менее точен из-за использования float вместо double. По сути, float-версия делает только одну операцию с битовым представлением вместо двух и работает с данными вдвое меньшего размера, что даёт выигрыш в производительности за счёт потери точности по сравнению с double.

❯ Быстрое деление на 3

Эта функция заменяет медленное целочисленное деление на 3 быстрым умножением и сдвигом. Она использует магическую константу 0xAAAAAAAB, которая представляет собой приближение 2³²/3.

uint32_t div3(uint32_t x) {
    return (uint32_t)(((uint64_t)x * 0xAAAAAAABULL) >> 33);
}

При умножении 32-битного числа на эту 64-битную константу старшие 32 бита результата содержат приблизительное значение x/3. Сдвиг вправо на 33 бита извлекает этот результат.

Почему 33 бита, а не 32? При сдвиге на 32 мы получаем 2/3 от x, а не x/3. Изначально я использовал сдвиг на 32 бита, и гадал, почему 12 / 3 = 8...

Такая оптимизация работает в 10–20 раз быстрее обычного деления, поскольку умножение и битовые операции выполняются процессором значительно быстрее операции деления. Этот метод широко используется компиляторами для оптимизации деления на известные константы.

❯ Алгоритм XOR Swap

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

void xor_swap(int *a, int *b) {
    if (a != b) {
        *a ^= *b;
        *b ^= *a;
        *a ^= *b;
    }
}

Классический, но всегда впечатляющий новичков трюк: обмен значений двух переменных с помощью трёх операций XOR. Это демонстрирует симметрию и свойства операции XOR. Хотя на современных процессорах это часто медленнее, чем использование временной переменной (из-за параллелизма), трюк остаётся красивым и поучительным.

Первая операция сохраняет разность a и b в a, вторая, используя исходное b и новое a, получает исходное a и сохраняет в b, и последняя, используя новое a и новое b, получает исходное b и сохраняет в a.

❯ Трюк Кернигана x & -x

Данный хак выделяет младший установленный бит числа. Работает это благодаря представлению отрицательных чисел в дополнительном коде. Отрицательное число -x вычисляется как ~x + 1 (инверсия всех битов плюс 1). При инверсии все нули становятся единицами. Когда добавляется 1, происходит цепная реакция переносов: все младшие нули становятся единицами, пока перенос не дойдет до первой единицы в исходном числе. Эта единица остается единственной общей между x и -x.

Пример для 12 (1100):

x = 1100
-x = 0100 (в дополнительном коде)
x & -x = 0100

Ниже предоставлено два варианта, которые дают один результат, но работают принципиально по-разному.

Первая функция работает с исходным числом: постепенно сдвигает исходное число x вправо, затем проверяет младший бит (x & 1) == 0, работает за O(n), где n — количество trailing zeros.

Вторая функция (от Кернигана) работает с выделенным битом, сначала вычисляет x & -x — получает число с одной единицей в позиции первого ненулевого бита, после сдвигает это число до тех пор, пока оно не станет равным 1. Работает за O(m), где m — позиция первого ненулевого бита (что равно n).

Эффективность также может отличаться, первая функция работает быстрее при малом количестве trailing zeros, а вторая может быть немного быстрее при работе с большим количеством trailing zeros.

int count_trailing_zeros(unsigned int x) {
    if (x == 0) return sizeof(x) * 8;

    int count = 0;
    while ((x & 1) == 0) {
        count++;
        x >>= 1;
    }
    return count;
}

int count_trailing_zeros_kernighan(unsigned int x) {
    if (x == 0) return sizeof(x) * 8;

    unsigned int lowest_set_bit = x & -x;

    int count = 0;
    while (lowest_set_bit > 1) {
        count++;
        lowest_set_bit >>= 1;
    }
    return count;
}

❯ ГПСЧ PCG

PCG (Permuted Congruential Generator) — это семейство современных ГПСЧ, сочетающих простоту линейного конгруэнтного генератора (LCG) с качественным выходным преобразованием (permutation). PCG быстр, требует мало памяти, имеет огромный период и проходит строгие статистические тесты. Он считается одним из лучших «лёгких» ГПСЧ.

В основе — простой LCG для обновления состояния. Магия происходит в выходной функции: она берёт старшие биты состояния, применяет XOR и сдвиг, а затем циклический сдвиг (ротацию) на случайное количество бит, определяемое другими старшими битами. Это ломает линейные зависимости, присущие простым LCG.

typedef struct { uint64_t state;  uint64_t inc; } pcg32_random_t;

uint32_t pcg32_random_r(pcg32_random_t* rng) {
    uint64_t oldstate = rng->state;
    rng->state = oldstate * 6364136223846793005ULL + (rng->inc | 1);
    uint32_t xorshifted = ((oldstate >> 18u) ^ oldstate) >> 27u;
    uint32_t rot = oldstate >> 59u;
    return (xorshifted >> rot) | (xorshifted << ((-rot) & 31));
}

Структура typedef struct { uint64_t state; uint64_t inc; } pcg32_random_t; имеет два параметра — первый это state (текущее состояние генератора), и inc, константу смещения (increment). Этот параметр фиксируется при инициализации генератора и определяет его поток (stream). Разные inc дают абсолютно разные, непересекающиеся последовательности чисел. Это очень мощная фича для параллельных симуляций.

Вторым шагом идет классический шаг LCG: new_state = old_state * multiplier + increment. Сам по себе LCG — довольно слабый генератор. Его младшие биты имеют очень короткие периоды (например, младший бит просто чередуется 0, 1, 0, 1...), а в многомерных пространствах точки, сгенерированные LCG, выстраиваются в предсказуемые гиперплоскости (парадокс Марсальи). Поэтому мы не можем просто взять младшие 32 бита от 64-битного state — они будут выглядеть очень неслучайно.

Парадокс Марсальи (теорема Марсальи) в теории случайных чисел описывает недостатки псевдослучайных чисел, получаемых в результате линейного конгруэнтного генератора. Это утверждение связано с работой американского математика Джорджа Марсальи, который предложил в 1965 году генератор Макларена — Марсальи — криптографически стойкий генератор псевдослучайных чисел.

В конце и происходит вся магия, превращающая слабый LCG в мощный генератор. Первый этап это XSH (Xor Shift) — старшие биты состояния сдвигаются и объединяются операцией XOR с младшими битами. Это «ломает» линейные зависимости LCG, перемешивая биты. Второй шаг это RR (Random Rotate) — другие старшие биты состояния определяют, на сколько бит нужно циклически сдвинуть результат первого шага. Это добавляет нелинейность и усложняет предсказание.

Простой LCG обеспечивает скорость и большой период, а функция вывода маскирует его недостатки, создавая на выходе статистически безупречные числа. Это делает PCG быстрым, компактным и надёжным генератором.

❯ Трюк Боба Дженкинса для быстрого хеширования — lookup3

Это один из классических, быстрых и качественных некриптографических хеш-алгоритмов. Он использует смесь сложения, XOR и битовых сдвигов для тщательного перемешивания битов.

#define rot(x, k) (((x) << (k)) | ((x) >> (32 - (k))))

void jenkins_mix(uint32_t *a, uint32_t *b, uint32_t *c) {
    *a -= *c; *a ^= rot(*c, 4); *c += *b;
    *b -= *a; *b ^= rot(*a, 6); *a += *c;
    *c -= *b; *c ^= rot(*b, 8); *b += *a;
    *a -= *c; *a ^= rot(*c, 16); *c += *b;
    *b -= *a; *b ^= rot(*a, 19); *a += *c;
    *c -= *b; *c ^= rot(*b, 4); *b += *a;
}

Lookup3 предназначен для одной главной цели: быстро преобразовать произвольные входные данные (ключ) в целое число (хеш) с равномерным распределением. Он не является криптографическим, то есть его нельзя использовать для паролей, цифровых подписей или где важна стойкость к взлому. Его сфера применения — это в первую очередь хеш-мапы, и прочие структуры и алгоритмы, где нужна «случайность» на основе данных.

Вращение битов — сердце перемешивания, оно реализовано в макросе rot, это циклический сдвиг. Функция jenkins_mix отвечает за первоначальный микс чисел. Она берет три числа (a, b, c) и за 12 шагов так их перемешивает, что исходные данные становятся неразличимы.

Полный исходный код от Боба Дженкинса можно посмотреть по этой ссылке.

Быстрый модуль 2^n и быстрая проверка на степень двойки

Под конец статьи стоит рассказать о двух быстрых коротких функциях.

Начнем с быстрой проверки на степень двойки:

int8_t is_power_of_two(uint32_t x) {
    return x && !(x & (x - 1));
}

Эта функция возвращает true (истина), если число x является степенью двойки (1, 2, 4, 8, 16, 32, …), и false (ложь) в противном случае.

Функция работает на наблюдении, что степень двойки в двоичной системе — это всегда число, в котором есть ровно одна единица (1), а все остальные биты — нули (0). Степени двойки в двоичном виде имеют ровно одну единицу. Выражение x & (x-1) сбрасывает младшую единицу числа. Если результат равен нулю и само число не ноль — значит, была всего одна единица, что соответствует степени двойки.

Разберем теперь более простую функцию быстрого модуля 2^n:

uint32_t fast_mod(uint32_t x, uint32_t mod) {
    return x & (mod - 1);
}

Работает только когда mod — степень двойки (2^n). Остаток от деления на 2^n определяется последними n битами числа. Выражение mod-1 создает битовую маску из n единиц. Операция x & (mod-1) выделяет эти последние n битов, что эквивалентно взятию модуля.

Пример: 13 % 8 = 5, так как 13 (1101) & 7 (0111) = 5 (0101).

❯ Бенчмаркинг

С учетом функционала из прошлой части, я разработал бенчмарк:

PRNG Performance (10,000,000 iterations):
-----------------------------------------
xorshift64:      28.92 ms  (345.76M numbers/s)
lehmer64:        43.29 ms  (231.00M numbers/s)
xoshiro256pp:    33.13 ms  (301.87M numbers/s)
pcg32:           27.99 ms  (357.32M numbers/s)
-----------------------------------------

Conversion Methods Performance (each method called 10000 times per point):
----------------------------------------------------------------------
Basic                    :     0.58 ms  ( 0.003 us/call)
Fibonacci Interpolation  :     3.38 ms  ( 0.017 us/call)
Fibonacci Cache          :     2.90 ms  ( 0.014 us/call)
Golden Ratio             :    33.72 ms  ( 0.169 us/call)
Golden Ratio (Binary)    :     7.30 ms  ( 0.036 us/call)
----------------------------------------------------------------------

Accuracy Comparison (5 sample points):
Miles |   Basic   | Interpol |  Cache   |  Golden  | GoldenBin
------+-----------+----------+----------+----------+-----------
    5 |      8.05 |    0.58% |    0.58% |    0.58% |    0.58%
   30 |     48.28 |    0.53% |    0.53% |    0.53% |    0.53%
   55 |     88.51 |    0.55% |    0.55% |    0.55% |    0.55%
   80 |    128.75 |    0.54% |    0.54% |    0.54% |    0.54%
  100 |    160.93 |    0.54% |    0.54% |    0.54% |    0.54%
---------------------------------------------------------------

Math Algorithms Performance (1000000 iterations):
--------------------------------------------
fast_pow:                2.87 ms  ( 0.003 us/call)
fastest_pow:             2.41 ms  ( 0.002 us/call)
fast_mod:                2.45 ms  ( 0.002 us/call)
is_power_of_two:         2.32 ms  ( 0.002 us/call)
jenkins_hash:           58.44 ms  ( 0.058 us/call)
jenkins_mix+final:      60.88 ms  ( 0.061 us/call)
xor_swap:                4.51 ms  ( 0.005 us/call)
div3:                    2.79 ms  ( 0.003 us/call)
--------------------------------------------

Как мы видим, pcg32 реально оказался самым быстрым генератором на данный момент.

И вот график по математическим и прочим алгоритмам:

❯ Заключение

Спасибо за прочтение статьи! Я надеюсь, вы узнали что-то новенькое, или может, какой-нибудь трюк натолкнул вас на другой интересный алгоритм. Если нашли нюанс в самой статье — пишите в комментарии. Возможно, я продолжу серию таких статей на тему трюков на разных языках программирования.

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

Примеры работы кода вы можете увидеть в моем репозитории The Art Of Fun C.

❯ Источники

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.

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