实力加自信就是一把坚韧不摧的利剑——栈与栈的应用

1. 前言

本文来源自我近期的一个开源博客,仓库地址:github.com/sicau-hsuya…,对前端感兴趣的小伙伴们,可以mark一下这个仓库哦,通过学习上面的内容一定能提高你的JS编码能力,目前还在不断的更新中,当前更新的章节主要是数据结构和算法。

2. 栈的介绍

栈(stack)又名堆栈,它是一种运算受限的线性表。限定仅在表尾进行插入和删除操作的线性表。这一端被称为栈顶,相对地,把另一端称为栈底。向一个栈插入新元素又称作进栈、入栈或压栈,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称作出栈或退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。

因此,栈具备一个重要的性质:后入先出

现实生活中,最常见的栈就是堆盘子。

JS 的数组同时具备栈和队列的特性,假设我们每次仅使用数组的pushpop方法,数组即栈。

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 四则运算

四则运算 ,这是华为的机考题,难度的话,其实还是不简单的。 四则运算.png 主要原因,就是因为加入了()[]{},计算机可不知道这些东西是什么的,[]{},但是我们用我们寻常的思维想,我们自己读中学的时候,解答四则混合运算,不就是先解答小括号的内容,然后把中括号变小括号,以此类推,直到没有括号,然后按正常的计算顺序算就好了。

既然这样,那么我们是否可以首先先把()之间的内容先求出来呢,它总会是一个结果,然后我把这期间的字符串替换成一个新的字符串,那我不就简化了问题了吗,对于[]{}也同理了。

我在编写这个算法的时候有一些偷懒,就是在不带括号的四则运算时,我用的是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],你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。

猜你喜欢

转载自juejin.im/post/7127575211083300877