【模板】不删除双指针 2(栈模拟队列)

【模板】不删除双指针 2(栈模拟队列)

队列

先给出一个问题描述,我们有一个二元函数 \(f:\mathbb N^2\to \{0,1\}\) 满足

\[\forall l_1\leq l_2\leq r_2\leq r_1\implies f(l_1,r_1)\leq f(l_2,r_2)\quad (\text{区间包含单调性}) \]

在这样的定义下,对于一个 \(r\),就会存在一个最大的 \(to_r\) 使得 \(f(to_r,r)=1\)。显然 \(to_r\) 是单调不降的,问题是求出所有的 \(to_r\)

双指针维护两个指针 \(l, r\),来求出 \(to_r\) 序列。算法流程是从小到大枚举 \(r\),然后一直检查 \(f(l,r)\) 是否为 \(0\),如果是,就将 \(l\) 加一,最后一直加到 \(f(l,r)=1\),这个 \(l\) 就是 \(to_r\)。注意,这里 \(l\) 是所有 \(r\) 共同使用的一直增加的变量,因此 \(l\) 的移动量是 \(O(n)\) 的。

双指针一般是用来尝试解决关于区间的问题的,\(f(l,r)\) 和区间 \([l,r]\) 的信息有关。每次 \(r\) 增加或者 \(l\) 增加,就对应将一个元素加入或删除。这就像一个队列,我们要维护一个队列,支持尾部加入头部删除查询队列中元素算出的 \(f\) 的值。我们进行 \(O(n)\) 次这三种操作就能完成双指针。

双栈模拟队列

如果信息不支持删除,但是有结合律且可以合并(例子:群,包括线性基),我们可以尝试双栈模拟队列。我们需要两个栈 \(A,B\),它们的两个栈底是相连的,就像这样:(下标越小越靠近栈顶)

\[A_1,A_2,A_3\mid B_4,B_3,B_2,B_1 \]

每次加入一个元素的时候,往 \(B\) 的栈顶压入元素。每次删除一个元素的时候,如果 \(A\) 栈非空,就弹出 \(A\) 栈栈顶;否则将 \(B\) 中元素全部倒出来,倒序压入 \(A\) 栈,再弹出 \(A\) 栈栈顶。信息在这个过程中的维护是简单的。

由于每个元素只会从 \(B\) 栈进入 \(A\) 栈恰好一次,因此时间复杂度为 \(O(n)\)

template <class T>
struct queue1 {
  T a[410], b[410], suf;
  int topa, topb;
  void clear() {
    topa = topb = 0;
  }
  void push(const T& x) {
    if (topa) suf = suf + x, a[++topa] = x;
    else suf = a[++topa] = x;
  }
  void pop() {
    if (topb) return --topb, void();
    assert(topa);
    swap(topa, topb);
    b[1] = a[topb];
    for (int i = 2; i <= topb; i++) b[i] = a[topb - i + 1] + b[i - 1];
    --topb;
  }
  T sum() {
    if (!topa && !topb) return /*零*/;
    if (!topa) return b[topb];
    if (!topb) return suf;
    return b[topb] + suf;
  }
};

单栈模拟队列

【UR #23】地铁规划 - 题目 - Universal Online Judge

如果信息不支持删除,但是支持加入撤销最近一次操作,同时信息之间有交换律(可能还需要结合律)(例子:可撤销并查集),那么可以花费一个额外的 \(O(\log n)\) 因子进行单栈模拟队列。思路是从双栈模拟队列出发,将两个栈合并成一个栈:

\[\mid B_4,A_3,A_2,B_3,A_1,B_2,B_1 \]

每次加入元素,就将其作为 \(B\) 栈元素压入栈;每次删除最先一次加入的元素(出队),如果 \(A\) 栈非空(有 \(A\) 元素),就弹出 \(A\) 栈栈顶,但是会有 \(B\) 元素压在 \(A_1\) 上面使其无法弹出,我们将 \(A_1\) 上的 \(B\) 元素连同 \(A_1\) 全部弹出,再将 \(B\) 元素按照原顺序放回去,就相当于是把 \(A_1\) 删了。如果 \(A\) 栈是空的(没有 \(A\) 元素),那就将所有 \(B\) 元素全部弹出再逆序放回去(为了改顺序让最先进队的元素靠近栈顶),然后再把 \(A_1\) 删了。

但是这样复杂度肯定是错的,\(B\) 元素下面可能压着很多 \(A\) 元素,如果反复出队就炸了。我们需要一个势能分析控制复杂度。提出一个理论,设有 \(cnt\)\(A\) 元素,我们尝试出队的时候一口气弹出 \(\newcommand{\lowbit}{\operatorname{lowbit}}\lowbit(cnt)\)\(A\) 元素以及这些元素头上压着它们的 \(B\) 元素,再将 \(B\) 元素压回去,再将 \(\lowbit(cnt)-1\)\(A\) 元素压回去,最后一个要弹掉的 \(A\) 元素就不放回去了。没有 \(A\) 元素的时候还是要把所有 \(B\) 元素弹出改成 \(A\) 元素的顺序。

为什么这样就对了呢?我们首先需要一个归纳假设,从栈底到栈顶,\(A\) 元素形成若干个连续段,每个连续段的长度都是 \(2^k\)\(k\) 从栈底到栈顶单调减(可能有部分段是连在一起的,但是可以拆成这样)。这样我们弹出 \(\lowbit(cnt)\)\(A\) 元素时,就实际上弹出的是最后一段 \(A\) 的连续段和它头上的 \(B\) 连续段,然后将这两段交换位置,将 \(A_1\) 弹出,\(\lowbit(cnt)\) 这个连续段立即拆为 \(2^k,2^{k-1},\cdots,2^0\) 这么多个连续段,连续段长度的总和是 \(\lowbit(cnt)-1\)(这些连续段是紧密相连的)。再证明完“所有 \(B\) 元素弹出改成 \(A\) 元素的顺序”的情况后,可以发现这个归纳就自动成立了。

现在设计势能函数以证明复杂度。一个 \(B\) 元素的势能是它前面 \(A\) 连续段的个数,每次拿出 \(B\) 元素时这个势能就会减一,而势能始终 \(\geq 0\),因此这部分复杂度正确。一个 \(A\) 元素的势能是它所在 \(2^k\) 连续段的 \(k\) 的值,每次拿出 \(A\) 元素的时候 \(k\) 会往下减,而势能始终 \(\geq 0\),因此这部分复杂度正确。还有很重要的“所有 \(B\) 元素弹出改成 \(A\) 元素的顺序”的情况,这个也显然对复杂度不影响。

因此复杂度就是每个元素加入或撤销 \(O(\log n)\) 次。

struct queue2 {
  stack<int, vector<int>> stk;
  int cnt = 0;
  void (*add)(int);
  void (*undo)();
  queue2() = default;
  queue2(decltype(add) _add, decltype(undo) _undo) : add(_add), undo(_undo) {}
  void push(int x) {
    stk.push(x), add(x);
  }
  void pop() {
    vector<int> tmp[2];
    int lb = cnt & -cnt;
    while (!stk.empty() && (int)tmp[0].size() < (lb ? lb : 1)) {
      int x = stk.top();
      stk.pop(), undo();
      tmp[x > 0].push_back(x);
    }
    if (!cnt) {
      swap(tmp[0], tmp[1]);
      reverse(tmp[0].begin(), tmp[0].end());
      for (int &x : tmp[0]) x = -x;
      cnt = (int)tmp[0].size();
    }
    reverse(tmp[1].begin(), tmp[1].end());
    for (int x : tmp[1]) add(x), stk.push(x);
    reverse(tmp[0].begin(), tmp[0].end());
    tmp[0].pop_back(), --cnt;
    for (int x : tmp[0]) add(-x), stk.push(x);
  }
};
posted @ 2025-06-19 20:13  caijianhong  阅读(73)  评论(0)    收藏  举报