用Objective-C實現雜湊表

Whip發表於2019-06-06

雜湊表的基本原理

首先回顧一下前面實現的陣列和連結串列,無論是動態陣列、連結串列、還是迴圈連結串列,而且如果是從其中查詢特定值,都不可避免的要遍歷依次遍歷所有元素依次去做比較才可以。

做為物件導向開發語言的使用者,一定用過類似於Map的物件,通過 key: value 儲存一個鍵值對。Objective-C中這種物件就是字典:NSDictionary、NSMutableDictionary,Swift中對於Dictionary。而且通過很多介紹的文章都說過,通過key取值的時間複雜度是O(1)。之前我們都只是使用它們,現在我們自己思考,如何利用我們之前已經瞭解的資料結構,自己實現一個自定義字典。

上面提到,通過陣列和連結串列查詢值的時候,時間複雜度都是O(n)級別,那麼如何實現僅需要O(1)級別就能夠查詢到值呢?首先回顧之前的幾個資料結構:陣列、單向連結串列、雙向連結串列,哪種資料結構能夠做到在任意位置取值都是O(1)?

答案就是陣列,資料可以做到通過下標從陣列中的任意位置取值都是O(1)的時間複雜度。既然是這樣,我們就先決定用靜態陣列來做為存放資料的底層結構。既然是這樣,我們的鍵值對都是存放在陣列中的。但是陣列只有通過下標取值才是O(1)的時間複雜度,直接查詢值依然需要依次對比元素是否相等。

那麼接下來的問題就是,如何將key的遍歷查詢轉換成直接通過index去陣列中取值。要做到這一點,我們假設一個函式,這個函式能夠做到:將不同的key轉換成對應陣列長度範圍內的一個index,且不同的key轉換成的index都不一樣。而且這個函式是O(1)級別的資料複雜度,那麼當需要將一個鍵值對存入陣列時,只需要通過這個函式計算一下對應在陣列中下標,然後將鍵值對存放在陣列中對應位置。當需要通過key從陣列中取時,只需要在通過這個函式計算key對應在陣列中下標,然後通過下標去陣列中直接取出鍵值對就可以了。存、取、查都是O(1)級別的時間複雜度。

現在就不需要解釋什麼是雜湊表了,上面就是雜湊表的原理,靜態陣列加上一個通過key計算index的函式,就是一個雜湊表。下面通過圖片看一下我們上面分析的雜湊表的存放資料過程:

用Objective-C實現雜湊表

我們有兩對鍵值對需要存放在我們的自定義字典中,key是名字,value是年齡,分別是 Whip:18、Jack:20。

當儲存Whip:18時,首先通過雜湊函式計算whip對應在雜湊表中的index為2,然後建立一個雜湊表的節點物件,key指向Whip,value指向18,然後將節點儲存在雜湊表中index為2的位置。

當儲存Jack:20時,首先通過雜湊函式計算Jack對應在雜湊表中的index為6,然後建立一個雜湊表的節點物件,key指向Jack,value指向20,然後將節點儲存在雜湊表中index為6的位置。

下面看一下雜湊表的取值過程:

用Objective-C實現雜湊表

當需要需要得到Whip的年齡時,雜湊表先通過雜湊函式計算出Whip在雜湊表中存放的位置index = 2,然後直接在陣列中index為2的位置拿到儲存的節點,返回節點的value值18。

雜湊函式

既然需要通過雜湊函式計算不同key對應在陣列中的index,那麼我們首先就需要實現一個雜湊函式。首先雜湊函式需要滿足以下條件才是合格的:

  • 不同的key生成的索引儘可能不同。
  • 生成的索引要在雜湊表長度範圍內。

在Objective-C中,NSObject物件自帶一個hash方法,可以返回一個物件的雜湊值:

Person *p1 = [Person personWithAge:1];
Person *p2 = [Person personWithAge:1];
NSLog(@"%zd %zd", p1.hash, p2.hash);

// 列印
4345476000 4345477856
複製程式碼

可以看的即使是相同型別的並且屬性值相同的物件,返回的雜湊值也是不同的。當然也可重寫自定義物件的hash方法,返回我們自己希望返回的雜湊值,做到讓屬性值相同的物件,返回相同的hash值:

- (NSUInteger)hash {
    NSUInteger hashCode = self.age;
    // 奇素數 hashCode * 31 == (hashCode<<5) - hashCode
    hashCode = (hashCode<<5) - hashCode;
    return hashCode;
}
複製程式碼

上面的計算是仿照JDK的方式,將整數乘以31得出雜湊值,因為31是一個奇素數,和它相乘可以更容易達到唯一性,減少衝突。

既然重寫了hash方法,還需要配套重寫物件的 isEqual方法,一個正確的判斷邏輯需要滿足:

  • 兩個物件isEqual:判斷為true,那麼兩個物件hash返回值一定相等。
  • 雜湊值相等的兩個物件,isEqual: 不一定相等。

想象一下,如果兩個物件isEqual:返回true,意味著兩個物件相等,但是它們的雜湊值不想等,意味著它們可能存放在雜湊表中的不同位置,這樣就相當於Map或者NSDictionary中存放了兩個key相同的鍵值對,這明顯是不符合邏輯的。

key的雜湊值我們已經知道如何使用預設和自定義的方法返回,下面我們通過key的雜湊值計算其在陣列中的index。首先key的雜湊值也是一個整數,並且長度不一定。比如上面,預設返回:4345477856,我們自定義hash方法後返回31。假設我們的陣列長度只有8,那麼肯定不能將key的雜湊值直接當作陣列中的index。

這裡我們可以通過 & 位運算來得到,前提是陣列的長度是2的n次方,假設陣列的長度為 2^3 = 8,8 - 1 = 7,其對應的二進位制位為:0111。

下面我們和0111做 & 位運算的效果:

// 十進位制253
1111 1101 
&    0111
---------
     0101 = 5
     
     
// 十進位制31
0001 1111
&    0111
---------
     0111 = 7
     
     
// 十進位制8
0000 1000
&    0111
---------
     0000 = 0
複製程式碼

可以看到,任何數字和7做&位運算的結果都不會大於8,即key的雜湊值通過和陣列長度-1的值(陣列長度為2的冪)做&位運算就可以得到key雜湊值對應在陣列中的index:

- (NSUInteger)indexWithKey:(id)key {
    if (!key) return 0;
    NSUInteger hash = [key hash];
    return hash & (self.array.length - 1);
}
複製程式碼

現在這個函式還不是很好用,比如下面的情況,假定雜湊表長度為2^6,2^6 - 1 = 63,二進位制位為:0011 1111

// 6390901416293117903
0101 1000 1011 0001 
0000 1010 1111 1010 
0100 1000 1011 0001 
0010 1111 1100 1111
&          011 1111
----------------------
           000 1111 = 15
     
     
// 6390901416293511119
0110 1000 1011 0010 
0000 1010 1111 1010
0100 1000 1011 0111 
0010 1111 1100 1111            
&          011 1111
----------------------
           000 1111 = 15
複製程式碼

6390901416293117903 和 6390901416293511119 由於最後7位2進位制位都是 1001111,所以和 011 1111 做位運算之後結果都是 000 1111 = 15,高位都沒有參與運算,導致只要末 7 位一樣的雜湊值的key在陣列中index都相同,而我們應該儘量讓所有的雜湊值位數都參與運算。

下面將雜湊值右移16位,然後和原來的雜湊值做 ^ 運算,然後在與陣列長度 -1 做 & 運算,之後的結果:

(6390901416293117903 ^ (6390901416293117903 >> 16)) & 63); // 62
(6390901416293511119 ^ (6390901416293511119 >> 16)) & 63); // 56
複製程式碼

下面是優化後最終雜湊表的雜湊函式:

- (NSUInteger)indexWithKey:(id)key {
    if (!key) return 0;
    NSUInteger hash = [key hash];
    return (hash ^ (hash >> 16)) & (self.array.length - 1);
}
複製程式碼

雖然上面的優化可以一定程度避免不同的雜湊值計算出相同的index,但是依然不能完全避免,比如:6390901416293117903 和 6681946542211936207,因為它們的二進位制的最後八位一樣,而右移後的最後八位仍然一樣,最終和63做位運算的最後幾位也是一樣的值,這樣就不可避免的出現的不同key通過雜湊函式計算後,在陣列中的index是相同的情況。

雜湊衝突

對鍵值對需要存放如雜湊表,通過key計算出index後,發現雜湊表中該位置已經存放了其它鍵值對。陣列中index位置鍵值對的key和當前正在新增的鍵值對的key,通過雜湊函式計算出得index是一樣的,就會出現這種問題。出現這種問題分為兩種情況:

  • 當前的key和陣列中index位置的key是相等的,這種只需要將新鍵值覆蓋就可以了。
  • 當前的key和陣列的index位置的key是不相等的,即兩個不同的key,通過雜湊函式計算得出的index相同。

出現第二種情況就是所謂的雜湊衝突,這種衝突是不可避免的,解決雜湊衝突的辦法有很多種:

  • 再次通過其它規則計算index,直到找到一個空的位置。
  • 找到index前面或者後面第一個不為空的位置。
  • 仍在index位置放置元素,並將兩個元素以連結串列等形式儲存。

我們這裡採用第三種方式,即發現衝突的時候,以連結串列的形式將一個index內的所有元素串起來,如下圖:

用Objective-C實現雜湊表

儲存Jack:20 的時候,通過雜湊函式計算出得index = 2,發現雜湊表中index為2的位置已經有了其它節點,這時就將最後一個節點的next指向新的節點。

這種情況下的取值如下圖:

用Objective-C實現雜湊表

首先通過雜湊函式計算key對應的index,然後找到陣列中對應位置的第一個節點,比較該節點儲存的key和查詢的key是否相等,如果不相等則通過該節點next指標找到下一個節點,重複判斷過程,直到找到key相等的節點。

雜湊表的基本結構

通過上面的分析,雜湊表的結構其實已經很清晰了,首先雜湊表是一個靜態陣列,資料中存放雜湊表的節點,雜湊表的節點是一個單向連結串列的結構,每一個節點通過next指標指向下一個節點,節點另外需要兩個指標指向key和value。我們還需要實現一個雜湊函式,可以通過key計算出其對應在雜湊表中的位置,這個函式我們前面已經實現了。

我這裡將雜湊表封裝成一個類似字典的類,外部介面完全和系統的NSMutableDictionary一致,實現的功能也是一樣的。首先建立一個JKRHashMap_LinkedList類。

_size來儲存當前雜湊表存放的鍵值對的數量,注意區分這裡的_size是存放鍵值對的數量,而不是雜湊表的長度。

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface JKRHashMap_LinkedList<KeyType, ObjectType> : NSObject{
@protected
    /// 節點個數
    NSUInteger _size;
}

/// 元素個數
- (NSUInteger)count;
/// 清空所有元素
- (void)removeAllObjects;
/// 刪除元素
- (void)removeObjectForKey:(KeyType)key;
/// 新增一個元素
- (void)setObject:(nullable ObjectType)object forKey:(nullable KeyType)key;
/// 獲取元素
- (nullable ObjectType)objectForKey:(nullable KeyType)key;
/// 是否包含元素
- (BOOL)containsObject:(nullable ObjectType)object;
/// 是否包含key
- (BOOL)containsKey:(nullable KeyType)key;

@end

@interface JKRHashMap_LinkedList<KeyType, ObjectType> (JKRExtendedHashMap)

- (nullable ObjectType)objectForKeyedSubscript:(nullable KeyType)key;
- (void)setObject:(nullable ObjectType)obj forKeyedSubscript:(nullable KeyType)key;

@end


NS_ASSUME_NONNULL_END
複製程式碼

然後建立雜湊表的節點物件:

@interface JKRHashMap_LinkedList_Node : NSObject

@property (nonatomic, strong) id key;
@property (nonatomic, strong) id value;
@property (nonatomic, strong) JKRHashMap_LinkedList_Node *next;

@end
複製程式碼

雜湊表的基本功能

初始化

首先雜湊表中應該有一個靜態陣列,由於Objective-C不提供,所以在第一篇文章中已經提前實現了一個靜態陣列,它需要在雜湊表初始化的時候建立,並且長度為2的冪:

@interface JKRHashMap_LinkedList ()

@property (nonatomic, strong) JKRArray *array;

@end

@implementation JKRHashMap_LinkedList

- (instancetype)init {
    self = [super init];
    self.array = [JKRArray arrayWithLength:1 << 4];
    return self;
}

@end
複製程式碼

雜湊函式

前面已經實現了,這裡直接就可以使用:

- (NSUInteger)indexWithKey:(id)key {
    if (!key) return 0;
    NSUInteger hash = [key hash];
    return (hash ^ (hash >> 16)) & (self.array.length - 1);
}
複製程式碼

通過key查詢節點

正如上面分析的查詢過程,

  • 1,首先通過雜湊函式找到key對應的index。
  • 2,通過index獲取雜湊表中對應位置的節點。
  • 3,如果節點為空,則key不在雜湊表中,返回null。
  • 4,如果節點存在,則比較節點的key的查詢key是否相等,相等直接返回該節點,否則進入下一步。
  • 5,得到該節點的下一個節點,重複第三步。
- (JKRHashMap_LinkedList_Node *)nodeWithKey:(id)key {
    NSUInteger index = [self indexWithKey:key];
    JKRHashMap_LinkedList_Node *node= self.array[index];
    while (node) {
        if (node.key == key || [node.key isEqual:key]) {
            return node;
        } else if (key && node.key && [key class] == [node.key class] && [key respondsToSelector:@selector(compare:)] && [key compare:node.key] == 0){
            return node;
        }
        node = node.next;
    }
    return node;
}
複製程式碼

通過key獲取object

上面已經實現了通過key獲取節點,這裡只需要將返回的節點的value返回:

- (id)objectForKey:(id)key {
    JKRHashMap_LinkedList_Node *node = [self nodeWithKey:key];
    return node ? node.value : nil;
}
複製程式碼

是否存包含key

上面已經實現了通過key獲取節點,這裡只需要判斷返回的節點是否為空:

- (BOOL)containsKey:(id)key {
    return [self nodeWithKey:key] != nil;
}
複製程式碼

返回雜湊表的鍵值對數量

只需要返回_size:

- (NSUInteger)count {
    return _size;
}
複製程式碼

新增鍵值對

新增鍵值對的步驟:

  • 1,通過key計算出index。
  • 2,通過index取出雜湊表對應位置的節點。
  • 3,如果節點為空,直接建立一個新節點並儲存傳入的key和value,並將節點指標存入雜湊表, _size++。否則進入下一步。
  • 4,判斷節點的key和傳入的key是否相等,如果相等,則需要進行覆蓋操作,將傳入的key和value覆蓋節點的key和value。否則進入下一步。
  • 5,儲存一下該節點為preNode,然後獲取該節點的下一個節點,判斷節點是否為空,如果為空,建立一個新節點,將key和value存入新節點的key和value,並將preNode的next指向新節點,_size++。否則返回步驟4。
- (void)setObject:(id)object forKey:(id)key {
    NSUInteger index = [self indexWithKey:key];
    JKRHashMap_LinkedList_Node *node = self.array[index];
    if (!node) {
        node = [JKRHashMap_LinkedList_Node new];
        node.key = key;
        node.value = object;
        self.array[index] = node;
        _size++;
        return;
    }
    
    JKRHashMap_LinkedList_Node *preNode = nil;
    while (node) {
        if (node.key == key || [node.key isEqual:key]) {
            break;
        } else if (key && node.key && [key class] == [node.key class] && [key respondsToSelector:@selector(compare:)] && [key compare:node.key] == 0) {
            break;
        }
        preNode = node;
        node = node.next;
    }
    
    if (node) {
        node.key = key;
        node.value = object;
        return;
    }
    
    JKRHashMap_LinkedList_Node *newNode = [JKRHashMap_LinkedList_Node new];
    newNode.key = key;
    newNode.value = object;
    preNode.next = newNode;
    _size++;
}
複製程式碼

刪除鍵值對

  • 1,通過key計算出index。
  • 2,通過index取出當前雜湊表對應的節點。
  • 3,判斷該節點是否為空,如果節點為空,直接返回。否則進入下一步。
  • 4,判斷該節點的key是否和傳入的key相等,如果相等,刪除該節點,_size--。 否則進入下一步。
  • 5,拿到該節點的下一個節點,重複第4步。
- (void)removeObjectForKey:(id)key {
    NSUInteger index = [self indexWithKey:key];
    JKRHashMap_LinkedList_Node *node= self.array[index];
    JKRHashMap_LinkedList_Node *preNode = nil;
    while (node) {
        if (node.key == key || [node.key isEqual:key]) {
            if (preNode) {
                preNode.next = node.next;
            } else {
                self.array[index] = node.next;
            }
            _size--;
            return;
        } else if (key && node.key && [key class] == [node.key class] && [key respondsToSelector:@selector(compare:)] && [key compare:node.key] == 0){
            if (preNode) {
                preNode.next = node.next;
            } else {
                self.array[index] = node.next;
            }
            _size--;
            return;
        }
        preNode = node;
        node = node.next;
    }
}
複製程式碼

是否包含某元素

因為雜湊表的value存放在節點中,並且無法直接找到其位置,只能通過遍歷雜湊表所有節點實現:

- (BOOL)containsObject:(id)object {
    if (_size == 0) {
        return NO;
    }
    
    for (NSUInteger i = 0; i < self.array.length; i++) {
        JKRHashMap_LinkedList_Node *node= self.array[i];
        while (node) {
            if (node.value == object || [node.value isEqual:object]) {
                return YES;
            }
            node = node.next;
        }
    }
    return NO;
}
複製程式碼

讓自定義的雜湊表支援字典運算子

- (id)objectForKeyedSubscript:(id)key {
    return [self objectForKey:key];
}

- (void)setObject:(id)obj forKeyedSubscript:(id)key {
    [self setObject:obj forKey:key];
}
複製程式碼

重寫列印方便檢視雜湊表結構

- (NSString *)description {
    NSMutableString *string = [NSMutableString string];
    [string appendString:[NSString stringWithFormat:@"<%@, %p>: \ncount:%zd length:%zd\n{\n", self.className, self, _size, self.array.length]];
    for (NSUInteger i = 0; i < self.array.length; i++) {
        [string appendString:[NSString stringWithFormat:@"\n\n--- index: %zd ---\n\n", i]];
        JKRHashMap_LinkedList_Node *node= self.array[i];
        if (node) {
            while (node) {
                [string appendString:[NSString stringWithFormat:@"[%@:%@ -> %@%@] ", node.key , node.value, node.next ? [NSString stringWithFormat:@"%@:", node.next.key] : @"NULL", node.next ? node.next.value : @""]];
                node = node.next;
                if (i) {
                    [string appendString:@", "];
                }
            }
        } else {
            [string appendString:@"   "];
            [string appendString:@"NULL"];;
        }
    }
    [string appendString:@"\n}"];
    return string;
}

複製程式碼

雜湊表的功能測試

JKRHashMap_LinkedList *dic = [JKRHashMap_LinkedList new];
for (NSUInteger i = 0; i < 30; i++) {
    NSString *key = getRandomStr();
    dic[key] = [NSString stringWithFormat:@"%zd", i];
}
NSLog(@"%@", dic);

// 列印:
<JKRHashMap_LinkedList, 0x102814a10>: 
count:30 length:16
{


--- index: 0 ---

[Wlqvuq:2 -> Xecsbw:9] [Xecsbw:9 -> Kvfexi:11] [Kvfexi:11 -> NULL] 

--- index: 1 ---

[Ifaeuy:15 -> NULL] , 

--- index: 2 ---

[Bmitqy:3 -> Ynqbcw:12] , [Ynqbcw:12 -> NULL] , 

--- index: 3 ---

[Djwmew:0 -> Epzzlc:4] , [Epzzlc:4 -> Jqjrvq:22] , [Jqjrvq:22 -> NULL] , 

--- index: 4 ---

[Myvwre:28 -> NULL] , 

--- index: 5 ---

[Mrgpfv:8 -> Ltdazq:25] , [Ltdazq:25 -> Tzweni:27] , [Tzweni:27 -> NULL] , 

--- index: 6 ---

   NULL

--- index: 7 ---

[Eyvque:5 -> Ltmzik:24] , [Ltmzik:24 -> NULL] , 

--- index: 8 ---

[Rvnupm:7 -> NULL] , 

--- index: 9 ---

[Ryrort:16 -> NULL] , 

--- index: 10 ---

[Rsdkaw:1 -> Hgszuk:20] , [Hgszuk:20 -> Jtrtes:26] , [Jtrtes:26 -> NULL] , 

--- index: 11 ---

[Txonlm:29 -> NULL] , 

--- index: 12 ---

[Bvbdbe:14 -> NULL] , 

--- index: 13 ---

[Pszvix:6 -> Dtizif:19] , [Dtizif:19 -> Czkxyj:21] , [Czkxyj:21 -> Kzatxv:23] , [Kzatxv:23 -> NULL] , 

--- index: 14 ---

[Ustobp:10 -> Erqclk:13] , [Erqclk:13 -> Fbliqs:17] , [Fbliqs:17 -> Jpcvbm:18] , [Jpcvbm:18 -> NULL] , 

--- index: 15 ---

   NULL

}
複製程式碼

可以看到,雜湊表中的值平均分散在陣列中,出現雜湊衝突時,以單向連結串列的形式儲存。

和NSMutableDictionary對比雜湊表的效能測試

既然要做效能的對比測試,首先需要資料,這裡使用蘋果公佈的objc4原始碼,官方下載地址:opensource.apple.com/tarballs/ob…,將其中runtime的原始碼資料夾當作資原始檔:

用Objective-C實現雜湊表

讀取其中所有檔案的程式碼,取出其中的所有單詞,計算不同單詞出現的次數。

這個需求剛好可以利用字典或者我們自定義的雜湊表,將單詞做為key,將單詞出現的次數做為value,計算邏輯如下:

  • 首先讀取所有檔案並擷取出所有單詞(包括重複的)。
  • 遍歷所有單詞,將單詞做為key,依次新增到雜湊表中。
  • 新增前,先通過key從雜湊表中取值,如果取到,則value為取到的值+1,否則為1,將key、value存入雜湊表中。

這樣當依次遍歷新增完所有單詞後,雜湊表中存放的就是每個單詞出現的次數。

首先取出所有單詞:

NSMutableArray * allFileStrings() {
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSError *fileManagerError;
    NSString *fileDirectory = @"/Users/Lucky/Documents/SourceCode/runtime";
    
    NSArray<NSString *> *array = [fileManager subpathsOfDirectoryAtPath:fileDirectory error:&fileManagerError];
    if (fileManagerError) {
        NSLog(@"讀取資料夾失敗");
        nil;
    }
    NSLog(@"檔案路徑: %@", fileDirectory);
    NSLog(@"檔案個數: %zd", array.count);
    NSMutableArray *allStrings = [NSMutableArray array];
    [array enumerateObjectsUsingBlock:^(NSString *  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        NSString *filePath = [fileDirectory stringByAppendingPathComponent:obj];
        NSError *fileReadError;
        NSString *str = [NSString stringWithContentsOfFile:filePath encoding:NSUTF8StringEncoding error:&fileReadError];
        if (fileReadError) {
            return;
        }
        [str enumerateSubstringsInRange:NSMakeRange(0, str.length) options:NSStringEnumerationByWords usingBlock:^(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL * _Nonnull stop) {
            [allStrings addObject:substring];
        }];
    }];
    NSLog(@"所有單詞的數量: %zd", allStrings.count);
    return allStrings;
}
複製程式碼

然後依次遍歷並加入雜湊表中:

JKRHashMap_LinkedList *map = [JKRHashMap_LinkedList new];
[JKRTimeTool teskCodeWithBlock:^{
    NSMutableDictionary *map = [NSMutableDictionary new];
    [allStrings enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        NSNumber *count = map[obj];
        if (count) {
            count = [NSNumber numberWithInteger:count.integerValue+1];
        } else {
            count = [NSNumber numberWithInteger:1];
        }
        map[obj] = count;
    }];
    NSLog(@"NSMutableDictionary 計算不重複單詞數量和出現次數 %zd", map.count);
    NSLog(@"NSMutableDictionary 計算單詞出現的次數NSObject: %@", map[@"NSObject"]);
    NSLog(@"NSMutableDictionary 計算單詞出現的次數include: %@", map[@"include"]);
    NSLog(@"NSMutableDictionary 計算單詞出現的次數return: %@", map[@"return"]);
    
    __block NSUInteger allCount = 0;
    [allStrings enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        allCount += [map[obj] integerValue];
        [map removeObjectForKey:obj];
    }];
    
    NSLog(@"NSMutableDictionary 累加計算所有單詞數量 %zd", allCount);
}];

// 列印:
檔案個數: 104
所有單詞的數量: 165627
JKRHashMap_LinkedList 計算不重複單詞數量和出現次數 10490
JKRHashMap_LinkedList 計算單詞出現的次數NSObject: 34
JKRHashMap_LinkedList 計算單詞出現的次數include: 379
JKRHashMap_LinkedList 計算單詞出現的次數return: 2681
JKRHashMap_LinkedList 累加計算所有單詞數量 165627
耗時: 14.768 s
複製程式碼

下面使用NSMutableDictionary測試一遍:

[JKRTimeTool teskCodeWithBlock:^{
    NSMutableDictionary *map = [NSMutableDictionary new];
    [allStrings enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        NSNumber *count = map[obj];
        if (count) {
            count = [NSNumber numberWithInteger:count.integerValue+1];
        } else {
            count = [NSNumber numberWithInteger:1];
        }
        map[obj] = count;
    }];
    NSLog(@"NSMutableDictionary 計算不重複單詞數量和出現次數 %zd", map.count);
    NSLog(@"NSMutableDictionary 計算單詞出現的次數NSObject: %@", map[@"NSObject"]);
    NSLog(@"NSMutableDictionary 計算單詞出現的次數include: %@", map[@"include"]);
    NSLog(@"NSMutableDictionary 計算單詞出現的次數return: %@", map[@"return"]);
    
    __block NSUInteger allCount = 0;
    [allStrings enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        allCount += [map[obj] integerValue];
        [map removeObjectForKey:obj];
    }];
    
    NSLog(@"NSMutableDictionary 累加計算所有單詞數量 %zd", allCount);
}];

// 列印:
檔案個數: 104
所有單詞的數量: 165627
NSMutableDictionary 計算不重複單詞數量和出現次數 10490
NSMutableDictionary 計算單詞出現的次數NSObject: 34
NSMutableDictionary 計算單詞出現的次數include: 379
NSMutableDictionary 計算單詞出現的次數return: 2681
NSMutableDictionary 累加計算所有單詞數量 165627
耗時: 0.058 s
複製程式碼

發現所有的計算結果都是一樣的,證明我們的雜湊表確實可以和系統的NSMutableDictionary完成一樣的功能,但是時間上有這非常大差距:14.768s VS 0.058s,好吧,簡直慢到不能忍。

雜湊表的優化-擴容

首先分析下為什麼我們的雜湊表這麼慢,其實很簡單,因為我們的雜湊表預設容量是 1 << 4 = 16,長度只有16的陣列內,分散存放了10490條資料,平均陣列的一個位置存放了655個節點,即每個單向連結串列平均長度達到了655,而基於我們上面實現的雜湊表的存、取、讀都是通過單向連結串列從頭節點開始遍歷,那麼當雜湊表元素過多,連結串列長度越長時,遍歷所需時間必然越長。

為了防止每條連結串列過長,我們需要在雜湊表元素達到一定數量就要擴充套件雜湊表陣列的長度,這非常類似於動態陣列的擴容操作。同時,如果雜湊表陣列長度發生變化,每個key對應雜湊函式計算出的index必然發生變化,那麼原來存放在雜湊表中的節點還需要重新調整在雜湊表中的位置。

那麼什麼時候去擴充雜湊表的容量呢,據科學統計,當雜湊表的儲存的元素個數大於陣列的長度 * 0.75時,擴容最優,我們就採用這個規則。

首先在新增鍵值對的方法最開始新增一個擴容方法:

- (void)setObject:(id)object forKey:(id)key {
    [self resize];
    // ...
}
複製程式碼

當陣列元素個數小於雜湊表長度 * 0.75 時,不去擴容,否則就擴容。

- (void)resize {
    if (_size <= self.array.length * 0.75) return;
    
}
複製程式碼

擴容需要建立一個新的容量更大的陣列,這裡我們採用擴容後的陣列是原來的陣列的兩倍:

JKRArray *oldArray = self.array;
self.array = [JKRArray arrayWithLength:oldArray.length << 1];
複製程式碼

需要將原來陣列中的所有節點重新排列在雜湊表中,這裡採用複用原來的節點,只將它們的位置重新排列,而不是依次取出值重新新增到雜湊表中,因為這樣需要重建建立所有節點,我們這裡節省不必要的開銷:

for (NSUInteger i = 0; i < oldArray.length; i++) {
    JKRHashMap_LinkedList_Node *node = oldArray[i];
    while (node) {
        JKRHashMap_LinkedList_Node *moveNode = node;
        node = node.next;
        moveNode.next = nil;
        // 重新排列節點
        [self moveNode:moveNode];
    }
}
複製程式碼

重新排列節點的邏輯如下:

  • 1,取出該節點的key計算index。
  • 2,通過index取出節點。
  • 3,判斷節點是否為空。如果為空進入第4步,否則進入第5步。
  • 4,將陣列index位置存放該節點。
  • 5,依次遍歷到最後一個節點,將最後一個節點的next指向該節點。

完整擴容邏輯如下:

- (void)resize {
    if (_size <= self.array.length * 0.75) return;
    JKRArray *oldArray = self.array;
    self.array = [JKRArray arrayWithLength:oldArray.length << 1];
    for (NSUInteger i = 0; i < oldArray.length; i++) {
        JKRHashMap_LinkedList_Node *node = oldArray[i];
        while (node) {
            JKRHashMap_LinkedList_Node *moveNode = node;
            node = node.next;
            moveNode.next = nil;
            [self moveNode:moveNode];
        }
    }
}

- (void)moveNode:(JKRHashMap_LinkedList_Node *)newNode {
    NSUInteger index = [self indexWithKey:newNode.key];
    JKRHashMap_LinkedList_Node *node = self.array[index];
    if (!node) {
        self.array[index] = newNode;
        return;
    }
    JKRHashMap_LinkedList_Node *preNode = nil;
    while (node) {
        preNode = node;
        node = node.next;
    }
    preNode.next = newNode;
}
複製程式碼

擴容後效能對比

擴容後重覆上面的測試,列印如下:

NSMutableDictionary 計算不重複單詞數量和出現次數 10490
耗時: 0.066 s
JKRHashMap_LinkedList 計算不重複單詞數量和出現次數 10490
耗時: 0.192 s
複製程式碼

時間已經從之前的 14.768s 減少到 0.192s。

接下來

僅僅使用單向連結串列實現的雜湊表並不能夠保證所有情況的查詢速度,當雜湊函式計算出現問題或者資料量特別大的時候,很可能出現某一條單向連結串列長度非常長。

在JDK開源的雜湊表解決方案中,單連結串列長度超過一定值時,將連結串列轉換成紅黑樹的方法解決這個問題,後面也會採用紅黑樹的方式重新實現一遍雜湊表。但是在這之前,會先介紹佇列和棧的實現,因為它們這是二叉樹操作的基礎。

原始碼

點選檢視原始碼-

相關文章