Почему hashCode() возвращает одно и то же значение для разных объектов в Java?

Цитата из книги, которую я читаю Head First Java:

Дело в том, что хэш-коды могут быть одинаковыми, не обязательно гарантируя, что объекты равны, потому что "алгоритм хеширования", используемый в методе hashCode(), может возвращать одно и то же значение для нескольких объектов.

Почему метод hashCode() возвращает одно и то же значение для разных объектов? Это не вызывает проблем?

Ответы

Ответ 1

"хеширование" объекта означает "найти хорошее, описательное значение (число), которое может быть воспроизведено одним и тем же экземпляром снова и снова". Поскольку хэш-коды из Java Object.hashCode() имеют тип int, вы можете иметь только 2 ^ 32 разных значения. Вот почему у вас будут так называемые "столкновения" в зависимости от алгоритма хеширования, когда два разных объекта производят один и тот же хэш-код.

Как правило, это не создает никаких проблем, поскольку hashCode() в основном используется вместе с equals(). Например, HashMap вызовет hashCode() на своих ключах, чтобы узнать, могут ли ключи уже содержаться в HashMap. Если HashMap не находит хеш-код, очевидно, что ключ еще не содержится в HashMap. Но если это так, ему придется дважды проверить все ключи, имеющие тот же хэш-код, с помощью equals().

т.е.

A.hashCode() == B.hashCode() // does not necessarily mean
A.equals(B)

Но

A.equals(B) // means
A.hashCode() == B.hashCode()

Если equals() и hashCode() реализованы правильно.

Более точное описание общего контракта hashCode см. в Javadoc.

Ответ 2

Существует только более 4 миллиардов возможных хэш-кодов (диапазон int), но количество объектов, которые вы могли бы выбрать, намного больше. Поэтому некоторые объекты должны использовать один и тот же хеш-код, в соответствии с принципом pigeonhole.

Например, число возможных строк, содержащих 10 букв из A-Z, составляет 26 ** 10, что равно 141167095653376. Невозможно присвоить всем этим строкам уникальный хеш-код. И это не важно - хэш-код не обязательно должен быть уникальным. Для реальных данных требуется не слишком много коллизий.

Ответ 3

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

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

String[] dictionary = new String[DICT_SIZE];
dictionary[15] = "Hello";
dictionary[121] = "world";

System.out.println(dictionary[15]); // prints "Hello"

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

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

String[] dictionary = new String[DICT_SIZE];
// "a" -> "Hello"
dictionary["a".hashCode()] = "Hello";

// "b" -> "world"
dictionary["b".hashCode()] = "world";

System.out.println(dictionary["b".hashCode()]); // prints world

Но эй, что, если есть какой-то объект, который мы хотели бы использовать в качестве ключа, но его метод hashCode возвращает значение, большее или равное DICT_SIZE? Тогда мы получим исключение ArrayIndexOutOfBoundsException, и это было бы нежелательно. Итак, позвольте просто сделать это как можно большим, не так ли?

public static final int DICT_SIZE = Integer.MAX_VALUE // Ooops!

Но это означало бы, что нам придется выделять гинеормные объемы памяти для нашего массива, даже если мы только намерены хранить несколько элементов. Так что это не может быть лучшим решением, и на самом деле мы можем сделать лучше. Предположим, что у нас была функция h, что для любого заданного DICT_SIZE отображает произвольные целые числа в диапазон [0, DICT_SIZE[. Затем мы могли бы просто применить h к тому, что возвращает метод hashCode() ключевого объекта, и быть уверенным, что мы остаемся в границах базового массива.

public static int h(int value, int DICT_SIZE) {
    // returns an integer >= 0 and < DICT_SIZE for every value.
}

Эта функция называется хэш-функцией. Теперь мы можем адаптировать нашу реализацию словаря, чтобы избежать исключения ArrayIndexOutOfBoundsException:

// "a" -> "Hello"
dictionary[h("a".hashCode(), DICT_SIZE)] = "Hello"

// "b" -> "world"
dictionary[h("b".hashCode(), DICT_SIZE)] = "world"

Но это порождает еще одну проблему: что, если h отображает два разных ключевых индекса в одно и то же значение? Например:

int keyA = h("a".hashCode(), DICT_SIZE);
int keyB = h("b".hashCode(), DICT_SIZE);

может давать одинаковые значения для keyA и keyB, и в этом случае мы случайно перезаписали бы значение в нашем массиве:

// "a" -> "Hello"
dictionary[keyA] = "Hello";

// "b" -> "world"
dictionary[keyB] = "world"; // DAMN! This overwrites "Hello"!!

System.out.println(dictionary[keyA]); // prints "world"

Ну, вы можете сказать, тогда нам просто нужно убедиться, что мы реализуем h таким образом, что этого никогда не произойдет. К сожалению, это вообще невозможно. Рассмотрим следующий код:

for (int i = 0; i <= DICT_SIZE; i++) {
    dictionary[h(i, DICT_SIZE)] = "dummy";
}

Этот цикл хранит значения DICT_SIZE + 1 (всегда то же самое значение, а именно String "dummy") в словаре. Mhh, но массив может хранить только DICT_SIZE разные записи! Это означает, что при использовании h мы должны перезаписать (по крайней мере) одну запись. Или, другими словами, h отобразит два разных ключа на одно и то же значение! Эти "столкновения" нельзя избежать: если n голубей попытаются войти в n-1 отверстия голубя, по крайней мере два из них должны войти в ту же дыру.

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

String[] dictionary = new String[DICT_SIZE];

пишем:

List<String>[] dictionary = new List<String>[DICT_SIZE];

(Боковое замечание: обратите внимание, что Java не позволяет создавать массивы общих типов, поэтому приведенная выше строка не будет компилироваться, но вы получите идею).

Это изменит доступ к словарю следующим образом:

// "a" -> "Hello"
dictionary[h("a".hashCode(), DICT_SIZE)].add("Hello");

// "b" -> "world"
dictionary[h("b".hashCode(), DICT_SIZE)].add("world");

Если наша хешфункция h возвращает разные значения для всех наших ключей, это приведет к спискам только с одним элементом, а извлечение элементов очень просто:

System.out.println(dictionary[h("a".hashCode(), DICT_SIZE)].get(0)); // "Hello"

Но мы уже знаем, что в общем случае h иногда отображает разные ключи на одно и то же целое число. В этих случаях списки будут содержать более одного значения. Для поиска нам нужно пройти весь список, чтобы найти "правильное" значение, но как мы его узнаем?

Ну, вместо того, чтобы хранить только одно значение, мы всегда можем хранить полную (ключ, значение) пару в списках. Затем поиск будет выполняться в два этапа:

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

Теперь добавление и извлечение стали настолько сложными, что неприлично относиться к отдельным методам для этих операций:

List<Pair<String,String>>[] dictionary = List<Pair<String,String>>[DICT_SIZE];

public void put(String key, String value) {
    int hashCode = key.hashCode();
    int arrayIndex = h(hashCode, DICT_SIZE);

    List<Pair<String,String>> listAtIndex = dictionary[arrayIndex];
    if (listAtIndex == null) {
        listAtIndex = new LinkedList<Pair<Integer,String>>();
        dictionary[arrayIndex] = listAtIndex;
    }

    for (Pair<String,String> previouslyAdded : listAtIndex) {
        if (previouslyAdded.getValue().equals(value)) {
            return; // the value is already in the dictionary;
        }
    }

    listAtIndex.add(new Pair<String,String>(key, value));
}

public String get(String key) {
    int hashCode = key.hashCode();
    int arrayIndex = h(hashCode, DICT_SIZE);

    List<Pair<String,String>> listAtIndex = dictionary[arrayIndex];
    if (listAtIndex != null) {
        for (Pair<String,String> previouslyAdded : listAtIndex) {
            if (previouslyAdded.getKey().equals(key)) {
                return previouslyAdded.getValue(); // entry found!
            }
        }
    }

    // entry not found
    return null;
}

Итак, для того, чтобы этот подход работал, нам действительно нужны две операции сравнения: метод hashCode для поиска списка в массиве (это работает быстро, если hashCode() и h являются быстрыми) и equals, который нам нужен при просмотре списка.

Это общая идея хэширования, и вы узнаете метод put и get из java.util.Map.. Конечно, вышеупомянутая реализация является упрощением, но она должна проиллюстрировать суть всего этого.

Естественно, что этот подход не ограничивается строками, он работает для всех видов объектов, поскольку методы hashCode() и equals являются членами класса java.lang.Object верхнего уровня и всех других классов, наследуемых от что один.

Как вы можете видеть, на самом деле не имеет значения, возвращают ли два разных объекта одинаковое значение в свой метод hashCode(): этот подход всегда будет работать! Но все же желательно, чтобы они возвращали разные значения, чтобы снизить шансы на хэш-коллизии, вызванные h. Мы видели, что их нельзя избежать на 100% в целом, но чем меньше мы столкнемся, тем эффективнее становится наша хэш-таблица. В худшем случае все ключи сопоставляются с одним и тем же индексом массива: в этом случае все пары хранятся в одном списке, а поиск значения становится операцией с затратами, линейными по размеру хеш-таблицы.

Ответ 4

Значение hashCode() может использоваться для быстрого поиска объекта с использованием хеш-кода в качестве адреса в ведро таблицы хэш-таблицы, где оно хранится.

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

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

Ответ 5

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

Ответ 6

Мне нужно подумать, что довольно неэффективный алгоритм хэширования для 2 объектов имеет один и тот же хеш-код.