本文會針對樹這種數據結構,進行相關內容的闡述。其實本文應該算是一篇讀書筆記。node
另外,我先在這裏給出 js 實現的源碼地址:git
這裏簡單說下樹是什麼。github
樹是一種非線性的數據結構。樹中的元素稱爲「節點」。每一個節點有有限個子節點或沒有子節點,且樹中不能有環路。面試
兩個相連的節點的關係稱爲 「父子關係」。算法
一些術語(摘自維基百科):編程
樹有不少種類,好比二叉樹、三叉樹、四叉樹等。但最經常使用的樹就是二叉樹。api
二叉樹是每一個節點最多隻有兩個分支的樹結構,這兩個分支的節點被稱爲 左子節點 和 右子節點。數組
滿二叉樹,指的是除了葉子節點,每一個節點都有兩個子節點的二叉樹。緩存
徹底二叉樹:除了最後一層,其餘層的節點個數都要最大,且最後一層的節點都靠左排列的二叉樹。bash
可能有人以爲徹底二叉樹看起來好像沒什麼用,怎麼還靠左邊的,靠中間不行嗎?其實靠左是由於二叉樹的其中一種數據存儲方式是用數組存儲,使用徹底二叉樹就不會浪費數組的空間(不會出現一些數組元素不存儲的狀況)
鏈式存儲法,是經過指針的方式來記錄父子關係的一種方法。它有點相似鏈表,每一個節點除了保存自身的數據外,還會有left 和 right 兩個指針,指向另外兩個節點。
const node = {
data: 1, // 節點保存的數據
left: node2, // 左子節點指向 node2 節點
right: null // null 表示沒有右子節點
}
複製代碼
用數組存儲。爲了代碼可讀性更好,咱們通常會選擇浪費數組下標爲 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;
複製代碼
若是某棵二叉樹是一棵徹底二叉樹,那用數組存儲無疑是最節省內存的一種方式。
這個是很常見的面試題呢。
根左右。 這裏的「前」描述的是根節點,即根節點最早輸出(打印),而後輸出左子樹,最後輸出右子樹。
代碼中的樹是用 鏈式存儲法 存儲的。代碼實現用到了 遞歸。
// 前序遍歷(根左右)
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);
}
},
複製代碼
左根右。 「中序」的這個「中」也是指的根節點的輸出位置是中間。中序遍歷先輸出左子樹,再輸出根節點,最後輸出右子樹。
// 中序遍歷
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);
}
},
複製代碼
左右根。 先打印左子樹,而後打印根節點,最後打印右子樹。
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},`;
}
},
複製代碼
層次遍歷,就是每層的節點從左往右遍歷,直到遍歷完全部節點。若是是順序存儲法存儲的,數組從前日後遍歷便可。若是是鏈式存儲法存儲樹,實現就會複雜一些,要用到一個隊列
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;
}
}
},
複製代碼
這個就很複雜,要分三種狀況:
// 刪除
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。
},
複製代碼
還有另外一種簡單的刪除操做,就是標記一個節點爲「已刪除」,雖然操做變得簡單了,但「已刪除」的數據仍然在內存中,會浪費內存空間。
通常來講,根據定義,二叉查找樹是不容許有重複數據的。但實際開發中,數據通常不可能不重複。因此咱們看看怎麼使二叉查找樹支持重複數據存儲:
維基百科的定義:平衡二叉搜索樹(英語:Balanced Binary Tree)是一種結構平衡的二叉搜索樹,即葉節點高度差的絕對值不超過1,而且左右兩個子樹都是一棵平衡二叉樹。它能在O(logn)內完成插入、查找和刪除操做,最先被髮明的平衡二叉搜索樹爲AVL樹。
平衡二叉樹的發明,是爲了解決二叉樹在不斷插入、刪除等動態操做後,致使時間複雜度退化的問題。它會讓二叉樹儘可能地保持平衡,即保持 矮矮胖胖* 的樣子。
平衡二叉樹中,最爲有名的就是 紅黑樹 了。是否是常常有羣友開玩笑說他們招人,要求當場手寫紅黑樹呢。紅黑樹的性能很好,普遍用於實際開發中,其餘的平衡二叉樹則不多出如今人們的視野之中。
平衡二叉樹的實現很複雜,就暫時不詳細分析了。
堆是一個 每一個節點的數據都大於等於(或小於等於)它的子節點的徹底二叉樹。
對於每一個節點的值都大於等於子樹中每一個節點值的堆,咱們叫做 大頂堆。對於每一個節點的值都小於等於子樹中每一個節點值的堆,咱們叫做 小頂堆。
由於堆是徹底二叉樹,因此咱們用數組存儲。
插入元素到堆,具體作法是插入到數組的末尾,而後經過 堆化 操做,對樹進行調整,從新變成堆。
堆化(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;
}
複製代碼
刪除了堆頂元素後,咱們須要把最後一個元素移動到堆頂元素位置,而後進行 從上往下的堆化。
從上往下堆化具體的實現是:最後一個元素替換掉堆頂元素後,就比較堆頂元素和它的兩個子節點,看看誰最大,若是不是堆頂元素最大,堆頂元素就和值最大的子節點交換。重複上面的步驟,直到當前節點最大或者當前節點成爲葉子節點(到底了)。
// 刪除堆頂元素
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;
}
複製代碼
堆排序算法分兩個步驟:先建堆,而後進行排序。
對數組進行 原地 建堆。原地是指在原數組上進行操做,不須要另開一個堆。
這裏就是往 「堆區域」 末尾插入元素,而後從下往上堆化。相似插入排序,咱們將數組分爲 「堆區域」 和 「未處理區域」,不停地將「未處理區域」裏的元素插入到「堆區域」 中,直到遍歷完整個數組。
葉子節點由於沒有子節點,因此不須要進行堆化。另外,對於徹底二叉樹來講,最後一個非葉子節點的下標爲 i / 2(i 從1開始,你能夠本身畫個徹底二叉樹驗證一下)。
建堆的複雜度是 O(n)。
將堆頂節點和最後一個節點進行交換,而後對剩餘的 n -1 個元素進行從上往下堆化。而後咱們再交換堆頂節點和第 n - 1 個元素,而後對剩餘的 n - 2 個元素進行從上往下堆化,就這樣不停地交換和堆化,直到堆中只有一個元素。
建堆的時間複雜度是 O(n),排序過程的時間複雜度是 O(nlogn),因此堆排序的時間複雜度是 O(nlogn)。
由於排序過程當中,堆頂元素會和堆的最後一個元素進行交換,致使排序不穩定。
快速排序比堆排序好。理由以下:
代碼實現是在原數組上進行數據交換的。
優先級能夠用於解決 合併有序小文件、高性能定時器 等問題。
這個就是維護一個大小爲 k 的小頂堆。將未入堆的元素和堆頂元素比較,若是比堆頂元素大,就入堆,直到全部元素都入堆後,這個堆就是 TopK 元素了。
時間複雜度是 O(nlogK)。最壞狀況下,一次堆化須要 O(logK),要進行 n 次堆化操做。
若是是靜態數據,先排序,而後求中位數便可,這樣邊際成本低。
若是是動態數據,那就須要維護一個 大頂堆 和一個小頂堆。要求大頂堆的數量等於小頂堆的數量或小頂堆的數量+1,且大頂堆的元素都小於小頂堆的元素。中位數即大頂堆的堆頂元素。
初始化的時候,能夠用相似 topK 算法,弄一個數量爲 k 爲 n/2 的小頂堆放數組右邊,而後將剩餘的元素轉換爲大頂堆。
而後每次添加數據的時候,都會分別比較大頂堆的堆頂元素和小頂堆的堆頂元素,決定插入到哪邊。插入後,還要進行一些堆的插入和刪除操做,以維持這兩個堆數量要差很少相同(左右元素數量相同或者左邊堆比右邊多一個)。
除了能夠利用堆求中位數,咱們還能夠利用堆計算 「99% 響應時間」 的問題。即維護數量爲 99/n 的大頂堆和數量爲 1/n 的小頂堆。