1. 程式人生 > >Java 內功修煉 之 資料結構與演算法(二)

Java 內功修煉 之 資料結構與演算法(二)

一、二叉樹補充、多叉樹

1、二叉樹(非遞迴實現遍歷)

(1)前提
  前面一篇介紹了 二叉樹、順序二叉樹、線索二叉樹、哈夫曼樹等樹結構。
  可參考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_1

(2)二叉樹遍歷

【遞迴與非遞迴實現:】
    使用遞迴實現時,系統隱式的維護了一個棧 用於操作節點。雖然遞迴程式碼易理解,但是對於系統的效能會造成一定的影響。
    使用非遞迴程式碼實現,可以主動去維護一個棧 用於操作節點。非遞迴程式碼相對於遞迴程式碼,其效能可能會稍好(資料大的情況下)。
注:
    棧是先進後出(後進先出)結構,即先存放的節點後輸出(後存放的節點先輸出)。
    所以使用棧時,需要明確每一步需要壓入的樹節點。
    遞迴實現二叉樹 前序、中序、後序遍歷。可參考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_2

 

(3)非遞迴實現前序遍歷

【非遞迴實現前序遍歷:】
    前序遍歷順序:當前節點(父節點)、左子節點、右子節點。
實現思路:
    首先明確一點,每次出棧的樹節點即為當前需要輸出的節點(第一個輸出的節點為 根節點)。
    
    每次首先輸出的為 當前節點(父節點),所以父節點先入棧、再出棧。
    出棧之後,需要重新選擇出下一次需要輸出的父節點。從當前節點的 左、右子節點中選擇。
    而左子節點需要在 右子節點前輸出,所以右子節點需要先進棧,然後左子節點再進棧。
    左子節點入棧後,再次出棧即為當前節點,然後重複上面操作,依次取出棧頂元素即可。
    
步驟:
    Step1:根節點入棧。
    Step2:根節點出棧,此時為當前節點,輸出或者儲存。
        Step2.1:若當前節點存在右子節點,則壓入棧。
        Step2.2:若當前節點存在左子節點,則壓入棧。
    Step3:重複 Step2,依次取出棧頂元素並輸出,棧為空時,則樹遍歷完成。    
    
【非遞迴前序遍歷程式碼實現:】
package com.lyh.tree;

import java.util.ArrayList;
import java.util.List;
import java.util.Stack;

public class BinaryTreeSort<K> {
    /**
     * 前序遍歷(非遞迴實現、使用棧模擬遞迴)
     */
    public List<K> prefixList(TreeNode9<K> root) {
        // 使用集合儲存最終結果
        List<K> result = new ArrayList<>();
        // 根節點不存在時,返回空集合
        if (root == null) {
            return result;
        }
        // 使用棧模擬遞迴
        Stack<TreeNode9<K>> stack = new Stack<>();
        // 根節點入棧
        stack.push(root);
        // 棧非空時,依次取出棧頂元素,此時棧頂元素為當前節點,輸出,並將當前節點 左、右子節點入棧
        // 左子節點 先於 右子節點出棧,所以左子節點在 右子節點入棧之後再入棧
        while(!stack.isEmpty()) {
            // 取出棧頂元素(當前節點)
            TreeNode9<K> tempNode = stack.pop();
            // 儲存(或者輸出)當前節點
            result.add(tempNode.data);
            // 存在右子節點,則壓入棧
            if (tempNode.right != null) {
                stack.push(tempNode.right);
            }
            // 存在左子節點,則壓入棧
            if (tempNode.left != null) {
                stack.push(tempNode.left);
            }
        }
        return result;
    }

    public static void main(String[] args) {
        // 構建二叉樹
        TreeNode9<String> root = new TreeNode9<>("0");
        TreeNode9<String> treeNode = new TreeNode9<>("1");
        TreeNode9<String> treeNode2 = new TreeNode9<>("2");
        TreeNode9<String> treeNode3 = new TreeNode9<>("3");
        TreeNode9<String> treeNode4 = new TreeNode9<>("4");
        root.left = treeNode;
        root.right = treeNode2;
        treeNode.left = treeNode3;
        treeNode.right = treeNode4;

        // 前序遍歷
        System.out.print("前序遍歷: ");
        System.out.println(new BinaryTreeSort<String>().prefixList(root));
        System.out.println("\n=====================");
    }

}

class TreeNode9<K> {
    K data; // 儲存節點資料
    TreeNode9<K> left; // 儲存節點的 左子節點
    TreeNode9<K> right; // 儲存節點的 右子節點

    public TreeNode9(K data) {
        this.data = data;
    }

    @Override
    public String toString() {
        return "TreeNode9{ data= " + data + "}";
    }
}

【輸出結果:】
前序遍歷: [0, 1, 3, 4, 2]

 

(4)非遞迴實現中序遍歷

【非遞迴實現中序遍歷:】
    中序遍歷順序:左子節點、當前節點、右子節點。
實現思路:
    首先明確一點,每次出棧的樹節點即為當前需要輸出的節點(第一次輸出的節點為 最左側節點)。

    由於每次都要先輸出當前節點的最左側節點,所以需要遍歷找到這個節點。
    而在遍歷的過程中,每次經過的樹節點均為 父節點,可以使用棧儲存起來。
    此時,找到並輸出最左側節點後,就可以出棧獲得父節點,然後根據父節點可以找到其右子節點。
    將右子節點入棧,同理找到其最左子節點,並重覆上面操作,依次取出棧頂元素即可。
注:
    為了防止重複執行父節點遍歷左子節點的操作,可以使用輔助變數記錄當前操作的節點。
    
步驟:
    Step1:記當前節點為根節點,從根節點開始,遍歷找到最左子節點,並依次將經過的樹節點入棧。
    Step2:取出棧頂元素,此時為最左子節點(當前節點),輸出或者儲存。
        Step2.1:若存在右子節點,則當前節點為 父節點,將右子節點入棧,並修改新的當前節點為 右子節點。遍歷當前節點,同理找到最左子節點,並依次將經過的節點入棧。
        Step2.2:若不存在右子節點,則當前節點為 左子節點,下一次取得的棧頂元素即為 父節點。
    Step3:重複上面過程,輸出順序即為 左、根、右。
    
【非遞迴中序遍歷程式碼實現:】
package com.lyh.tree;

import java.util.ArrayList;
import java.util.List;
import java.util.Stack;

public class BinaryTreeSort<K> {

    /**
     * 中序遍歷(非遞迴實現,使用棧模擬遞迴)
     */
    public List<K> infixList(TreeNode9<K> root) {
        // 使用集合儲存遍歷結果
        List<K> result = new ArrayList<>();
        if (root == null) {
            return result;
        }
        // 儲存當前節點
        TreeNode9<K> currentNode = root;
        // 使用棧模擬遞迴實現
        Stack<TreeNode9<K>> stack = new Stack<>();
        while(!stack.isEmpty() || currentNode != null) {
            // 找到當前節點的左子節點,並依次將經過的節點入棧
            while(currentNode != null) {
                stack.push(currentNode);
                currentNode = currentNode.left;
            }
            // 取出棧頂元素
            TreeNode9<K> tempNode = stack.pop();
            // 儲存棧頂元素
            result.add(tempNode.data);
            // 存在右子節點,則右子節點入棧,
            if (tempNode.right != null) {
                currentNode = tempNode.right;
            }
        }
        return result;
    }

    public static void main(String[] args) {
        // 構建二叉樹
        TreeNode9<String> root = new TreeNode9<>("0");
        TreeNode9<String> treeNode = new TreeNode9<>("1");
        TreeNode9<String> treeNode2 = new TreeNode9<>("2");
        TreeNode9<String> treeNode3 = new TreeNode9<>("3");
        TreeNode9<String> treeNode4 = new TreeNode9<>("4");
        root.left = treeNode;
        root.right = treeNode2;
        treeNode.left = treeNode3;
        treeNode.right = treeNode4;

        // 前序遍歷
        System.out.print("中序遍歷: ");
        System.out.println(new BinaryTreeSort<String>().infixList(root));
        System.out.println("\n=====================");
    }

}

class TreeNode9<K> {
    K data; // 儲存節點資料
    TreeNode9<K> left; // 儲存節點的 左子節點
    TreeNode9<K> right; // 儲存節點的 右子節點

    public TreeNode9(K data) {
        this.data = data;
    }

    @Override
    public String toString() {
        return "TreeNode9{ data= " + data + "}";
    }
}

【輸出結果:】
中序遍歷: [3, 1, 4, 0, 2]

 

(5)非遞迴實現後序遍歷

【非遞迴實現後序遍歷:】
    後序遍歷順序:左子節點、右子節點、當前節點。
實現思路:
    首先明確一點,每次出棧的樹節點即為當前需要輸出的節點(第一次輸出的節點為最左側節點)。
    
    這裡與 中序遍歷還是有點類似的,同樣是先輸出最左側節點。區別在於,後序遍歷先輸出 右子節點,再輸出父節點。
    同樣使用一個變數,用來輔助遍歷,防止父節點重複遍歷子節點。
    此處的變數,可以理解成上一次節點所在位置。而棧頂取出的當前節點為上一次節點的父節點。

步驟:
    Step1:根節點入棧。
    Step2:取出棧頂元素(當前節點),判斷其是否存在子節點。
        Step2.1:存在左子節點,且未被訪問過,左子節點入棧(此處為遍歷找到最左子節點)。
        Step2.2:存在右子節點,且未被訪問過,右子節點入棧。
        Step2.3:不存在 或者 已經訪問過 左、右子節點,輸出當前節點。
    Step3:重複以上操作,直至棧空。        
    
【非遞迴後序遍歷程式碼實現:】
package com.lyh.tree;

import java.util.ArrayList;
import java.util.List;
import java.util.Stack;

public class BinaryTreeSort<K> {

    /**
     * 後序遍歷(非遞迴實現,使用棧模擬遞迴)
     */
    public List<K> suffixList(TreeNode9<K> root) {
        // 使用集合儲存遍歷結果
        List<K> result = new ArrayList<>();
        if (root == null) {
            return result;
        }

        // 儲存當前節點
        TreeNode9<K> currentNode = root;
        // 使用棧模擬遞迴實現
        Stack<TreeNode9<K>> stack = new Stack<>();
        // 根節點入棧
        stack.push(root);
        // 依次取出棧頂元素
        while(!stack.isEmpty()) {
            // 取出棧頂元素
            TreeNode9<K> tempNode = stack.peek();
            // 若當前節點 左子節點 存在,且未被訪問,則入棧
            if (tempNode.left != null && currentNode != tempNode.left && currentNode != tempNode.right) {
                stack.push(tempNode.left);
            } else if (tempNode.right != null && currentNode != tempNode.right){
                // 若當前節點 右子節點存在,且未被訪問,則入棧
                stack.push(tempNode.right);
            } else {
                // 當前節點不存在 左、右子節點 或者 左、右子節點已被訪問,則取出棧頂元素,
                // 並標註當前節點位置,表示當前節點已被訪問
                result.add(stack.pop().data);
                currentNode = tempNode;
            }
        }
        return result;
    }

    public static void main(String[] args) {
        // 構建二叉樹
        TreeNode9<String> root = new TreeNode9<>("0");
        TreeNode9<String> treeNode = new TreeNode9<>("1");
        TreeNode9<String> treeNode2 = new TreeNode9<>("2");
        TreeNode9<String> treeNode3 = new TreeNode9<>("3");
        TreeNode9<String> treeNode4 = new TreeNode9<>("4");
        root.left = treeNode;
        root.right = treeNode2;
        treeNode.left = treeNode3;
        treeNode.right = treeNode4;

        // 前序遍歷
        System.out.print("後序遍歷: ");
        System.out.println(new BinaryTreeSort<String>().suffixList(root));
        System.out.println("\n=====================");
    }

}

class TreeNode9<K> {
    K data; // 儲存節點資料
    TreeNode9<K> left; // 儲存節點的 左子節點
    TreeNode9<K> right; // 儲存節點的 右子節點

    public TreeNode9(K data) {
        this.data = data;
    }

    @Override
    public String toString() {
        return "TreeNode9{ data= " + data + "}";
    }
}

【輸出結果:】
後序遍歷: [3, 4, 1, 2, 0]

 

2、多叉樹、B樹

(1)平衡二叉樹可能存在的問題
  平衡二叉樹雖然效率高,但是當資料量非常大時(資料存放在 資料庫 或者 檔案中,需要經過磁碟 I/O 操作),此時構建平衡二叉樹會消耗大量時間,影響程式執行速度。同時會出現大量的樹節點,導致平衡二叉樹的高度非常大,此時再去進行查詢操作 效能也不是很高。
  平衡二叉樹中,每個節點有 一個數據項,以及兩個子節點,那麼能否增加 節點的子節點數 以及 資料項 來提高程式效能呢?從而引出了 多路查詢樹 的概念。

注:
  前面介紹了平衡二叉樹,可參考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_9
  即平衡二叉樹只允許每個節點最多出現兩個分支,而此處的多路查詢樹指的是允許出現多個分支(且分支有序)。

(2)多叉樹、多路查詢樹
  多叉樹 允許每個節點 可以有 兩個以上的子節點以及資料項。
  多路查詢樹 即 平衡的多叉樹(資料有序)。
  常見多路查詢樹 有:2-3 樹、B 樹(B-樹)、B+樹、2-3-4 樹 等。

(3)B 樹(B-樹)
  B 樹 即 Balanced-tree,簡稱 B-tree(B 樹、B-樹是同一個東西),是一種平衡的多路查詢樹。
  樹節點的子節點最多的數目稱為樹的階。比如:2-3 樹的階為 3。2-3-4 樹的階為 4。

【一顆 M 階的 B 樹特點:(M 階指的是最大節點的子節點個數)】
    每個節點最多有 M 個子節點(子樹)。
    根節點存在  0 個或者 2 個以上子節點。
    非葉子節點 若存在 j 個子節點,那麼該非葉子節點儲存 j - 1 個數據項,且按照遞增順序儲存。
    所有的葉子節點均在同一層。
注:
    B 樹是一個平衡多路查詢樹,具有與 平衡二叉樹 類似的特點,
    區別在於 B 樹分支更多,從而構建出的樹高度低。
    當然 B 樹也不能無限制的增大 樹的階,階約大,則非葉子節點儲存的資料項越多(變成了一個有序陣列,增加查詢時間)。

 

(4)2-3 樹
  2-3 樹是最簡單的 B 樹,是一顆平衡多路查詢樹。
  其節點可以分為 2 節點、3 節點,且 所有葉子節點均在同一個層。

【2-3 樹特點:】
對於 2 節點:
    只能包含一個數據項 和 兩個子節點(或者沒有子節點)。
    左子節點值 小於 當前節點值,右子節點值 大於 當前節點值。
    不存在只有一個子節點的情況。

對於 3 節點:
    包含一大一小兩個資料項(從小到大排序) 和 三個子節點(或者沒有子節點)。
    左子節點值 小於 當前節點資料項最小值,右子節點值 大於 當前節點資料項最大值,中子節點值 在 當前節點資料項值之間。
    不存在有 1 子節點、2 個子節點的情況。

根據 {16, 24, 12, 32, 14, 26, 34, 10, 8, 28, 38, 20, 33} 構建的 2-3 樹如下:
可使用 https://www.cs.usfca.edu/~galles/visualization/Algorithms.html 構建。

 

 

 

 

(5)B+ 樹
  B+ 樹是 B 樹的變種。
  區別在於 B+ 樹資料儲存在葉子節點,資料最終只能在 葉子節點 中找到,而 B 樹可以在 非葉子節點 找到。
  B+ 樹效能可以等價於 對 全部葉子節點(所有關鍵字)進行一次 二分查詢。

【B+ 樹特點:】
   所有 資料項(關鍵字) 均存放於 葉子節點。
   每個葉子節點 存放的 資料項(關鍵字)是有序的。
   所有葉子節點使用連結串列相連(即進行範圍查詢時,只需要查詢到 首尾節點、然後遍歷連結串列 即可)。
注:
    所有資料項(關鍵字) 均存放與 葉子節點組成的連結串列中,且資料有序,可以視為稠密索引。
    非葉子節點 相當於 葉子節點的索引,可以視為 稀疏索引。

根據 {16, 24, 12, 32, 14, 26, 34, 10, 8, 28, 38, 20, 33} 構建的 B+ 樹(3階、2-3 樹)如下:

 

 

 

 

(6)B* 樹
  B* 樹 是 B+ 樹的變體。
  其在 B+樹 基礎上,在除 非根節點、非葉子節點 之外的其餘節點之間增加指標,提高節點利用率。

【B* 樹與 B+ 樹 節點分裂的區別:】
對於 B+ 樹:
    B+ 樹 節點的最低使用率是 1/2,其非葉子節點關鍵字(資料項)個數至少為 (1/2)*M。M 為 B+ 樹的階。
    當一個節點存放滿時,會增加一個節點,並將原節點 1/2 的資料移動到新的節點,然後在 父節點 新增新的節點。
    B+ 樹 隻影響 原節點 以及 父節點,不會影響兄弟節點,兄弟之間不需要指標。
    
對於 B* 樹:
    B* 樹 節點的最低使用率為 2/3,其非葉子節點關鍵字(資料項)個數至少為 (2/3)*M。
    當一個節點存放滿時,若其下一個兄弟節點未滿,則將一部分資料移到兄弟節點中,在原節點 新增新節點,然後修改 父節點 中的節點(兄弟節點發生改變)。
    若其下一個兄弟已滿,則在 兩個兄弟之間 增加一個新節點,並分別從兩個兄弟節點中 移動 1/3 的資料到新節點,然後在 父節點 新增新的節點。
    B* 樹 影響了 兄弟節點,所以需要指標將兄弟節點連線起來。
    
總的來說,B* 樹分配新節點的概率比 B+ 樹低,B* 樹的節點利用率更高。
    
注:
    相關內容參考:https://blog.csdn.net/wyqwilliam/article/details/82935922

下圖不一定正確,大概理解意思就行。

 

 

(7)B-樹、B+樹、B*樹總結

【B 樹 或者 B- 樹:】
    平衡的多路查詢樹,非葉子節點至少儲存 (1/2)*M 個關鍵字(資料項),
    關鍵字升序儲存,且僅出現一次,
    進行查詢匹配操作時,可以在 非葉子節點 成功匹配。
    
【B+ 樹:】
    B 樹的變種,僅在 葉子節點 儲存資料項,且葉子節點之間 通過連結串列儲存。
    整體 資料項 有序儲存。
    非葉子節點 作為 葉子節點 的索引存在,匹配時通過 非葉子節點 快速定位到 葉子節點,然後在 葉子節點 處進行匹配操作,相當於進行 二分查詢。
    
【B* 樹:】
    B+ 樹的變種,給 非葉子節點 也加上指標,非葉子節點 至少儲存 (2/3)*M 個關鍵字。
    將節點利用率 從 1/2 提高到 2/3 。

 

二、延伸一下 MySQL 索引底層資料結構

1、索引(Index)

(1)索引是什麼?
  索引是一種有序的、快速查詢的資料結構。
  索引 由 若干個 索引項組成,每個索引項 由 資料的關鍵字 以及 其相對應的記錄(比如:記錄對應在磁碟中的 地址資訊)組成。
  索引的查詢,就是根據 索引項中的關鍵字 去關聯 其相應的記錄 的過程。

(2)資料庫為什麼使用索引?
  為了提高資料查詢效率,資料庫在維護資料的同時維護一個滿足特定查詢演算法的資料結構,這個資料結構以某種方式指向資料、或者儲存資料的引用,通過這個資料結構實現高階查詢演算法,這樣就可以快速查詢資料。
  而這種資料結構就是索引。
  索引按照結構劃分為:線性索引、樹形索引、多級索引。

 

如下圖所示資料結構:(樹形索引,僅供參考,圖片來源於網路)
使用二叉樹維護資料的索引值以及資料的實體地址,使用二叉樹可以在一定的時間複雜度內查詢到資料,然後根據該資料的實體地址找到儲存在表中的資料,從而實現快速查詢。

 

 

2、線性索引(稠密索引、稀疏索引)

(1)什麼是線性索引?
  線性索引 指的是 將索引項組合成線性結構,也可稱為索引表。
  常見分類:稠密索引(密集索引)、稀疏索引(分塊索引)、倒排索引。

(2)稠密索引(密集索引)
  稠密索引 指的線性結構是:每個索引項 對應一個數據集(記錄),記錄在資料區(磁碟)中可以是無序的,但是所有索引項 是有序的(方便查詢)。
  但由於每個索引項佔用的空間較大,若資料量較大時(每個索引項對應一個記錄),佔用空間會很大(可能無法一次在記憶體中讀取,需要多次磁碟 I/O,降低查詢效能)。
  即 佔用空間大、查詢效率高。

 

如下圖(圖片來源於網路):
左邊索引表 中的索引項 按照關鍵碼有序,可以使用 二分查詢 或者其他高效查詢演算法,快速定位到對應的索引項,然後找到對應的 記錄。

 

 

注:
  前面介紹的 B+ 樹的所有葉子節點可以看成是 稠密索引,其所有葉子節點 由連結串列連線,且葉子節點有序,可以應用上 稠密索引。

(3)稀疏索引(分塊索引)
  稠密索引 其每個索引項 對應一個記錄,佔用空間大。
  稀疏索引 指的線性結構是:將資料集按照某種方式 分成若干個資料塊,每個索引項 對應一個數據塊。每個資料塊可以包含多個數據(記錄),這些資料之間可以是無序的。但 資料塊之間是有序的(索引項有序)。
  索引項無需儲存 所有記錄,只需要記錄關鍵字即可,佔用空間小。且索引項有序,可以快速定位到資料塊。但是 資料塊內沒要求是有序的(維護有序序列需要付出一些代價),所以資料塊中可能順序查詢(資料量較大時,查詢效率較低)。
  即 佔用空間小、查詢效率可能較低。

 

如下圖(圖片來源於網路):
左邊索引表 按照關鍵碼有序,可以通過 二分查詢 等演算法快速定位到 資料塊,然後在資料塊中查詢資料。

 

 

注:
  前面介紹的 B+ 樹中 非葉子節點 與 葉子節點 之間可以看成 稀疏索引,非葉子節點 僅儲存 葉子節點的索引,葉子節點 儲存 資料塊。且此時 多個數據塊之間 有序、每個資料塊 之內也有序。

3、MySQL 索引底層資料結構

(1)底層資料結構
  MySQL 底層資料結構,一般回答都是 B+ 樹。
  那麼為什麼選擇 B+ 樹?雜湊、二叉樹、B樹 等結構不可以嗎?

(2)為什麼不使用 雜湊表 作為索引?

【常用快速查詢的資料結構有兩種:】
雜湊表:
    比如 HashMap,其查詢、新增、刪除、修改的平均時間複雜度均為 O(1)

樹:
    比如 平衡二叉樹,其查詢、新增、刪除、修改的平均時間複雜度均為O(logn)
    
【什麼是雜湊表?】
    雜湊表(Hash table 、散列表),是根據鍵(Key)直接訪問資料(Value)的一種資料結構。
規則:
   使用某種方式(對映函式)將鍵值(Key)對映到陣列中的某個位置,並在此位置存放記錄,用於加快查詢速度。
   對映函式 也稱為 雜湊函式,存放記錄的陣列 稱為 散列表。
   
理解:
    使用 雜湊函式,將 鍵值(Key)轉換為一個 整型數字,
    然後再對數字進行轉換(取模、與運算等),將其轉為 陣列對應的下標,並將 value 儲存在該下標對應的儲存空間中。
    而進行查詢操作時,再次對 Key 進行運算,轉換為對應的陣列下標,即可定位並獲取 value 值(時間複雜度為 O(1))。
        
【為什麼不使用 雜湊表?】
    對於 單次寫操作或者讀操作 來說,雜湊的速率比樹快,但是為什麼不用雜湊表呢?

    可以想一下如果是排序或者範圍查詢的情況下,執行雜湊是什麼情況,很顯然,雜湊無法很快的進行範圍查詢(其資料都是無序的),查詢範圍 0~n 的情況下,會執行 n 次查詢,也即時間複雜度為 O(n)。
    
    而樹(AVL樹、B樹、B+樹等)是有序的(1、2 次查詢即可),其時間複雜度仍可以保證在 O(logn)。
    
    相比較之下,雜湊肯定沒有樹的效率高,因此不會使用雜湊這種資料結構作為索引。
    
【平衡二叉樹時間複雜度 O(logn) 怎麼來的?】
    在樹中查詢一個數字時,第一次在樹的第一層(根節點)判斷,第二次在樹的第二層判斷,依次類推,樹有多少層,就會進行多少次判斷,即對於 k 層的樹,最壞時間複雜度為O(k)。
    所以只需要知道 n 個節點的樹有多少層即可。

    若為滿二叉樹(除葉子節點外,每個節點均有兩個節點),則對於第一層,有一個節點(2^0),對於第二層有兩個節點(2^1),依次類推對於第 k 層有 2^k-1(2 的 k-1 次方)。
    所以 n = 2^0 + 2^1 + ... + 2^k-1,從而 k = log(n + 1)。
    所以時間複雜度為 O(k) = O(logn)     k 為樹 層數,n 為樹 節點數。

 

(3)為什麼不使用二叉查詢樹(BST)、平衡二叉樹(AVL)?
  通過上面分析,可以使用樹作為 索引(解決了範圍、排序等問題),但是樹有很多種類,比如:二叉查詢樹(BST)、平衡二叉樹(AVL)、B 樹、B+樹等。應該選擇哪種樹作為索引呢?

  對於二叉查詢樹,由於左子節點小於當前節點,右子節點大於當前節點,當一個數據是有序的時候,即資料要麼遞增,要麼遞減,此處二叉樹出現如下圖所示情況,相當於所有節點組成了鏈式結構,此時時間複雜度從 O(logn) 變為 O(n)。隨著資料量增大,n 肯定非常大,這種情況下肯定不可取,捨棄。
  二叉查詢樹可參考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_8

 

 

 

 

  為了降低樹的高度,引出了 平衡二叉樹,其可以動態的維護樹的高度,使任意一個節點左右子樹高度差絕對值不大於 1。

  對於平衡二叉查詢樹(AVL),新增節點時,會不斷的調整節點位置以及樹的高度。但隨著資料量增大,樹的高度也會增大,高度增大導致比較次數增多,若資料 無法一次讀取到記憶體中,則每次比較前都得通過磁碟 IO 讀取外存資料,導致磁碟 IO 增大,影響效能。
  二叉平衡樹可參考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_9

 

  通過上面分析,二叉查詢樹可能出現 只有左子樹或者只有右子樹的情況,當 資料量過大時,樹的高度會變得很高,此時時間複雜度從 O(logn) 變為 O(n),n 為 樹的高度。

  為了解決這種情況,可以使用平衡二叉查詢樹,其會在左右子樹高度差大於 1 時對樹節點進行旋轉,保證樹之間的高度差,從而解決二叉查詢樹的問題,但是資料量過大時,樹的高度依舊會很大,增大磁碟 IO,影響效能。

  所以為了解決樹的高度問題,既然 二叉平衡樹 不能滿足需求,那就採用多叉平衡樹,讓一個節點儲存多個數據(兩個以上子樹),進一步降低樹的高度。從而引出 B 樹、B+樹。

 

(4)AVL 樹、B樹、B+樹 舉例:
  構建樹,並按照順序插入 1 - 10,若查詢 10 這個數,需要比較幾次?

AVL 樹構建如下:
  樹總高度為 4,而 10 在葉子節點,所以需要比較 4 次。

 

 

B 樹構建如下:
  樹高度為 3 ,10 在葉子節點,此時只需要比較 3 次即可。
  但對於 AVL,需要比較 4 次,隨著資料量增大,B 樹 明顯比 AVL 高度低。

 

 

B+ 樹構建如下:
  樹高度為 4,10 在葉子節點,此時需要比較 4 次。
  B+ 樹比 B 樹更適合範圍查詢。

 

 

(5)為什麼不使用 B 樹 而使用 B+ 樹?
  通過上面分析,可以知道 平衡二叉樹不能 滿足實際的需求(資料量大時,樹高度太大,且可能需要與磁碟進行多次 I/O 操作,查詢效率低)。
  那麼 B 樹能否滿足需求呢?B 樹的定義參考前面的分析。

 

  理論上,B 樹可以增加 每個節點儲存的資料項 以及 節點的子節點數,並達到平衡樹的條件,從而降低樹的高度。但是不能無限制的 增大,B 樹階越大,那麼每個節點 就可能成為 有序陣列,則每次查詢時效率反而會降低。

  在 InnoDB 中,索引是儲存元素的,一個表的資料 行數、列數 越多,那麼相對應的索引檔案就會很大。其不可能一次存放在記憶體中,需要經過多次磁碟 I/O。所以考慮 資料結構時,需要判斷哪種資料結構更適合從磁碟中讀取資料,減少磁碟 I/O 次數,從而提高磁碟 I/O 效率。

 

  假定每次讀取樹的節點 都是 一次 磁碟 I/O,那麼樹的高度 將是決定 磁碟 I/O 的關鍵因素。


  通過上面 AVL樹、B樹、B+樹 的舉例,可以看到 AVL 樹由於每個節點只能儲存兩個元素,資料量大時,樹的高度將會很大。

  那麼 B樹、B+樹 如何選擇呢?

 

  B 樹由於 非葉子節點也會存放完整資料,則 B樹 每個非葉子節點 存放的 元素總數 受到資料的影響,也即 每個非葉子節點 存放的 元素 較少,從而導致樹的高度 也會很大。
  B+ 樹由於 非葉子節點 不存放完整資料(存放主鍵 + 指標),其完整資料存放在 葉子節點中,也即 非葉子節點 可以存放 更多的 元素,從而樹的高度可以 很低。

  通過上面分析,可以知道 B+ 樹的高度很低,可以減少磁碟 I/O 的次數,提高執行效率。且 B+ 樹所有葉子節點之間通過連結串列連線,其可以提高範圍查詢的效率。
  所以 一般採用 B+ 樹作為索引結構。

 

(6)總結
  使用 B+ 樹作為索引結構可以 減少磁碟 I/O 次數,提高查詢效率。
  B+ 樹實際應用場景一般高度為 3(見下面分析,若一條記錄為 1 KB,那麼高度為 3 的 B+樹 可以儲存 2000 多萬條資料)。

 

4、區域性性原理、磁碟預讀、B+樹每個節點適合存多少資料

(1)區域性性原理 與 磁碟預讀
  區域性性原理 指的是 當一個數據被使用時,那麼其附近的資料通常也會被使用。
  在 InnoDB 中,資料儲存在磁碟上,而直接操作磁碟 I/O 操作會很耗時(比操作記憶體中的資料慢),降低效率。
  為了提高效率、降低磁碟 I/O 次數,在真正處理資料前 先要將資料 從磁碟中讀取並載入到 記憶體中。
  若每次只從 磁碟 讀一條資料到 記憶體中,那麼效率肯定很低。所以作業系統一般採用 磁碟預讀的形式,一次讀取 指定長度的資料進入記憶體(即使不需要使用到這麼多資料,區域性性原理)。此處指定長度稱為 頁,是作業系統操作資料的基本單位,作業系統中 頁的大小一般為 4KB。

(2)B+樹中 一個節點儲存多少資料合適?
  進行磁碟預讀時,將資料劃分成若干個頁,以 頁 作為 磁碟 與 記憶體 互動的基本單位,InnoDB 預設頁大小是 16 KB(類似於作業系統頁的定義,若作業系統頁大小為 4KB,那麼 InnoDB 中 1頁 等於 作業系統 4頁),即每次最少從磁碟中讀取 16KB 資料到記憶體,最少從 記憶體寫入 16KB 資料到磁碟。

  B+ 樹每個節點 存放 一頁、或者 頁的倍數比較合適。(假設每次讀取節點均會經過磁碟 I/O)
  以一頁為例,如果節點儲存小於 一頁,那麼讀取這個節點時仍然會讀出一頁,從而造成資源的浪費。而如果節點儲存大於 一頁小於二頁,那麼讀取這個節點時將會讀出 二頁,同樣也會造成資源的浪費。所以,一般 B+樹 節點存放資料為 一頁 或者 頁的倍數。

【檢視 InnoDB 預設頁大小:】
    SHOW GLOBAL STATUS like 'Innodb_page_size';

 

 

(3)為什麼 InnoDB 設定預設頁大小為 16KB?而不是 32KB?

【首先明確一點:】
    B+ 樹 非葉子節點儲存的是 主鍵(關鍵字)+ 指標(指向葉子節點)。
    B+ 樹 葉子節點儲存的是 資料(真實的資料記錄)。
    假設每次讀取一個節點均會執行一次磁碟 I/O,即每個節點大小為頁的大小。

【以節點大小為 16KB 為例:】
假設一行資料大小為 1KB,那麼一個葉子節點能儲存 16 條記錄。
假設非葉子節點主鍵為 bigint 型別,那麼長度為 8B,而指標在 InnoDB 中大小為 6B,即一個非葉子節點能儲存 16KB / 14B = 1170 個數據(主鍵 + 指標)。
那麼對於 高度為 2 的 B+樹,能儲存記錄數為: 1170 * 16 = 18720 條。
對於 高度為 3 的 B+樹,能儲存記錄數為:1170 * 1170 * 16 = 21902400 條。

也就是說,若頁大小為 16KB,那麼高度為 3 的 B+ 樹就能支援 2千萬的資料儲存。
當然若頁大小更大,樹的高度也會低,但是一般沒有必要去修改。

讀取一個節點需要經過一次磁碟 I/O,那麼根據主鍵 只需要 1-3 次磁碟 I/O 即可查詢到資料,能滿足絕大部分需求。

 

5、MySQL 表儲存引擎 MyISAM 與 InnoDB 區別?

(1)MySQL 採用 外掛式的表儲存引擎 管理資料,基於表而非基於資料庫。在 MySQL 5.5 版本前預設使用 MyISAM 為預設儲存引擎,在 5.5 版本後採用 InnoDB 作為預設儲存引擎。

(2)MyISAM 不支援外來鍵、不支援事務,支援表級鎖(即每次操作均會對整個表加鎖,不適合高併發操作)。會儲存表的總行數,佔用表空間小,多用於 讀操作多 的場合。只快取索引但不快取真實資料。

(3)InnoDB 支援外來鍵、支援事務,支援行級鎖。不儲存表的總行數,佔用表空間大,多用於 寫操作多 的場合。快取索引的同時快取真實資料,對記憶體要求較高(記憶體大小影響效能)。

(4)底層索引實現:
  MyISAM 使用 B+樹作為 索引結構,但是其 索引檔案 與 資料檔案是 分開的,其葉子節點 存放的是 資料記錄的地址,也即根據索引檔案 找到 對應的資料記錄的地址後,再去獲取相應的資料。

  InnoDB 使用 B+樹作為 索引結構,但是其 索引檔案本身就是 資料檔案,其葉子節點 存放的就是 完整的資料記錄。InnoDB 必須要有主鍵,如果沒有顯示指定,系統會預設選擇一個能夠唯一標識資料記錄的列作為主鍵,如果不存在這樣的鍵,系統會給表生成一個隱含欄位作為主鍵。

注:
  InnoDB 中一般使用 自增的 id 作為主鍵,每插入一條記錄,相當於增加一個節點,如果主鍵是順序的,那麼直接新增在上一個記錄後即可,若當前頁滿後,在新的頁中繼續儲存。
  若主鍵無序,那麼在插入資料的過程中,可能或出現在 所有葉子節點任意位置,若出現在所有葉子節點頭部,那麼將會導致所有葉子節點均向後移一位,涉及到 頁的分裂以及資料的移動,是一種耗時操作、且造成大量記憶體碎片,影響效率。

 

6、索引的代價 與 選擇

(1)索引的代價:
空間上:
  一個索引 對應一顆 B+ 樹,樹的每個節點都是一個數據頁,一個數據頁佔用大小為 16KB 的儲存空間,資料量越大,佔用的空間也就越大。

時間上:
  索引會根據資料進行排序,當對資料表資料進行 增、刪、改 操作時,相應的 B+ 樹索引也要去維護,會消耗時間 進行 記錄移動、頁面分裂、頁面回收 等操作,並維護 資料有序。

(2)索引的選擇:
索引的選擇性:
  指的是 不重複索引值(基數)與 表記錄總數 的比值(選擇性 = 不重複索引值 / 表記錄總數)。
  範圍為 (0, 1],選擇性 越大,即不重複索引值 越多,則建立索引的價值越大。
  選擇性越小,即 重複索引值 越多,那麼索引的意義不大。

索引選擇:
  索引列 型別應儘量小。
  主鍵自增。

 

三、圖

1、圖的基本介紹

(1)圖是什麼?
  圖用來描述 多對多關係 的一種資料結構。
  上一篇介紹了 一對一 的資料結構(比如:單鏈表、佇列、棧等)以及 一對多的資料結構(比如:樹),參考連結:https://www.cnblogs.com/l-y-h/p/13751459.html 。
  為了解決 多對多 關係,此處引入了 圖 這種資料結構。

(2)圖的基本概念
  圖是一種資料結構,其每個節點可以具有 零個 或者 多個相鄰的元素。
  兩個節點之間的連線稱為邊(edge),節點可稱為頂點(vertex)。
  從一個節點到另一個節點 所經過的邊稱為 路徑。
  即 圖由若干個頂點以及 頂點之間的邊 組合而成。

圖按照邊可以分為:
  無向圖。指的是 頂點之間的連線(邊) 沒有方向的圖。
  有向圖。指的是 邊 有方向的圖。
  帶權圖。指的是 邊 帶有權值的圖。

 

 

(3)圖的表示方式
  圖的表示形式一般有兩種:鄰接矩陣(二維陣列表示)、鄰接表(連結串列)。
鄰接矩陣:
  使用 一維陣列 記錄圖中 頂點資料,使用 二維陣列 記錄圖中 頂點之間的相鄰關係(邊)。對於 n 個頂點的圖,使用 n*n 的二維陣列記錄 邊的關係。

 

 

鄰接表:
  使用 陣列 + 連結串列的形式 記錄 各頂點 以及頂點之間的 相鄰關係(只記錄存在的邊)。
  使用 一維陣列 記錄圖中 頂點資料,使用連結串列記錄 存在的邊。

 

 

鄰接表 與 鄰接矩陣區別:
  鄰接矩陣中 需要為 每個頂點 記錄 n 個邊,其中很多邊不存在(無需記錄),造成空間的浪費。
  鄰接表只 記錄存在的邊,不會造成空間的浪費。

 

(4)使用 鄰接矩陣 形式構建 無向圖:

【構建思路:】
    使用 一維陣列 記錄 圖的頂點資料。
    使用 二維陣列 記錄 圖的各頂點的聯絡(邊,其中 1 表示存在邊,0 表示不存在邊)。

【程式碼實現:】
package com.lyh.chart;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * 使用 鄰接矩陣 形式構建無向圖
 */
public class UndirectedGraph {
    private List<String> vertexs; // 用於儲存 無向圖 的頂點資料(可以使用一維陣列)
    private int[][] edges; // 用於儲存 無向圖 中各頂點之間的關係,1 表示兩頂點之間存在邊,0 表示不存在邊
    private int numberOfEdges; // 用於記錄 無向圖中邊的個數

    /**
     * 根據 頂點個數 進行初始化
     * @param number 頂點個數
     */
    public UndirectedGraph(int number) {
        vertexs = new ArrayList<>(number); // 用於記錄頂點
        edges = new int[number][number]; // 用於記錄頂點之間的關係
        numberOfEdges = 0; // 用於記錄邊的個數
    }

    /**
     * 新增頂點
     * @param vertex 頂點
     */
    public void insertVertex(String vertex) {
        vertexs.add(vertex);
    }

    /**
     * 新增邊
     * @param row 行
     * @param column 列
     * @param value 值(1 表示存在邊,0表示不存在邊)
     */
    public void insertEdge(int row, int column, int value) {
        edges[row][column] = value; // 設定邊
        edges[column][row] = value; // 設定邊,對稱
        numberOfEdges++; // 邊總數加 1
    }

    /**
     * 返回邊的總數
     * @return 邊的總數
     */
    public int getNumberOfEdges() {
        return numberOfEdges;
    }

    /**
     * 返回頂點的總數
     * @return 頂點總數
     */
    public int getNumberOfVertex() {
        return vertexs.size();
    }

    /**
     * 返回 下標對應的頂點資料
     * @param index 頂點下標
     * @return 頂點資料
     */
    public String getValueByIndex(int index) {
        return vertexs.get(index);
    }

    /**
     * 輸出鄰接矩陣
     */
    public void showGraph() {
        for (int[] row : edges) {
            System.out.println(Arrays.toString(row));
        }
    }

    public static void main(String[] args) {
        // 初始化無向圖
        UndirectedGraph undirectedGraph = new UndirectedGraph(5);
        // 插入頂點資料
        String[] vertexs = new String[]{"A", "B", "C", "D", "E"};
        for (String vertex : vertexs) {
            undirectedGraph.insertVertex(vertex);
        }
        // 插入邊
        undirectedGraph.insertEdge(0, 1, 1); // A-B
        undirectedGraph.insertEdge(0, 2, 1); // A-C
        undirectedGraph.insertEdge(1, 2, 1); // B-C
        undirectedGraph.insertEdge(1, 3, 1); // B-D
        undirectedGraph.insertEdge(1, 4, 1); // B-E

        // 輸出
        System.out.println("無向圖頂點總數為: " + undirectedGraph.getNumberOfVertex());
        System.out.println("無向圖邊總數為: " + undirectedGraph.getNumberOfEdges());
        System.out.println("無向圖第 3 個頂點為: " + undirectedGraph.getValueByIndex(2));
        System.out.println("無向圖 鄰接矩陣為: ");
        undirectedGraph.showGraph();
    }
}

【輸出結果為:】
無向圖頂點總數為: 5
無向圖邊總數為: 5
無向圖第 3 個頂點為: C
無向圖 鄰接矩陣為: 
[0, 1, 1, 0, 0]
[1, 0, 1, 1, 1]
[1, 1, 0, 0, 0]
[0, 1, 0, 0, 0]
[0, 1, 0, 0, 0]

 

(5)圖的遍歷方式:
  圖的遍歷,即對頂點的訪問,一般遍歷頂點有兩種策略:DFS、BFS。
  DFS 為深度優先遍歷,可以聯想到 樹的 先序、中序、後序 遍歷。即 縱向訪問 節點。
  BFS 為廣度優先遍歷,可以聯想到 樹的 順序(層序)遍歷,即 橫向分層 訪問 節點。

 

 

2、深度優先遍歷(DFS)

(1)DFS
  DFS 指的是 Depth First Search,即 深度優先搜尋。
  其從一個節點出發,優先訪問該節點的第一個鄰接節點,並將此鄰接節點作為新的節點,繼續訪問其第一個鄰接節點(為了防止重複訪問同一節點,可以將節點分為 已訪問、未訪問 兩種狀態,若節點已訪問,則跳過該節點)。
  深度優先搜尋是一個遞迴的過程(可以使用棧模擬遞迴實現),每次訪問當前節點的第一個鄰接節點。

(2)DFS 步驟 與 程式碼實現:

【步驟:】
Step1:訪問初始節點 start,標記該節點 start 已訪問。
Step2:查詢節點 start 的第一個鄰接節點 neighbor。
  Step2.1:若 neighbor 不存在,則返回 Step1,且從 start 下一個節點繼續執行。
  Step2.2:若 neighbor 存在,且未被訪問,則返回 Step1,且將 neighbor 視為新的 start 執行。
  Step2.3:若 neighbor 存在,且已被訪問,則返回 Step2,且從 neighbor 下一個節點繼續執行。

【舉例:】
圖的 鄰接矩陣表示如下:,圖各頂點 按照順序為 A B C D E。
      A  B  C  D  E
  A  [0, 1, 1, 0, 0]
  B  [1, 0, 1, 1, 1]
  C  [1, 1, 0, 0, 0]
  D  [0, 1, 0, 0, 0]
  E  [0, 1, 0, 0, 0]
注:
    1 表示兩個頂點間存在邊,0 表示不存在邊。

則遍歷過程如下:(整個過程是縱向的)
Step1:從 A 開始遍歷,將 A 標記為 已訪問。找到 A 的 第一個鄰接節點 B。
Step2:B 未被訪問,將 B 視為新的節點開始遍歷,將 B 標記為已訪問,找到 B 的第一個鄰接節點 A。
Step3:A 被訪問過,繼續查詢 B 下一個鄰接節點為 C。
Step4:C 未被訪問過,將 C 視為新節點開始遍歷,將 C 標記為已訪問,找到 C 的第一個鄰接節點 A。
Step5:A 被訪問,繼續查詢 C 下一個鄰接節點為 B,B 也被訪問,繼續查詢,C 沒有鄰接節點,回退到上一層 B。
Step6:繼續查詢 B 下一個鄰接節點為 D,將 D 標記已訪問,同理可知 D 沒有 未被訪問的鄰接頂點,回退到上一層 B。
Step7:查詢 B 下一個鄰接節點為 E,將 E 標記已訪問,至此遍歷完成。
即順序為:A -> B -> C -> D -> E

【程式碼實現:】
package com.lyh.chart;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * 使用 鄰接矩陣 形式構建無向圖
 */
public class UndirectedGraph {
    private List<String> vertexs; // 用於儲存 無向圖 的頂點資料(可以使用一維陣列)
    private int[][] edges; // 用於儲存 無向圖 中各頂點之間的關係,1 表示兩頂點之間存在邊,0 表示不存在邊
    private int numberOfEdges; // 用於記錄 無向圖中邊的個數
    private boolean[] isVisit; // 用於記錄 頂點是否被訪問,true 表示已訪問

    /**
     * 根據 頂點個數 進行初始化
     * @param number 頂點個數
     */
    public UndirectedGraph(int number) {
        vertexs = new ArrayList<>(number); // 用於記錄頂點
        edges = new int[number][number]; // 用於記錄頂點之間的關係
        numberOfEdges = 0; // 用於記錄邊的個數
        isVisit = new boolean[number]; // 用於記錄頂點是否被訪問
    }

    /**
     * 新增頂點
     * @param vertex 頂點
     */
    public void insertVertex(String vertex) {
        vertexs.add(vertex);
    }

    /**
     * 新增邊
     * @param row 行
     * @param column 列
     * @param value 值(1 表示存在邊,0表示不存在邊)
     */
    public void insertEdge(int row, int column, int value) {
        edges[row][column] = value; // 設定邊
        edges[column][row] = value; // 設定邊,對稱
        numberOfEdges++; // 邊總數加 1
    }

    /**
     * 返回邊的總數
     * @return 邊的總數
     */
    public int getNumberOfEdges() {
        return numberOfEdges;
    }

    /**
     * 返回頂點的總數
     * @return 頂點總數
     */
    public int getNumberOfVertex() {
        return vertexs.size();
    }

    /**
     * 返回 下標對應的頂點資料
     * @param index 頂點下標
     * @return 頂點資料
     */
    public String getValueByIndex(int index) {
        return vertexs.get(index);
    }

    /**
     * 輸出鄰接矩陣
     */
    public void showGraph() {
        for (int[] row : edges) {
            System.out.println(Arrays.toString(row));
        }
    }

    /**
     * 獲取下一個頂點的下標
     * @param row 行
     * @param column 列
     * @return 下一個鄰接頂點的下標(-1 表示不存在下一個鄰接頂點)
     */
    public int getNeighborVertexIndex(int row, int column) {
        for (int index = column + 1; index < vertexs.size(); index++) {
            if (edges[row][index] != 0) {
                return index;
            }
        }
        return -1;
    }

    /**
     * 返回當前頂點 的第一個鄰接頂點的下標
     * @param index 當前頂點下標
     * @return 第一個鄰接頂點的下標(-1 表示不存在鄰接頂點)
     */
    public int getFirstVertextIndex(int index) {
        return getNeighborVertexIndex(index, -1);
    }

    /**
     * 深度優先遍歷
     */
    public void dfs() {
        // 未被訪問的頂點,進行深度優先遍歷
        for (int index = 0; index < vertexs.size(); index++) {
            if (!isVisit[index]) {
                dfs(index);
            }
        }
    }

    /**
     * 深度優先遍歷
     * @param index 頂點下標
     */
    private void dfs(int index) {
        // 輸出當前頂點資料
        System.out.print(getValueByIndex(index) + " ==> ");
        // 標記當前頂點為 已訪問
        isVisit[index] = true;
        // 獲取當前頂點第一個鄰接頂點下標
        int neighborIndex = getFirstVertextIndex(index);
        // 當下一個鄰接頂點存在時
        while(neighborIndex != -1) {
            // 若鄰接頂點未被訪問,則遞迴遍歷
            if (!isVisit[neighborIndex]) {
                dfs(neighborIndex);
            } else {
                // 若鄰接頂點已被訪問,則訪問當前鄰接頂點的下一個鄰接頂點
                neighborIndex = getNeighborVertexIndex(index, neighborIndex);
            }
        }
    }

    public static void main(String[] args) {
        // 初始化無向圖
        UndirectedGraph undirectedGraph = new UndirectedGraph(5);
        // 插入頂點資料
        String[] vertexs = new String[]{"A", "B", "C", "D", "E"};
        for (String vertex : vertexs) {
            undirectedGraph.insertVertex(vertex);
        }
        // 插入邊
        undirectedGraph.insertEdge(0, 1, 1); // A-B
        undirectedGraph.insertEdge(0, 2, 1); // A-C
        undirectedGraph.insertEdge(1, 2, 1); // B-C
        undirectedGraph.insertEdge(1, 3, 1); // B-D
        undirectedGraph.insertEdge(1, 4, 1); // B-E

        // 輸出
        System.out.println("無向圖頂點總數為: " + undirectedGraph.getNumberOfVertex());
        System.out.println("無向圖邊總數為: " + undirectedGraph.getNumberOfEdges());
        System.out.println("無向圖第 3 個頂點為: " + undirectedGraph.getValueByIndex(2));
        System.out.println("無向圖 鄰接矩陣為: ");
        undirectedGraph.showGraph();

        System.out.println("深度優先遍歷結果為: ");
        undirectedGraph.dfs();
    }
}

【輸出結果:】    
無向圖頂點總數為: 5
無向圖邊總數為: 5
無向圖第 3 個頂點為: C
無向圖 鄰接矩陣為: 
[0, 1, 1, 0, 0]
[1, 0, 1, 1, 1]
[1, 1, 0, 0, 0]
[0, 1, 0, 0, 0]
[0, 1, 0, 0, 0]
深度優先遍歷結果為: 
A ==> B ==> C ==> D ==> E ==>

 

3、廣度優先遍歷(BFS)

(1)BFS
  BFS 指的是 Broad First Search,即廣度優先搜尋。
  其類似於 分層搜尋的過程,依次訪問各層 的節點。可以使用佇列來記錄 訪問過的節點的順序,用於按照該順序來訪問 這些節點的鄰接節點。

(2)BFS 步驟、程式碼實現

【步驟:】
Step1:訪問初始節點 start,並標記為 已訪問,start 入佇列。
Step2:迴圈取出佇列,若佇列為空,則結束迴圈,否則執行下面步驟。
Step3:取得佇列頭部節點,即為 first,並查詢 first 的第一個鄰接節點 neighbor。
  Step3.1:若 neighbor 不存在,則返回 Step2,再取出佇列 新的頭節點。
  Step3.2:若 neighbor 存在,且未被訪問,則將其標記為 已訪問併入佇列。
  Step3.3:若 neighbor 存在,且已被訪問,則返回 Step3,並查詢 neighbor 的下一個節點。
注:
    Step3 將某一層 未訪問的節點 入佇列,當該層頂點全部被訪問時,執行 Step2,
    從佇列中取出 頭部節點,即為 新的層,並開始查詢未被訪問的節點入佇列。

【舉例:】
圖的 鄰接矩陣表示如下:,圖各頂點 按照順序為 A B C D E。
      A  B  C  D  E
  A  [0, 1, 1, 0, 0]
  B  [1, 0, 1, 1, 1]
  C  [1, 1, 0, 0, 0]
  D  [0, 1, 0, 0, 0]
  E  [0, 1, 0, 0, 0]
注:
    1 表示兩個頂點間存在邊,0 表示不存在邊。

則遍歷過程如下:(整個過程是橫向分層的)
Step1:從 A 開始,將其標記為 已訪問,將 A 存入佇列末尾。
Step2:取出佇列頭部元素為 A,查詢其鄰接節點為 B,B 未被訪問將其入佇列、並標記為 已訪問。
Step3:繼續查詢 A 下一個鄰接節點為 C,C 為被訪問將其入佇列、並標記為 已訪問。
Step4:A 層遍歷結束,取出佇列頭部為元素為 B,即開始訪問 B 層。
Step5:B 層未被訪問的節點 依次入佇列並標記為已訪問。即 D、E 入佇列。
Step6:同理依次取出佇列頭部元素 C、D、E,直至遍歷完成。
即順序為:A -> B -> C -> D -> E

【程式碼實現:】
package com.lyh.chart;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;

/**
 * 使用 鄰接矩陣 形式構建無向圖
 */
public class UndirectedGraph {
    private List<String> vertexs; // 用於儲存 無向圖 的頂點資料(可以使用一維陣列)
    private int[][] edges; // 用於儲存 無向圖 中各頂點之間的關係,1 表示兩頂點之間存在邊,0 表示不存在邊
    private int numberOfEdges; // 用於記錄 無向圖中邊的個數
    private boolean[] isVisit; // 用於記錄 頂點是否被訪問,true 表示已訪問

    /**
     * 根據 頂點個數 進行初始化
     * @param number 頂點個數
     */
    public UndirectedGraph(int number) {
        vertexs = new ArrayList<>(number); // 用於記錄頂點
        edges = new int[number][number]; // 用於記錄頂點之間的關係
        numberOfEdges = 0; // 用於記錄邊的個數
        isVisit = new boolean[number]; // 用於記錄頂點是否被訪問
    }

    /**
     * 新增頂點
     * @param vertex 頂點
     */
    public void insertVertex(String vertex) {
        vertexs.add(vertex);
    }

    /**
     * 新增邊
     * @param row 行
     * @param column 列
     * @param value 值(1 表示存在邊,0表示不存在邊)
     */
    public void insertEdge(int row, int column, int value) {
        edges[row][column] = value; // 設定邊
        edges[column][row] = value; // 設定邊,對稱
        numberOfEdges++; // 邊總數加 1
    }

    /**
     * 返回邊的總數
     * @return 邊的總數
     */
    public int getNumberOfEdges() {
        return numberOfEdges;
    }

    /**
     * 返回頂點的總數
     * @return 頂點總數
     */
    public int getNumberOfVertex() {
        return vertexs.size();
    }

    /**
     * 返回 下標對應的頂點資料
     * @param index 頂點下標
     * @return 頂點資料
     */
    public String getValueByIndex(int index) {
        return vertexs.get(index);
    }

    /**
     * 輸出鄰接矩陣
     */
    public void showGraph() {
        for (int[] row : edges) {
            System.out.println(Arrays.toString(row));
        }
    }

    /**
     * 獲取下一個頂點的下標
     * @param row 行
     * @param column 列
     * @return 下一個鄰接頂點的下標(-1 表示不存在下一個鄰接頂點)
     */
    public int getNeighborVertexIndex(int row, int column) {
        for (int index = column + 1; index < vertexs.size(); index++) {
            if (edges[row][index] != 0) {
                return index;
            }
        }
        return -1;
    }

    /**
     * 返回當前頂點 的第一個鄰接頂點的下標
     * @param index 當前頂點下標
     * @return 第一個鄰接頂點的下標(-1 表示不存在鄰接頂點)
     */
    public int getFirstVertextIndex(int index) {
        return getNeighborVertexIndex(index, -1);
    }

    /**
     * 廣度優先遍歷
     */
    public void bfs() {
        // 未被訪問的頂點,進行廣度優先遍歷
        for (int index = 0; index < vertexs.size(); index++) {
            if (!isVisit[index]) {
                bfs(index);
            }
        }
    }

    /**
     * 廣度優先遍歷
     * @param index 頂點下標
     */
    private void bfs(int index) {
        // 輸出當前頂點資料
        System.out.print(getValueByIndex(index) + " ==> ");
        // 用於記錄訪問的頂點
        LinkedList<Integer> queue = new LinkedList<>();
        int firstIndex; // 用於記錄佇列的頭部節點
        int neighborIndex; // 用於記錄鄰接節點
        isVisit[index] = true; // 標記當前節點已被訪問
        queue.addLast(index); // 當前節點入佇列
        // 佇列不空時
        while(!queue.isEmpty()) {
            // 取出佇列頭節點
            firstIndex = queue.removeFirst();
            // 找到鄰接節點
            neighborIndex = getFirstVertextIndex(index);
            while(neighborIndex != -1) {
                if(!isVisit[neighborIndex]) {
                    // 輸出當前頂點資料
                    System.out.print(getValueByIndex(neighborIndex) + " ==> ");
                    isVisit[neighborIndex] = true;
                    queue.addLast(neighborIndex);
                } else {
                    neighborIndex = getNeighborVertexIndex(firstIndex, neighborIndex);
                }
            }
        }
    }

    public static void main(String[] args) {
        // 初始化無向圖
        UndirectedGraph undirectedGraph = new UndirectedGraph(5);
        // 插入頂點資料
        String[] vertexs = new String[]{"A", "B", "C", "D", "E"};
        for (String vertex : vertexs) {
            undirectedGraph.insertVertex(vertex);
        }
        // 插入邊
        undirectedGraph.insertEdge(0, 1, 1); // A-B
        undirectedGraph.insertEdge(0, 2, 1); // A-C
        undirectedGraph.insertEdge(1, 2, 1); // B-C
        undirectedGraph.insertEdge(1, 3, 1); // B-D
        undirectedGraph.insertEdge(1, 4, 1); // B-E

        // 輸出
        System.out.println("無向圖頂點總數為: " + undirectedGraph.getNumberOfVertex());
        System.out.println("無向圖邊總數為: " + undirectedGraph.getNumberOfEdges());
        System.out.println("無向圖第 3 個頂點為: " + undirectedGraph.getValueByIndex(2));
        System.out.println("無向圖 鄰接矩陣為: ");
        undirectedGraph.showGraph();

        System.out.println("廣度優先遍歷結果為: ");
        undirectedGraph.bfs();
    }
}

【輸出結果:】
無向圖頂點總數為: 5
無向圖邊總數為: 5
無向圖第 3 個頂點為: C
無向圖 鄰接矩陣為: 
[0, 1, 1, 0, 0]
[1, 0, 1, 1, 1]
[1, 1, 0, 0, 0]
[0, 1, 0, 0, 0]
[0, 1, 0, 0, 0]
廣度優先遍歷結果為: 
A ==> B ==> C ==> D ==> E ==> 

 

四、常用五種演算法

1、二分查詢演算法(遞迴與非遞迴)

(1)二分查詢
  二分查詢是一個效率較高的查詢方法。其要求必須採用 順序儲存結構 且 儲存資料有序。
  每次查詢資料時 根據 待查詢資料 將總資料 分為兩部分(一部分小於 待查詢資料,一部分大於 待查詢資料),設折半次數為 x,則 2^x = n,即折半次數為 x = logn,時間複雜度為 O(logn)。

(2)遞迴、非遞迴實現 二分查詢

【程式碼實現:】
package com.lyh.algorithm;

/**
 * 二分查詢、遞迴 與 非遞迴 實現
 */
public class BinarySearch {
    public static void main(String[] args) {
        // 構建升序序列
        int[] arrays = new int[]{13, 27, 38, 49, 65, 76, 97};
        // 設定待查詢資料
        int key = 27;
        // 遞迴二分查詢
        int index = binarySearch(arrays, 0, arrays.length - 1, key);
        if (index != -1) {
            System.out.println("查詢成功,下標為: " + index);
        } else {
            System.out.println("查詢失敗");
        }

        // 非遞迴二分查詢
        int index2 = binarySearch2(arrays, 0, arrays.length - 1, key);
        if (index2 != -1) {
            System.out.println("查詢成功,下標為: " + index2);
        } else {
            System.out.println("查詢失敗");
        }
    }

    /**
     * 折半查詢,返回元素下標(遞迴查詢,陣列升序)
     * @param arrays 待查詢陣列
     * @param left 最左側下標
     * @param right 最右側下標
     * @param key 待查詢資料
     * @return 查詢失敗返回 -1,查詢成功返回元素下標 0 ~ n
     */
    public static int binarySearch(int[] arrays, int left, int right, int key) {
        if (left <= right) {
            // 獲取中間下標
            int middle = (left + right) / 2;
            // 查詢成功返回資料
            if (arrays[middle] == key) {
                return middle;
            }
            // 待查詢資料 小於 中間資料,則從 左半部分資料進行查詢
            if (arrays[middle] > key) {
                return binarySearch(arrays, left, middle - 1, key);
            }
            // 待查詢資料 大於 中間資料,則從 右半部分資料進行查詢
            if (arrays[middle] < key) {
                return binarySearch(arrays, middle + 1, right, key);
            }
        }
        return -1;
    }

    /**
     * 折半查詢,返回元素下標(非遞迴查詢,陣列升序)
     * @param arrays 待查詢陣列
     * @param left 最左側下標
     * @param right 最右側下標
     * @param key 待查詢資料
     * @return 查詢失敗返回 -1,查詢成功返回元素下標 0 ~ n
     */
    public static int binarySearch2(int[] arrays, int left, int right, int key) {
        while(left <= right) {
            // 獲取中間下標
            int middle = (left + right) / 2;
            // 查詢成功返回資料
            if (arrays[middle] == key) {
                return middle;
            }
            // 待查詢資料 小於 中間資料,則從 左半部分資料進行查詢
            if (arrays[middle] > key) {
                right = middle - 1;
            } else {
                // 待查詢資料 大於 中間資料,則從 右半部分資料進行查詢
                left = middle + 1;
            }
        }
        return -1;
    }
}

【輸出結果:】
查詢成功,下標為: 1
查詢成功,下標為: 1

 

2、分治演算法(漢諾塔問題)

(1)分治演算法:
  分治法 簡單理解就是 分而治之,其將一個複雜的問題 分成 兩個或者 若干個 相同或者 類似的 子問題,子問題 又可進一步劃分為 若干個更小的子問題,直至 子問題可以很簡單的求