前言
【從蛋殼到滿天飛】JS 資料結構解析和演算法實現,全部文章大概的內容如下: Arrays(陣列)、Stacks(棧)、Queues(佇列)、LinkedList(連結串列)、Recursion(遞迴思想)、BinarySearchTree(二分搜尋樹)、Set(集合)、Map(對映)、Heap(堆)、PriorityQueue(優先佇列)、SegmentTree(線段樹)、Trie(字典樹)、UnionFind(並查集)、AVLTree(AVL 平衡樹)、RedBlackTree(紅黑平衡樹)、HashTable(雜湊表)
原始碼有三個:ES6(單個單個的 class 型別的 js 檔案) | JS + HTML(一個 js 配合一個 html)| JAVA (一個一個的工程)
全部原始碼已上傳 github,點選我吧,光看文章能夠掌握兩成,動手敲程式碼、動腦思考、畫圖才可以掌握八成。
本文章適合 對資料結構想了解並且感興趣的人群,文章風格一如既往如此,就覺得手機上看起來比較方便,這樣顯得比較有條理,整理這些筆記加原始碼,時間跨度也算將近半年時間了,希望對想學習資料結構的人或者正在學習資料結構的人群有幫助。
並查集 路徑壓縮 Path compression
-
並查集的一個非常重要的優化 路徑壓縮
- 以下三種方式都是完全一樣的,
- 都可以表示這五個節點是相互連線的,
- 也就是說這三種方式是等效的,
- 在具體的查詢過程中,無論是呼叫 find 還是 isConnected,
- 在這三種不同的方式查詢這五個節點中任意兩個節點都是相連線的,
- 但是由於這三種樹它們的深度不同,所以效率是存在不同的,
- 顯然第一種樹的高度達到了 5,所以執行 find(4)這個操作,
- 那相應的時間效能會相對的慢一些,而第三種樹它的高度只有 2,
- 在這棵樹中 find 任意一個節點它響應的時間效能就會比較高,
- 在之前實現的 union 中,是讓根節點去指向另外一個根節點,
- 這樣的一個過程免不了構建出來的樹越來越高,
- 路徑壓縮所解決的問題就是讓一棵比較高的樹能夠壓縮成為一棵比較矮的樹,
- 對於並查集來說每一個節點的子樹的個數是沒有限制的,
- 所以最理想的情況下其實希望每一棵樹都是直接指向某一個根節點,
- 也就是說這個樹它只有兩層,根節點在第一層,其它的所有的節點都在第二層,
- 達到這種最理想的情況可能相對比較困難,所以退而求其次,
- 只要能夠讓這棵樹的高度降低,那麼對整個並查集的整體效能都是好的。
//// 第一種連線方式 的樹 // (0) // / // (1) // / // (2) // / // (3) // / //(4) //// 第二種連線方式 的樹 // (0) // / \ //(1) (2) // / \ // (3) (4) //// 第三種連線方式 的樹 // (0) // / | \ \ //(1)(2)(3)(4) 複製程式碼
-
路徑壓縮
- 路徑壓縮是發生在執行 find 這個操作中,也就是查詢一個節點對應的根節點的過程中,
- 需要從這個節點不斷的向上直到找到這個根節點,那麼可以在尋找的這個過程中,
- 順便讓這個節點的深度降低,順便進行路徑壓縮的過程,
- 只需要在向上遍歷的時候同時執行
parent[p] = parent[parent[p]]
, - 也就是將 p 這個節點的父親設定成這個節點父親的父親,
- 這樣一來每次執行 find 都會讓你的樹降低高度,
- 如下圖,整棵樹原來的深度為 5,經過一輪遍歷後,
- 深度降到了 3,這個過程就叫做路徑壓縮,在查詢節點 4 的時候,
- 順便整棵樹的結構改變,讓它的深度更加的淺了,
- 路徑壓縮是並查集這種資料結構相對比較經典,
- 也是比較普遍的一種優化思路,
- 在演算法競賽中通常實現並查集都要新增上路徑壓縮這樣的優化。
// // 原來的樹是這個樣子 // (0) // / // (1) // / // (2) // / // (3) // / // (4) // // 執行一次find(4) 使用了 parent[p] = parent[parent[p]] // (0) // / // (1) // | // (2) // / \ // (3) (4) // // 然後再從2開始向上遍歷 再使用 parent[p] = parent[parent[p]] // (0) // / \ // (1) (2) // / \ // (3) (4) // 最後陣列就是這個樣子 // 0 1 2 3 4 // ----------------- // prent 0 0 0 2 2 複製程式碼
-
這個 rank 就是指樹的高度或樹的深度
- 之所以不叫做 height 和 depth,
- 是因為進行路徑壓縮的時候並不會維護這個 rank 了,
- 每一個節點都在 rank 中記錄了
- 以這個節點 i 為根的這個集合所表示的這棵樹相應的層數,
- 在路徑壓縮的過程中,節點的層數其實發生了改變,
- 不過並沒有這 find 中去維護 rank 陣列,
- 這麼做是合理的,這就是為什麼管這個陣列叫做 rank
- 而不叫做深度 depth 或高度 height 的原因,
- 它實際在新增上路徑壓縮這樣的一個優化之後,
- 就不再表示當前這個節點的高度或者是深度了,
- rank 這個詞就是排名或者序的意思,
- 給每一個節點其實相應的都有這樣一個排名,
- 當你新增上了路徑壓縮之後,
- 依然是這個 rank 值相對比較低的這些節點在下面,
- rank 值相對比較高的節點在上面,
- 只不過可能出現同層的節點它們的 rank 值實際上是不同的,
- 不過它們整體之間的大小關係依然是存在的,
- 所以 rank 值只是作為 union 合併操作的時候進行的一個參考,
- 它依然可以勝任這樣的一個參考的工作,
- 但是它並不實際反應每一個節點所對應的那個高度值或者深度值,
- 實際上就算你不做這樣的一個 rank 維護也是效能上的考慮,
- 如果要想把每一個節點的具體高度或者深度維護住,
- 相應的效能消耗是比較高的,在整個並查集的使用過程中,
- 其實對於每一個節點非常精準的知道這個階段所處的高度或者深度是多少,
- 並沒有必要那樣去做,
- 使用這樣一個比較粗略的 rank 值就可以完全勝任整個並查集執行的工作了。
程式碼示例
-
(class: MyUnionFindThree, class: MyUnionFindFour, class: MyUnionFindFive, class: PerformanceTest, class: Main)
-
MyUnionFindThree
// 自定義並查集 UnionFind 第三個版本 QuickUnion優化版 // Union 操作變快了 // 還可以更快的 // 解決方案:考慮size 也就是某一棵樹從根節點開始一共有多少個節點 // 原理:節點少的向節點多的樹進行融合 // 還可以更快的 class MyUnionFindThree { constructor(size) { // 儲存當前節點所指向的父節點 this.forest = new Array(size); // 以以某個節點為根的所有子節點的個數 this.branch = new Array(size); // 在初始的時候每一個節點都指向它自己 // 也就是每一個節點都是獨立的一棵樹 const len = this.forest.length; for (var i = 0; i < len; i++) { this.forest[i] = i; this.branch[i] = 1; // 預設節點個數為1 } } // 功能:將元素q和元素p這兩個資料以及他們所在的集合進行合併 // 時間複雜度:O(h) h 為樹的高度 unionElements(treePrimary, treeSecondary) { const primaryRoot = this.find(treePrimary); const secondarRoot = this.find(treeSecondary); if (primaryRoot === secondarRoot) return; // 節點少的 樹 往 節點多的樹 進行合併,在一定程度上減少最終樹的高度 if (this.branch[primaryRoot] < this.branch[secondarRoot]) { // 主樹節點上往次樹節點進行合併 this.forest[primaryRoot] = this.forest[secondarRoot]; // 次樹的節點個數 += 主樹的節點個數 this.branch[secondarRoot] += this.branch[primaryRoot]; } else { // branch[primaryRoot] >= branch[secondarRoot] // 次樹節點上往主樹節點進行合併 this.forest[secondarRoot] = this.forest[primaryRoot]; // 主樹的節點個數 += 次樹的節點個數 this.branch[primaryRoot] += this.branch[secondarRoot]; } } // 功能:查詢元素q和元素p這兩個資料是否在同一個集合中 // 時間複雜度:O(h) h 為樹的高度 isConnected(treeQ, treeP) { return this.find(treeQ) === this.find(treeP); } // 查詢元素所對應的集合編號 find(id) { if (id < 0 || id >= this.forest.length) throw new Error('index is out of bound.'); // 不斷的去查查詢當前節點的根節點 // 根節點的索引是指向自己,如果根節點為 1 那麼對應的索引也為 1。 while (id !== this.forest[id]) id = this.forest[id]; return id; } // 功能:當前並查集一共考慮多少個元素 getSize() { return this.forest.length; } } 複製程式碼
-
MyUnionFindFour
// 自定義並查集 UnionFind 第四個版本 QuickUnion優化版 // Union 操作變快了 // 還可以更快的 // 解決方案:考慮rank 也就是某一棵樹從根節點開始計算最大深度是多少 // 原理:讓深度比較低的那棵樹向深度比較高的那棵樹進行合併 // 還可以更快的 class MyUnionFindFour { constructor(size) { // 儲存當前節點所指向的父節點 this.forest = new Array(size); // 記錄某個節點為根的樹的最大高度或深度 this.rank = new Array(size); // 在初始的時候每一個節點都指向它自己 // 也就是每一個節點都是獨立的一棵樹 const len = this.forest.length; for (var i = 0; i < len; i++) { this.forest[i] = i; this.rank[i] = 1; // 預設深度為1 } } // 功能:將元素q和元素p這兩個資料以及他們所在的集合進行合併 // 時間複雜度:O(h) h 為樹的高度 unionElements(treePrimary, treeSecondary) { const primaryRoot = this.find(treePrimary); const secondarRoot = this.find(treeSecondary); if (primaryRoot === secondarRoot) return; // 根據兩個元素所在樹的rank不同判斷合併方向 // 將rank低的集合合併到rank高的集合上 if (this.rank[primaryRoot] < this.rank[secondarRoot]) { // 主樹節點上往次樹節點進行合併 this.forest[primaryRoot] = this.forest[secondarRoot]; } else if (this.rank[primaryRoot] > this.rank[secondarRoot]) { // 次樹節點上往主樹節點進行合併 this.forest[secondarRoot] = this.forest[primaryRoot]; } else { // rank[primaryRoot] == rank[secondarRoot] // 如果元素個數一樣的根節點,那誰指向誰都無所謂 // 本質都是一樣的 // primaryRoot合併到secondarRoot上了,qRoot的高度就會增加1 this.forest[primaryRoot] = this.forest[secondarRoot]; this.rank[secondarRoot] += 1; } } // 功能:查詢元素q和元素p這兩個資料是否在同一個集合中 // 時間複雜度:O(h) h 為樹的高度 isConnected(treeQ, treeP) { return this.find(treeQ) === this.find(treeP); } // 查詢元素所對應的集合編號 find(id) { if (id < 0 || id >= this.forest.length) throw new Error('index is out of bound.'); // 不斷的去查查詢當前節點的根節點 // 根節點的索引是指向自己,如果根節點為 1 那麼對應的索引也為 1。 while (id !== this.forest[id]) id = this.forest[id]; return id; } // 功能:當前並查集一共考慮多少個元素 getSize() { return this.forest.length; } } 複製程式碼
-
MyUnionFindFive
// 自定義並查集 UnionFind 第五個版本 QuickUnion優化版 // Union 操作變快了 // 解決方案:考慮path compression 路徑 // 原理:在find的時候,迴圈遍歷操作時,讓當前節點的父節點指向它父親的父親。 // 還可以更快的 class MyUnionFindFive { constructor(size) { // 儲存當前節點所指向的父節點 this.forest = new Array(size); // 記錄某個節點為根的樹的最大高度或深度 this.rank = new Array(size); // 在初始的時候每一個節點都指向它自己 // 也就是每一個節點都是獨立的一棵樹 const len = this.forest.length; for (var i = 0; i < len; i++) { this.forest[i] = i; this.rank[i] = 1; // 預設深度為1 } } // 功能:將元素q和元素p這兩個資料以及他們所在的集合進行合併 // 時間複雜度:O(h) h 為樹的高度 unionElements(treePrimary, treeSecondary) { const primaryRoot = this.find(treePrimary); const secondarRoot = this.find(treeSecondary); if (primaryRoot === secondarRoot) return; // 根據兩個元素所在樹的rank不同判斷合併方向 // 將rank低的集合合併到rank高的集合上 if (this.rank[primaryRoot] < this.rank[secondarRoot]) { // 主樹節點上往次樹節點進行合併 this.forest[primaryRoot] = this.forest[secondarRoot]; } else if (this.rank[primaryRoot] > this.rank[secondarRoot]) { // 次樹節點上往主樹節點進行合併 this.forest[secondarRoot] = this.forest[primaryRoot]; } else { // rank[primaryRoot] == rank[secondarRoot] // 如果元素個數一樣的根節點,那誰指向誰都無所謂 // 本質都是一樣的 // primaryRoot合併到secondarRoot上了,qRoot的高度就會增加1 this.forest[primaryRoot] = this.forest[secondarRoot]; this.rank[secondarRoot] += 1; } } // 功能:查詢元素q和元素p這兩個資料是否在同一個集合中 // 時間複雜度:O(h) h 為樹的高度 isConnected(treeQ, treeP) { return this.find(treeQ) === this.find(treeP); } // 查詢元素所對應的集合編號 find(id) { if (id < 0 || id >= this.forest.length) throw new Error('index is out of bound.'); // 不斷的去查查詢當前節點的根節點 // 根節點的索引是指向自己,如果根節點為 1 那麼對應的索引也為 1。 while (id !== this.forest[id]) { // 進行一次節點壓縮。 this.forest[id] = this.forest[this.forest[id]]; id = this.forest[id]; } return id; } // 功能:當前並查集一共考慮多少個元素 getSize() { return this.forest.length; } } 複製程式碼
-
PerformanceTest
// 效能測試 class PerformanceTest { constructor() {} // 對比佇列 testQueue(queue, openCount) { let startTime = Date.now(); let random = Math.random; for (var i = 0; i < openCount; i++) { queue.enqueue(random() * openCount); } while (!queue.isEmpty()) { queue.dequeue(); } let endTime = Date.now(); return this.calcTime(endTime - startTime); } // 對比棧 testStack(stack, openCount) { let startTime = Date.now(); let random = Math.random; for (var i = 0; i < openCount; i++) { stack.push(random() * openCount); } while (!stack.isEmpty()) { stack.pop(); } let endTime = Date.now(); return this.calcTime(endTime - startTime); } // 對比集合 testSet(set, openCount) { let startTime = Date.now(); let random = Math.random; let arr = []; let temp = null; // 第一遍測試 for (var i = 0; i < openCount; i++) { temp = random(); // 新增重複元素,從而測試集合去重的能力 set.add(temp * openCount); set.add(temp * openCount); arr.push(temp * openCount); } for (var i = 0; i < openCount; i++) { set.remove(arr[i]); } // 第二遍測試 for (var i = 0; i < openCount; i++) { set.add(arr[i]); set.add(arr[i]); } while (!set.isEmpty()) { set.remove(arr[set.getSize() - 1]); } let endTime = Date.now(); // 求出兩次測試的平均時間 let avgTime = Math.ceil((endTime - startTime) / 2); return this.calcTime(avgTime); } // 對比對映 testMap(map, openCount) { let startTime = Date.now(); let array = new MyArray(); let random = Math.random; let temp = null; let result = null; for (var i = 0; i < openCount; i++) { temp = random(); result = openCount * temp; array.add(result); array.add(result); array.add(result); array.add(result); } for (var i = 0; i < array.getSize(); i++) { result = array.get(i); if (map.contains(result)) map.add(result, map.get(result) + 1); else map.add(result, 1); } for (var i = 0; i < array.getSize(); i++) { result = array.get(i); map.remove(result); } let endTime = Date.now(); return this.calcTime(endTime - startTime); } // 對比堆 主要對比 使用heapify 與 不使用heapify時的效能 testHeap(heap, array, isHeapify) { const startTime = Date.now(); // 是否支援 heapify if (isHeapify) heap.heapify(array); else { for (const element of array) heap.add(element); } console.log('heap size:' + heap.size() + '\r\n'); document.body.innerHTML += 'heap size:' + heap.size() + '<br /><br />'; // 使用陣列取值 let arr = new Array(heap.size()); for (let i = 0; i < arr.length; i++) arr[i] = heap.extractMax(); console.log( 'Array size:' + arr.length + ',heap size:' + heap.size() + '\r\n' ); document.body.innerHTML += 'Array size:' + arr.length + ',heap size:' + heap.size() + '<br /><br />'; // 檢驗一下是否符合要求 for (let i = 1; i < arr.length; i++) if (arr[i - 1] < arr[i]) throw new Error('error.'); console.log('test heap completed.' + '\r\n'); document.body.innerHTML += 'test heap completed.' + '<br /><br />'; const endTime = Date.now(); return this.calcTime(endTime - startTime); } // 對比並查集 testUnionFind(unionFind, openCount, primaryArray, secondaryArray) { const size = unionFind.getSize(); const random = Math.random; return this.testCustomFn(function() { // 合併操作 for (var i = 0; i < openCount; i++) { let primaryId = primaryArray[i]; let secondaryId = secondaryArray[i]; unionFind.unionElements(primaryId, secondaryId); } // 查詢連線操作 for (var i = 0; i < openCount; i++) { let primaryRandomId = Math.floor(random() * size); let secondaryRandomId = Math.floor(random() * size); unionFind.unionElements(primaryRandomId, secondaryRandomId); } }); } // 計算執行的時間,轉換為 天-小時-分鐘-秒-毫秒 calcTime(result) { //獲取距離的天數 var day = Math.floor(result / (24 * 60 * 60 * 1000)); //獲取距離的小時數 var hours = Math.floor((result / (60 * 60 * 1000)) % 24); //獲取距離的分鐘數 var minutes = Math.floor((result / (60 * 1000)) % 60); //獲取距離的秒數 var seconds = Math.floor((result / 1000) % 60); //獲取距離的毫秒數 var milliSeconds = Math.floor(result % 1000); // 計算時間 day = day < 10 ? '0' + day : day; hours = hours < 10 ? '0' + hours : hours; minutes = minutes < 10 ? '0' + minutes : minutes; seconds = seconds < 10 ? '0' + seconds : seconds; milliSeconds = milliSeconds < 100 ? milliSeconds < 10 ? '00' + milliSeconds : '0' + milliSeconds : milliSeconds; // 輸出耗時字串 result = day + '天' + hours + '小時' + minutes + '分' + seconds + '秒' + milliSeconds + '毫秒' + ' <<<<============>>>> 總毫秒數:' + result; return result; } // 自定義對比 testCustomFn(fn) { let startTime = Date.now(); fn(); let endTime = Date.now(); return this.calcTime(endTime - startTime); } } 複製程式碼
-
Main
// main 函式 class Main { constructor() { this.alterLine('UnionFind Comparison Area'); // 千萬級別 const size = 10000000; // 並查集維護節點數 const openCount = 10000000; // 運算元 // 生成同一份測試資料的輔助程式碼 const random = Math.random; const primaryArray = new Array(openCount); const secondaryArray = new Array(openCount); // 生成同一份測試資料 for (var i = 0; i < openCount; i++) { primaryArray[i] = Math.floor(random() * size); secondaryArray[i] = Math.floor(random() * size); } // 開始測試 const myUnionFindThree = new MyUnionFindThree(size); const myUnionFindFour = new MyUnionFindFour(size); const myUnionFindFive = new MyUnionFindFive(size); const performanceTest = new PerformanceTest(); // 測試後獲取測試資訊 const myUnionFindThreeInfo = performanceTest.testUnionFind( myUnionFindThree, openCount, primaryArray, secondaryArray ); const myUnionFindFourInfo = performanceTest.testUnionFind( myUnionFindFour, openCount, primaryArray, secondaryArray ); const myUnionFindFiveInfo = performanceTest.testUnionFind( myUnionFindFive, openCount, primaryArray, secondaryArray ); // 總毫秒數:8042 console.log( 'MyUnionFindThree time:' + myUnionFindThreeInfo, myUnionFindThree ); this.show('MyUnionFindThree time:' + myUnionFindThreeInfo); // 總毫秒數:7463 console.log( 'MyUnionFindFour time:' + myUnionFindFourInfo, myUnionFindFour ); this.show('MyUnionFindFour time:' + myUnionFindFourInfo); // 總毫秒數:5118 console.log( 'MyUnionFindFive time:' + myUnionFindFiveInfo, myUnionFindFive ); this.show('MyUnionFindFive time:' + myUnionFindFiveInfo); } // 將內容顯示在頁面上 show(content) { document.body.innerHTML += `${content}<br /><br />`; } // 展示分割線 alterLine(title) { let line = `--------------------${title}----------------------`; console.log(line); document.body.innerHTML += `${line}<br /><br />`; } } // 頁面載入完畢 window.onload = function() { // 執行主函式 new Main(); }; 複製程式碼
更多和並查集相關的話題
-
路徑壓縮還可以繼續優化
- 可以將樹壓縮的只剩下最後兩層,
- 但是實現到這樣的樣子就需要藉助遞迴來實現了,
- 查詢某一個節點的時候,直接讓當前這個節點以及之前所有的節點,
- 全部直接指向根節點。
// // 原來的樹是這個樣子 // (0) // / // (1) // / // (2) // / // (3) // / // (4) // 你可以優化成這個樣子 // (0) // / | \ \ // (1)(2)(3)(4) // 最後陣列就是這個樣子 // 0 1 2 3 4 // ----------------- // prent 0 0 0 0 0 複製程式碼
-
非遞迴實現的路徑壓縮要比遞迴實現的路徑壓縮相對來說快一點點
- 因為遞迴的過程是會有相應的開銷的,所以相對會慢一點,
- 但是第五版的非遞迴實現的路徑壓縮也可以做到遞迴實現的路徑壓縮
- 這樣直接讓當前節點及所有的節點指向根節點,只不過是不能一次性的做到,
- 第五版的路徑壓縮下圖這樣的,如果在深度為 3 的樹上再呼叫一下
find(4)
, - 就會變成第三個樹結構的樣子,它需要多呼叫幾次
find(4)
, - 但是最終依然能夠達到這樣的一個結果,如果再呼叫一下
find(3)
, - 那麼就會變成最後的和第六版遞迴一樣的樣子,
- 此時所有的節點都會指向根節點,
- 也就是說所製作的第五版的路徑壓縮也能夠達到第六版路徑壓縮的效果,
- 只不過需要多呼叫幾次,再加上第五版的路徑壓縮沒有使用遞迴函式實現,
- 而是直接在迴圈遍歷中實現的,所以整體效能會高一點點。
// // 原來的樹是這個樣子 // (0) // / // (1) // / // (2) // / // (3) // / // (4) // 優化成這個樣子了 // (0) // / \ // (1) (2) // / \ // (3) (4) // 再呼叫一下find(4),就會變成這個樣子 // (0) // / | \ // (1)(2) (4) // / // (3) // 再呼叫一下find(3),就優化成這個樣子 // (0) // / | \ \ // (1)(2)(3)(4) // 最後陣列就是這個樣子 // 0 1 2 3 4 // ----------------- // prent 0 0 0 0 0 複製程式碼
程式碼示例
-
(class: MyUnionFindThree, class: MyUnionFindFour, class: MyUnionFindFive,
class: MyUnionFindSix, class: PerformanceTest, class: Main)
-
MyUnionFindThree
// 自定義並查集 UnionFind 第三個版本 QuickUnion優化版 // Union 操作變快了 // 還可以更快的 // 解決方案:考慮size 也就是某一棵樹從根節點開始一共有多少個節點 // 原理:節點少的向節點多的樹進行融合 // 還可以更快的 class MyUnionFindThree { constructor(size) { // 儲存當前節點所指向的父節點 this.forest = new Array(size); // 以以某個節點為根的所有子節點的個數 this.branch = new Array(size); // 在初始的時候每一個節點都指向它自己 // 也就是每一個節點都是獨立的一棵樹 const len = this.forest.length; for (var i = 0; i < len; i++) { this.forest[i] = i; this.branch[i] = 1; // 預設節點個數為1 } } // 功能:將元素q和元素p這兩個資料以及他們所在的集合進行合併 // 時間複雜度:O(h) h 為樹的高度 unionElements(treePrimary, treeSecondary) { const primaryRoot = this.find(treePrimary); const secondarRoot = this.find(treeSecondary); if (primaryRoot === secondarRoot) return; // 節點少的 樹 往 節點多的樹 進行合併,在一定程度上減少最終樹的高度 if (this.branch[primaryRoot] < this.branch[secondarRoot]) { // 主樹節點上往次樹節點進行合併 this.forest[primaryRoot] = this.forest[secondarRoot]; // 次樹的節點個數 += 主樹的節點個數 this.branch[secondarRoot] += this.branch[primaryRoot]; } else { // branch[primaryRoot] >= branch[secondarRoot] // 次樹節點上往主樹節點進行合併 this.forest[secondarRoot] = this.forest[primaryRoot]; // 主樹的節點個數 += 次樹的節點個數 this.branch[primaryRoot] += this.branch[secondarRoot]; } } // 功能:查詢元素q和元素p這兩個資料是否在同一個集合中 // 時間複雜度:O(h) h 為樹的高度 isConnected(treeQ, treeP) { return this.find(treeQ) === this.find(treeP); } // 查詢元素所對應的集合編號 find(id) { if (id < 0 || id >= this.forest.length) throw new Error('index is out of bound.'); // 不斷的去查查詢當前節點的根節點 // 根節點的索引是指向自己,如果根節點為 1 那麼對應的索引也為 1。 while (id !== this.forest[id]) id = this.forest[id]; return id; } // 功能:當前並查集一共考慮多少個元素 getSize() { return this.forest.length; } } 複製程式碼
-
MyUnionFindFour
// 自定義並查集 UnionFind 第四個版本 QuickUnion優化版 // Union 操作變快了 // 還可以更快的 // 解決方案:考慮rank 也就是某一棵樹從根節點開始計算最大深度是多少 // 原理:讓深度比較低的那棵樹向深度比較高的那棵樹進行合併 // 還可以更快的 class MyUnionFindFour { constructor(size) { // 儲存當前節點所指向的父節點 this.forest = new Array(size); // 記錄某個節點為根的樹的最大高度或深度 this.rank = new Array(size); // 在初始的時候每一個節點都指向它自己 // 也就是每一個節點都是獨立的一棵樹 const len = this.forest.length; for (var i = 0; i < len; i++) { this.forest[i] = i; this.rank[i] = 1; // 預設深度為1 } } // 功能:將元素q和元素p這兩個資料以及他們所在的集合進行合併 // 時間複雜度:O(h) h 為樹的高度 unionElements(treePrimary, treeSecondary) { const primaryRoot = this.find(treePrimary); const secondarRoot = this.find(treeSecondary); if (primaryRoot === secondarRoot) return; // 根據兩個元素所在樹的rank不同判斷合併方向 // 將rank低的集合合併到rank高的集合上 if (this.rank[primaryRoot] < this.rank[secondarRoot]) { // 主樹節點上往次樹節點進行合併 this.forest[primaryRoot] = this.forest[secondarRoot]; } else if (this.rank[primaryRoot] > this.rank[secondarRoot]) { // 次樹節點上往主樹節點進行合併 this.forest[secondarRoot] = this.forest[primaryRoot]; } else { // rank[primaryRoot] == rank[secondarRoot] // 如果元素個數一樣的根節點,那誰指向誰都無所謂 // 本質都是一樣的 // primaryRoot合併到secondarRoot上了,qRoot的高度就會增加1 this.forest[primaryRoot] = this.forest[secondarRoot]; this.rank[secondarRoot] += 1; } } // 功能:查詢元素q和元素p這兩個資料是否在同一個集合中 // 時間複雜度:O(h) h 為樹的高度 isConnected(treeQ, treeP) { return this.find(treeQ) === this.find(treeP); } // 查詢元素所對應的集合編號 find(id) { if (id < 0 || id >= this.forest.length) throw new Error('index is out of bound.'); // 不斷的去查查詢當前節點的根節點 // 根節點的索引是指向自己,如果根節點為 1 那麼對應的索引也為 1。 while (id !== this.forest[id]) id = this.forest[id]; return id; } // 功能:當前並查集一共考慮多少個元素 getSize() { return this.forest.length; } } 複製程式碼
-
MyUnionFindFive
// 自定義並查集 UnionFind 第五個版本 QuickUnion優化版 // Union 操作變快了 // 解決方案:考慮path compression 路徑 // 原理:在find的時候,迴圈遍歷操作時,讓當前節點的父節點指向它父親的父親。 // 還可以更快的 class MyUnionFindFive { constructor(size) { // 儲存當前節點所指向的父節點 this.forest = new Array(size); // 記錄某個節點為根的樹的最大高度或深度 this.rank = new Array(size); // 在初始的時候每一個節點都指向它自己 // 也就是每一個節點都是獨立的一棵樹 const len = this.forest.length; for (var i = 0; i < len; i++) { this.forest[i] = i; this.rank[i] = 1; // 預設深度為1 } } // 功能:將元素q和元素p這兩個資料以及他們所在的集合進行合併 // 時間複雜度:O(h) h 為樹的高度 unionElements(treePrimary, treeSecondary) { const primaryRoot = this.find(treePrimary); const secondarRoot = this.find(treeSecondary); if (primaryRoot === secondarRoot) return; // 根據兩個元素所在樹的rank不同判斷合併方向 // 將rank低的集合合併到rank高的集合上 if (this.rank[primaryRoot] < this.rank[secondarRoot]) { // 主樹節點上往次樹節點進行合併 this.forest[primaryRoot] = this.forest[secondarRoot]; } else if (this.rank[primaryRoot] > this.rank[secondarRoot]) { // 次樹節點上往主樹節點進行合併 this.forest[secondarRoot] = this.forest[primaryRoot]; } else { // rank[primaryRoot] == rank[secondarRoot] // 如果元素個數一樣的根節點,那誰指向誰都無所謂 // 本質都是一樣的 // primaryRoot合併到secondarRoot上了,qRoot的高度就會增加1 this.forest[primaryRoot] = this.forest[secondarRoot]; this.rank[secondarRoot] += 1; } } // 功能:查詢元素q和元素p這兩個資料是否在同一個集合中 // 時間複雜度:O(h) h 為樹的高度 isConnected(treeQ, treeP) { return this.find(treeQ) === this.find(treeP); } // 查詢元素所對應的集合編號 find(id) { if (id < 0 || id >= this.forest.length) throw new Error('index is out of bound.'); // 不斷的去查查詢當前節點的根節點 // 根節點的索引是指向自己,如果根節點為 1 那麼對應的索引也為 1。 while (id !== this.forest[id]) { // 進行一次節點壓縮。 this.forest[id] = this.forest[this.forest[id]]; id = this.forest[id]; } return id; } // 功能:當前並查集一共考慮多少個元素 getSize() { return this.forest.length; } } 複製程式碼
-
MyUnionFindSix
// 自定義並查集 UnionFind 第六個版本 QuickUnion優化版 // Union 操作變快了 // 解決方案:考慮path compression 路徑 // 原理:在find的時候,迴圈遍歷操作時,讓所有的節點都指向根節點 以遞迴的形式進行。 // 還可以更快的 class MyUnionFindSix { constructor(size) { // 儲存當前節點所指向的父節點 this.forest = new Array(size); // 記錄某個節點為根的樹的最大高度或深度 this.rank = new Array(size); // 在初始的時候每一個節點都指向它自己 // 也就是每一個節點都是獨立的一棵樹 const len = this.forest.length; for (var i = 0; i < len; i++) { this.forest[i] = i; this.rank[i] = 1; // 預設深度為1 } } // 功能:將元素q和元素p這兩個資料以及他們所在的集合進行合併 // 時間複雜度:O(h) h 為樹的高度 unionElements(treePrimary, treeSecondary) { const primaryRoot = this.find(treePrimary); const secondarRoot = this.find(treeSecondary); if (primaryRoot === secondarRoot) return; // 根據兩個元素所在樹的rank不同判斷合併方向 // 將rank低的集合合併到rank高的集合上 if (this.rank[primaryRoot] < this.rank[secondarRoot]) { // 主樹節點上往次樹節點進行合併 this.forest[primaryRoot] = this.forest[secondarRoot]; } else if (this.rank[primaryRoot] > this.rank[secondarRoot]) { // 次樹節點上往主樹節點進行合併 this.forest[secondarRoot] = this.forest[primaryRoot]; } else { // rank[primaryRoot] == rank[secondarRoot] // 如果元素個數一樣的根節點,那誰指向誰都無所謂 // 本質都是一樣的 // primaryRoot合併到secondarRoot上了,qRoot的高度就會增加1 this.forest[primaryRoot] = this.forest[secondarRoot]; this.rank[secondarRoot] += 1; } } // 功能:查詢元素q和元素p這兩個資料是否在同一個集合中 // 時間複雜度:O(h) h 為樹的高度 isConnected(treeQ, treeP) { return this.find(treeQ) === this.find(treeP); } // 查詢元素所對應的集合編號 find(id) { if (id < 0 || id >= this.forest.length) throw new Error('index is out of bound.'); // 如果當前節點不等於根節點, // 就找到根節點並且把當前節點及之前的節點全部指向根節點 if (id !== this.forest[id]) this.forest[id] = this.find(this.forest[id]); return this.forest[id]; } // 功能:當前並查集一共考慮多少個元素 getSize() { return this.forest.length; } } 複製程式碼
-
PerformanceTest
// 效能測試 class PerformanceTest { constructor() {} // 對比佇列 testQueue(queue, openCount) { let startTime = Date.now(); let random = Math.random; for (var i = 0; i < openCount; i++) { queue.enqueue(random() * openCount); } while (!queue.isEmpty()) { queue.dequeue(); } let endTime = Date.now(); return this.calcTime(endTime - startTime); } // 對比棧 testStack(stack, openCount) { let startTime = Date.now(); let random = Math.random; for (var i = 0; i < openCount; i++) { stack.push(random() * openCount); } while (!stack.isEmpty()) { stack.pop(); } let endTime = Date.now(); return this.calcTime(endTime - startTime); } // 對比集合 testSet(set, openCount) { let startTime = Date.now(); let random = Math.random; let arr = []; let temp = null; // 第一遍測試 for (var i = 0; i < openCount; i++) { temp = random(); // 新增重複元素,從而測試集合去重的能力 set.add(temp * openCount); set.add(temp * openCount); arr.push(temp * openCount); } for (var i = 0; i < openCount; i++) { set.remove(arr[i]); } // 第二遍測試 for (var i = 0; i < openCount; i++) { set.add(arr[i]); set.add(arr[i]); } while (!set.isEmpty()) { set.remove(arr[set.getSize() - 1]); } let endTime = Date.now(); // 求出兩次測試的平均時間 let avgTime = Math.ceil((endTime - startTime) / 2); return this.calcTime(avgTime); } // 對比對映 testMap(map, openCount) { let startTime = Date.now(); let array = new MyArray(); let random = Math.random; let temp = null; let result = null; for (var i = 0; i < openCount; i++) { temp = random(); result = openCount * temp; array.add(result); array.add(result); array.add(result); array.add(result); } for (var i = 0; i < array.getSize(); i++) { result = array.get(i); if (map.contains(result)) map.add(result, map.get(result) + 1); else map.add(result, 1); } for (var i = 0; i < array.getSize(); i++) { result = array.get(i); map.remove(result); } let endTime = Date.now(); return this.calcTime(endTime - startTime); } // 對比堆 主要對比 使用heapify 與 不使用heapify時的效能 testHeap(heap, array, isHeapify) { const startTime = Date.now(); // 是否支援 heapify if (isHeapify) heap.heapify(array); else { for (const element of array) heap.add(element); } console.log('heap size:' + heap.size() + '\r\n'); document.body.innerHTML += 'heap size:' + heap.size() + '<br /><br />'; // 使用陣列取值 let arr = new Array(heap.size()); for (let i = 0; i < arr.length; i++) arr[i] = heap.extractMax(); console.log( 'Array size:' + arr.length + ',heap size:' + heap.size() + '\r\n' ); document.body.innerHTML += 'Array size:' + arr.length + ',heap size:' + heap.size() + '<br /><br />'; // 檢驗一下是否符合要求 for (let i = 1; i < arr.length; i++) if (arr[i - 1] < arr[i]) throw new Error('error.'); console.log('test heap completed.' + '\r\n'); document.body.innerHTML += 'test heap completed.' + '<br /><br />'; const endTime = Date.now(); return this.calcTime(endTime - startTime); } // 對比並查集 testUnionFind(unionFind, openCount, primaryArray, secondaryArray) { const size = unionFind.getSize(); const random = Math.random; return this.testCustomFn(function() { // 合併操作 for (var i = 0; i < openCount; i++) { let primaryId = primaryArray[i]; let secondaryId = secondaryArray[i]; unionFind.unionElements(primaryId, secondaryId); } // 查詢連線操作 for (var i = 0; i < openCount; i++) { let primaryRandomId = Math.floor(random() * size); let secondaryRandomId = Math.floor(random() * size); unionFind.unionElements(primaryRandomId, secondaryRandomId); } }); } // 計算執行的時間,轉換為 天-小時-分鐘-秒-毫秒 calcTime(result) { //獲取距離的天數 var day = Math.floor(result / (24 * 60 * 60 * 1000)); //獲取距離的小時數 var hours = Math.floor((result / (60 * 60 * 1000)) % 24); //獲取距離的分鐘數 var minutes = Math.floor((result / (60 * 1000)) % 60); //獲取距離的秒數 var seconds = Math.floor((result / 1000) % 60); //獲取距離的毫秒數 var milliSeconds = Math.floor(result % 1000); // 計算時間 day = day < 10 ? '0' + day : day; hours = hours < 10 ? '0' + hours : hours; minutes = minutes < 10 ? '0' + minutes : minutes; seconds = seconds < 10 ? '0' + seconds : seconds; milliSeconds = milliSeconds < 100 ? milliSeconds < 10 ? '00' + milliSeconds : '0' + milliSeconds : milliSeconds; // 輸出耗時字串 result = day + '天' + hours + '小時' + minutes + '分' + seconds + '秒' + milliSeconds + '毫秒' + ' <<<<============>>>> 總毫秒數:' + result; return result; } // 自定義對比 testCustomFn(fn) { let startTime = Date.now(); fn(); let endTime = Date.now(); return this.calcTime(endTime - startTime); } } 複製程式碼
-
Main
// main 函式 class Main { constructor() { this.alterLine('UnionFind Comparison Area'); // 千萬級別 const size = 10000000; // 並查集維護節點數 const openCount = 10000000; // 運算元 // 生成同一份測試資料的輔助程式碼 const random = Math.random; const primaryArray = new Array(openCount); const secondaryArray = new Array(openCount); // 生成同一份測試資料 for (var i = 0; i < openCount; i++) { primaryArray[i] = Math.floor(random() * size); secondaryArray[i] = Math.floor(random() * size); } // 開始測試 const myUnionFindThree = new MyUnionFindThree(size); const myUnionFindFour = new MyUnionFindFour(size); const myUnionFindFive = new MyUnionFindFive(size); const myUnionFindSix = new MyUnionFindSix(size); const performanceTest = new PerformanceTest(); // 測試後獲取測試資訊 const myUnionFindThreeInfo = performanceTest.testUnionFind( myUnionFindThree, openCount, primaryArray, secondaryArray ); const myUnionFindFourInfo = performanceTest.testUnionFind( myUnionFindFour, openCount, primaryArray, secondaryArray ); const myUnionFindFiveInfo = performanceTest.testUnionFind( myUnionFindFive, openCount, primaryArray, secondaryArray ); const myUnionFindSixInfo = performanceTest.testUnionFind( myUnionFindSix, openCount, primaryArray, secondaryArray ); // 總毫秒數:8042 console.log( 'MyUnionFindThree time:' + myUnionFindThreeInfo, myUnionFindThree ); this.show('MyUnionFindThree time:' + myUnionFindThreeInfo); // 總毫秒數:7463 console.log( 'MyUnionFindFour time:' + myUnionFindFourInfo, myUnionFindFour ); this.show('MyUnionFindFour time:' + myUnionFindFourInfo); // 總毫秒數:5118 console.log( 'MyUnionFindFive time:' + myUnionFindFiveInfo, myUnionFindFive ); this.show('MyUnionFindFive time:' + myUnionFindFiveInfo); // 總毫秒數:5852 console.log( 'MyUnionFindSix time:' + myUnionFindSixInfo, myUnionFindSix ); this.show('MyUnionFindSix time:' + myUnionFindSixInfo); } // 將內容顯示在頁面上 show(content) { document.body.innerHTML += `${content}<br /><br />`; } // 展示分割線 alterLine(title) { let line = `--------------------${title}----------------------`; console.log(line); document.body.innerHTML += `${line}<br /><br />`; } } // 頁面載入完畢 window.onload = function() { // 執行主函式 new Main(); }; 複製程式碼
並查集的時間複雜度分析
- 在並查集使用了這樣一個奇怪的樹結構來實現以後,
- 其實並查集的時間複雜度就是
O(h)
, - 無論是查詢操作還是合併操作它的時間複雜度都是
O(h)
這個級別的, - 這個 h 就是樹的高度或者深度,但是這個複雜度並不能反映 h 和 n 之間的關係,
- 對於並查集來說它並不是一個嚴格的二叉樹、三叉樹、幾叉樹,
- 所以這個 h 並不是嚴格意義上 logn 的級別,
- 對於並查集的時間複雜度分析整體在數學上相對比較複雜。
- 其實並查集的時間複雜度就是
- 嚴格意義上來講使用了路徑壓縮之後
- 並查集相應的時間複雜度,無論是查詢操作還是合併操作,
- 都是
O(log*n)
這個級別,這個 log*n 是另外一個函式, - 它和 log 函式不一樣,相應的
log*
的英文叫做iterated logarithm
, - 也可以直接讀成 log star,這個
log*n
在數學上有一個公式, log*n= {0 if(n<=1) || 1+log*(logn) if(n>1)}
,- 也就是當
n<=1
的時候,log*n
為 0, - 當
n>1
的時候,稍微有點複雜了,這是一個遞迴的定義, - 這個
log*n = 1 + log*(logn)
,括號中就是對這個n
取一個log
值, - 再來看這個
log
值對應的log*
的這個是多少, - 直到這個括號中
logn
得到的結果小於等於 1 了,那麼就直接得到了 0, - 這樣遞迴的定義就到底了,這就是 log*n 這個公式的數學意義,
- 這也就證明了加入了路徑壓縮之後,
- 對於並查集的時間複雜度為什麼是
O(log*n)
這個級別的, - 就會稍微有些複雜,只需要瞭解即可,
log*n
這樣的時間複雜度可以通過以上公式可以看出,- 它是一個比 logn 還要快的這樣一個時間複雜度,整體上近乎是
O(1)
級別的, - 所以它比
O(1)
稍微要慢一點點,其實 logn 已經是非常快的一個時間複雜度了, - 那麼當並查集新增上了路徑壓縮之後,
- 平均來講查詢操作和合並操作是比 logn 這個級別還要快的,
- 這就是因為在路徑壓縮之後每一個節點都直接指向了根節點,
- 近乎每一次查詢都只需要看一次就可以直接找到這個節點所對應的根節點是誰,
- 這就是並查集的時間複雜度。
leetcode 中並查集相應的問題
- leetcode 並查集題庫
https://leetcode-cn.com/tag/union-find/
- 這些問題不是中等就是困難的題目,
- 如果只是參加面試的話,在演算法面試中考察的並查集概率很低很低的,
- 如果是參加競賽的話,在一些競賽的問題中可能會使用上並查集,
- 對於 leetcode 中的問題,不僅僅是使用並查集可以解決的,
- 對於很多問題可以使用圖論中的相應的尋路演算法或者
- 是求連通分量的方式直接進行解決,
- 也可以回答並查集單獨回答的這樣的一個連線問題的結果,
- 但是對於有一些問題來說不但是高效的而且是有它獨特的優勢的,
- 尤其是對於這個問題來說,
- 相應的資料之間的合併以及查詢這兩個操作是交替進行的,
- 它們是一個動態的過程,在這種時候並查集是可以發揮最大的優勢。
- 這些題目是有難度,如果沒有演算法競賽的經驗,會花掉很多的時間。
四種樹結構
- 並查集是一種非常奇怪的樹結構
- 它是一種由孩子指向父親這樣的一種樹結構。
- 四個處理不同的問題的樹結構
- 這些都是樹結構的變種,
- 分別是 堆、線段樹、Trie 字典樹、並查集。
- 二分搜尋樹是最為普通的樹結構。
- 之前自己實現的二分搜尋樹有一個很大的問題,
- 它可能會退化成為一個連結串列,
- 需要通過新的機制來避免這個問題的發生,
- 也就是讓二分搜尋樹可以做到自平衡,
- 使得它不會退化成一個連結串列,
- 其實這種可以保持二分搜尋樹是自平衡的資料結構有很多,
- 最為經典的,同時也是在歷史上最早實現的可以達到自平衡的二分搜尋樹,
- AVL 樹。