前言
【从蛋壳到满天飞】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 树。