Доброго времени суток, господа и дамы! Иногда у некоторых людей возникает желание заняться откровенным непотребством в программировании — то, что не несет практической пользы напрямую, но помогает развлечься. И я — не исключение. В этой статье я хочу рассказать вам о лайфхаках, трюках (магических и не очень) и алгоритмах на языке C!

Идея написать эту статью зародилась из моего поста, после него я начал серию статей, которая раскрывала много интересных моментов — от математических алгоритмов и оптимизации до ГПСЧ.

Если вы видите эту статью, значит еще не все тайны C раскрыты. В этой статье будет еще больше свежих хаков, фанов, трюков, еще больше магии и скорости! А также нетипичных алгоритмов и структур данных, что позволит вам почерпнуть и полезную информацию тоже!

Добро пожаловать в новую часть. Прошу под кат — там будет жарко, быстро и очень, очень интересно.


❯ Содержание

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


❯ Алгоритм bitboard для шахмат/игр

Использование 64-битных чисел для игрового поля — классика оптимизации.

#include <stdio.h>
#include <stdint.h>

typedef uint64_t Bitboard;

Bitboard knights_attacks(Bitboard knights) {
    Bitboard l1 = (knights >> 1) & 0x7F7F7F7F7F7F7F7F;
    Bitboard l2 = (knights >> 2) & 0x3F3F3F3F3F3F3F3F;
    Bitboard r1 = (knights << 1) & 0xFEFEFEFEFEFEFEFE;
    Bitboard r2 = (knights << 2) & 0xFCFCFCFCFCFCFCFC;
    return (l1 | r1) << 16 | (l1 | r1) >> 16 | (l2 | r2) << 8 | (l2 | r2) >> 8;
}

void print_bitboard(Bitboard bb) {
    for (int row = 7; row >= 0; row--) {
        for (int col = 0; col < 8; col++) {3
            int square = row * 8 + col;
            printf("%c ", (bb >> square) & 1 ? '1' : '.');
        }
        printf("\n");
    }
    printf("\n");
}

int main() {
    Bitboard knight = 1ULL << 36;  /* конь на e4*/

    printf("позиция коня:\n");
    print_bitboard(knight);

    printf("возможные ходы коня:\n");
    Bitboard attacks = knights_attacks(knight);
    print_bitboard(attacks);

    return 0;
}

Фанфакт: в английском как шахматная фигура конь это не horse, а knight. Ладья — Rook (грач/утес/башня). Слон — Bishop (епископ).

Функция knights_attacks вычисляет все возможные ходы коня за несколько битовых операций. Принцип такой: конь ходит буквой «Г» — на две клетки в одном направлении и одну в перпендикулярном.

Алгоритм делает сдвиги в четыре стороны. Сначала сдвигает позиции коней влево и вправо на 1 и 2 клетки (l1, r1, l2, r2). Маски 0x7F... и 0xFE... нужны чтобы не было «перетекания» битов через края доски — они отсекают боковые столбцы.

Затем комбинируем эти сдвиги. Объединение l1 и r1 дает горизонтальные смещения на одну клетку. Сдвиг этой комбинации на 16 рядов вверх и вниз дает вертикальное смещение на две клетки — получаем ходы типа «две вверх/вниз, одна вбок». Аналогично l2 и r2 дают смещение на две клетки по горизонтали. Сдвиг на 8 рядов дает смещение на одну клетку по вертикали — это ходы «одна вверх/вниз, две вбок».

Битовое ИЛИ всех четырех вариантов дает полную маску атак.

Главная прелесть — всего 10 битовых операций для любого количества коней на доске. Вместо перебора 64 клеток или циклов по фигурам — мгновенный расчет. Именно так работают современные шахматные движки.

При запуске мы получаем такой красивый вывод:

позиция коня:
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . 1 . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .

возможные ходы коня:
. . . . . . . .
. . . 1 . 1 . .
. . 1 . . . 1 .
. . . . . . . .
. . 1 . . . 1 .
. . . 1 . 1 . .
. . . . . . . .
. . . . . . . .

❯ Неточное сравнение дробных чисел

Алгоритм решает проблему неточного сравнения чисел с плавающей точкой. Прямое сравнение через == часто даёт ложный результат из-за особенностей двоичного представления и накопления ошибок округления.

bool fuzzy_float_eq(float a, float b) {
    float abs_diff = fabsf(a - b);
    float max_abs = fmaxf(fabsf(a), fabsf(b));
    return abs_diff <= max_abs * 1e-6f || abs_diff < 1e-12f;
}

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

int main() {
    float num1 = 1.0000009901f;
    float num2 = 1.0000009902f;

    if (fuzzy_float_eq(num1, num2)) {
        printf("Числа считаются равными\n");
    } else {
        printf("Числа различны\n");
    }

    return 0;
}

Но так как это нечеткое сравнение, данный код выдаст что числа num1 и num2 равные. По сути, суть алгоритма в том, что числа считаются равными, если они либо очень близки относительно их величины, либо отличаются на исчезающе малую величину.

❯ ГПСЧ GJF (Gonzalo Joseph’s Fast)

В этой серии статьи мы давно не затрагивали тему ГПСЧ!

#include <stdint.h>
uint64_t gjf_state[2] = {0x123456789ABCDEF, 0xFEDCBA987654321};

uint64_t gjf64(void) {
    uint64_t s0 = gjf_state[0];
    uint64_t s1 = gjf_state[1];
    gjf_state[0] = s1;
    s0 ^= s0 << 23;
    gjf_state[1] = s0 ^ s1 ^ (s0 >> 17) ^ (s1 >> 26);
    return gjf_state[1] + s1;
}

Это компактный и ⚡ blazingly fast ⚡ (и даже не надо переписывать на раст) генератор, который хранит состояние в двух 64-битных переменных. В основе — перестановка и смешивание битов через сдвиги и XOR.

Его работа основана на быстрых битовых операциях: сдвигах и XOR. В начале функции сохраняются текущие значения состояния в локальные переменные s0 и s1. Первое состояние обновляется простой перестановкой — оно становится равным старому второму состоянию.

Затем начинается перемешивание. Значение s0 подверг��ется операции XOR'а с собственной копией, сдвинутой влево на 23 бита. Этот этап вносит нелинейность и распространяет изменение битов. После этого вычисляется новое значение для второго элемента состояния. Оно представляет собой XOR четырёх компонентов: только что изменённого s0, старого s1, а также сдвинутых вправо на 17 бит версии s0 и на 26 бит версии s1. Такая комбинация сдвигов разной длины обеспечивает тщательное перемешивание битов со всего 128-битного пространства состояния.

Финальный шаг — генерация выходного числа. Функция возвращает сумму только что вычисленного нового второго состояния и старого значения s1. Это сложение улучшает статистические свойства вывода, делая распределение более равномерным. Весь цикл выполняется за несколько тактов процессора, что делает генератор одним из самых быстрых в своём классе. Его период огромен, а качества случайности достаточно для большинства симуляций, игр и задач, не требующих криптографической стойкости.

Давайте посмотрим на пример:

int main() {
    for (int i = 0; i < 5; i++) {
        printf("%016llx\n", gjf64());
    }
    return 0;
}

И код выдаст следующие шестнадцатеричные числа:

ccf7ce0251e21edb
232e19416e19e115
5d6f795c479bf4d2
dc8a1592370d6141
10e2fc8d1d6cc6f0

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

❯ XXTEA — крошечный блочный шифр

Компактный и эффективный шифр для эмбединга, осдева или прочих ваших странных потребностей. XXTEA шифрует массив 32-битных слов налету, используя ключ из 4 слов. Алгоритм выполняет несколько раундов, на каждом смешивая данные с ключом через XOR, сдвиги и сложение.

#include <stdint.h>
#define DELTA 0x9e3779b9
#define MX (((z>>5^y<<2) + (y>>3^z<<4)) ^ ((sum^y) + (key[(p&3)^e] ^ z)))

void xxtea_encrypt(uint32_t *v, int n, uint32_t const key[4]) {
    uint32_t y, z, sum;
    unsigned p, rounds, e;
    if (n < 1) return;
    rounds = 6 + 52/n;
    sum = 0;
    z = v[n-1];
    do {
        sum += DELTA;
        e = (sum >> 2) & 3;
        for (p=0; p<n-1; p++) {
            y = v[p+1];
            z = v[p] += MX;
        }
        y = v[0];
        z = v[n-1] += MX;
    } while (--rounds);
}

Первое, что делает алгоритм — вычисляет число раундов. Формула 6 + 52/n гарантирует, что даже для больших массивов будет выполнено достаточное количество перемешиваний. Начинается цикл с обнулённой переменной sum.

В каждом раунде к sum прибавляется число DELTA. Затем определяется e — индекс для выбора слова ключа, основанный на текущей сумме.

Далее идёт сердце алгоритма — внутренний цикл по всем словам массива, кроме последнего. Для каждого слова вычисляется замысловатая функция MX. В ней через сдвиги, сложения и XOR перемешиваются соседние слова (y и z), текущая сумма и часть ключа. Результат этого микса прибавляется к текущему слову, что и является шифрованием.

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

void xxtea_decrypt(uint32_t *v, int n, uint32_t const key[4]) {
    uint32_t y, z, sum;
    unsigned p, rounds, e;
    if (n < 1) return;
    rounds = 6 + 52/n;
    sum = rounds * DELTA;
    y = v[0];
    do {
        e = (sum >> 2) & 3;
        for (p=n-1; p>0; p--) {
            z = v[p-1];
            y = v[p] -= MX;
        }
        z = v[n-1];
        y = v[0] -= MX;
        sum -= DELTA;
    } while (--rounds);
}

Ну и также покажу функцию для дешифровки, ключевое отличие в обратном порядке операций и направлении. Если шифрование идёт «вперёд», то дешифрование идёт «назад», отменяя каждый шаг. Начальное значение sum не ноль, а rounds * DELTA — полная сумма, которая накопилась бы к концу шифрования. Важно понимать, что порядок обновления переменных y и z в дешифровании обратный по отношению к шифрованию, чтобы гарантировать, что при вычислении MX используются те же значения, что и при шифровании.

Ну и на примере демонстрация работы:

int main() {
    uint32_t data[] = {0x12345678, 0x9ABCDEF0};
    uint32_t key[4] = {0xA1B2C3D4, 0x5E6F7A8B, 0x9C0D1E2F, 0x3A4B5C6D};

    printf("Original:      0x%08X 0x%08X\n", data[0], data[1]);

    xxtea_encrypt(data, 2, key);
    printf("After encrypt: 0x%08X 0x%08X\n", data[0], data[1]);

    xxtea_decrypt(data, 2, key);
    printf("After decrypt: 0x%08X 0x%08X\n", data[0], data[1]);

    return 0;
}

С таким вот выводом:

Original:      0x12345678 0x9ABCDEF0
After encrypt: 0xB9F221ED 0x71F80F5B
After decrypt: 0x12345678 0x9ABCDEF0

Если кому то надо, то на моей системе такой результат времени запуска бинарника:

Original:      0x12345678 0x9ABCDEF0
After encrypt: 0xB9F221ED 0x71F80F5B
After decrypt: 0x12345678 0x9ABCDEF0

________________________________________________________
Executed in    3.96 millis    fish           external
   usr time    1.09 millis    1.09 millis    0.00 millis
   sys time    2.89 millis    1.02 millis    1.87 millis

❯ Прямой доступ к битам дробного числа

В языке C нет прямого способа посмотреть битовое представление числа с плавающей запятой. Трюк в том, чтобы заставить память интерпретироваться по-разному. Для этого используется union — область памяти, в которой данные разных типов хранятся по одному и тому же адресу.

#include <stdio.h>
#include <stdint.h>

typedef union {
    float f;
    struct {
        uint32_t mantissa : 23;
        uint32_t exponent : 8;
        uint32_t sign : 1;
    } bits;
} float_cast;

int main() {
    float_cast fc;
    fc.f = -3.14f;

    printf("Number: %f\n", fc.f);
    printf("Sign: %u\n", fc.bits.sign);
    printf("Exponent: %u\n", fc.bits.exponent);
    printf("Mantissa: 0x%06X\n", fc.bits.mantissa);
    return 0;
}

Объявляется union с именем float_cast. Внутри него два поля: обычное число float f и структура bits. Они занимают один и тот же участок памяти размером 4 байта (32 бита). Когда мы записываем значение в fc.f, биты сразу располагаются в соответствии со стандартом IEEE 754. Через структуру bits мы получаем доступ к этим же битам, но уже как к отдельным именованным полям: знак (1 бит), порядок (8 бит) и мантисса (23 бита). Это не создаёт копии и не конвертирует данные — это просто другая «маска» для просмотра тех же битов.

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

Number: -3.140000
Sign: 1
Exponent: 128
Mantissa: 0x48F5C3

❯ Свертка массива XOR-ом для быстрой проверки изменений

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

Главный принцип алгоритма в его чувствительности к изменениям. Изменится хотя бы один бит в любом элементе массива — и итоговый хэш, скорее всего, будет совершенно другим. Это делает метод идеальным для быстрой проверки целостности данных в памяти или при простом обмене, где не нужна криптографическая стойкость. Скорость работы очень высока, так как это всего один проход по данным с минимальной арифметикой.

#include <stdio.h>
#include <stdint.h>

uint32_t xor_fold(const uint32_t* data, size_t len) {
    uint32_t result = 0;
    for (size_t i = 0; i < len; i++) result ^= data[i];
    return result;
}

int main() {
    uint32_t array1[] = {0x12345678, 0x9ABCDEF0, 0x13579BDF};
    uint32_t array2[] = {0x12345678, 0x9ABCDEF0, 0x13579BDF};
    uint32_t array3[] = {0x12345678, 0x9ABCDEF0, 0x13579BDE};

    printf("Hash array1: %08X\n", xor_fold(array1, 3));
    printf("Hash array2: %08X\n", xor_fold(array2, 3));
    printf("Hash array3: %08X\n", xor_fold(array3, 3));
    return 0;
}

И вот такой вот вывод:

Hash array1: 9BDF1357
Hash array2: 9BDF1357
Hash array3: 9BDF1356

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

❯ ГПСЧ Tyche

Tyche — это вариация генератора Xorshift с 128-битным состоянием (мы его уже рассматривали в прошлой статье). Алгоритм использует сдвиги и XOR для перемешивания. Инициализация выполняет «прогрев» генератора (20 пустых итераций) для улучшения начального состояния. Генератор быстр и подходит для симуляций.

#include <stdio.h>
#include <stdint.h>
#include <time.h>

typedef struct {
    uint32_t a, b, c, d;
} TycheState;

uint32_t tyche_next(TycheState *s) {
    uint32_t t = s->b;
    s->b = s->c;
    s->c = s->d;
    s->a = (s->a ^ (s->a << 11)) ^ (t ^ (t >> 19));
    s->d = s->a ^ s->b ^ s->c;
    return s->d;
}

void tyche_init(TycheState *s, uint32_t seed) {
    s->a = 0x6C078965 * (seed ^ (seed >> 30)) + 1;
    s->b = s->a + 0x6C078965;
    s->c = s->b + 0x6C078965;
    s->d = s->c + 0x6C078965;

    for (int i = 0; i < 20; i++) {
        tyche_next(s);
    }
}

Ключевая структура — TycheState. Это сердце генератора, где хранится его 128-битное состояние в виде четырёх 32-битных целых чисел: a, b, c, d.

Алгоритм работает в два этапа. Первый — инициализация в tyche_init. Она превращает один сид в начальное состояние из четырёх чисел. Используется умножение на константу и XOR для рассеивания битов. После этого генератор «прогревается» 20 холостыми тактами, чтобы устранить возможные корреляции, если начальное зерно было таким себе.

Второй этап — генерация нового числа в tyche_next. Это и есть вариация Xorshift. Алгоритм выполняет быструю перестановку: b, c, d сдвигаются, а значение a интенсивно перемешивается с использованием сдвигов и операции XOR с предыдущим значением b. Новое значение d (которое и возвращается) вычисляется как XOR всех трёх обновлённых регистров состояния. Весь процесс крайне быстр, требует лишь несколько битовых операций на процессоре.

И давайте реализуем пример работы:

int main() {
    TycheState rng;
    tyche_init(&rng, time(NULL));

    printf("рандомные 16битные числа:\n");
    for (int i = 0; i < 10; i++) {
        printf("0x%08X\n", tyche_next(&rng));
    }

    printf("\nВ диапазоне 0..99:\n");
    for (int i = 0; i < 10; i++) {
        printf("%d ", tyche_next(&rng) % 100);
    }
    printf("\n");

    return 0;
}

С вот таким выводом у меня:

рандомные 16битные числа:
0xF01D2505
0x71012006
0xE0182024
0x21292603
0x81310620
0x00002403
0xC0210425
0x81302026
0x88000000
0x89200004

В диапазоне 0..99:
38 20 16 84 72 61 80 52 44 65

❯ Заключение

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

Девятая часть получилась насыщенной: от сортировок и криптографии до битовой магии и математических трюков.

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

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

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