資料結構 - 雜湊表,三探之程式碼實現

IT规划师發表於2024-10-31

書接上回,我們繼續來聊雜湊表的程式碼實現。

相信透過前面兩章對雜湊表的學習,大家應該已經掌握了雜湊表的基礎知識,今天我們就選用簡單的取模方式構建雜湊函式,分別實現鏈式法和開放定址法中的線性探測法來解決碰撞問題,而再雜湊法則以方法的形式分別在兩種實現方法中實現。

01、鏈式法實現

1、元素定義

透過前面鏈式法的詳細講解,我們知道鏈式法需要構建雜湊桶,每個桶又指向一個連結串列,所以首先需要定義一個連結串列節點物件用來儲存雜湊表的記,而記錄中包括key、value以及指向下個節點的指標,程式碼如下:

//儲存雜湊表的記錄
private class Entry
{
    //鍵
    public TKey Key;
    //值
    public TValue Value;
    //下一個節點
    public Entry Next;
    public Entry(TKey key, TValue value)
    {
        Key = key;
        Value = value;
        Next = null;
    }
}

2、初始化 Init

定義好連結串列,我們還需要定義雜湊桶,其實就是定義一個陣列,同時我們在定義兩個私有變數分別維護桶的數量和雜湊表總的元素個數。

而初始化方法主要就是根據指定初始容量來初始化這些變數,如果不指定初始容量則預設為16,具體程式碼如下:

//雜湊桶陣列
private Entry[] _buckets;
//桶的數量
private int _size;
//元素數量
private int _count;
//初始化指定容量的雜湊表
public MyselfHashChaining<TKey, TValue> Init(int capacity = 16)
{
    //桶數量
    _size = capacity;
    //初始化桶陣列
    _buckets = new Entry[capacity];
    _count = 0;
    return this;
}

3、獲取雜湊元素數量 Count

獲取雜湊表元素數量只需返回維護元素數量的私有欄位即可,實現如下:

//元素數量
public int Count
{
    get
    {
        return _count;
    }
}

4、插入 Insert

插入方法相對比較複雜,我們可以大致分為以下幾步:

(1)檢測負載因子是否達到閾值,超過則觸發再雜湊動作;

(2)構建好新的鍵值物件;

(3)檢測新的鍵所在的桶是否有元素,沒有元素則直接插入新物件;

(4)如果鍵所在桶有元素,則遍歷桶中連結串列,已存在相同key則更新value,否則插入新物件;

(5)維護元素數量;

具體程式碼實現如下:

//插入鍵值
public void Insert(TKey key, TValue value)
{
    //負載因子達到 0.75 觸發重新雜湊
    if (_count >= _size * 0.75)
    {
        Rehash();
    }
    //計算key的雜湊桶索引
    var index = CalcBucketIndex(key);
    //新建一條雜湊表記錄
    var newEntry = new Entry(key, value);
    //判斷key所在桶索引位置是否為空
    if (_buckets[index] == null)
    {
        //如果為空,則直接儲存再此桶索引位置
        _buckets[index] = newEntry;
    }
    else
    {
        //如果不為空,則儲存在此桶裡的連結串列上
        //取出此桶中的記錄即連結串列的頭節點
        var current = _buckets[index];
        //遍歷連結串列
        while (true)
        {
            //如果連結串列中存在相同的key,則更新其value
            if (current.Key.Equals(key))
            {
                //更新值
                current.Value = value;
                return;
            }
            //如果當前節點沒有後續節點,則停止遍歷連結串列
            if (current.Next == null)
            {
                break;
            }
            //如果當前節點有後續節點,則繼續遍歷連結串列後續節點
            current = current.Next;
        }
        //如果連結串列中不存在相同的key
        //則把新的雜湊表記錄新增到連結串列尾部
        current.Next = newEntry;
    }
    //元素數量加1
    _count++;
}
//計算key的雜湊桶索引
private int CalcBucketIndex(TKey key)
{
    //使用取模法計算索引,使用絕對值防止負數索引
    return Math.Abs(key.GetHashCode() % _size);
}

5、刪除 Remove

刪除邏輯和插入邏輯類似,都需要先計算key所在的雜湊桶,然後再處理桶中連結串列,只需要把連結串列上相應的節點刪除即可,具體程式碼如下:

//根據key刪除記錄
public void Remove(TKey key)
{
    //計算key的雜湊桶索引
    var index = CalcBucketIndex(key);
    //取出key所在桶索引位置的記錄即連結串列的頭節點
    var current = _buckets[index];
    //用於暫存上一個節點
    Entry previous = null;
    //遍歷連結串列
    while (current != null)
    {
        //如果連結串列中存在相同的key,則刪除
        if (current.Key.Equals(key))
        {
            if (previous == null)
            {
                //刪除頭節點
                _buckets[index] = current.Next;
            }
            else
            {
                //刪除中間節點
                previous.Next = current.Next;
            }
            //元素數量減1
            _count--;
            return;
        }
        //當前節點賦值給上一個節點變數
        previous = current;
        //繼續遍歷連結串列後續節點
        current = current.Next;
    }
    //如果未找到key則報錯
    throw new KeyNotFoundException($"未找到key");
}

6、查詢 Find

查詢邏輯和插入、刪除邏輯類似,都是先計算key所在桶位置,然後處理桶中連結串列,直至找到相應的元素,程式碼如下:

//根據key查詢value
public TValue Find(TKey key)
{
    //計算key的雜湊桶索引
    var index = CalcBucketIndex(key);
    //取出key所在桶索引位置的記錄即連結串列的頭節點
    var current = _buckets[index];
    //遍歷連結串列
    while (current != null)
    {
        //如果連結串列中存在相同的key,則返回value
        if (current.Key.Equals(key))
        {
            return current.Value;
        }
        //如果當前節點有後續節點,則繼續遍歷連結串列後續節點
        current = current.Next;
    }
    //如果未找到key則報錯
    throw new KeyNotFoundException($"未找到key");
}

7、獲取所有鍵 GetKeys

獲取所有鍵,是遍歷所有雜湊桶即桶中連結串列上的所有元素,最後取出所有key。

//獲取所有鍵
public TKey[] GetKeys()
{
    //初始化所有key陣列
    var keys = new TKey[_count];
    var index = 0;
    //遍歷雜湊桶
    for (var i = 0; i < _size; i++)
    {
        //獲取每個桶連結串列頭節點
        var current = _buckets[i];
        //遍歷連結串列
        while (current != null)
        {
            //收集鍵
            keys[index++] = current.Key;
            //繼續遍歷連結串列後續節點
            current = current.Next;
        }
    }
    //返回所有鍵的陣列
    return keys;
}

8、獲取所有值 GetValues

獲取所有值,是遍歷所有雜湊桶即桶中連結串列上的所有元素,最後取出所有value。

//獲取所有值
public TValue[] GetValues()
{
    //初始化所有value陣列
    var values = new TValue[_count];
    var index = 0;
    //遍歷雜湊桶
    for (var i = 0; i < _size; i++)
    {
        //獲取每個桶連結串列頭節點
        var current = _buckets[i];
        //遍歷連結串列
        while (current != null)
        {
            //收集值
            values[index++] = current.Value;
            //繼續遍歷連結串列後續節點
            current = current.Next;
        }
    }
    //返回所有值的陣列
    return values;
}

9、再雜湊 Rehash

再雜湊也是比較有挑戰的一個方法,這裡並沒有像上一篇文章中說的去實現分批次遷移老資料,而是一次性遷移,對分批次遷移感興趣的可用自己實現試試。

這裡的實現是非常簡單的,就是遍歷所有老資料,然後對每個老資料重新執行一次插入操作,具體程式碼如下:

//再雜湊
public void Rehash()
{
    //擴充套件2倍大小
    var newSize = _size * 2;
    //更新桶數量
    _size = newSize;
    //初始化元素個數
    _count = 0;
    //暫存老的雜湊表陣列
    var oldBuckets = _buckets;
    //初始化新的雜湊表陣列
    _buckets = new Entry[newSize];
    //遍歷老的雜湊桶
    for (var i = 0; i < oldBuckets.Length; i++)
    {
        //獲取老的雜湊桶的每個桶連結串列頭節點
        var current = oldBuckets[i];
        //遍歷連結串列
        while (current != null)
        {
            //呼叫插入方法
            Insert(current.Key, current.Value);
            //暫存下一個節點
            var next = current.Next;
            if (next == null)
            {
                break;
            }
            //繼續處理下一個節點
            current = next;
        }
    }
}

02、開放定址法實現

1、元素定義

該元素的定義和鏈式法實現的元素定義略有不同,首先不需要指向下一個節點的指標,其次需要一個標記位用來標記空位或被刪除。因為如果刪除後直接置空則可能會導致後續查詢過程中出現誤判,因為如果置空,而後面還有相同雜湊值元素,但是探測方法探測到空值後會停止探測後續元素,從而引發錯誤,具體實現程式碼如下:

//儲存雜湊表
private struct Entry
{
    //鍵
    public TKey Key;
    //值
    public TValue Value;
    //用於標記該位置是否被佔用
    public bool IsActive;
}

2、初始化 Init

初始化方法主要就是根據指定初始容量來初始化雜湊表以及其大小和總的元素數量,如果不指定初始容量則預設為16,具體程式碼如下:

//雜湊表陣列
private Entry[] _array;
//雜湊表的大小
private int _size;
//元素數量
private int _count;
//初始化指定容量的雜湊表
public MyselfHashOpenAddressing<TKey, TValue> Init(int capacity = 16)
{
    //雜湊表的大小
    _size = capacity;
    //初始化雜湊表陣列
    _array = new Entry[capacity];
    _count = 0;
    return this;
}

3、獲取雜湊元素數量 Count

獲取雜湊表元素數量只需返回維護元素數量的私有欄位即可,實現如下:

//元素數量
public int Count
{
    get
    {
        return _count;
    }
}

4、插入 Insert

此插入方法和鏈式法實現整體思路相差不大具體實現上略有差別,我們可以大致分為以下幾步:

(1)檢測負載因子是否達到閾值,超過則觸發再雜湊動作;

(2)檢測新的鍵所在的位置是否有元素,沒有元素或位置非被佔用則直接插入新物件;

(4)如果鍵所在位置有元素並且位置被佔用,則線性探測後續位置,已存在相同key則更新value,否則插入新物件;

(5)維護元素數量;

具體程式碼實現如下:

//插入鍵值
public void Insert(TKey key, TValue value)
{
    //負載因子達到 0.75 觸發重新雜湊
    if (_count >= _size * 0.75)
    {
        Rehash();
    }
    //計算key的雜湊表索引
    var index = CalcIndex(key);
    //遍歷雜湊表,當位置為非佔用狀態則結束探測
    while (_array[index].IsActive)
    {
        //如果雜湊表中存在相同的key,則更新其value
        if (_array[index].Key.Equals(key))
        {
            _array[index].Value = value;
            return;
        }
        //否則,使用線性探測法,繼續探測下一個元素
        index = (index + 1) % _size;
    }
    //在非佔用位置處新增新元素
    _array[index] = new Entry
    {
        Key = key,
        Value = value,
        IsActive = true
    };
    //元素數量加1
    _count++;
}
//計算key的雜湊表索引
private int CalcIndex(TKey key)
{
    //使用取模法計算索引,使用絕對值防止負數索引
    return Math.Abs(key.GetHashCode() % _size);
}

5、刪除 Remove

刪除邏輯和插入邏輯類似,都需要先計算key所在的雜湊表中的索引,迴圈探測後續位置元素如果發現相同的key,則標記元素為非佔用狀態,具體程式碼如下:

//根據key刪除元素
public void Remove(TKey key)
{
    //計算key的雜湊表索引
    var index = CalcIndex(key);
    //遍歷雜湊表,當位置為非佔用狀態則結束探測
    while (_array[index].IsActive)
    {
        //如果雜湊表中存在相同的key,則標記為非佔用狀態
        if (_array[index].Key.Equals(key))
        {
            _array[index].IsActive = false;
            //元素數量減1
            _count--;
            return;
        }
        //否則,使用線性探測法,繼續探測下一個元素
        index = (index + 1) % _size;
    }
    //如果未找到key則報錯
    throw new KeyNotFoundException($"未找到key");
}

6、查詢 Find

查詢邏輯和插入、刪除邏輯類似,都是先計算key所在索引,如果有元素並且位置標記為被佔用且key相同則返回此元素,否則線性探測後續元素,如果最後未找到則報錯,程式碼如下:

//根據key查詢value
public TValue Find(TKey key)
{
    //計算key的雜湊表索引
    int index = CalcIndex(key);
    while (_array[index].IsActive)
    {
        //如果雜湊表中存在相同的key,則返回value
        if (_array[index].Key.Equals(key))
        {
            return _array[index].Value;
        }
        //否則,使用線性探測法,繼續探測下一個元素
        index = (index + 1) % _size;
    }
    //如果未找到key則報錯
    throw new KeyNotFoundException($"未找到key");
}

7、獲取所有鍵 GetKeys

獲取所有鍵,是遍歷所有雜湊表所有元素,最後取出標記為被佔用狀態的所有key。

//獲取所有鍵
public IEnumerable<TKey> GetKeys()
{
    //遍歷雜湊表
    for (var i = 0; i < _size; i++)
    {
        //收集所有佔用狀態的鍵
        if (_array[i].IsActive)
        {
            yield return _array[i].Key;
        }
    }
}

8、獲取所有值 GetValues

獲取所有值,是遍歷所有雜湊表所有元素,最後取出標記為被佔用狀態的所有value。

//獲取所有值
public IEnumerable<TValue> GetValues()
{
    //遍歷雜湊表
    for (var i = 0; i < _size; i++)
    {
        //收集所有佔用狀態的值
        if (_array[i].IsActive)
        {
            yield return _array[i].Value;
        }
    }
}

9、再雜湊 Rehash

這裡的實現和鏈式法實現思路一樣,就是遍歷所有老資料,然後對每個老資料重新執行一次插入操作,具體程式碼如下:

//再雜湊
public void Rehash()
{
    //擴充套件2倍大小
    var newSize = _size * 2;
    //暫存老的雜湊表陣列
    var oldArray = _array;
    //初始化新的雜湊表陣列
    _array = new Entry[newSize];
    //更新雜湊表大小
    _size = newSize;
    //初始化元素個數
    _count = 0;
    //遍歷老的雜湊表陣列
    foreach (var entry in oldArray)
    {
        if (entry.IsActive)
        {
            //如果是佔用狀態
            //則重新插入到新的雜湊表陣列中
            Insert(entry.Key, entry.Value);
        }
    }
}

:測試方法程式碼以及示例原始碼都已經上傳至程式碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner

相關文章