Blog

C# 栈、队列与单调栈

一个不肯留下无用元素的栈,一趟扫描就能回答整个数组里每个元素的“下一个更大元素”是谁。同样的结构还能求出柱状图中最大的矩形。

栈是每个人最先学的数据结构,却也是大家最想不到去用的那一个。这一篇的关键不在栈本身,而在于决定哪些东西不许留在栈里。

下面每个程序都是完整的,都在 .NET 10 上跑过,输出直接从运行结果里粘贴过来。

模式 21 — 单调栈

对每个元素,找出它右边第一个比它大的元素。

拿每个元素去和它后面的所有元素比,是 O(n²)。线性解法来自一个观察:从左往右扫的时候,总有一些元素还在等答案。只留下这些元素,别的一概不要。

新值到来时,它会给所有比它小的等待者一个答案,可能一次好几个,然后自己也排进等待队伍。每个等待者只会被第一个比它大的值解决,所以等待列表从栈底到栈顶始终是递减的。这里的“单调”就是这个意思。

2 1 2 4 3 01 23 4 不存在 更大的元素 更大的值一到,箭头就画上,之后再也不会回头看。 每个索引入栈一次、出栈一次,所以整个过程就是一趟线性扫描。

栈里存的是还在等答案的索引,它们对应的值从栈底到栈顶始终递减。新值比栈顶高,就解决栈顶 — 可能连同下面好几个一起,都在同一步里完成。

int[] a = [2, 1, 2, 4, 3];
int[] answer = new int[a.Length];
Array.Fill(answer, -1);

Stack<int> st = [];          // indices, values DECREASING from bottom to top

for (int i = 0; i < a.Length; i++)
{
    while (st.Count > 0 && a[st.Peek()] < a[i])
    {
        int j = st.Pop();
        answer[j] = a[i];
        Console.WriteLine($"i={i} a[i]={a[i]}   resolves index {j} (value {a[j]})  ->  next greater is {a[i]}");
    }
    st.Push(i);
    Console.WriteLine($"i={i} a[i]={a[i]}   push   stack(values)=[{string.Join(",", st.Select(k => a[k]).Reverse())}]");
}

Console.WriteLine($"\nleft unresolved: [{string.Join(", ", st.Select(k => $"a[{k}]={a[k]}").Reverse())}]  -> no greater element exists");
Console.WriteLine($"a      = [{string.Join(", ", a)}]");
Console.WriteLine($"answer = [{string.Join(", ", answer)}]");

输出:

i=0 a[i]=2   push   stack(values)=[2]
i=1 a[i]=1   push   stack(values)=[2,1]
i=2 a[i]=2   resolves index 1 (value 1)  ->  next greater is 2
i=2 a[i]=2   push   stack(values)=[2,2]
i=3 a[i]=4   resolves index 2 (value 2)  ->  next greater is 4
i=3 a[i]=4   resolves index 0 (value 2)  ->  next greater is 4
i=3 a[i]=4   push   stack(values)=[4]
i=4 a[i]=3   push   stack(values)=[4,3]

left unresolved: [a[3]=4, a[4]=3]  -> no greater element exists
a      = [2, 1, 2, 4, 3]
answer = [4, 2, 4, -1, -1]

i=3 这一步。值 4 一次解决了两个等待中的元素,而且哪个都不用再回头看。扫描结束时还留在栈里的两个元素没有得到答案,这完全正确:它们右边根本没有更大的值。

嵌套的 while 看起来像平方级。其实不是:每个索引恰好入栈一次,最多出栈一次,所以整趟扫描的总工作量不超过 2n

代价: O(n) 时间,O(n) 空间。

适用场景: 问题是下一个更大元素上一个更小元素还要多远才遇到更高的股票价格跨度每日温度。把比较方向反过来,就得到“更小”的版本。

模式 22 — 柱状图中最大的矩形

这是上面那个模式真正派上用场的地方。

以柱子 i 的完整高度做矩形,往右延伸到遇见一根更矮的柱子为止,往左也是一样。所以它的宽度由两侧各自的下一个更小元素决定,也就是把模式 21 跑两遍。

单调栈一趟就能给出两侧边界。某根柱子出栈时,正在到来的那个值就是它的右边界,出栈后栈里它下面的那个元素就是它的左边界。

21 56 23 5 × 2 = 10 柱子 2(高 5)向右延伸,被柱子 4(高 2)挡住;向左被柱子 1(高 1)挡住。

每个矩形都受限于两侧第一根更矮的柱子 — 也就是把“下一个更小元素”问两次。所以单调栈能解这道题:一根柱子出栈,两侧边界就同时知道了。

int[] h = [2, 1, 5, 6, 2, 3];

// The sentinel: a zero-height bar past the end forces every remaining bar to
// be resolved, so there is no separate drain loop after the scan.
int[] bars = [.. h, 0];

Stack<int> st = [];
int best = 0;

for (int i = 0; i < bars.Length; i++)
{
    while (st.Count > 0 && bars[st.Peek()] >= bars[i])
    {
        int top = st.Pop();
        int height = bars[top];
        int left = st.Count == 0 ? -1 : st.Peek();
        int width = i - left - 1;
        int area = height * width;
        Console.WriteLine($"i={i}  pop bar {top} (height {height})  spans ({left}..{i}) exclusive  width {width}  area {area}");
        best = Math.Max(best, area);
    }
    st.Push(i);
}

Console.WriteLine($"\nlargest rectangle: {best}");

输出:

i=1  pop bar 0 (height 2)  spans (-1..1) exclusive  width 1  area 2
i=4  pop bar 3 (height 6)  spans (2..4) exclusive  width 1  area 6
i=4  pop bar 2 (height 5)  spans (1..4) exclusive  width 2  area 10
i=6  pop bar 5 (height 3)  spans (4..6) exclusive  width 1  area 3
i=6  pop bar 4 (height 2)  spans (1..6) exclusive  width 4  area 8
i=6  pop bar 1 (height 1)  spans (-1..6) exclusive  width 6  area 6

largest rectangle: 10

有两个细节缺一不可。

哨兵。 数组末尾追加了一根高度为 0 的柱子。没有它,扫描结束时还留在栈里的柱子就得另写一个清空循环,逻辑还略有不同,而 bug 恰恰藏在这段重复的逻辑里。高度为 0 的柱子比谁都矮,主循环因此会把剩下的柱子全部处理掉。

宽度。 宽度是 i - left - 1,其中 left 是栈里出栈柱子下面那个索引,不是出栈的柱子本身。两侧边界都是开区间:跨度严格位于两根更矮的柱子之间。这里差一,得到的答案在小输入上看起来还挺像回事。

代价: O(n) 时间,O(n) 空间。

适用场景: 求最大的矩形、二值矩阵中最大的正方形(每行跑一次这个算法),或者任何“能延伸多宽才被挡住”的问题。

模式 23 — 括号匹配

栈最朴素的用法。值得收进来,是因为出错的地方都在细节里。

static bool Valid(string s)
{
    Dictionary<char, char> pairs = new() { [')'] = '(', [']'] = '[', ['}'] = '{' };
    Stack<char> st = [];

    foreach (char c in s)
    {
        if (pairs.ContainsValue(c)) { st.Push(c); continue; }
        if (!pairs.TryGetValue(c, out char open)) continue;   // not a bracket
        if (st.Count == 0 || st.Pop() != open) return false;
    }
    return st.Count == 0;
}

foreach (string s in new[] { "{[()]}", "([)]", "(((", "", "a(b[c]d)e" })
    Console.WriteLine($"{$"\"{s}\"",12}  ->  {Valid(s)}");

输出:

    "{[()]}"  ->  True
      "([)]"  ->  False
       "((("  ->  False
          ""  ->  True
 "a(b[c]d)e"  ->  True

大家容易忘的两种情况都在这份输出里。"(((" 不合法,不是因为发现了不匹配,而是因为最后栈不为空:每个左括号都需要配对。"" 合法,这也是同一个检查自然得出的结果,不需要特判。

"([)]" 正是非用栈不可的原因。给每种括号各设一个计数器,会判它合法:一个 (、一个 )、一个 [、一个 ]。嵌套讲的是顺序,而只有栈会记录顺序。

代价: O(n) 时间,O(n) 空间。

适用场景: 任何有嵌套的东西:括号、标签、表达式解析、撤销历史。

模式 24 — 知道自己最小值的栈

在不断入栈、出栈的同时,用 O(1) 报告栈里所有元素的最小值。

只维护一个 min 变量,出栈时就会出问题:最小值被弹出后,得重新找次小值,那是 O(n)。

解决办法是别再把最小值当成整个栈的一个事实。给每个元素都存一份“它自己及其下方所有元素的最小值”。

目前最小 5 2 7 2 9 5 2 2 2 2 top ← Min() 读这里 出栈不需要重新计算。下面那个条目早就知道自己的最小值, 因为在被弹出的条目入栈之前,它就已经是对的。

每个元素多存一个 int,换来 O(1) 的 Min()。出栈后依然正确,是因为每个条目的最小值只由它下面的元素算出,从不依赖后来的元素。

// Each entry carries the minimum of everything at or below it. That makes Min
// a peek, and costs one extra int per element.
Stack<(int value, int min)> st = [];

void Push(int v)
{
    int min = st.Count == 0 ? v : Math.Min(v, st.Peek().min);
    st.Push((v, min));
    Console.WriteLine($"push {v,3}   min is now {min,3}   stack=[{string.Join(" ", st.Select(x => $"{x.value}/{x.min}").Reverse())}]");
}

foreach (int v in new[] { 5, 2, 7, 2, 9 }) Push(v);

Console.WriteLine();
while (st.Count > 0)
{
    var (v, m) = st.Peek();
    Console.WriteLine($"top {v,3}   Min() = {m,3}");
    st.Pop();
}

输出:

push   5   min is now   5   stack=[5/5]
push   2   min is now   2   stack=[5/5 2/2]
push   7   min is now   2   stack=[5/5 2/2 7/2]
push   2   min is now   2   stack=[5/5 2/2 7/2 2/2]
push   9   min is now   2   stack=[5/5 2/2 7/2 2/2 9/2]

top   9   Min() =   2
top   2   Min() =   2
top   7   Min() =   2
top   2   Min() =   2
top   5   Min() =   5

出栈完全不需要重新计算,原因值得说清楚:每个条目的最小值只由它下面的元素算出,从不依赖后来的元素。移除后来的条目,不会让它失效。

代价是每个元素多一个 int。看输出里的出栈序列:上面的元素全部弹出后,5 正确地报告自己的最小值是 5

代价: 入栈、出栈、取最小值都是 O(1)。O(n) 空间。

适用场景: 需要一个在出栈后依然成立的累计量。同样的结构也适用于最大值,或者 gcd。

模式 25 — C# 没有自带的容器

Stack<T>Queue<T> 都有,也都好用。但没有基于数组的双端队列,没有 ArrayDeque

LinkedList<T> 能胜任,第 2 篇的滑动窗口最大值用的就是它。但它给每个元素都分配一个节点对象,在竞赛级别的输入规模下,这些分配会体现在耗时上。基于单个数组的环形缓冲区没有这个开销,而且代码短到可以凭记忆敲出来。

01 23 45 67 1 2 3 0 head last 逻辑顺序:0, 1, 2, 3 PushFront 把 head 从 0 移到 7 — (head – 1 + length) % length。没有移动任何元素。

C# 自带 Stack<T>Queue<T>,却没有基于数组的双端队列。LinkedList<T> 可以补上,但每个元素分配一个节点对象;环形缓冲区只分配一个数组,大约三十行代码。

var d = new Deque(8);
d.PushBack(1); d.PushBack(2); d.PushBack(3);
Console.WriteLine($"pushed 1,2,3 at the back   {d}");

d.PushFront(0);
Console.WriteLine($"pushed 0 at the front      {d}   (head wrapped round to the end of the array)");
Console.WriteLine($"first={d.First} last={d.Last}");
Console.WriteLine($"popFront -> {d.PopFront()}          {d}");
Console.WriteLine($"popBack  -> {d.PopBack()}          {d}");

// A full array has to grow. Without the check the next push lands on the head.
var small = new Deque(2);
small.PushBack(1); small.PushBack(2);
Console.WriteLine($"\nsmall deque, now full      {small}   count={small.Count} capacity={small.Capacity}");
small.PushBack(3);
Console.WriteLine($"pushed 3 into a full array {small}   count={small.Count} capacity={small.Capacity}   nothing lost");

// Empty is an error, not a stale cell.
try { _ = new Deque(4).First; }
catch (InvalidOperationException ex) { Console.WriteLine($"First on an empty deque -> {ex.Message}"); }

Console.WriteLine();
Stack<int> st = []; st.Push(1); st.Push(2);
Queue<int> q = []; q.Enqueue(1); q.Enqueue(2);
Console.WriteLine($"Stack<int>  Peek={st.Peek()}   LIFO — Push / Pop / Peek");
Console.WriteLine($"Queue<int>  Peek={q.Peek()}   FIFO — Enqueue / Dequeue / Peek");
Console.WriteLine($"Deque       {d}   both ends, one array, no per-item allocation");

// In a file-based app, type declarations come AFTER the top-level statements.
class Deque(int cap)
{
    int[] buf = new int[Math.Max(1, cap)];
    int head = 0, count = 0;

    public int Count => count;
    public int Capacity => buf.Length;

    public int First => count > 0 ? buf[head] : throw new InvalidOperationException("deque is empty");
    public int Last => count > 0 ? buf[(head + count - 1) % buf.Length] : throw new InvalidOperationException("deque is empty");

    public void PushBack(int v) { if (count == buf.Length) Grow(); buf[(head + count) % buf.Length] = v; count++; }
    public void PushFront(int v) { if (count == buf.Length) Grow(); head = (head - 1 + buf.Length) % buf.Length; buf[head] = v; count++; }
    public int PopFront() { int v = First; head = (head + 1) % buf.Length; count--; return v; }
    public int PopBack() { int v = Last; count--; return v; }

    void Grow()
    {
        var bigger = new int[buf.Length * 2];
        for (int i = 0; i < count; i++) bigger[i] = buf[(head + i) % buf.Length];
        buf = bigger;
        head = 0;
    }

    public override string ToString()
    {
        var parts = new List<int>();
        for (int i = 0; i < count; i++) parts.Add(buf[(head + i) % buf.Length]);
        return "[" + string.Join(", ", parts) + "]";
    }
}

输出:

pushed 1,2,3 at the back   [1, 2, 3]
pushed 0 at the front      [0, 1, 2, 3]   (head wrapped round to the end of the array)
first=0 last=3
popFront -> 0          [1, 2, 3]
popBack  -> 3          [1, 2]

small deque, now full      [1, 2]   count=2 capacity=2
pushed 3 into a full array [1, 2, 3]   count=3 capacity=4   nothing lost
First on an empty deque -> deque is empty

Stack<int>  Peek=2   LIFO — Push / Pop / Peek
Queue<int>  Peek=1   FIFO — Enqueue / Dequeue / Peek
Deque       [1, 2]   both ends, one array, no per-item allocation

核心就是 % buf.Length。在前端入队时,head 往回退,退过头就绕到数组末尾;没有移动任何元素,只是元素不再按逻辑顺序存放。ToStringhead, head+1, … 对长度取模依次访问,把逻辑顺序还原出来。

注意 PushFront 里的 + buf.Length。在 C# 里,-1 % 8-1,不是 7% 运算符的结果和左操作数同号。漏掉这一项会得到负索引,而它抛出的异常离真正出错的那一行很远。

剩下的分量压在两道检查上。没有它们的环形缓冲区算不上数据结构,只是个陷阱。

往满数组里入队,必须先扩容。 没有 count == buf.Length 这个检查,(head + count) % buf.Length 会绕回 head 自己的格子并写进去。什么异常都不抛。上面那个两格的双端队列装着 [1, 2],再入队一次,读出来就成了 [3, 2, 3]count 是 3:1 没了,3 被算了两次,容量悄无声息地超了。Grow() 按逻辑顺序把元素复制到一个两倍大的数组里,并把 head 重置为 0,这是环唯一可以“拉直”自己的时刻。

空队列是错误,不是一个值。 FirstLast 读取之前先检查 count > 0。没有这个检查,对空队列取 First 会返回 buf[head] 里残留的东西:一个你早已弹出的值,却被当成有效值返回。这比崩溃更糟,因为程序会继续跑下去。Last 出错的方式不同,但同样帮不上忙:headcount 都是 0 时,它计算 (0 + 0 - 1) % 8,结果是 -1,然后从一行看起来和错误毫不相干的代码里抛出 IndexOutOfRangeException

代价: 两端都是 O(1),扩容时均摊 O(1),任何时候只占一个数组。

适用场景: 需要同时操作两端:滑动窗口最大值、第 8 篇的 0-1 BFS,或者任何有零权边的 BFS。

要点

  • 单调栈只保存还在等答案的元素。 它们之所以有序,是因为每个元素都由第一个比它大的值来解决。
  • 嵌套的 while 依然是线性的。 每个索引进一次、出一次。心里要把这句话过一遍,别只看代码的形状下结论。
  • 用哨兵代替清空循环。 末尾加一根高度为 0 的柱子,主循环就会处理完所有元素,也省掉了容易藏 bug 的重复逻辑。
  • 柱状图的宽度两侧都是开区间。 i - left - 1,其中 left 取自出栈之后的栈顶。
  • 没闭合的括号,就是最后栈不为空。 靠这一个检查,"(((" 判为不合法,"" 判为合法,不需要任何特判。
  • 最小栈给每个元素都存最小值,而不是只存一份。 它出栈后依然正确,因为它只往下看过。
  • C# 没有基于数组的双端队列,而且 -1 % 8-1 每次取模前都先加上长度。

第 6 篇讲真正的容器:字典、自定义比较器、坐标压缩,以及 PriorityQueue<TElement, TPriority>。它直到 .NET 6 才出现,很多老的 C# 竞赛资料至今还在绕开它。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。