С++ - Почему boost:: hash_combine лучший способ комбинировать хэш-значения?

Я читал в других сообщениях, что это лучший способ комбинировать хэш-значения. Может кто-нибудь, пожалуйста, сломайте это и объясните, почему это лучший способ сделать это?

template <class T>
inline void hash_combine(std::size_t& seed, const T& v)
{
    std::hash<T> hasher;
    seed ^= hasher(v) + 0x9e3779b9 + (seed<<6) + (seed>>2);
}

Изменить: Другой вопрос заключается только в запросе магического числа, но я хотел бы узнать о всей функции, а не только этой части.

Ответы

Ответ 1

Это "лучший" аргумент.

Это "хорошо" или даже "очень хорошо", по крайней мере поверхностно, легко.

seed ^= hasher(v) + 0x9e3779b9 + (seed<<6) + (seed>>2);

Мы предположим, что seed является предыдущим результатом hasher или этого алгоритма.

^= означает, что бит слева и биты справа изменяют биты результата.

hasher(v) считается приличным хешем на v. Но остальное - защита, если это не приличный хеш.

0x9e3779b9 - это 32-битное значение (оно может быть расширено до 64 бит, если size_t, возможно, 64 бит), который содержит половину 0 с и половину 1 с. Это в основном случайный ряд из 0s и 1s, выполненный путем аппроксимации конкретной иррациональной константы как значения фиксированной точки базы-2. Это помогает убедиться, что если хешер возвращает плохие значения, мы все равно получаем мазок 1 и 0 в нашем выпуске.

(seed<<6) + (seed>>2) немного перетасовывает входящее семя.

Представьте, что константа 0x отсутствует. Представьте, что хешер возвращает константу 0x01000 для почти каждого v, переданного в. Теперь каждый бит семени распределяется по следующей итерации хэша, в течение которого он снова распространяется.

После одной итерации seed ^= (seed<<6) + (seed>>2) 0x00001000 становится 0x00041400. Тогда 0x00859500. Когда вы повторяете операцию, любые биты набора "размазаны" по выходным битам. В конце концов, правый и левый бит сталкиваются и переносят сдвиг установленного бита из "четных местоположений" в "нечетные местоположения".

Биты, зависящие от значения входного семени, растут относительно быстро и сложными способами, когда операция объединения повторяется при операции семени. Добавление причин несет, что мажет вещи еще больше. Константа 0x добавляет кучу псевдослучайных битов, которые заставляют скучные хэш-значения занимать более нескольких бит хэш-пространства после объединения.

Это несимметрично благодаря добавлению (объединение хэшей "dog" и "god" дает разные результаты), оно обрабатывает скучные значения хеша (сопоставление символов с их значением ascii, которое включает только скручивание нескольких бит). И это достаточно быстро.

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

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

Ответ 2

Это не самое лучшее, удивительно для меня это даже не особенно хорошо. entropy effect of a single bit change Рисунок 1: Энтропийный эффект изменения одного бита в одном из двух случайных 32-разрядных чисел, объединяемых в одно 32-разрядное число, с использованием boost :: hash_combine

boost entropy matrix Рисунок 2: Влияние изменения одного бита в одном из двух случайных 32-битных чисел на результат boost :: hash_combine

Энтропийный эффект изменения одного бита в любом объединяемом значении должен быть не менее log (2) [черная линия]. Как видно из рисунка 1, это не относится к старшему биту начального значения, а также немного к старшему значению. Это означает, что статистически старшие биты в семени теряются. Используя битовые вращения вместо битовых сдвигов, xor или сложения с переносом вместо простого сложения, можно легко создать аналогичный hash_combine, который лучше сохраняет энтропию. Также, когда hash и seed оба имеют низкую энтропию, предпочтительнее использовать hash_combine, который распространяет больше. Вращение, которое максимизирует этот разброс, является золотым сечением, если число хэшей, которые должны быть объединены, заранее неизвестно или велико. Используя эти идеи, я предлагаю следующий hash_combine, который использует 6 операций точно так же, как boost, но обеспечивает более хаотичное поведение хеш-функции и лучше сохраняет входные биты. Конечно, всегда можно сойти с ума и выиграть конкурс, добавив всего лишь одно умножение на неравное целое число, это очень хорошо распределит хэши.

proposed entropy response Рисунок 3: Энтропийный эффект изменения одного бита в одном из двух случайных 32-разрядных чисел, объединяемых в одно 32-разрядное число, с использованием предложенной альтернативы hash_combine

proposed response matrix Рисунок 4. Влияние изменения одного бита в одном из двух случайных 32-разрядных чисел на результат предложенной альтернативы hash_combine

#include <iostream>
#include <limits>
#include <cmath>
#include <random>
#include <bitset>
#include <iomanip>
#include "wmath.hpp"

using wmath::popcount;
using wmath::reverse;

using std::cout;
using std::endl;
using std::bitset;
using std::setw;


constexpr uint32_t hash_combine_boost(const uint32_t& a, const uint32_t& b){
  return a^( b + 0x9e3779b9 + (a<<6) + (a>>2) );
}

template <typename T,typename S>
typename std::enable_if<std::is_unsigned<T>::value,T>::type
constexpr rol(const T n, const S i){
  const T m = (std::numeric_limits<T>::digits-1);
  const T c = i&m;
  return (n<<c)|(n>>((-c)&m)); // this is usually recognized by the compiler to mean rotation, try it with godbolt
}

template <typename T,typename S>
typename std::enable_if<std::is_unsigned<T>::value,T>::type
constexpr ror(const T n, const S i){
  const T m = (std::numeric_limits<T>::digits-1);
  const T c = i&m;
  return (n>>c)|(n<<((-c)&m)); // this is usually recognized by the compiler to mean rotation, try it with godbolt
}

template <typename T>
typename std::enable_if<std::is_unsigned<T>::value,T>::type
constexpr circadd(const T& a,const T& b){
  const T t = a+b;
  return t+(t<a);
}

template <typename T>
typename std::enable_if<std::is_unsigned<T>::value,T>::type
constexpr circdff(const T& a,const T& b){
  const T t = a-b;
  return t-(t>a);
}

constexpr uint32_t hash_combine_proposed(const uint32_t&seed, const uint32_t& v){
  return rol(circadd(seed,v),19)^circdff(seed,v);
}

int main(){
  size_t boost_similarity[32*64]    = {0};
  size_t proposed_similarity[32*64] = {0};
  std::random_device urand;
  std::mt19937 mt(urand());
  std::uniform_int_distribution<uint32_t> bit(0,63);
  std::uniform_int_distribution<uint32_t> rnd;
  const size_t N = 1ull<<24;
  uint32_t a,b,c;
  size_t collisions_boost=0,collisions_proposed=0;
  for(size_t i=0;i!=N;++i){
    const size_t n = bit(mt);
    uint32_t t0 = rnd(mt);
    uint32_t t1 = rnd(mt);
    uint32_t t2 = t0;
    uint32_t t3 = t1;
    if (n>31){
      t2^=1ul<<(n-32);
    }else{
      t3^=1ul<<n;
    }
    a = hash_combine_boost(t0,t1);
    b = hash_combine_boost(t2,t3);
    c = a^b;
    size_t count = 0;
    for (size_t i=0;i!=32;++i) boost_similarity[n*32+i]+=(0!=(c&(1ul<<i)));
    a = hash_combine_proposed(t0,t1);
    b = hash_combine_proposed(t2,t3);
    c = a^b;
    for (size_t i=0;i!=32;++i) proposed_similarity[n*32+i]+=(0!=(c&(1ul<<i)));
  }

  for (size_t j=0;j!=64;++j){
    for (size_t i=0;i!=32;++i){
      cout << setw(12) << boost_similarity[j*32+i] << " ";
    }
    cout << endl;
  }

  for (size_t j=0;j!=64;++j){
    for (size_t i=0;i!=32;++i){
      cout << setw(12) << proposed_similarity[j*32+i] << " "; 
    }
    cout << endl;
  }
}

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

mul•ror•mul•ror•mul

Смотрите также: fooobar.com/info/79932/...