萬字長文帶你漫遊資料結構世界

秦怀杂货店發表於2022-01-12

資料結構是什麼?

程式 = 資料結構 + 演算法

是的,上面這句話是非常經典的,程式由資料結構以及演算法組成,當然資料結構和演算法也是相輔相成的,不能完全獨立來看待,但是本文會相對重點聊聊那些常用的資料結構。

資料結構是什麼呢?

首先得知道資料是什麼?資料是對客觀事務的符號表示,在電腦科學中是指所有能輸入到計算機中並被計算機程式處理的符號總稱。那為何加上“結構”兩字?

資料元素是資料的基本單位,而任何問題中,資料元素都不是獨立存在的,它們之間總是存在著某種關係,這種資料元素之間的關係我們稱之為結構

因此,我們有了以下定義:

資料結構是計算機儲存、組織資料的方式。資料結構是指相互之間存在一種或多種特定關係的資料元素的集合。通常情況下,精心選擇的資料結構可以帶來更高的執行或者儲存效率。資料結構往往同高效的檢索演算法索引技術有關。

簡單講,資料結構就是組織,管理以及儲存資料的方式。雖然理論上所有的資料都可以混雜,或者糅合,或者飢不擇食,隨便儲存,但是計算機是追求高效的,如果我們能瞭解資料結構,找到較為適合當前問題場景的資料結構,將資料之間的關係表現在儲存上,計算的時候可以較為高效的利用適配的演算法,那麼程式的執行效率肯定也會有所提高。

常用的4種資料結構有:

  • 集合:只有同屬於一個集合的關係,沒有其他關係
  • 線性結構:結構中的資料元素之間存在一個對一個的關係
  • 樹形結構:結構中的資料元素之間存在一個對多個的關係
  • 圖狀結構或者網狀結構:圖狀結構或者網狀結構

何為邏輯結構和儲存結構?

資料元素之間的邏輯關係,稱之為邏輯結構,也就是我們定義了對操作物件的一種數學描述。但是我們還必須知道在計算機中如何表示它。資料結構在計算機中的表示(又稱為映像),稱之為資料的物理結構,又稱儲存結構

資料元素之前的關係在計算機中有兩種不同的表示方法:順序映像和非順序映像,並且由此得到兩種不同的儲存結構:順序儲存結構鏈式儲存結構,比如順序儲存結構,我們要表示複數z1 =3.0 - 2.3i ,可以直接藉助元素在儲存器中的相對位置來表示資料元素之間的邏輯關係:

而鏈式結構,則是以指標表示資料元素之間的邏輯關係,同樣是z1 =3.0 - 2.3i ,先找到下一個是 100,是一個地址,根據地址找到真實的資料-2.3i:

位(bit)

在計算機中表示資訊的最小的單位是二進位制數中的一位,叫做。也就是我們常見的類似01010101010這種資料,計算機的底層就是各種電晶體,電路板,所以不管是什麼資料,即使是圖片,聲音,在最底層也是01,如果有八條電路,那麼每條電路有自己的閉合狀態,有82相乘,28,也就是256種不同的訊號。

但是一般我們需要表示負數,也就是最高的一位表示符號位,0表示正數,1表示負數,也就是8位的最大值是01111111,也就是127

值得我們注意的是,計算機的世界裡,多了原碼,反碼,補碼的概念:

  • 原碼:用第一位表示符號,其餘位表示值
  • 反碼:正數的補碼反碼是其本身,負數的反碼是符號位保持不變,其餘位取反。
  • 補碼:正數的補碼是其本身,負數的補碼是在其反碼的基礎上 + 1

為什麼有了原碼還要反碼和補碼?

我們知道加減法是高頻的運算,人可以很直觀的看出加號減號,馬上就可以算出來,但是計算機如果區分不同的符號,那麼加減就會比較複雜,比如正數+正數,正數-正數,正數-負數,負數+負數...等等。於是,有人就想用同一個運算器(加號運算器),解決所有的加減法計算,可以減少很多複雜的電路,以及各種符號轉換的開銷,計算也更加高效。

我們可以看到,下面負數參加運算的結果也是符合補碼的規則的:

        00100011		35
 +      11011101	   -35
-------------------------
        00000000       0
        00100011		35
 + 	    11011011	   -37
-------------------------
        11111110       -2

當然,如果計算結果超出了位數所能表示的範圍,那就是溢位,就說明需要更多的位數才能正確表示。

一般能用位運算的,都儘量使用位運算,因為它比較高效, 常見的位運算:

  • ~:按位取反
  • &:按為與運算
  • |:按位或運算
  • ^:按位異或
  • <<: 帶符號左移,比如35(00100011),左移一位為 70(01000110),-35(11011101)左移一位為-70(10111010)
  • >>:帶符號右移,比如35(00100011),右移一位為 17(00010001),-35(11011101)左移一位為-18(11101110)
  • <<<:無符號左移,比如35(00100011),左移一位為70(01000110)
  • >>>:無符號右移,比如-35(11011101),右移一位為110(01101110)
  • x ^= y; y ^= x; x ^= y;:交換
  • s &= ~(1 << k):第k位置0

要說哪裡使用位運算比較經典,那麼要數布隆過濾器,需要了解詳情的可以參考:http://aphysia.cn/archives/cachebloomfilter

布隆過濾器是什麼呢?

布隆過濾器(Bloom Filter)是由布隆(Burton Howard Bloom)在1970年提出的,它實際上是由一個很長的二進位制向量和一系列隨機hash對映函式組成(說白了,就是用二進位制陣列儲存資料的特徵)。可以使用它來判斷一個元素是否存在於集合中,它的優點在於查詢效率高,空間小,缺點是存在一定的誤差,以及我們想要剔除元素的時候,可能會相互影響。

也就是當一個元素被加入集合的時候,通過多個hash函式,將元素對映到位陣列中的k個點,置為1

重點是多個hash函式,可以將資料hash到不同的位上,也只有這些位全部為1的時候,我們才能判斷該資料已經存在

假設有三個hash函式,那麼不同的元素,都會使用三個hash函式,hash到三個位置上。

假設後面又來了一個張三,那麼在hash的時候,同樣會hash到以下位置,所有位都是1,我們就可以說張三已經存在在裡面了。

那麼有沒有可能出現誤判的情況呢?這是有可能的,比如現在只有張三,李四,王五,蔡八,hash對映值如下:

後面來了陳六,但是不湊巧的是,它hash的三個函式hash出來的位,剛剛好就是被別的元素hash之後,改成1了,判斷它已經存在了,但是實際上,陳六之前是不存在的。

上面的情況,就是誤判,布隆過濾器都會不可避免的出現誤判。但是它有一個好處是,布隆過濾器,判斷存在的元素,可能不存在,但是判斷不存在的元素,一定不存在。,因為判斷不存在說明至少有一位hash出來是對不上的。

也是由於會出現多個元素可能hash到一起,但有一個資料被踢出了集合,我們想把它對映的位,置為0,相當於刪除該資料。這個時候,就會影響到其他的元素,可能會把別的元素對映的位,置為了0。這也就是為什麼布隆過濾器不能刪除的原因。

陣列

線性表示最常用而且最為簡單的一種資料結構,一個線性表示 n 個資料元素的有限序列,有以下特點:

  • 存在唯一的第一個的資料元素
  • 存在唯一被稱為最後一個的資料元素
  • 除了第一個以外,集合中每一個元素均有一個前驅
  • 除了最後一個元素之外,集合中的每一個資料元素都有一個後繼元素

線性表包括下面幾種:

  • 陣列:查詢 / 更新快,查詢/刪除慢
  • 連結串列
  • 佇列

陣列是線性表的一種,線性表的順序表示指的是用一組地址連續的儲存單元依次儲存線性表的資料元素

Java中表示為:

int[] nums = new int[100];
int[] nums = {1,2,3,4,5};

Object[] Objects = new Object[100];

C++ 中表示為:

int nums[100];

陣列是一種線性的結構,一般在底層是連續的空間,儲存相同型別的資料,由於連續緊湊結構以及天然索引支援,查詢資料效率高:

假設我們知道陣列a的第 1 個值是 地址是 296,裡面的資料型別佔 2 個 單位,那麼我們如果期望得到第 5 個: 296+(5-1)*2 = 304,O(1)的時間複雜度就可以獲取到。

更新的本質也是查詢,先查詢到該元素,就可以動手更新了:

但是如果期望插入資料的話,需要移動後面的資料,比如下面的陣列,插入元素6,最差的是移動所有的元素,時間複雜度為O(n)

image-20220104225524289

而刪除元素則需要把後面的資料移動到前面,最差的時間複雜度同樣為O(n):

Java程式碼實現陣列的增刪改查:

package datastruction;

import java.util.Arrays;

public class MyArray {
    private int[] data;

    private int elementCount;

    private int length;

    public MyArray(int max) {
        length = max;
        data = new int[max];
        elementCount = 0;
    }

    public void add(int value) {
        if (elementCount == length) {
            length = 2 * length;
            data = Arrays.copyOf(data, length);
        }
        data[elementCount] = value;
        elementCount++;
    }

    public int find(int searchKey) {
        int i;
        for (i = 0; i < elementCount; i++) {
            if (data[i] == searchKey)
                break;
        }
        if (i == elementCount) {
            return -1;
        }
        return i;
    }

    public boolean delete(int value) {
        int i = find(value);
        if (i == -1) {
            return false;
        }
        for (int j = i; j < elementCount - 1; j++) {
            data[j] = data[j + 1];
        }
        elementCount--;
        return true;
    }

    public boolean update(int oldValue, int newValue) {
        int i = find(oldValue);
        if (i == -1) {
            return false;
        }
        data[i] = newValue;
        return true;
    }
}

// 測試類
public class Test {
    public static void main(String[] args) {
        MyArray myArray = new MyArray(2);
        myArray.add(1);
        myArray.add(2);
        myArray.add(3);
        myArray.delete(2);
        System.out.println(myArray);
    }
}

連結串列

上面的例子中,我們可以看到陣列是需要連續的空間,這裡面如果空間大小隻有 2,放到第 3 個元素的時候,就不得不擴容,不僅如此,還得拷貝元素。一些刪除,插入操作會引起較多的資料移動的操作。

連結串列,也就是鏈式資料結構,由於它不要求邏輯上相鄰的資料元素在物理位置上也相鄰,所以它沒有順序儲存結構所具有的缺點,但是同時也失去了通過索引下標直接查詢元素的優點。

重點:連結串列在計算機的儲存中不是連續的,而是前一個節點儲存了後一個節點的指標(地址),通過地址找到後一個節點。

下面是單連結串列的結構:

一般我們會手動在單連結串列的前面設定一個前置結點,也可以稱為頭結點,但是這並非絕對:

一般連結串列結構分為以下幾種:

  • 單向連結串列:連結串列中的每一個結點,都有且只有一個指標指向下一個結點,並且最後一個節點指向空。
  • 雙向連結串列:每個節點都有兩個指標(為方便,我們稱之為前指標後指標),分別指向上一個節點和下一個節點,第一個節點的前指標指向NULL,最後一個節點的後指標指向NULL
  • 迴圈連結串列:每一個節點的指標指向下一個節點,並且最後一個節點的指標指向第一個節點(雖然是迴圈連結串列,但是必要的時候還需要標識頭結點或者尾節點,避免死迴圈)
  • 複雜連結串列:每一個連結串列有一個後指標,指向下一個節點,同時有一個隨機指標,指向任意一個結點。

連結串列操作的時間複雜度:

  • 查詢:O(n),需要遍歷連結串列
  • 插入:O(1),修改前後指標即可
  • 刪除:O(1),同樣是修改前後指標即可
  • 修改:不需要查詢則為O(1),需要查詢則為O(n)

連結串列的結構程式碼怎麼表示呢?

下面只表示單連結串列結構,C++表示:

// 結點
typedef struct LNode{
  // 資料
  ElemType data;
  // 下一個節點的指標
  struct LNode *next;
}*Link,*Position;

// 連結串列
typedef struct{
  // 頭結點,尾節點
  Link head,tail;
  // 長度
  int len;
}LinkList;

Java 程式碼表示:

    public class ListNode {
        int val;
        ListNode next = null;

        ListNode(int val) {
            this.val = val;
        }
    }

自己實現簡單連結串列,實現增刪改查功能:

class ListNode<T> {
    T val;
    ListNode next = null;

    ListNode(T val) {
        this.val = val;
    }
}

public class MyList<T> {
    private ListNode<T> head;
    private ListNode<T> tail;
    private int size;

    public MyList() {
        this.head = null;
        this.tail = null;
        this.size = 0;
    }

    public void add(T element) {
        add(size, element);
    }

    public void add(int index, T element) {
        if (index < 0 || index > size) {
            throw new IndexOutOfBoundsException("超出連結串列長度範圍");
        }
        ListNode current = new ListNode(element);
        if (index == 0) {
            if (head == null) {
                head = current;
                tail = current;
            } else {
                current.next = head;
                head = current;
            }
        } else if (index == size) {
            tail.next = current;
            tail = current;
        } else {
            ListNode preNode = get(index - 1);
            current.next = preNode.next;
            preNode.next = current;
        }
        size++;
    }

    public ListNode get(int index) {
        if (index < 0 || index >= size) {
            throw new IndexOutOfBoundsException("超出連結串列長度");
        }
        ListNode temp = head;
        for (int i = 0; i < index; i++) {
            temp = temp.next;
        }
        return temp;
    }

    public ListNode delete(int index) {
        if (index < 0 || index >= size) {
            throw new IndexOutOfBoundsException("超出連結串列節點範圍");
        }
        ListNode node = null;
        if (index == 0) {
            node = head;
            head = head.next;
        } else if (index == size - 1) {
            ListNode preNode = get(index - 1);
            node = tail;
            preNode.next = null;
            tail = preNode;
        } else {
            ListNode pre = get(index - 1);
            pre.next = pre.next.next;
            node = pre.next;
        }
        size--;
        return node;
    }

    public void update(int index, T element) {
        if (index < 0 || index >= size) {
            throw new IndexOutOfBoundsException("超出連結串列節點範圍");
        }
        ListNode node = get(index);
        node.val = element;
    }

    public void display() {
        ListNode temp = head;
        while (temp != null) {
            System.out.print(temp.val + " -> ");
            temp = temp.next;
        }
        System.out.println("");
    }
}

測試程式碼如下:

public class Test {
    public static void main(String[] args) {
        MyList myList = new MyList();
        myList.add(1);
        myList.add(2);
        // 1->2
        myList.display();

        // 1
        System.out.println(myList.get(0).val);

        myList.update(1,3);
        // 1->3
        myList.display();

        myList.add(4);
        // 1->3->4
        myList.display();

        myList.delete(1);
        // 1->4
        myList.display();
    }
}

輸出結果:

1 -> 2 -> 
1
1 -> 3 -> 
1 -> 3 -> 4 -> 
1 -> 4 ->

單向連結串列的查詢更新比較簡單,我們看看插入新節點的具體過程(這裡只展示中間位置的插入,頭尾插入比較簡單):

那如何刪除一箇中間的節點呢?下面是具體的過程:

image-20220108114627633

或許你會好奇,a5節點只是指標沒有了,那它去哪裡了?

如果是Java程式,垃圾回收器會收集這種沒有被引用的節點,幫我們回收掉了這部分記憶體,但是為了加快垃圾回收的速度,一般不需要的節點我們需要置空,比如 node = null, 如果在C++ 程式中,那麼就需要手動回收了,否則容易造成記憶體洩漏等問題。

複雜連結串列的操作暫時講到這裡,後面我會單獨把連結串列這一塊的資料結構以及常用演算法單獨分享一下,本文章主要講資料結構全貌。

跳錶

上面我們可以觀察到,連結串列如果搜尋,是很麻煩的,如果這個節點在最後,需要遍歷所有的節點,才能找到,查詢效率實在太低,有沒有什麼好的辦法呢?

辦法總比問題多,但是想要絕對的”多快好省“是不存在的,有舍有得,計算機的世界裡,充滿哲學的味道。既然搜尋效率有問題,那麼我們不如給連結串列排個序。排序後的連結串列,還是隻能知道頭尾節點,知道中間的範圍,但是要找到中間的節點,還是得走遍歷的老路。如果我們把中間節點儲存起來呢?存起來,確實我們就知道資料在前一半,還是在後一半。比如找7,肯定就從中間節點開始找。如果查詢4,就得從頭開始找,最差到中間節點,就停止查詢。

但是如此,還是沒有徹底解決問題,因為連結串列很長的情況,只能通過前後兩部分查詢。不如回到原則:空間和時間,我們選擇時間,那就要捨棄一部分空間,我們每個節點再加一個指標,現在有 2 層指標(注意:節點只有一份,都是同一個節點,只是為了好看,弄了兩份,實際上是同一個節點,有兩個指標,比如 1 ,既指向2,也指向5):

兩層指標,問題依然存在,那就不斷加層,比如每兩個節點,就加一層:

這就是跳錶了,跳錶的定義如下:

跳錶(SkipList,全稱跳躍表)是用於有序元素序列快速搜尋查詢的一個資料結構,跳錶是一個隨機化的資料結構,實質就是一種可以進行二分查詢的有序連結串列。跳錶在原有的有序連結串列上面增加了多級索引,通過索引來實現快速查詢。跳錶不僅能提高搜尋效能,同時也可以提高插入和刪除操作的效能。它在效能上和紅黑樹,AVL樹不相上下,但是跳錶的原理非常簡單,實現也比紅黑樹簡單很多。

主要的原理是用空間換時間,可以實現近乎二分查詢的效率,實際上消耗的空間,假設每兩個加一層, 1 + 2 + 4 + ... + n = 2n-1,多出了差不多一倍的空間。你看它像不像書的目錄,一級目錄,二級,三級 ...

如果我們不斷往跳錶中插入資料,可能出現某一段節點會特別多的情況,這個時候就需要動態更新索引,除了插入資料,還要插入到上一層的連結串列中,保證查詢效率。

redis 中使用了跳錶來實現zset,redis中使用一個隨機演算法來計算層級,計算出每個節點到底多少層索引,雖然不能絕對保證比較平衡,但是基本保證了效率,實現起來比那些平衡樹,紅黑樹的演算法簡單一點。

棧是一種資料結構,在Java裡面體現是Stack類。它的本質是先進後出,就像是一個桶,只能不斷的放在上面,取出來的時候,也只能不斷的取出最上面的資料。要想取出底層的資料,只有等到上面的資料都取出來,才能做到。當然,如果有這種需求,我們一般會使用雙向佇列。

以下是棧的特性演示:

棧的底層用什麼實現的?其實可以用連結串列,也可以用陣列,但是JDK底層的棧,是用陣列實現的,封裝之後,通過API操作的永遠都只能是最後一個元素,棧經常用來實現遞迴的功能。如果想要了解Java裡面的棧或者其他集合實現分析,可以看看這系列文章:http://aphysia.cn/categories/collection

元素加入稱之為入棧(壓棧),取出元素,稱之為出棧,棧頂元素則是最後一次放進去的元素。

使用陣列實現簡單的棧(注意僅供參考測試,實際會有執行緒安全等問題):

import java.util.Arrays;

public class MyStack<T> {
    private T[] data;
    private int length = 2;
    private int maxIndex;

    public MyStack() {
        data = (T[]) new Object[length];
        maxIndex = -1;
    }

    public void push(T element) {
        if (isFull()) {
            length = 2 * length;
            data = Arrays.copyOf(data, length);
        }
        data[maxIndex + 1] = element;
        maxIndex++;
    }

    public T pop() {
        if (isEmpty()) {
            throw new IndexOutOfBoundsException("棧內沒有資料");
        } else {
            T[] newdata = (T[]) new Object[data.length - 1];
            for (int i = 0; i < data.length - 1; i++) {
                newdata[i] = data[i];
            }
            T element = data[maxIndex];
            maxIndex--;
            data = newdata;
            return element;
        }
    }

    private boolean isFull() {
        return data.length - 1 == maxIndex;
    }

    public boolean isEmpty() {
        return maxIndex == -1;
    }

    public void display() {
        for (int i = 0; i < data.length; i++) {
            System.out.print(data[i]+" ");
        }
        System.out.println("");
    }
}

測試程式碼:

public class MyStackTest {
    public static void main(String[] args) {
        MyStack<Integer> myStack = new MyStack<>();
        myStack.push(1);
        myStack.push(2);
        myStack.push(3);
        myStack.push(4);
        myStack.display();

        System.out.println(myStack.pop());

        myStack.display();

    }
}

輸出結果如下,符合預期:

1 2 3 4 
4
1 2 3 

棧的特點就是先進先出,但是如果需要隨機取出前面的資料,效率會比較低,需要倒騰出來,但是如果底層使用陣列,理論上是可以通過索引下標取出的,Java裡面正是這樣實現。

佇列

既然前面有先進後出的資料結構,那我們必定也有先進先出的資料結構,疫情的時候,排隊估計大家都有測過核酸,那排隊老長了,排在前面先測,排在後面後測,這道理大家都懂。

佇列是一種特殊的線性表,特殊之處在於它只允許在表的前端(front)進行刪除操作,而在表的後端(rear)進行插入操作,和棧一樣,佇列是一種操作受限制的線性表。進行插入操作的端稱為隊尾,進行刪除操作的端稱為隊頭。

佇列的特點是先進先出,以下是例子:

一般只要說到先進先出(FIFO),全稱First In First Out,就會想到佇列,但是如果你想擁有佇列即可以從隊頭取出元素,又可以從隊尾取出元素,那就需要用到特殊的佇列(雙向佇列),雙向佇列一般使用雙向連結串列實現會簡單一點。

下面我們用Java實現簡單的單向佇列:

class Node<T> {
    public T data;
    public Node next;

    public Node(T data) {
        this.data = data;
    }
}

public class MyQueue<T> {
    private Node<T>  head;
    private Node<T>  rear;
    private int size;

    public MyQueue() {
        size = 0;
    }

    public void pushBack(T element) {
        Node newNode = new Node(element);
        if (isEmpty()) {
            head = newNode;
        } else {
            rear.next = newNode;
        }
        rear = newNode;
        size++;
    }

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

    public T popFront() {
        if (isEmpty()) {
            throw new NullPointerException("佇列沒有資料");
        } else {
            Node<T> node = head;
            head = head.next;
            size--;
            return node.data;
        }
    }

    public void dispaly() {
        Node temp = head;
        while (temp != null) {
            System.out.print(temp.data +" -> ");
            temp = temp.next;
        }
        System.out.println("");
    }
}

測試程式碼如下:

public class MyStackTest {
    public static void main(String[] args) {
        MyStack<Integer> myStack = new MyStack<>();
        myStack.push(1);
        myStack.push(2);
        myStack.push(3);
        myStack.push(4);
        myStack.display();

        System.out.println(myStack.pop());

        myStack.display();

    }
}

執行結果:

1 -> 2 -> 3 -> 
1
2 -> 3 -> 
2
3 -> 

常用的佇列型別如下:

  • 單向佇列:也就是我們說的普通佇列,先進先出。

  • 雙向佇列:可以從不同方向進出佇列

  • 優先佇列:內部是自動排序的,按照一定順序出佇列

  • 阻塞佇列:從佇列取出元素的時候,佇列沒有元素則會阻塞,同樣如果佇列滿了,往佇列裡面放入元素也會被阻塞。

  • 迴圈佇列:可以理解為一個迴圈連結串列,但是一般需要標識出頭尾節點,防止死迴圈,尾節點的next指向頭結點。

佇列一般可以用來儲存需要順序的資料,或者儲存任務,在樹的層次遍歷中可以使用佇列解決,一般廣度優先搜尋都可以使用佇列解決。

雜湊表

前面的資料結構,查詢的時候,一般都是使用=或者!=,在折半查詢或者其他範圍查詢的時候,可能會使用<>,理想的時候,我們肯定希望不經過任何的比較,直接能定位到某個位置(儲存位置),這種在陣列中,可以通過索引取得元素。那麼,如果我們將需要儲存的資料和陣列的索引對應起來,並且是一對一的關係,那不就可以很快定位到元素的位置了麼?

只要通過函式f(k)就能找到k對應的位置,這個函式f(k)就是hash函式。它表示的是一種對映關係,但是對不同的值,可能會對映到同一個值(同一個hash地址),也就是f(k1) = f(k2),這種現象我們稱之為衝突或者碰撞

hash表定義如下:

雜湊表(Hash table,也叫雜湊表),是根據鍵(Key)而直接訪問在記憶體儲存位置的資料結構。也就是說,它通過計算一個關於鍵值的函式,將所需查詢的資料對映到表中一個位置來訪問記錄,這加快了查詢速度。這個對映函式稱做雜湊函式,存放記錄的陣列稱做雜湊表。

一般常用的hash 函式有:

  • 直接定址法:取出關鍵字或者關鍵字的某個線性函式的值為雜湊函式,比如H(key) = key或者H(key) = a * key + b
  • 數字分析法:對於可能出現的數值全部瞭解,取關鍵字的若干數位組成雜湊地址
  • 平方取中法:取關鍵字平方後的中間幾位作為雜湊地址
  • 摺疊法:將關鍵字分割成為位數相同的幾部分(最後一部分的位數可以不同),取這幾部分的疊加和(捨去進位),作為雜湊地址。
  • 除留餘數法:取關鍵字被某個不大於雜湊表表長m的數p除後所得的餘數為雜湊地址。即hash(k)=k mod pp< =m。不僅可以對關鍵字直接取模,也可在摺疊法、平方取中法等運算之後取模。對p的選擇很重要,一般取素數或m,若p選擇不好,容易產生衝突。
  • 隨機數法:取關鍵字的隨機函式值作為它的雜湊地址。

但是這些方法,都無法避免雜湊衝突,只能有意識的減少。那處理hash衝突,一般有哪些方法呢?

  • 開放地址法:hash計算後,如果該位置已經有資料,那麼對該地址+1,也就是往後找,知道找到一個空的位置。
  • 重新hash法:發生雜湊衝突後,可以使用另外的hash函式重新極計算,找到空的hash地址,如果有,還可以再疊加hash函式。
  • 鏈地址法:所有hash值一樣的,連結成為一個連結串列,掛在陣列後面。
  • 建立公共溢位區:不常見,意思是所有元素,如果和表中的元素hash衝突,都弄到另外一個表,也叫溢位表。

Java裡面,用的就是鏈地址法:

但是如果hash衝突比較嚴重,連結串列會比較長,查詢的時候,需要遍歷後面的連結串列,因此JDK優化了一版,連結串列的長度超過閾值的時候,會變成紅黑樹,紅黑樹有一定的規則去平衡子樹,避免退化成為連結串列,影響查詢效率。

但是你肯定會想到,如果陣列太小了,放了比較多資料了,怎麼辦?再放衝突的概率會越來越高,其實這個時候會觸發一個擴容機制,將陣列擴容成為 2倍大小,重新hash以前的資料,雜湊到不同的陣列中。

hash表的優點是查詢速度快,但是如果不斷觸發重新 hash, 響應速度也會變慢。同時,如果希望範圍查詢,hash表不是好的選擇。

陣列和連結串列都是線性結構,而這裡要介紹的樹,則是非線性結構。現實中樹是金字塔結構,資料結構中的樹,最上面稱之為根節點。

我們該如何定義樹結構呢?

是一種資料結構,它是由n(n≥1)個有限節點組成一個具有層次關係的集合。把它叫做“樹”是因為它看起來像一棵倒掛的樹,也就是說它是根朝上,而葉朝下的。它具有以下的特點:

每個節點有零個或多個子節點;沒有父節點的節點稱為根節點;每一個非根節點有且只有一個父節點;除了根節點外,每個子節點可以分為多個不相交的子樹。(百度百科)

下面是樹的基本術語(來自於清華大學資料結構C語言版):

  • 節點的度:一個節點含有的子樹的個數稱為該節點的度
  • 樹的度:一棵樹中,最大的節點度稱為樹的度;
  • 葉節點或終端節點:度為零的節點;
  • 非終端節點或分支節點:度不為零的節點;
  • 父親節點或父節點:若一個節點含有子節點,則這個節點稱為其子節點的父節點;
  • 孩子節點或子節點:一個節點含有的子樹的根節點稱為該節點的子節點;
  • 兄弟節點:具有相同父節點的節點互稱為兄弟節點;
  • 節點的層次:從根開始定義起,根為第1層,根的子節點為第2層,以此類推;
  • 深度:對於任意節點n,n的深度為從根到n的唯一路徑長,根的深度為0
  • 高度:對於任意節點n,n的高度為從n到一片樹葉的最長路徑長,所有樹葉的高度為0
  • 堂兄弟節點:父節點在同一層的節點互為堂兄弟;
  • 節點的祖先:從根到該節點所經分支上的所有節點;
  • 子孫:以某節點為根的子樹中任一節點都稱為該節點的子孫。
  • 有序樹:將樹種的節點的各個子樹看成從左至右是有次序的(不能互換),則應該稱該樹為有序樹,否則為無序樹
  • 第一個孩子:在有序樹中最左邊的子樹的根稱為第一個孩子
  • 最後一個孩子:在有序樹種最右邊的子樹的根稱為最後一個孩子
  • 森林:由mm>=0)棵互不相交的樹的集合稱為森林;

樹,其實我們最常用的是二叉樹:

二叉樹的特點是每個節點最多隻有兩個子樹,並且子樹有左右之分,左右子節點的次序不能任意顛倒。

二叉樹在Java中表示:

public class TreeLinkNode {
    int val;
    TreeLinkNode left = null;
    TreeLinkNode right = null;
    TreeLinkNode next = null;

    TreeLinkNode(int val) {
        this.val = val;
    }
}

滿二叉樹:一棵深度為 k 且有 2k-1 個節點的二叉樹,稱之為滿二叉樹

完全二叉樹:深度為 k 的,有 n 個節點的二叉樹,當且僅當其每一個節點都與深度為 k 的滿二叉樹中編號從 1 到 n 的節點一一對應是,稱之為完全二叉樹。

一般二叉樹的遍歷有幾種:

  • 前序遍歷:遍歷順序 根節點 --> 左子節點 --> 右子節點
  • 中序遍歷:遍歷順序 左子節點 --> 根節點 --> 右子節點
  • 後序遍歷:遍歷順序 左子節點 --> 右子節點 --> 根節點
  • 廣度 / 層次遍歷: 從上往下,一層一層的遍歷

如果是一棵混亂的二叉樹,那查詢或者搜尋的效率也會比較低,和一條混亂的連結串列沒有什麼區別,何必弄更加複雜的結構呢?

其實,二叉樹是可以用在排序或者搜尋中的,因為二叉樹有嚴格的左右子樹之分,我們可以定義根節點,左子節點,右子節點的大小之分。於是有了二叉搜尋樹:

二叉查詢樹(Binary Search Tree),(又:二叉搜尋樹,二叉排序樹)它或者是一棵空樹,或者是具有下列性質的二叉樹: 若它的左子樹不空,則左子樹上所有結點的值均小於它的根結點的值; 若它的右子樹不空,則右子樹上所有結點的值均大於它的根結點的值; 它的左、右子樹也分別為二叉排序樹。二叉搜尋樹作為一種經典的資料結構,它既有連結串列的快速插入與刪除操作的特點,又有陣列快速查詢的優勢;所以應用十分廣泛,例如在檔案系統和資料庫系統一般會採用這種資料結構進行高效率的排序與檢索操作。

二叉查詢樹樣例如下:

比如上面的樹,如果我們需要查詢到 4, 從 5開始,45小,往左子樹走,查詢到343大,往右子樹走,找到了4,也就是一個 7個節點的樹,我們只查詢了3次,也就是層數,假設n個節點,那就是log(n+1)

樹維護好了,查詢效率固然高,但是如果樹沒維護好,容易退化成為連結串列,查詢效率也會下降,比如:

一棵對查詢友好的二叉樹,應該是一個平衡或者接近平衡的二叉樹,何為平衡二叉樹:

平衡二叉搜尋樹的任何結點的左子樹和右子樹高度最多相差1。平衡二叉樹也稱為 AVL 樹。

為了保證插入或者刪除資料等之後,二叉樹還是平衡二叉樹,那麼就需要調整節點,這個也稱為平衡過程,裡面會涉及各種旋轉調整,這裡暫時不展開。

但是如果涉及大量的更新,刪除操作,平衡樹種的各種調整需要犧牲不小的效能,為了解決這個問題,有大佬提出了紅黑樹.

紅黑樹(Red Black Tree) 是一種自平衡二叉查詢樹,是在計算機科學中用到的一種資料結構,典型的用途是實現關聯陣列。 [1]

紅黑樹是在1972年由[Rudolf Bayer](https://baike.baidu.com/item/Rudolf Bayer/3014716)發明的,當時被稱為平衡二叉B樹(symmetric binary B-trees)。後來,在1978年被 Leo J. Guibas 和 Robert Sedgewick 修改為如今的“紅黑樹”。 [2]

紅黑樹是一種特化的AVL樹(平衡二叉樹),都是在進行插入和刪除操作時通過特定操作保持二叉查詢樹的平衡,從而獲得較高的查詢效能。

紅黑樹有以下的特點:

  • 性質1. 結點是紅色或黑色。

  • 性質2. 根結點是黑色。

  • 性質3. 所有葉子都是黑色。(葉子是NIL結點)

  • 性質4. 每個紅色結點的兩個子結點都是黑色。(從每個葉子到根的所有路徑上不能有兩個連續的紅色結點)

  • 性質5. 從任一節結點其每個葉子的所有路徑都包含相同數目的黑色結點。

正是這些特性,讓紅黑樹在調整的時候,不像普通的平衡二叉樹調整那般困難,頻繁。也就是加上了條條框框,讓它符合一定的標準,減少平衡過程的混亂以及頻次。

前面說的雜湊表,Java 中的實現,正是應用了紅黑樹,在hash衝突較多的時候,會將連結串列轉換成為紅黑樹。

上面說的都是二叉樹,但是我們不得不扯一下多叉樹,為什麼呢?雖然二叉樹中的各種搜尋樹,紅黑樹已經很優秀了,但是在與磁碟互動的時候,大多數是資料儲存中,我們不得不考慮 IO 的因素,因為磁碟IO比記憶體慢太多了。如果索引樹的層高有幾千上萬,那麼磁碟讀取的時候,需要次數太多了。B樹更加適合磁碟儲存。

970年,R.Bayer和E.mccreight提出了一種適用於外查詢的,它是一種平衡的多叉樹,稱為B樹(或B-樹、B_樹)。

一棵m階B樹(balanced tree of order m)是一棵平衡的m路搜尋樹。它或者是空樹,或者是滿足下列性質的樹:

1、根結點至少有兩個子女;

2、每個非根節點所包含的關鍵字個數 j 滿足:m/2 - 1 <= j <= m - 1;

3、除根結點以外的所有結點(不包括葉子結點)的度數正好是關鍵字總數加1,故內部子樹個數 k 滿足:m/2 <= k <= m ;

4、所有的葉子結點都位於同一層。

每個節點放多一點資料,查詢的時候,記憶體中的操作比磁碟快很多,b樹可以減少磁碟IO的次數。B 樹:

而每個節點的data可能很大,這樣會導致每一頁查出來的資料很少,IO查詢次數自然就增加了,那我們不如只在葉子節點中儲存資料:

B+樹是B樹的一種變形形式,B+樹上的葉子結點儲存關鍵字以及相應記錄的地址,葉子結點以上各層作為索引使用。一棵m階的B+樹定義如下:

(1)每個結點至多有m個子女;

(2)除根結點外,每個結點至少有[m/2]個子女,根結點至少有兩個子女;

(3)有k個子女的結點必有k個關鍵字。

一般b+樹的葉子節點,會用連結串列連線起來,方便遍歷以及範圍遍歷。

這就是b+樹,b+樹相對於B樹多了以下優勢:

  1. b+樹的中間節點不儲存資料,每次IO查詢能查到更多的索引,,是一個矮胖的樹。
  2. 對於範圍查詢來說,b+樹只需遍歷葉子節點連結串列即可,b樹卻需要從根節點都葉子節點。

除了上面的樹,其實還有一種叫Huffman樹:給定N個權值作為N個葉子結點,構造一棵二叉樹,若該樹的帶權路徑長度達到最小,稱這樣的二叉樹為最優二叉樹,也稱為哈夫曼樹(Huffman Tree)。哈夫曼樹是帶權路徑長度最短的樹,權值較大的結點離根較近。

一般用來作為壓縮使用,因為資料中,每個字元出現的頻率不一樣,出現頻率越高的字元,我們用越短的編碼儲存,就可以達到壓縮的目的。那這個編碼怎麼來的呢?

假設字元是hello,那麼編碼可能是(只是編碼的大致雛形,高頻率出現的字元,編碼更短),編碼就是從根節點到當前字元的路徑的01串:

通過不同權值的編碼,哈夫曼樹到了有效的壓縮。

堆,其實也是二叉樹中的一種,堆必須是完全二叉樹,完全二叉樹是:除了最後一層,其他層的節點個數都是滿的,最後一層的節點都集中在左部連續位置。

而堆還有一個要求:堆中每一個節點的值都必須大於等於(或小於等於)其左右子節點的值。

堆主要分為兩種:

  • 大頂堆:每個節點都大於等於其子樹節點(堆頂是最大值)
  • 小頂堆:每個節點都小於等於其子樹節點(堆頂是最小值)

一般情況下,我們都是用陣列來表示堆,比如下面的小頂堆:

image-20220109000632499

陣列中父子節點以及左右節點的關係如下:

  • i 結點的父結點 parent = floor((i-1)/2) (向下取整)
  • i 結點的左子結點 2 * i +1
  • i 結點的右子結點 2 * i + 2

既然是儲存資料的,那麼一定會涉及到插入刪除等操作,堆裡面插入刪除,會涉及到堆的調整,調整之後才能重新滿足它的定義,這個調整的過程,叫做堆化

用小頂堆舉例,調整主要是為了保證:

  • 還是完全二叉樹
  • 堆中每一個節點都還小於等於其左右子節點

對於小頂堆,調整的時候是:小元素往上浮,大元素往下沉,就是不斷交換的過程。

堆一般可以用來求解TOP K 問題,或者前面我們說的優先佇列等。

終於來到了圖的講解,圖其實就是二維平面,之前寫過掃雷,掃雷的整個方塊區域,其實也可以說是圖相關的。圖是非線性的資料結構,主要是由邊和頂點組成。

image-20220109002114134

同時圖又分為有向圖與無向圖,上面的是無向圖,因為邊沒有指明方向,只是表示兩者關聯關係,而有向圖則是這樣:

如果每個頂點是一個地方,每條邊是路徑,那麼這就是一張地圖網路,因此圖也經常被用於求解最短距離。先來看看圖相關的概念:

  • 頂點:圖最基本的單元,那些節點
  • 邊:頂點之間的關聯關係
  • 相鄰頂點:由邊直接關聯的頂點
  • 度:一個頂點直接連線的相鄰頂點的數量
  • 權重:邊的權值

一般表示圖有以下幾種方法:

  1. 鄰接矩陣,使用二維陣列表示,為1 表示聯通,0表示不連通,當然如果表示路徑長度的時候,可以用大於0的數表示路徑長度,用-1表示不連通。

下面的圖片中,0和 1,2連通,我們可以看到第 0行的第1,2列是1 ,表示連通。還有一點:頂點自身我們是標識了0,表示不連通,但是有些情況可以視為連通狀態。

  1. 鄰接表

鄰接表,儲存方法跟樹的孩子連結串列示法相類似,是一種順序分配和鏈式分配相結合的儲存結構。如這個表頭結點所對應的頂點存在相鄰頂點,則把相鄰頂點依次存放於表頭結點所指向的單向連結串列中。

對於無向圖來說,使用鄰接表進行儲存也會出現資料冗餘,表頭結點A所指連結串列中存在一個指向C的表結點的同時,表頭結點C所指連結串列也會存在一個指向A的表結點。

圖裡面遍歷一般分為廣度優先遍歷和深度優先遍歷,廣度優先遍歷是指優先遍歷與當前頂點直接相關的頂點,一般藉助佇列實現。而深度優先遍歷則是往一個方向一直走到不能再走,有點不撞南牆不回頭的意思,一般使用遞迴實現。

圖,除了用了計算最小路徑以外,還有一個概念:最小生成樹。

一個有 n 個結點的連通圖的生成樹是原圖的極小連通子圖,且包含原圖中的所有 n 個結點,並且有保持圖連通的最少的邊。 最小生成樹可以用kruskal(克魯斯卡爾)演算法或prim(普里姆)演算法求出。

有一種說法,圖是平面上的點,我們把其中一個點拎起來,能將其他頂點帶起來的邊,取最小權值,多餘的邊去掉,就是最小生成樹。

當然,最小生成樹並不一定是唯一的,可能存在多種結果。

秦懷@觀點

瞭解這些基本的資料結構,在寫程式碼或者資料建模的時候,能夠選擇更加合適的,這是最大的用處。計算機是為人服務的,程式碼也是,資料結構的全部型別我們是無法一下子一一掌握的,但是基本的東西是變動不會很大,除非新一代革命性變化。

程式是由資料結構和演算法組成,資料結構就像是基石,藉助《資料結構C語言》版本中的一句話結尾:

為了編寫出一個”好“的程式,必須分析待處理的物件的特性以及各處理物件之間存在的關係,這就是”資料結構“這門學科和發展的背景。

【作者簡介】
秦懷,公眾號【秦懷雜貨店】作者,個人網站:http://aphysia.cn,技術之路不在一時,山高水長,縱使緩慢,馳而不息。

劍指Offer全部題解PDF

開源程式設計筆記

相關文章