前言
【从蛋壳到满天飞】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,点击我吧,光看文章能够掌握两成,动手敲代码、动脑思考、画图才可以掌握八成。
本文章适合 对数据结构想了解并且感兴趣的人群,文章风格一如既往如此,就觉得手机上看起来比较方便,这样显得比较有条理,整理这些笔记加源码,时间跨度也算将近半年时间了,希望对想学习数据结构的人或者正在学习数据结构的人群有帮助。
栈 Statck
- 栈也是一种线性结构
- 相比数组来说相应的操作更少,
- 栈对应的操作是数组的子集,
- 因为它的本质就是一个数组,
- 并且它有比数组更多的限制。
- 栈的本质就是一个数组
- 它将数据排开来放的,
- 添加元素的时候只能从栈的一端添加元素,
- 取出元素的时候也只能栈的一端取出元素,
- 这一端叫做栈顶,当这样的限定了数组,
- 从而形成了栈这种数据结构之后,
- 它可以在计算机世界中对于
- 组建逻辑产生非常非常重要的作用。
- 栈的操作
- 从栈顶添加元素,把元素一个一个的放入到栈中,
- 如添加值的时候为 1、2、3,
- 你取值的时候顺序则为 3、2、1,
- 因为你添加元素是只能从一端放入,
- 取出元素时也只能从一端取出,
- 而这一段就是栈顶,
- 栈的出口和入口都是同一个位置,
- 所以你只能按照先进后出、后进先出的顺序
- 添加数据或者取出数据,不存在插入和索引。
- 栈是一种后进先出的数据结构
- 也就是 Last In First Out(LIFO),
- 这样的一种数据结构,在计算机的世界里,
- 它拥有着不可思议的作用,
- 无论是经典的算法还是算法设计都接触到
- 栈这种看似很简单但其实应用非常广泛的数据结构,
栈的简单应用
-
无处不在的 Undo 操作(撤销)
- 编辑器的撤销操作的原理就是靠一个栈来进行维护的,
- 如 将 每次输入的内容依次放入栈中 我 喜欢 你,
- 如果 你 字写错,你撤销一下,变成 我 喜欢,
- 再撤销一下 变成 我。
-
程序调用的系统栈
- 程序调用时经常会出现在一个逻辑中间
- 先终止然后跳到另外一个逻辑去执行,
- 所谓的子函数的调用就是这个过程,
- 在这个过程中计算机就需要使用一个
- 称为系统栈的一个数据结构来记录程序的调用过程。
- 例如有三个函数 A、B、C,
- 当 A 执行到一半的时候调用 B,
- 当 B 执行到一半的时候调用 C,
- C 函数可以执行运行完,
- C 函数运行完了之后继续运行未完成的 B 函数,
- B 函数运行完了就运行未完成 A 函数,
- A 函数运行完了就结束了。
function A () { 1 ...; 2 B(); 3 ...; } function B () { 1 ...; 2 C(); 3 ...; } function C () { 1 ...; 2 ...; 3 ...; } 复制代码
-
系统栈记录的过程是:
- A 函数执行,在第二行中断了,因为要去执行函数 B 了,
- 这时候函数信息
A2
会被放入系统栈中,系统栈中显示:[A2]
, - 然后 B 函数执行,在第二行也中断了,因为要去执行函数 C 了,
- 这时候函数信息 B2 会被放入系统栈中,系统栈中显示:
[A2, B2]
, - 然后 C 函数执行,C 函数没有子函数可执行,那么执行到底,函数 C 执行完毕,
- 从系统栈中取出函数 B 的信息,系统栈中显示:
[A2]
, - 根据从系统栈中取出的函数 B 的信息,从函数 B 原来中断的位置继续开始执行,
- B 函数执行完毕了,这时候会再从系统栈中取出函数 A 的,系统栈中显示:
[]
, - 根据从系统栈中取出的函数 A 的信息,从函数 A 原来中断的位置继续开始执行,
- A 函数执行完了,系统栈中已经没有函数信息了,好的,程序结束。
- 存入系统栈中的是函数执行时的一些信息,
- 所以取出来后,可以根据这些信息来继续完成
- 原来函数未执行完毕的那部分代码。
-
2 和 3 中解释的原理 就是系统栈最神奇的地方
- 在编程的时候进行子过程调用的时候,
- 当一个子过程执行完成之后,
- 可以自动的回到上层调用中断的位置,
- 并且继续执行下去。
- 都是靠一个系统栈来记录每一次调用过程中
- 中断的那个调用的点来实现的。
-
栈虽然是一个非常简单的数据结构
- 但是它能够解决计算机领域非常复杂的一个问题,
- 这个问题就是这种子过程子逻辑的调用,
- 在编译器内部它运行实现的原理是什么,
- 深入理解这个过程,
- 甚至能够帮助你理解一些更复杂的逻辑过程,
- 比如递归这样的一个过程,你会有更加深刻的理解。
栈的实现
- 栈这种数据结构非常有用
- 但其实是非常简单的。
MyStack
void push(e)
:入栈E pop()
:出栈E peek()
:查看位于栈顶位置的元素int getSize()
:获取栈中实际元素的个数boolean isEmpty()
:栈是否为空
- 从用户的角度看
- 只要支持这些操作就好了,
- 用户不管你要怎样 resize,
- 他只要知道你这个数组是一个动态的,
- 他可以不停的往里面添加元素,
- 并且不会出现问题就 ok,
- 其实对于栈也是这样的,
- 对于具体的底层实现,用户不关心,
- 实际底层也有多种实现方式,
- 所以用户就更加不关心了。
- 为了让代码更加的清晰,
- 同时也是为了支持面向对象的一些特性,
- 比如说支持多态性,
- 那么就会这样的去设计,
- 定义一个接口叫做 IMyStack,
- 接口中有栈默认的所有方法,
- 然后再定义一个类叫做 MyStack,
- 让它去实现 IMyStack,
- 这样就可以在 MyStack 中完成对应的逻辑,
- 这个 MyStack 就是自定义的栈。
- 会复用到之前自定义数组对象。
栈的复杂度分析
MyStack
void push(e)
:O(1) 均摊E pop()
:O(1) 均摊E peek()
:O(1)int getSize()
:O(1)boolean isEmpty()
:O(1)
代码示例
-
(class: MyArray, class: MyStack, class: Main)
-
MyArray
class MyArray { // 构造函数,传入数组的容量capacity构造Array 默认数组的容量capacity=10 constructor(capacity = 10) { this.data = new Array(capacity); this.size = 0; } // 获取数组中的元素实际个数 getSize() { return this.size; } // 获取数组的容量 getCapacity() { return this.data.length; } // 判断数组是否为空 isEmpty() { return this.size === 0; } // 给数组扩容 resize(capacity) { let newArray = new Array(capacity); for (var i = 0; i < this.size; i++) { newArray[i] = this.data[i]; } // let index = this.size - 1; // while (index > -1) { // newArray[index] = this.data[index]; // index --; // } this.data = newArray; } // 在指定索引处插入元素 insert(index, element) { // 先判断数组是否已满 if (this.size == this.getCapacity()) { // throw new Error("add error. Array is full."); this.resize(this.size * 2); } // 然后判断索引是否符合要求 if (index < 0 || index > this.size) { throw new Error( 'insert error. require index < 0 or index > size.' ); } // 最后 将指定索引处腾出来 // 从指定索引处开始,所有数组元素全部往后移动一位 // 从后往前移动 for (let i = this.size - 1; i >= index; i--) { this.data[i + 1] = this.data[i]; } // 在指定索引处插入元素 this.data[index] = element; // 维护一下size this.size++; } // 扩展 在数组最前面插入一个元素 unshift(element) { this.insert(0, element); } // 扩展 在数组最后面插入一个元素 push(element) { this.insert(this.size, element); } // 其实在数组中添加元素 就相当于在数组最后面插入一个元素 add(element) { if (this.size == this.getCapacity()) { // throw new Error("add error. Array is full."); this.resize(this.size * 2); } // size其实指向的是 当前数组最后一个元素的 后一个位置的索引。 this.data[this.size] = element; // 维护size this.size++; } // get get(index) { // 不能访问没有存放元素的位置 if (index < 0 || index >= this.size) { throw new Error('get error. index < 0 or index >= size.'); } return this.data[index]; } // 扩展: 获取数组中第一个元素 getFirst() { return this.get(0); } // 扩展: 获取数组中最后一个元素 getLast() { return this.get(this.size - 1); } // set set(index, newElement) { // 不能修改没有存放元素的位置 if (index < 0 || index >= this.size) { throw new Error('set error. index < 0 or index >= size.'); } this.data[index] = newElement; } // contain contain(element) { for (var i = 0; i < this.size; i++) { if (this.data[i] === element) { return true; } } return false; } // find find(element) { for (var i = 0; i < this.size; i++) { if (this.data[i] === element) { return i; } } return -1; } // findAll findAll(element) { // 创建一个自定义数组来存取这些 元素的索引 let myarray = new MyArray(this.size); for (var i = 0; i < this.size; i++) { if (this.data[i] === element) { myarray.push(i); } } // 返回这个自定义数组 return myarray; } // 删除指定索引处的元素 remove(index) { // 索引合法性验证 if (index < 0 || index >= this.size) { throw new Error('remove error. index < 0 or index >= size.'); } // 暂存即将要被删除的元素 let element = this.data[index]; // 后面的元素覆盖前面的元素 for (let i = index; i < this.size - 1; i++) { this.data[i] = this.data[i + 1]; } this.size--; this.data[this.size] = null; // 如果size 为容量的四分之一时 就可以缩容了 // 防止复杂度震荡 if (Math.floor(this.getCapacity() / 4) === this.size) { // 缩容一半 this.resize(Math.floor(this.getCapacity() / 2)); } return element; } // 扩展:删除数组中第一个元素 shift() { return this.remove(0); } // 扩展: 删除数组中最后一个元素 pop() { return this.remove(this.size - 1); } // 扩展: 根据元素来进行删除 removeElement(element) { let index = this.find(element); if (index !== -1) { this.remove(index); } } // 扩展: 根据元素来删除所有元素 removeAllElement(element) { let index = this.find(element); while (index != -1) { this.remove(index); index = this.find(element); } // let indexArray = this.findAll(element); // let cur, index = 0; // for (var i = 0; i < indexArray.getSize(); i++) { // // 每删除一个元素 原数组中就少一个元素, // // 索引数组中的索引值是按照大小顺序排列的, // // 所以 这个cur记录的是 原数组元素索引的偏移量 // // 只有这样才能够正确的删除元素。 // index = indexArray.get(i) - cur++; // this.remove(index); // } } // @Override toString 2018-10-17-jwl toString() { let arrInfo = `Array: size = ${this.getSize()},capacity = ${this.getCapacity()},\n`; arrInfo += `data = [`; for (var i = 0; i < this.size - 1; i++) { arrInfo += `${this.data[i]}, `; } if (!this.isEmpty()) { arrInfo += `${this.data[this.size - 1]}`; } arrInfo += `]`; // 在页面上展示 document.body.innerHTML += `${arrInfo}<br /><br /> `; return arrInfo; } } 复制代码
-
MyStack
class MyStack { constructor(capacity = 10) { this.myArray = new MyArray(capacity); } // 入栈 push(element) { this.myArray.push(element); } // 出栈 pop() { return this.myArray.pop(); } // 查看栈顶的元素 peek() { return this.myArray.getLast(); } // 栈中实际元素的个数 getSize() { return this.myArray.getSize(); } // 栈是否为空 isEmpty() { return this.myArray.isEmpty(); } // 查看栈的容量 getCapacity() { return this.myArray.getCapacity(); } // @Override toString 2018-10-20-jwl toString() { let arrInfo = `Stack: size = ${this.getSize()},capacity = ${this.getCapacity()},\n`; arrInfo += `data = [`; for (var i = 0; i < this.myArray.size - 1; i++) { arrInfo += `${this.myArray.data[i]}, `; } if (!this.isEmpty()) { arrInfo += `${this.myArray.data[this.myArray.size - 1]}`; } arrInfo += `] stack top is right!`; // 在页面上展示 document.body.innerHTML += `${arrInfo}<br /><br /> `; return arrInfo; } } 复制代码
-
Main
class Main { constructor() { this.alterLine('MyStack Area'); let ms = new MyStack(10); for (let i = 1; i <= 10; i++) { ms.push(i); console.log(ms.toString()); } console.log(ms.peek()); this.show(ms.peek()); while (!ms.isEmpty()) { console.log(ms.toString()); ms.pop(); } } // 将内容显示在页面上 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(); }; 复制代码
栈的应用
- undo 操作-编辑器
- 系统调用栈-操作系统
- 括号匹配-编译器
以编程的方式体现栈的应用
-
括号匹配-编译器
- 无论是写表达式,这个表达式中有小括号、中括号、大括号,
- 自然会出现括号套括号的情况发生,
- 在这种情况下就一定会产生一个括号匹配的问题,
- 如果括号匹配是不成功的,那么编译器会进行报错。
-
编译器是如何检查括号匹配的问题?
- 原理是使用了一个栈。
-
可以通过解答 Leetcode 中的一个问题,
- 同时来看栈在括号匹配这个问题中的应用。
- Leetcode 是总部在美国硅谷一家
- 非常有年头又同时有信誉度的面向 IT 公司
- 面试这样一个在线的平台,
- 只需要注册一个 Leetcode 用户后,
- 就可以看到 Leetcode 上有非常多的问题,
- 对于每一个问题会规定输入和输出之后,
- 然后就可以编写属于自己的逻辑,
- 更重要的是可以直接把你编写的这个程序
- 提交给这个网站,
- 这个网站会自动的判断你的逻辑书写的是否正确,
- 英文网址:
leetcode.com
, - 2017 中文网址:
leetcode-cn.com
-
leetcode.com
与leetcode-cn.com
的区别leetcode-cn.com
支持中文,leetcode-cn.com
的题目数量没有英文版的多。leetcode-cn.com
的探索栏目的内容没有英文版的多。leetcode-cn.com
中的题目没有社区讨论功能,但英文版的有。
-
leetcode 中第二十号题目:有效的括号
- 如:
{ [ ( ) ] }
, - 从左往右,先将左侧的括号入栈,
- 然后遇到右侧的括号时就查看栈顶的左侧括号进行匹配,
- 如果可以匹配表示括号有效,否则括号无效,
- 括号有效那么就将栈顶的左侧括号取出,
- 然后继续从左往右,左侧括号就入栈,右侧括号就匹配,
- 匹配成功就让左侧括号出栈,匹配失败就是无效括号。
- 其实栈顶元素反映了在嵌套的层级关系中,
- 最新的需要匹配的元素。
- 这个算法非常的简单,但是也非常的实用。
- 很多工具中都有这样的逻辑来检查括号的匹配。
class Solution { isValid(s) { // leetcode 20. 有效的括号 /** * @param {string} s * @return {boolean} */ var isValid = function(s) { let stack = []; // 以遍历的方式进行匹配操作 for (let i = 0; i < s.length; i++) { // 是否是正括号 switch (s[i]) { case '{': case '[': case '(': stack.push(s[i]); break; default: break; } // 是否是反括号 switch (s[i]) { case '}': if (stack.length === 0 || stack.pop() !== '{') { console.log('valid error. not parentheses. in'); return false; } break; case ']': if (stack.length === 0 || stack.pop() !== '[') { console.log('valid error. not parentheses. in'); return false; } break; case ')': if (stack.length === 0 || stack.pop() !== '(') { console.log('valid error. not parentheses. in'); return false; } break; default: break; } } // 是否全部匹配成功 if (stack.length === 0) { return true; } else { console.log('valid error. not parentheses. out'); return false; } }; return isValid(s); } } 复制代码
class Main { constructor() { // this.alterLine("MyStack Area"); // let ms = new MyStack(10); // for (let i = 1; i <= 10 ; i++) { // ms.push(i); // console.log(ms.toString()); // } // console.log(ms.peek()); // this.show(ms.peek()); // while (!ms.isEmpty()) { // console.log(ms.toString()); // ms.pop(); // } this.alterLine('leetcode 20. 有效的括号'); let s = new Solution(); this.show(s.isValid('{ [ ( ) ] }')); this.show(s.isValid(' [ ( ] ) ')); } // 将内容显示在页面上 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(); }; 复制代码
- 如:
-
leetcode 是一个非常好的准备面试的一个平台
- 同时它也是算法竞赛的一个入门的地方。
- 你可以通过题库来进行训练,
- 题库的右边有关于这些题目的标签,
- 你可以选择性的去练习,
- 而且可以根据难度来进行排序这些题目,
- 你不一定要全部答对,
- 因为这些题目不仅仅只有一个标签。
-
如果你想使用你自己写的类,
- 那么你可以你自己写的自定义栈作为内部类来进行使用,
- 例如 把自定义栈的代码放到 Solution 类中,
- 那样也是可以使用,
- 还样就顺便测试了你自己数据结构实现的逻辑是否正确。
学习方法讨论
- 不要完美主义。掌握好“度”。
- 太过于追求完美会把自己逼的太紧,
- 会产生各种焦虑的心态,. 最后甚至会怀疑自己,
- 温故而知新,不要停止不前,
- 掌握好这个度,不存在你把那些你认为完全掌握了,
- 然后就成了某一个领域的专家,
- 相反一旦你产生很浓厚的厌恶感,
- 那么就意味着你即将会放弃或者已经选择了放弃,
- 虽然你之前想把它做到 100 分,
- 但是由于你的放弃让它变为 0 分。
- 学习本着自己的目标去。
- 不要在学的过程中偏离了自己的目标。
- 要分清主次。
- 难的东西,你可以慢慢的回头看一看。
- 那样才会更加的柳暗花明,
- 更能提升自己的收获。
队列 Queue
- 队列也是一种线性的数据结构
- 依然就是将数据排成一排。
- 相比数组,队列对应的操作是数组的子集。
- 与栈只能在同一端添加元素和取出元素有所不同,
- 在队列中只能从一端(队尾)添加元素,
- 只能从另一端(队首)取出元素。
- 例如你去银行取钱
- 你需要排队,入队的人不允许插队,
- 所以他要从队尾开始排队,
- 而前面取完钱的会从队首离开,
- 然后后面的人再往前移动一位,
- 最后重复这个过程,
- 直到没人再排队取钱了。
- 队列是一种先进先出的数据结构(先到先得)
- First In First Out(FIFO) 先进先出
队列的实现
Queue
void enqueue(E)
:入队E dequeue()
:出队E getFront()
:查看队首的元素int getSize()
:获取队列中的实际元素大小boolean isEmpty()
:获取队列是否为空的 bool 值
- 写一个接口叫做 IMyQueue
- 让 MyQueue 实现这个接口
- 这样就符合了面向对象的特性。
代码示例
-
class: MyArray, class: MyQueue, class: Main)
-
MyArray
class MyArray { // 构造函数,传入数组的容量capacity构造Array 默认数组的容量capacity=10 constructor(capacity = 10) { this.data = new Array(capacity); this.size = 0; } // 获取数组中的元素实际个数 getSize() { return this.size; } // 获取数组的容量 getCapacity() { return this.data.length; } // 判断数组是否为空 isEmpty() { return this.size === 0; } // 给数组扩容 resize(capacity) { let newArray = new Array(capacity); for (var i = 0; i < this.size; i++) { newArray[i] = this.data[i]; } // let index = this.size - 1; // while (index > -1) { // newArray[index] = this.data[index]; // index --; // } this.data = newArray; } // 在指定索引处插入元素 insert(index, element) { // 先判断数组是否已满 if (this.size == this.getCapacity()) { // throw new Error("add error. Array is full."); this.resize(this.size * 2); } // 然后判断索引是否符合要求 if (index < 0 || index > this.size) { throw new Error( 'insert error. require index < 0 or index > size.' ); } // 最后 将指定索引处腾出来 // 从指定索引处开始,所有数组元素全部往后移动一位 // 从后往前移动 for (let i = this.size - 1; i >= index; i--) { this.data[i + 1] = this.data[i]; } // 在指定索引处插入元素 this.data[index] = element; // 维护一下size this.size++; } // 扩展 在数组最前面插入一个元素 unshift(element) { this.insert(0, element); } // 扩展 在数组最后面插入一个元素 push(element) { this.insert(this.size, element); } // 其实在数组中添加元素 就相当于在数组最后面插入一个元素 add(element) { if (this.size == this.getCapacity()) { // throw new Error("add error. Array is full."); this.resize(this.size * 2); } // size其实指向的是 当前数组最后一个元素的 后一个位置的索引。 this.data[this.size] = element; // 维护size this.size++; } // get get(index) { // 不能访问没有存放元素的位置 if (index < 0 || index >= this.size) { throw new Error('get error. index < 0 or index >= size.'); } return this.data[index]; } // 扩展: 获取数组中第一个元素 getFirst() { return this.get(0); } // 扩展: 获取数组中最后一个元素 getLast() { return this.get(this.size - 1); } // set set(index, newElement) { // 不能修改没有存放元素的位置 if (index < 0 || index >= this.size) { throw new Error('set error. index < 0 or index >= size.'); } this.data[index] = newElement; } // contain contain(element) { for (var i = 0; i < this.size; i++) { if (this.data[i] === element) { return true; } } return false; } // find find(element) { for (var i = 0; i < this.size; i++) { if (this.data[i] === element) { return i; } } return -1; } // findAll findAll(element) { // 创建一个自定义数组来存取这些 元素的索引 let myarray = new MyArray(this.size); for (var i = 0; i < this.size; i++) { if (this.data[i] === element) { myarray.push(i); } } // 返回这个自定义数组 return myarray; } // 删除指定索引处的元素 remove(index) { // 索引合法性验证 if (index < 0 || index >= this.size) { throw new Error('remove error. index < 0 or index >= size.'); } // 暂存即将要被删除的元素 let element = this.data[index]; // 后面的元素覆盖前面的元素 for (let i = index; i < this.size - 1; i++) { this.data[i] = this.data[i + 1]; } this.size--; this.data[this.size] = null; // 如果size 为容量的四分之一时 就可以缩容了 // 防止复杂度震荡 if (Math.floor(this.getCapacity() / 4) === this.size) { // 缩容一半 this.resize(Math.floor(this.getCapacity() / 2)); } return element; } // 扩展:删除数组中第一个元素 shift() { return this.remove(0); } // 扩展: 删除数组中最后一个元素 pop() { return this.remove(this.size - 1); } // 扩展: 根据元素来进行删除 removeElement(element) { let index = this.find(element); if (index !== -1) { this.remove(index); } } // 扩展: 根据元素来删除所有元素 removeAllElement(element) { let index = this.find(element); while (index != -1) { this.remove(index); index = this.find(element); } // let indexArray = this.findAll(element); // let cur, index = 0; // for (var i = 0; i < indexArray.getSize(); i++) { // // 每删除一个元素 原数组中就少一个元素, // // 索引数组中的索引值是按照大小顺序排列的, // // 所以 这个cur记录的是 原数组元素索引的偏移量 // // 只有这样才能够正确的删除元素。 // index = indexArray.get(i) - cur++; // this.remove(index); // } } // @Override toString 2018-10-17-jwl toString() { let arrInfo = `Array: size = ${this.getSize()},capacity = ${this.getCapacity()},\n`; arrInfo += `data = [`; for (var i = 0; i < this.size - 1; i++) { arrInfo += `${this.data[i]}, `; } if (!this.isEmpty()) { arrInfo += `${this.data[this.size - 1]}`; } arrInfo += `]`; // 在页面上展示 document.body.innerHTML += `${arrInfo}<br /><br /> `; return arrInfo; } } 复制代码
-
MyQueue
class MyQueue { constructor(capacity = 10) { this.myArray = new MyArray(capacity); } // 入队 enqueue(element) { this.myArray.push(element); } // 出队 dequeue() { return this.myArray.shift(); } // 查看队首的元素 getFront() { return this.myArray.getFirst(); } // 查看队列中实际元素的个数 getSize() { return this.myArray.getSize(); } // 查看 队列当前的容量 getCapacity() { return this.myArray.getCapacity(); } // 查看队列是否为空 isEmpty() { return this.myArray.isEmpty(); } // 输出队列中的信息 // @Override toString 2018-10-20-jwl toString() { let arrInfo = `Queue: size = ${this.getSize()},capacity = ${this.getCapacity()},\n`; arrInfo += `data = front [`; for (var i = 0; i < this.myArray.size - 1; i++) { arrInfo += `${this.myArray.data[i]}, `; } if (!this.isEmpty()) { arrInfo += `${this.myArray.data[this.myArray.size - 1]}`; } arrInfo += `] tail`; // 在页面上展示 document.body.innerHTML += `${arrInfo}<br /><br /> `; return arrInfo; } } 复制代码
-
Main
class Main { constructor() { this.alterLine('MyQueue Area'); let mq = new MyQueue(10); for (let i = 1; i <= 10; i++) { mq.enqueue(i); console.log(mq.toString()); } console.log(mq.getFront()); this.show(mq.getFront()); while (!mq.isEmpty()) { console.log(mq.toString()); mq.dequeue(); } } // 将内容显示在页面上 show(content) { document.body.innerHTML += `${content}<br /><br />`; } // 展示分割线 alterLine(title) { let line = `--------------------${title}----------------------`; console.log(line); document.body.innerHTML += `${line}<br /><br />`; } } 复制代码
队列的复杂度分析
MyQueue
void enqueue(E)
:O(1)
均摊E dequeue()
:O(n)
出队的性能消耗太大了E getFront()
:O(1)
int getSize()
:O(1)
boolean isEmpty()
:O(1)
- 出队的性能消耗太大了
- 如果有一百万条数据,每次都要操作一百万次,
- 那么需要优化它,要让他出队的时候时间复杂度为
O(1)
, - 并且还要让他入队的时候时间复杂度依然是
O(1)
。 - 可以使用循环队列的方式来解决这个问题。
循环队列
- 自定义队列的性能是有局限性的
- 出队操作时的时间复杂度为
O(n)
, - 要把他变为
O(1)
。
- 出队操作时的时间复杂度为
- 当取出队列的第一个元素后,
- 第一个元素后面所有的元素位置不动,
- 这样一来时间复杂度就为
O(1)
了, - 下一次再取元素的时候从第二个开始,
- 取完第二个元素之后,
- 第二个元素后面所有的元素位置也不动,
- 入队的话直接往队尾添加元素即可。
- 循环队列的使用
- 你可以先用一个数字变量 front 指向队首,
- 然后再用一个数字变量 tail 指向队尾,
- front 指向的是队列中的第一个元素,
- tail 指向的是队列中最后一个元素的后一个位置,
- 当队列整体为空的时候,它们才会指向同一个位置,
- 所以
front == tail
时队列就为空, - 如果有一个元素入队了,
- front 会指向这个元素,
- 而 tail 会指向这个元素后一个位置(也就是 tail++),
- 然后再有一个元素入队了,
- front 还是指向第一个元素的位置,
- 而 tail 会指向第二个元素的后一个位置(还是 tail++),
- 然后再来四个元素入队了,
- front 还是指向第一个元素的位置,
- 而 tail 会指向第六个元素的后一个位置(tail++四次),
- 之后 要出队两个元素,
- front 会指向第三个元素的位置(也就是 front++两次),
- front 从指向第一个元素变成指向第三个元素的位置,
- 因为前两个已经出队了,
- 这时候再入队一个元素,
- tail 会指向第七个元素的后一个位置(还是 tail++),
- 这时队列的容量已经满了,可能需要扩容,
- 但是由于队列中有两个元素已经出队了,
- 那这两个位置空出来了,这时就需要利用这两个位置的空间了,
- 这就是循环队列了,以循环的方式重复利用空间,
- 自定义队列使用自定义数组实现的,
- 其实就是把数组看成一个环,数组中一共可以容纳 8 个元素,
- 索引是 0-7,那么 7 之后的索引应该是 0,tail 应该指向 0,
- 而不是认为整个数组的空间已经满了,
- 应该使用 tail 对数组的容量进行求余计算,
- tail 为 8,容量也为 8,求余之后为 0,所以 tail 应该指向 0,
- 这时再入队一个元素,tail 指向这个元素的后一个位置,即 1,
- 这时候如果再入队一个元素,那么此时 tail 和 front 相等,
- 但是那并不能证明队列为空,反而是队列满了,
- 所以需要在队列满之前进行判断,
tail+1==front
, - 就表示队列已满,当数组中只剩最后一个空间了,
- 队列就算是满的,因为再入队就会让 tail 与 front 相等,
- 而那个条件是队列已空才成立的,虽然对于整个数组空间来说,
- 是有意识地浪费了一个空间,但是减少了很大的时间消耗,
- 所以当
(tail+1)%c==front
时就可以扩容了, - 将
tail+1==front
变成(tail+1)%c==front
是因为 - tail 从数组的末端跑到前端是有一个求余的过程,
- 例如 front 指向的是第一个元素,而 tail 指向的第六个元素之后的位置,
- 那么此时 front 为 0,tail 为 7,容量为 8,还有一个浪费掉的空间,
- 这时候
(tail+1)%c==front
,所以队列满了, - 这就是循环队列所有的具体实现必须遵守的规则,
- 所有的 front 和 tail 向后移动的过程都要是这种循环的移动,
- 例如钟表,11 点钟的下一个钟头为 12 点钟,也可以管它叫做 0 点,
- 之后又会变成 1 点、2 点、3 点、4 点依次类推,
- 所以整个循环队列的索引也是像钟表一样形成了一个环,
- 只不过不一定有 12 刻度,而刻度的数量是由数组的容量(空间总数)决定的,
- 这就是循环队列的原理。
- 使用循环队列之后,
- 出队操作不再是整体往前移动一位了
- 而是通过改变 front 的指向,
- 入队操作则是改变 tail 的指向,
- 整个操作循环往复,
- 这样一来出队入队的时间复杂度都为
O(1)
了。
循环队列的简单实现解析
- 循环队列 MyLoopQueue
- 他的实现与 MyQueue 有很大的不同,
- 所以就不使用 MyArray 自定义动态数组了。
- 循环队列要从底层重新开始写起
- data:一个数组。
- front: 指向队头有效元素的索引。
- tail: 指向队尾有效元素的后一个位置的索引。
- size: 通过 front 和 tail 也可以做到循环。
- 但是使用 size 能够让逻辑更加的清晰明了。
- 循环队列实现完毕之后,
- 你可以不使用 size 来进行循环队列的维护,
- 而完完全全的使用 front 和 tail,
- 这样难度会稍微的难一点,
- 因为具体逻辑需要特别的小心,
- 会有一些小陷阱。
- 可以试着添加 resize 数组扩容缩容功能到极致,
- 可以锻炼逻辑能力、程序编写调试能力等等。
循环队列的实现
- 入队前先判断队列是否已经满了
- 判断方式
(tail + 1) % data.length == front
- 判断分析 (队尾指向的索引 + 1)余以数组的容量是否为队首指向的索引,
- 判断方式
- 从用户的角度上来看
- 队列里就是有这么多元素,
- 一侧是队首一侧是队尾,
- 其它的内容包括实际的数组的大小是用户指定的容量大小+1,
- 这些实现细节,用户是全部不知道的,给用户屏蔽掉了,
- 这就是封装自定义数据结构的目的所在,
- 用户在具体使用这些自定义数据结构的时候,
- 只需要了解接口中所涉及到的这些方法即可,
- 至于它的内部细节用户完全可以不用关心。
代码示例 (class: MyLoopQueue, class: Main)
-
MyLoopQueue
class MyLoopQueue { constructor(capacity = 10) { // 初始化新数组 this.data = new Array(capacity); // 初始化 队首、队尾的值 (索引) this.front = this.tail = 0; // 队列中实际元素个数 this.size = 0; } // 扩容 resize(capacity) { let newArray = new Array(capacity); let index = 0; for (let i = 0; i < this.size; i++) { // 索引可能会越界,于是就要取余一下, // 如果越界了,就从队首开始 index = (this.front + i) % this.getCapacity(); newArray[i] = this.data[index]; } this.data = newArray; this.front = 0; this.tail = this.size; } // 入队 enqueue(element) { // 判断队列中是否已满 if ((this.tail + 1) % this.getCapacity() === this.front) { this.resize(Math.floor(this.getCapacity() * 2)); } this.data[this.tail] = element; this.tail = (this.tail + 1) % this.getCapacity(); this.size++; } // 出队 dequeue() { // 判断队列是否为空 if (this.isEmpty()) { throw new Error("can't dequeue from an empty queue."); } let element = this.data[this.front]; this.data[this.front] = null; this.front = (this.front + 1) % this.getCapacity(); this.size--; // 当size 为容量的四分之一时就缩容一倍 if (this.size === Math.floor(this.getCapacity() / 4)) { this.resize(this.getCapacity() / 2); } return element; } // 查看队首的元素 getFront() { if (this.isEmpty()) { throw new Error('queue is empty.'); } return this.data[front]; } // 查看实际的元素个数 getSize() { return this.size; } // 查看容量 getCapacity() { return this.data.length; } // 队列是否为空 isEmpty() { // return this.size === 0; return this.front == this.tail; } // 输出循环队列中的信息 // @Override toString 2018-10-20-jwl toString() { let arrInfo = `LoopQueue: size = ${this.getSize()},capacity = ${this.getCapacity()},\n`; arrInfo += `data = front [`; for (var i = 0; i < this.myArray.size - 1; i++) { arrInfo += `${this.myArray.data[i]}, `; } if (!this.isEmpty()) { arrInfo += `${this.myArray.data[this.myArray.size - 1]}`; } arrInfo += `] tail`; // 在页面上展示 document.body.innerHTML += `${arrInfo}<br /><br /> `; return arrInfo; } } 复制代码
-
Main
class Main { constructor() { this.alterLine('MyLoopQueue Area'); let mlq = new MyQueue(10); for (let i = 1; i <= 10; i++) { mlq.enqueue(i); console.log(mlq.toString()); } console.log(mlq.getFront()); this.show(mlq.getFront()); while (!mlq.isEmpty()) { console.log(mlq.toString()); mlq.dequeue(); } } // 将内容显示在页面上 show(content) { document.body.innerHTML += `${content}<br /><br />`; } // 展示分割线 alterLine(title) { let line = `--------------------${title}----------------------`; console.log(line); document.body.innerHTML += `${line}<br /><br />`; } } 复制代码
自定义队列两种方式的对比
- 原来自定队列的出队时,时间复杂度为
O(n)
,- 使用循环队列的方式后,
- 出队时时间复杂度为
O(1)
, - 复杂度的分析只是一个抽象上的理论结果,
- 具体这个变化在性能上意味着会有一个质的飞跃,
- 队列中元素越多,性能就更能够体现出来。
自定义队列的时间复杂度对比
MyQueue
:数组队列,使用了自定义数组void enqueue(E)
:O(1)
均摊E dequeue()
:O(n)
出队的性能消耗太大了E getFront()
:O(1)
int getSize()
:O(1)
boolean isEmpty()
:O(1)
MyLoopQueue
:循环队列,没有使用自定义数组void enqueue(E)
:O(1)
均摊E dequeue()
:O(1)
均摊E getFront()
:O(1)
int getSize()
:O(1)
boolean isEmpty()
:O(1)
循环队列的复杂度分析
- 通过设置循环队列底层的机制
- 虽然稍微比数组队列要复杂一些,
- 但是这些复杂的工作是值得的,
- 因为他使得在数组队列中,
- 出队本该有
O(n)
的复杂度变为了O(1)
的复杂度, - 但是这个
O(1)
为均摊的时间复杂度, - 因为出队还是会涉及到缩容的操作,
- 在缩容的过程中还是免不了对队列中所有的元素进行一次遍历,
- 但是由于不可能每一次操作都会触发缩容操作来遍历所有的元素,
- 所以应该使用均摊复杂度的分析方式,那样才更加合理。
- 循环队列中所有的操作都是
O(1)
的时间复杂度。 O(n)
的复杂度要比O(1)
要慢,- 但是具体会慢多少可以通过程序来进行测试,
- 这样就能够知道在算法领域和数据结构领域
- 要费这么大的劲去研究更加优化的操作
- 这背后实际的意义到底在哪里。
- 让这两个队列进行入队和出队操作,
- 操作的次数为 100000 次,
- 通过在同一台机器上的耗时情况,
- 就能够知道性能有什么不同。
- 数据队列与循环队列十万次入队出队操作后的结果是:
MyQueue,time:15.463472711s
,MyLoopQueue,time:0.009602136s
,- 循环队列就算操作一亿次,
- 时间也才
MyLoopQueue,time:2.663835877s
, - 这个差距主要是在出队的操作中体现出来的,
- 这个性能差距是上千倍,所以这也是性能优化的意义。
- 测试性能时,不要只测试一次,你可以测试 100 次
- 取平均值即可,因为这不光和你的程序相关,
- 还会和你当前计算机的状态有关,
- 特别是在两个算法的时间复杂度一致时,
- 测试性能时可能出入会特别大,
- 因为这有多方面原因、如语法、语言、编译器、解释器等等,
- 这些都会导致你代码真正运行的逻辑机制
- 和你理论分析的是不一样的,
- 但是当两个算法的时间复杂度不一致时,
- 这时候测试性能的结果肯定会有巨大的差异,
- 如
O(1)
对O(n)
、O(n)
对O(n^2)
、O(n)
对O(logn)
。
代码示例
-
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); } // 计算运行的时间,转换为 天-小时-分钟-秒-毫秒 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; } } 复制代码
-
Main
class Main { constructor() { this.alterLine('Queues Comparison Area'); let mq = new MyQueue(); let mlq = new MyLoopQueue(); let performanceTest = new PerformanceTest(); let mqInfo = performanceTest.testQueue(mq, 10000); let mlqInfo = performanceTest.testQueue(mlq, 10000); this.alterLine('MyQueue Area'); console.log(mqInfo); this.show(mqInfo); this.alterLine('MyLoopQueue Area'); console.log(mlqInfo); this.show(mlqInfo); } // 将内容显示在页面上 show(content) { document.body.innerHTML += `${content}<br /><br />`; } // 展示分割线 alterLine(title) { let line = `--------------------${title}----------------------`; console.log(line); document.body.innerHTML += `${line}<br /><br />`; } } 复制代码
队列的应用
- 队列的概念在生活中随处可见
- 所以使用计算机来模拟生活中队列,
- 如在业务方面你需要排队,
- 或者更加专业的一些领域,
- 比如 网络数据包的排队、
- 操作系统中执行任务的排队等,
- 都可以使用队列。
- 队列本身是一个很复杂的问题
- 对于排队来说,队首到底怎么定义,
- 是有多样的定义方式的,也正因为如此,
- 所以存在广义队列这个概念,
- 这两种自定义队列
- 在组建计算机世界的其它算法逻辑的时候
- 也是有重要的应用的,最典型的应用是广度优先遍历。