讲课:单调栈与单调队列

单调栈与单调队列

栈与队列

为了防止部分同学没学过或忘记了这两种基础数据结构,让我们先简单介绍一下。

In computer science, a stack is an abstract data type that serves as a collection of elements with two main operations:

  • Push, which adds an element to the collection.
  • Pop, which removes the most recently added element.

在数据结构与算法的语境中(注意与内存中的“栈空间”区分),是一种“后进先出”(Last In, First Out, LIFO)的一维数据结构。更严谨地,栈(stack)是这样一个数据结构,它维护一些元素,支持压入(push,将一个指定的元素插入栈中)与弹出(pop,删除当前元素中最后被压入的)两种操作,使后压入的元素总是先被弹出。

通常,我们将栈想象成一端开口而另一端封闭的羽毛球筒,把封闭的那一端放在地上立起来。压入操作就是将一个羽毛球从开口端放入。要取出里面的羽毛球,也只能从开口端依次拿出,而每次拿出的必定是最后放入的,因为你只能拿最接近开口端的那个羽毛球。出于这个形象的类比,我们通常将开口的那一端称作“栈顶”(top),而将封闭的那一端称作“栈底”(bottom)。

由于栈的维护十分简单,我们可以用数组或std::vector来维护,只需记录指针起始位置和栈顶位置即可。另一种常见的形式是单链表,同样也很好实现,但由于其时空开销较大,我们一般不采用。而STL的std::stack实际上是通过封装std::deque实现的。关于std::deque以及这样做的好处,我们后面会讲到。

队列

In computer science, a queue is an abstract data type that serves as an ordered collection of entities. By convention, the end of the queue where elements are added, is called the back, tail, or rear of the queue. The end of the queue where elements are removed is called the head or front of the queue. The name queue is an analogy to the words used to describe people in line to wait for goods or services. It supports two main operations:

  • Enqueue, which adds one element to the rear of the queue.
  • Dequeue, which removes one element from the front of the queue.

正如它的名字所展示的那样,队列(queue)是这样一种数据结构,它维护一些元素,支持入队(enqueue)/压入(push)出队(push)/弹出(pop)两种操作,使先压入的元素总是先被弹出,即“先进先出”(First In, First Out, FIFO)。

我们通常将队列想象成一个由人排成的队列,将队列的前端称为“队前”或“队首”(front),将队伍的后端称为“队后”或“队末”(back)。但这会使我想到我自己排队时看到的景象(只有前面的人),而非队伍的全貌,所以我更喜欢将其想象成两端开口的管道从左到右摆放,从右端压入元素,从左端弹出。

队列的维护是对双端队列的特化,这里不做赘述,让我们直接进入双端队列。

双端队列

双端队列(double-end queue)是对队列的扩展,它在队列的基础上,还允许从队首压入元素和从队末弹出元素,也就是首尾都可以压入和弹出元素。

将双端队列想象为两端开口的管道是极为合适的。

双端队列的维护比栈稍复杂,通常也有数组和链表两种维护的形式。算法竞赛中,出于开辟堆空间的时间开销和指针操作的复杂性,我们通常选用循环数组的形式,这也是Java中ArrayDeque采用的实现。而std::deque采用的实现则比较特别,它的本质是分块的数组。

关于std::deque

std::deque的文档可以在Cpp Reference上阅读。出于灵活性和性能,C++标准从不规定容器的实现方式,而仅规定其抽象的操作和复杂度要求。但大多数STL实现都会采用块状数组的形式来实现。

这种实现根据分块思想,将整个队列分为若干大小相等的连续内存块(称为节点(node))来存储元素;再额外维护一个“中控器”(map,注意并非std::map)指针数组,保存指向各个节点的指针。这种结构下,可以以平均\(O(1)\)的时间复杂度实现压入(首尾都是)、弹出(首尾都是)和随机访问:

  • 压入:不妨以队尾为例。从中控器上找到最后一个节点,如果它有空位,就直接放进去即可;如果最后一个节点满了,就在申请一块新的连续内存作为节点,放在中控器数组的末尾。中控器数组的前后通常一开始留有余地,如果余地已经占满了,就重新开辟一块更大的内存,并将原有的中控器数组移动到新内存的中部,这样前后又有余地了。不过,有的实现可能也会在前部空间富余时选择将中控数组整体平移,取决于具体实现。无论是哪一种,其平均时间复杂度都是\(O(1)\)
  • 弹出:不妨以队首为例。从中控器上找到第一个节点,删除其中第一个元素。如果节点空了,就从中控数组中移除该节点并释放空间。如果中控器中元素过少,可能也会有移动或内存的重新分配,取决于具体实现。时间复杂度显然也是\(O(1)\)的。
  • 随机访问:由于各节点的大小是确定的,对于给定的下标,可以通过取模在中控器上找到对应节点的下标,再进到节点里面去找到目标元素。时间复杂度显然也是\(O(1)\)

从上面的介绍中,我们可以看出std::deque的常数是巨大的,无论是时间还是空间。那为何标准库要这样实现呢?总的来说,主要有以下优点:

  • 支持\(O(1)\)随机访问:这是相对于链表实现的巨大优势。
  • 扩容成本低且平稳:申请内存是很费时间的,这种实现避免了频繁开辟内存,相对于链表实现优势明显;而循环数组实现虽然无需频繁扩容,但在容量用尽需要扩容时出现用时暴增,因为它需要开辟更大的空间、移动原有元素并释放原空间,尽管平均复杂度相同,但会出现时间开销的贫富分化,这对标准库而言是极不利的。
  • 内存利用率高:使用循环数组实现时,每次都需要开辟连续空间。如果内存不足,很可能开不出连续的一块大空间,而这种实现每次都只开辟恒定大小的相对小的内存块,可以充分利用碎片空间,减少内存分配失败的情况。
  • 不会出现迭代器/引用失效:如果采用循环数组实现,当容量已满时,就会需要开辟新的空间并移动原有元素,这可能导致迭代器和引用失效,而标准库的实现则不会有这个问题,因为新分配的节点不会影响原有节点。

缺点则有:

  • 时空常数大。
  • 缓存命中率低:空间不连续,相比循环数组而言,缓存命中率低。
  • 中间插入和删除的时间复杂度高:在中间增删元素所需时间仍为\(O(n)\),这是相对于链表实现的劣势。
  • 额外的空间:为了保存分块数组结构,需要额外维护一个中控数组。

总的来说,std::deque这样设计,意在平衡数组与链表的优缺点,取其精华的同时尽可能减少副作用。

单调栈

问题引入

“理论创新只能从问题开始。从某种意义上说,理论创新的过程就是发现问题、筛选问题、研究问题、解决问题的过程。”——敏感词

给定数组\(\vec{a}=(a_i)_{i=0}^{n-1}\),对每个下标\(i\in\mathbb{N}\cap[0,n)\),找到最小的\(j\in\mathbb{N}\cap(i.n]\)使\(a_i<a_j\)(令\(a_n=+\infty\))。也就是说,对每个元素,找出它之后第一个出现的比它大的元素的位置,除非不存在。这个问题称为next greater element问题。

朴素的做法是对每个元素都遍历它之后的所有元素,直到找到第一个比它大的或到达末尾。时间复杂度:\(O(n^2)\)。空间复杂度:\(\varTheta(1)\)。有没有更快的方法呢?

考虑这样一个场景:你和其他一些人在排队买饭,你想看看今天有甚么菜(假设你活在二维世界,你不能左右扭头或探头)。你的前面如果有个子更高的人,他就会挡住你的视线;而个子更矮的对你就没有影响。这启发我们将其从考虑范围中删除。由于我们只考虑在你前面的人,你后面的人对你的视线不会有任何影响,因此删除是单方向的。这启发我们考虑“栈”这种数据结构。

方法描述

维护一个栈,依次添加元素的同时,使其始终保持单调。通常情况下我们存储下标而非元素,因为下标能提供的信息更多,因此“单调”实际上是指下标对应的元素单调(这里的“单调”是一个抽象的概念,可以定义在任何给定的(严格)偏序关系上)。

这种维护仅通过栈的基本操作,即压入和弹出来实现。因此,当有一个新元素要压入时,我们先检查栈顶与该元素是否满足单调;如果不满足,循环弹出栈顶元素,直到栈为空或单调性成立时才停止。将该元素入栈。特别地,我们有时需要获得出栈元素的信息(哪些元素因为这个元素而出栈,也就是在它们后面到哪里才遇到一个不满足单调性的元素),应当在出栈时处理。

让我们回到上面的问题,看看这个做法是如何高效解决它的。维护一个单调递减(在本文中约定,“单调”都是不严格的,“严格单调”才是严格的)的栈\(\overrightarrow{stk}\),从下标\(0\)开始,依次将各个下标入栈。如果下标\(i\)在下标\(j\)入栈时被弹出,说明\(j\)处必定是\(i\)后面第一个比它大的元素——如果不比它大,就不会使它弹出;如果不是第一个,则\(j\)到来时\(i\)早已弹出。不过,在遍历结束时,因为有一个假想的“无穷大”(\(a_n=+\infty\)),我们需要在这里将栈中剩余所有元素都弹出,并将其标记为“在\(n\)处弹出“。因为只需遍历\(\vec{a}\)一遍,栈的操作都是\(\varTheta(1)\)的,所以总的时间复杂度就是\(\varTheta(n)\)。在最坏情况下,我们的栈会一直压入,在结束前都不弹出,也就是会存放\(\vec{a}\)的所有下标,所以空间复杂度是\(\varTheta(n)\)

典型例题

Luogu P5788 【模板】单调栈

就是上面的问题引入,即next greater element问题。

Luogu P2947 [USACO09MAR] Look Up S

模板题加了个背景。

Luogu P1901 发射站

分别在两个方向上求NGE。

SpOJ HISTOGRA - Largest Rectangle in a Histogram

单调栈的经典应用:等宽直方图的最大矩形面积。

实际上是NGE问题的变种:知道下一个比当前元素大的元素的位置,就知道当前元素作为上边界的“管辖范围”。在NGE问题中,弹出时计算面积并取\(\max\)即可。

posted @ 2026-01-22 03:20  我就是蓬蒿人  阅读(4)  评论(0)    收藏  举报