Swift数据结构-哈希表 Hash Table

声明:算法和数据结构的文章均是作者从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,这并不是一个理想的情况。

所以,我们可以对代码进行修改,假定负载值大于某一个百分比,则容量值自动修改为新的大小。这里我们不直接提供代码,由读者自行探索。需要注意的是,如果篮子的数量改变了,键值所对应的索引也会改变!所以每一次修改哈希表容量值的时候,都需要将所有数据重新插入到哈希表内!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容

  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,581评论 18 399
  • 继续深入Java基础系列。今天是研究下哈希表,毕竟我们很多应用层的查找存储框架都是哈希作为它的根数据结构进行封装的...
    JackFrost_fuzhu阅读 1,276评论 0 4
  • 部队的人和事一 在部队几年,认识了不少天南海北的战友,好多人给我的影响到今还在。 认识宝民不是很早,他从蓬...
    天涯孤旅背包客阅读 388评论 0 4
  • 一 天地间,人是最为复杂、矛盾的。当我们的日子过得贫瘠、单调,我们渴望着商业化、现代化;当我们所处的城市破旧、拥挤...
    戈月阅读 608评论 0 3
  • 以后请大家多多支持,点评
    yishor阅读 182评论 0 1