Day 158-Q1 — SWAG(Sliding Window Aggregation・2スタックで作る「畳み込み可能キュー」)

2026-09-23 赤色 Master / Phase 8+ ★★★★★★★★★ 逆元のない演算をキュー全体で集約する償却 O(1) データ構造

問題

キュー(先入れ先出し)に 一次関数 $f(x) = a x + b$ を出し入れする。次の $Q$ 個の操作を順に処理せよ。すべての計算は $\bmod\ 998244353$ で行う。

  • 0 a b : 関数 $f(x)=ax+b$ をキューの 末尾 に追加する
  • 1 : キューの 先頭 の関数を取り除く(キューは空でないことが保証される)
  • 2 x : キューの中身を先頭から順に $f_1, f_2, \dots, f_k$ とするとき、$f_k(\cdots f_2(f_1(x))\cdots) \bmod 998244353$ を出力する(キューが空なら $x$ をそのまま出力)

入力形式

Q
query_1
...
query_Q

制約

#条件
1$1 \le Q \le 5 \times 10^5$
2$1 \le a < 998244353$, $0 \le b, x < 998244353$
31 の操作時、キューは空でない

入出力例

入力例1

7
0 1 2
0 3 4
2 1
1
2 1
1
2 1

出力例1

13
7
1

($f_1=x+2, f_2=3x+4$。$f_2(f_1(1))=3\cdot3+4=13$。$f_1$ を捨てると $f_2(1)=7$。空になると $1$)

この問題は Library Checker の「Queue Operate All Composite」(https://judge.yosupo.jp/problem/queue_operate_all_composite 、2026-09-23 参照)と同じ形式で、そこでの制約は $Q \le 5\times10^5$。

概念図:2本のスタックと集約の向き

キュー f1 f2 f3 f4 f5 を 2 本のスタックで持つfront(pop 側)f1集約: f1→f2 を合成済みf2集約: f2← topback(push 側)f5f4f3← topback_fold = f3→f4→f5front が空のときだけ全部移す(各要素 1 回)全体 = comp( front[-1] の集約 , back_fold ) = f1→f2→f3→f4→f5

ヒント(クリックで段階的に開示)

ヒント1(方向性)

関数合成には 逆元が無い($a$ が 0 でないので本問では実は逆関数が作れるが、一般のモノイド——min、max、gcd、行列積の非正則なもの——では作れない)。「全体から先頭を引き算する」累積和型の発想は使えない。引き算を使わずに、先頭を消せる集約を考える。

ヒント2(アプローチ)

キューは「スタック2本」で作れる(入れる用 back と出す用 front)。スタックは末尾でしか変化しないので、各要素に "自分から底までの集約値" を持たせておけば、push/pop で集約値を壊さずに済む。キュー全体の集約 = front の top が持つ集約 と back 全体の集約 の合成。

ヒント3(誘導)
def comp(f, g):          # 「f を適用してから g」を 1 本の一次関数に
    return (f[0]*g[0] % MOD, (f[1]*g[0] + g[1]) % MOD)

# push: back に積み、back_fold = comp(back_fold, f)
# pop : front が空なら back を全部 pop しながら front に移す。
#       移すとき「その要素から (元の) 先頭側までの合成」ではなく
#       「その要素から キュー末尾側まで」… 向きに注意して acc = comp(f, acc)
# fold: comp(front[-1] の集約, back_fold)

模範解答 (Python)

模範解答を表示する(まず自力で解いてから)
import sys

MOD = 998244353
ID = (1, 0)  # 恒等関数 x -> x


def comp(f, g):
    """f を先に適用し、その後 g を適用した合成 g(f(x)) を返す。"""
    return (f[0] * g[0] % MOD, (f[1] * g[0] + g[1]) % MOD)


def main():
    data = sys.stdin.buffer.read().split()
    q = int(data[0])
    pos = 1
    front = []          # 各要素 = 「その要素 ~ front の底(キュー中央側)」までの合成
    back = []           # 生の関数
    back_fold = ID      # back 全体の合成(先に入った順に適用)
    out = []
    for _ in range(q):
        t = data[pos]
        if t == b'0':
            f = (int(data[pos + 1]), int(data[pos + 2]))
            pos += 3
            back.append(f)
            back_fold = comp(back_fold, f)
        elif t == b'1':
            pos += 1
            if not front:
                acc = ID
                while back:                 # 末尾側から取り出す
                    f = back.pop()
                    acc = comp(f, acc)      # f を先に、既に積んだ(より後ろの)関数を後に
                    front.append(acc)
                back_fold = ID
            front.pop()
        else:
            x = int(data[pos + 1])
            pos += 2
            a, b = comp(front[-1] if front else ID, back_fold)
            out.append((a * x + b) % MOD)
    sys.stdout.write("\n".join(map(str, out)) + ("\n" if out else ""))


main()

計算量: 各関数は back → front へ 高々1回 しか移動しないので、全操作で償却 $O(1)$、全体 $O(Q)$。空間 $O(Q)$。

Step-by-Step 解説

1なぜ累積和(引き算)ではダメか

スライド窓の和なら「全体 − 先頭」で済むが、それは加法に逆元があるからである。min・max・gcd・行列積・関数合成(一般の場合)には逆元が無いので、先頭を「取り消す」手段がない。SWAG はこの「逆元が無いモノイドでも、キュー全体の集約を償却 O(1) で維持する」ための道具。

2スタックなら逆元なしで集約を維持できる

スタックは末尾だけが変わる。各要素に「底から自分までの集約」を持たせておけば、pop は単に1段下の値に戻るだけで、取り消し計算が要らない。キュー = 2本のスタック に分解すれば、この性質をキューにも持ち込める。

3合成の向き(本問で一番バグりやすい所)

関数合成は 非可換 なので向きが命。キューの先頭から $f_1, f_2, \dots$ の順に適用する。

  • back は「後から来たもの」が上に積まれる → back_fold = comp(back_fold, f)(既存を先に、新しい f を後に)
  • front に移すときは back の 上(新しい方)から 取り出す。front の上にくる要素ほどキューの先頭側なので、front の各要素は「自分を先に、自分より後ろの関数群を後に」適用した合成を持つ → acc = comp(f, acc)
  • 全体 = comp(front[-1], back_fold)(front 側 = 先頭側を先に)

入力例1で確認すると、2 1 の時点で front は空、back_fold $= f_2\circ f_1 = (3, 3\cdot2+4)=(3,10)$ で $3\cdot1+10=13$。

4償却解析

移し替え(while back)は1回で $|back|$ 要素を動かすが、各要素は back に入ってから front を経て捨てられるまでに 1回しか移動しない。したがって全体で移動は $Q$ 回以下、各操作は償却 $O(1)$。ただし 最悪1回の pop は $O(Q)$ かかる(リアルタイム性が要る場面では問題になる → 次の「迷った他案」参照)。

迷った他案と採らなかった理由

案計算量採否理由
deque を毎回先頭から全部合成(素朴)1クエリ $O(k)$比較用のみ$Q=2\times10^4$ でも 1.168 秒(実測)。$Q=5\times10^5$ では TLE
セグメント木(時刻を添字に、pop は単位元で上書き)1操作 $O(\log Q)$不採用正しく動き、区間クエリにも拡張できる。ただし本問は「キュー全体」しか問わないので $\log$ と配列 $2Q$ 個が余計。区間の任意部分を問う形に変わったらこちらに切り替える
逆関数を使う($a \ne 0$ なので $f^{-1}(x) = a^{-1}(x-b)$)1操作 $O(\log MOD)$(逆元計算)不採用本問は偶然 $a\ge1$ なので可能だが、$a=0$ を許す・min/max/gcd に変わる、と即座に破綻する。一般性を失う割に速くない
SWAG(本解)償却 $O(1)$採用逆元不要・実装 30 行。最悪ケースの1操作 $O(Q)$ は競プロでは問題にならない
Two-Stack の de-amortized 版(Okasaki 型の段階的移し替え)/ DABA最悪 $O(1)$扱わないストリーム処理(Apache Flink 等の窓集約)でレイテンシのばらつきを嫌う場合の選択肢。競プロでは償却で十分なので本問の範囲外とした

よくあるミス

ミス原因正しい書き方
合成の向きを逆にする(comp(acc, f))関数合成を可換だと思い込むfront へ移すときは「取り出した f を先に、既に積んだ後方の合成を後に」 acc = comp(f, acc)
移し替えのあと back_fold を単位元に戻し忘れるback が空になったことを集約値に反映しないback_fold = ID を移し替えの最後で必ず行う
空キューで front[-1] を参照空の場合の単位元を用意していないfront[-1] if front else ID
入出力が遅いinput() を $5\times10^5$ 回呼ぶsys.stdin.buffer.read().split() で一括読み込み
% MOD を付け忘れて多倍長整数が膨張Python は溢れないので気づきにくい合成のたびに % MOD(付け忘れると値が桁数を増やし続け、遅くなるだけで WA にならないので発見が遅れる)

次のステップ

  • 発展1: 「キュー」ではなく 両端キュー(deque) に集約を持たせる(Library Checker「Deque Operate All Composite」)。2スタックを「空になった側に反対側の半分を移す」形に変えると償却 O(1) を保てる
  • 発展2: 窓の長さ $K$ 固定のスライド最小値を SWAG で書き、単調デック(Day074・Day126 のモノトニックキュー)と実測比較する——単調デックは「比較可能な値の min/max」専用、SWAG は任意のモノイドで使える、という守備範囲の違いを体感する
  • 扱わなかった範囲: 最悪 $O(1)$ 保証版(DABA: De-Amortized Banker's Aggregator、Tangwongsan ら 2017)と、その実務での用途(ストリーム処理の窓集約)

検証ログ(本セッションで実行した数値)

  • 入力例1: SWAG 版 [13, 7, 1]、素朴版 [13, 7, 1] で一致
  • ランダムテスト: 小さい値($Q\le40$、係数 $\le10$)1000 件 + 係数を $\bmod$ 全域にとった $Q=300$ を 200 件、計 1200 件で素朴版と比較 → 不一致 0 件
  • 模範解答そのもの(標準入力版)でも、ランダム 100 件($Q\le60$)を素朴版と比較 → 不一致 0 件
  • 性能(実測): $Q=2\times10^4$(push 55%・pop 20%・fold 25%)で SWAG 0.006 秒 / 素朴版 1.168 秒(出力一致)
  • 性能(実測): $Q=2\times10^5$、キューの最大長 69,877 で SWAG 0.057 秒(素朴版は1回の fold で最大 7 万回の合成になるため計測を省略)
  • 検証スクリプト: スクラッチパッドの q1.py(上記は実行結果の転記)

自己評価

自分の回答

気づき・メモ