《算法》之查找

 

 

《算法》之查找

源代碼地址php

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

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,而咱們的目的即是要去實現它。java

Q:咱們僅僅學的是操做算法,學會怎麼去使用get()就好了,爲何還要去學其餘的操做呢?node

A:學習這些操做並非說爲了讓咱們學得更多。相信知道「樹」的同窗都知道,在樹中實現查找是一種很簡單的工做,可是如何插入卻並不簡單,而插入倒是爲了更好的進行查找。特別是在平衡樹中,添加刪除節點每每意味着樹的結構的改變。因此對於咱們而言,並非說僅僅說可以使用get()函數便可,而是應該可以寫get(),put(),delete()等等函數。由於由於這些函數的做用,纔可以讓咱們可以輕輕鬆鬆的使用get函數。git

查找之鏈表和數組

無序的鏈表

首先咱們須要說的是無序的鏈表, i這個沒什麼好說的。由於在無序的i狀況下,沒有什麼騷操做,只能利用for循環一個一個地進行查找。下面將實現插入查找刪除的功能。其餘的功能較爲簡單,就不展現了。github

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;
        }
    }
}

性能分析:redis

無序鏈表 插入 刪除 查找 空間
複雜度 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天的時間。誰讓我這麼菜呢,我能怎麼辦,我也很無奈啊。

 

相關文章
相關標籤/搜索