1. 前言
本文来源自我近期的一个开源博客,仓库地址:github.com/sicau-hsuya…,对前端感兴趣的小伙伴们,可以mark一下这个仓库哦,通过学习上面的内容一定能提高你的JS编码能力,目前还在不断的更新中,当前更新的章节主要是数据结构和算法。
2. 栈的介绍
栈(stack
)又名堆栈
,它是一种运算受限的线性表。限定仅在表尾进行插入和删除操作的线性表。这一端被称为栈顶,相对地,把另一端称为栈底。向一个栈插入新元素又称作进栈、入栈或压栈,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称作出栈或退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。
因此,栈具备一个重要的性质:后入先出。
现实生活中,最常见的栈就是堆盘子。
JS 的数组同时具备栈和队列的特性,假设我们每次仅使用数组的push
和pop
方法,数组即栈。
const queue = [];
queue.push(1); //[1]
queue.push(12); //[1, 12]
queue.push(123); //[1, 12, 123]
let tail = queue.pop() // tail 为123
tail = queue.pop() // tail为12
tail = queue.pop() // tail为1, 此时栈已空
复制代码
3. 栈的通用实现
有时候,我们会严谨的自行实现栈的结构。
栈的实现方式可以有两种,一种是直接使用数组,另外就是使用链表。
3.1 数组实现
使用数组实现栈,对于JS的话可能我们察觉不出什么问题,那是因为JS的数组的长度是可变的。
而对于C#
或者Java
这类语言来说的话,其在申明的时候就必须要知道数组的长度,如果插入超过了可存储的最大长度,就可能面临扩容的问题,而数组拷贝需要花费一定的时间,若每个数据单元占用内存比较大的话,这个单次拷贝时间还是不能忽略的。
class Stack<T> {
/* 用于存储栈的数据 */
private data: T[] = [];
/* 栈的容量 */
get size(): number {
return this.data.length;
}
/* 获取栈顶的元素 */
get top(): T | null {
return this.isEmpty() ? null : this.data[this.size-1];
}
/**
* 压栈
* @param ele
*/
push(ele: T) {
let length = this.data.length;
// 让数组长度增加
this.data.length++;
// 将新加入的元素放在最后面
this.data[length] = ele;
}
/**
* 退栈
* @returns
*/
pop() {
if (this.isEmpty()) {
throw new Error("can't pop from an empty stack");
}
let length = this.data.length;
// 取出最后一个元素
let ele = this.data[length - 1];
// 数组长度-1
this.data.length--;
return ele;
}
/**
* 判断栈是否为空
* @returns
*/
isEmpty() {
return this.data.length === 0;
}
}
复制代码
3.2 链表实现
使用链表实现栈相对来说比较好一些,因为理论上内存足够多的话,链表实现的栈,长度是无限的,不存在数组中拷贝数组的问题。
/**
* 栈的节点元素定义,必须使用双向链表,便于我们查找前驱和后继元素
*/
interface LinkedListNode<T> {
next: LinkedListNode<T> | null;
prev: LinkedListNode<T> | null;
data: T;
}
/*
* 栈
*/
class Stack<T> {
/**
* 链表的头结点
*/
private head: LinkedListNode<T> | null = null;
private length = 0;
/* 栈的长度 */
public get size() {
return this.length;
}
/* 获取栈顶的元素 */
public get top(): T | null {
return this.isEmpty() ? null : this.head.data;
}
/**
* 压栈
* @param ele
*/
public push(ele: T) {
const newNode: LinkedListNode<T> = {
next: null,
prev: null,
data: ele,
};
// 栈长度增加
this.length++;
// 如果一个元素都没有,直接让head指向这个节点
if (this.head === null) {
this.head = newNode;
} else {
// 如果存在多个元素,让头指针指向新来的节点
this.head!.next = newNode;
// 新来的节点指向头指针
newNode.prev = this.head;
// 让原本的头指针指向新来的节点
this.head = newNode;
}
}
/**
* 退栈
*/
public pop() {
if (this.isEmpty()) {
throw new Error("can not pop from an empty stack");
}
// 获取到头节点的后继节点
let head = this.head!.next;
// 栈中的元素
let ele = this.head!.data;
// 解开第一个节点的后继节点
this.head!.next = null;
// 解开第一个节点的后继节点的前驱节点
head!.prev = null;
// 让栈首元素指向新的栈首元素
this.head = head;
// 栈长度递减
this.length--;
return ele;
}
/**
* 栈是否为空
* @returns
*/
public isEmpty() {
return this.length === 0;
}
}
复制代码
4. 应用场景
-
1.系统的堆栈就是栈的应用场景之一,这是我们每天开发都在用,却有不太容易感觉得到的;
-
2.在
深度优先搜索
中,我们需要使用栈用来记住向上返回的路径; -
3.在需要逆序的场景时,也需要使用栈,常见的如
无权图的单源最短路
问题。 -
4.在词法分析时,也需要栈,如
babel
将我们写在vue
组件中得template
的内容解析为AST
;
下面我们根据上面所提到的应用场景举例:
4.1 二叉树的非递归先序遍历
二叉树结点的定义如下:
interface TreeNode<T> {
left: TreeNode | null;
right: TreeNode | null;
val: T;
}
复制代码
非递归遍历二叉树,使用栈记录回溯的节点,继续后续的遍历。
function treePreOrder(tree) {
if (!tree) {
console.log("empty tree!");
return;
}
let stack = [];
let node = tree;
while (stack.length > 0 || node) {
while (node) {
stack.push(node);
console.log(node.data);
node = node.left;
}
if (stack.length > 0) {
node = stack.pop();
node = node.right;
}
}
}
复制代码
4.2 无权图求单源最短路径
对于有这样的图:
假设我们采用如下表示方法:
/* 图中的边类 */
class Edge {
constructor(name) {
this.name = name;
}
/**
* 边的编号
*/
name;
/**
* 起始点
* @type {Vertex}
*/
from;
/**
* 终止点
* @type {Vertex}
*/
to;
}
/* 图中的顶点类 */
class Vertex {
/**
* 城市名称
*/
cityName;
/**
* 邻接点
*/
siblings = [];
constructor(cityName) {
this.cityName = cityName;
}
}
/* 图类 */
class Graph {
/* 顶点集 */
vertexList = [];
/* 边集 */
edgeList = [];
/**
* 增加顶点
* @param {Vertex}
*/
addVertex(v) {
this.vertexList.push(v);
}
/**
* 增加边
* @param {Vertex} from
* @param {Vertex} to
*/
addEdge(from, to) {
const name = `${from.cityName}至${to.cityName}`;
const edge = new Edge(name);
this.edgeList.push(edge);
from.siblings.push(to);
to.siblings.push(from);
}
}
复制代码
根据上面的图表示的图的链接关系,我们初始化数据如下:
// 初始化图
const g = new Graph();
// 初始化城市
const beijing = new Vertex("北京");
const nanjing = new Vertex("南京");
const guangzhou = new Vertex("广州");
const shenzhen = new Vertex("深圳");
const hongkong = new Vertex("香港");
const chengdu = new Vertex("成都");
const xian = new Vertex("西安");
const urumchi = new Vertex("乌鲁木齐");
/**
* 将城市加入到图中
*/
g.addVertex(beijing);
g.addVertex(nanjing);
g.addVertex(guangzhou);
g.addVertex(shenzhen);
g.addVertex(hongkong);
g.addVertex(chengdu);
g.addVertex(xian);
g.addVertex(urumchi);
/**
* 建立连接关系
*/
g.addEdge(beijing, nanjing);
g.addEdge(beijing, xian);
g.addEdge(nanjing, guangzhou);
g.addEdge(guangzhou, shenzhen);
g.addEdge(guangzhou, hongkong);
g.addEdge(hongkong, shenzhen);
g.addEdge(chengdu, guangzhou);
g.addEdge(chengdu, xian);
g.addEdge(urumchi, xian);
g.addEdge(urumchi, beijing);
复制代码
那么,求指定起点到图中任意节点的最短路径的算法如下:
/**
* 单源无权图的最短路算法
* @param {Vertex} start
* @param {Vertex} end
*/
function unweightedShortestPath(start, end) {
const queue = [];
// 定义一个距离哈希表,用于记录每个开始顶点到任意顶点的距离
const dist = new Map();
// 定义一个路径哈希表,用于记录从开始顶点到任意顶点所经过的点的走法。
const path = new Map();
// 记录开始节点的距离
dist.set(start, 0);
queue.push(start);
while (queue.length > 0) {
let vertex = queue.shift();
for (let i = 0; i < vertex.siblings.length; i++) {
let adjoinVertex = vertex.siblings[i];
/* 若adjoinVertex未被访问过 */
if (typeof dist.get(adjoinVertex) === "undefined") {
/* 将这个点到start的距离更新 */
dist.set(adjoinVertex, dist.get(vertex) + 1);
/* 将这个点记录在S到adjoinVertex的路径上 */
path.set(adjoinVertex, vertex);
queue.push(adjoinVertex);
}
}
}
// 获取终点的最短路径长度
const distance = dist.get(end);
// 使用栈记住终点,根据路径的递推,找到一个从开始顶点到指定终点的路径
const stack = [end];
let preVertex = path.get(end);
// 沿途处理从终点到起点所经过的路径
while (preVertex) {
stack.push(preVertex);
preVertex = path.get(preVertex);
}
// 经过逆序,得到了正确的路径
let via = "";
while (stack.length) {
const city = stack.pop();
via += "->" + city.cityName;
}
return { distance, path: via.replace(/(^->)|(->$)/g, "") };
}
复制代码
这个算法不管是对有向图
还是无向图
,都适用。
4.3 词法分析
各位Vue
用户有没思考过,写在template
的代码是如何转换成最终的组件的,为什么我们可以使用Vue
既可以支持写template
的类HTML
语法,又可以写JSX
呢?
这都得益于babel
在底层给我们做了功不可没的工作,而babel
是怎么知道转换规则的呢,这就叫词法分析
。
下面,我们用两个例题阐述一下栈
在词法分析
中的应用。
4.3.1 序列化和反序列化二叉树
这道题,序列化是一个相当容易的事儿,我们可以直接用递归方式的DFS
对二叉树进行序列化。
/**
* 序列化二叉树
* @param {TreeNode} root
* @return {string}
*/
var serialize = function (root) {
if (typeof root === "undefined") {
return "";
} else if (root === null) {
return "null";
} else {
const leftStr = serialize(root.left);
const rightStr = serialize(root.right);
let str = `val:${root.val}`;
if (leftStr !== "") {
str += `,left:${leftStr}`;
}
if (rightStr !== "") {
str += `,right:${rightStr}`;
}
return `{${str}}`;
}
};
复制代码
但是反序列化是相当不容易的一件事(说用JSON.parse
的小伙伴们就赶紧洗洗睡吧去吧,哈哈哈),因为在二叉树序列化之后,我们会得到很多很多的{
和}
的嵌套,你该怎么确定一个对象的边界呢,这就是个问题。
但是通过分析,我们可以得知,假设输入的二叉树序列化字符串是合法的,每当我们遇到}
,那么,我们只要把这个}
到之前{
这期间的内容全部输出(可以包含{}
,也可以不包含,取决于你算法的具体实现),这期间的字符串,它一定是一个简单的对象字符串(不带嵌套),我们把这个对象解析之后,先按一定的规则存起来,后面,再处理到父级节点的时候,我们把父级节点的引用指向之前已经处理的节点即可,然后再把父节点存起来,不断重复这样的步骤,最终我们一定还剩下1个节点,那就是解析到了树根节点。
之前提到的,把这些字符存起来,怎么存,怎么取,这就需要用到栈。
算法实现如下:
/**
* 反序列化二叉树
* @param {string} data
* @return {TreeNode}
*/
var deserialize = function (data) {
try {
// 定义一个栈,用于词法分析
const stack = [];
// 定义一个节点栈,用于存储解析的结果,你也可以不用栈,根据自己的需要即可
const nodeStack = [];
let offset = 0;
while (offset < data.length) {
let char = data[offset];
// 如果遇到后大括号,需要退栈,退到遇到前花括号为止
if (char === "}") {
let leftChar = stack.pop();
let tempStr = "";
while (stack.length && leftChar != "{") {
tempStr = leftChar + tempStr;
leftChar = stack.pop();
}
/*
经过上述操作之后,我们可以得到一个不含嵌套的树节点字符串
主要有这几种case:
"val: 1"
"val: 1, left: null",
"val: 1, left: null, right: null",
"val: 1, left: , right: "
*/
// 定义解析val域的正则
let valArr = tempStr.match(/val:\s*(-?\d+)/);
// 定义解析左子树的正则
let leftArr = tempStr.match(/left:\s*(null)?/);
// 定义解析右子树的正则
let rightArr = tempStr.match(/right:\s*(null)?/);
// 申明一个初始的空节点,一会根据提取的内容覆盖其属性
let node = { val: Infinity };
// 解析val
if (!Array.isArray(valArr)) {
throw `the data source is not valid`;
} else {
node.val = Number.parseInt(valArr[1]);
}
// 解析左子树,但左子树不一定存在
if (Array.isArray(rightArr)) {
if (rightArr[1] === "null") {
node.right = null;
} else {
const rightChild = nodeStack.pop();
if (typeof rightChild === "undefined") {
throw `the data source is not valid`;
}
node.right = rightChild;
}
}
// 解析右子树,但右子树不一定存在
if (Array.isArray(leftArr)) {
if (leftArr[1] === "null") {
node.left = null;
} else {
const leftChild = nodeStack.pop();
if (typeof leftChild === "undefined") {
throw `the data source is not valid`;
}
node.left = leftChild;
}
}
/* 把解析出来的节点加入节点栈 */
nodeStack.push(node);
} else {
// 否则内容直接入栈
stack.push(char);
}
offset++;
}
if (nodeStack.length != 1) {
throw `the data source is not valid`;
}
// 在输入合法的前提下,节点栈中的第一个节点就是树的根节点
return nodeStack[0];
} catch (exp) {
console.log(exp);
return null;
}
};
复制代码
4.3.2 四则运算
四则运算 ,这是华为的机考题,难度的话,其实还是不简单的。 主要原因,就是因为加入了(
,)
,[
,]
,{
,}
,计算机可不知道这些东西是什么的,[
,]
,{
,}
,但是我们用我们寻常的思维想,我们自己读中学的时候,解答四则混合运算,不就是先解答小括号的内容,然后把中括号变小括号,以此类推,直到没有括号,然后按正常的计算顺序算就好了。
既然这样,那么我们是否可以首先先把()
之间的内容先求出来呢,它总会是一个结果,然后我把这期间的字符串替换成一个新的字符串,那我不就简化了问题了吗,对于[]
和{}
也同理了。
我在编写这个算法的时候有一些偷懒,就是在不带括号的四则运算时,我用的是eval
,如果不用eval
,仍然可以再继续使用栈进行词法解析。
算法实现如下:
/**
* 根据不带括号的表达式求值
* @param {string} str
*/
var calc = function (str) {
// TODO: 暂时先用eval实现,后期优化为自行处理
return eval(str);
};
/**
* 四则运算求值
* @param {string} s
*/
var arithmetic = function (s) {
let offset = 0;
let stack = [];
while (offset < s.length) {
let char = s[offset];
if (char === ")") {
// 解析()之间的内容
let tmpStr = "";
let tmpChar = stack.pop();
while (tmpChar != "(") {
tmpStr = tmpChar + tmpStr;
tmpChar = stack.pop();
}
stack.push(eval(tmpStr));
} else if (char === "]") {
// 解析[]之间的内容
let tmpStr = "";
let tmpChar = stack.pop();
while (tmpChar != "[") {
tmpStr = tmpChar + tmpStr;
tmpChar = stack.pop();
}
stack.push(calc(tmpStr));
} else if (char === "}") {
// 解析{}
let tmpStr = "";
let tmpChar = stack.pop();
while (tmpChar != "{") {
tmpStr = tmpChar + tmpStr;
tmpChar = stack.pop();
}
stack.push(calc(tmpStr));
} else {
stack.push(char);
}
offset++;
}
// 解析不带括号的表达式的结果
let tmpStr = "";
while (stack.length) {
tmpStr = stack.pop() + tmpStr;
}
let val = calc(tmpStr);
return val;
};
复制代码
上面需要注意的一个场景就是如果表达式不带大中小括号的话,我们不要忘了解析它,所以最后需要查看栈是否还是存着内容。
5. 结语
以上便是我总结的栈的实现与栈的3大类应用场景,其中每一个场景都非常重要。
因为现在我们都是基于框架写代码,有些时候难以洞察底层库为我们做的事儿,但是一旦遇到问题时,如何快速的定位和解决问题,这是一件道阻且长的事儿,需要不断的培养这方面的能力。
在当前这么艰难的互联网严冬环境下,只能提高自己的竞争力,物竞天择,适者生存。
近半年,我花了相当的时间用以研究算法和数据结构,通过广泛的涉猎发现,当我们具备这些知识之后,再回过头去看某些框架或库的源代码,以前的疑问几乎都能得到解答,算法和数据结构并不是单纯的为了面试而学习,作为程序的灵魂,拥有它,你可能会跑的更快,达到高于普通竞争者不易企及的高度,从而得到更好的平台和发展。
由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱[email protected],你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。