一、前言
三者都属于数据结构,作为专业的技术人员来说,理解数据结构是不可或缺的一部分。在日常的面试中,可能会遇到栈、堆、队列等一系列问题。
- Event Loop的执行栈、任务队列。
- 变量存储的堆、栈问题。
- 栈、队列数据结构的实现问题。
- 栈、堆、队列是什么?
- 还有一些列相关的手写问题。
在面试中,经常问遇到与之相关的一些列问题哈。
二、栈
2.1 简介
栈 是一种遵循 后进先出(LIFO) 原则的有序集合。新添加和待删除的数据都保存在栈的同一端栈顶,另一端就是栈底。新元素靠近栈顶,旧元素靠近栈底。 栈由编译器自动分配释放。栈使用一级缓存。调用时处于存储空间,调用完毕自动释放。
举个栗子:乒乓球盒子/搭建积木
2.2 基本数据结构的存储(存储栈)
javaScript中,数据类型分为基本数据类型和引用数据类型,基本数据类型包含:string、number、boolean、undefined、null、symbol、bigint这几种。在内存中这几种数据类型存储在栈空间,我们按值访问。原型类型都存储在栈内存中,是大小固定并且有序的。
2.3 执行栈(函数调用栈)
我们知道了基本数据结构的存储之后,我们再来看看JavaScript中如何通过栈来管理多个执行上下文。
- 程序执行进入一个执行环境时,它的执行上下文就会被创建,并被推入执行栈中(入栈)。
- 程序执行完成时,它的执行上下文就会被销毁,并从栈顶被推出(出栈),控制权交由下一个执行上下文。
JavaScript中每一个可执行代码,在解释执行前,都会创建一个可执行上下文。按照可执行代码块可分为三种可执行上下文。
- 全局可执行上下文:每一个程序都有一个全局可执行代码,并且只有一个。任何不在函数内部的代码都在全局执行上下文。
- 函数可执行上下文:每当一个函数被调用时, 都会为该函数创建一个新的上下文。每个函数都被调用时都会创建它自己的执行上下文。
- Eval可执行上下文:Eval也有自己执行上下文。
因为JS执行中最先进入全局环境,所以处于"栈底的永远是全局环境的执行上下文"。而处于"栈顶的是当前正在执行函数的执行上下文",当函数调用完成后,它就会从栈顶被推出(理想的情况下,闭包会阻止该操作,闭包后续文章深入详解)。
"全局环境只有一个,对应的全局执行上下文也只有一个,只有当页面被关闭之后它才会从执行栈中被推出,否则一直存在于栈底"
看个例子:
let name = '蜗牛'; function sayName(name) { sayNameStart(name); } function sayNameStart(name) { sayNameEnd(name); } function sayNameEnd(name) { console.log(name); }
当代码进行时声明:
执行sayName函数时,会把直接函数压如执行栈,并且会创建执行上下文,执行完毕编译器会自动释放:
2.4 创建一个栈(实现栈方法)
我们需要自己创建一个栈,并且这个栈包含一些方法。
- push(element(s)):添加一个(或多个)新元素到栈顶
- pop():删除栈顶的元素,并返回该元素
- peek():返回栈顶的元素,不对栈做任何操作
- isEmpty():检查栈是否为空
- size():返回栈的元素个数
- clear():清空栈
function Stack() { let items = []; this.push = function(element) { items.push(element); }; this.pop = function() { let s = items.pop(); return s; }; this.peek = function() { return items[items.length - 1]; }; this.isEmpty = function() { return items.length == 0; }; this.size = function() { return items.length; }; this.clear = function() { items = []; } }
但是这样的方式在创建多个实例的时候为创建多个items的副本。就不太合适了。 用ES如何6实现Stack类了。可以用WeakMap实现,并保证属性是私有的。
let Stack = (function() { const items = new WeakMap(); class Stack { constructor() { items.set(this, []); } getItems() { let s = items.get(this); return s; } push(element) { this.getItems().push(element); } pop() { return this.getItems().pop(); } peek() { return this.getItems()[this.getItems.length - 1]; } isEmpty() { return this.getItems().length == 0; } size() { return this.getItems().length; } clear() { this.getItems() = []; } } return Stack; })();
2.5 使用栈解决问题
栈可以解决十进制转为二进制的问题、任意进制转换的问题、平衡园括号问题、汉罗塔问题。
// 例子十进制转二进制问题 function divideBy2(decNumber) { var remStack = new Stack(), rem, binaryString = ''; while (decNumber > 0) { rem = Math.floor(decNumber % 2); remStack.push(rem); decNumber = Math.floor(decNumber / 2); } while(!remStack.isEmpty()) { binaryString += remStack.pop().toString(); } return binaryString; } // 任意进制转换的算法 function baseConverter(decNumber, base) { var remStack = new Stack(), rem, binaryString = '', digits = '0123456789ABCDEF'; while (decNumber > 0) { rem = Math.floor(decNumber % base); remStack.push(rem); decNumber = Math.floor(decNumber / base); } while(!remStack.isEmpty()) { binaryString += digits[remStack.pop()].toString(); } return binaryString; }
2.6 栈溢出问题
2.6.1 栈大小限制
不同浏览器对调用栈的大小是有限制,超过将出现栈溢出的问题。下面这段代码可以检验不用浏览器对调用栈的大小限制。
var i = 0; function recursiveFn () { i++; recursiveFn(); } try { recursiveFn(); } catch (ex) { console.log(`我的最大调用栈 i = ${i} errorMsg = ${ex}`); }
谷歌浏览器:
QQ浏览器:
搜狗浏览器:
微软 Edge浏览器:
2.6.2 递归调用的栈溢出问题
function Fibonacci (n) { if ( n <= 1 ) {return 1}; return Fibonacci(n - 1) + Fibonacci(n - 2); } Fibonacci(10) // 89 Fibonacci(100) // 超时 Fibonacci(500) // 超时
上面代码是一个阶乘函数,计算n的阶乘,最多需要保存n个调用记录,复杂度 O(n) 。如果超出限制,会出现栈溢出问题。
2.6.3 尾递归调用优化
递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。
function Fibonacci2 (n , ac1 = 1 , ac2 = 1) { if( n <= 1 ) {return ac2}; return Fibonacci2 (n - 1, ac2, ac1 + ac2); } Fibonacci2(100) // 573147844013817200000 Fibonacci2(1000) // 7.0330367711422765e+208 Fibonacci2(10000) // Infinity
由此可见,“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6 亦是如此,第一次明确规定,所有 ECMAScript 的实现,都必须部署“尾调用优化”。这就是说,ES6 中只要使用尾递归,就不会发生栈溢出(或者层层递归造成的超时),相对节省内存。
2.6.4 为什么这个例子里尾递归占用的栈内存也会随着参数增加呢
说好的尾递归优化只存在一个调用帧了,说好的永远不会发生“栈溢出”了。这路给大家解释一下原因。 各大浏览器(除了safari)根本就没部署尾调用优化,直接在浏览器上的控制台上调试尾递归的代码当然还是会出现栈溢出的问题。谢谢 EternallyMaybe提出并给到的补充。
列子来源:ECMAScript 6 入门
三、堆
3.1 简介
堆,一般由操作人员(程序员)分配释放,若操作人员不分配释放,将由OS回收释放。分配方式类似链表。堆存储在二级缓存中。
3.2 堆内存
JavaScript 的数据类型除了原始类型,还有一类是 Object 类型,它包含:
- Object
- Function
- Array
- Date
- RegExp
Object 类型都存储在堆内存中,是大小不定,复杂可变的。 Object 类型数据的 指针 存储在栈内存空间, 指针实际指向的值存储在堆内存空间。
3.3 为什么会有堆内存、栈内存之分
通常与垃圾回收机制有关。为了使程序运行时占用的内存最小。
当一个方法执行时,每个方法都会建立自己的内存栈,在这个方法内定义的变量将会逐个放入这块栈内存里,随着方法的执行结束,这个方法的内存栈也将自然销毁了。因此,所有在方法中定义的变量都是放在栈内存中的;
当我们在程序中创建一个对象时,这个对象将被保存到运行时数据区中,以便反复利用(因为对象的创建成本通常较大),这个运行时数据区就是堆内存。堆内存中的对象不会随方法的结束而销毁,即使方法结束后,这个对象还可能被另一个引用变量所引用(方法的参数传递时很常见),则这个对象依然不会被销毁,只有当一个对象没有任何引用变量引用它时,系统的垃圾回收机制才会在核实的时候回收它。
四、队列
4.1 简介
队列遵循FIFO,先进先出原则的一组有序集合。队列在尾部添加元素,在顶部删除元素。在现实中最常见的队列就是排队。先排队的先服务。(请大家文明排队,不要插队。)
4.2 任务队列
JavaScript是单线程,单线程任务被分为同步任务和异步任务。同步任务在调用栈中等待主线程依次执行,异步任务会在有了结果之后,将回调函数注册到任务队列,等待主线程空闲(调用栈为空),放入执行栈等待主线程执行。
Event loop执行如下图,任务队列只是其中的一部分。
执行栈在执行完同步任务之后,如果执行栈为空,就会去检查微任务(MicroTask)队列是否为空,如果为空的话,就会去执行宏任务队列(MacroTask)。否则就会一次性执行完所有的微任务队列。 每次一个宏任务执行完成之后,都会去检查微任务队列是否为空,如果不为空就会按照先进先出的方式执行完微任务队列。然后在执行下一个宏任务,如此循环执行。直到结束。
4.3 创建一个队列(实现队列方法)
实现包含以下方法的Queue类
- enqueue(element(s)):向队列尾部添加一个(或多个)元素。
- dequeue():移除队列的第一项,并返回移除的元素。
- front():返回队列的第一个元素--最先被添加,最先被移除的元素。
- isEmpty():判断队列是否为空。
- size():返回队列的长度。
// 队列Queue类简单实现 function Queue() { let items = []; // 添加元素 this.enqueue = function(element) { items.push(element); }; // 删除元素 this.dequeue = function() { return items.shift(); }; // 返回队列第一个元素 this.front = function() { return items[0]; }; // 判断队列是否为空 this.isEmpty = function() { return items.length === 0; }; // 返回队列长度 this.size = function() { return items.length; }; }
ES6语法实现Queue队列类,利用WeakMap来保存私有属性items,并用外层函数(闭包)来封装Queue类。
let Queue1 = (function() { const items = new WeakMap(); class Queue1 { constructor() { items.set(this, []); } // 获取队列 getQueue() { return items.get(this); } // 添加元素 enqueue (element) { this.getQueue().push(element); } // 删除元素 dequeue() { return this.getQueue().shift(); } // 返回队列第一个元素 front() { return this.getQueue()[0]; } // 判断队列是否为空 isEmpty() { return this.getQueue().length === 0; } // 返回队列长度 size() { return this.getQueue().length; } } return Queue1; })();
4.4 优先队列
元素的添加和删除基于优先级。常见的就是机场的登机顺序。头等舱和商务舱的优先级高于经济舱。实现优先队列,设置优先级。
// 优先列队 function PriorityQueue() { let items = []; // 创建元素和它的优先级(priority越大优先级越低) function QueueElement(element, priority) { this.element = element; this.priority = priority; } // 添加元素(根据优先级添加) this.enqueue = function(element, priority) { let queueElement = new QueueElement(element, priority); // 标记是否添加元素的优先级的值最大 let added = false; for (let i = 0; i < items.length; i++) { if (queueElement.priority < items[i].priority) { items.splice(i, 0, queueElement); added = true; break; } } if (!added) { items.push(queueElement); } }; // 删除元素 this.dequeue = function() { return items.shift(); }; // 返回队列第一个元素 this.front = function() { return items[0]; }; // 判断队列是否为空 this.isEmpty = function() { return items.length === 0; }; // 返回队列长度 this.size = function() { return items.length }; // 打印队列 this.print = function() { for (let i = 0; i < items.length; i++) { console.log(`${items[i].element} - ${items[i].priority}`); } }; }
4.5 循环队列(击鼓传花)
// 循环队列(击鼓传花) function hotPotato(nameList, num) { let queue = new Queue(); //{1} // 构造函数为4.3创建 for(let i =0; i< nameList.length; i++) { queue.enqueue(nameList[i]); // {2} } let eliminted = ''; while(queue.size() > 1) { // 把队列num之前的项按照优先级添加到队列的后面 for(let i = 0; i < num; i++) { queue.enqueue(queue.dequeue()); // {3} } eliminted = queue.dequeue(); // {4} console.log(eliminted + '在击鼓传花游戏中被淘汰'); } return queue.dequeue(); // {5} } let names = ['John', 'Jack', 'Camila', 'Ingrid', 'Carl']; let winner = hotPotato(names, 7); console.log('获胜者是:' + winner); 复制代码
实现一个模拟击鼓传花的游戏:
- 利用队列类,创建一个队列。
- 把当前玩击鼓传花游戏的所有人都放进队列。
- 给定一个数字,迭代队列,从队列的开头移除一项,添加到队列的尾部(如游戏就是:你把花传给旁边的人,你就可以安全了)。
- 一旦迭代次数到达,那么这时拿着花的这个人就会被淘汰。
- 最后剩下一个人,这个人就是胜利者。
参考来源:














