上一课我们搞明白了一件事:算法快不快,看复杂度。同样是找最大数,O(n) 的做法一眨眼,O(n²) 的做法要跑半天。

但这里藏着一个我们上节课没细说的问题——数据本身是怎么存的?

想象你要在一本字典里查一个字。字典有两种编法:

  • 编法一:所有字按拼音顺序,一页挨一页排好。
  • 编法二:字是散的,每个字下面挂张小纸条,写着"下一个字在第几页"。

同样是"查字典",这两种编法,查起来的速度完全不一样。

数据结构,研究的就是"数据在内存里怎么存、怎么摆",因为它直接决定了算法能跑多快。

这一课,我们认识四种最基础、也最重要的数据结构:数组、链表、栈、队列。后面所有花哨的数据结构(哈希表、树、图),全是拿它们当积木搭出来的。


一、数组:一排连号的储物柜

数组(array) 是最简单的一种存法:一段连续的内存,从头到尾挨着排,每个位置有个编号(下标)。

就像学校澡堂的一排储物柜,0 号柜、1 号柜、2 号柜……一个挨一个,中间没有空隙。

数组的快与慢

快的地方:按下标直接拿。 只要你知道柜号,一步到位,不管柜子有多少个。

nums = [10, 20, 30, 40, 50]
print(nums[2])   # 30,直接拿 2 号柜,瞬间的事

这在复杂度上叫 O(1)——不管你数组里有 10 个还是 1 亿个数,nums[2] 都是"立刻拿到"。

慢的地方:从中间插一个、删一个,要挪一堆。

还是储物柜的比喻:50 个柜子全用满了,现在有个人非得插到 2 号柜的位置。怎么办?只能让 2 号柜往后所有人集体往后挪一个柜子,腾出空位。挪动的代价是 O(n)。

nums = [1, 2, 3, 4, 5]
nums.insert(2, 99)   # 插到下标 2 的位置,后面 3、4、5 都得往后挪
print(nums)          # [1, 2, 99, 3, 4, 5]

记住:数组读快(O(1)),中间插入/删除慢(O(n))。


二、链表:每人拿张小纸条,指着下一个人

链表(linked list) 是另一种存法:每个数据单独住一个小隔间(叫"结点"),每个结点里除了存数据,还存一张小纸条,写着"下一个结点在哪儿"。

数据是散的、不连续的,靠纸条一根线串起来。所以叫"链"表。

就像寻宝游戏:你站在起点,第一张纸条说"去 3 号点",到了 3 号点,纸条说"再去 7 号点"……一个个跳下去。

链表的快与慢

快的地方:插入、删除,改改纸条就行。

还是排队:现在要让新同学插到队伍中间。数组那套"后面全挪一遍"不用了——只需要让前面那个人把纸条改成"指向新同学",新同学再指向原来他后面的那个人。改 2 张纸条,完事,O(1)

慢的地方:要找第 5 个结点,得从头一张张纸条摸过去。

数组是"直接开 5 号柜",链表是"从第 1 个开始,1→2→3→4→5,一个个跳"。要找第 n 个,得跳 n 次,O(n)

Python 里没有内置链表,我们用类手搓一个,感受一下:

class Node:
    def __init__(self, value):
        self.value = value   # 这个结点存的数据
        self.next = None     # 小纸条:指向下一个结点

# 串一条链表:10 -> 20 -> 30
a = Node(10)
b = Node(20)
c = Node(30)
a.next = b
b.next = c

# 从头走到尾
cur = a
while cur is not None:
    print(cur.value, end=" ")
    cur = cur.next
# 输出:10 20 30

三、数组 vs 链表:一张表看清

操作数组链表
按下标读第 i 个O(1) 快O(n) 慢,得从头跳
在末尾加一个O(1) 快O(1) 快
在中间插/删O(n) 慢,要挪一堆O(1) 快,改纸条
内存占用连续一大块每个结点多一张纸条(指针)

记一条实用口诀:

经常"按下标读",用数组;经常"中间插删",用链表。

现实中 Python 的 list 底层就是"数组",所以 nums[1000] 快、nums.insert(0, x) 慢。这也是为什么很多人说"别在列表中间频繁插删"。


四、栈:一条道走到黑,后进先出

栈(stack) 是个有规矩的容器:只能从一头进出,先进去的最后出来(LIFO,Last In First Out)。

就像一摞盘子:你只能从顶上放、从顶上拿。最先放进去的盘子,被压在最底下,最后才能拿出来。

实际场景

  • 撤回(Ctrl+Z):你每做一步,就压一个"上一步"进栈;按撤回,就从栈顶弹出最近一步。所以撤回是一步步往回退的。
  • 函数调用:程序调函数 A,A 又调 B,B 又调 C。C 跑完回到 B,B 跑完回到 A。这"回去"的顺序,就是栈。

Python 里怎么用栈

Python 没有单独的"栈"类型,但 list 天生就能当栈用,记住两个操作:

stack = []          # 空栈
stack.append(1)     # push:压入
stack.append(2)
stack.append(3)
print(stack)        # [1, 2, 3]

top = stack.pop()   # pop:弹出栈顶(最后压入的)
print(top)          # 3
print(stack)        # [1, 2]

append 是"压入",pop() 是"弹出栈顶",正好一进一出,栈就是"后进先出"的列表


五、队列:排队买奶茶,先进先出

队列(queue) 是另一种规矩:一头进、另一头出,先进去的先出来(FIFO,First In First Out)。

就像排队买奶茶:先来的人先买到、先离开。新来的人只能排在队尾,不能插队。

实际场景

  • 打印任务:谁先提交打印,谁的文档先打出来。
  • 消息队列:服务器收到的一堆请求,按先后顺序处理。
  • 叫号系统:银行、医院取号,号小的先办。

Python 里怎么用队列

collections 里的 deque(读作 deck,双端队列),专为"从两头高效进出"设计:

from collections import deque

q = deque()          # 空队列
q.append(1)          # 入队:排到队尾
q.append(2)
q.append(3)
print(list(q))       # [1, 2, 3]

first = q.popleft()  # 出队:从队头拿(先进先出)
print(first)         # 1
print(list(q))       # [2, 3]

一句话区分栈和队列:

  • 栈 = 从同一头进出 → 后进先出(像摞盘子)
  • 队列 = 一头进一头出 → 先进先出(像排队)

六、单调栈与单调队列:把没用的先踢掉

前面讲的栈、队列是"容器",这一节讲两个用它们解题的高级技巧——单调栈、单调队列。名字听着唬人,核心就一句话:

维护一堆"暂时还没轮到"的数据,让它始终保持有序;新来的先把没用的踢掉,剩下的自然就是答案。

单调栈:栈里永远有序

单调栈,就是在普通栈上加一条规矩:栈里的元素始终保持递增或递减。每次压入新元素前,先"踢掉"栈顶那些会破坏单调性的元素。

经典场景——「下一个更大元素」:给你一排数,问每个数右边第一个比它大的数是谁。笨办法是每个数都往后扫一遍(O(n²));单调栈能做到 O(n):

def next_greater(nums):
    res = [-1] * len(nums)
    stack = []                  # 存下标,栈内对应的值单调递减
    for i, v in enumerate(nums):
        # 新来的 v 比栈顶大,说明栈顶"终于等到了"它的下一个更大
        while stack and nums[stack[-1]] < v:
            res[stack.pop()] = v
        stack.append(i)
    return res

print(next_greater([2, 1, 2, 4, 3]))   # [4, 2, 4, -1, -1]

为什么快? 每个元素最多进栈一次、出栈一次,所以整体 O(n)。

单调队列:队头永远是当前最值

单调队列,是在队列基础上维护"队头永远是当前窗口的最大/最小值"。经典场景——滑动窗口最大值:一个固定宽度的窗口从左滑到右,每滑一步问窗口里最大的是谁。

from collections import deque

def max_sliding_window(nums, k):
    q = deque()                  # 存下标,队头对应窗口最大值
    res = []
    for i, v in enumerate(nums):
        # 队尾比 v 小的都出队(它们再也没机会当最大值了)
        while q and nums[q[-1]] < v:
            q.pop()
        q.append(i)
        # 队头已经滑出窗口了,丢掉
        if q[0] <= i - k:
            q.popleft()
        # 窗口凑满 k 个才开始记录
        if i >= k - 1:
            res.append(nums[q[0]])
    return res

print(max_sliding_window([1, 3, -1, -3, 5, 3, 6, 7], 3))
# [3, 3, 5, 5, 6, 7]

记忆口诀:单调栈管"下一个更大/更小",单调队列管"滑动窗口最值"。两者的共同点是——把没用的先踢掉,剩下的自然有序


七、动手时间 🎯

实验 1:感受数组"中间插入"的代价

造一个 10 万个数的列表,然后分别在末尾开头各插入 10 万次,计时对比:

import time

n = 100000

# 末尾追加(快,O(1) 摊还)
nums = []
start = time.time()
for i in range(n):
    nums.append(i)
print("末尾追加耗时:", round(time.time() - start, 3), "秒")

# 开头插入(慢,O(n),每次后面全要挪)
nums = []
start = time.time()
for i in range(n):
    nums.insert(0, i)
print("开头插入耗时:", round(time.time() - start, 3), "秒")

你大概率会看到:开头插入比末尾追加慢几百上千倍。 这就是数组"中间写慢"的铁证。

实验 2:用栈检查括号是否配对

经典面试题:给一串括号 ((())) 判断配不配对。思路——遇到左括号压栈,遇到右括号弹栈,最后栈空就配对:

def is_valid(s):
    stack = []
    for ch in s:
        if ch == "(":
            stack.append(ch)
        elif ch == ")":
            if not stack:      # 栈空,说明右括号多了
                return False
            stack.pop()
    return len(stack) == 0     # 最后栈空,说明正好配对

print(is_valid("((()))"))   # True
print(is_valid("(()"))      # False
print(is_valid(")("))       # False

实验 3:用队列模拟"叫号"

from collections import deque

q = deque()
for name in ["张三", "李四", "王五"]:
    q.append(name)
    print(f"{name} 取号排队")

while q:
    done = q.popleft()
    print(f"叫号 -> {done}")

实验 4(挑战):手搓链表反转

把一条链表 10 -> 20 -> 30 反转成 30 -> 20 -> 10。思路:遍历时把每张小纸条的指向反过来

class Node:
    def __init__(self, value):
        self.value = value
        self.next = None

a, b, c = Node(10), Node(20), Node(30)
a.next = b; b.next = c

def reverse(head):
    prev = None
    cur = head
    while cur:
        nxt = cur.next   # 先记住下一个,别弄丢
        cur.next = prev  # 纸条反过来,指向前面
        prev = cur
        cur = nxt
    return prev

new_head = reverse(a)
cur = new_head
while cur:
    print(cur.value, end=" ")
    cur = cur.next
# 输出:30 20 10

八、小结

  1. 数据结构 = 数据在内存里怎么存,它决定了算法能跑多快。
  2. 数组读快中间写慢,链表中间写快读慢——经常按下标读用数组,经常中间插删用链表。
  3. 栈是"后进先出",队列是"先进先出"——栈像摞盘子(一头进出),队列像排队(一头进一头出)。
  4. 单调栈/单调队列是"把没用的先踢掉"——单调栈管"下一个更大/更小",单调队列管"滑动窗口最值",都能把 O(n²) 压到 O(n)。

先别急着往后看,把这四个实验敲熟,四种数据结构的"手感"就长在你脑子里了。

标签: none

添加新评论