声明:算法和数据结构的文章均是作者从github上翻译过来,为方便大家阅读。如果英语阅读能力强的朋友,可以直接到swift算法俱乐部查看所有原文,以便快速学习。作者同时也在学习中,欢迎交流
哈希表允许用户可以通过键值key存取对象。
哈希表可以用来表示特殊数据结构,比如说字典,映射和关联数组等。这一类的结构可以通过树状图或者普通数组来表示,但是使用哈希表的话效率更高。
这也说明来为什么Swift内置的Dictionary
类型的键值对必须遵守Hashable
协议,因为它使用的就是哈希表,与我们接下来要说的内容相同。
基本原理
简单来说,哈希表就是一个数组。在起始时候,数组本身是空的。当我们想要储存一个数值的时候,我们会在哈希表里创建一个键值,这个键值是用来计算这个数值在数组中的索引,并指向数值。举例:
hashTable["firstName"] = "Steve"
The hashTable array:
+--------------+
| 0: |
+--------------+
| 1: |
+--------------+
| 2: |
+--------------+
| 3: firstName |---> Steve
+--------------+
| 4: |
+--------------+
在实例中,键值firstName
对应数组索引3. 如果我们用不同的键值添加新的数值,该键值对应的索引也是不同的。如下:
hashTable["hobbies"] = "Programming Swift"
The hashTable array:
+--------------+
| 0: |
+--------------+
| 1: hobbies |---> Programming Swift
+--------------+
| 2: |
+--------------+
| 3: firstName |---> Steve
+--------------+
| 4: |
+--------------+
这里的窍门是哈希表如何去计算这些数组索引。这也是哈希化开始的地方。当你写入以下代码:
hashTable["firstName"] = "Steve"
哈希表取出键值firstName
并且对本身的hashValue
属性进行询问。所以,键值必须是Hashable
。
如果你在代码里写"firstName".hashValue
,它将会返回一个大整数 :-4799450059917011053。与此同时,"hobbies".hashValue
对应的哈希值为4799450060928805186。
这两个大整数都很大,无法用做数组的索引值,而且还有一个是负数!所以这里我们对这两个数值进行取绝对值,并对数值大小进行求余数。当前我们的数组大小为5,所以键值firstName
的索引为abs(-4799450059917011053) % 5 = 3
.
这样的过程也就是字典高效的原因:我们需要在哈希表里找一个元素,你必须把这个键值哈希化去得到一个索引值,然后去找到数组对应索引的数值。所有的操作过程都消耗固定时间,也就是说,插入,获取和删除都是O(1)。
注意:因为我们无法预测你的数组将会有多少对象,所以字典本身不保证任何顺序。
避免冲突
这里有一个问题:因为我们是用数组大小对哈希值进行取模,有可能不同键值所得到的索引值相同,这里就是冲突。
避免冲突的其中一个方法是用一个很大的数组来降低两个键值映射同一个索引的概率。另一个方法是用一个基本数字来作为数组大小。然而,这样的方法还是无法避免冲突的发生,所以我们需要一个更好的方法来处理这个问题。
在实例中,因为我们的哈希表非常小,所以冲突的概率很大。比如,下图中键值lastName
对应的索引也为3,但是我们并不想要重写索引3对应的数值。所以,我们用链接来处理冲突。
buckets:
+-----+
| 0 |
+-----+ +----------------------------+
| 1 |---> | hobbies: Programming Swift |
+-----+ +----------------------------+
| 2 |
+-----+ +------------------+ +----------------+
| 3 |---> | firstName: Steve |---> | lastName: Jobs |
+-----+ +------------------+ +----------------+
| 4 |
+-----+
通过使用链接,键值和它对应的数值不再直接存储在数组中。相反,每一个数组元素对应一串零个到多个的键值对。这里的数组元素通常被称为篮子bucket而对应列表称为链条。这里我们有5个篮子,其中有2个篮子拥有链条。其他三个篮子为空。
假如我们用以下代码从哈希表里获取数值:
let x = hashTable["lastName"]
首先我们会对键值lastName
哈希化来获取对应数组索引,也就是3。由于3号篮子有链条,我们进一步用键值lastName
来获取最后的数值。这个过程是通过字符比较来完成。哈希表对比了查找的键值与链条里的键值,最后返回了对应的数值。
通常来说,链条的长度不可以太长因为会花费更多的时间来查看数据,最理想的情况是没有链条,但是在现实中是不可能的。所以我们采用这样的方法来避免冲突。当然,我们可以通过更高质量的哈希函数来保证哈希表的篮子数量,从而避免冲突。
注意:另一个可选链接方案是"开放赋值"。主要思想是:如果某一个数组索引已经被占用了,我们就使用索引对应的下一个索引或者上一个索引。
代码
public struct HashTable<Key: Hashable, Value> {
private typealias Element = (key: Key, value: Value)
private typealias Bucket = [Element]
private var buckets: [Bucket]
private(set) public var count = 0
public var isEmpty: Bool { return count == 0 }
public init(capacity: Int) {
assert(capacity > 0)
buckets = Array<Bucket>(repeatElement([], count: capacity))
}
HashTable
是通用容器,然后这里有两个参数Key
(必须遵守Hashable)和Value
。我们还定义了另外两个类型:Element
是链条里的键值对,Bucket
是篮子,存放键值对的数组。
这里的主数组是buckets
。它的大小是固定的,由init(capacity)
来决定。同时我们还对被加入到哈希表的元素个数进行统计,存入变量count
。
我们可以用以下方法来创建新的哈希表对象:
var hashTable = HashTable<String, String>(capacity: 5)
目前哈希表并没有实现任何功能,我们可以加入一些想要的功能。首先,我们可以计算一个指定键值在主数组的对应索引值:
private func index(forKey key: Key) -> Int {
return abs(key.hashValue) % buckets.count
}
此函数的原理在之前我们有提到过,我们需要得到键值的哈希值,取绝对值并求余。由于这个函数在很多地方都会被用到,所以我们把它放到HashTable
里。
一般来说,我们会对哈希表或者字典做以下事件:插入新元素,寻找元素,更新已存在元素,取出元素。对应的语法如下:
hashTable["firstName"] = "Steve" // insert
let x = hashTable["firstName"] // lookup
hashTable["firstName"] = "Tim" // update
hashTable["firstName"] = nil // delete
我们可以通过subscript
函数来实现上述功能:
public subscript(key: Key) -> Value? {
get {
return value(forKey: key)
}
set {
if let value = newValue {
updateValue(value, forKey: key)
} else {
removeValue(forKey: key)
}
}
}
这里调用了三个辅助函数来实现真正的功能。我们可以看一下value(forKey:)
,用于从哈希表中获取对象。
public func value(forKey key: Key) -> Value? {
let index = self.index(forKey: key)
for element in buckets[index] {
if element.key == key {
return element.value
}
}
return nil // key not in hash table
}
上述函数首先通过index(forKey:)
将键值转换成数组索引。这个索引值也就是之前说的篮子标号,每个篮子可能存放着多个键值对,所以还需要将搜索的键值与篮子里的键值一一进行对比。如果有相等的,则返回键值相应的数值,若没有则返回nil
插入和更新元素的代码就稍微复杂点:
public mutating func updateValue(_ value: Value, forKey key: Key) -> Value? {
let index = self.index(forKey: key)
// Do we already have this key in the bucket?
for (i, element) in buckets[index].enumerated() {
if element.key == key {
let oldValue = element.value
buckets[index][i].value = value
return oldValue
}
}
// This key isn't in the bucket yet; add it to the chain.
buckets[index].append((key: key, value: value))
count += 1
return nil
}
第一步仍然是获取索引,然后对索引值对应的篮子进行判断,如果已经存在键值对,则直接更新当前键值对应的数值,如果对应篮子没有该键值,则直接插入新的键值对。
我们可以看出,每个篮子里面的链条数量(键值对)多少对整个哈希表来说很重要,不然我们每次还需要消耗额外的时间去比值。这个时候,哈希表的性能就不是O(1)而是O(n)了。
移除的过程也是类似的:
public mutating func removeValue(forKey key: Key) -> Value? {
let index = self.index(forKey: key)
// Find the element in the bucket's chain and remove it.
for (i, element) in buckets[index].enumerated() {
if element.key == key {
buckets[index].remove(at: i)
count -= 1
return element.value
}
}
return nil // key not in hash table
}
总体来说,哈希表的基本函数的工作流程都是相似的:首先获取索引,找到篮子,再篮子进行比值,然后进行相应操作。
改变哈希表的大小
上述代码中HashTable
的大小是固定的。所以当我们有很多数据需要存入到哈希表时候,我们需要设定一个比数据最大个数还大的数字来作为哈希表的容量值。
我们用负载来表示当前使用量在哈希表容量值的占比。如果哈希表中储存了3个元素,但是有5个篮子,那么当前的负载为3/5 = 60%
.如果哈希表太小,而链表的数量又多,负载值就会大于1,这并不是一个理想的情况。
所以,我们可以对代码进行修改,假定负载值大于某一个百分比,则容量值自动修改为新的大小。这里我们不直接提供代码,由读者自行探索。需要注意的是,如果篮子的数量改变了,键值所对应的索引也会改变!所以每一次修改哈希表容量值的时候,都需要将所有数据重新插入到哈希表内!