基于拉鏈?zhǔn)胶途€性探測(cè)式散列表實(shí)現(xiàn)Map

程序員必讀書單:https://github.com/silently9527/ProgrammerBooks

微信公眾號(hào):貝塔學(xué)Java

前言

前幾篇我們一起學(xué)習(xí)了基于數(shù)組、鏈表、二叉樹、紅黑樹來(lái)實(shí)現(xiàn)Map的操作,本篇我們將會(huì)一起來(lái)學(xué)習(xí)基于散列表來(lái)實(shí)現(xiàn)Map,這種方式對(duì)應(yīng)著java里面的HashMap,這也是使用最多的一種方式

散列表實(shí)現(xiàn)Map主要分為了兩個(gè)步驟:

  1. 基于散列函數(shù)將被查找鍵轉(zhuǎn)換為數(shù)組的下標(biāo)
  2. 處理散列值沖突的情況,有兩種方式來(lái)處理沖突:拉鏈?zhǔn)胶途€性探測(cè)

散列函數(shù)

實(shí)現(xiàn)散列表的第一步就是需要考慮如何把一個(gè)鍵轉(zhuǎn)換為數(shù)組的下標(biāo),這時(shí)候就需要使用到散列函數(shù),先把鍵值轉(zhuǎn)換成一個(gè)整數(shù)然后在使用除留余數(shù)法計(jì)算出數(shù)組的下標(biāo)。每種類型的鍵我們都需要一個(gè)不同的散列函數(shù)。

在java中對(duì)于常用的數(shù)據(jù)類型已經(jīng)實(shí)現(xiàn)了hashCode,我們只需要根據(jù)hashCode的值使用除留余數(shù)法即可轉(zhuǎn)換成數(shù)組的下標(biāo)

java中的約定:如果兩個(gè)對(duì)象的equals相等,那么hashCode一定相同;如果hashCode相同,equals不一定相同。對(duì)于自定義類型的鍵我們通常需要自定義實(shí)現(xiàn)hashCode和equals;默認(rèn)的hashCode返回的是對(duì)象的內(nèi)存地址,這種散列值不會(huì)太好。

下面我來(lái)看看Java中常用類型的hashCode計(jì)算方式

Integer

由于hashCode需要返回的值就是一個(gè)int值,所以Integer的hashCode實(shí)現(xiàn)很簡(jiǎn)單,直接返回當(dāng)前的value值

@Override
public int hashCode() {
    return Integer.hashCode(value);
}
public static int hashCode(int value) {
    return value;
}

Long

Java中Long類型的hashCode計(jì)算是先把值無(wú)符號(hào)右移32位,之后再與值相異或,保證每一位都用用到,最后強(qiáng)制轉(zhuǎn)換成int值

@Override
public int hashCode() {
    return Long.hashCode(value);
}

public static int hashCode(long value) {
    return (int)(value ^ (value >>> 32));
}

Double、Float

浮點(diǎn)類型的鍵java中hashCode的實(shí)現(xiàn)是將鍵表示為二進(jìn)制

public static int hashCode(float value) {
    return floatToIntBits(value);
}
public static int floatToIntBits(float value) {
    int result = floatToRawIntBits(value);
    // Check for NaN based on values of bit fields, maximum
    // exponent and nonzero significand.
    if ( ((result & FloatConsts.EXP_BIT_MASK) ==
          FloatConsts.EXP_BIT_MASK) &&
         (result & FloatConsts.SIGNIF_BIT_MASK) != 0)
        result = 0x7fc00000;
    return result;
}

String

java中每個(gè)char都可以表示成一個(gè)int值,所以字符串轉(zhuǎn)換成一個(gè)int值

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

軟緩存

如果計(jì)算一個(gè)散列值比較耗時(shí),那么我可以這個(gè)計(jì)算好的值緩存起來(lái),即使用一個(gè)變量把這個(gè)值保存起來(lái),在下一次調(diào)用時(shí)直接返回。Java中的String就是采用的這種方式。

拉鏈?zhǔn)降纳⒘斜?/h2>

散列函數(shù)能夠?qū)㈡I值轉(zhuǎn)換成數(shù)組的下標(biāo);第二步就是需要處理碰撞沖突,也就是需要處理遇到了兩個(gè)散列值相同的對(duì)象應(yīng)該如何存儲(chǔ)。有一種方式是數(shù)組中的每一個(gè)元素都指向一個(gè)鏈表用來(lái)存放散列值相同的對(duì)象,這種方式被稱為拉鏈法

拉鏈法可以使用原始的鏈表保存鍵,也可以采用我們之前實(shí)現(xiàn)的紅黑樹來(lái)表示,在java8中采用的這兩種方式的混合,在節(jié)點(diǎn)數(shù)超過了8之后變?yōu)榧t黑樹。

這里我們就采用簡(jiǎn)單的鏈表來(lái)實(shí)現(xiàn)拉鏈?zhǔn)缴⒘斜?,?shù)據(jù)結(jié)構(gòu)使用在前幾篇中已經(jīng)實(shí)現(xiàn)的LinkedMap,可以參考前面的文章《基于數(shù)組或鏈表實(shí)現(xiàn)Map》。

image
public class SeparateChainingHashMap<K, V> implements Map<K, V> {

    private int size;
    private LinkedMap<K, V>[] table;

    public SeparateChainingHashMap(int capacity) {
        this.table = (LinkedMap<K, V>[]) new LinkedMap[capacity];
        for (int i = 0; i < capacity; i++) {
            this.table[i] = new LinkedMap<>();
        }
    }

    @Override
    public void put(K key, V value) {
        this.table[hash(key)].put(key, value);
        size++;
    }

    private int hash(K key) {
        return (key.hashCode() & 0x7fffffff) % table.length;
    }

    @Override
    public V get(K key) {
        return this.table[hash(key)].get(key);
    }

    @Override
    public void delete(K key) {
        this.table[hash(key)].delete(key);
        size--;
    }

    @Override
    public int size() {
        return size;
    }

}

這的hash函數(shù)使用key的hashCode與上0x7fffffff得到一個(gè)非負(fù)的整數(shù),然后再使用除留余數(shù)法計(jì)算出數(shù)組的下標(biāo)。其中0x7FFFFFFF 的二進(jìn)制表示就是除了首位是 0,其余都是1,也就是說,這是最大的整型數(shù) int(因?yàn)榈谝晃皇欠?hào)位,0 表示他是正數(shù)),可以使用Integer.MAX_VALUE代替

散列表的主要目的在于把鍵值均勻的分布到數(shù)組中,所以散列表是無(wú)序的,如果你需要的Map需要支持取出最大值,最小值,那么散列表都不適合。

這里我們實(shí)現(xiàn)的拉鏈?zhǔn)缴⒘斜淼臄?shù)組大小是固定的,但是通常在隨著數(shù)據(jù)量的增大,越短的數(shù)組出現(xiàn)碰撞的幾率會(huì)增大,所以需要數(shù)組支持動(dòng)態(tài)的擴(kuò)容,擴(kuò)容之后需要把原來(lái)的值重新插入到擴(kuò)容之后的數(shù)組中。數(shù)組的擴(kuò)容可以參考之前的文章《老哥是時(shí)候來(lái)復(fù)習(xí)下數(shù)據(jù)結(jié)構(gòu)與算法了》

線性探測(cè)式散列表

實(shí)現(xiàn)散列表的另一種方式就是用大小為M來(lái)保存N個(gè)鍵值,其中M>N;解決碰撞沖突就需要利用數(shù)組中的空位;這種方式中最簡(jiǎn)單的實(shí)現(xiàn)就是線性探測(cè)。

線性探測(cè)的主要思路:當(dāng)插入一個(gè)鍵,發(fā)生碰撞沖突之后直接把索引加一來(lái)檢查下一個(gè)位置,這時(shí)候會(huì)出現(xiàn)三種情況:

  1. 下一個(gè)位置和待插入的鍵相等,那么值就修改值
  2. 下一個(gè)位置和待插入的鍵不相等,那么索引加一繼續(xù)查找
  3. 如果下一個(gè)位置還是一個(gè)空位,那么直接把待插入對(duì)象放入到這個(gè)空位

初始化

線性探測(cè)式散列表使用兩個(gè)數(shù)組來(lái)存放keys和values,capacity表示初始化數(shù)組的大小

private int size;
private int capacity;
private K[] keys;
private V[] values;

public LinearProbingHashMap(int capacity) {
    this.capacity = capacity;
    this.keys = (K[]) new Object[capacity];
    this.values = (V[]) new Object[capacity];
}

插入

  1. 當(dāng)插入鍵的位置超過了數(shù)組的大小,就需要回到數(shù)組的開始位置繼續(xù)查找,直到找到一個(gè)位置為null的才結(jié)束;index = (index + 1) % capacity
  2. 當(dāng)數(shù)組已存放的容量超過了數(shù)組總?cè)萘康囊话?,就需要擴(kuò)容到原來(lái)的2倍
@Override
public void put(K key, V value) {
    if (Objects.isNull(key)) {
        throw new IllegalArgumentException("Key can not null");
    }
    if (this.size > this.capacity / 2) {
        resize(2 * this.capacity);
    }
    int index;
    for (index = hash(key); this.keys[index] != null; index = (index + 1) % capacity) {
        if (this.keys[index].equals(key)) {
            this.values[index] = value;
            return;
        }
    }
    this.keys[index] = key;
    this.values[index] = value;
    size++;
}

動(dòng)態(tài)調(diào)整數(shù)組的大小

我們可以參考之前在文章《老哥是時(shí)候來(lái)復(fù)習(xí)下數(shù)據(jù)結(jié)構(gòu)與算法了》中實(shí)現(xiàn)的動(dòng)態(tài)調(diào)整數(shù)據(jù)的大小;在線性探測(cè)中需要把原來(lái)的數(shù)據(jù)重新插入到擴(kuò)容之后的數(shù)據(jù),因?yàn)閿?shù)組的大小變了需要重新計(jì)算索引的位置。

private void resize(int cap) {
    LinearProbingHashMap<K, V> linearProbingHashMap = new LinearProbingHashMap<>(cap);
    for (int i = 0; i < capacity; i++) {
        linearProbingHashMap.put(keys[i], values[i]);
    }
    this.keys = linearProbingHashMap.keys;
    this.values = linearProbingHashMap.values;
    this.capacity = linearProbingHashMap.capacity;
}

查詢

線性探測(cè)散列表中實(shí)現(xiàn)查詢的思路:根據(jù)待查詢key的hash函數(shù)計(jì)算出索引的位置,然后開始判斷當(dāng)前位置上的key是否和待查詢key相等,如果相等那么就直接返回value,如果不相等那么就繼續(xù)查找下一個(gè)索引直到遇到某個(gè)位置是null才結(jié)束,表示查詢的key不存在;

@Override
public V get(K key) {
    if (Objects.isNull(key)) {
        throw new IllegalArgumentException("Key can not null");
    }
    int index;
    for (index = hash(key); this.keys[index] != null; index = (index + 1) % capacity) {
        if (this.keys[index].equals(key)) {
            return this.values[index];
        }
    }
    return null;
}

刪除元素

線性探測(cè)式的刪除稍微麻煩一些,首先需要查找出待刪除的元素位置,刪除元素之后需要把當(dāng)前索引之后的連續(xù)位置上的元素重新插入;因?yàn)槭欠裼锌瘴粚?duì)于線性探測(cè)式散列表的查找至關(guān)重要;例如:5->7->9,刪除了7之后,如果不重新插入7的位置就是空位,那么get方法將無(wú)法查詢到9這個(gè)元素;

每次刪除之后都需要檢測(cè)一次數(shù)組中實(shí)際元素的個(gè)數(shù),如果與數(shù)組的容量相差較大,就可以進(jìn)行縮容操作;

@Override
public void delete(K key) {
    if (Objects.isNull(key)) {
        throw new IllegalArgumentException("Key can not null");
    }
    int index;
    for (index = hash(key); this.keys[index] != null; index = (index + 1) % capacity) {
        if (this.keys[index].equals(key)) {
            this.keys[index] = null;
            this.values[index] = null;
            break;
        }
    }

    for (index = (index + 1) % capacity; this.keys[index] != null; index = (index + 1) % capacity) {
        this.size--;
        this.put(this.keys[index], this.values[index]);
        this.keys[index] = null;
        this.values[index] = null;
    }
    this.size--;
    if (size > 0 && size < capacity / 4) {
        resize(capacity / 2);
    }

}

文中所有源碼已放入到了github倉(cāng)庫(kù):
https://github.com/silently9527/JavaCore

最后(點(diǎn)關(guān)注,不迷路)

文中或許會(huì)存在或多或少的不足、錯(cuò)誤之處,有建議或者意見也非常歡迎大家在評(píng)論交流。

最后,寫作不易,請(qǐng)不要白嫖我喲,希望朋友們可以點(diǎn)贊評(píng)論關(guān)注三連,因?yàn)檫@些就是我分享的全部動(dòng)力來(lái)源??

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

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

  • 本文主要介紹散列表(Hash Table)這一常見數(shù)據(jù)結(jié)構(gòu)的原理與實(shí)現(xiàn)。由于個(gè)人水平有限,文章中難免存在不準(zhǔn)確或是...
    absfree閱讀 16,638評(píng)論 2 35
  • 符號(hào)表是一種用于存儲(chǔ)鍵值對(duì)(key-value pair)的數(shù)據(jù)結(jié)構(gòu),我們平常經(jīng)常使用的數(shù)組也可以看做是一個(gè)特殊的...
    Java圈子閱讀 437評(píng)論 0 0
  • 特殊的線性表存儲(chǔ)結(jié)構(gòu)--散列表(HashTable) 散列表用的是數(shù)組支持按照下標(biāo)隨機(jī)訪問數(shù)據(jù)的特性,所以散列表其...
    凱凱丶凱凱閱讀 2,218評(píng)論 0 0
  • 數(shù)據(jù)結(jié)構(gòu)與算法--散列表 之前學(xué)習(xí)了基于鏈表的順序查找、基于有序數(shù)組的二分查找、二叉查找樹、紅黑樹,這些算法在查找...
    sunhaiyu閱讀 737評(píng)論 3 5
  • HashMap是Map接口的一個(gè)實(shí)現(xiàn)。Map的實(shí)現(xiàn)都是一個(gè)容器,用于存儲(chǔ)鍵值對(duì)。 Map并不一定要通過散列表實(shí)現(xiàn),...
    劉惜有閱讀 771評(píng)論 0 1

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