死磕算法第一彈——數組、集合與散列表

本文整理來源 《輕鬆學算法——互聯網算法面試寶典》/趙燁 編著java

數組

自我解讀

數組是一堆數據按照順序放入的固定長度空間。面試

  1. 數組的長度固定,因此在聲明時須要指定數組長度。若是長度不夠用,也沒有什麼辦法,想要繼續存放數據,只能從新聲明一個數組空間。
  2. 數據只可以按順序訪問,雖然開發時能夠經過下標直接訪問指定位置元素,可是實際上計算機在處理時也是按照順序訪問的

使用場景

固定的長度的情景下,例如遊戲中的快捷鍵等。算法

升級版數組——集合

數組的致命缺點是固定長度,若是沒法對長度進行預估。這時候就須要採用集合,其實集合也是基於數組實現的。數據庫

集合的概念比較寬泛,如下主要指可變長度列表(也叫動態數組)編程

  • 列表:通常是有序的集合,特色就是有順序,好比鏈表、隊列、棧等。
  • 集:通常是無序的集合,特色就是沒有順序切數據不能重複,多數語言是使用散列表實現,支持對集進行添加、刪除、查找包含等操做。
  • 多重集:通常是無需的集合,特色是沒有順序,可是數據能夠有重複值,只是對集進行添加、刪除、查找包含、查找一個操做在集合的個數等操做。多重通常能夠經過排序轉換成列表。
  • 關聯數組:其實多數語言是使用散列表實現的,就是經過鍵(key)獲取到值(value)。一樣是沒有順序的。
  • 樹、圖:一樣是集合。

集合的實現

以java中的ArrayList爲例,它是一個數組的列表,其實就是數組的擴展,或者說是可變長度的數組。數組

具體的實現方式是內部存在一個數組,同時存在一個標記,標記標識着數組裏放了多少數據。這樣咱們往裏面放數據時,就會知道數據應該放在內部數組的哪一個位置,可是這個數組也會存在長度限制,若是超過這個長度限制,內部會從新建立一個數組,將舊的數組複製到新的數組當中,這樣就能夠繼續添加數據了。緩存

在外部,咱們就感受不到ArrayList是有長度限制的,內部已經處理好了。網絡

public class ArrayList<T> {

    private static final int INITIAL_SIZE = 10;

    private int size;

    private Object[] array;

    public ArrayList() {
        array = new Object[INITIAL_SIZE];
    }

    public ArrayList(int initial) {
        if (initial <= 0) {
            initial = INITIAL_SIZE;
        }
        array = new Object[initial];
    }

    /**
     * 添加元素
     *
     * @param data 新元素
     */
    public void add(T data) {
        if (size == array.length) {
            array = Arrays.copyOf(array, size * 2);
        }
        array[size++] = data;
    }

    /**
     * 獲取指定位置的元素值
     *
     * @param i 指定位置
     * @return 元素值
     */
    @SuppressWarnings(value = "unchecked")
    public T get(int i) {
        if (i > size) {
            throw new IndexOutOfBoundsException("獲取元素的位置超過了最大長度");
        }
        return (T) array[i];
    }

    /**
     * 修改指定位置的元素值
     *
     * @param i    指定位置
     * @param data 新的值
     * @return 舊的值
     */
    public T set(int i, T data) {
        T oldData = get(i);
        array[i] = data;
        return oldData;
    }

    /**
     * 獲取變長數組的長度
     *
     * @return 數組長度
     */
    public int size() {
        return size;
    }
}

集合的特色

集合的特色和實現有關,那就是變長。邊長是相對而言的,內部仍是根據數組實現的,只是在不夠長時根據必定的策略生成一個更長的數組,把舊的數組中的數組複製到新的數組中使用。數據結構

正常狀況下會有兩個系統開銷:一個是數組老是比咱們實際使用的長度長,因此存在空間浪費;拎一個是當數組長度不夠長時,須要新建一個更長的數組,同時把舊數組的數據複製到新數組中,這個操縱比較消耗系統性能。編程語言

集合的適用場景

集合的使用場景不少。如今基本上全部的批量查詢及得到必定條件的數據列表,都使用變長數組去存儲返回的結果。好比查詢某遊戲中的一個瓦加包囊裏的全部物品,若不清楚物品的數量,則會用變長數組去存儲返回的結果。

博客的文章列表、評論列表等,只要涉及列表,就會有集合的身影。

變長數組的查詢效率很低,全部須要使用一些複雜的數據結構,幫助咱們完成更高效的算法實現。

數組與變長數組的性能

雖然集合這個變長數組比普通數組高級一些,可是本質仍是基於數組實現的,因此與數組的性能差很少。

對數組的操做,計算機須要根據咱們提供具體操做的位置,從頭至尾一個一個地尋找到指定位置,因此在數組中增長元素、修改元素、獲取元素等操縱的時間複雜度都爲O(n)。

變長數組也有性能損耗的問題,在插入元素時發現其中的固定長度不夠,則須要新建更長的數組,還要複製元素,都會形成性能的損耗。

散列表

集合實際上是不少種,散列表也算集合中的一種。實際上順序的存儲是按照一個一個地按順序訪問元素,當這個總量很大且咱們所要訪問的元素比較靠後,性能就會很低。

散列表是一種空間換時間的存儲結構,是在算法中提升效率的一種比較經常使用的方式,可是所須要空間太大也會讓人頭疼,因此一般是二者之間權衡。

什麼是散列表

試想一下,咱們要在手機通信錄中查詢一我的,咱們不多會從第1我的開始往下一直找,由於這樣太慢了。咱們常常作的事情是根據這我的的名字首字母去查詢。好比姓張,那麼咱們必定會滑到最後,由於「Z」姓的名字都在最後。

還有在查字典的時候,要查找一個單詞,確定不會從第一個開始查,而是從這個單詞的首字母,找到以後再找第2個字母、第三個字母以此類推,這樣就能夠快速跳到哪一個單詞所在頁。

其實這就用到散列表的思想。

散列表,幼教哈希表(Hash Table),可以經過給定的關鍵字的值去直接訪問到具體的對應值的一個數據結構。也就是說,把關鍵字映射到一個表中的位置來直接訪問記錄,以加快訪問速度。

一般咱們把關鍵字稱爲Key,把對應的記錄稱爲Value,因此也能夠說是經過Key訪問一個映射表來獲得Value的地址。而這個映射表,也叫做散列函數或哈希函數,存放記錄的數組叫作散列表。

其中有個特殊狀況,就是經過不一樣的Key,可能訪問到同一個地址,這種現象叫作碰撞(Collision)。而經過某個Key必定會獲得惟一的Value地址。

目前,這個哈希函數比較經常使用的實現方法比較多,一般須要考慮幾個因素:關鍵字的長度、哈希表的大小、關鍵字的分佈狀況、記錄的查找頻率,等等。

下面簡單介紹幾種哈希函數:

  1. 直接尋址法:取關鍵字或關鍵字的某個線性函數值爲散列地址。
  2. 數字分析法:經過對數據的分析,發現數據中衝突較少的部分,並構造散列地址。例如學號,一般同一屆學生的學號,其中前面的部分差異不太大,因此用後面部分來構造散列地址。
  3. 平方取中法:當沒法肯定關鍵字裏哪幾位的比較平均時,能夠先求出關鍵字的平方值,而後按須要取平方值的中間極爲做爲散列地址。這是由於:計算平方以後的中間極爲和關鍵字中的每一位都相關,因此不一樣的關鍵字會以較高的機率產生不一樣的散列地址。
  4. 取隨機數法:使用一個隨機函數,取關鍵字的隨機值做爲散列地址,這種方式一般用於關鍵字的長度不一樣的場合。
  5. 除留取餘法:取關鍵字被某個不大於散列表表長n的數m除後所得的玉樹p爲散列地址。這種方式也能夠在用過其餘方法以後在使用。該函數對m的選擇很重要,通常取素數或者直接用n。

對散列表函數產生衝突的解決辦法

散列表爲何會衝突?是由於不一樣的key經過哈希函數可能會產生相同的地址。

開放地址法(開放尋址法)

實際上就是當須要存儲時,對Key進行哈希以後,發現這個地址已經有值了,這時改怎麼辦?不能放在這個地址,否則以前的映射就會被覆蓋。這是計算出來的地址進行探測再哈希,好比日後移動一位,若是沒有人佔用,就用這個地址。若是超過長度,則對總長度取餘。這裏移動的地址就是產生衝突時的增列序量

再哈希

在產生衝突以後,使用關鍵字的其餘部分繼續計算地址,若是仍是有衝突,則繼續使用其餘部分再計算地址。這種方式的缺點是時間增長了。

鏈地址法

鏈地址法其實就是對Key經過哈希以後落到同一個地址上的值,作一個鏈表。其實在不少高級語言的實現中,也是使用這種方式處理衝突的。

創建一個公共溢出區

這種方式是創建一個公共溢出區,當地址存在衝突時,把新的地址房子公共溢出區裏。

散列表的存儲結構

一個好的散列表設計,除了須要選擇一個性能較好的哈希函數,不然衝突時沒法避免的,因此一般還須要一個好的衝突解決方式。

舉例,採用除留取餘法做爲哈希函數、鏈地址法做爲解決衝突的方式。

這裏以Key的類型爲整型數字爲例,用Key對長度8取餘,一定會等到很大的衝突,這時麼個地址並不放真正的值,而是記錄一個鏈表的起始地址。固然,在實際狀況下咱們不會讓這個哈希表這麼短,只是簡單舉例子。

經過這種方式,咱們能夠快速地知道Key所對應的Value在哪一個地址上,若是這個地址的鏈表比較長,則也須要一個一個地去檢索。

一般狀況下,新增元素若是遇到衝突,那麼鏈表會有兩種的方式去插入元素。

  • 一種方式是直接把新元素的下一個元素指向原來鏈表的第1個元素,而後把剛剛對應上的那個地址鏈表頭的下一個元素指向新建的元素。這種方式的好處是在插入元素時會比較快,由於不須要遍歷鏈表,而是直接改變頭部的指向關係。
  • 另外一種方式是使鏈表元素有序,這種方式的劣勢就是在每次插入元素時須要遍歷鏈表,在中間打開鏈表插入元素。

這裏以整數爲例,對於其餘類型不少編程語言都具備必定的實現方式。好比Java中的每一個對象都有個hashCode方法,經過獲取字符串的這個方法,就能夠將一個字符串輕鬆的轉換成整型。固然這種方法還可能返回負數,這也是能夠直接使用絕對值解決的。

散列表的特色

散列表有兩種用法:一種是Key的值與Value的值同樣,通常咱們稱這種狀況的結構爲Set(集合);而若是Key和Value所對應的內容不同時,那麼咱們稱這種狀況爲Map,也就是人們俗稱的鍵值對集合。

根據散列表的存儲結構,咱們能夠得出散列表的如下特色。

  1. 訪問速度很快:因爲散列表有散列函數,能夠將制定的Key都用射到一個地址上,因此在訪問一個Key(鍵)對應的Value(值)時,根本不須要一個一個地進行查找,能夠直接跳到那個地址。因此咱們在對散列表進行添加、刪除、修改、查找等操做時,速度都很快。
  2. 須要額外的空間:首先,散列表其實是存不滿的,若是一個散列表恰好可以存滿,那麼確定是一個巧合。並且當散列表中的元素的使用率愈來愈高時性能就會降低,因此通常會選擇擴容來解決這個問題。另外,若是有衝突的話,則也須要額外的空間去存儲的,好比鏈表地址法,不但須要額外的空間,甚至還須要使用其餘的數據結構。這個特色很經常使用的表達,叫作「空間換時間」,在大多數的時候,對於算法的實現,爲了可以更好的性能,每每會考慮犧牲些空間,讓算法可以更快些。
  3. 無序:散列表有個很是明顯的特色就是無序。爲了更快的訪問元素,散列表是根據散列函數直接找到存儲地址的,這樣咱們的訪問速度就可以更快,可是對於有序訪問就沒有辦法應對了。
  4. 可能會產生碰撞:沒有完美的散列函數,不管如何總會產生衝突,這是就須要採用衝突解決方案,這也使散列表更加複雜。一般在不一樣的高級語言的實現,對於衝突的解決方案不必定同樣。

散列表的適用場景

根據散列表的特色能夠想到,散列表比較適合無序、須要快速訪問的狀況

緩存

一般咱們在開發程序的時候,對於一些經常使用的信息會作存儲,用的就是散列表。好比咱們要緩存用戶信息,通常用戶信息都會有惟一表示的字段,好比ID。這時作緩存,能夠把ID做爲Key,而Value用來存儲用戶的詳細信息,這裏的Value一般是一個對象,包含用戶的一些關鍵字段,好比名字、年齡等。

在咱們每次須要獲取一個用戶信息是,就不用與數據庫這類的本地磁盤存儲交互了(大多數時候,數據庫可能與咱們的服務不在一臺機器上,還會有相應的網絡性能損耗),能夠直接從內存中獲得結果。這樣不只能夠快速獲取數據,也可以減輕數據庫的壓力。

快速查找

這裏說的查找,不是排序,而是在集合中找到是否存在指定元素。

這種場景不少,好比咱們要在指定的用戶列表中查找是否存在指定的用戶,這時就可使用散列表了。在這種場景下使用的散列表其實就是上面提到的Set類型,實際上不須要Value這個值。

還有一個場景,咱們通常對網站的操做會有一個ip地址黑名單,咱們認爲某些ip有大量非法操做,因而封鎖了這個ip對咱們網站的訪問。這個ip是如何存儲的呢?就是使用散列表。當一個訪問行爲發送過來時,咱們會獲取其ip,判斷是否存在於黑名單中,若是存在,則禁止訪問。這種狀況也是使用Set。

以上兩種狀況使用列表也能夠解決,當列表愈來愈長時,查找速度很慢。散列表則不會。

散列表性能分析

散列表的訪問,若是沒有碰撞,那麼咱們徹底能夠認爲對元素的訪問是O(1)的時間複雜度,由於對於任何元素的訪問,均可以經過散列函數獲得元素的值所在地址。

但其實是不可能沒有碰撞,因此咱們不得不對碰撞進行必定的處理。

咱們經常使用鏈表的方式進行解決(固然,也有一些語言使用開放尋址方式解決,Java使用鏈表解決),因爲可能會產生碰撞,而碰撞以後訪問須要遍歷列表,因此時間複雜度將變成O(L),其中L爲鏈表的長度。固然,在大多數時候不必定會碰撞,而不少Key也不必定恰好碰撞到一個地址上,因此性能仍是很不錯的。

若是可能分配的地址即散列表的元素大部分被使用了,這時再向散列表中添加元素,就會很容易產生碰撞了,甚至散列表分配的地址越日後使用,越容易被佔用。這種狀況下就可使用擴容

在使用散列表的時候,通常不會等到真的佔滿了纔會去擴容,而是會提早擴容。這裏設計一個擴容因子的術語(也叫作載荷因子),是一個小數,在使用散列表的過程當中,不會等到把全部地址都用完纔去擴容,而是會在佔用到地址達到散列表長度乘以擴容因子的這個值時去擴容,通常的擴容是在原有的基礎上乘以2做爲新的長度。

Java中擴容因子默認爲0.75。

擴容的時候,除了簡單的增長原來散列表的長度,還須要把以前那些因爲碰撞而存放在一個地址的鏈表上的元素從新進行哈希運算,有可能以前存在碰撞的元素,如今就不會碰撞了。

public class HashTable<K, V> {

    /**
     * 默認散列表的初始長度
     * 設置小一點,這樣咱們可以清楚看到擴容。
     * 實際使用過程當中是能夠初始化傳參的,擴容是很是損耗性能的
     */
    private static final int DEFAULT_INITIAL_CAPACITY = 4;

    /**
     * 擴容因子
     */
    private static final float LOAD_FACTOR = 0.75f;

    /**
     * 散列表數組
     */
    private Entry[] table = new Entry[DEFAULT_INITIAL_CAPACITY];

    /**
     * 散列表元素的個數
     */
    private int size = 0;

    /**
     * 散列表使用地址的個數
     */
    private int use = 0;


    /**
     * 添加元素
     *
     * @param key   鍵
     * @param value 值
     */
    public void put(K key, V value) {
        int index = hash(key);

        if (table[index] == null) {
            table[index] = new Entry<K, V>(null, null, null);
        }
        Entry e = table[index];
        if (e.next == null) {
            //不存在值,向鏈表添加,可能擴容,要使用table屬性
            table[index].next = new Entry<>(key, value, null);
            size++;
            use++;
            //不存在值,說明是個未用過的地址,須要判斷是否須要擴容
            if (use >= table.length * LOAD_FACTOR) {
                resize();
            }
        } else {
            //自己存在值,修改已有的值
            for (e = e.next; e != null; e = e.next) {
                Object k = e.key;
                if (Objects.equals(k, key)) {
                    e.value = value;
                    return;
                }
            }
            //不存在相同的值,直接向鏈表中添加元素
            Entry temp = table[index].next;
            //往鏈表開頭添加元素
            table[index].next = new Entry<>(key, value, temp);
            size++;
        }
    }

    /**
     * 刪除
     *
     * @param key 鍵
     */
    private void remove(K key) {
        int index = hash(key);
        Entry e = table[index];
        Entry pre = table[index];

        if (e != null && e.next != null) {
            for (e = e.next; e != null; pre = e, e = e.next) {
                Object k = e.key;
                if (Objects.equals(k, key)) {
                    pre.next = e.next;
                    size--;
                    return;
                }
            }
        }
    }

    /**
     * 獲取
     *
     * @param key 鍵
     * @return 值
     */
    @SuppressWarnings("unchecked")
    public V get(K key) {
        int index = hash(key);
        Entry e = table[index];
        if (e != null && e.next != null) {
            for (e = e.next; e != null; e = e.next) {
                Object k = e.key;
                if (Objects.equals(k, key)) {
                    return (V) e.value;
                }
            }
        }
        //若是沒有找到,則返回null;
        return null;
    }

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

    /**
     * 非散列表該有的方法,只是爲了讓咱們知道確實擴容了
     *
     * @return 散列表長度
     */
    public int getLength() {
        return table.length;
    }

    /**
     * 擴容
     */
    @SuppressWarnings("unchecked")
    private void resize() {
        int newLength = table.length * 2;
        Entry[] oldTable = table;
        table = new Entry[newLength];
        use = 0;
        for (Entry anOldTable : oldTable) {
            if (anOldTable != null && anOldTable.next != null) {
                Entry e = anOldTable;
                while (null != e.next) {
                    Entry next = e.next;
                    //從新計算哈希值,放入新的地址中
                    int index = hash((K) next.key);
                    if (table[index] == null) {
                        use++;
                        table[index] = new Entry<K, V>(null, null, null);
                    }
                    Entry temp = table[index].next;
                    table[index].next = new Entry<>((K) next.key, (V) next.value, temp);
                    e = next;
                }
            }
        }
    }

    /**
     * 根據Key的hashCode,經過哈希函數獲取位域散列表數組中的哪一個位置
     *
     * @param key 鍵
     * @return 散列值
     */
    private int hash(K key) {
        return key.hashCode() % table.length;
    }

    public class Entry<Key, Value> {
        Key key;
        Value value;
        Entry next;

        Entry(Key key, Value value, Entry next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }
}

測試:

public class HashTableTest {

    @Test
    public void main() {
        HashTable<Integer,Integer> hashTable = new HashTable<>();
        hashTable.put(1,10);
        hashTable.put(2,20);
        //和key爲1的元素落到了同一個散列表地址上了,實際是一個長度爲2
        hashTable.put(5,50);
        //散列表長度爲4
        Assert.assertEquals(4,hashTable.getLength());
        //總長度爲4,添加上鈣元素就大於等於3了,須要進行擴容
        hashTable.put(3,30);
        //散列長度爲8
        Assert.assertEquals(8,hashTable.getLength());
        //在擴容後4個元素分別落在不一樣的地址上
        //使用了第5個地址
        hashTable.put(6,60);
        //使用了第6個地址,爲8的0.75倍,又須要擴容
        hashTable.put(7,70);
        Assert.assertEquals(16,hashTable.getLength());

        Assert.assertEquals(10, (int) hashTable.get(1));
        Assert.assertEquals(30,(int)hashTable.get(3));
        Assert.assertEquals(50,(int)hashTable.get(5));
        Assert.assertEquals(60,(int)hashTable.get(6));
    }

}
相關文章
相關標籤/搜索