数据结构:栈及其运用

699 阅读4分钟

一、什么是栈

1.1 物理结构和逻辑结构

在说什么是栈之前, 先理解一个概念:物理结构和逻辑结构。

什么是数据存储的物理结构?

  • 如果把数据结构比作活生生的人,那么物理结构就是人的血肉和骨骼,看得见,摸得着,实实在在。例如:数组、链表,都是在内存中实实在在的存储结构。
  • 如果把物质层面的人体比作数据存储的物理结构,那么精神层面的人格则是数据存储中的逻辑结构。逻辑结构是抽象概念,它依赖于物理结构而存在。

1.2 栈的概念

举个例子,假如有一个又细又长的圆筒,圆筒一端封闭,另一端开口。往圆筒里放入乒乓球,先放入的靠近圆筒底部,后放入的靠近圆筒入口。

如果,想要取出这些乒乓球,只能按照和放入顺序相反的顺序来取,先取出后放入的,再取出先放入的,而不可能把最里面最先放入的乒乓球优先取出。

栈(stack)是一种线性数据结构,它就像是上图所示的放入乒乓球的圆筒容器,栈中的元素只能先入后出 (First In Last Out,简称FILO)。

最早进入的元素存放的位置叫做栈底(bottom) ,最后进入的元素存放的位置叫做栈顶(top)

特点:先入后出(FILO,First In Last Out)。

栈的数据结构,既可以用数组来实现,也可以用链表来实现。

栈的数组实现如下:

栈的链表实现如下:

1.3 简单了解下数组

数组可以说是最基本最常见的数据结构。数组一般用来存储相同类型的数据,可通过数组名和下标进行数据的访问和更新。数组中元素的存储是按照先后顺序进行的,同时在内存中也是按照这个顺序进行连续存放。数组相邻元素之间的内存地址的间隔一般就是数组数据类型的大小。

1.4 简单了解下链表

链表相较于数组,除了数据域,还增加了指针域用于构建链式的存储数据。链表中每一个节点都包含此节点的数据和指向下一节点地址的指针。由于是通过指针进行下一个数据元素的查找和访问,使得链表的自由度更高。

这表现在对节点进行增加和删除时,只需要对上一节点的指针地址进行修改,而无需变动其它的节点。不过事物皆有两极,指针带来高自由度的同时,自然会牺牲数据查找的效率和多余空间的使用。

一般常见的是有头有尾的单链表,对指针域进行反向链接,还可以形成双向链表或者循环链表。

二、栈的基本操作

2.1 入栈

入栈操作(push),就是把新元素放入栈中,只允许从栈顶一侧放入元素,新元素的位置将会成为新的栈顶。

以数组实现为例:

2.2 出栈

出栈操作(pop)就是把元素从栈中弹出,只有栈顶元素才允许出栈,出栈元素的前一个元素将会成为新的栈顶。

以数组实现为例:

基于数组实现的顺序栈:

// 基于数组实现的顺序栈
public class ArrayStack {
  private String[] items;  // 数组
  private int count;       // 栈中元素个数
  private int n;           //栈的大小

  // 初始化数组,申请一个大小为n的数组空间
  public ArrayStack(int n) {
    this.items = new String[n];
    this.n = n;
    this.count = 0;
  }

  // 入栈操作
  public boolean push(String item) {
    // 数组空间不够了,直接返回false,入栈失败。
    if (count == n) return false;
    // 将item放到下标为count的位置,并且count加一
    items[count] = item;
    ++count;
    return true;
  }
  
  // 出栈操作
  public String pop() {
    // 栈为空,则直接返回null
    if (count == 0) return null;
    // 返回下标为count-1的数组元素,并且栈中元素个数count减一
    String tmp = items[count-1];
    --count;
    return tmp;
  }
}

2.3 复杂度

因为出栈和入栈都只会影响到最后一个元素,不涉及其他元素的整体移动,所以无论是以数组还是以链表实现的栈。其入栈和出栈的时间复杂度都是O(1)

而栈的空间复杂度是O(n)

三、栈的应用

3.1题目:20.有效的括号

leetcode.cn/problems/va…

给定一个只包括 '('')''{''}''['']' 的字符串 s ,判断字符串是否有效。

有效字符串需满足:

  • 左括号必须用相同类型的右括号闭合。
  • 左括号必须以正确的顺序闭合。

示例 1:

输入:s = "()"

输出:true

示例 2:

输入:s = "()[]{}"

输出:true

示例 3:

输入:s = "(]"

输出:false

示例 4:

输入:s = "([)]"

输出:false

示例 5:

输入:s = "{[]}"

输出:true

提示:

-   1 <= s.length <= 104
-   s 仅由括号 '()[]{}' 组成

当开始接触题目时,我们会不禁想到如果计算出左括号的数量,和右括号的数量,如果每种括号左右数量相同,会不会就是有效的括号了呢?

事实上不是的,假如输入是[{]},每种括号的左右数量分别相等,但不是有效的括号。这是因为结果还与括号的位置有关。

仔细分析我们发现,对于有效的括号,它的部分子表达式仍然是有效的括号,比如 {()[()]} 是一个有效的括号,()[{}] 是有效的括号,[()] 也是有效的括号。并且当我们每次删除一个最小的括号对时,我们会逐渐将括号删除完。比如下面的例子。

这个思考的过程其实就是栈的实现过程。因此我们考虑使用栈,当遇到匹配的最小括号对时,我们将这对括号从栈中删除(即出栈),如果最后栈为空,那么它是有效的括号,反之不是。

栈的解法:

/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function(s) {
    const n = s.length;
    if(n%2 === 1){
        return false;
    }
    const map1 = new Map([[')', '('],[']', '['],['}', '{']]);
    const stack1 = [];
    for(let ch of s){
        if(map1.has(ch)){
            if(!stack1.length || stack1[stack1.length-1]!== map1.get(ch)){
                return false;
            }
            stack1.pop();
        }else{
            stack1.push(ch);
        }
    };
    return !stack1.length;
};
  • 时间复杂度:O(N)。遍历了一遍字符串。
  • 空间复杂度:O(N)。最坏情况下,假如输入是 (((((((,栈的大小将是输入字符串的长度。

非栈的解法:

var isValid = function(s) {
  let length;
  do{
    length = s.length;
    s = s.replace("()","").replace("{}","").replace("[]","");
  }while(length != s.length);
  return s.length == 0;
}

时间复杂度不好判断,平均的情况下会达到O(n^2/2)的复杂度。因为replace本身的操作是 o(n) 的,所以最坏情况下会有产生n^2 的复杂度。

3.2 浏览器的前进和后退功能

浏览器的前进、后退功能,我们一定很熟悉。

  • 当我们依次访问完一串页面 a-b-c 之后
  • 点击浏览器的后退按钮,就可以查看之前浏览过的页面 b a
  • 当你后退到页面 a,点击前进按钮,就可以重新查看页面 bc
  • 如果你后退到页面 b 后,点击了新的页面 d,那就无法再通过前进、后退功能查看页面 c 了。

浏览器中的前进后退表现出的特征:后进者先出,先进者后出,这就是典型的“栈”结构。

  • 打开 a,b,c 三个页面,X 和 Y 的栈帧如下
|c|        | |
|b|        | |
|a|        | |
---        ---
 X          Y
  • 点击两次后退,从 c 退 b 再退到 a 页面
| |        | |
| |        |b|
|a|        |c|
---        ---
 X          Y
  • 点击前进,回到 b页面
| |        | |
|b|        | |
|a|        |c|
---        ---
 X          Y
  • 打开新页面 d,从逻辑上讲,d时最新页面,不存在更前面的页面了,需要清空 Y
|c|        | |
|b|        | |
|a|        | |
---        ---
 X          Y

3.3 函数调用栈

在软件工程的实际应用中,栈作为一个比较基础的数据结构,应用场景还是很多,经典的一个应用场景就是函数调用栈。

操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

为了让你更好地理解,我们一块来看下这段代码的执行过程。

function main() {
   let a = 1; 
   let ret = 0;
   let res = 0;
   ret = add(3, 5);
   res = a + ret;
   console.log(res);
   return 0;
}

function add(x, y) {
   int sum = 0;
   sum = x + y;
   return sum;
}

从代码中可以看出,main() 函数调用了 add() 函数,获取计算结果,并且与临时变量 a 相加,最后打印 res 的值。上述代码对应的函数栈里出栈、入栈的操作,如下图所示。图中显示的是,在执行到 add() 函数时,函数调用栈的情况。

四、课后练习

参考