Как справиться с хеш-коллизиями для словарей в Swift

TL;DR

Моя пользовательская структура реализует Hashable Protocol. Однако, когда возникают хэш-коллизии при вставке ключей в Dictionary, они не обрабатываются автоматически. Как решить эту проблему?

Фон

Ранее я задавал этот вопрос Как реализовать протокол Hashable в Swift для массива Int (настраиваемая строковая структура). Позже я добавил мой собственный ответ, который, казалось, работал.

Однако недавно я обнаружил тонкую проблему при столкновении hashValue при использовании Dictionary.

Самый простой пример

Я упростил код, насколько это возможно, в следующем примере.

Пользовательская структура

struct MyStructure: Hashable {

    var id: Int

    init(id: Int) {
        self.id = id
    }

    var hashValue: Int {
        get {
            // contrived to produce a hashValue collision for id=1 and id=2
            if id == 1 {
                return 2 
            }
            return id
        }
    }
}

func ==(lhs: MyStructure, rhs: MyStructure) -> Bool {
    return lhs.hashValue == rhs.hashValue
}

Обратите внимание на глобальную функцию, чтобы перегрузить оператор равенства (==), чтобы соответствовать Equatable Protocol, который требуется Протокол Hashable.

Проблема с тонким словарем

Если я создаю Dictionary с MyStructure в качестве ключа

var dictionary = [MyStructure : String]()

let ok = MyStructure(id: 0)            // hashValue = 0
let collision1 = MyStructure(id: 1)    // hashValue = 2
let collision2 = MyStructure(id: 2)    // hashValue = 2

dictionary[ok] = "some text"
dictionary[collision1] = "other text"
dictionary[collision2] = "more text"

print(dictionary) // [MyStructure(id: 2): more text, MyStructure(id: 0): some text]
print(dictionary.count) // 2

равные хэш-значения вызывают замену клавиши collision1 клавишей collision2. Предупреждений нет. Если такое столкновение произошло только один раз в словаре со 100 ключами, то его можно было бы легко упустить. (Мне потребовалось довольно много времени, чтобы заметить эту проблему.)

Очевидная проблема со словарным литералом

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

let ok = MyStructure(id: 0)            // hashValue = 0
let collision1 = MyStructure(id: 1)    // hashValue = 2
let collision2 = MyStructure(id: 2)    // hashValue = 2

let dictionaryLiteral = [
    ok : "some text",
    collision1 : "other text",
    collision2 : "more text"
]
// fatal error: Dictionary literal contains duplicate keys

Вопрос

У меня создалось впечатление, что не нужно, чтобы hashValue всегда возвращал уникальное значение. Например, MattT Thompson говорит,

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

И уважаемый пользователь SO @Gaffa говорит, что одним из способов обработки хеш-коллизий является

Рассмотрим хеш-коды, которые не являются уникальными, и используйте сопоставитель равенства для фактические данные для определения уникальности.

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

После прочтения вопроса Swift Dictionary Как обрабатываются хэш-столкновения?, я предположил, что Swift автоматически обрабатывает хэш-столкновения с Dictionary. Но, по-видимому, это неверно, если я использую собственный класс или структуру.

Этот комментарий заставляет меня думать, что ответ заключается в том, как реализуется Equatable-протокол, но я не уверен, как его изменить.

func ==(lhs: MyStructure, rhs: MyStructure) -> Bool {
    return lhs.hashValue == rhs.hashValue
}

Вызывается ли эта функция для каждого поиска в словаре или только при наличии хеш-коллизии? (Обновление: см. этот вопрос)

Что мне делать, чтобы определить уникальность, когда (и только когда) происходит столкновение хэшей?

Ответ 1

func ==(lhs: MyStructure, rhs: MyStructure) -> Bool {
    return lhs.hashValue == rhs.hashValue
}

Обратите внимание на глобальную функцию, чтобы перегрузить оператор равенства (==), чтобы соответствовать Equatable Protocol, который требуется по протоколу Hashable.

Ваша проблема - неправильная реализация равенства.

Для хэш-таблицы (например, Swift Dictionary или Set) требуются отдельные реализации равенства и хэша.

hash приближает вас к объекту, который вы ищете; равенство дает вам точный объект, который вы ищете.

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

Чтобы устранить проблему, выполните равенство для соответствия точным значениям объекта (однако ваша модель определяет равенство). Например:.

func ==(lhs: MyStructure, rhs: MyStructure) -> Bool {
    return lhs.id == rhs.id
}

Ответ 2

Я думаю, у вас есть все части головоломки, которые вам нужны - вам просто нужно собрать их вместе. У вас есть множество отличных источников.

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

Это точная причина, по которой объекты, соответствующие Hashable, также должны соответствовать Equatable. Swift нуждается в более подходящем для домена методе сравнения, когда хэширование не сокращает его.

В той же статье NSHipster вы можете увидеть, как Mattt реализует isEqual: по сравнению с hash в своем примере класса Person. В частности, он имеет метод isEqualToPerson:, который проверяет другие свойства человека (дату рождения, полное имя) для определения равенства.

- (BOOL)isEqualToPerson:(Person *)person {
  if (!person) {
    return NO;
  }

  BOOL haveEqualNames = (!self.name && !person.name) || [self.name isEqualToString:person.name];
  BOOL haveEqualBirthdays = (!self.birthday && !person.birthday) || [self.birthday isEqualToDate:person.birthday];

  return haveEqualNames && haveEqualBirthdays;
}

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

Аналогично, Swift не позволяет просто использовать объект Hashable в качестве словарного ключа - неявно, по наследованию протокола - ключи должны соответствовать также Equatable. Для стандартных типов библиотек Swift это уже позаботились, но для ваших пользовательских типов и классов вы должны создать свою собственную реализацию ==. Вот почему Swift автоматически не обрабатывает словарные конфликты с пользовательскими типами - вы должны реализовать Equatable самостоятельно!

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

if person1 === person2 {
    // ...
}

Здесь нет никакой гарантии, что person1 и person2 имеют разные свойства, просто занимают отдельное пространство в памяти. И наоборот, в более раннем методе isEqualToPerson: нет гарантии, что два человека с одинаковыми именами и датами рождения фактически являются одними и теми же людьми. Таким образом, вы должны учитывать, что имеет смысл для конкретного типа объекта. Опять же, еще одна причина, по которой Swift не реализует Equatable для вас на пользовательских типах.

Ответ 3

равные хэш-значения вызывают замену ключа collision1 на ключ collision2. Предупреждений нет. Если такое столкновение произошло один раз в словаре со 100 ключами, тогда это может быть легко пропущено.

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

Операции

Dictionary работают с клавишами равенства (==). Словари не содержат повторяющихся ключей (что означает одинаковые ключи). Когда вы устанавливаете значение с помощью ключа, он перезаписывает любую запись, содержащую равный ключ. Когда вы получаете запись с индексом, она находит значение с ключом, который равен, не обязательно такой же, как и то, что вы дали. И так далее.

collision1 и collision2 равны (==), основываясь на том, как вы определили оператор ==. Поэтому установка записи с ключом collision2 должна перезаписать любую запись клавишей collision1.

P.S. То же самое относится и к словарям на других языках. Например, в Cocoa, NSDictionary не допускаются дубликаты ключей, то есть ключи, которые isEqual:. В Java Map не разрешают дублировать ключи, то есть клавиши .equals().

Ответ 4

Вы можете увидеть мои комментарии на этой странице ответов и этот ответ. Я думаю, что все ответы все еще написаны ОЧЕНЬ запутанным способом.Суб >

tl; dr 0) вам не нужно писать реализацию isEqual ie == между hashValues. 1) Предоставлять/возвращать hashValue. 2) просто реализуйте Equatable, как обычно,

0) Чтобы соответствовать hashable, вы должны иметь вычисленное значение с именем hashValue и присваивать ему соответствующее значение. В отличие от протокола Equatable, сравнение hashValues ​​уже существует. Вы НЕ должны писать:

func ==(lhs: MyStructure, rhs: MyStructure) -> Bool {
    return lhs.hashValue == rhs.hashValue
    // Snippet A
}

1) Это точка с w, возвращающая hashValue. Компилятор является умным и достаточно любезным, чтобы автоматически использовать hashValue для проверки равенства между двумя объектами, используя их hashValues.

2) Однако, как безопасный сбой, т.е. в случае наличия хэшей соответствия вы возвращаетесь к регулярной функции ==. (Логически вам это нужно из-за отказоустойчивости, но вы также нуждаетесь в нем, потому что протокол Hashable соответствует Equatable, и поэтому вы должны написать реализацию для ==. В противном случае вы получите ошибку компилятора)

func == (lhs: MyStructure, rhs: MyStructure) -> Bool {
    return lhs.id == rhs.id
    //Snippet B
}

Вывод:

Вы должны включить Snippet B, исключить фрагмент A, а также иметь свойство hashValue