栈的从0-1的应用

栈 作为一种遵循先入后出逻辑的线性数据结构 它是基于数组与链表来实现的 我们 先来聊聊栈的应用 首先 当我们浏览浏览器的页面时 我们依次访问了a→b→c三个网页 显然当我们在c页面点击后退时 会返回b页面 在点击前进 又会回到c页面

这种遵循先入后出的程序 就被我们称为栈 后进者先出,先进者后出,这就是典型的“栈”结构。

从栈的操作特性上来看,栈是一种“操作受限”的线性表,只允许在一端插入和删除数据。

我第一次接触这种数据结构的时候,就对它存在的意义产生了很大的疑惑。因为我觉得,相比数组和链表,栈带给我的只有限制,并没有任何优势。那我直接使用数组或者链表不就好了吗?为什么还要用这个“操作受限”的“栈”呢?

事实上,从功能上来说,数组或链表确实可以替代栈,但你要知道,特定的数据结构是对特定场景的抽象,而且,数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就比较不可控,自然也就更容易出错。

当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,我们就应该首选“栈”这种数据结构

内置栈

在c语言中并未设置独立的内置栈 但是在java中有 我们用Java来简单了解一下内置栈

//在java中 push() pop() peak()分别代表元素入栈 出栈以及访问栈顶元素
Stack<Integer> stack = new Stack<>();

/* 元素入栈 */
stack.push(1);
stack.push(3);
stack.push(2);
stack.push(5);
stack.push(4);

/* 访问栈顶元素 */
int peek = stack.peek();

/* 元素出栈 */
int pop = stack.pop();

int size = stack.size();
boolean isEmpty = stack.isEmpty(); 

对于Java中的内置栈分别有三个函数 push pop peak来简易实现栈操作 这些操作时间复杂度均为O(1)

以链表为实现机制的栈

使用链表实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。

对于入栈操作,我们只需将元素插入链表头部,这种节点插入方法被称为“头插法”。而对于出栈操作,只需将头节点从链表中删除即可。

/* 基于链表实现的栈 */
typedef struct {
    ListNode *top; // 将头节点作为栈顶
    int size;      // 栈的长度
} LinkedListStack;

/* 构造函数 */
LinkedListStack *newLinkedListStack() {
    LinkedListStack *s = malloc(sizeof(LinkedListStack));
    s->top = NULL;
    s->size = 0;
    return s;
}

/* 析构函数 */
void delLinkedListStack(LinkedListStack *s) {
    while (s->top) {
        ListNode *n = s->top->next;
        free(s->top);
        s->top = n;
    }
    free(s);
}

/* 获取栈的长度 */
int size(LinkedListStack *s) {
    return s->size;
}

/* 判断栈是否为空 */
bool isEmpty(LinkedListStack *s) {
    return size(s) == 0;
}

/* 入栈 */
void push(LinkedListStack *s, int num) {
    ListNode *node = (ListNode *)malloc(sizeof(ListNode));
    node->next = s->top; // 更新新加节点指针域
    node->val = num;     // 更新新加节点数据域
    s->top = node;       // 更新栈顶
    s->size++;           // 更新栈大小
}

/* 访问栈顶元素 */
int peek(LinkedListStack *s) {
    if (s->size == 0) {
        printf("栈为空\n");
        return INT_MAX;
    }
    return s->top->val;
}

/* 出栈 */
int pop(LinkedListStack *s) {
    int val = peek(s);
    ListNode *tmp = s->top;
    s->top = s->top->next;
    // 释放内存
    free(tmp);
    s->size--;
    return val;
}

首先我们先来解释析构函数 析构函数是一种特殊的函数,用于在对象生命周期结束时自动释放资源。具体来说,析构函数会在对象被销毁或手动调用析构操作时执行,释放内存、关闭文件或断开数据库连接等,从而避免内存泄漏和资源占用。而c语言并没有类似Java的析构函数 需要我们手动创建

析构函数 delLinkedListStack

:用于释放栈内存,避免内存泄漏。

  • while 循环:只要 top 不为 NULL,就逐一释放节点。
  • n 指向栈顶的下一个节点,之后释放当前 top 节点的内存。
  • s->top = n 将栈顶指针更新为下一个节点,以便在下一次循环中继续释放。
  • 循环结束后,释放栈结构体本身的内存 free(s)

入栈 出栈的程序相差无几 我们举例解释

pop 函数

:移除栈顶元素并返回其值。

  • val = peek(s):获取栈顶元素的值。
  • tmp 保存当前栈顶节点指针,以便之后释放。
  • s->top = s->top->next:将栈顶更新为下一个节点。
  • free(tmp) 释放旧的栈顶节点内存。
  • s->size-- 栈大小减1。
  • 返回栈顶元素的值 val

以数组为实现机制的栈

/* 基于数组实现的栈 */
typedef struct {
    int *data;
    int size;
} ArrayStack;

/* 构造函数 */
ArrayStack *newArrayStack() {
    ArrayStack *stack = malloc(sizeof(ArrayStack));
    // 初始化一个大容量,避免扩容
    stack->data = malloc(sizeof(int) * MAX_SIZE);
    stack->size = 0;
    return stack;
}

/* 析构函数 */
void delArrayStack(ArrayStack *stack) {
    free(stack->data);
    free(stack);
}

/* 获取栈的长度 */
int size(ArrayStack *stack) {
    return stack->size;
}

/* 判断栈是否为空 */
bool isEmpty(ArrayStack *stack) {
    return stack->size == 0;
}

/* 入栈 */
void push(ArrayStack *stack, int num) {
    if (stack->size == MAX_SIZE) {
        printf("栈已满\n");
        return;
    }
    stack->data[stack->size] = num;
    stack->size++;
}

/* 访问栈顶元素 */
int peek(ArrayStack *stack) {
    if (stack->size == 0) {
        printf("栈为空\n");
        return INT_MAX;
    }
    return stack->data[stack->size - 1];
}

/* 出栈 */
int pop(ArrayStack *stack) {
    int val = peek(stack);
    stack->size--;
    return val;
}

stack->datastack->size> 用于访问结构体指针 stack 中的成员变量。等同于 (*stack).data(*stack).size,但 > 使语法更简洁直观。

当我们有一个结构体指针(例如这里的 stack)时,直接使用 stack.data 会导致编译错误,因为 stack 是一个指针而不是直接的结构体对象。通过 stack->data,可以直接访问 stack 所指向的结构体中的成员变量。

push 函数

  • 首先检查 stack->size 是否等于 MAX_SIZE,如果是,表示栈已满,打印“栈已满”并返回。
  • stack->data[stack->size] = num:将 num 存入数组中栈顶的位置。索引表示栈顶元素下一个位置
  • stack->size++:栈大小加1。

两者区别

以数组为基础实现的栈(顺序栈)和以链表为基础实现的栈(链式栈)在底层实现和内存管理上有一些重要区别,以下是两者的详细对比:

1. 内存分配方式

  • 数组栈:数组栈在初始化时通常会分配一个固定大小的数组。它在栈满时需要重新分配更大的数组来扩容。由于是连续的内存空间,所以访问速度更快,但在扩容时需要重新分配和复制元素,影响效率。
  • 链表栈:链表栈按需动态分配内存,每次 push 操作时会分配一个新节点,因此理论上可以无限扩展(受限于系统内存)。链表栈的内存利用更灵活,但分配新节点比数组的直接访问慢。

2. 存储空间

  • 数组栈:有可能会因为预分配了多余的空间而浪费内存,尤其是栈的元素个数远小于分配的容量时。
  • 链表栈:只需要分配实际使用的节点,不会浪费内存,但每个节点需要额外的指针来存储链表信息,导致空间利用率稍低。

3. 访问速度

  • 数组栈:由于数组在内存中是连续分配的,数据访问效率高,栈顶元素可以直接通过索引 size-1 快速访问。
  • 链表栈:链表栈的节点不连续存储,每次访问栈顶节点时,需要通过指针访问,速度稍慢。

4. 扩展性

  • 数组栈:一旦数组达到容量限制,如果继续 push 就需要扩容,而扩容涉及重新分配数组并复制现有数据,开销较大。
  • 链表栈:链表栈按需分配内存,不受栈大小的固定限制,扩展性更好。

5. 实现复杂性

  • 数组栈:实现简单,pushpop 操作只涉及简单的索引操作和数据赋值。
  • 链表栈:实现稍复杂,需要处理指针,插入、删除节点时需要更新链表指针关系。

6. 适用场景

  • 数组栈:适合元素数量稳定且对访问速度有要求的场景,例如表达式求值、浏览器的前进后退栈等。
  • 链表栈:适合需要动态调整栈大小的场景,或对内存空间利用有严格要求的场景。

因此 对于两者 我们无法通过简单对比判断出谁更有效 而是需要根据实际需求来选择更合适的需求

函数调用栈

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

栈的从0-1的应用_第1张图片

在栈中 对于代码 程序会先让主函数中变量入栈 之后逐个在入函数中的栈

栈在表达式求值中的应用

我们再来看栈的另一个常见的应用场景,编译器如何利用栈来实现表达式求值

为了方便解释,我将算术表达式简化为只包含加减乘除四则运算,比如:34+13*9+44-12/3。对于这个四则运算,我们人脑可以很快求解出答案,但是对于计算机来说,理解这个表达式本身就是个挺难的事儿。如果换作你,让你来实现这样一个表达式求值的功能,你会怎么做呢?

实际上,编译器就是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。

如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。

栈在括号匹配中的应用

除了用栈来实现表达式求值,我们还可以借助栈来检查表达式中的括号是否匹配。

我们同样简化一下背景。我们假设表达式中只包含三种括号,圆括号 ()、方括号 [] 和花括号{},并且它们可以任意嵌套。比如,{[{}]}或 [{()}([])] 等都为合法格式,而{[}()] 或 [({)] 为不合法的格式。那我现在给你一个包含三种括号的表达式字符串,如何检查它是否合法呢?

这里也可以用栈来解决。我们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。

当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。

结束语

在日常开发中 以双栈实现的应用场景有很多 像是计算四则运算 又或者网站前进与后退 栈是一种数据结构 遵循先入后出的原则 在应用场景中有许多实现机制 它与队列也有许多相似之处 下一篇博客笔者会介绍如何实现队列

你可能感兴趣的:(数据结构,java,c语言)