如何处理Swift中字典的哈希冲突问题

20

简述

我的自定义结构实现了Hashable Protocol。然而,在向Dictionary中插入键时发生哈希冲突时,它们不会自动处理。我该如何解决这个问题?

背景

我以前曾在这里问过这个问题——如何为Int数组(自定义字符串结构)在Swift中实现Hashable Protocol。后来我添加了自己的答案,似乎是有效的。

然而,最近我发现使用Dictionary时存在hashValue冲突的微妙问题。

最基本的示例

我已将代码简化到以下示例:

自定义结构

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 Protocol所必需的。

微妙的字典键问题

如果我使用MyStructure作为键创建一个Dictionary

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中的可哈希协议哈希函数是否需要返回唯一值的问题还没有得到足够的解答。

在阅读了Swift Dictionary问题如何处理哈希冲突?之后,我假设Swift会自动处理Dictionary中的哈希冲突。但是,如果我使用自定义类或结构,则显然不是这样。

这个评论让我想到答案可能在如何实现可比较协议上,但我不确定该如何更改它。

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

这个函数是在每次字典键查找时都被调用,还是只有在哈希冲突时才被调用?(更新:请参见此问题

当且仅当发生哈希冲突时,我应该怎么做才能确定唯一性?


Swift比较协议也是一个有用的链接。 - Suragch
你返回哈希值 12 只是为了演示冲突,对吗? - mfaani
我返回的哈希值为“2”和“2”,这是碰撞的示例。(这些id分别为“1”和“2”。) @Honey - Suragch
4个回答

16
func ==(lhs: MyStructure, rhs: MyStructure) -> Bool {
    return lhs.hashValue == rhs.hashValue
}
请注意全局函数重载等号运算符(==)以符合Equatable协议的要求,该协议为Hashable协议所必需。
您的问题是错误的相等性实现。
哈希表(例如Swift字典或集合)需要单独实现相等性和哈希功能。
哈希功能可以让你接近你要查找的对象;相等性功能可以让你精确地获取你要查找的对象。
您的代码使用了相同的实现方式来处理哈希和相等性,这将保证碰撞发生。
为解决此问题,请实现相等性以匹配确切对象值(根据您的模型定义相等性)。例如:
func ==(lhs: MyStructure, rhs: MyStructure) -> Bool {
    return lhs.id == rhs.id
}

我非常困惑。从你提供的两个代码片段中,一个是Hashable的第一种实现方式,另一个是Equatable的实现方式。为了符合Hashable,我需要同时实现这两个吗?如果是这样,那么编译器如何知道在它们具有相同方法签名的情况下选择哪一个呢?! - mfaani
我刚刚尝试写另一个用于相等性==,但是我收到了无效的==重新声明错误提示。@Suragch - mfaani
@Honey,第一个代码片段是Darren引用了我的错误代码。第二个代码片段是Darren的正确版本。Hashable协议要求Equitable协议来解决任何哈希冲突的情况(即,任何时候两个不同的对象具有相同的哈希值)。不过,从你自己的答案中我看到你已经弄清楚了这一点。 - Suragch
@Honey,我假设你也看到了这个答案。这是我在研究所有内容后写的。大局部分展示了实现Hashable协议所需的所有组件。 - Suragch
@Honey,我之前关于Equatable协议目的的说法似乎是错误的。请看这个问题 - Suragch
1
亲爱的读者,如果这个答案让您感到困惑,我建议您快速了解一下哈希表的工作原理。在这里,key保持隐式;hashValue是哈希f(key),而==用于在所有具有相同哈希值的条目中查找正确的条目。 - Raphael

5

我认为你已经拥有了所有需要的拼图碎片,你只需要将它们组合起来。你有许多很棒的资源。

哈希冲突是可以接受的。如果出现哈希冲突,则会检查对象的相等性(仅针对具有匹配哈希值的对象)。因此,除非您确定哈希值不会发生冲突,否则对象的Equatable一致性需要基于其他内容而不是hashValue

这正是符合Hashable的对象必须同时符合Equatable的确切原因。当哈希无法胜任时,Swift需要更多特定于领域的比较方法。

在同一篇NSHipster文章中,您可以看到Mattt如何在他的示例Person类中实现isEqual:hash。具体来说,他有一个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 不允许您仅将一个可哈希对象用作字典键 - 隐式地,通过协议继承 - 键必须符合 可比较 协议。对于标准库 Swift 类型,这已经被处理了,但是对于您的自定义类型和类,则必须创建自己的 == 实现。这就是为什么 Swift 不会自动处理具有自定义类型的字典冲突 - 您必须自己实现可比较协议!
最后,Mattt 还指出,您通常可以执行身份验证以确保您的两个对象位于不同的内存地址,因此是不同的对象。在 Swift 中,这看起来像这样:
if person1 === person2 {
    // ...
}

这里不能保证 person1person2 拥有不同的属性,只能保证它们在内存中占据不同的空间。相反,在之前的 isEqualToPerson: 方法中,即使两个人具有相同的姓名和出生日期,也不能保证它们是同一个人。因此,您必须考虑适用于特定对象类型的合理性。这也是 Swift 不会为自定义类型实现 Equatable 的另一个原因。


你说,“如果哈希冲突发生,对象将被检查是否相等(只与具有匹配哈希的对象相比较)。” 然而我在测试时注意到了一些奇怪的行为,似乎与我预期的不符。请参见我刚刚提出的这个问题 - Suragch
1
在理论上,一个对象只需要被测试与碰撞的对象相比,但是Swift开发人员可以自由地实现他们的字典类型,只要它对最终用户正常工作即可。他们不保证键将被检查多少次以确保相等,因此他们可以自由地优化字典类型,无论他们如何适合。他们可能有一些疯狂的优化,真正提高了字典的性能,但在内部操作方式与基本哈希映射模型不同。唯一重要的是该类型的API按照文档工作。 - justinpawela

3
相等的哈希值会导致冲突1的键被冲突2的键覆盖。没有警告。如果在具有100个键的字典中只发生一次这样的冲突,那么很容易被忽略。
哈希冲突与此无关。(哈希冲突从不影响结果,只影响性能。)它的工作方式与文档完全一样。
字典操作基于键的相等性(==)。字典不包含重复键(即相等的键)。当您使用一个键设置一个值时,它将覆盖任何包含相等键的条目。当您使用下标获取一个条目时,它会找到一个键值相等的条目,而不必是您提供的那个。依此类推。
基于您定义的==运算符,collision1和collision2是相等的(==)。因此,使用键collision2设置一个条目必须覆盖任何键为collision1的条目。
附注:其他语言中的字典也适用相同的规则。例如,在Cocoa中,NSDictionary不允许重复键,即isEqual:的键。在Java中,Map不允许重复键,即.equals()的键。

1

您可以在此页面的答案和this答案中看到我的评论。我认为所有的答案都写得非常混乱。

tl;dr 0) 您不需要编写实现isEqual即==之间的语法。 1) 仅提供/返回hashValue2) 只需按照通常的方式实现Equatable

0) 要符合hashable,必须有一个名为hashValue的计算值,并为其赋予适当的值。与equatable协议不同,已经存在对hashValues的比较。您不需要编写:

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

1) 接着它使用hashValue来检查是否存在正在查找的键对应的哈希值的索引(通过其模数计算得出),并在该索引中的键/值对数组中查找。

2) 然而,作为一种故障保护措施,即在存在匹配哈希的情况下,你需要回退到常规的==函数。(从逻辑上讲,由于故障保护,你需要这个函数。但是,因为Hashable协议符合Equatable,因此必须编写==的实现。否则,你将收到编译器错误)

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

结论:
在编程中,你必须包含代码片段B,排除代码片段A,并且还要有一个hashValue属性。

第一点没有意义。hashValue 不用于检查两个对象之间的相等性。两个不相等的对象具有相同的哈希值是完全有效的。要求只是两个相等的对象必须具有相同的哈希值。 - rmaddy
@rmaddy 感谢您的反馈。我最近稍微复习了一下数据结构的知识,现在好些了吗? - mfaani
你在说哪个数组? - rmaddy
从我在这里学到的知识来看,字典被存储在一个二维数组中。每个键被追加到内部数组中,该内部数组位于 "键的 hashValue % array.count" 的外层数组的索引处。我猜它实际上并没有存储在一个数组中,但这是你在面试时需要做的事情... - mfaani
那是一个实现细节。Swift的Dictionary没有保证被完全按照那样的方式实现。这与问题无关,也不需要在任何答案中处理。 - rmaddy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接