Разговор о хеш-функциях

Java алгоритм Android
Разговор о хеш-функциях

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

определение

Цитирование абзаца определения хеш-функции в энциклопедии Baidu.

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

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

природа

ПроверятьВикипедияиЭнциклопедия Байду, оба упоминают несколько моментов о природе хеширования:

1. Уверенность

Если два хеш-значения не совпадают, то исходные входные данные двух хеш-значений также различны;

2. Конфликт (столкновение)

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

3. Необратимость

Последнее касается того, является ли оно обратимым, и эти два выражения различны:


Википедия — хэш-функция Энциклопедия Baidu — Хеш-функция

В Википедии четко сказано, что «хэш-функция должна быть необратимой», а утверждение Baidu Baike двусмысленно, а последнее, напротив, слишком неточное. Автор больше склоняется к упомянутой в Википедии необратимости.

4. Путаница

В разделе «Свойства хеш-функций» Википедия также упоминает, что:

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

В этом выражении два слова: "Сильное запутывание", "полностью отличается". Что это означает?

Давайте сначала разберемся с концепцией:Лавинный эффект
Суть его в том, что "Строгие правила схода лавин": Когда любой из входных битов инвертируется, каждый бит на выходе имеет 50% шанс измениться.

Изучите еще одно понятие:Hamming distance.
Некоторые переводятся как «расстояние Хэмминга», а другие - как «расстояние Хэмминга». Имя не важно, важна коннотация.

Количество битов, в котором соответствующие биты двух кодовых слов имеют разные значения, называется расстоянием Хэмминга двух кодовых слов. Пример следующий: 10101 и 00110 имеют первую, четвертую и пятую позиции, отличные от первой, тогда расстояние Хэмминга равно 3.

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

Пример хэш-функции

Общие хэш-функцииMD5иСемья ШАи другие криптографические хэш-функции,CRCЭто также следует рассматривать как хэш.
Оба используются для проверки данных, а первый также используется для цифровых подписей, аутентификации доступа и других полей безопасности.
Однако сегодня мы не будем много говорить о криптографических хэшах, а в основном поговорим о следующих двух хешах:

BKDRHash

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

    public int hashCode() {
        int h = hash;
        final int len = length();
        if (h == 0 && len > 0) {
            for (int i = 0; i < len; i++) {
                h = 31 * h + charAt(i);
            }
            hash = h;
        }
        return h;
    }

Определите класс, и если вы позволите IDE автоматически сгенерировать функцию hashCode(), ее реализация будет аналогичной:

    public static class Foo{
        int a;
        double b;
        String c;
        
        @Override
        public int hashCode() {
            int result;
            long temp;
            result = a;
            temp = Double.doubleToLongBits(b);
            result = 31 * result + (int) (temp ^ (temp >>> 32));
            result = 31 * result + (c != null ? c.hashCode() : 0);
            return result;
        }
    }

Почему у тебя всегда проблемы с "31"? Зачем итеративно умножать и суммировать вот так?
В этой статье рассматриваются некоторые из этих принципов:Анализ и расширение алгоритма bkdrhash хеш-таблицы
И на Чжиху есть много великих богов, которые сделали анализ:Каков математический принцип алгоритма хеширования и как обеспечить как можно меньше коллизий
Из сравнения баллов, приведенного во второй ссылке, видно, что, хотя BKDRHash прост в реализации, он очень эффективен (низкая частота конфликтов).

Низкая коллизия, так что BKDRHash используется не только для хеш-таблиц, но и для индексации объектов.
Чаще всего используется MD5.Некоторые веб-сайты могут использовать MD5 файла в качестве ключа для извлечения файла.
рисунокDiskLruCacheВ качестве ключа также используется MD5, но обычно MD5 вычисляется не для самого файла, а MD5 для URL-адреса (например, OkHttp, Glide).
Дайджест сообщения, сгенерированный MD5, имеет биты 128. Если идентифицируемых объектов немного, частота конфликтов будет очень низкой;
Когда частота коллизий намного ниже вероятности повреждения оборудования, можно считать надежным использование MD5 в качестве ключа.
Для веб-сайтов, если вы хотите хранить массивные файлы, не рекомендуется использовать MD5 в качестве ключа.
Кстати, UUID на самом деле имеет 128-битную точность, но добавляет еще несколько разделительных линий для удобства чтения.

Это слишком далеко, вернемся к теме~
Причина, по которой я вижу, что BKDRHash используется для индексации объектов, в основном состоит в том, чтобы увидеть эту статью (я не изучал исходный код Volley):
Анализ исходного кода Android Volley (2), изучение механизма кэширования
В нем упоминается дизайн кэшированных ключей Volley:

private String getFilenameForKey(String key) {
       int firstHalfLength = key.length() / 2;
       String localFilename = String.valueOf(key.substring(0, firstHalfLength).hashCode());
       localFilename += String.valueOf(key.substring(firstHalfLength).hashCode());
       return localFilename;
}

Поскольку возвращаемое значение JDK hashCode() имеет тип int, можно сказать, что эта функция имеет 64-битную точность.
Нельзя сказать, что это хэш-функция, потому что длина возвращаемого значения не фиксирована, хэш-функцией ее назвать нельзя по определению, хотя идея очень близка.
Его эквивалент записывается следующим образом:

    public static String getFilenameForKey(String key) {
        byte[] bytes = key.getBytes();
        int h1 = 0, h2 = 0;
        int len = bytes.length;
        int firstHalfLength = len / 2;
        for (int i = 0; i < firstHalfLength; i++) {
            byte ch = bytes[i];
            h1 = 31 * h1 + ch;
        }
        for (int i = firstHalfLength; i < len; i++) {
            byte ch = bytes[i];
            h2 = 31 * h2 + ch;
        }
        long hash = (((long) h1 << 32) & 0xFFFFFFFF00000000L) | ((long) h2 & 0xFFFFFFFFL);
        return Long.toHexString(hash);
    }

Эффект примерно эквивалентен 64-битной точности BKDRHash. 64-битный BKDRHash выглядит следующим образом:

    public static long BKDRHash(byte[] bytes) {
        long seed = 1313; // 31 131 1313 13131 131313 etc..
        long hash = 0;
        int len = bytes.length;
        for (int i = 0; i < len; i++) {
            hash = (hash * seed) + bytes[i];
        }
        return hash;
    }

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

32-битный хэш, независимо от того, какой из них, пока набор данных (случайные данные) равен 10 ^ 6, в основном каждый запуск будет иметь коллизию. 64-битный хеш, пока производительность не так уж плоха, если длина данных относительно велика (например, 20-байтовый случайный массив), трудно конфликтовать даже с набором данных в десятки миллионов (у меня есть не перепробовал сотни миллионов да машина не выдержит).

Автор также был поклонником BKDRHash и какое-то время использовал его в проекте (в качестве ключа кэша). Я знаю, что видел ответ на обсуждение в вышеупомянутой Жиху:



Прочитав Zhihu, я был удивлен, и я вернулся и модифицировал тестовый пример.При построении случайных данных я использовал данные неопределенной длины, например, 1-30 случайных байт.
Протестировано на написанном выше 64bitBKDRHash, результат такой:
Конфликты можно увидеть на наборе данных из 50 000.

Я и раньше знал, что путаница BKDRHash недостаточна (например, значение последнего байта увеличивается на 1, а значение хеша увеличивается только на 1, если оно не переполняется);
Однако из-за его простой реализации и необоснованных результатов тестирования выше, он используется в качестве ключа кэша, ведь Volley сделал то же самое.
На самом деле большой проблемы нет, т.к. ввод обычно длинный, а файлов для кеширования не много (сотни-тысячи уровней), так что конфликтов быть не должно.
Но сердце мое все еще не в покое (см.:закон Мерфи) и быстро переключился на другую хеш-функцию.

MurmurHash

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


Однако есть поговорка, что «люди не могут смотреть на свою внешность», и нельзя судить об алгоритме по имени, но он зависит от его эффекта.
Как показано на скриншоте, многие известные компоненты с открытым исходным кодом используют этот хеш, так где же он священный? Давайте разберемся.
Сначала посмотрите исходный код:сайты.Google.com/site/murmur…
Исходный код написан на C++:
uint64_t MurmurHash64A ( const void * key, int len, unsigned int seed )
{
    const uint64_t m = 0xc6a4a7935bd1e995;
    const int r = 47;

    uint64_t h = seed ^ (len * m);

    const uint64_t * data = (const uint64_t *)key;
    const uint64_t * end = data + (len/8);

    while(data != end)
    {
        uint64_t k = *data++;

        k *= m; 
        k ^= k >> r; 
        k *= m; 
        
        h ^= k;
        h *= m; 
    }

    const unsigned char * data2 = (const unsigned char*)data;

    switch(len & 7)
    {
    case 7: h ^= uint64_t(data2[6]) << 48;
    case 6: h ^= uint64_t(data2[5]) << 40;
    case 5: h ^= uint64_t(data2[4]) << 32;
    case 4: h ^= uint64_t(data2[3]) << 24;
    case 3: h ^= uint64_t(data2[2]) << 16;
    case 2: h ^= uint64_t(data2[1]) << 8;
    case 1: h ^= uint64_t(data2[0]);
            h *= m;
    };
 
    h ^= h >> r;
    h *= m;
    h ^= h >> r;

    return h;
} 

В целом не очень сложно, по сравнению с BKDHHash это все цикл.
Кроме того, C++ может указывать указатель int64 на массив char, который может вычислять 8 байт за раз, а для массивов большей длины операция выполняется быстрее.
Для java это относительно проблематично:

public static long hash64(final byte[] data) {
        if (data == null || data.length == 0) {
            return 0L;
        }
        final int len = data.length;
        final long m = 0xc6a4a7935bd1e995L;
        final long seed = 0xe17a1465;
        final int r = 47;

        long h = seed ^ (len * m);
        int remain = len & 7;
        int size = len - remain;

        for (int i = 0; i < size; i += 8) {
            long k = ((long) data[i] << 56) +
                    ((long) (data[i + 1] & 0xFF) << 48) +
                    ((long) (data[i + 2] & 0xFF) << 40) +
                    ((long) (data[i + 3] & 0xFF) << 32) +
                    ((long) (data[i + 4] & 0xFF) << 24) +
                    ((data[i + 5] & 0xFF) << 16) +
                    ((data[i + 6] & 0xFF) << 8) +
                    ((data[i + 7] & 0xFF));
            k *= m;
            k ^= k >>> r;
            k *= m;
            h ^= k;
            h *= m;
        }

        switch (remain) {
            case 7: h ^= (long)(data[size + 6] & 0xFF) << 48;
            case 6: h ^= (long)(data[size + 5] & 0xFF) << 40;
            case 5: h ^= (long)(data[size + 4] & 0xFF) << 32;
            case 4: h ^= (long)(data[size + 3] & 0xFF) << 24;
            case 3: h ^= (data[size + 2] & 0xFF) << 16;
            case 2: h ^= (data[size + 1] & 0xFF) << 8;
            case 1: h ^= (data[size] & 0xFF);
                h *= m;
        }

        h ^= h >>> r;
        h *= m;
        h ^= h >>> r;

        return h;
    }

Приведенная выше ссылка на реализацию:
GitHub.com/tanema/murmur и…
GitHub.com/themthem180/м…

Сделайте тест, случайный массив, число 10 ^ 7, длина 1-30, результаты следующие:

хэш-функция количество конфликтов Время работы (мс)
BKDRHash 1673 1121
CRC64 1673 1331
MurmurHash 0 1119

Этот тест включает в себя еще один 64-битный хеш, CRC64.
Удивительно, что частота коллизий CRC64 и BKDRHash одинакова (проверено много раз, она одинакова), что может быть причиной проблемы переполнения.
Что касается времени расчета, то особой разницы нет.
Если длина случайного массива настроена на 1-50, частота коллизий первого (BKDRHash & CRC64) будет относительно снижена, а эффективность работы последнего будет немного лучше, чем у первого.

Я думаю, что MurmurHash широко используется не только из-за его низкой конфликтности, но и из-за очень важной особенности:
Упомянутая ранее «сильная путаница».
Напишите функцию для вычисления кодового расстояния следующим образом:

   private static int getHammingDistance(long a, long b) {
       int count = 0;
       long mask = 1L;
       while (mask != 0) {
           if ((a & mask) != (b & mask)) {
               count += 1;
           }
           mask <<= 1;
       }
       return count;
   }

Создайте случайный массив, изменяйте по одному биту за раз, вычисляйте MurmurHash, и кодовое расстояние находится между 31-32.
Для 64-битного хэша это ровно около 50%, что указывает на лавинный эффект хэш-функции.
Или, с другой точки зрения, значение хеш-функции, рассчитанное MurmurHash, является относительно «однородным», что очень важно.
Например, при использовании в хеш-таблицеФильтр БлумаЖдать, Элементы распределены равномерно.

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


Возвращаясь к упомянутому ранее ключу кеша, 64-битному MurmurHash, когда количество кешей находится на уровне 10^4,
Вероятность столкновения двух кэшированных ключей составляет порядка 10^-12 (одна часть на триллион), что должно быть относительно надежным.
Если вы считаете, что этого недостаточно, вы можете использовать 128-битный MurmurHash, который должен быть более подходящим, чем MD5, только для индексации (быстрая работа, низкий уровень коллизий, высокий уровень путаницы).

Эпилог

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