1. 程式人生 > >資料結構—雜湊—PART1(雜湊的Java實現)

資料結構—雜湊—PART1(雜湊的Java實現)

0. 前言

我是一名資料結構與演算法的初學者,為了鞏固知識點,與更多的IT朋友們交流,我將在CSDN社群釋出資料結構與算法系列的學習總結。根據學習進度,內容大概1-2週一更,歡迎大家對相關知識點進行校正和補充,共同進步,謝謝~

1. 概念

這一章將介紹雜湊的基本知識以及一些核心思想。高亮部分為重要知識點或需要掌握的概念。1.1節介紹什麼是散列表,1.2-1.4節介紹散列表的核心思想:雜湊函式、解決衝突、再雜湊

1.1 什麼是散列表

散列表是一種以常數平均時間執行增、刪、查的技術,但不支援排序。 散列表其實是包含一些項的具有固定大小的陣列大小為TableSize,如下表。向陣列中新增元素時,將項對映到 [0,TableSize)中的某個數index,然後把這個元素新增到陣列中相應的位置。這就是散列表的基本想法,接下來就要解決如何對映的問題。

角標
0 張三 18
1
2 王五 18
3 趙六 18
TableSize-1

1.2 雜湊函式h(x)

1.1節介紹了雜湊的基本工作原理,在這一節將解決對映的問題,給定一個項,我們可以根據這個項中的某個關鍵字進行對映,雜湊函式將關鍵字轉換成散列表陣列的角標。

  1. 當關鍵字Key為整數時,令雜湊函式h(x) = Key % TableSize,這種方法最為簡單,但是需要考慮的是,若表的大小是10,而項的關鍵字個位都是0時,所有的項都被對映到0這個位置,就發生了衝突,為了避免衝突,最好的方法是將TableSize設定為質數,這樣衝突就會減少,但還不能完全避免,比如TableSize = 11,而關鍵字是11的倍數。本文將在1.3中介紹解決衝突的方法
    ,在第2章中給出雜湊的java程式碼實現。
  2. 當關鍵字是字串時,雜湊函式見Code1
    //Code1 關鍵字是字串時的雜湊函式
    //一個良好的雜湊函式,根據Horner法則計算
    public int hash(String key, int tableSize){
    	int hasVal = 0;
    
    	for (int i = 0; i < key.length(); i++) {
    		hasVal = 37 * hasVal + key.charAt(i);
    	}
    
    	hasVal %= tableSize;
    	if(hasVal < 0)//計算出來的hasVal可能會溢位,產生負數,因此需要進行判斷
    hasVal += tableSize; return hasVal; }
  3. 使用物件進行雜湊:雜湊函式並不是一成不變的,只是有些雜湊函式能把關鍵字均勻的分配,不易造成衝突,而有些雜湊函式映射出來的結果很差。其實Object的hashCode()方法可以為我們獲取物件的雜湊值,我們可以在此基礎上寫雜湊函式,需要時,也可以覆蓋hashCode()方法。
    //基於物件雜湊值的雜湊函式	
    public int hash(Object obj, int tableSize){
    int hasVal = obj.hashCode();
    
    hasVal %= tableSize;
    
    if(hasVal < 0)
    	hasVal += tableSize;
    return hasVal;
    }
    

1.2 解決衝突

本節解釋發生衝突時如何解決衝突,但本節並不能解決全部的衝突,這個小問題留在1.3中解決。 將一個新的項存入散列表的某個位置時,這個位置可能已經被其他項佔據,這稱之為衝突。解決衝突的方法有幾種,最簡單的是分離連結法開放定址法,而開放定址法又分為線性探測、平方探測以及雙雜湊。根據方法的不同,我們有不同的雜湊實現,見第2章。以下介紹這些方法的基本原理和基本概念。

  1. 分離連結法-分離散列表 將雜湊到同一個值的所有元素儲存到一個表(LinkedList)中,這樣便解決了衝突。散列表陣列儲存的元素為LinkedList連結串列,而每個linkedList儲存衝突的項。比如向連結串列中插入2時發現3佔據了這個位置,但是沒關係,用連結串列把3和2串起來一起放到這個位置即可。將雜湊到同一個值的所有元素儲存到一個表(LinkedList)中,這樣便解決了衝突。散列表陣列儲存的元素為LinkedList連結串列,而每個linkedList儲存衝突的項。比如向連結串列中插入2時發現3佔據了這個位置,但是沒關係,用連結串列把3和2串起來一起放到這個位置即可。 執行查詢時,使用雜湊函式來確定需要遍歷哪個連結串列;執行插入時,檢查對應連結串列中是否存在這個元素,若不存在,則在連結串列的前端插入,這麼做的原因是:新插入的元素最有可能不久後又被訪問。
角標 陣列儲存的連結串列
0 linkedList0 2->3->4->1
1 linkedList1 5->6->7->8
2 linkedList2 12->10->11->9
3 linkedList3 13->14->16->15
TableSize-1 linkedList… …->…->…->…
  1. 開放定址法-探測散列表 當儲存元素髮生衝突時,這種方法嘗試另外的單元h(x),直到找出空單元位置。根據1.2節的雜湊函式hash(x)構造新的雜湊函式雜湊函式hi(x) hi(x) = ( hash( x ) + f( i ) ) % tableSize 通過試探h0(x)、h1(x)、h2(x)……這些單元,找到空位。hash(x)是1.2節的雜湊函式,f(i)函式的作用是解決衝突,hi(x)是新的雜湊函式。 根據f(i)的不同,開放定址法可分為線性探測法、平方探測法、雙雜湊法總之,開放地址法的思想是,當發生衝突時,我們從衝突發生的位置按照一定的規則不斷試探其他單元,直到找到空單元時把元素存入。但這樣的努力並不能解決所有衝突,有時可能永遠也找不到空位,這樣的探測可能是失效的,對於這個問題,1.3給出解決辦法。

1.3 再雜湊

考慮一種情況,當表的大小固定而儲存的元素數不斷增多時,對於分散連結法,連結串列長度將不斷增加,導致查詢和刪除時執行時間的增加(因為要遍歷連結串列);而對於開放定址法,元素不斷增多時,可能導致元素找不到空位,從而引發死迴圈,造成致命錯誤。再雜湊指當元素數目過多時,增大散列表TableSize的大小。 因此,分離連結法通過在雜湊降低刪除和查詢操作的複雜度;開放定址法通過再雜湊降低運算複雜度,並解決元素找不到空位這種致命的錯誤,這回應了1.2節中遺留的問題。 那麼元素過多怎麼定義,具體什麼時候需要進行再雜湊呢? 定義散列表的裝填因子R為散列表中元素個數與該表大小的比,對於分離連線法,一般使得R = 1,如果 R > 1 則進行再雜湊;對於開放地址法,R > 0.5 的時候進行再雜湊相關定理表明,如果使用平方探測,且表大小是質數時,那麼當表至少有一半是空的時候,總能插入一個新的元素。這解決了開放地址法中平方探測法的致命錯誤。為了減少衝突以及解決致命錯誤,我們要把散列表的大小設定為質數

2. 雜湊的實現(Java程式碼)

本章使用java語言實現分離連結法以及開放定址中的平方探測法,線性探測法以及雙雜湊僅做簡單介紹。

2.1 分離連結法散列表的Java實現

import java.util.LinkedList;
import java.util.List;

/**
 * 分離連結法實現散列表
 * 表中維護著一個數組array、元素個數currentSize
 * 定義了散列表預設大小
 */
public class MySeparateChainingHashTable<T> {
    /**
     * 預設構造方法
     */
    public MySeparateChainingHashTable(){this(DEFAULT_TABLE_SIZE); }

    /**
     * 建立一個具有指定大小的散列表
     * @param tableSize 指定散列表的大小
     */
    public MySeparateChainingHashTable(int tableSize){
        array =  new LinkedList[nextPrime(tableSize)];
        for (int i = 0; i < array.length; i++) {
            array[i] = new LinkedList<T>();
        }
        currentSize = 0;
    }

    /**
     * 將表置空
     */
    public void makeEmpty(){
        for(List list : array){
            list.clear();
        }
        currentSize = 0;
    }

    /**
     * 儲存一個元素,若元素存在,則無需儲存
     * 若元素不存再則儲存該元素
     * 若裝填因子>1則進行再雜湊
     * @param t 要儲存的元素
     */
    public void insert(T t){
        List<T> list = array[myhash(t)];  //找到對應的連結串列

        if(!list.contains(t)){
            list.add(t);
            //若裝填因子大於1,則進行再雜湊操作
            if(++currentSize > array.length)
                rehash();
        }
    }

    /**
     * 判斷散列表中是否存在元素t
     * @param t 要查詢的元素
     * @return  是否存在該元素
     */
    public boolean contains(T t){
        List<T> list = array[myhash(t)];
        return list.contains(t);
    }

    /**
     * 從散列表中刪除元素t
     * @param t 想要刪除的元素
     */
    public void remove(T t){
        if(contains(t)){
            List<T> list = array[myhash(t)];
            list.remove(t);
            currentSize--;
        }
    }

    /**
     * 判斷散列表是否為空
     * @return
     */
    public boolean isEmpty(){
        return  size() == 0;
    }

    /**
     * 獲取散列表大小
     * @return
     */
    public int size(){
        return  currentSize;
    }

    /*
    再雜湊,防止裝填因子過大而增加運算複雜度
     */
    private void rehash() {
        List<T>[] oldArray = array;

        array = new LinkedList[nextPrime(oldArray.length * 2)];
        for (int i = 0; i < array.length; i++) {
            array[i] = new LinkedList<T>();
        }

        currentSize =0;
        for (List<T> list : oldArray){
            for(T e : list){
                insert(e);
            }
        }
    }

    /*
    雜湊函式,獲取元素t對應散列表的陣列角標
     */
    private int myhash(T t) {
        int hashVal = t.hashCode();

        hashVal %= array.length;
        if (hashVal < 0)
            hashVal += array.length;

        return  hashVal;
    }

    /*
    獲取不小於tableSize的最小質數
     */
    private int nextPrime(int tableSize) {
        if(isPrime(tableSize)) return  tableSize;
        while(isPrime(tableSize))
            tableSize++;
        return tableSize;
    }

    /*
    判斷一個數是否為質數
     */
    private boolean isPrime(int num) {
        if(num < 2) return false;
        for (int i = 2; i < num; i++) {
            if (num % i == 0)   return false;
        }
        return true;
    }

    //常量
    private static final int DEFAULT_TABLE_SIZE = 11;

    //維護的欄位
    private List<T>[] array;
    private int currentSize;
}

下面對這個散列表進行一個簡單的載入測試:

public static void  main(String[] args){
        MySeparateChainingHashTable t = new MySeparateChainingHashTable<String>();
        t.insert("aaa");
        t.insert("bbb");
        t.insert("ccc");
        System.out.println(t.contains("aaa"));  		//true
        System.out.println(t.contains("ddd"));  		//false
        t.remove("aaa");
        System.out.println(t.contains("aaa"));  		//false
        t.insert("ddd");
        System.out.println(t.contains("ddd"));  		//true
        System.out.println(t.size());             	//3
        System.out.println(t.isEmpty());          	//false
        t.makeEmpty();
        System.out.println(t.isEmpty());          	//true
        System.out.println(t.contains("ddd"));  		//false

        //載入測試
        for(int i =0; i < 1000; i++){
            t.insert("元素"+i);
        }
        System.out.println(t.contains("元素999"));	//true
        System.out.println(t.size());               //1000
        t.makeEmpty();
        System.out.println(t.contains("元素999"));	//false
    }

測試成功,證明散列表是可用的。

2.2 線性探測法簡介

線性探測法是開放地址法的一種,只是規定其解決衝突的函式 f(i) = i。線性探測法容易造成表中元素的聚集,導致元素無法均勻的存入表中,增加了運算的複雜度(需要很多次探測才能解決衝突)。因此不推薦這種方法。

2.3 平方探測法散列表的Java實現

平方探測法解決了線性探測法的聚焦問題。平方探測法的f(i) = i2。值得強調的是,開放地址法的刪除操作是懶惰刪除。如果我們真的刪除了某個元素,那麼對跳過這個元素的其他元素執行contians()方法時將捕獲到null,contains方法失效。我們寫一個內部類HashEntry來儲存元素資訊。

/**
 * 開放地址法——平方探測實現散列表
 * 維護一個HashEntry<T>[]陣列
 * 維護currentSize:當前元素個數
 * 定義了散列表的預設大小DEFAULT_TABLE_SIZE
 * @param <T>
 */
public class MyQuadraticProbingHashTable<T> {

    /**
     * 預設構造初始化,建立預設大小(11)的散列表
     */
    public MyQuadraticProbingHashTable(){
        this(DEFAULT_TABLE_SIZE);
    }

    /**
     * 建立指定大小的散列表
     * @param tableSize 接收一個散列表的大小
     */
    public MyQuadraticProbingHashTable(int tableSize){
        allocate(tableSize);
    }

    /**
     * 把元素插入到散列表中,若元素已存在,則什麼也不做
     * 若裝填因子過大則執行再雜湊
     * @param t 接收一個元素
     */
    public void insert(T t){
        int currentPost = findPos(t);
        if (!isActive(currentPost)){
            array[currentPost] = new HashEntry<T>(t,true);
            currentSize++;
            if (currentSize > array.length / 2)
                rehash();
        }
    }


    /**
     * 判斷散列表中是否包含元素t
     * @return
     */
    public boolean contains(T t){
        return isActive(findPos(t));
    }

    /**
     * 將元素t從散列表中刪除
     * @param t
     */
    public void remove(T t){
        if (contains(t)){
            array[findPos(t)].isActive = false;
            currentSize--;
        }
    }

    /**
     * 獲得散列表中元素個數
     * @return
     */
    public int size(){
        return currentSize;
    }

    /**
     * 判斷散列表是否為空
     * @return
     */
    public boolean isEmpty(){
        return size() ==0;
    }

    /**
     * 將散列表置空
     */
    public void makeEmpty(){
        allocate(array.length);
        currentSize = 0;
    }

    /*
    當裝填因子 > 0.5時進行再雜湊,避免運算複雜度過大,避免出現致命的錯誤。
     */
    private void rehash() {
        HashEntry<T>[] oldArray = array;
        allocate(nextPrime(2 * oldArray.length));
        currentSize = 0;

        for(HashEntry<T> entry : oldArray){
            if (entry != null && entry.isActive)
            insert(entry.eletment);
        }
    }

    /*
    判斷指定位置是否存在active的元素
     */
    private boolean isActive(int currentPost){
        return (array[currentPost] != null && array[currentPost].isActive);
    }

    /*
    返回元素t所在的位置,若散列表中沒有元素t,則返回t可以新增的位置
    這個函式其實就是再平方法的雜湊函式
    */
    private int findPos(T t) {
        int currentPos = myhash(t);
        int offset = 1;

        while (array[currentPos] != null &&
                !array[currentPos].eletment.equals(t)){
            currentPos += offset;
            offset +=2;
            if(currentPos >= array.length)
                currentPos -= array.length;
        }

        return  currentPos;
    }

    /*
    雜湊函式,獲取元素t對應散列表的陣列角標
     */
    private int myhash(T t) {
        int hashVal = t.hashCode();

        hashVal %= array.length;
        if (hashVal < 0)
            hashVal += array.length;

        return  hashVal;
    }

    /*
    為陣列HashEntry[]初始化
     */
    private void allocate(int size) {
        array = new HashEntry[nextPrime(size)];
    }

    /*
     獲取不小於tableSize的最小質數
     */
    private int nextPrime(int tableSize) {
        if(isPrime(tableSize)) return  tableSize;
        while(isPrime(tableSize))
            tableSize++;
        return tableSize;
    }

    /*
    判斷一個數是否為質數
     */
    private boolean isPrime(int num) {
        if(num < 2) return false;
        for (int i = 2; i < num; i++) {
            if (num % i == 0)   return false;
        }
        return true;
    }

    /*
    私有類,儲存元素以及元素的狀態,便於惰性刪除
     */
    private static class HashEntry<T>{
        public T eletment;
        public boolean isActive;

        public HashEntry(T t){
            this(t, true);
        }

        public HashEntry(T t, boolean isActive){
            eletment = t;
            this.isActive = isActive;
        }
    }

    private static  final  int DEFAULT_TABLE_SIZE = 11;

    //維護的私有變數
    private HashEntry<T>[] array;
    private int currentSize;
}

下面對這個散列表進行一個簡單的載入測試:

public static void  main(String[] args){
        MyQuadraticProbingHashTable<String> t = new MyQuadraticProbingHashTable<String>();
        t.insert("aaa");
        t.insert("bbb");
        t.insert("ccc");
        System.out.println(t.contains("aaa"));  		//true
        System.out.println(t.contains("ddd"));  		//false
        t.remove("aaa");
        System.out.println(t.contains("aaa"));  		//false
        t.insert("ddd");
        System.out.println(t.contains("ddd"));  		//true
        System.out.println(t.size());             	/