15

为了解决这个问题,我一直在玩一个实现哈希协议的自定义结构。我试图查看等效运算符重载(==)被调用多少次,具体取决于填充Dictionary.

更新

@matt写了一个更简洁的自定义结构示例,它实现了 Hashable 协议并显示了调用频率hashValue==调用频率。我在下面复制他的代码。要查看我的原始示例,请查看编辑历史记录

struct S : Hashable {
    static func ==(lhs:S,rhs:S) -> Bool {
        print("called == for", lhs.id, rhs.id)
        return lhs.id == rhs.id
    }
    let id : Int
    var hashValue : Int {
        print("called hashValue for", self.id)
        return self.id
    }
    init(_ id:Int) {self.id = id}
}
var s = Set<S>()
for i in 1...5 {
    print("inserting", i)
    s.insert(S(i))
}

这会产生结果:

/*
inserting 1
called hashValue for 1
inserting 2
called hashValue for 2
called == for 1 2
called hashValue for 1
called hashValue for 2
inserting 3
called hashValue for 3
inserting 4
called hashValue for 4
called == for 3 4
called == for 1 4
called hashValue for 2
called hashValue for 3
called hashValue for 1
called hashValue for 4
called == for 3 4
called == for 1 4
inserting 5
called hashValue for 5
*/

由于 Hashable 使用 Equatable 来区分哈希冲突(我无论如何都假设),我希望func ==()只在存在哈希冲突时被调用。但是,在上面的@matt 示例中根本没有哈希冲突,但==仍在调用。在我的其他强制哈希冲突的实验中(参见这个问题的编辑历史),==似乎被称为随机次数。

这里发生了什么?

4

3 回答 3

12

我在这里从bugs.swift.org复制我的答案。它谈到了集合,但细节以同样的方式适用于字典。

在散列集合中,只要桶的数量小于键空间,就会发生冲突。当您在未指定最小容量的情况下创建新 Set 时,该集合可能只有一个存储桶,因此当您插入第二个元素时,会发生冲突。然后插入方法将决定是否应该增加存储,使用称为负载因子的东西。如果存储空间增加,则必须将现有元素迁移到新的存储缓冲区。那时您会在插入 4 时看到所有对 hashValue 的额外调用。

如果桶的数量等于或高于元素的数量,您仍然看到比您预期更多的调用 == 的原因与桶索引计算的实现细节有关。hashValue 的位在模运算之前混合或“混洗”。这是为了减少具有不良哈希算法的类型的过度冲突。

于 2016-12-05T06:23:33.327 回答
11

好吧,这就是你的答案:

https://bugs.swift.org/browse/SR-3330?focusedCommentId=19980&page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel#comment-19980

实际发生了什么:

  • 我们只在插入时散列一次值。
  • 我们不使用散列来比较元素,只有 ==。仅当您存储散列时使用散列进行比较才合理,但这意味着每个字典的内存使用量更多。需要评估的妥协。
  • 我们尝试在评估 Dictionary 是否适合该元素之前插入该元素。这是因为该元素可能已经在 Dictionary 中,在这种情况下我们不需要更多容量。
  • 当我们调整 Dictionary 的大小时,我们必须重新散列所有内容,因为我们没有存储散列。

所以你看到的是:

  • 搜索键的一个哈希
  • some =='s(搜索空格)
  • 集合中每个元素的哈希值(调整大小)
  • 搜索键的一个哈希(实际上完全浪费,但考虑到它只发生在 O 重新分配之后,这没什么大不了的)
  • some =='s(在新缓冲区中搜索空间)

我们都完全错了。他们根本不使用哈希值——只是 ==——来决定这是否是一个不同的键。然后在集合增长的情况下进行第二轮调用。

于 2016-12-08T18:33:34.127 回答
3

虽然这个问题已经得到回答,但这些答案在评论中提出了一些额外的问题。@Suragch 询问我是否会将我的评论包含在新答案中,以帮助其他可能对Equatable和之间的关系感到困惑的人Hashable。我必须首先说我对底层机制只有初步的了解,但我会尽我所能解释我所知道的。

Equatable是一个相当简单的概念,我们只需要查看 Swift 文档就可以了解该协议的简洁定义:

Equatable:可以比较值相等的类型。

如果一个类型是等价的,我们可以用 比较两个实例==。简单的。

Hashable是一个不同的故事。当我第一次在 Swift 文档中阅读这个协议的定义时,我真的笑出了声:

Hashable:一种可以散列到Hasher中以产生整数散列值的类型。

在此处输入图像描述

如果这还没有为您解决问题,那么您并不孤单!无论如何,如果==用于确定一个实例是否真正唯一(它需要在一个集合中或用作字典中的键时),我们为什么需要Hashable呢?(这是@Suragch 在评论中提出的问题。)

这个问题涉及散列集合(或散列表)的基本性质,如集合和字典。考虑一下为什么您可能首先选择字典而不是数组。当您需要通过已知索引以外的方式查找或引用实例时,您会选择字典,对吗?与数组不同,字典的元素不是按顺序编号的,这使得查找内容变得更加困难。如果我们只有==,我们将不得不逐个迭代字典的元素,并且随着字典大小的增加,这将变得越来越慢。

这就是哈希函数的魅力所在。哈希函数(或哈希器)将唯一键作为参数并返回元素的地址。你怎么能确定它会返回正确的地址?因为它与最初用于设置该地址的函数相同!创建字典时,它获取每个键(或者更确切地说,每个键的唯一标识属性),根据一些秘密公式将它们混合起来,并为每个键吐出一个数字(哈希值),然后从这些数字中得出每个元素的新索引。稍后,当您想要查找其中一个元素时,散列器获取相同的参数,因此它返回相同的值。而且因为您所做的只是调用一个函数,所以无论集合有多大,都不会涉及迭代并且结果很快。

不过有一个问题。没有哈希是完美的。即使你给它提供唯一的参数,偶尔它也可能为两个完全不同的元素返回相同的哈希值——哈希冲突。发生这种情况时,它需要检查两个元素是否真的相同,当然,它通过调用==.

但是在您的示例中,您hashValue直接进行了操作(这是人们之前所做的那种事情hash(into:)!),它仍然调用==!我的意思是,理论上,它不应该这样做,因为没有任何碰撞。但答案就在robinkunde 引用的评论中:

在散列集合中,只要桶的数量小于键空间,就会发生冲突

虽然一般来说,我们不需要担心 Swift 内置的 hasher 函数的实现细节,但这个特定的细节很重要……在幕后,hasher 需要另一个参数,那就是集合的大小。如果大小发生变化(当您在一个范围内迭代并将新元素插入集合时它会反复发生),则哈希器可以尝试推入比索引插槽(或存储桶)更多的元素,并且您会遇到冲突,或者它需要使用足够的内存从头开始重新散列所有内容,以便为每个元素提供唯一索引。正如马特引用的评论所说,似乎发生的是两者的结合:

我们尝试在评估 Dictionary 是否适合该元素之前插入该元素。这是因为该元素可能已经在 Dictionary 中,在这种情况下我们不需要更多容量。


这就是我尝试对散列集合、散列函数与您的==方法之间的关系以及意外行为的原因进行更简单的解释。但这一切又给我提出了一个问题……为什么我们需要手动声明Hashable?Apple 不能采用某种算法来为所有 Equatable 类型合成 Hashable 一致性吗?我的意思是,hash(into:)文档说:

用于散列的组件必须与类型的 == 运算符实现中比较的组件相同。

如果组件需要相同,Swift 不能仅从我们的实现中推断出我们的意图Equatable吗?我不确定为什么它不能为那些不想更多控制细节的人提供这样的便利(类似于它提供默认初始化程序的方式)。也许有一天 Swift 会提供这个?不过现在,他们将它们作为单独的关注点,Hashable继承自Equatable.

于 2019-07-12T06:46:26.707 回答