查找算法吐血超詳細總結

源代碼地址

首先我們會使用符號表這個詞來描述一張抽象的表格。 我們將信息(值)儲存在裏面,然後通過特定的鍵來搜索並獲取這些信息。那麼首先我們來定義這個符號表的抽象類。

public abstract class AbstractST<Key extends Comparable<Key>,Value>{
    /**
     * 將鍵值存入表中 
     * @param key
     * @param value
     */
    public abstract void put(Key key,Value value);
    /**
     * 通過key獲得value
     * @param key
     * @return 若不存在則返回空
     */
    public abstract Value get(Key key);
    /**
     * 通過key刪除結點
     * @param key
     * @return
     */
    public abstract Value delete(Key key);
    /**
     * 表中的鍵值對數量
     * @return
     */
    public abstract int size();
    /**
     * 判斷是否爲空
     * @return
     */
    public boolean isEmpty(){
        return size() == 0;
    }
    /**
     * 判斷建是否存在
     * @param key
     * @return
     */
    public boolean contains(Key key){
        return get(key) != null;
    }
    /**
     * 返回小於key的數量
     * @param key
     * @return
     */
    public abstract int rank(Key key);
    /**
     * 返回排名爲index 的鍵
     * @param index
     * @return
     */
    public abstract Key select(int index);
}

既然是查找,是一種算法, 他的實現需要依靠一定的數據結構來實現高效的算法 。所以我們之前構建的抽象類是有一定作用的,它定義了一些簡單的API,而我們的目的便是要去實現它。

Q:我們僅僅學的是操作算法,學會怎麼去使用get()就行了,爲什麼還要去學其他的操作呢?

A:學習這些操作並不是說爲了讓我們學得更多。相信知道“樹”的同學都知道,在樹中實現查找是一種很簡單的工作,但是如何插入卻並不簡單,而插入卻是爲了更好的進行查找。特別是在平衡樹中,添加刪除節點往往意味着樹的結構的改變。所以對於我們而言,並不是說僅僅說能夠使用get()函數即可,而是應該能夠寫get(),put(),delete()等等函數。因爲因爲這些函數的作用,才能夠讓我們能夠輕輕鬆鬆的使用get函數。

查找之鏈表和數組

無序的鏈表

首先我們需要說的是無序的鏈表, i這個沒什麼好說的。因爲在無序的i情況下,沒有什麼騷操作,只能利用for循環一個一個地進行查找。下面將實現插入查找刪除的功能。其他的功能較爲簡單,就不展示了。

package search;
public class SequentialSearch<Key extends Comparable<Key>,Value> extends AbstractST<Key,Value> {
    private Node first;
    private  int size;
    @Override
    public void put(Key key, Value value) {
        // 進行更新
        for (Node  x = first ; x!=null; x = x.next) {
            if (x.key.equals(key)){
                x.value = value;
                return;
            }
        }
        // 新的創建
        first = new Node(key,value,first);
        size ++;
    }
    @Override
    public Value get(Key key) {
        // 命中
        for (Node  x = first ; x!=null; x = x.next) {
            if (x.key.equals(key)){
             return x.value;
            }
        }
        //  沒有命中。
        return null;
    }
    @Override
    public Value delete(Key key) {
        Node pre = first;

        for (Node x = first;x!=null;pre = x, x = x.next){
            if (x.key.equals(key)){
                Value value = x.value;
                pre.next = x.next;
                return value;
            }
        }
        return null;
    }
    @Override
    public int size() {
        return size;
    }
    …………省略了其他的方法
    // 結點
    private class Node{
        Key key;
        Value value;
        Node next;

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

性能分析:

無序鏈表 插入 刪除 查找 空間
複雜度 O(1) O(n) O(n) O(n)

有序數組的二分查找

看到有序,相信大家已經想到什麼好的算法進行查找了吧——二分查找。yes,構建有序的數組,在插入,刪除的時候依舊保持有序,在get()函數中,使用二分查找,便是我們需要做的事情。(在鏈表中我們沒必要使用二分查找,因爲在鏈表中我們沒辦法準確定位中間結點的位置)。

在進行put()之前,首先我們需要使用rank()函數來獲取key在數組中的“排名”。

/**
 * 返回小於key的數量
 * 非遞歸二分查找
 * @param key
 * @return
 */
@Override
public int rank(Key key) {
    // lo……hi代表二分查找的範圍
    int lo = 0,hi = size -1;
    while(lo<=hi){
        int mid = lo + ((hi-lo)>>1);
        int result = key.compareTo(keys[mid]);
        // 假如key 大於 keys[mid]則返回大於0的值
        if (result > 0){
            lo = mid + 1;
        }
        // 假如key 小於 keys[mid]則返回小於0的值
        else if(result < 0){
            hi = mid -1;
        }
        // 如果兩個相等
        else {
            return mid;
        }
    }
    return lo;
}

接下來我們將實現put,delete,get。

package search;

public class BinarySearchST<Key extends Comparable<Key>,Value> extends AbstractST <Key,Value> {
    private int size;
    private Key[] keys;
    private  Value[] values;
    public BinarySearchST(int capacity) {
        keys = (Key[]) new Comparable[capacity];
        values = (Value[]) new Comparable[capacity];
        this.size = 0;
    }
    @Override
    public void put(Key key, Value value) {
        // 假如值是空的,則代表刪除這個鍵
        if (value == null){
            delete(key);
        }
        int position = rank(key);
        // 如果鍵存在則更新。之所以先【position < size 】是爲了防止出現數組越界。
        if (position < size && keys[position].compareTo(key) == 0){
            values[position] = value;
            return;
        }
        // 如果容量已經滿了,則進行擴容到原來的兩倍
        if (size == keys.length){
            resize(2*size);
        }
        // 爲position這個位置騰出空間
        for (int i = size; i > position ; i--) {
            keys[i] = keys[i -1];
            values[i] = values[i -1];
        }
        keys[position] = key;
        values[position] = value;
        size ++;
    }
    // 擴容操作
    public void resize(int capacity){
        Key[] newKeys = (Key[]) new Comparable[capacity];
        Value[] newValues = (Value[]) new Comparable[capacity];
        for (int i =0;i<size;i++){
            newKeys[i] = keys[i];
            newValues[i] = values[i];
        }
        keys = newKeys;
        values = newValues;
    }
    @Override
    public Value get(Key key) {
        if (size == 0){
            return  null;
        }
        // 獲得所在位置
        int i = rank(key);
        if (i<size && keys[i].compareTo(key) == 0){
            return values[i];
        }
        else{
            return null;
        }
    }
    @Override
    public Value delete(Key key) {
        if (key == null){
            return null;
        }
        int position = rank(key);
        // 假如key不存在
        if (position < size && key.compareTo(keys[position]) != 0){
            return  null;
        }
        // 假如超出範圍
        else if (position == size){
            return null;
        }
        // 沒有超出範圍
        Value returnValue = values[position];
        for (int i = position;i < size - 1;i++){
            keys[i] = keys[i+1];
            values[i] = values[i+1];
        }
        size --;
        // 減小容量
        if (size>0 && size == keys.length/4){
            resize(keys.length/2);
        }
        return returnValue;
    }
    // 省略其他等等操作(包括rank())
    ……
}

由上面我們可以知道,在有序數組中,二分查找以一件很令人高興的事情,但是在插入中卻並不是那麼讓人樂觀。在添加數據的時候, 我們不僅僅是插入數據而已,還需要進行查找。

性能分析:

有序數組 插入 刪除 查找 空間
複雜度 O(n) O(n) O(lgn) O(n)

跳躍鏈表(skip list)

skip list 是一個很讓人新奇的東西,儘管可能會對它很陌生,但是你卻可以在redis中間看到它的身影。下面是跳躍鏈表的示意圖(圖源wiki):

 

 

 

在這張示意圖中我們可以很簡單的知道當我們需要去尋找6的時候只需要簡單的3步:head->1->4->6。不得不說這個是一個讓人幸喜的發現(感謝W. Pugh)。這裏面說下個人的觀點

​ 在前面我們介紹了有序數組中查找使用了二分法能夠明顯的降低時間複雜度,但是卻無法降低插入的時間複雜(即使我們知道插入的位置在哪),這個是由數組的特性決定的(假如插入的值在數組的中間,後面的數據就不得不進行移動位置)。

​ 而在鏈表中,即使我們卻不得不進行遍歷,才能查找到結點。而鏈表的插入的時間卻又是很快的(它並不需要 進行移動位置)。

​ 跳躍鏈表剛好集成了兩者的優點,讓我們能夠更好的查找元素和插入元素。

在上面的示意圖中,我們知道跳躍鏈表是按層進行構建,最下面的層就是一個普通的有序鏈表,而上面一層則是下面鏈表的“快速通道”,而每一層都是一個有序的鏈表。

在這裏我們可以去考慮下在跳躍鏈表中結點有什麼特點:

  • 每一個結點有兩個指針,一個指向下一個結點,一個指向下一層結點。
  • 如果一個第i層包含A結點,那麼比i小的層都包含A結點(這一點從上面的圖中可以含簡單的看到)。

這個是一個Node的模型(這個圖真畫),它包含key,value,left,right,up,down。這個是一個雙向循環鏈表。left指向前一個結點,right指後一個結點,up指向上面一層的結點,down指向下面一層的結點。ps:不過如果Node不在第一層,則Node中Value沒有什麼存在的意義,也就是說value可以爲空。

 

Node模型

Node模型

 

我們可以簡單的定義一下跳躍鏈表需要實現的API。

  • 查找
  • 插入
  • 刪除

首先,讓我們來定義一下SkipList的成員結構。在類中,有前面的Node,這個是數據的保存位置和方式。在head和tail中,這兩個都是空的,不保存數據,只作爲跳躍鏈表開始和結束的標誌位。

public class SkipList<Key extends Comparable<Key>,Value>{

    // 首先我們先定義好結點。
    private class Node{
        Key key;
        Value value;
        Node up;
        Node down;
        Node right;
        Node left;

        public Node(Key key, Value value){
            this.key = key;
            this.value = value;
        }
    }

    // 當前跳錶最大層數
    private int maxLevel;
    // 當前插入結點的個數
    private int size;
    // 首結點
    private Node head;
    // 尾結點
    private Node tail;


    public SkipList() {
        maxLevel = 0;
        // 創建首尾結點
        head = new Node(null,null);
        tail = new Node(null,null);
        head.right = tail;
        tail.left = head;
        size = 0;
    }
}

下面以前面的圖爲例,來說下查找的方法。

 

 

 

/**
  * 通過key獲得node(node不一定是正確的)
  * @param key
  * @return 返回的node.key <= key
  */
public Node getNode(Key key) {
    if (key == null){
        return  null;
    }
    Node node = head;
    while(true){
        /**
         * 假如node.right不爲尾結點且key.value大於或者等於右邊結點的key
         */
        while (node.right.key != tail.key && key.compareTo(node.right.key) >= 0){
            node = node.right;
        }
        if (node.down != null){
            node = node.down;
        }else {
            break;
        }
    }
    // 返回的node.key <= key
    return node;
}

大家看到上面的方法,可能會很疑惑,爲什麼我們進行查找的時候,既然可能得到的node.key != key,爲什麼還要使用getNode方法呢?大家想一想前面的二分查找,在前面我們使用二分查找得到的lo所對應的位置難道就是key的位置嗎?不是,我們之所以這樣做就是爲了在put()的時候能夠重新使用這個方法。

這個纔是真正的獲取value的方法:

/**
 * 通過key獲得value
 * @param key
 * @return
 */
public Value get(Key key){
     if (key == null){
        return  null;
    }
    
    Node node = getNode(key);
    if (node.key.compareTo(key) == 0){
        return node.value;
    }else {
        return null;
    }
}

查找很簡單,在跳躍鏈表中,難的是put和delete。

下面我將介紹一下put的方法。在前面我們說過,跳躍鏈表是由層構建的,當我們插入一個數據的時候,這個數據該有幾層呢,誰來決定它的層數?天決定,對,就是天決定。我們使用隨機數來決定層數。

  1. 如果存在則修改。
  2. 如果put的key值在跳躍鏈表中不存在,則進行新增節點,而高度由“天決定”。
  3. 當新添加的節點高度達到maxLevel(即跳躍鏈表中的最大level),則在head和tail添加一個新的層,這個層由head指向tail,同時maxLevel+1。

首先讓我們將添加新的一層的代碼完成。

/**
 * 添加新的一層
 */
public void addEmptyLevel(){
    Node newHead = new Node(null,null);
    Node newTail= new Node(null,null);

    newHead.right  = tail;
    tail.left = newHead;

    newHead.down = head;
    newTail.down = tail;

    head.up = newHead;
    tail.up = newTail;

    head = newHead;
    tail = newTail;

    maxLevel ++;
}

然後我們就可以開開心心的完成put的函數了。

public void put(Key key, Value value) {
    // Key不能爲null
    if (key == null){
        return;
    }
    // 插入的合適位置
    Node putPosition = getNode(key);
    // 如果相等則更新
    if (key.equals(putPosition.key)){
        putPosition.value = value;
        return;
    }
    // 進行新增操作
    Node newNode = new Node(key,value);
    /**
     * putPostion的key小於key,所以排序位置應該是
     * putPosition 【newNode】 putPosition.next
     * 所以進行下面的操作
     */
    newNode.right = putPosition.right;
    newNode.left = putPosition;
    putPosition.right.left = newNode;
    putPosition.right = newNode;
    Random random = new Random();
    int level = 0;
    // 產生0和1,使得新的結點有1/2的機率去增加level
    while (random.nextInt(2) == 0){
        // 假如高度達到了maxLevel則添加一個層
        if (level >= maxLevel){
            addEmptyLevel();
        }
        while (putPosition.up == null){
            putPosition = putPosition.left;
        }
        putPosition = putPosition.up;
        // 可以將skipNode中的value設置爲空,不過爲空也沒什麼關係
        Node skipNode = new Node(key, null);
        /**
         * 需要的順序是:
         * putPosition 【skipNode】 putPosition.right
         */
        skipNode.left = putPosition;
        skipNode.right = putPosition.right;
        putPosition.right.left = skipNode;
        putPosition.right = skipNode;
        // 將newNode放到上一層
        /**
         * putpostion skipNode skipNode.right
         * newNode
         */
        skipNode.down = newNode;
        newNode.up = skipNode;
        newNode = skipNode;
        level ++;
    }
    size ++;
}

大家可以在代碼中,發現隨機的機率爲0.5(這個可以去自己設置,同樣也可以根據size的大小去設置),那麼既然機率是0.5,那麼有多少的結點有2層,有多少的結點有3層呢…….根據概率論的知識我們知道:

2層——>N*1/2,3層——>N*1/2*1/2,所以根據等比數列我們可以知道,除第1層以外所有的層數一共有N層

所以跳躍鏈表中一共有2N個結點。

性能分析:

跳躍數組 插入 刪除 查找 空間
複雜度 O(lgn) O(lgn) O(lgn) O(n/p)【p代表隨機的概率】

在前面我們講的都是鏈表和數組的查找,無疑跳躍鏈表是最高效的,儘管它的操作相比數組和鏈表繁瑣很多,但是O(logn)的時間複雜度和O(2N)的空間複雜度卻能夠讓人很欣喜接受這一切。接下來我將介紹樹的查找。

我家門前有幾棵樹

在前面, 我們使用線性的數據結構來儲存數據,現在我們將用樹來表達數據。這裏我不會詳細的介紹樹,大家有可以去看看別人寫的博客。

下面是維基百科對於樹的介紹:

樹的介紹

在計算機科學中,(英語:tree)是一種抽象數據類型(ADT)或是實現這種抽象數據類型的數據結構,用來模擬具有樹狀結構性質的數據集合。它是由n(n>0)個有限節點組成一個具有層次關係的集合。把它叫做“樹”是因爲它看起來像一棵倒掛的樹,也就是說它是根朝上,而葉朝下的。它具有以下的特點:

  • 每個節點都只有有限個子節點或無子節點;
  • 沒有父節點的節點稱爲根節點;
  • 每一個非根節點有且只有一個父節點;
  • 除了根節點外,每個子節點可以分爲多個不相交的子樹;
  • 樹裏面沒有環路(cycle)

樹的種類

  • 無序樹:樹中任意節點的子節點之間沒有順序關係,這種樹稱爲無序樹,也稱爲自由樹
  • 有序樹:樹中任意節點的子節點之間有順序關係,這種樹稱爲有序樹;
    • 二叉樹:每個節點最多含有兩個子樹的樹稱爲二叉樹;
      • 完全二叉樹:對於一顆二叉樹,假設其深度爲d(d>1)。除了第d層外,其它各層的節點數目均已達最大值,且第d層所有節點從左向右連續地緊密排列,這樣的二叉樹被稱爲完全二叉樹;
        • 滿二叉樹:所有葉節點都在最底層的完全二叉樹;
      • 平衡二叉樹AVL樹):當且僅當任何節點的兩棵子樹的高度差不大於1的二叉樹;
      • 排序二叉樹(二叉查找樹(英語:Binary Search Tree)):也稱二叉搜索樹、有序二叉樹;
    • 霍夫曼樹帶權路徑最短的二叉樹稱爲哈夫曼樹或最優二叉樹;
    • B樹:一種對讀寫操作進行優化的自平衡的二叉查找樹,能夠保持數據有序,擁有多於兩個子樹。

二叉查找樹(BST)

下面是一張二叉查找樹的模型:

 

 

 

二叉查找樹有一下特點:

  1. 若某結點的左子樹不爲空,則左子樹上面所有的節點的值都小於該節點。【8大於左邊所有的值】
  2. 若某結點的右子樹不爲空,則右子樹上面所有的節點的值都大於該節點。【8小於右邊所有的值】
  3. 沒有鍵相等的節點(也就是說所有節點的key都不相等)

接下來定義一下二叉查找樹的數據結構:

public class BST<Key extends Comparable<Key>,Value> extends AbstractST <Key,Value> {

    // 根節點
    private Node root;

    private class Node{
        private Key key;
        private Value value;
        private Node left,right;
        // 以該結點爲根的子樹中結點總數,包括該節點
        private int N;

        public Node(Key key, Value value, int n) {
            this.key = key;
            this.value = value;
            this.N = n;
        }
    }
    
    
    /**
     * 查看是否含有key
     * @param key
     * @return
     */
    public boolean containsNode(Key key){
        if (key == null){
            return false;
        }
        Node node = root;
        int temp;
        while (node!= null) {
            temp = node.key.compareTo(key);
            // 假如key小於結點的key,則轉向左子樹
            if (temp > 0) {
                node = node.left;
            } else if (temp < 0) { // 轉向右子樹
                node = node.right;
            } else {
                return true;
            }
        }
        return false;
    }
        /**
     * 獲得查找二叉樹中所有結點的數量
     * @return
     */
    @Override
    public int size() {
        return size(root);
    }

    /**
     * 獲得以某結點爲根所有子樹結點的數量(包括該結點)
     * @param node
     * @return
     */
    public int size(Node node){
        if (node == null){
            return 0;
        }
        return node.N;
    }
    ……省略一些了繼承過來的方法
}

在前面我們知道了二叉查找樹的特點,那麼根據這些特點可以很簡單的寫查找算法。

  • 查找
@Override
public Value get(Key key) {
    // 默認返回值爲空
    Value resultValue = null;
    // 假如key爲空或則二叉樹爲空
    if (key == null){
        return resultValue;
    }

    Node node = root;
    int temp;
    // 可以使用遞歸或者非遞歸實現
    while (node!= null && (temp=node.key.compareTo(key)) != 0){
        // 假如key小於結點的key,則轉向左子樹
        if (temp>0){
            node = node.left;
        }else if (temp < 0){ // 轉向右子樹
            node = node.right;
        }else {
            resultValue = node.value;
            break;
        }
    }
    return resultValue;
}

二叉查找樹的查找還是比較簡單的,無非就是當key比結點的key大的時候,則轉向右子樹,小於則轉向左子樹。

  • 插入

下面讓我們來說說插入函數吧,在插入的同時,我們還要不斷的更新N(以該結點爲根的子樹中結點總數,包括該節點)。插入的操作已經在註釋裏面寫的很詳細了。

@Override
public void put(Key key, Value value) {
    if(key == null){
        return;
    }
    // 假如二叉樹是空的
    if (root == null){
        root = new Node(key,value,1);
        return;
    }
    int addN = 1;
    // 假如樹中含有key,則進行更新,所以樹中的N就不需要發生變化。
    if (containsNode(key)){
        addN = 0;
    }
    int temp;
    Node node = root;
    while(true){
        temp = node.key.compareTo(key);
        // key相等則進行更新
        if (temp == 0){
            node.value = value;
            return;
        }
        // 插入的key比node的key小,轉向左子樹
        if (temp>0){
            node.N += addN;
            if (node.left == null){
                node.left = new Node(key,value,1);
            }
            node = node.left;
        }else {
            node.N += addN;
            if (node.right == null){
                node.right = new Node(key,value,1);
            }
            node = node.right;
        }
    }

}
  • 刪除

    刪除應該可以說是樹中最難的一個操作了。在查找二叉樹中,刪除分爲一下3種情況:

  1. 刪除的結點是一個葉子結點,也就是說結點沒有子節點。這種情況最容易處理。圖源【在這位大佬的博客中,很生動形象的講了刪除的操作】。在這種情況下,我們可以直接將圖中的結點7.right = null即可。

     

    刪除葉子結點

    刪除葉子結點

     

  2. 要刪除的結點有一個子節點。這種情況也不復雜,如圖所示的操作即可,同時被刪除的結點的子節點上升了一個層次。

     

    有一個子節點

    有一個子節點

     

  3. 要刪除的結點有兩個子節點,這種情況是最複雜的。因爲父節點的left或者right無法同時指向被刪除結點的兩個子節點。解決這這個問題的方案有兩種方案:合併刪除和複製刪除。

    合併刪除

    首先先說一下合併刪除,先給搭建看一看來自geeksforgeeks的一張圖(稍微修改了一下)。

    爲什麼叫合併刪除呢?因爲合併刪除的思想就是將被刪除節點的兩個子節點合併成一棵樹,然後代替被刪除結點的位置,下面我將根據下圖來講一下具體的操作。

     

    合併刪除

    合併刪除

     

    在圖中我們需要刪除的結點是32

    1. 首先我們在刪除結點的左子樹找到最右邊的結點X(也就是圖中是結點29)。
    2. 然後將結點X的右子節點指向被刪除結點的右子節點。(也就是將29結點的右子節點指向結點A)。
    3. 最後使得被刪除結點的父節點指向被刪除結點的左子結點。(也就是17結點指向28結點)。

    我們可以想一想爲什麼要這麼做?根據二叉查找樹的性質我們可以很簡單的知道,A子樹是一定大於被刪除結點的做子樹的,所以將A子樹放在左子樹的最右邊。

    首先,我們讓我們來看看delete函數。

    /**
      * 進行刪除
      * @param key
      * @return 返回刪除結點的value
      */
    @Override
    public Value delete(Key key){
        // 如果不包含key
        if (!containsNode(key)){
            return null;
        }
        // preNode代表刪除結點的父節點
        Node node = root,preNode = root;
        int temp;
        while (true) {
            temp = node.key.compareTo(key);
            // 假如key小於結點的key,則轉向左子樹
            if (temp > 0) {
                preNode = node;
                // 在刪除的同時,將結點的N--
                preNode.N --;
                node = node.left;
            } else if (temp < 0) { // 轉向右子樹
                preNode.N --;
                preNode = node;
                node = node.right;
            } else {
                break;
            }
        }
        // 被刪除結點的返回值
        Value value = node.value;
        
        // mergeDelete代表合併刪除
        if (node == root){
            root = mergeDelete(node);
        }
        // 假如刪除的是父節點的左邊的結點
        else if (preNode.left!=null && preNode.left == node){
            preNode.left = mergeDelete(node);
        }else {
            preNode.right = mergeDelete(node);
        }
        return value;
    }

    接下來是mergeDelete函數,代碼的註釋已經寫的很清楚了,如果能夠理解合併刪除的原理,那麼理解下面的代碼也將輕而易舉:

    /**
     * 使用合併刪除
     * @param node
     * @return 返回的結點爲已經進行刪除後新的結點
     */
    public Node mergeDelete(Node node){
        // 假如沒有右子樹
        if (node.right == null){
            return node.left;
        }
        // 假如沒有左子樹
        else if (node.left == null){
            return node.right;
        }
        // 既有右子樹也有左子樹
        else {
            Node tempNode = node.left;
            // 轉向左子樹中最右邊的結點
            while (tempNode.right != null){
                tempNode= tempNode.right;
                tempNode.N += size(node.right);
            }
            // 將刪除結點的右子樹放入正確的位置
            tempNode.right = node.right;
            node.left.N += size(node.right);
            return node.left;
        }
    }

    歸併刪除有一個很大的缺點,那就是刪除結點會導致樹的高度的增加。接下來讓我們來看看複製刪除是怎麼解決這個問題的。

    複製刪除(拷貝刪除)

    在說複製刪除之前,我們需要先熟悉二叉查找樹的前驅和後繼(根據中序遍歷衍生出來的概念)。

    • 前驅:A結點的前驅是其左子樹中最右側結點。
    • 後繼:A結點的後繼是其右子樹中最左側結點。

    那麼複製刪除的原理是什麼樣的呢?很簡,使用刪除結點的前驅或者後繼代替被刪除的結點即可。

    以下圖來講一下複製刪除的原理(圖源

     

     

     

16結點的前驅爲14結點,後驅爲18結點,假如我們要刪除16結點,即可將14結點或者18結點替代16即可。

/**
 * 使用複製刪除
 * @param node 被刪除的結點
 */
private Node copyDelete(Node node){
    if (node.right == null){
        return node.left;
    }else if(node.left == null){
        return node.right;
    }
    // 既有左子樹又有右子樹
    else {
        Node tempNode = node.left;
        while(tempNode.right != null){
            tempNode.N --;
            tempNode = tempNode.right;
        }
        tempNode.right = node.right;
        tempNode.left = (node.left==tempNode?tempNode.left:node.left);
        tempNode.N = size(tempNode.left) + size(tempNode.right)+1;
        return tempNode;
    }
}

// 調用刪除函數
public Value deleteByCopy(Key key){
     // 如果不包含key
     if (!containsNode(key)){
         return null;
     }
     Node node = root;
     Node preNode = node;
     int temp;
     while (true){
         node.N --;
         temp = node.key.compareTo(key);
         // 假如key小於結點的key,則轉向左子樹
         if (temp > 0) {
             preNode = node;
             node = node.left;
         } else if (temp < 0) { // 轉向右子樹
             preNode = node;
             node = node.right;
         } else {
             break;
         }
     }
     // 被刪除結點的返回值
     Value value = node.value;
     if (node == root){
         root = copyDelete(node);
     }
     // 假如刪除的是父節點的左邊的結點
     else if (preNode.left!=null && preNode.left == node){
         preNode.left = copyDelete(node);
     }else {
         preNode.right = copyDelete(node);
     }
     return value;
 }

在前面的代碼中,我們總是刪除node中的前驅結點,這樣必然會降低左子樹的高度,在前面中我們知道,我們也可以使用後繼結點來代替被刪除的結點。所以我們可以交替的使用前驅和後繼來代替被刪除的結點。

J.Culberson從理論證實了使用非對稱刪除,IPL的期望值是O(n√n),平均查找時間爲(O(√n)),而使用對稱刪除,IPL的期望值爲(nlgn),平均查找時間爲O(lgn)。

性能分析:

二叉查找樹 插入 刪除 查找 空間
複雜度(平均) O(lgn) O(lgn) O(lgn) O(n)
複雜度(最壞) O(n) O(n) O(n) O(n)

儘管樹已經很優秀了,但是我們我們可以想一想,如果我在put的操作的時候,假如使用的是順序的put(e也就是按順序的插入1,2,3,4……),那麼樹還是樹嗎?此時的樹不再是樹,而是變成了鏈表,而時間複雜度也不再是O(lgn)了,而是變成了O(n)。 接下來我們將介紹樹中的2-3查找樹紅黑樹

2-3 查找樹

定義(來源:wiki)

2–3樹是一種樹型數據結構,內部節點(存在子節點的節點)要麼有2個孩子和1個數據元素,要麼有3個孩子和2個數據元素,葉子節點沒有孩子,並且有1個或2個數據元素。

 

2個結點

2個結點

3個結點

 

  • 定義

    如果一個內部節點擁有一個數據元素、兩個子節點,則此節點爲2節點

    如果一個內部節點擁有兩個數據元素、三個子節點,則此節點爲3節點

    當且僅當以下敘述中有一條成立時,T爲2–3樹:

    • T爲空。即T不包含任何節點。
    • T爲擁有數據元素a的2節點。若T的左孩子爲L、右孩子爲R,則
      • LR是等高的非空2–3樹;
      • a大於L中的所有數據元素;
      • a小於等於R中的所有數據元素。
    • T爲擁有數據元素ab的3節點,其中a < b。若T的左孩子爲L、中孩子爲M、右孩子爲R,則
      • LM、和R是等高的非空2–3樹;
      • a大於L中的所有數據元素,並且小於等於M中的所有數據元素;
      • b大於M中的所有數據元素,並且小於等於R中的所有數據元素。

首先我們說一下查找

2-3查找樹的查找和二叉樹很類似,無非就是進行比較然後選擇下一個查找的方向。

 

2-3h奧

2-3h奧

 

2-3a查找樹的插入

我們可以思考一下,爲什麼要兩個結點。在前面可以知道,二叉查找樹變成鏈表的原因就是因爲新插入的結點沒有選擇的”權利”,當我們插入一個元素的時候,實際上它的位置已經確定了, 我們並不能對它進行操作。那麼2-3查找樹是怎麼做到賦予“權利”的呢?祕密便是這個多出來結點,他可以緩存新插入的結點。(具體我們將在插入的時候講)

前面我們知道,2-3查找樹分爲2結點3結點,so,插入就分爲了2結點插入和3結點插入。

**2-結點插入:**向2-結點插入一個新的結點和向而插入插入一個結點很類似,但是我們並不是將結點“吊”在結點的末尾,因爲這樣就沒辦法保持樹的平衡。我們可以將2-結點替換成3-結點即可,將其中的鍵插入這個3-結點即可。(相當於緩存了這個結點)

 

 

 

**3-結點插入:**3結點插入比較麻煩,emm可以說是特別麻煩,它分爲3種情況。

  1. 向一棵只含有3-結點的樹插入新鍵。

    假如2-3樹只有一個3-結點,那麼當我們插入一個新的結點的時候,我們先假設結點變成了4-結點,然後使得中間的結點爲根結點,左邊的結點爲其左結點,右邊的結點爲其右結點,然後構成一棵2-3樹,樹的高度加1

     

     

     

  2. 向父結點爲2-結點的3-結點中插入新鍵。

    和上面的情況類似,我們將新的節點插入3-結點使之成爲4-結點,然後將結點中的中間結點”升“到其父節點(2-結點)中的合適的位置,使其父節點成爲一個3-節點,然後將左右節點分別掛在這個3-結點的恰當位置,樹的高度不發生改變

 

 


3. 向父節點爲3-結點的3-結點中插入新鍵。

 

這種情況有點類似遞歸:當我們的結點爲3-結點的時候,我們插入新的結點會將中間的元素”升“父節點,然後父節點爲4-結點,右將中間的結點”升“到其父結點的父結點,……如此進行遞歸操作,直到遇到的結點不再是3-結點。

 

 

 

接下來就是最難的操作來了,實現這個算法,2-3查找樹的算法比較麻煩,所以我們不得不將問題分割,分割求解能將問題變得簡單。參考博客

首先我們定義數據結構,作用在註釋已經寫的很清楚了。

public class Tree23<Key extends Comparable<Key>,Value> {
        /**
     * 保存key和value的鍵值對
     * @param <Key>
     * @param <Value>
     */
    private class Data<Key extends Comparable<Key>,Value>{
        private Key key;
        private Value value;

        public Data(Key key, Value value) {
            this.key = key;
            this.value = value;
        }
        public void displayData(){
            System.out.println("/" + key+"---"+value);
        }
    }

    /**
     * 保存樹結點的類
     * @param <Key>
     * @param <Value>
     */
    private class Node23<Key extends Comparable<Key>,Value>{

        public void displayNode() {
            for(int i = 0; i < itemNum; i++){
                itemDatas[i].displayData();
            }
            System.out.println("/");
        }

        private static final int N = 3;
        // 該結點的父節點
        private Node23 parent;
        // 子節點,子節點有3個,分別是左子節點,中間子節點和右子節點
        private Node23[] chirldNodes = new Node23[N];
        // 代表結點保存的數據(爲一個或者兩個)
        private Data[] itemDatas = new Data[N - 1];
        // 結點保存的數據個數
        private int itemNum = 0;

        /**
         * 判斷是否是葉子結點
         * @return
         */
        private boolean isLeaf(){
            // 假如不是葉子結點。必有左子樹(可以想一想爲什麼?)
            return chirldNodes[0] == null;
        }

        /**
         * 判斷結點儲存數據是否滿了
         * (也就是是否存了兩個鍵值對)
         * @return
         */
        private boolean isFull(){
            return itemNum == N-1;
        }

        /**
         * 返回該節點的父節點
         * @return
         */
        private Node23 getParent(){
            return this.parent;
        }

        /**
         * 將子節點連接
         * @param index 連接的位置(左子樹,中子樹,還是右子樹)
         * @param child
         */
        private void connectChild(int index,Node23 child){
            chirldNodes[index] = child;
            if (child != null){
                child.parent = this;
            }
        }

        /**
         * 解除該節點和某個結點之間的連接
         * @param index 解除鏈接的位置
         * @return
         */
        private Node23 disconnectChild(int index){
            Node23 temp = chirldNodes[index];
            chirldNodes[index] = null;
            return temp;
        }

        /**
         * 獲取結點左或右的鍵值對
         * @param index 0爲左,1爲右
         * @return
         */
        private Data getData(int index){
            return itemDatas[index];
        }

        /**
         * 獲得某個位置的子樹
         * @param index 0爲左指數,1爲中子樹,2爲右子樹
         * @return
         */
        private Node23 getChild(int index){
            return chirldNodes[index];
        }

        /**
         * @return 返回結點中鍵值對的數量,空則返回-1
         */
        public int getItemNum(){
            return itemNum;
         }

        /**
         * 尋找key在結點的位置
         * @param key
         * @return 結點沒有key則放回-1
         */
        private int findItem(Key key){
            for (int i = 0; i < itemNum; i++) {
                if (itemDatas[i] == null){
                    break;
                }else if (itemDatas[i].key.compareTo(key) == 0){
                    return i;
                }
            }
            return -1;
        }

        /**
         * 向結點插入鍵值對:前提是結點未滿
         * @param data
         * @return 返回插入的位置 0或則1
         */
        private int insertData(Data data){
            itemNum ++;
            for (int i = N -2; i >= 0 ; i--) {
                if (itemDatas[i] == null){
                    continue;
                }else{
                    if (data.key.compareTo(itemDatas[i].key)<0){
                        itemDatas[i+1] = itemDatas[i];
                    }else{
                        itemDatas[i+1] = data;
                        return i+1;
                    }
                }
            }
            itemDatas[0] = data;
            return 0;
        }

        /**
         * 移除最後一個鍵值對(也就是有右邊的鍵值對則移右邊的,沒有則移左邊的)
         * @return 返回被移除的鍵值對
         */
        private Data removeItem(){
            Data temp = itemDatas[itemNum - 1];
            itemDatas[itemNum - 1] = null;
            itemNum --;
            return temp;
        }
    }
    /**
     * 根節點
     */
    private Node23 root = new Node23();
    ……接下來就是一堆方法了
}

主要是兩個方法:find查找方法和Insert插入方法:看註釋

/**
 *查找含有key的鍵值對
 * @param key
 * @return 返回鍵值對中的value
 */
public Value find(Key key) {
    Node23 curNode = root;
    int childNum;
    while (true) {
        if ((childNum = curNode.findItem(key)) != -1) {
            return (Value) curNode.itemDatas[childNum].value;
        }
        // 假如到了葉子節點還沒有找到,則樹中不包含key
        else if (curNode.isLeaf()) {
            return null;
        } else {
            curNode = getNextChild(curNode,key);
        }
    }
}

/**
 * 在key的條件下獲得結點的子節點(可能爲左子結點,中間子節點,右子節點)
 * @param node
 * @param key
 * @return 返回子節點,若結點包含key,則返回傳參結點
 */
private Node23 getNextChild(Node23 node,Key key){
    for (int i = 0; i < node.getItemNum(); i++) {
        if (node.getData(i).key.compareTo(key)>0){
            return node.getChild(i);
        }
        else if (node.getData(i).key.compareTo(key) == 0){
            return node;
        }
    }
    return node.getChild(node.getItemNum());
}

/**
 * 最重要的插入函數
 * @param key
 * @param value
 */
public void insert(Key key,Value value){
    Data data = new Data(key,value);
    Node23 curNode = root;
    // 一直找到葉節點
    while(true){
        if (curNode.isLeaf()){
            break;
        }else{
            curNode = getNextChild(curNode,key);
            for (int i = 0; i < curNode.getItemNum(); i++) {
                // 假如key在node中則進行更新
                if (curNode.getData(i).key.compareTo(key) == 0){
                    curNode.getData(i).value =value;
                    return;
                }
            }
        }
    }

    // 若插入key的結點已經滿了,即3-結點插入
    if (curNode.isFull()){
        split(curNode,data);
    }
    // 2-結點插入
    else {
        // 直接插入即可
        curNode.insertData(data);
    }
}

/**
 * 這個函數是裂變函數,主要是裂變結點。
 * 這個函數有點複雜,我們要把握住原理就好了
 * @param node 被裂變的結點
 * @param data 要被保存的鍵值對
 */
private void split(Node23 node, Data data) {
    Node23 parent = node.getParent();
    // newNode用來保存最大的鍵值對
    Node23 newNode = new Node23();
    // newNode2用來保存中間key的鍵值對
    Node23 newNode2 = new Node23();
    Data mid;

    if (data.key.compareTo(node.getData(0).key)<0){
        newNode.insertData(node.removeItem());
        mid = node.removeItem();
        node.insertData(data);
    }else if (data.key.compareTo(node.getData(1).key)<0){
        newNode.insertData(node.removeItem());
        mid = data;
    }else{
        mid = node.removeItem();
        newNode.insertData(data);
    }
    if (node == root){
        root = newNode2;
    }
    /**
     * 將newNode2和node以及newNode連接起來
     * 其中node連接到newNode2的左子樹,newNode
     * 連接到newNode2的右子樹
     */
    newNode2.insertData(mid);
    newNode2.connectChild(0,node);
    newNode2.connectChild(1,newNode);
    /**
     * 將結點的父節點和newNode2結點連接起來
     */
    connectNode(parent,newNode2);
}

/**
 * 鏈接node和parent
 * @param parent
 * @param node node中只含有一個鍵值對結點
 */
private void connectNode(Node23 parent, Node23 node) {
    Data data = node.getData(0);
    if (node == root){
        return;
    }
    // 假如父節點爲3-結點
    if (parent.isFull()){
        // 爺爺結點(爺爺救葫蘆娃)
        Node23 gParent = parent.getParent();
        Node23 newNode = new Node23();
        Node23 temp1,temp2;
        Data itemData;

        if (data.key.compareTo(parent.getData(0).key)<0){
            temp1 = parent.disconnectChild(1);
            temp2 = parent.disconnectChild(2);
            newNode.connectChild(0,temp1);
            newNode.connectChild(1,temp2);
            newNode.insertData(parent.removeItem());

            itemData = parent.removeItem();
            parent.insertData(itemData);
            parent.connectChild(0,node);
            parent.connectChild(1,newNode);
        }else if(data.key.compareTo(parent.getData(1).key)<0){
            temp1 = parent.disconnectChild(0);
            temp2 = parent.disconnectChild(2);
            Node23 tempNode = new Node23();

            newNode.insertData(parent.removeItem());
            newNode.connectChild(0,newNode.disconnectChild(1));
            newNode.connectChild(1,temp2);

            tempNode.insertData(parent.removeItem());
            tempNode.connectChild(0,temp1);
            tempNode.connectChild(1,node.disconnectChild(0));

            parent.insertData(node.removeItem());
            parent.connectChild(0,tempNode);
            parent.connectChild(1,newNode);
        } else{
            itemData = parent.removeItem();

            newNode.insertData(parent.removeItem());
            newNode.connectChild(0,parent.disconnectChild(0));
            newNode.connectChild(1,parent.disconnectChild(1));
            parent.disconnectChild(2);
            parent.insertData(itemData);
            parent.connectChild(0,newNode);
            parent.connectChild(1,node);
        }
        // 進行遞歸
        connectNode(gParent,parent);
    }
    // 假如父節點爲2結點
    else{
        if (data.key.compareTo(parent.getData(0).key)<0){
            Node23 tempNode = parent.disconnectChild(1);
            parent.connectChild(0,node.disconnectChild(0));
            parent.connectChild(1,node.disconnectChild(1));
            parent.connectChild(2,tempNode);
        }else{
            parent.connectChild(1,node.disconnectChild(0));
            parent.connectChild(2,node.disconnectChild(1));
        }
        parent.insertData(node.getData(0));
    }
}

2-3樹的查找效率與樹的高度相關,這個可以很簡單的理解,因爲2-3樹是平衡樹,所以即使是最壞的情況下,它然仍是一棵樹,不會產生類似鏈表的情況。

  • 最壞情況:全是2-結點,樹的高度最大,查找效率爲lgN。
  • 最好情況:全是3-結點,樹的高度最小,查找效率爲log3(N),約等於0.631lgN。

2-3查找樹的原理很簡單,甚至說代碼實現起來難度都不是很大,但是卻很繁瑣,因爲它有很多種情況,結束完欲仙欲死的2-3查找樹,接下來讓我們來好好的研究一下紅黑樹。

二叉查找樹 插入 刪除 查找 空間
複雜度(最好) O(0.631lgN) O(0.631lgN) O(0.631lgN) O(n)
複雜度(最壞) O(lgN) O(lgN) O(lgN) O(n)

紅黑樹

如果大家能夠很好的理解2-3查找樹的工作流程,那麼理解紅黑樹也會變得輕鬆。因爲可以這樣說,紅黑樹是2-3樹的一種實現,大家可以看下圖:

 

紅黑二叉查找樹背後的思想就是使用標準的二叉查找樹(由二結點構成) 和一些額外的信息(替換3-結點)來表示2-3樹, 那麼額外的信息是什麼呢?由圖我們可以得出:

  • 紅鏈接將兩個2-結點鏈接起來構成了一個3-結點。
  • 黑鏈接則是一個2-3樹中普通的鏈接。

 

 

(圖源)

 

紅黑樹的性質:

  1. 節點是紅色或黑色。
  2. 根是黑色。
  3. 所有葉子結點都是黑色(葉子是NIL節點)。
  4. 每個紅色節點必須有兩個黑色的子節點。(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點。)
  5. 從任一節點到其每個葉子的所有簡單路徑都包含相同數目的黑色節點。

我們可以想一想這些性質具體能夠帶來什麼樣的效果。

  • 從根到葉子的最長距離不會大於最短距離的兩倍長。(由第4條和第5條性質保證)這樣就保證了樹的平衡性。

由圖我們可以知道。要判斷一個是紅還是黑,可以由指向該節點的鏈接判斷出來。所以我們設置一個變量color,當鏈接爲紅時,變量爲true,爲黑時,變量color爲false。(例如:若父節點指向該節點的鏈接爲紅色,則color爲true)

其中我們約定空鏈接爲黑色,根節點也爲黑色,當插入一個結點的時候,設置結點的初始color爲RED

那麼此時我們就可以來定義數據結構了。

public class RBTree<Key extends Comparable<Key>,Value> {
    private static final boolean RED = true;
    private static final boolean BLACK = false;
    private class Node<Key extends Comparable<Key>,Value>{
        private Key key;
        private Value value;
        private boolean color;
        private Node rightNode,leftNode;
        // 這棵子樹中結點的個數,包括該節點
        private int N;
        private Node root;
        public Node(Key key, Value value, boolean color, int n) {
            this.key = key;
            this.value = value;
            this.color = color;
            N = n;
        }
    }

    /**
     * 獲得改結點與其父節點之間鏈接的顏色
     * @param node
     * @return
     */
    private boolean isRed(Node node){
        if (node == null){
            return false;
        }
        return node.color;
    }
    ……其他方法

}

接下來我們先說一下紅黑樹的3個經典變換(左旋轉,右旋轉,顏色變換), 這些操作都是爲了保持紅黑的性質。

左旋的動畫(圖源)如圖所示:(其中,在最上面的那根灰色的鏈接可紅可黑)

 

 

 

有動態圖後,實現Java就特別簡單了,其中h,x和gif中的h,x相對應。

/**
 * 左旋轉
 * @param h
 * @return 返回根節點
 */
private Node rotateLeft(Node h) {
    Node x = h.rightNode;
    h.rightNode = x.leftNode;
    x.leftNode = h;
    x.color = h.color;
    h.color = RED;
    x.N = h.N;
    h.N = size(h.leftNode)+size(h.rightNode)+1;
    return x;
}

有左旋轉當然有右旋轉,下面是右旋轉的gif圖片。

 

 

 

/**
 * 右旋轉
 * @param h
 * @return 返回根節點    
 */
private Node rotateRight(Node h) {
    Node x = h.leftNode;
    h.leftNode = x.rightNode;
    x.rightNode = h;
    x.color = h.color;
    h.color = RED;
    x.N = h.N;
    h.N = size(h.leftNode)+size(h.rightNode)+1;
    return x;
}

有前面的定義我們知道沒有任何一個結點同時和兩條紅鏈接相連接,那麼出現了我們該怎麼辦呢?進行顏色轉換即可。

 

 

 

/**
 * 顏色轉換
 * @param h 
 */
private void changeColor(Node h){
    h.color = !h.color;
    h.leftNode.color = !h.leftNode.color;
    h.rightNode.color = h.rightNode.color;
}

在準備好這些操作後,我們就可以來正式的談一談put函數了。由前面的2-3 樹我們知道2-3樹的插入分爲了很多種情況,既然紅黑樹是2-3樹的一種實現,毋庸置疑,紅黑樹的插入情況也分爲多種:2-結點插入(根節點葉子結點),3-結點插入。

2-結點插入:2-結點插入實際上就是其父節點沒有紅鏈接與之相連接

  • 根結點插入(向左插入和向右插入)

     

     

     

  • 葉子結點插入

     

     

     

**3-結點插入:**也就是其父節點有一個紅鏈接與之相連接。

  • 一個3-結點插入

    在下圖中,分爲了三種情況,larger(新鍵最大),smaller(新鍵最小),between(新鍵介於兩者中間)

 

 

 

  • 向樹底部插入

     

     

     

那麼我們該什麼時候使用左旋轉和右旋轉呢?下面有幾條規律

  • 若右子節點爲紅色而左子節點爲黑色,則左旋轉
  • 若左子節點爲紅色且左子節點的左子節點也爲紅色則右旋轉
  • 左右結點都爲紅色,則顏色轉換

下面有一張轉換關係圖片。

 

 

 

有了這些轉換關係圖片我們寫插入函數也就比較輕鬆了。

插入算法Java代碼:

/**
  * 紅黑樹的插入
  * @param key
  * @param value
  */
public void put(Key key,Value value){
   if(key == null){
   		return ;
   	}
    root = put(root,key,value);
    // 進行顏色變換會將本身結點變紅,而根節點必須保持黑色
    root.color = BLACK;
}

/**
 * 插入操作進行遞歸調用
 * @param h
 * @param key
 * @param value
 * @return
 */
private Node put(Node h, Key key, Value value) {
    // 當h爲葉子結點的左子樹或者右子樹
    if (h == null){
        return new Node(key,value,RED,1);
    }
    // 假如key小於結點的key,就轉向左結點
    if (key.compareTo(h.key)<0){
        h.leftNode = put(h.leftNode,key,value);
    }
    // 轉向右結點
    else if (key.compareTo(h.key)>0){
        h.rightNode = put(h.rightNode,key,value);
    }
    // 更新
    else{
        h.value = value;
    }
    // 若左邊結點是黑色,右邊結點是紅色,則左旋轉
    if (isRed(h.rightNode) && !isRed(h.leftNode)){
        h = rotateLeft(h);
    }
    // 若左子節點爲紅色且左子節點的左子節點也爲紅色則右旋轉
    if (isRed(h.leftNode) && isRed(h.leftNode.leftNode)){
        h = rotateRight(h);
    }
    // 左右結點都爲紅色,則顏色轉換
    if (isRed(h.leftNode) && isRed(h.rightNode)){
        changeColor(h);
    }
    h.N = size(h.leftNode)+size(h.rightNode) + 1;
    return h;
}

該段插入算法來自《算法(第四版)》。

接下來我們可以來說一說紅黑樹中另外一個比較難的操作:刪除。刪除這個操作不難,難的是我們如何在刪除後任然保持紅黑樹的性質,這個問題纔是難點。

在說刪除之前我們可以回憶下前面的查找二叉樹的刪除中的複製刪除方法,我們使用前驅後者後繼的key和value來替代被刪除的結點【注意:只有key和value互換,顏色不變】,那麼如果我們在紅黑樹中也使用複製刪除這種算法是不是能夠把問題變得簡單呢?當我們把刪除的結點轉移到前驅或者後繼的時候,那麼問題就變成了刪除紅色的葉子結點和刪除黑色的葉子結點【注意:改葉子結點指的不是NULL(NIL)結點】。

因此我們可以很簡單的將問題分成兩類:

  • 刪除紅色葉子結點
  • 刪除黑色葉子結點

刪除紅色葉子結點:

​ 刪除紅色的葉子結點並沒有什麼好說的,直接刪除,因爲刪除紅色的葉子結點並不會影響破壞紅黑樹的平衡。因爲我們知道一個紅色結點絕對不可能存在只有左結點或者只有右結點的情況(可以解釋下:若存在左子結點或右子結點,則子節點絕對會是黑色的,那麼則會違反“從任一節點到其每個葉子的所有簡單路徑都包含相同數目的黑色節點”這個性質)

如下圖(圖源),就是不符合性質5,因爲左邊結點和右邊結點到根節點的經過的黑色結點的數量相差一。

 

 

 

刪除黑色葉子結點:

刪除黑色結點有個很大的問題,那就是會破壞從任一節點到其每個葉子的所有簡單路徑都包含相同數目的黑色節點這個性質,所以我們不得不對黑色結點的刪除做一下考慮。對此我們又可以分兩個方面來考慮:

  • 刪除的黑色結點只有左子結點或者右子結點
  • 刪除的黑色結點沒有子結點
  1. 刪除的黑色結點只有左子結點或者右子結點

    前面我們知道,我們使用的是前驅或者後繼結點來替代被刪除的結點,那麼被刪除的結點只最多隻有一個子節點。並且由於紅黑樹的性質我們知道情況只能爲以下兩種情況(可以想下爲什麼結點不能爲黑色):

     

    前驅

    前驅

    後繼

     

    對於這種,我們只需要將被刪除黑結點的子節點替代被刪除的結點即可,並將顏色改爲black即可。

  2. 刪除的黑色結點沒有子結點

    這種情況是最複雜的,so,讓我們來好好聊一聊這個東東。

    2.1 待刪除的節點的兄弟節點是紅色的節點。

    ​ 因爲刪除的結點的兄弟結點是紅色結點,我們進行左旋轉,如圖所示,我們可以發現紅黑樹的性質並沒有被破害。

    當然如果B結點在右邊,我們使用右旋轉就好了。這個時候情況就變成了下面2.2的那種情況。

    圖片來源美團技術團隊

     

     

     

    2.2 待刪除的節點的兄弟節點是黑色的節點,且兄弟節點的子節點都是黑色的。

    下面的這幅圖我糾結了很久,當時我看的時候,我一直在糾結爲什麼紅黑樹會出現下面的這種情況,因爲它明顯不符合紅黑樹的性質。我認爲是博主搞錯了,後來我又去看了聖書《算法導論》也是這樣的,就更加的堅信是我想錯了。

    在這裏我說下自己的理解:

    在下面的圖中,DE結點不一定是有數據的結點,也可能爲NULL結點(NULL結點也是黑色的,這是我們的規定),如果爲NULL結點,那麼這種情況就回到了上圖中的情況,B的兄弟結點D爲黑色。 我們按照下圖的操作,然後將D結點變成紅色。在將B結點刪除後,這個時候我們很容易的知道A結點的兄弟結點和A結點絕對不平衡了(違反了性質5),這個時候我們將B結點看成A結點以及它的子樹C結點就看成A結點的兄弟結點。(這便是一個遞歸操作)

     

     

     

    2.3 待調整的節點的兄弟節點是黑色的節點,且兄弟節點的左子節點是紅色的,右節點是黑色的(兄弟節點在右邊),如果兄弟節點在左邊的話,就是兄弟節點的右子節點是紅色的,左節點是黑色的。

    這種狀態我們可以理解爲D是一棵左子樹,E是一棵右子樹,這樣理解的話樹還是可以保持平衡的。不過在*美團技術團隊*上面說“他是通過case 2(也就是2.2操作)操作完後向上回溯出現的狀態”,不過我沒畫出這幅圖。

    ​ 在這種情況下我們可以這樣理解:

    在B子樹中已經缺失了一個黑結點,那麼我們必須要在D子樹中和E子樹中,讓他們也損失一個黑結點(這個不像2.1我們直接將兄弟變紅就行了,因爲該節點的左結點爲紅結點),so,我們先將結點左旋轉,得到2.4的情況

     

     

     

    2.4 待調整的節點的兄弟節點是黑色的節點,且右子節點是是紅色的(兄弟節點在右邊),如果兄弟節點在左邊,則就是對應的就是左節點是紅色的。

    ​ 關於這個我們可以這樣理解:

    我們將D結點變紅後,那邊右邊就要兩個連續的紅色結點了,so,我們需要左旋轉,同時A結點變紅。這樣右邊的結點就少了一個黑色的結點。樹局部平衡

     

     

     

這裏有一個《算法第四版》關於紅黑樹的參考資料。我會根據算法的思路逐個解決問題。

首先,我們可以想一想,當我們刪除一個結點的時候會進行一些操作,但是我們必須得保證黑鏈的平衡,這個時候我們就要根據一些規則來平衡樹:

  • 若右子節點爲紅色而左子節點爲黑色,則左旋轉
  • 若左子節點爲紅色且左子節點的左子節點也爲紅色則右旋轉
  • 左右結點都爲紅色,則顏色轉換

讓我們來寫一個修複函數吧:

/**
 * 平衡樹
 * @param h
 * @return
 */
private Node fixUp(Node h){
    if (isRed(h.rightNode)){
        h = rotateLeft(h);
    }
    if (isRed(h.leftNode) && isRed(h.leftNode.leftNode)){
        h = rotateRight(h);
    }
    if (isRed(h.leftNode) && isRed(h.rightNode)){
        changeColor(h);
    }
    h.N = size(h.leftNode)+size(h.rightNode)+1;
    return h;
}

有了這個函數,接下來讓我們實現一個刪除最大值的函數:

在前面我們知道刪除一個紅結點直接刪除就行了,所以如果被刪除的結點附近有紅結點,然後直接進行刪除豈不是美滋滋!!

 

 

 

這個步驟很簡單,就是將紅色的結點移動到最右邊:

/**
 * 將紅色結點移動到右邊
 * @param h
 * @return
 */
private Node moveRedRight(Node h){
    changeColor(h);
    // 假如結點的左結點的左結點爲紅色結點
    if (isRed(h.leftNode.leftNode)){
        // 進行右轉
        h = rotateRight(h);
        // 然後改顏色
        changeColor(h);
    }
    return h;
}

接下來我們就可以進行刪除最大元素了:

public void deleteMax(){
    if (root == null){
        return;
    }
    if (!isRed(root.leftNode) && !isRed(root.rightNode)) {
        root.color = RED;
    }
    root = deleteMax(root);
    // 刪除之後root不爲空,則將root的顏色變爲黑色
    if (root != null){
        root.color = BLACK;
    }
}

private Node deleteMax(Node h) {
    // 假如結點的左邊是紅色結點,則進行右旋轉
    if (isRed(h.leftNode)){
        h = rotateRight(h);
    }
   	// 如果右邊爲空的,則代表以及達到最大的結點
    if (h.rightNode == null){
        return null;
    }
    // 假如結點的右子節點爲是黑色,右子節點的左子節點是黑色
    // 在這種情況下,我們進行右旋轉沒辦法得到將紅色的結點轉到右邊來
    // 所以我們執行moveRedRight並在裏面創造紅色的結點
    if (!isRed(h.rightNode) && !isRed(h.rightNode.leftNode)){
        h = moveRedRight(h);
    }
    h.rightNode = deleteMax(h.rightNode);
    return fixUp(h);
}

下面是一個關於刪除最大值的例子:

既然我們能夠刪除最大值,那麼也就能夠刪除最小值,刪除最小值就是將紅色的結點移動到左邊:

 

 

 

/**
 * 將紅色結點移動到左邊
 * @param h
 * @return
 */
private Node moveRedLeft(Node h){
    changeColor(h);
    if (isRed(h.rightNode.leftNode)){
        h.rightNode = rotateLeft(h.rightNode);
        h = rotateLeft(h);
        changeColor(h);
    }
    return h;
}

然後是刪除最小值的算法

public void deleteMin(){
    if (root == null){
        return;
    }
    if (!isRed(root.leftNode) && !isRed(root.rightNode)) {
        root.color = RED;
    }
    root = deleteMin(root);
    if (root != null){
        root.color = BLACK;
    }
}

private Node deleteMin(Node h) {
    if (h.leftNode == null) {
        return null;
    }
    if (!isRed(h.leftNode) && !isRed(h.leftNode.leftNode)){
        h = moveRedLeft(h);
    }
    h.leftNode = deleteMin(h.leftNode);
    return fixUp(h);
}

上面的刪除最大和刪除最小值的操作總結起來就是:我希望我刪除的結點是紅色的結點,如果不是紅色的結點,那麼我就去借紅色的結點。以刪除最大值爲例,我希望h的右子結點爲紅色結點,如果沒有怎麼辦?那麼我們就去左子節點的左邊拿(也就是進行右旋轉-這樣可以保證h的下一個結點爲紅色),如果左子節點爲黑色。但是h的右子節點爲的左子節點爲紅色,我們就可以安安心心的向右子節點移了,因爲我們可以保證右子節點的右子節點爲紅結點(通過右旋轉),如果不是怎麼辦?創造就行了。

說完這麼多,我們終於可以來說說刪除操作了:

public void delete(Key key){
    if (key == null){
        return;
    }
    // 首先將結點變紅以便於操作
    if (!isRed(root.leftNode) && !isRed(root.rightNode)) {
        root.color = RED;
    }
    root = delete(root,key);
    if (root != null){
        root.color = BLACK;
    }

}

private Node delete(Node h, Key key) {
    if (key.compareTo(h.key)<0){
        if (!isRed(h.leftNode) && !isRed(h.leftNode.leftNode)){
            h = moveRedLeft(h);
        }
        h.leftNode = delete(h.leftNode,key);
    }
    // 假如key比Node的key要大,或者相等
    else{
        // 左子節點爲紅色,則進行右旋轉,這樣能夠將紅色結點向右集中
        if (isRed(h.leftNode)){
            // 通過右轉能夠將紅色結點上升
            h = rotateRight(h);
        }
        // 這一步中,假如h的右結點爲空,則h爲葉子結點(此葉子結點並不代表NULL結點)
        // 因爲假如有左子節點的話,那麼左子節點一定是紅色(因爲右子節點爲空),那麼在上面的一個if語句中h已經被右旋轉到了右子節點
        // 且h必定爲紅色的結點,這個時候我們就可以直接刪除
        if (key.compareTo(h.key) == 0 && h.rightNode == null){
            return null;
        }


        if (!isRed(h.rightNode) && !isRed(h.rightNode.leftNode)){
            h = moveRedRight(h);
        }

        if (key.compareTo(h.key) == 0){
            // 找到h的後繼結點,然後交換key和value,然後就可以刪除最小節點了
            Node x = min(h.rightNode);
            h.key = x.key;
            h.value = x.value;
            h.rightNode = deleteMin(h.rightNode);
        }
        else{
            h.rightNode = delete(h.rightNode,key);
        }
    }
    return fixUp(h);
}
/**
 * 找後繼結點
 * @param x
 * @return
 */
private Node min(Node x) {
    if (x.leftNode == null) {
        return x;
    }
    else{
        return min(x.leftNode);
    }
}

以上的來自來自於《算法第四版》,這個是官網的源代碼。寫的真好,簡潔明瞭。我們可以根據這個源代碼把自己的思路理一理,想一想爲什麼它要這樣去寫。

紅黑樹 插入 刪除 查找 空間
複雜度 O(lgN) O(lgN) O(lgN) O(n)

HASH大法好

散列表

散列表是一個比較簡單的數據結構,但是卻設計的很精妙,大家可以看看我寫的關於Java中HashMap的源碼分析

散列表的思想很簡單,就是將KEY通過數學方法轉化爲數組的索引,然後通過索引來訪問數組中的值value。如果大家能夠將那篇HashMap的源碼分析看懂,那麼這一小節的內容也輕而易舉。

 

 

 

由上圖我們知道,如何設置一個好的散列方法是算法中的很重要的一部分。總的來說,爲一個數據結構實現一個優秀的散列方法需要滿足3個條件。

  • 一致性:等價的key必定要產生相等的散列值
  • 高效性:計算簡便
  • 均勻性:均勻地散列所有的鍵

在HashMap的源碼中,我們可以知道HashMap是使用拉鍊法(或者紅黑樹)來解決hash衝突的。實際上我們還有一種方法,叫做開放地址散列表,我們使用大小爲M的數組來保存N個鍵值對(M>N),通過依靠數組中的空位來解決碰撞衝突。

開放地址散列表中最簡單的方法叫做線性探測法:當碰撞發生時(也就是數組中該散列值的位置已經存在一個鍵了),我們就直接檢查散列表中的下一個位置(將索引值+1)。so,我們可以來寫一下代碼實現它。

我們可以先將插入,查找寫完

public class LinearHashST<Key extends Comparable<Key>,Value> {
    private Key[] keys;
    private Value[] values;
    /**
     * 鍵值對的數量
     */
    private int N;

    /**
     * 默認數組大小
     */
    private int M = 16;

    public LinearHashST() {
        keys = (Key[]) new Object[M];
        values = (Value[]) new Object[M];
    }
    
    /**
     * 初始化容量
     * @param N 指令數組大小
     */
    public LinearHashST(int N) {
        M = N;
        keys = (Key[]) new Object[M];
        values = (Value[]) new Object[M];
    }

    private int hash(Key key){
        return (key.hashCode()&0x7fffffff)%M;
    }
    public void put(Key key,Value value){
        // 如果容量達到閥值,則擴容
        if (N>=M*0.8){
            resize(M*2);
        }
        // 得到hash值
        int h;
        for (h = hash(key);keys[h]!=null;h = (h+1)%M){
            // key相等則更新
            if (key.compareTo(keys[h]) == 0){
                values[h] = value;
                return;
            }
        }
        keys[h] = key;
        values[h] = value;
        N ++;
    }
    public Value get(Key key){
        int h;
        for (h = hash(key);keys[h]!=null;h=(h+1)%M){
            if (key.compareTo(keys[h]) == 0){
                return values[h];
            }
        }
        return null;
    }
}

接下來我們來說說刪除操作:

操作操作我們僅僅就是就是將位置上面的key和value變成null嗎?不,當然不是。我們舉個例子(其中S和C是同一個hash值2,也就是他們是產生了hash衝突,假如我們刪除了S並把它置爲NULL):

0 1 2 3 4 5 6 7 8 9
A   S Z C D   F V G
A   NULL Z C D   F V G

我們這個時候可以用get算法看一看,看看是否能夠找到C。是不是發現我們並不能夠找到C。這時候可能有人會有疑問,我全部循環一遍,不就ok了嗎?但是,如果這樣操作我們是不是就失去了散列表的查找時間複雜度的優勢了呢?

讓我們來看一看散列表的delete操作,當我們刪除某個位置上面的鍵值對時,我們就需要將被刪除位置上面的坑填好。哪麼哪些元素回來填這個坑呢?1. 本身hash值爲這個位置的鍵值對,但是因爲這個“坑”被佔了而不得不下移一位的結點。2. hash值與被刪除結點的hash值一樣,所以它可能會有機會來補這個“坑”位

/**
 * 進行刪除操作
 * @param key
 */
public void delete(Key key){
    int h = hash(key);
    while(keys[h]!=null){
        // 假如key存在
        if (keys[h].compareTo(key) == 0){
            keys[h] = null;
            values[h] = null;
            // 鍵值對數量減1
            N--;
            for (h=(h+1)%M; keys[h] != null;h=(h+1)%M){
                // 將被刪除結點後面的重新排列一下
                Key keyToRedo = keys[h];
                Value valToRedo = values[h];
                keys[h] = null;
                values[h] = null;
                // 之所以N--是因爲在put操作中N++了
                N--;
                put(keyToRedo,valToRedo);
            }
            // 縮小容量
            if (N>0 && N == M/8){
                resize(M/2);
            }

        }
        h = (h+1)%M;
    }
    return;
}

接下來就是擴容操作了(耗時操作)

/**
 * 進行改變容量操作
 * @param cap 容量大小
 */
private void resize(int cap){
    LinearHashST<Key,Value> linearHashST = new LinearHashST(cap);
    for (int i=0;i<M;i++) {
        if (keys[i] != null){
            linearHashST.put(keys[i],values[i]);
        }
    }
    keys = linearHashST.keys;
    values = linearHashST.values;
    M = linearHashST.M;
}
線性探測法 插入 刪除 查找 空間 擴容
複雜度 O(1) O(N) O(1) O(n) O(n)

​ 寫完這篇博客我是一個頭兩個大,寫的快哭了同時也快吐了,有史以來寫的最吃力的一篇博客,其中在紅黑樹的刪除花了自己將近3天的時間。誰讓我這麼菜呢,我能怎麼辦,我也很無奈啊。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章