線性數據結構總結

數組

數組是一種線性數據結構。建立數組時會在內存中劃分出一塊連續的內存區域,數據會保存在這塊連續區域的每塊索引。java

數組支持的操做

查詢(get(int index))

首先說明經過下標獲取元素的時間複雜度爲O(1)。由於數組是一塊連續的內存區域,而且每一個元素的大小都相等,經過一個線性方程就很快能找到改下標對應的內存地址。例如若是是一個int數組,0位置的內存地址爲b,index是你要找的下標,那麼顯然你要獲取的內存地址就是b+4*index,4就是一個int佔的字節數。因此計算機經過b+type_size*index來計算下標。固然,並非全部的數組都是如此計算下標的,例若有的虛擬機在開闢數組空間時並非開闢的連續空間。node

插入(add(int index,E e))

增長時若是要指定增長到的數組下標通常將要對數組元素進行移動,例若有一個十個元素的整型數組,要將一個數字A插入到第一個元素的位置,那麼就須要先將全部元素從後往前向後移動一個位置再將A插入到第一個位置。因此數組的插入操做平均時間複雜度爲O(n)算法

刪除(remove(int index))

和插入同樣,指定下標進行刪除。例若有十個元素,刪除第一個元素,那麼就須要將數組元素從當前元素由前至後向前移一個下標。因此數組的刪除操做平均時間複雜度爲O(n)數組

修改(set(int index,E e))

修改就比較簡單了,直接獲取下標改變當前元素的值便可瀏覽器

動態數組

不少高級語言都有數組這個基本結構,可是在使用他們的時候若是咱們增長的元素超過一開始定義它的總個數的話是沒辦法繼續添加的。因此,當咱們一開始不知道這個數組的大小時這就比較麻煩了,咱們就須要本身定義動態數組,咱們不須要管他的初始容量。bash

實現本身的數組

實現動態數組主要須要重寫數組的增長、刪除操做,還要實現擴容操做數據結構

增長

本身實現的增長操做和原來的區別就是要判斷是否須要將數組擴容。擴容的條件爲當數組的長度和元素的個數相同時就須要擴容,通常擴容爲兩倍。 擴容的步驟:app

  • 將原數組複製一份並讓大小爲原數組的2倍
  • 將原數組的全部元素寫入到新數組中,並增長新添加的元素
刪除

本身實現的刪除操做和原來的區別就是要判斷是否須要將數組縮容,縮容是有必要的。縮容的條件比擴容的條件多一點,就是縮容前的數組大小要大於等於2,而且當前元素個數爲數組大小的一半。 縮容的步驟:函數

  • 先將原數組的元素刪除
  • 將原數組複製一份並讓大小爲原數組的1/2倍
  • 將原數組的全部元素寫入到新數組中

擴容,縮容產生的震盪

產生的震盪和咱們的擴容,縮容條件有關,若是按照上面的條件進行擴容和縮容,那麼若是這個數組的元素個數若是在8和9之間徘徊,那麼數組的大小就會在8,16中徘徊。測試

而震盪的解決方法和具體的需求有關,咱們能夠將縮容的條件改成"縮容前的數組大小要大於等於2,而且當前元素個數爲數組大小的1/4"

java.util.ArrayList中如何擴容

擴容確定在

//新增方法
    public boolean add(E e) {
        //modCount是記錄修改次數的(迭代器判斷結構是否變),迭代器fail-fast機制就靠它
        modCount++;
        //elementData是數組元素,size是數組大小
        add(e, elementData, size);
        return true;
    }
    
    private void add(E e, Object[] elementData, int s) {
        //數組大小和數組元素個數相等,要擴容了
        if (s == elementData.length)
            elementData = grow(s+1);
        elementData[s] = e;
        size = s + 1;
    }
    
    private Object[] grow(int minCapacity) {
        //能夠看到也是經過複製一個新數組
        return elementData = Arrays.copyOf(elementData,
                                           newCapacity(minCapacity));
    }
    
    //接下來就是如何真正實現擴容的!!!
    private int newCapacity(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        //新大小是老大小的1.5倍
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        //若是新大小比oldCapacity+1小
        if (newCapacity - minCapacity <= 0) {
            //若是數組是空的,第一次添加元素,就直接擴容到10和minCapacity中大的那個
            if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
                return Math.max(DEFAULT_CAPACITY, minCapacity);
            if (minCapacity < 0) // overflow
                throw new OutOfMemoryError();
            return minCapacity;
        }
        //返回大的那個,不超過最大
        return (newCapacity - MAX_ARRAY_SIZE <= 0)
            ? newCapacity
            : hugeCapacity(minCapacity);
    }
複製代碼

能夠看到擴容的思路仍是很簡單的,本人環境是jdk1.9,至於其餘版本的擴容思路我想都差很少。至於縮容,這裏就再也不闡述了,arraylist的源碼仍是比較容易理解的。

鏈表

鏈表和數組都是線性結構,可是鏈表是一個一個的節點組成的,這個節點有一個next指針指向下一個節點,也就是說鏈表不須要連續的數組空間。如圖:

image

鏈表的操做

下面來看一下如何對鏈表進行增刪改查

增長

增長的操做時間複雜度爲O(1),不用像數組同樣去移動數組元素,如A->C,增長B到A的後面 增長步驟:

  • 將A的next指針保存爲temp
  • 將A的next指針指向B
  • 將B的next指向temp

刪除

刪除的時間複雜度爲O(1),不用像數組同樣去移動數組元素,如A->B->C,刪除B 刪除步驟:

  • 獲取變量temp保存節點B
  • 將A的next指針指向C(free掉B)

查詢

查詢的時間複雜度爲O(n),由於鏈表不像數組能夠直接經過下標計算出內存地址。因此必須經過遍歷找到相應下標節點。

這裏的增長和刪除時間複雜度爲O(1)很好理解,咱們可能會得出一個結論那就是數組的查詢修改效率高,鏈表的刪除增長效率高。但實際上這也是分狀況的。當咱們在對一個節點進行插入或刪除時咱們要去遍歷到指定位置(由於咱們只有頭節點地址或者尾節點地址)。以前作過一個測試,對於java中的結構LinkedList(雙向鏈表)和ArrayList(雙向鏈表),在增長大概一百萬個元素的時候會發現數組的增長方法效率更高,由於在指定位置對鏈表添加元素鏈表要去遍歷。

虛擬頭節點:最後,通常鏈表都會有一個頭節點,這個節點指向鏈表的首節點,這個頭節點的用處是當咱們將刪除最後一個元素的時候不用專門判斷刪除只有一個元素的狀況

棧,隊列

棧和隊列很類似,因此結合起來一塊兒看。

  • 棧是一個FILO(先進後出)的結構,它能夠由數組或鏈表實現。常見的例如在瀏覽器瀏覽網頁,在當前網頁進入另外一個網頁至關於入棧,返回至關於出棧。
  • 隊列是一個FIFO(先進先出)的結構,它能夠由數組或鏈表實現。隊列的應用也很廣,例如排隊。

基於數組的棧實現

結構:數組array,top變量

數組用來保存進入的數據。top指針指向最後一個元素的下一個位置,若是棧爲空top指向0。

舉個例子:

image
如上圖

  • 棧空:當棧爲空的時候top指向0下標
  • 棧滿:當元素滿了後top==array.length;
  • 入棧:若是有元素入棧array[top++]=入棧元素
  • 出棧:當元素要出棧時return array[top--];

基於鏈表的棧實現

結構:top指針,節點

  • 節點中有data,next指向下一個節點
  • top指針用來指向棧最上面的節點

如圖:

image

  • 棧爲空:top指向null
  • 入棧:構造新節點node,新節點node的next指向top指針的指向,top指針指向新節點node
  • 出棧:用node保存top指向的節點,top指針指向node的next指針指向的節點,返回node

隊列

基於鏈表的隊列

結構:虛擬頭節點,節點

  • 虛擬頭節點:front指針指向第一個元素,rear指針指向最後一個元素
  • 節點:data數據元素,next下一個節點指向

如圖:

image

  • 空隊列:front和rear都指向null
  • 入隊:構造新節點,rear指針的next指針指向新節點
  • 出隊:判斷爲非空,保存front指向的節點node,front指向node的next節點,返回node

基於數組的隊列

基於數組的隊列和基於數組的棧有一些不一樣。能夠想到,當數組隊列入隊時能夠增長到數組後面,出隊時將前面的元素移出,那麼就會出現問題就是前面出隊的元素會變爲不可用但又無法用,有一種解決方式能夠在出隊的時候把後面的元素放在前面,就像前面動態數組那樣刪除首元素便可,可是若是是這樣每出隊一次就總體移動一次元素未免也太耗時了一些。因此這裏引入循環隊列。

理解循環隊列

循環隊列要解決的問題就是數組隊列浪費空間的問題。循環隊列並非在物理地址上是循環的,而是在邏輯上循環的。

結構:數組,front指向頭,rear指向隊尾的後一位元素

front=front%array.length

如圖:

image

  • 圖中有兩個指針front、rear,front表明隊頭下標,rear表明隊尾下標。
  • 隊列爲空:front和rear都指向一個位置
  • a,b,c入隊,隊頭下標front不變,rear(rear=(rear+1)%array.length)指向最後元素的下一個座標
  • a出隊,front指向下一個元素(front=(front+1)%array.length)
  • 最後當增長到如圖d2那樣(rear+1)%array.length=front時表明元素已滿

要注意的點

  1. 入隊時,rear=(rear+1)%array.length
  2. 出隊時,front=(front+1)%array.length
  3. 在循環隊列中至關於咱們浪費了一個元素位置,這樣作的好處是咱們能夠區別開空隊和滿隊的區別。
  4. 空隊列:front==rear
  5. 滿隊列:(rear+1)%array.length==front,這個時候就須要擴容了
  6. 擴容:擴容的步驟和動態數組的邏輯類似,只不過要從新給front和rear賦值

哈希表

當咱們想在學校想找到某我的的信息,咱們會向教務處去查詢學號,而教務處得到你提供的學號就會給你一個學生的信息。這裏經過學號得到學生信息就是用了哈希表的思想,而學號和學生信息的對應關係就是哈希函數,而若是兩個學號對應到了同一個學生信息就是哈希衝突。

因此接下來來看一下哈希表中的名詞:

  • 哈希表:經過給定的關鍵字的值直接訪問到具體對應的值的一個數據結構
  • 哈希函數:將給定關鍵字轉化爲內存地址索引
  • 哈希衝突:不一樣的關鍵字經過哈希函數轉化爲同一個索引

哈希表做用

哈希表的查找時間複雜度爲O(1),哈希表查找的思路是直接經過關鍵字查找到元素。增長刪除的時間複雜度也是O(1),也就是說哈希表

哈希表的結構

底層有一個Node數組array,當前元素個數M

  • 這個Node有key(鍵),value(值),next(下一個Node節點)。
  • M爲當前array中有的元素數量

哈希函數

由於咱們要實現對於不一樣的key儘量的經過哈希函數得出不一樣的值。因此對於哈希函數的選取是比較重要的。

如下爲常見哈希函數:

  • 直接尋址法:取關鍵字或關鍵字的某個線性函數值爲散列地址。如H(key)=key或H(key) = a?key + b。a,b爲常數。
  • 數字分析法:經過對數據的分析,發現數據中衝突較少的部分,並構造散列地址。例如身份證號,咱們能夠提取出身份證號中關鍵的數字,例如表示出生年月和後六位。
  • 平方取中法:取關鍵字平方後的中間幾位做爲散列地址。
  • 取隨機數法:使用一個隨機函數,取關鍵字的隨機值做爲散列地址,這種方式一般用於關鍵字長度不一樣的場合。
  • 除留取餘法:H(key) = key MOD p。key爲關鍵字,MOD爲取餘操做,p爲哈希表的長度,p最好爲質數,能夠達到最小可能的哈希衝突

接下來咱們將哈希函數都設置爲除留取餘法進行分析。

哈希衝突

哈希衝突就是不一樣的兩個key,他們hash(key)以後獲得的結果相同。對於哈希衝突的解決也有多種

  • 鏈地址法:若A通過hash以後肯定在數組的下標爲2,B通過hash以後肯定數組下標也爲2,B就跟在A以後造成鏈表。這時整個哈希表就造成了數組+鏈表的結構。
  • 開放地址法:
    • 線性探測:當發現哈希衝突時將下標分別進行+1直到下標位置沒有元素再放入
    • 平方探測:當發現哈希衝突時將下標分別進行1^2, 2^2, 3^2...直到下標位置沒有元素再放入
    • 僞隨機序列法:當發現衝突就將key隨機加一個數再取模,直到下標位置沒有元素再放入
  • 再哈希法:這種方法是同時構造多個不一樣的哈希函數,若是第一個哈希衝突了就用第二個哈希函數,以此類推

接下來咱們將哈希衝突解決方式設置爲鏈地址法進行分析。

哈希表操做過程

增長操做

例如用戶要增長一個K,V鍵值對,先將K的哈希值計算出來,再將哈希值對數組長度取模得到下標,若是下標沒有元素,就將K,V封裝成Node放入這個下標,若是下標已經有元素,就K,V封裝成Node加入到這個下標元素的最後面。

查找操做

例如一個用戶要經過K查找這個節點,經過hash(K)再取模獲得這個數組的下標,若是這個下標沒有元素,表明查找失敗,若是這個下標有元素,那就從這個元素向後面的鏈表遍歷,有就返回。

刪除和修改邏輯相似

HashMap的實現方式

java中的HashMap是封裝的很是好的一個哈希表,經過分析它的實現也可讓咱們更完善本身的哈希表設計

咱們來看一下它有哪些關鍵字段

//負載因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    //哈希表中的數組
    transient Node<K,V>[] table;
    
    //數組中每一個元素保存的下面這個節點
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    
        //..一些節點其餘的方法
        
    }
複製代碼

接下來來看一個put操做(增長操做)的流程。

//增長一個key,value
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    
    //採用的hash算法
    static final int hash(Object key) {
        int h;
        //能夠看到這裏hash算法用的key的hashCode和h右移16位的異或運算
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    //最後調用的putVal操做
    /**
     * hash是傳來的哈希值
     * key是鍵,value是值
     * onlyIfAbsent爲false表明多個key會重寫
     * evict爲false表明表處於建立狀態
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //若是一開始hashmap沒有元素的話初始化hashmap大小
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //沒有哈希衝突的話直接構造新的鏈表節點添加進數組中,i爲計算出的下標
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //接下來是有哈希衝突的狀況
        else {
            Node<K,V> e; K k;
            //這裏計算出下標的節點的哈希值要等於以前傳過來計算好的哈希值。而且要引用同一個對象而且equals方法也要相等!!這裏表示判斷爲同一個對象的邏輯!!我曾經在這裏踩過坑。。其次,若是兩次的key都爲null的話
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //這裏不是同一個對象而且是TreeNode結構(當鏈表節點個數大於等於8的時候會轉化爲紅黑樹)
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //接下來正常添加鏈表節點
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //若是value變換以後這裏會返回老的value
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
複製代碼

關於HashMap中的put操做,有如下幾點注意的地方

  • 若是傳來的key爲null,putVal仍是按常規操做進行添加,同上面的邏輯同樣,而且得出來的哈希值爲0
  • 如何判斷兩個key是否相等,簡單來講就是兩個鍵hashCode和equals必須同時相同或者說保持一致。好久以前我踩過的一個坑就是兩個對象他們的hashCode返回的相同值,而equals卻返回false,致使添加的時候老是會添加兩個元素,事實上這也是個人設計出錯,一個類最好保證重寫的hashCode和quals方法可以一致

下一篇會總結樹結構數據類型,而且下一篇會把本身實現的數據類型分享出來

相關文章
相關標籤/搜索