數據結構 - 哈希表解析實現

哈希表

Hash表也稱散列表,也可以直接譯作哈希表,Hash表是一種根據關鍵字值(key - value)映射到表中的一個位置而直接進行訪問的數據結構,這個映射函數叫散列函數(哈希函數)

在這裏插入圖片描述

(鏈地址法哈希表)

哈希表基於數組實現,通過把關鍵字映射到數組的某個下標(哈希函數)來加快查找速度,查找某個關鍵字對於哈希表來說,只是O(1)的時間級


什麼是散列函數

散列函數:將關鍵字裝換爲數組的特定下標,這種轉換的函數就是哈希函數

若對於關鍵字集合中的任一個關鍵字,經散列函數映象到地址集合中任何一個地址的概率是相等的,則稱此類散列函數爲均勻散列函數(Uniform Hash function)

就如同構建一個字典,先將所有的單詞全部存入內存,散列函數就如同目錄,是單詞與對應地址的映射

一個高效的散列函數可以幫我們快速定位對應的地址,如何設置高效的散列函數?

需要考慮:

  • 計算哈希函數所需時間
  • 關鍵字的長度、種類
  • 哈希表的大小
  • 關鍵字的分佈情況
  • 記錄的查找頻率

常見的散列函數有六種:

  1. 直接尋址法:取關鍵字或關鍵字的某個線性函數值爲散列地址,也就是f(key) = a * key + b,最基本的散列函數
  2. 數據分析法:分析一組數據,找出數字的規律,儘可能利用這些數據來構造衝突機率較低的散列地址(如網址都是後幾位不同,就利用後幾位映射地址)
  3. 平方取中法:當無法確定關鍵字中哪幾位分佈較均勻時,可以先求出關鍵字的平方值,然後按需要取平方值的中間幾位作爲哈希地址
  4. 摺疊法:將關鍵字分割成位數相同的幾部分,最後一部分位數可以不同,然後取這幾部分的疊加和(去除進位)作爲散列地址
  5. 隨機數法:選擇一隨機函數,取關鍵字的隨機值作爲散列地址
  6. 除留餘數法:取關鍵字被某個不大於散列表表長m的數p除後所得的餘數爲散列地址

當關鍵字是非基本數據類型時需要考慮多方面,這裏就不深追,以下的例子以關鍵字爲整形,通過取餘法爲散列函數簡單處理


衝突

取餘法:關鍵字除一個設置的不大於散列表表長的數m,key % m得到餘數,餘數爲0~9,即將關鍵字映射到了0-9的數組上

把巨大的數字範圍壓縮到較小的數字範圍,那麼肯定會有幾個不同的單詞哈希化到同一個數組下標,即產生了衝突

例如設置m=10,數組爲0~9,1和11都會映射到數組下標爲1的位置,這就產生了衝突

常用兩種方法:

  • 開放地址法:當衝突產生時,再給關鍵字找一個地址,有三種方法:線性探測、二次探測以及再哈希法
  • 鏈地址法:擴展數組的單元,即數組每個數據項設置爲鏈表或者子數組

開放地址法

開發地址法:當衝突發生時,爲關鍵字再找一個合適的位置

線性探測

當散列函數得到的位置被佔,數組下標依次遞增,直到找到空白的位置

例如:存入1和11,1的位置被佔了,在下標2的位置存放11

以下通過Java實現一個開放地址法的哈希表

將要存放的數據,關鍵字爲int型


class DataItem {
    private int iData;

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

    public int getKey() {
        return iData;
    }
}

哈希表,設置了以下重要方法:

  • 取餘:除數組大小
  • 插入數據項
  • 更新數組:新建數據爲原數組大小的兩倍
  • 刪除數據項
  • 根據關鍵字查找數據項
public class MyHashTable {
    //DataItem類,表示每個數據項信息
    private DataItem[] hashArray;
    //數組的初始大小
    private int arraySize;
    //數組實際存儲了多少項數據
    private int itemNum;
    //用於刪除數據項
    private DataItem nonItem;

    public MyHashTable(int arraySize) {
        this.arraySize = arraySize;
        hashArray = new DataItem[arraySize];
        //刪除的數據項下標初始化爲-1
        nonItem = new DataItem(-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.println("== 第 "+j+" 項爲:"+hashArray[j].getKey() + " ==");
            } else {
                System.out.println("== 第 "+j+" 項爲空 ==");
            }
        }
    }

    //通過哈希函數轉換得到數組下標
    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) {
            //線性探測,直接下標+1
            ++hashVal;
            //防止越界
            hashVal %= arraySize;
        }
        hashArray[hashVal] = item;
        itemNum++;
    }

    /**
     * 數組不能擴展,只能新建數組存放,數組大小改變,需要重新映射所有數據
     * 這個過程叫做重新哈希化。這是一個耗時的過程
     * */
    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];
                //nonItem表示空Item,其key爲-1
                hashArray[hashVal] = nonItem;
                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];
            }
            ++hashVal;
            hashVal %= arraySize;
        }
        return null;
    }
}

設置一個測試類:

class Test{
    public static void main(String[] args) {
        MyHashTable myHashTable = new MyHashTable(10);

        Scanner scanner = new Scanner(System.in);
        while (true){
            System.out.println("add:添加數據 display:顯示數據 find:查找 exit:退出");
            String key = scanner.next();

            switch (key){
                case "add":
                    System.out.println("輸入iData:");
                    int iData = scanner.nextInt();
                    DataItem dataItem = new DataItem(iData);
                    myHashTable.insert(dataItem);
                    break;
                case "display":
                    myHashTable.display();
                    break;
                case "find":
                    System.out.println("請輸入要查找的數據項的iData:");
                    iData = scanner.nextInt();
                    myHashTable.find(iData);
                    break;
                case "exit":
                    scanner.close();
                    System.exit(0);
                default:
                    break;
            }
        }
    }
}

添加3個數據:

在這裏插入圖片描述

打印,可以發現通過取餘分別將數據放置在相應的位置:

在這裏插入圖片描述

再添加一個數據11,與1衝突,按照線性探測,放到下一個位置:

在這裏插入圖片描述

當數據滿時,需要重新哈希化:

在這裏插入圖片描述

數組大小*2:
在這裏插入圖片描述

裝填因子

可以從上面的案例中看出,當數組中數據數量到一定大小後,每次插入數據需要多次計算插入位置,這是比較耗時的

我們需要儘量減少再次計算,就需要引入一個填裝因子

裝填因子:已添加到哈希表內的數據項與表長的比例
例如哈希表大小1000,添加了500個數據項,裝填因子爲0.5

裝填因子表示哈希表中元素填滿的程度,當數據項到達一定程度,數據需要重新計算位置即衝突的機率很大

二次探測

二次探測是爲了防止數據聚集,本質也是線性探測,區別在與設置較遠的探測步長

從散列函數計算的原始下標爲x,線性探測是x+1,x+2,x+3…,而二次探測爲x+1,x+4,x+9,x+16…
在這裏插入圖片描述

相對與線性探測有了一定的改進

再散列法

前面線性探測、二次探測實際上都是固定的步長,我們可以設置一種依賴關鍵字的探測序列

再散列法:把關鍵字用不同的散列函數再做一遍散列化,用這個結果作爲步長,進行探測

第二個散列函數需要:

  • 和第一個散列函數不同
  • 不能輸出0,不然每次探測會原地踏步,陷入死循環

專家們已經發現下面形式的哈希函數工作的非常好:stepSize = constant - key % constant; 其中constant是質數,且小於數組容量,表的容量要是一個質數

例如:對上面的例子進行修改

創建第二個散列函數:步長 = 7 - key % 7

	//第二個散列函數:計算步長
	public int hashFunction2(int key){
        return 7 - key % 7;
    }
    
    //插入數據項
    public void insert2(DataItem item) {

        if (isFull()) {
            //哈希表已滿,擴展哈希表
            System.out.println("哈希表已滿,重新哈希化...");
            extendHashTable();
        }
        int key = item.getKey();
        int hashVal = hashFunction(key);
        //第二散列函數計算步長
        int stepSize = hashFunction2(key);
        while (hashArray[hashVal] != null && hashArray[hashVal].getKey() != -1) {
            //再散列法
            hashVal += stepSize;
            //防止越界
            hashVal %= arraySize;
        }
        hashArray[hashVal] = item;
        itemNum++;
    }

修改一下測試類,添加1和11,添加11時,步長爲7-11%7=3,即在下標爲4的位置加入數據

在這裏插入圖片描述

再散列法因爲由關鍵字決定步長,可以較有效的減少聚集


鏈地址法

把數組的數據項設置爲鏈表或者數組

常用的數組+鏈表

在這裏插入圖片描述

Java實現哈希表:數組+鏈表

這裏再稍微該動:存放的數據爲員工對象,屬性爲id,name

//表示一個員工
class Emp{
    public int id;
    public String name;
    public Emp next;

    public Emp(int id, String name) {
        this.id = id;
        this.name = name;
    }
}

這裏的哈希表=數組+鏈表,即需要分開爲兩個類:員工鏈表、哈希表

員工鏈表:

  • 添加員工:直接在鏈表尾加上新增員工
  • 遍歷:遍歷鏈表打印
  • 根據id查找員工
package com.company.hashTable;

import java.util.Scanner;

/**
 * @author zfk
 * 哈希表
 */

//員工鏈表
class EmpLinkedList{
    //頭指針爲第一個Emp
    private Emp head;

    //添加員工,直接加在最後
    public void add(Emp emp){
        //如果是添加第一個員工
        if (head == null){
            head = emp;
            return;
        }
        //如果不是第一個
        Emp cur = head;

        while (true){
            //到了鏈表最後
            if (cur.next == null) {
                break;
            }
            cur = cur.next;
        }
        //退出時cur爲鏈表最後一個元素
        cur.next = emp;
    }

    //遍歷鏈表
    public void list(int no){

        if (head == null){
            System.out.println("=== 第 "+no+" 條鏈表爲空 ===");
            return;
        }
        System.out.println("=====第 "+no+" 條鏈表=======");
        Emp cur = head;
        while (true){
            System.out.print("=> id = "+cur.id+" name = "+cur.name);
            //已經是最後的節點
            if (cur.next == null){
                break;
            }
            //後移
            cur = cur.next;
        }
        System.out.println();
        System.out.println("============");
    }

    //根據id查找員工
    public Emp findEmpById(int id){
        //判斷鏈表是否爲空
        if (head == null){
            System.out.println("=== 當前鏈表爲空 ===");
            return null;
        }
        Emp cur = head;

        while (true){
            //找到了cur
            if (cur.id == id){
                break;
            }
            //遍歷完當前鏈表沒有找到,退出
            if (cur.next == null){
                cur = null;
                break;
            }
            //後移
            cur = cur.next;
        }

        return cur;
    }
}

//哈希表
class HashTable{
    private EmpLinkedList[] linkedListArray;
    //表示鏈表數
    private int size;

    public HashTable(int size) {
        this.size = size;
        this.linkedListArray = new EmpLinkedList[size];
        //分別初始化鏈表
        for (int i = 0;i < size;i++){
            linkedListArray[i] = new EmpLinkedList();
        }
    }
    //添加
    public void add(Emp emp){
        //根據員工的id得到該員工應當添加到哪個鏈表
        int listNum = hashFun(emp.id);
        //將emp添加到對應的鏈表
        linkedListArray[listNum].add(emp);
    }

    //散列函數,使用取模法
    public int hashFun(int id){
        return id % size;
    }
    //遍歷所有的鏈表
    public void list(){
        for (int i = 0;i < size;i++){
            linkedListArray[i].list(i);
        }
    }

    //根據輸入的id查找員工
    public void findEmpById(int id){
        //使用散列函數確定應該從哪條鏈表查找
        int listNum = hashFun(id);
        Emp empById = linkedListArray[listNum].findEmpById(id);

        if (empById != null){
            System.out.println("在第 "+listNum+" 條鏈表查找到");
        }
        else {
            System.out.println("=== 在哈希表中沒有找到 ===");
        }
    }
}

測試類:設置哈希表數組大小7

public class HashTableDemo {
    public static void main(String[] args) {
        //創建一個哈希表
        HashTable hashTable = new HashTable(7);
        Scanner scanner = new Scanner(System.in);
        while (true){
            System.out.println("add:添加成員 list:顯示成員 find:查找 exit:退出");
            String key = scanner.next();

            switch (key){
                case "add":
                    System.out.println("輸入id:");
                    int id = scanner.nextInt();
                    System.out.println("輸入name:");
                    String name = scanner.next();
                    Emp emp = new Emp(id, name);
                    hashTable.add(emp);
                    break;
                case "list":
                    hashTable.list();
                    break;
                case "find":
                    System.out.println("請輸入要查找的id:");
                    id = scanner.nextInt();
                    hashTable.findEmpById(id);
                    break;
                case "exit":
                    scanner.close();
                    System.exit(0);
                 default:
                     break;
            }
        }
    }

}

添加1,8數據項,這個時候我們就不需要考慮衝突的問題了

在這裏插入圖片描述


總結

hash表是一種根據關鍵字值(key-value)映射到表中的位置而直接訪問的數據結構,構建hash表關鍵在於創建高效的hash函數

需要考慮:

  • 計算哈希函數所需時間
  • 關鍵字的長度、種類
  • 哈希表的大小
  • 關鍵字的分佈情況
  • 記錄的查找頻率

常見的hash函數有6種:

  • 直接尋址法
  • 數字分析法
  • 平方取中法
  • 摺疊法
  • 隨機數法
  • 取餘法

hash函數可以壓縮數據,將大範圍數據映射到小範圍,但不可避免會出現衝突:不同數據計算出同一下標
當數據出現衝突,會大量消耗時間去計算新的地址,引進了裝填因子的概念,裝填因子=數組中數據項 / 數組容量,裝填因子表示哈希表中元素填滿的程度,當裝填因子越大,越可能發生衝突

爲了解決衝突,有兩種方法:

  • 開放地址法:當地址已被佔有,根據一定規則找到新的位置放置
  • 鏈地址法:將數組的項設置爲鏈表或子數組
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章