1. 程式人生 > >Java資料結構和演算法--雜湊表

Java資料結構和演算法--雜湊表

Hash表也稱散列表,直譯為雜湊表,hash表是一種根據關鍵字值(key-value)而直接進行訪問的資料結構。它基於陣列,通過把關鍵字對映到陣列的某個下標來加快查詢速度,這種對映轉換作用的函式我們稱之為雜湊函式。

每種雜湊表都有自己的雜湊函式,雜湊函式是自己定義的,沒有統一的標準,下面我們基於這個簡單的雜湊函式(hashValue=key%arraySize)來分析一下雜湊表的實現過程。其中hashValue為雜湊值,key為雜湊表的鍵值,arraySize為表的陣列的大小。我們先來說一下什麼是雜湊值衝突,就是我們不同的key值通過雜湊函式的計算,有可能得到相同的雜湊值,比如上面的雜湊函式來計算,如果key為1和11,而arraySize為10的話,那麼它們的雜湊值等於1,這就衝突了。所以單純依靠雜湊值來對映陣列單元的話,是不可能實現的雜湊表的,我們必須要有方法來解決這種衝突。

有什麼解決辦法呢?我們常用的有開放地址法鏈地址法,下面我們來看看什麼是開放地址法

1.開放地址法

開放地址法是指,當我們通過雜湊函式計算得出的下標值對應的陣列單元已經被佔用的時候,我們就要尋找其他的位置,主要的方法有:線性探測法、二次探測法、再雜湊法。

1.1線性探測

線上性探測中,我們會線性去查詢空白單元。比如我們的a位置被佔用,我們就會去查詢a+1,如果a+1也被佔用,繼續a+2,以此類推,它會沿著陣列下標一步一步去查詢,直到找到空白的位置。下面我們通過一個程式碼例子來看看線性探測是怎麼樣的

public class LinearProbingHashTable {

    private DataItem[] hashArray;   //DataItem類,表示每個資料項資訊
    private int arraySize;//陣列的初始大小
    private int itemNum;//陣列實際儲存了多少項資料
    private DataItem nonItem;//用於刪除資料項

    public LinearProbingHashTable(int arraySize) {
        this.arraySize = arraySize;
        hashArray = new DataItem[arraySize];
        nonItem = new DataItem(-1);//刪除的資料項下標為-1
    }

    //判斷陣列是否儲存滿了
    public boolean isFull() {
        return (itemNum == arraySize);
    }

    //判斷陣列是否為空
    public boolean isEmpty() {
        return (itemNum == 0);
    }

    //列印陣列內容
    public void display() {
        System.out.println("Table:");
        for (int j = 0; j < arraySize; j++) {
            if (hashArray[j] != null) {
                System.out.print(hashArray[j].getKey() + " ");
            } else {
                System.out.print("** ");
            }
        }
    }

    //通過雜湊函式轉換得到陣列下標
    public int hashFunction(int key) {
        return key % arraySize; //對陣列大小取餘
    }

    //插入資料項
    public void insert(DataItem item) {
        if (isFull()) {
            //擴充套件雜湊表
            System.out.println("雜湊表已滿,重新雜湊化...");
            extendHashTable();
        }
        int key = item.getKey();
        int hashVal = hashFunction(key);
        while (hashArray[hashVal] != null && hashArray[hashVal].getKey() != -1) {
            //線性探測
            ++hashVal;
            //做一次雜湊計算,
            hashVal %= arraySize;
        }
        hashArray[hashVal] = item;
        itemNum++;
    }

    /**
     * 陣列有固定的大小,而且不能擴充套件,所以擴充套件雜湊表只能另外建立一個更大的陣列,然後把舊陣列中的資料插到新的陣列中。
     * 但是雜湊表是根據陣列大小計算給定資料的位置的,所以這些資料項不能再放在新陣列中和老陣列相同的位置上。
     * 因此不能直接拷貝,需要按順序遍歷老陣列,並使用insert方法向新陣列中插入每個資料項。
     * 這個過程叫做重新雜湊化。這是一個耗時的過程,但如果陣列要進行擴充套件,這個過程是必須的。
     */
    public void extendHashTable() {
        int num = arraySize;
        itemNum = 0;//重新計數,因為下面要把原來的資料轉移到新的擴張的陣列中
        arraySize *= 2;//陣列大小翻倍
        DataItem[] oldHashArray = hashArray;
        hashArray = new DataItem[arraySize];
        for (int i = 0; i < num; i++) {
            insert(oldHashArray[i]);
        }
    }

    //刪除資料項
    public DataItem delete(int key) {
        if (isEmpty()) {
            System.out.println("Hash Table is Empty!");
            return null;
        }
        int hashVal = hashFunction(key);
        while (hashArray[hashVal] != null) {
            if (hashArray[hashVal].getKey() == key) {
                DataItem temp = hashArray[hashVal];
                hashArray[hashVal] = nonItem;//nonItem表示空Item,其key為-1,作為被刪除項的標識
                itemNum--;
                return temp;
            }
            ++hashVal;
            hashVal %= arraySize;
        }
        return null;
    }

    //查詢資料項
    public DataItem find(int key) {
        int hashVal = hashFunction(key);
        while (hashArray[hashVal] != null) {
            if (hashArray[hashVal].getKey() == key) {
                return hashArray[hashVal];
            }
            //當沒有找到key對應的data時,用和插入同樣的線性探測方法去尋找
            ++hashVal;
            hashVal %= arraySize;
        }
        return null;
    }


    public static class DataItem {
        private int iData;

        public DataItem(int iData) {
            this.iData = iData;
        }

        public int getKey() {
            return iData;
        }
    }
}

像這種線性探測的雜湊表,有一個嚴重的缺點,就是當陣列填得越來越滿時,有可能探測的次數就會越來越多,因為空白單元越來越少,解決這種問題的主要方法有二次探測和再雜湊法。因為再雜湊法是一種比較好的解決方案,所以下面我們來介紹再雜湊法。

1.2 再雜湊法

就是在探測前,我們再利用一個雜湊函式來計算探測步長,而不是線性探測那樣每次的步長都為1,經驗得出的有效再雜湊方法為:stepSize = constant - key % constant,其中constant為質數而且小於雜湊表陣列的大小, 同時陣列的大小也為質數。因為這樣才能避免步長出現重複迴圈的現象。下面我們來看看一個再雜湊法的程式碼實現

public class HashDouble {

    private DataItem[] hashArray;   //DataItem類,表示每個資料項資訊
    private int arraySize;//陣列的初始大小
    private int itemNum;//陣列實際儲存了多少項資料
    private DataItem nonItem;//用於刪除資料項

    public HashDouble() {
        this.arraySize = 13; //預設大小13
        hashArray = new DataItem[arraySize];
        nonItem = new DataItem(-1);//刪除的資料項下標為-1
    }

    //判斷陣列是否儲存滿了
    public boolean isFull() {
        return (itemNum == arraySize);
    }

    //判斷陣列是否為空
    public boolean isEmpty() {
        return (itemNum == 0);
    }

    //列印陣列內容
    public void display() {
        System.out.println("Table:");
        for (int j = 0; j < arraySize; j++) {
            if (hashArray[j] != null) {
                System.out.print(hashArray[j].getKey() + " ");
            } else {
                System.out.print("** ");
            }
        }
    }

    //通過雜湊函式轉換得到陣列下標
    public int hashFunction1(int key) {
        return key % arraySize;
    }

    public int hashFunction2(int key) {
        return 5 - key % 5;
    }

    //插入資料項
    public void insert(DataItem item) {
        if (isFull()) {
            //擴充套件雜湊表
            System.out.println("雜湊表已滿,重新雜湊化...");
            extendHashTable();
        }
        int key = item.getKey();
        int hashVal = hashFunction1(key);
        int stepSize = hashFunction2(key);//用第二個雜湊函式計算探測步數
        while (hashArray[hashVal] != null && hashArray[hashVal].getKey() != -1) {
            hashVal += stepSize;
            hashVal %= arraySize;//以指定的步數向後探測
        }
        hashArray[hashVal] = item;
        itemNum++;
    }

    /**
     * 陣列有固定的大小,而且不能擴充套件,所以擴充套件雜湊表只能另外建立一個更大的陣列,然後把舊陣列中的資料插到新的陣列中。
     * 但是雜湊表是根據陣列大小計算給定資料的位置的,所以這些資料項不能再放在新陣列中和老陣列相同的位置上。
     * 因此不能直接拷貝,需要按順序遍歷老陣列,並使用insert方法向新陣列中插入每個資料項。
     * 這個過程叫做重新雜湊化。這是一個耗時的過程,但如果陣列要進行擴充套件,這個過程是必須的。
     */
    public void extendHashTable() {
        int num = arraySize;
        itemNum = 0;//重新計數,因為下面要把原來的資料轉移到新的擴張的陣列中
        arraySize *= 2;//陣列大小翻倍
        DataItem[] oldHashArray = hashArray;
        hashArray = new DataItem[arraySize];
        for (int i = 0; i < num; i++) {
            insert(oldHashArray[i]);
        }
    }

    //刪除資料項
    public DataItem delete(int key) {
        if (isEmpty()) {
            System.out.println("Hash Table is Empty!");
            return null;
        }
        int hashVal = hashFunction1(key);
        int stepSize = hashFunction2(key);
        while (hashArray[hashVal] != null) {
            if (hashArray[hashVal].getKey() == key) {
                DataItem temp = hashArray[hashVal];
                hashArray[hashVal] = nonItem;//nonItem表示空Item,其key為-1
                itemNum--;
                return temp;
            }
            hashVal += stepSize;
            hashVal %= arraySize;
        }
        return null;
    }

    //查詢資料項
    public DataItem find(int key) {
        int hashVal = hashFunction1(key);
        int stepSize = hashFunction2(key);
        while (hashArray[hashVal] != null) {
            if (hashArray[hashVal].getKey() == key) {
                return hashArray[hashVal];
            }
            hashVal += stepSize;
            hashVal %= arraySize;
        }
        return null;
    }

    public static class DataItem {
        private int iData;

        public DataItem(int iData) {
            this.iData = iData;
        }

        public int getKey() {
            return iData;
        }
    }

}

上面是開放地址法的雜湊表實現過程,下面我們來看看鏈地址法是怎麼實現雜湊表的。

2.鏈地址法

就是在雜湊表中的每個單元中設定一個連結串列,資料項還是像之前一樣通過對映關鍵字找到陣列單元,但是資料項不是插入到當前陣列單元中而是插入到單元所在的連結串列中。其他產生雜湊值衝突的資料項也將不用再去尋找空白單元了,一併地插入到對應單元的連結串列中。下面我們通過程式碼來說話:

先定義一個有序連結串列

public class SortLink {
    
    private LinkNode first;

    public SortLink() {
        first = null;
    }

    public boolean isEmpty() {
        return (first == null);
    }

    //插入節點
    public void insert(LinkNode node) {
        int key = node.getKey();
        LinkNode previous = null;
        LinkNode current = first;
        //按順序插入,找到需要插入位置兩邊的節點
        while (current != null && current.getKey() < key) {
            previous = current;
            current = current.next;
        }
        //如果頭結點為null
        if (previous == null) {
            first = node;
        } else {
            //插入到中間
            node.next = current;
            previous.next = node;
        }
    }

    public void delete(int key) {
        LinkNode previous = null;
        LinkNode current = first;
        if (isEmpty()) {
            System.out.println("Linked is Empty!!!");
            return;
        }
        while (current != null && current.getKey() != key) {
            previous = current;
            current = current.next;
        }
        if (previous == null) {
            first = first.next;
        } else {
            previous.next = current.next;
        }
    }

    //查詢節點
    public LinkNode find(int key) {
        LinkNode current = first;
        //從頭節點開始查詢
        while (current != null && current.getKey() <= key) {
            if (current.getKey() == key) {
                return current;
            }
        }
        return null;
    }

    public void displayLink() {
        System.out.println("Link(First->Last)");
        LinkNode current = first;
        while (current != null) {
            current.displayLink();
            current = current.next;
        }
        System.out.println("");
    }

    class LinkNode {
        private int iData;
        public LinkNode next;

        public LinkNode(int iData) {
            this.iData = iData;
        }

        public int getKey() {
            return iData;
        }

        public void displayLink() {
            System.out.println(iData + " ");
        }
    }
}

基於這個有序連結串列,我們來看看鏈地址法的程式碼

public class HashChaining {

    private SortLink[] hashArray;//陣列中存放連結串列
    private int arraySize;

    public HashChaining(int size) {
        arraySize = size;
        hashArray = new SortLink[arraySize];
        //new 出每個空連結串列初始化陣列
        for (int i = 0; i < arraySize; i++) {
            hashArray[i] = new SortLink();
        }
    }

    public void displayTable() {
        for (int i = 0; i < arraySize; i++) {
            System.out.print(i + ":");
            hashArray[i].displayLink();
        }
    }

    public int hashFunction(int key) {
        return key % arraySize;
    }

    //插入
    public void insert(SortLink.LinkNode node) {
        int key = node.getKey();
        int hashVal = hashFunction(key);
        hashArray[hashVal].insert(node);//直接往連結串列中新增即可
    }

    public SortLink.LinkNode delete(int key) {
        int hashVal = hashFunction(key);
        SortLink.LinkNode temp = find(key);
        hashArray[hashVal].delete(key);//從連結串列中找到要刪除的資料項,直接刪除
        return temp;
    }

    public SortLink.LinkNode find(int key) {
        int hashVal = hashFunction(key);
        SortLink.LinkNode node = hashArray[hashVal].find(key); //直接從連結串列中查詢
        return node;
    }

}

一般來說鏈地址法比開放地址法要好,所用時間要少。我們經常用到的HashMap也是用到了鏈地址法, 後面有時間話,我會寫一篇關於HashMap原理的文章。好了,關於雜湊表我就簡單講到這裡。

原始碼地址:https://github.com/jiusetian/DataStructureDemo/tree/master/app/src/main/java/hash