Swift數(shù)據(jù)結構-哈希表 Hash Table

聲明:算法和數(shù)據(jù)結構的文章均是作者從github上翻譯過來,為方便大家閱讀。如果英語閱讀能力強的朋友,可以直接到swift算法俱樂部查看所有原文,以便快速學習。作者同時也在學習中,歡迎交流

哈希表允許用戶可以通過鍵值key存取對象。

哈希表可以用來表示特殊數(shù)據(jù)結構,比如說字典,映射和關聯(lián)數(shù)組等。這一類的結構可以通過樹狀圖或者普通數(shù)組來表示,但是使用哈希表的話效率更高。

這也說明來為什么Swift內(nèi)置的Dictionary類型的鍵值對必須遵守Hashable協(xié)議,因為它使用的就是哈希表,與我們接下來要說的內(nèi)容相同。

基本原理

簡單來說,哈希表就是一個數(shù)組。在起始時候,數(shù)組本身是空的。當我們想要儲存一個數(shù)值的時候,我們會在哈希表里創(chuàng)建一個鍵值,這個鍵值是用來計算這個數(shù)值在數(shù)組中的索引,并指向數(shù)值。舉例:

hashTable["firstName"] = "Steve"

    The hashTable array:
    +--------------+
    | 0:           |
    +--------------+
    | 1:           |
    +--------------+
    | 2:           |
    +--------------+
    | 3: firstName |---> Steve
    +--------------+
    | 4:           |
    +--------------+

在實例中,鍵值firstName對應數(shù)組索引3. 如果我們用不同的鍵值添加新的數(shù)值,該鍵值對應的索引也是不同的。如下:

hashTable["hobbies"] = "Programming Swift"

    The hashTable array:
    +--------------+
    | 0:           |
    +--------------+
    | 1: hobbies   |---> Programming Swift
    +--------------+
    | 2:           |
    +--------------+
    | 3: firstName |---> Steve
    +--------------+
    | 4:           |
    +--------------+

這里的竅門是哈希表如何去計算這些數(shù)組索引。這也是哈?;_始的地方。當你寫入以下代碼:

hashTable["firstName"] = "Steve"

哈希表取出鍵值firstName并且對本身的hashValue屬性進行詢問。所以,鍵值必須是Hashable。

如果你在代碼里寫"firstName".hashValue,它將會返回一個大整數(shù) :-4799450059917011053。與此同時,"hobbies".hashValue對應的哈希值為4799450060928805186。

這兩個大整數(shù)都很大,無法用做數(shù)組的索引值,而且還有一個是負數(shù)!所以這里我們對這兩個數(shù)值進行取絕對值,并對數(shù)值大小進行求余數(shù)。當前我們的數(shù)組大小為5,所以鍵值firstName的索引為abs(-4799450059917011053) % 5 = 3.

這樣的過程也就是字典高效的原因:我們需要在哈希表里找一個元素,你必須把這個鍵值哈?;サ玫揭粋€索引值,然后去找到數(shù)組對應索引的數(shù)值。所有的操作過程都消耗固定時間,也就是說,插入,獲取和刪除都是O(1)。

注意:因為我們無法預測你的數(shù)組將會有多少對象,所以字典本身不保證任何順序。

避免沖突

這里有一個問題:因為我們是用數(shù)組大小對哈希值進行取模,有可能不同鍵值所得到的索引值相同,這里就是沖突。

避免沖突的其中一個方法是用一個很大的數(shù)組來降低兩個鍵值映射同一個索引的概率。另一個方法是用一個基本數(shù)字來作為數(shù)組大小。然而,這樣的方法還是無法避免沖突的發(fā)生,所以我們需要一個更好的方法來處理這個問題。

在實例中,因為我們的哈希表非常小,所以沖突的概率很大。比如,下圖中鍵值lastName對應的索引也為3,但是我們并不想要重寫索引3對應的數(shù)值。所以,我們用鏈接來處理沖突。

buckets:
    +-----+
    |  0  |
    +-----+     +----------------------------+
    |  1  |---> | hobbies: Programming Swift |
    +-----+     +----------------------------+
    |  2  |
    +-----+     +------------------+     +----------------+
    |  3  |---> | firstName: Steve |---> | lastName: Jobs |
    +-----+     +------------------+     +----------------+
    |  4  |
    +-----+

通過使用鏈接,鍵值和它對應的數(shù)值不再直接存儲在數(shù)組中。相反,每一個數(shù)組元素對應一串零個到多個的鍵值對。這里的數(shù)組元素通常被稱為籃子bucket而對應列表稱為鏈條。這里我們有5個籃子,其中有2個籃子擁有鏈條。其他三個籃子為空。

假如我們用以下代碼從哈希表里獲取數(shù)值:

let x = hashTable["lastName"]

首先我們會對鍵值lastName哈?;瘉慝@取對應數(shù)組索引,也就是3。由于3號籃子有鏈條,我們進一步用鍵值lastName來獲取最后的數(shù)值。這個過程是通過字符比較來完成。哈希表對比了查找的鍵值與鏈條里的鍵值,最后返回了對應的數(shù)值。

通常來說,鏈條的長度不可以太長因為會花費更多的時間來查看數(shù)據(jù),最理想的情況是沒有鏈條,但是在現(xiàn)實中是不可能的。所以我們采用這樣的方法來避免沖突。當然,我們可以通過更高質(zhì)量的哈希函數(shù)來保證哈希表的籃子數(shù)量,從而避免沖突。

注意:另一個可選鏈接方案是"開放賦值"。主要思想是:如果某一個數(shù)組索引已經(jīng)被占用了,我們就使用索引對應的下一個索引或者上一個索引。

代碼

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是通用容器,然后這里有兩個參數(shù)Key(必須遵守Hashable)和Value。我們還定義了另外兩個類型:Element是鏈條里的鍵值對,Bucket是籃子,存放鍵值對的數(shù)組。

這里的主數(shù)組是buckets。它的大小是固定的,由init(capacity)來決定。同時我們還對被加入到哈希表的元素個數(shù)進行統(tǒng)計,存入變量count。

我們可以用以下方法來創(chuàng)建新的哈希表對象:

var hashTable = HashTable<String, String>(capacity: 5)

目前哈希表并沒有實現(xiàn)任何功能,我們可以加入一些想要的功能。首先,我們可以計算一個指定鍵值在主數(shù)組的對應索引值:

  private func index(forKey key: Key) -> Int {
    return abs(key.hashValue) % buckets.count
  }

此函數(shù)的原理在之前我們有提到過,我們需要得到鍵值的哈希值,取絕對值并求余。由于這個函數(shù)在很多地方都會被用到,所以我們把它放到HashTable里。

一般來說,我們會對哈希表或者字典做以下事件:插入新元素,尋找元素,更新已存在元素,取出元素。對應的語法如下:

hashTable["firstName"] = "Steve"   // insert
let x = hashTable["firstName"]     // lookup
hashTable["firstName"] = "Tim"     // update
hashTable["firstName"] = nil       // delete

我們可以通過subscript函數(shù)來實現(xiàn)上述功能:

public subscript(key: Key) -> Value? {
    get {
      return value(forKey: key)
    }
    set {
      if let value = newValue {
        updateValue(value, forKey: key)
      } else {
        removeValue(forKey: key)
      }
    }
  }

這里調(diào)用了三個輔助函數(shù)來實現(xiàn)真正的功能。我們可以看一下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
  }

上述函數(shù)首先通過index(forKey:)將鍵值轉(zhuǎn)換成數(shù)組索引。這個索引值也就是之前說的籃子標號,每個籃子可能存放著多個鍵值對,所以還需要將搜索的鍵值與籃子里的鍵值一一進行對比。如果有相等的,則返回鍵值相應的數(shù)值,若沒有則返回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
  }

第一步仍然是獲取索引,然后對索引值對應的籃子進行判斷,如果已經(jīng)存在鍵值對,則直接更新當前鍵值對應的數(shù)值,如果對應籃子沒有該鍵值,則直接插入新的鍵值對。

我們可以看出,每個籃子里面的鏈條數(shù)量(鍵值對)多少對整個哈希表來說很重要,不然我們每次還需要消耗額外的時間去比值。這個時候,哈希表的性能就不是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
  }

總體來說,哈希表的基本函數(shù)的工作流程都是相似的:首先獲取索引,找到籃子,再籃子進行比值,然后進行相應操作。

改變哈希表的大小

上述代碼中HashTable的大小是固定的。所以當我們有很多數(shù)據(jù)需要存入到哈希表時候,我們需要設定一個比數(shù)據(jù)最大個數(shù)還大的數(shù)字來作為哈希表的容量值。

我們用負載來表示當前使用量在哈希表容量值的占比。如果哈希表中儲存了3個元素,但是有5個籃子,那么當前的負載為3/5 = 60%.如果哈希表太小,而鏈表的數(shù)量又多,負載值就會大于1,這并不是一個理想的情況。

所以,我們可以對代碼進行修改,假定負載值大于某一個百分比,則容量值自動修改為新的大小。這里我們不直接提供代碼,由讀者自行探索。需要注意的是,如果籃子的數(shù)量改變了,鍵值所對應的索引也會改變!所以每一次修改哈希表容量值的時候,都需要將所有數(shù)據(jù)重新插入到哈希表內(nèi)!

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

  • 1. Java基礎部分 基礎部分的順序:基本語法,類相關的語法,內(nèi)部類的語法,繼承相關的語法,異常的語法,線程的語...
    子非魚_t_閱讀 34,838評論 18 399
  • 繼續(xù)深入Java基礎系列。今天是研究下哈希表,畢竟我們很多應用層的查找存儲框架都是哈希作為它的根數(shù)據(jù)結構進行封裝的...
    JackFrost_fuzhu閱讀 1,345評論 0 4
  • 部隊的人和事一 在部隊幾年,認識了不少天南海北的戰(zhàn)友,好多人給我的影響到今還在。 認識寶民不是很早,他從蓬...
    天涯孤旅背包客閱讀 475評論 0 4
  • 一 天地間,人是最為復雜、矛盾的。當我們的日子過得貧瘠、單調(diào),我們渴望著商業(yè)化、現(xiàn)代化;當我們所處的城市破舊、擁擠...
    戈月閱讀 720評論 0 3
  • 以后請大家多多支持,點評
    yishor閱讀 201評論 0 1

友情鏈接更多精彩內(nèi)容