算法學習筆記之散列表與非線性表結構

算法學習筆記之散列表與非線性表結構

記錄一些關於散列表與非線性表結構,包括二叉樹,紅黑樹,堆,圖等相關數據結構的學習筆記,接上一篇《算法學習筆記之複雜度分析與線性表》

1. 散列表

散列表用的是數組支持按照下標隨機訪問數據的特性,所以散列表其實就是數組的一種擴展,由數組演化而來。可以說,如果沒有數組,就沒有散列表。散列表的英文叫“Hash Table”,我們平時也叫它“哈希表”或者“Hash 表”。
散列表用的就是數組支持按照下標隨機訪問的時候,時間複雜度是 O(1) 的特性。我們通過散列函數把元素的鍵值映射爲下標,然後將數據存儲在數組中對應下標的位置。當我們按照鍵值查詢元素時,我們用同樣的散列函數,將鍵值轉化數組下標,從對應的數組下標的位置取數據。
散列表兩個核心問題是散列函數設計和散列衝突解決。

1.1 散列函數

散列函數,顧名思義,它是一個函數。我們可以把它定義成 hash(key),其中 key 表示元素的鍵值,hash(key) 的值表示經過散列函數計算得到的散列值。

  • 散列函數計算得到的散列值是一個非負整數;
  • 如果 key1 = key2,那 hash(key1) == hash(key2);
  • 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。
1.2 散列衝突

常用的散列衝突解決方法有兩類,開放尋址法(open addressing)和鏈表法(chaining)。

1.2.1 開放尋址法
  • 線性探測(Linear Probing)
    當我們往散列表中插入數據時,如果某個數據經過散列函數散列之後,存儲位置已經被佔用了,我們就從當前位置開始,依次往後查找,看是否有空閒位置,直到找到爲止。
  • 二次探測(Quadratic probing)
    線性探測每次探測的步長是 1,那它探測的下標序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探測探測的步長就變成了原來的“二次方”
  • 雙重散列(Double hashing)
    所謂雙重散列,意思就是不僅要使用一個散列函數。我們使用一組散列函數 hash1(key),hash2(key),hash3(key)……我們先用第一個散列函數,如果計算得到的存儲位置已經被佔用,再用第二個散列函數,依次類推,直到找到空閒的存儲位置。

爲了儘可能保證散列表的操作效率,一般情況下,我們會儘可能保證散列表中有一定比例的空閒槽位。我們用裝載因子(load factor)來表示空位的多少。裝載因子的計算公式是:散列表的裝載因子=填入表中的元素個數/散列表的長度,裝載因子越大,說明空閒位置越少,衝突越多,散列表的性能會下降。

1.2.2. 鏈表法

在散列表中,每個“桶(bucket)”或者“槽(slot)”會對應一條鏈表,所有散列值相同的元素我們都放到相同槽位對應的鏈表中。

1.3 設計散列函數
  • 當數據量比較小、裝載因子小的時候,適合採用開放尋址法。這也是 Java 中的ThreadLocalMap使用開放尋址法解決散列衝突的原因。
  • 基於鏈表的散列衝突處理方法比較適合存儲大對象、大數據量的散列表,而且,比起開放尋址法,它更加靈活,支持更多的優化策略,比如用紅黑樹代替鏈表。
  • Java 中的 HashMap

int hash(Object key) {
    int h = key.hashCode()return (h ^ (h >>> 16)) & (capicity -1); //capicity表示散列表的大小
}

public int hashCode() {
  int var1 = this.hash;
  if(var1 == 0 && this.value.length > 0) {
    char[] var2 = this.value;
    for(int var3 = 0; var3 < this.value.length; ++var3) {
      var1 = 31 * var1 + var2[var3];
    }
    this.hash = var1;
  }
  return var1;
}
  • Java 中的 LinkedHashMap
    LinkedHashMap 是通過雙向鏈表和散列表這兩種數據結構組合實現的。LinkedHashMap 中的“Linked”實際上是指的是雙向鏈表,並非指用鏈表法解決散列衝突。
1.4 散列表總結

散列表這種數據結構雖然支持非常高效的數據插入、刪除、查找操作,但是散列表中的數據都是通過散列函數打亂之後無規律存儲的。也就說,它無法支持按照某種順序快速地遍歷數據。如果希望按照順序遍歷散列表中的數據,那我們需要將散列表中的數據拷貝到數組中,然後排序,再遍歷。因爲散列表是動態數據結構,不停地有數據的插入、刪除,所以每當我們希望按順序遍歷散列表中的數據的時候,都需要先排序,那效率勢必會很低。爲了解決這個問題,我們將散列表和鏈表(或者跳錶)結合在一起使用。

2. 哈希算法

2.1 概念定義

將任意長度的二進制值串映射爲固定長度的二進制值串,這個映射的規則就是哈希算法,而通過原始數據映射之後得到的二進制值串就是哈希值。

  • 從哈希值不能反向推導出原始數據(所以哈希算法也叫單向哈希算法);
  • 對輸入數據非常敏感,哪怕原始數據只修改了一個 Bit,最後得到的哈希值也大不相同;
  • 散列衝突的概率要很小,對於不同的原始數據,哈希值相同的概率非常小;
  • 哈希算法的執行效率要儘量高效,針對較長的文本,也能快速地計算出哈希值。
2.2 應用場景

安全加密,唯一標識,數據校驗,散列函數,脫庫加鹽值(salt),負載均衡、數據分片、分佈式存儲(一致性哈希算法)。

3. 二叉樹

3.1 二叉樹的定義

“樹”,裏面每個元素我們叫作“節點”;用來連線相鄰節點之間的關係,我們叫作“父子關係”。比如,A 節點就是 B 節點的父節點,B 節點是 A 節點的子節點。B、C、D 這三個節點的父節點是同一個節點,所以它們之間互稱爲兄弟節點。把沒有父節點的節點叫作根節點,把沒有子節點的節點叫作葉子節點或者葉節點。

  • 高度(Height)
    節點到葉子節點的最長路徑,即邊數,其實就是從下往上度量,從最底層開始計數,並且計數的起點是 0。
  • 深度(Depth)
    根節點到這個節點所經歷的邊的個數,從上往下度量的,從根結點開始度量,並且計數起點也是 0
  • 層(Level)
    根節點的高度,跟深度的計算類似,不過,計數起點是 1,也就是說根節點的位於第 1 層。

二叉樹,顧名思義,每個節點最多有兩個“叉”,也就是兩個子節點,分別是左子節點和右子節點。不過,二叉樹並不要求每個節點都有兩個子節點,有的節點只有左子節點,有的節點只有右子節點。

  • 葉子節點全都在最底層,除了葉子節點之外,每個節點都有左右兩個子節點,這種二叉樹就叫作滿二叉樹
  • 葉子節點都在最底下兩層,最後一層的葉子節點都靠左排列,並且除了最後一層,其他層的節點個數都要達到最大,這種二叉樹叫作完全二叉樹。堆其實就是一種完全二叉樹,最常用的存儲方式就是數組。
3.2 二叉樹的存儲

存儲一棵二叉樹,我們有兩種方法,一種是基於指針或者引用的二叉鏈式存儲法,一種是基於數組的順序存儲法

  • 二叉鏈式存儲法
    每個節點有三個字段,其中一個存儲數據,另外兩個是指向左右子節點的指針。我們只要拎住根節點,就可以通過左右子節點的指針,把整棵樹都串起來。這種存儲方式我們比較常用。大部分二叉樹代碼都是通過這種結構來實現的。

  • 順序存儲法
    把根節點存儲在下標 i = 1 的位置,那左子節點存儲在下標 2 * i = 2 的位置,右子節點存儲在 2 * i + 1 = 3 的位置。
    組順序存儲的方式比較適合完全二叉樹。

3.3 二叉樹的遍歷
  • 前序遍歷是指,對於樹中的任意節點來說,先打印這個節點,然後再打印它的左子樹,最後打印它的右子樹。
  • 中序遍歷是指,對於樹中的任意節點來說,先打印它的左子樹,然後再打印它本身,最後打印它的右子樹。中序遍歷二叉查找樹,可以輸出有序的數據序列,時間複雜度是 O(n),非常高效。
  • 後序遍歷是指,對於樹中的任意節點來說,先打印它的左子樹,然後再打印它的右子樹,最後打印這個節點本身

二叉樹遍歷的時間複雜度是 O(n)。

3.4 二叉查找樹

二叉查找樹最大的特點就是,支持動態數據集合的快速插入、刪除、查找操作。
二叉查找樹要求,在樹中的任意一個節點,其左子樹中的每個節點的值,都要小於這個節點的值,而右子樹節點的值都大於這個節點的值。時間複雜度其實都跟樹的高度成正比,也就是 O(height)


public class BinarySearchTree {
  private Node tree;

  public Node find(int data) {
    Node p = tree;
    while (p != null) {
      if (data < p.data) p = p.left;
      else if (data > p.data) p = p.right;
      else return p;
    }
    return null;
  }

  public static class Node {
    private int data;
    private Node left;
    private Node right;

    public Node(int data) {
      this.data = data;
    }
  }
}
  • 二叉查找樹的插入操作

public void insert(int data) {
  if (tree == null) {
    tree = new Node(data);
    return;
  }

  Node p = tree;
  while (p != null) {
    if (data > p.data) {
      if (p.right == null) {
        p.right = new Node(data);
        return;
      }
      p = p.right;
    } else { // data < p.data
      if (p.left == null) {
        p.left = new Node(data);
        return;
      }
      p = p.left;
    }
  }
}
  • 二叉查找樹的刪除操作

public void delete(int data) {
  Node p = tree; // p指向要刪除的節點,初始化指向根節點
  Node pp = null; // pp記錄的是p的父節點
  while (p != null && p.data != data) {
    pp = p;
    if (data > p.data) p = p.right;
    else p = p.left;
  }
  if (p == null) return; // 沒有找到

  // 要刪除的節點有兩個子節點
  if (p.left != null && p.right != null) { // 查找右子樹中最小節點
    Node minP = p.right;
    Node minPP = p; // minPP表示minP的父節點
    while (minP.left != null) {
      minPP = minP;
      minP = minP.left;
    }
    p.data = minP.data; // 將minP的數據替換到p中
    p = minP; // 下面就變成了刪除minP了
    pp = minPP;
  }

  // 刪除節點是葉子節點或者僅有一個子節點
  Node child; // p的子節點
  if (p.left != null) child = p.left;
  else if (p.right != null) child = p.right;
  else child = null;

  if (pp == null) tree = child; // 刪除的是根節點
  else if (pp.left == p) pp.left = child;
  else pp.right = child;
}
  • 散列表與二叉樹
  1. 散列表中的數據是無序存儲的,如果要輸出有序的數據,需要先進行排序。而對於二叉查找樹來說,我們只需要中序遍歷,就可以在 O(n) 的時間複雜度內,輸出有序的數據序列。
  2. 散列表擴容耗時很多,而且當遇到散列衝突時,性能不穩定,儘管二叉查找樹的性能不穩定,但是在工程中,我們最常用的平衡二叉查找樹的性能非常穩定,時間複雜度穩定在 O(logn)。
  3. 因爲哈希衝突的存在,這個常量不一定比 logn 小,所以實際的查找速度可能不一定比 O(logn) 快。加上哈希函數的耗時,也不一定就比平衡二叉查找樹的效率高。
  4. 散列表的構造比二叉查找樹要複雜,需要考慮的東西很多。比如散列函數的設計、衝突解決辦法、擴容、縮容等。平衡二叉查找樹只需要考慮平衡性這一個問題,而且這個問題的解決方案比較成熟、固定。
  5. 爲了避免過多的散列衝突,散列表裝載因子不能太大,特別是基於開放尋址法解決衝突的散列表,不然會浪費一定的存儲空間。

4. 紅黑樹

二叉樹中任意一個節點的左右子樹的高度相差不能大於 1。紅黑樹的英文是“Red-Black Tree”,簡稱 R-B Tree。

  • 紅黑樹中的節點,一類被標記爲黑色,一類被標記爲紅色。
  • 根節點是黑色的
  • 每個葉子節點都是黑色的空節點(NIL),也就是說,葉子節點不存儲數據;
  • 任何相鄰的節點都不能同時爲紅色,也就是說,紅色節點是被黑色節點隔開的;
  • 每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點;

紅黑樹是一種性能非常穩定的二叉查找樹,所以,在工程中,但凡是用到動態插入、刪除、查找數據的場景,都可以用到它。不過,它實現起來比較複雜,如果自己寫代碼實現,難度會有些高,這個時候,我們其實更傾向用跳錶來替代它。

5. 堆

堆是一個完全二叉樹;堆中每一個節點的值都必須大於等於(或小於等於)其子樹中每個節點的值。即堆中每個節點的值都大於等於(或者小於等於)其左右子節點的值。這兩種表述是等價的。對於每個節點的值都大於等於子樹中每個節點值的堆,我們叫作“大頂堆”。對於每個節點的值都小於等於子樹中每個節點值的堆,我們叫作“小頂堆”。
數組中下標爲 i 的節點的左子節點,就是下標爲 i∗2 的節點,右子節點就是下標爲 i∗2+1 的節點,父節點就是下標爲 2i​ 的節點。

5.1 往堆中插入一個元素

讓新插入的節點與父節點對比大小。如果不滿足子節點小於等於父節點的大小關係,我們就互換兩個節點。一直重複這個過程,直到父子節點之間滿足剛說的那種大小關係。


public class Heap {
  private int[] a; // 數組,從下標1開始存儲數據
  private int n;  // 堆可以存儲的最大數據個數
  private int count; // 堆中已經存儲的數據個數

  public Heap(int capacity) {
    a = new int[capacity + 1];
    n = capacity;
    count = 0;
  }

  public void insert(int data) {
    if (count >= n) return; // 堆滿了
    ++count;
    a[count] = data;
    int i = count;
    while (i/2 > 0 && a[i] > a[i/2]) { // 自下往上堆化
      swap(a, i, i/2); // swap()函數作用:交換下標爲i和i/2的兩個元素
      i = i/2;
    }
  }
 }
5.2 刪除堆頂元素

我們把最後一個節點放到堆頂,然後利用同樣的父子節點對比方法。對於不滿足父子節點大小關係的,互換兩個節點,並且重複進行這個過程,直到父子節點之間滿足大小關係爲止。這就是從上往下的堆化方法。


public void removeMax() {
  if (count == 0) return -1; // 堆中沒有數據
  a[1] = a[count];
  --count;
  heapify(a, count, 1);
}

private void heapify(int[] a, int n, int i) { // 自上往下堆化
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}

往堆中插入一個元素和刪除堆頂元素的時間複雜度都是 O(logn)。

5.3 堆排序

首先將數組原地建成一個堆。所謂“原地”就是,不借助另一個數組,就在原數組上操作。建堆的過程,有兩種思路。


private static void buildHeap(int[] a, int n) {
  for (int i = n/2; i >= 1; --i) {
    heapify(a, n, i);
  }
}

private static void heapify(int[] a, int n, int i) {
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}

建堆的時間複雜度就是 O(n)。
建堆結束之後,數組中的數據已經是按照大頂堆的特性來組織的。數組中的第一個元素就是堆頂,也就是最大的元素。我們把它跟最後一個元素交換,那最大元素就放到了下標爲 n 的位置。當堆頂元素移除之後,我們把下標爲 n 的元素放到堆頂,然後再通過堆化的方法,將剩下的 n−1 個元素重新構建成堆。堆化完成之後,我們再取堆頂的元素,放到下標是 n−1 的位置,一直重複這個過程,直到最後堆中只剩下標爲 1 的一個元素,排序工作就完成了。


// n表示數據的個數,數組a中的數據從下標1到n的位置。
public static void sort(int[] a, int n) {
  buildHeap(a, n);
  int k = n;
  while (k > 1) {
    swap(a, 1, k);
    --k;
    heapify(a, k, 1);
  }
}

建堆過程的時間複雜度是 O(n),排序過程的時間複雜度是 O(nlogn),所以,堆排序整體的時間複雜度是 O(nlogn)。堆排序不是穩定的排序算法。

5.4 堆的應用
5.4.1 優先級隊列

一個堆就可以看作一個優先級隊列。很多時候,它們只是概念上的區分而已。往優先級隊列中插入一個元素,就相當於往堆中插入一個元素;從優先級隊列中取出優先級最高的元素,就相當於取出堆頂元素。很多數據結構和算法都要依賴它,比如赫夫曼編碼、圖的最短路徑、最小生成樹算法等等。很多語言中,都提供了優先級隊列的實現,比如,Java 的 PriorityQueue,C++ 的 priority_queue 等。
優先級隊列應用:合併有序小文件,高性能定時器…

5.4.2 利用堆求 Top K

一直維護一個 K 大小的小頂堆,當有數據被添加到集合中時,就拿它與堆頂的元素對比。如果比堆頂元素大,我們就把堆頂元素刪除,並且將這個元素插入到堆中;如果比堆頂元素小,則不做處理。這樣,無論任何時候需要查詢當前的前 K 大數據,我們都可以立刻返回給他。

5.4.3 利用堆求中位數

面對動態數據集合,中位數在不停地變動,藉助堆這種數據結構,我們不用排序,就可以非常高效地實現求中位數操作。
維護兩個堆,一個大頂堆,一個小頂堆。大頂堆中存儲前半部分數據,小頂堆中存儲後半部分數據,且小頂堆中的數據都大於大頂堆中的數據。
利用兩個堆不僅可以快速求出中位數,還可以快速求其他百分位的數據。

6. 圖

6.1 圖結構定義
  • 圖中的元素我們就叫作頂點(vertex)。圖中的一個頂點可以與任意其他頂點建立連接關係。我們把這種建立的關係叫作邊(edge)。跟頂點相連接的邊的條數叫做,度(degree)
  • 在圖中畫一條從 A 到 B 的帶箭頭的邊,來表示邊的方向。畫一條從 A 指向 B 的邊,再畫一條從 B 指向 A 的邊。我們把這種邊有方向的圖叫作“有向圖”。以此類推,我們把邊沒有方向的圖就叫作“無向圖”。
  • 在有向圖中,我們把度分爲入度(In-degree)和出度(Out-degree)。頂點的入度,表示有多少條邊指向這個頂點;頂點的出度,表示有多少條邊是以這個頂點爲起點指向其他頂點。
  • 帶權圖(weighted graph)。在帶權圖中,每條邊都有一個權重(weight)
6.2 圖的存儲結構
  • 鄰接矩陣(Adjacency Matrix)
    鄰接矩陣的底層依賴一個二維數組。對於無向圖來說,如果頂點 i 與頂點 j 之間有邊,我們就將 A[i][j]和 A[j][i]標記爲 1;對於有向圖來說,如果頂點 i 到頂點 j 之間,有一條箭頭從頂點 i 指向頂點 j 的邊,那我們就將 A[i][j]標記爲 1。同理,如果有一條箭頭從頂點 j 指向頂點 i 的邊,我們就將 A[j][i]標記爲 1。對於帶權圖,數組中就存儲相應的權重。

  • 鄰接表(Adjacency List)
    針對上面鄰接矩陣比較浪費內存空間的問題,我們來看另外一種圖的存儲方法,鄰接表(Adjacency List)。
    有向圖的鄰接表存儲方式,每個頂點對應的鏈表裏面,存儲的是指向的頂點。
    對於無向圖來說,,每個頂點的鏈表中存儲的,是跟這個頂點有邊相連的頂點。

  • 逆鄰接表
    逆鄰接表中,每個頂點的鏈表中,存儲的是指向這個頂點的頂點。

我們可以將鄰接表中的鏈表改成平衡二叉查找樹。實際開發中,我們可以選擇用紅黑樹。這樣,我們就可以更加快速地查找兩個頂點之間是否存在邊了。當然,這裏的二叉查找樹可以換成其他動態數據結構,比如跳錶、散列表等。除此之外,我們還可以將鏈表改成有序動態數組,可以通過二分查找的方法來快速定位兩個頂點之間否是存在邊。

  • 總結
    鄰接矩陣存儲方法的缺點是比較浪費空間,但是優點是查詢效率高,而且方便矩陣運算。鄰接表存儲方法中每個頂點都對應一個鏈表,存儲與其相連接的其他頂點。儘管鄰接表的存儲方式比較節省存儲空間,但鏈表不方便查找,所以查詢效率沒有鄰接矩陣存儲方式高。針對這個問題,可以將鏈表換成更加高效的動態數據結構,比如平衡二叉查找樹、跳錶、散列表等。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章