【數據結構】樹的簡單分析總結(附js實現)

本文會針對樹這種數據結構,進行相關內容的闡述。其實本文應該算是一篇讀書筆記。node

內容總覽

  • 二叉樹
  • 二叉查找樹
  • 堆和堆的一些操做
  • 堆排序
  • 堆的應用

另外,我先在這裏給出 js 實現的源碼地址:git

  1. 樹和二叉樹
  2. 堆的實現
  3. 堆排序

這裏簡單說下樹是什麼。github

樹是一種非線性的數據結構。樹中的元素稱爲「節點」。每一個節點有有限個子節點或沒有子節點,且樹中不能有環路。面試

兩個相連的節點的關係稱爲 「父子關係」。算法

一些術語(摘自維基百科):編程

  1. 節點的度:一個節點含有的子樹的個數稱爲該節點的度;
  2. 樹的度:一棵樹中,最大的節點的度稱爲樹的度;
  3. 葉節點或終端節點:度爲零的節點;
  4. 非終端節點或分支節點:度不爲零的節點;
  5. 父親節點或父節點:若一個節點含有子節點,則這個節點稱爲其子節點的父節點;
  6. 孩子節點或子節點:一個節點含有的子樹的根節點稱爲該節點的子節點;
  7. 兄弟節點:具備相同父節點的節點互稱爲兄弟節點;
  8. 節點的層次:從根開始定義起,根爲第1層,根的子節點爲第2層,以此類推;
  9. 深度:對於任意節點n,n的深度爲從根到n的惟一路徑長,根的深度爲0;
  10. 高度:對於任意節點n,n的高度爲從n到一片樹葉的最長路徑長,全部樹葉的高度爲0;
  11. 堂兄弟節點:父節點在同一層的節點互爲堂兄弟;
  12. 節點的祖先:從根到該節點所經分支上的全部節點;
  13. 子孫:以某節點爲根的子樹中任一節點都稱爲該節點的子孫。
  14. 森林:由m(m>=0)棵互不相交的樹的集合稱爲森林;

二叉樹

樹有不少種類,好比二叉樹、三叉樹、四叉樹等。但最經常使用的樹就是二叉樹。api

二叉樹是每一個節點最多隻有兩個分支的樹結構,這兩個分支的節點被稱爲 左子節點右子節點數組

滿二叉樹,指的是除了葉子節點,每一個節點都有兩個子節點的二叉樹。緩存

徹底二叉樹:除了最後一層,其餘層的節點個數都要最大,且最後一層的節點都靠左排列的二叉樹。bash

可能有人以爲徹底二叉樹看起來好像沒什麼用,怎麼還靠左邊的,靠中間不行嗎?其實靠左是由於二叉樹的其中一種數據存儲方式是用數組存儲,使用徹底二叉樹就不會浪費數組的空間(不會出現一些數組元素不存儲的狀況)

二叉樹的存儲

1. 鏈式存儲法

鏈式存儲法,是經過指針的方式來記錄父子關係的一種方法。它有點相似鏈表,每一個節點除了保存自身的數據外,還會有left 和 right 兩個指針,指向另外兩個節點。

const node = {
    data: 1,         // 節點保存的數據
    left: node2,    // 左子節點指向 node2 節點
    right: null     // null 表示沒有右子節點
}
複製代碼
2. 順序存儲法

用數組存儲。爲了代碼可讀性更好,咱們通常會選擇浪費數組下標爲 0 的存儲位置,即根節點在下標爲 1 的位置。 這時父節點和左右節點的下標關係以下:

left = 2 * i;
right = 2 * i + 1;
i = left / 2;   
i = right / 2;  // 這裏是向下取整
複製代碼

這裏的 i 爲父節點下標,left 和 right 爲兩個子節點下標。

這裏有個要注意的地方:這裏的父節點的下標值是子節點除以 2 並 取整。(有些編程語言的整數相除,會自動將獲得的結果去掉小數部分,而一些編程語言,好比 Javascript,是會獲得小數的,須要手動向下取整。)

若是你就是不想浪費數組的第一個元素的存儲位置,誓要將根節點保存在數組的第一個位置。那此時父節點和子節點的下標關係爲:

left = 2 * i + 1;
right = 2 * i + 2;
i = (left - 1) / 2;
i = (right - 1) / 2;
複製代碼

若是某棵二叉樹是一棵徹底二叉樹,那用數組存儲無疑是最節省內存的一種方式。

二叉樹的遍歷

這個是很常見的面試題呢。

1. 前序遍歷

根左右。 這裏的「前」描述的是根節點,即根節點最早輸出(打印),而後輸出左子樹,最後輸出右子樹。

代碼中的樹是用 鏈式存儲法 存儲的。代碼實現用到了 遞歸

// 前序遍歷(根左右)
preOrder() {
    let order = '';
    r(this.root);

    order = order.slice(0, order.length - 1); // 這裏只是去掉最後的一個逗號。
    return order;

    // 遞歸函數
    function r(node) {
        if (!node) return;
        order += `${node.val},`;
        r(node.left);
        r(node.right);
    }
},
複製代碼
2. 中序遍歷

左根右。 「中序」的這個「中」也是指的根節點的輸出位置是中間。中序遍歷先輸出左子樹,再輸出根節點,最後輸出右子樹。

// 中序遍歷
inOrder() {
    let order = '';
    r(this.root);

    order = order.slice(0, order.length - 1);
    return order;

    // 遞歸函數
    function r(node) {
        if (!node) return;
        r(node.left);
        order += `${node.val},`;
        r(node.right);
    }
},
複製代碼
3. 後續遍歷

左右根。 先打印左子樹,而後打印根節點,最後打印右子樹。

postOrder() {
    let order = '';
    r(this.root);

    order = order.slice(0, order.length - 1);
    return order;

    // 遞歸函數
    function r(node) {
        if (!node) return;
        r(node.left);
        r(node.right);
        order += `${node.val},`;
    }
},
複製代碼
4. 層次遍歷

層次遍歷,就是每層的節點從左往右遍歷,直到遍歷完全部節點。若是是順序存儲法存儲的,數組從前日後遍歷便可。若是是鏈式存儲法存儲樹,實現就會複雜一些,要用到一個隊列

levelOrder() {
    if (this.root == null) return '';
    let a = [],
        left, right;
    a.push(this.root);

    // 節點入隊,指針指向頭部元素,若是它有left/right,入隊。
    // 指針後移,繼續一樣步驟。。。直至指針跑到隊列尾部後面去。。。
    for(let i = 0; i < a.length; i++) {     // 須要注意的是,數組 a 的長度是動態變化的(不停變長)
        left = a[i].left;
        if (left) a.push(left);

        right = a[i].right;
        if (right) a.push(right);
    }
    return a.map(item => item.val).join(',');
}
複製代碼

二叉查找樹

二叉查找樹,也叫作 二叉搜索樹。此外它也被稱爲 二叉排序樹,由於中序遍歷就能夠獲得有序的數據序列(很是高效,時間複雜度是 O(n))。

二叉查找樹的做用是快速查找。除了快速查找,它也支持快速插入、刪除數據。

那麼什麼樣的二叉樹纔是二叉查找樹呢?二叉查找樹是任意一個節點的左子樹的節點都小於該節點,任意一個節點的的右子樹的節點都大於該節點的二叉樹。

根據定義,二叉查找樹是 不容許有兩個數據相同的節點的

二叉查找樹的查找操做

先和根節點的值比較,若是相等,就找到了;若是要查找的值比根節點小,就在左子樹中遞歸查找;若是比根節點大,就在右子樹中遞歸查找。

find(val) {
    // 假設二叉樹沒有重複數據
    let p = this.root;
    while(p != null) {
        if (val == p.val) return p;
        else if (val < p.val) p = p.left;
        else p = p.right;
    }
    return null; // 沒找到
},
複製代碼

二叉查找樹的插入操做

相似查找操做。從根節點開始,比較要插入的數據和節點的大小關係。若是要插入的數據比當前節點的數據大,且右子樹爲空,就做爲該節點的右子節點;若是右子樹不爲空,就繼續遞歸右子樹。同理,比當前節點數據小,就看該節點的左子樹,若爲空,插入到左子節點位置;若不爲空,就遞歸左子樹。

// 插入節點
insert(val) {
    if (this.root == null) {
        this.root = node;
        return true;
    }

    let node = new Node(val);
    let p = this.root;

    while (p != null) {
        if (val < p.val) {
            if (p.left == null) {
                p.left = node;
                return this.inOrder(); 
                // 返回箇中序遍歷的結果,檢查插入是否正確。(你能夠改成 true,表示插入成功)
            }
            p = p.left;
        }
        else if (val > p.val) {
            // preP = p;
            if (p.right == null) {
                p.right = node;
                return this.inOrder();
            }
            p = p.right;
        }

        if (val == p.val) {
            console.warn('二叉樹中含相同值的數據,沒法插入')
            return false;
        }
    }
},
複製代碼

二叉查找樹的刪除操做

這個就很複雜,要分三種狀況:

  1. 要刪除的節點沒有子節點:直接更新父節點指向其的指針爲 null
  2. 要刪除的節點只有一個子節點:父節點中指向要刪除的節點的指針,更新爲要刪除節點的那個子節點。
  3. 要刪除的節點有兩個子節點:找到右子樹中的最小節點(通常是葉子節點),替換到要刪除的節點上。(固然你也能夠選擇找左子樹的最大節點)
// 刪除
remove(val) {
    // 假設二叉樹沒有重複數據
    let p = this.root;
    let parent, dir;   // 暫不考慮只有根節點一個的狀況。
    while(p != null) {
        
        if (val == p.val) {
            // 要刪除的節點沒有子節點
            if (p.left == null && p.right == null) {
                parent[dir] = null;
                return true;
            } 
            // 要刪除的節點只有一個子節點
            else if (p.left == null && p.right != null) {
                parent[dir] = p.right
                console.log('只有右節點');
                return true;
            } else if (p.right == null && p.left != null) {
                parent[dir] = p.left;
                console.log('只有左節點');
                return true;
            } 
            
            // 要刪除的節點有兩個子節點
            // 能夠將右子樹的最小結點替換到被刪除的節點位置,並刪除這個最小節點
            // 固然你也能夠在左子樹中找最大節點。
            else if (p.left != null && p.right != null) {
                // 由於要記錄最小節點的父節點,因此不能用 this.findMin()
                // 第一步:找出最小節點 minP
                let minParent,
                    minP = p;
                while (minP) {
                    if (minP.left == null) {
                        // 找到。
                        break;
                        // return minP;
                    }
                    minParent = minP;
                    minP = minP.left;
                }

                // 第二步:替換(把數據轉移過去便可)
                p.val = minP.val;

                // 第三步:刪除最小節點
                if (minP.right == null) {
                    minParent.left = null;
                    console.log('最小節點沒有子節點');
                    return true;
                } else if (minP.right != null) {
                    minParent.left = minP.right;
                    console.log('最小節點只有右節點');
                    return true;
                } 

            }
            return p;
        } 

        // 繼續找要刪除的節點。
        else {
            parent = p;
            if (val < p.val) {
                p = p.left;
                dir = 'left';
            } else {
                p = p.right;
                dir = 'right';
            }
        }
    }
    return null;   // 沒找到

    // 要保存父節點,且要記錄當前節點是父節點的 left 仍是 right。
},
複製代碼

還有另外一種簡單的刪除操做,就是標記一個節點爲「已刪除」,雖然操做變得簡單了,但「已刪除」的數據仍然在內存中,會浪費內存空間。

支持重複數據的二叉查找樹

通常來講,根據定義,二叉查找樹是不容許有重複數據的。但實際開發中,數據通常不可能不重複。因此咱們看看怎麼使二叉查找樹支持重複數據存儲:

  1. 節點改成能夠存儲多個數據,而不是隻有一個數據。能夠考慮鏈表和動態擴容數組。
  2. 插入過程當中,若是發現已經有重複的數據了,就放到這個節點的右子樹的最左節點的位置(固然你也能夠考慮放到左子樹的最右邊節點位置)。若是是這樣的實現的話,查找操做和刪除操做就要跟着作一些小修改。

平衡二叉樹

維基百科的定義:平衡二叉搜索樹(英語:Balanced Binary Tree)是一種結構平衡的二叉搜索樹,即葉節點高度差的絕對值不超過1,而且左右兩個子樹都是一棵平衡二叉樹。它能在O(logn)內完成插入、查找和刪除操做,最先被髮明的平衡二叉搜索樹爲AVL樹。

平衡二叉樹的發明,是爲了解決二叉樹在不斷插入、刪除等動態操做後,致使時間複雜度退化的問題。它會讓二叉樹儘可能地保持平衡,即保持 矮矮胖胖* 的樣子。

平衡二叉樹中,最爲有名的就是 紅黑樹 了。是否是常常有羣友開玩笑說他們招人,要求當場手寫紅黑樹呢。紅黑樹的性能很好,普遍用於實際開發中,其餘的平衡二叉樹則不多出如今人們的視野之中。

平衡二叉樹的實現很複雜,就暫時不詳細分析了。

堆是一個 每一個節點的數據都大於等於(或小於等於)它的子節點的徹底二叉樹。

對於每一個節點的值都大於等於子樹中每一個節點值的堆,咱們叫做 大頂堆。對於每一個節點的值都小於等於子樹中每一個節點值的堆,咱們叫做 小頂堆

由於堆是徹底二叉樹,因此咱們用數組存儲。

1. 插入一個元素

插入元素到堆,具體作法是插入到數組的末尾,而後經過 堆化 操做,對樹進行調整,從新變成堆。

堆化(heapify),是指一個節點,不停地向上或向下進行交換,直到找到合適的位置,使當前的二叉樹變成堆。

插入時進行的堆化是 從下往上堆化。新插入的元素位於末尾,須要不停地和父元素進行比較和進行交換,直到找到合適的位置結束,此時樹就會又變成堆。

// 入堆,從下往上堆化。
insert(val) {
    // count 指的是當前數組存儲數據的大小,n 爲 數組的容量
    //(固然js的數組是動態數組。這裏的 n 是我手動加的限制)
    if (this.count >= this.n) {   
        console.log('堆滿了,別加了!!')
        return;
    }
    this.count++;

    let a = this.a;  // a 是存儲數據的數組
    a[this.count] = val;

    let i = this.count,
        j = Math.floor(i/2);   // 臨時存儲 i/2

    while (i > 1 && a[i] > a[j]) {
        [a[i], a[j]] = [a[j], a[i]];
        i = j;
        j = Math.floor(i/2);
    }
    return true;
}
複製代碼

2. 刪除堆頂元素

刪除了堆頂元素後,咱們須要把最後一個元素移動到堆頂元素位置,而後進行 從上往下的堆化

從上往下堆化具體的實現是:最後一個元素替換掉堆頂元素後,就比較堆頂元素和它的兩個子節點,看看誰最大,若是不是堆頂元素最大,堆頂元素就和值最大的子節點交換。重複上面的步驟,直到當前節點最大或者當前節點成爲葉子節點(到底了)。

// 刪除堆頂元素
removeMax() {
    if (this.count == 0) return false;  // 堆爲空

    this.a[1] = this.a[this.count];
    this.a[this.count] = undefined;
    this.count--;

    // 從上往下 堆化
    let i = 1,
        maxPos = i;

    while (true) {
        if (i * 2 <= this.count && this.a[i*2] > this.a[maxPos]) maxPos = i * 2;
        if (i * 2 + 1 <= this.count && this.a[i*2 + 1] > this.a[maxPos]) maxPos = i * 2 + 1;
        if (maxPos == i) {
            break;
        }
        [this.a[i], this.a[maxPos]] = [this.a[maxPos], this.a[i]];
        i = maxPos;
    }
    return true;
}
複製代碼

堆排序

堆排序算法分兩個步驟:先建堆,而後進行排序。

1. 建堆

對數組進行 原地 建堆。原地是指在原數組上進行操做,不須要另開一個堆。

建堆的方式有兩種:
  1. 藉助前面提到的插入操做的方式。

這裏就是往 「堆區域」 末尾插入元素,而後從下往上堆化。相似插入排序,咱們將數組分爲 「堆區域」 和 「未處理區域」,不停地將「未處理區域」裏的元素插入到「堆區域」 中,直到遍歷完整個數組。

  1. 從最後一個非葉子節點開始往前,進行 從上往下的堆化

葉子節點由於沒有子節點,因此不須要進行堆化。另外,對於徹底二叉樹來講,最後一個非葉子節點的下標爲 i / 2(i 從1開始,你能夠本身畫個徹底二叉樹驗證一下)。

建堆的複雜度是 O(n)。

2. 排序

將堆頂節點和最後一個節點進行交換,而後對剩餘的 n -1 個元素進行從上往下堆化。而後咱們再交換堆頂節點和第 n - 1 個元素,而後對剩餘的 n - 2 個元素進行從上往下堆化,就這樣不停地交換和堆化,直到堆中只有一個元素。

性能分析

1. 堆排序的時間複雜度是 O(nlogn)。

建堆的時間複雜度是 O(n),排序過程的時間複雜度是 O(nlogn),因此堆排序的時間複雜度是 O(nlogn)。

2. 堆排序是不穩定的排序

由於排序過程當中,堆頂元素會和堆的最後一個元素進行交換,致使排序不穩定。

3. 堆排序是原地排序

堆排序和快速排序的比較

快速排序比堆排序好。理由以下:

  1. 堆排序訪問數據方式很差,是跳着訪問數組元素的,不利於 CPU緩存
  2. 堆排序的交換操做更多。堆排序的建堆完成後,會下降數據的有序堆,這樣會使得交換操做變多。

代碼實現是在原數組上進行數據交換的。

堆的應用

1. 優先級隊列

優先級能夠用於解決 合併有序小文件、高性能定時器 等問題。

2. 求 TopK 數據

這個就是維護一個大小爲 k 的小頂堆。將未入堆的元素和堆頂元素比較,若是比堆頂元素大,就入堆,直到全部元素都入堆後,這個堆就是 TopK 元素了。

時間複雜度是 O(nlogK)。最壞狀況下,一次堆化須要 O(logK),要進行 n 次堆化操做。

3. 求中位數

若是是靜態數據,先排序,而後求中位數便可,這樣邊際成本低。

若是是動態數據,那就須要維護一個 大頂堆 和一個小頂堆。要求大頂堆的數量等於小頂堆的數量或小頂堆的數量+1,且大頂堆的元素都小於小頂堆的元素。中位數即大頂堆的堆頂元素。

初始化的時候,能夠用相似 topK 算法,弄一個數量爲 k 爲 n/2 的小頂堆放數組右邊,而後將剩餘的元素轉換爲大頂堆。

而後每次添加數據的時候,都會分別比較大頂堆的堆頂元素和小頂堆的堆頂元素,決定插入到哪邊。插入後,還要進行一些堆的插入和刪除操做,以維持這兩個堆數量要差很少相同(左右元素數量相同或者左邊堆比右邊多一個)。

除了能夠利用堆求中位數,咱們還能夠利用堆計算 「99% 響應時間」 的問題。即維護數量爲 99/n 的大頂堆和數量爲 1/n 的小頂堆。

參考文獻

  1. 維基百科——樹 (數據結構)
  2. 數據結構與算法之美
相關文章
相關標籤/搜索