算法初级教学第四步:栈与队列

昨天我们讲了数组与链表,今天讲一下可以由数组与链表组成的栈与队列。

第一部分:地基 ------ 内存条里的"积木"

在讨论任何数据结构之前,我们必须先理解数据(Data) 住在哪里。

1. 计算机的"草稿纸":RAM(随机存取存储器)

想象一下,你的计算机内存(RAM)就是一条无限长的、被划分成无数小格子的纸带

  • 物理现实:内存并非二维的表格,在逻辑上,它是一维的线性空间。

  • 单元(Cell):每一个小格子只能存放一个字节(1 Byte = 8 bits)的数据。

  • 地址(Address) :为了找到这些格子,计算机给每一个格子编了个号,从 0 开始,一直到内存的尽头。这个编号就是内存地址

关键概念:为什么叫"随机"存取?

这对理解算法复杂度 O(1) 至关重要。这意味着,CPU 访问地址为 0x0001 的格子,和访问地址为 0xFFFF 的格子,所花费的物理时间是完全一样的。计算机不需要像卷磁带一样从头卷到尾,它可以直接"瞬移"到那个地址。

2. 数据的"居住方式"

栈和队列本质上是管理数据进出的规则,但数据本身必须先存下来。在底层,数据存储只有两种最基本的形态,它们是栈和队列实现的物理基础:

A. 连续存储(Contiguous Memory)------ 数组的真身

这是计算机最喜欢的存储方式。如果你想存 3 个整数(int,假设占 4 字节),计算机就在内存里圈出一块连续的地盘。

内存视图可视化: 假设内存地址从 100 开始:

地址 (Address) 数据 (Data) 说明
100 [Int 1 的第1字节] \
101 [Int 1 的第2字节] -> 整数 1
102 [Int 1 的第3字节]
103 [Int 1 的第4字节] /
104 [Int 2 的第1字节] \
105 ... -> 整数 2
... ...

特点:紧凑、整齐。

在栈/队列中的应用 :这是实顺序栈(Array Stack)循环队列(Circular Queue)的基础。

B. 离散存储(Non-contiguous Memory)------ 链表的真身

如果内存没有一大块连续的空间,或者你需要动态增加数据,数据就会散落在内存的各个角落。

为了把它们串起来,我们需要指针(Pointer)

  • 指针是什么? 指针本质上也是一个数据,但它存的不是"数值",而是另一个格子的地址

内存视图可视化:

  • 地址 200 存了数据 "A",同时存了一个小纸条写着:下一个在 "地址 999"。

  • 地址 999 存了数据 "B",同时存了一个小纸条写着:下一个在 "地址 50"。

  • 特点:灵活,但需要额外的空间存"地址"。

  • 在栈/队列中的应用 :这是实现链式栈(Linked Stack)链式队列(Linked Queue)的基础。

3. 指针:操作栈与队列的"机械臂"

在底层原理中,指针是灵魂。无论是栈的 Top(栈顶)还是队列的 Front/Rear(队头/队尾),它们在物理上通常就是一个指针(或者是一个表示偏移量的整数索引)。

想象一个机械臂(指针),它悬停在上述的"纸带"上方:

  1. 移动 :指针 +1,并不是数学上的加一,而是机械臂向后移动一个数据单位(比如向后移 4 个字节)。

  2. 解引用:机械臂抓起下面格子里的内容。

为什么这很重要? 当我们说"入栈(Push)"时,底层的物理动作其实是:

  1. 把数据写入当前指针指向的内存格子。

  2. 把指针向后移动一位(或者向前,取决于架构)。

当我们说"出栈(Pop)"时:

  1. 读取当前指针位置的数据。

  2. 把指针往回退一位。

  3. 注意 :原来的数据通常还在内存里,只是如果不覆盖它,我们认为它已经是"垃圾"数据了。这就是为什么在这个层面,数据删除往往很快,因为只是移动了指针而已。

这里有一个颠覆性的认知:在 CPU 眼里,栈(Stack)不仅仅是一个数据结构,它是程序能够运行的生理本能。 相比之下,队列(Queue)更多时候是软件层面人为设计的"排队规则"。

因此,这一部分我们主要聚焦于硬件栈(Hardware Stack)

第二部分:核心 ------ CPU 视角下的"栈"与指令集

绝大多数现代 CPU(x86, ARM 等)的设计中,栈是"原生支持"的。这意味着 CPU 芯片里直接刻录了操作栈的电路和指令。

1. 领地划分:栈内存段(Stack Segment)

当你的程序(比如一个 Python 脚本)刚被操作系统加载到内存时,操作系统会大手一挥,直接在内存里划出一块固定的、连续的区域,说:"这块地盘归专用。"

  • 栈底(Bottom):这块区域的起始位置(通常是高地址)。

  • 栈顶(Top):这块区域当前数据存放到的边缘位置(通常向低地址延伸)。

2. 只有 CPU 才有特权使用的指针:SP 寄存器

CPU 内部有一组非常珍贵的存储单元,叫寄存器(Register)。它们的存取速度比内存快 100 倍以上。

其中有一个最重要的寄存器,专门用来盯着栈顶。

  • 在 x86 架构中,它叫 ESP (Extended Stack Pointer) 或 RSP

  • 我们简单称之为 SP (Stack Pointer,栈指针)

SP 的唯一任务: 永远指向栈的最顶部(也就是最新放进去的那个数据)。

3. 反直觉的物理运动:倒着长的塔

在现实生活中,我们要堆积木,是从下往上堆。 但在大多数计算机内存架构中,栈是从高地址向低地址生长的

可视化演示: 假设栈底地址是 1000。SP 初始指向 1000

动作 A:入栈(PUSH)一个整数 5 CPU 内部其实做了两件事:

  1. 移动指针 :SP 减去 4(因为一个整数占4字节)。现在 SP 指向 996

  2. 写入数据 :在内存地址 996 处写入 5

地址 数据 指针位置
... ...
1000 [空/旧数据] <--- 栈底
... ...
996 5 <--- SP (栈顶)

动作 B:再入栈(PUSH)一个整数 8

  1. 移动指针 :SP 再减 4。现在 SP 指向 992

  2. 写入数据 :在内存地址 992 处写入 8

地址 数据 指针位置
1000 ... <--- 栈底
996 5
992 8 <--- SP (栈顶)

动作 C:出栈(POP) CPU 只需要做相反的事:

  1. 读取数据 :把 SP (992) 指向的数据 8 拿出来放到 CPU 寄存器里计算。

  2. 回退指针 :SP 加上 4。现在 SP 回到了 996注意:地址 992 里的 8 还在那里,但因为 SP 已经移走了,那个位置被视为"无效区域",下次入栈时会直接覆盖它。

4. 为什么 CPU 需要栈?(这是关键)

你可能会问:"CPU 算数就由着它算呗,为什么要搞个'先进后出'的栈?"

答案是为了函数调用(Function Call)。这是计算机科学中最美妙的设计之一。

想象这个场景:

  1. 你正在执行主函数 main()

  2. main() 调用了 func_A()

  3. func_A() 又调用了 func_B()

问题来了:func_B() 执行完那一瞬间,CPU 怎么知道该回到哪一行代码继续执行? 它必须回到 func_A() 调用它的地方,对吧?

这完全符合后进先出(LIFO) 的逻辑:

  • 最后被调用的函数(func_B),最先执行完。

  • 最先开始的函数(main),最后才结束。

函数调用的幕后过程(栈帧): 每当你调用一个函数,CPU 就会在栈里"压"入一块数据,这块数据叫栈帧(Stack Frame)。里面包含:

  • 返回地址:做完这个函数后,我要跳回代码的第几行。

  • 局部变量 :这个函数里定义的 x, y, z 都在这。

过程模拟:

  1. 调用 func_A -> 压入 func_A 的栈帧。

  2. func_A 调用 func_B -> 压入 func_B 的栈帧(现在它在栈顶)。

  3. func_B 执行完毕 -> 弹出 func_B 的栈帧(销毁局部变量),CPU 拿到返回地址,跳回 func_A

  4. func_A 继续执行...

5. 著名的 Stack Overflow(栈溢出)

现在你理解这个术语的物理含义了。 栈内存段的大小是有限的(比如操作系统限制为 8MB)。

如果你写了一个死循环递归:

python 复制代码
def endless_recursion():
    return endless_recursion()

6.栈的实现

基于链表的实现

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

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

python 复制代码
# === 节点类:模拟内存中的一个小格子 ===
class Node:
    def __init__(self, value):
        self.value = value  # 存放数据
        self.next = None    # 存放下一个节点的地址(指针)

# === 栈:链表版实现 ===
class HardStack:
    def __init__(self):
        # 只需要一个指针,永远指向栈顶
        self.top = None 

    def push(self, val):
        new_node = Node(val)  # 1. 申请一块新内存(创建节点)
        
        # 核心逻辑:
        # 把新节点的 next 指向当前的栈顶(让新来的踩在旧的头顶上)
        new_node.next = self.top 
        
        # 更新 top 指针,现在新节点才是栈顶
        self.top = new_node
        
        print(f"链表入栈: {val}")

    def pop(self):
        if self.top is None:
            return None
        
        # 1. 拿到当前栈顶的数据
        val = self.top.value
        
        # 2. 核心逻辑:移动 top 指针
        # 既然栈顶要走了,新的栈顶就是原来栈顶脚下的那个(next)
        self.top = self.top.next
        
        return val

# --- 测试 ---
# 想象一下:内存里本来散落着数据,现在被这一根线串起来了
hs = HardStack()
hs.push(10)
hs.push(20) # 20 -> 10 -> None
print(hs.pop()) # 20

基于数组的实现:

python 复制代码
class ArrayStack:
    """基于数组实现的栈"""

    def __init__(self):
        """构造方法"""
        self._stack: list[int] = []

    def size(self) -> int:
        """获取栈的长度"""
        return len(self._stack)

    def is_empty(self) -> bool:
        """判断栈是否为空"""
        return self.size() == 0

    def push(self, item: int):
        """入栈"""
        self._stack.append(item)

    def pop(self) -> int:
        """出栈"""
        if self.is_empty():
            raise IndexError("栈为空")
        return self._stack.pop()

    def peek(self) -> int:
        """访问栈顶元素"""
        if self.is_empty():
            raise IndexError("栈为空")
        return self._stack[-1]

    def to_list(self) -> list[int]:
        """返回列表用于打印"""
        return self._stack

第二部分总结

在这一层,我们学到了:

  1. 栈是硬件特性:CPU 有专门的 SP 寄存器来追踪它。

  2. 生长方向:在物理内存中,栈通常是从高地址向低地址"倒着长"的。

  3. 函数调用即入栈 :我们写的每一行函数调用,底层都是一次 PUSH 动作;函数返回,就是 POP 动作。

现在,物理和硬件原理我们都懂了。 但是,我们在写 Python 或 C++ 代码时,通常不会直接去操作 SP 寄存器。我们需要更高级的"抽象"。

同时,我们还没详细讲队列 。因为队列在 CPU 硬件层面上不如栈那么核心,它更多是在软件和算法层面发光发热。

第三部分:架构 ------ 软件实现与算法灵魂

在软件层面,我们将栈和队列称为 ADT(抽象数据类型) 。意思是:我不关心你在底层是用数组还是链表实现的,我只关心你对外提供的功能

但作为架构师,我们必须知道底层实现的代价。这里最大的挑战在于队列

1. 队列的物理难题:它会"爬行"

还记得栈吗?栈很老实,指针只在一个固定的底部来回晃动,数据利用率极高。

但队列是"先进先出"(FIFO)。这意味着:

  • 入队(Enqueue):在尾巴加数据。

  • 出队(Dequeue):在头部拿数据。

这就产生了一个物理难题------"假溢出": 想象你在内存里开辟了一个长度为 5 的数组来实现队列。

  1. 你入队 5 个元素,填满了数组。

  2. 你出队 3 个元素。此时,数组的前 3 个格子空了。

  3. 关键点:虽然前 3 个格子空了,但你的"队尾指针"还在数组的最后面!计算机呆呆地告诉你:"队尾满了,不能加人了。"

这就像排队买票,队伍往前走了,但窗口却不让后面的人跟进空出来的位置,导致队伍后面有一大截空地,却还在喊"满员"。

为了解决这个问题,软件工程师发明了两个经典的底层架构:

A. 搬砖法(低效)

每次出队一个人,就把后面所有的人往前挪一位。

  • 代价 :这需要极大的计算量。如果你有 1 万个数据,出队 1 个,就要搬运 9999 次数据。这在算法上叫 O(n) 复杂度。绝对不可取。
B. 环形缓冲(Circular Buffer / Ring Buffer)------ 优雅的解法

这是操作系统和高性能网络包处理中最常用的技巧。

我们把直线的内存数组,在逻辑上想象成一个圆环。

  • 原理 :当队尾指针走到数组的尽头(比如索引 9),下一个位置不是报错,而是通过数学运算(取模运算 %)瞬间回到数组的开头(索引 0)。

  • 结果:只要圆环里还有空位,队伍就可以永远不停地转圈圈流动,完全不需要搬运数据。

  • 复杂度 :入队和出队都是完美的 O(1)

2. Python 中的陷阱与真相

基于上述原理,我们来看看 Python 中的实现,你会豁然开朗。

栈的实现

Python 的 list 本质上就是动态数组。

  • list.append():就是入栈。

  • list.pop():就是出栈(默认弹最后一个)。

  • 评价:非常高效,符合 CPU 缓存原理,速度极快。

队列的实现(大坑)

很多新手喜欢用 list 做队列:

  • 入队:list.append()

  • 出队:list.pop(0) ------ 千万别这么做!

底层原理分析list 是连续内存。当你 pop(0) 删掉第 0 个元素时,Python 为了保持内存连续性,必须把后面成千上万个元素全部向前移动一位(就是我们刚才说的"搬砖法")。这会把 CPU 累死。

正确的做法 :使用 collections.deque

  • deque 的底层通常是用双向链表 (Doubly Linked List)或者分段数组实现的。

  • 链表原理:删掉第一个节点,只需要断开指针,后面的人不需要动。

  • 所以,在 Python 里做队列,必须deque

python 复制代码
class Node:
    def __init__(self, value):
        self.value = value
        self.next = None

class HardQueue:
    def __init__(self):
        self.front = None # 队头(出队用)
        self.rear = None  # 队尾(入队用)

    def enqueue(self, val):
        new_node = Node(val)
        
        if self.rear is None:
            # 如果队列是空的,头尾都是这个新节点
            self.front = new_node
            self.rear = new_node
        else:
            # 1. 让现在的队尾指向新节点(接上队伍)
            self.rear.next = new_node
            # 2. 更新队尾指针,指向最新的末尾
            self.rear = new_node
        
        print(f"链表入队: {val}")

    def dequeue(self):
        if self.front is None:
            return None
        
        # 1. 拿数据
        val = self.front.value
        
        # 2. 移动队头指针(让第二个人变成排头的)
        self.front = self.front.next
        
        # 3. 特殊情况:如果你出队后,队伍空了
        if self.front is None:
            self.rear = None # 队尾也要置空
            
        return val

# --- 测试 ---
hq = HardQueue()
hq.enqueue("A")
hq.enqueue("B") # 队列形态:A -> B
print(hq.dequeue()) # 输出 A,队列剩下 B

3. 算法灵魂:深度 vs 广度

最后,我们升华一下。栈和队列不仅仅是存储数据的容器,它们代表了两种截然不同的解决问题的哲学

栈(Stack)------ 执着者
  • 哲学:一条路走到黑,不撞南墙不回头。

  • 对应算法DFS(深度优先搜索)

  • 场景:走迷宫。

    1. 遇到岔路口,把当前位置压入

    2. 选一条路一直走下去。

    3. 如果是死胡同,就出栈(回溯),回到上一个路口,换条路走。

  • 本质:栈保存了"我从哪里来"的历史,让我们可以时光倒流。

队列(Queue)------ 公平者
  • 哲学:雨露均沾,层层推进。

  • 对应算法BFS(广度优先搜索)

  • 场景:水波纹扩散、寻找最近的好友。

    1. 不像栈那样一头扎进去,而是先把周围邻居都看一遍(入队)。

    2. 按照先来后到的顺序,处理邻居,再看邻居的邻居。

  • 本质:队列保证了处理顺序的公平性,也保证了我们找到的一定是"最近"的路径。

相关推荐
客梦1 小时前
数据结构核心内容
数据结构·笔记
FMRbpm1 小时前
栈练习--------有效的括号(LeetCode 20)
数据结构·c++·leetcode·新手入门
wxl7812271 小时前
从图片PDF到结构化文本:基于Python+Dify的批量OCR自动化解决方案
python·pdf·ocr
ReinaXue1 小时前
快速认识图像生成算法:VAE、GAN 和 Diffusion Models
图像处理·人工智能·神经网络·算法·生成对抗网络·计算机视觉·语言模型
Evan芙1 小时前
shell编程求10个随机数的最大值与最小值
java·linux·前端·javascript·网络
kimi7041 小时前
计算机体系结构与参考模型
网络
再睡一夏就好1 小时前
进程调度毫秒之争:详解Linux O(1)调度与进程切换
linux·运维·服务器·c++·算法·哈希算法
无限进步_1 小时前
C语言双向循环链表实现详解:哨兵位与循环结构
c语言·开发语言·数据结构·c++·后端·算法·链表
wljun7391 小时前
五、OrcaSlicer 切片
算法·切片软件 orcaslicer