AtCoder Beginner Contest 351 G問題メモ

G - Hash on Tree

問題

  • 頂点1を根とした $N$ 頂点の根付き木が与えられる
  • 各頂点には $A_1,A_2,...,A_N$ の値が書き込まれている
  • 頂点 $v$ に対し、$f(v)$ を以下で定義する
    • $v$ が葉の時、$f(v)=A_v$
    • それ以外の時、$v$ の子を $c_1,c_2,...,c_k$ として、$f(v)=f(c_1) \times f(c_2) \times ... \times f(c_k) + A_v$
  • $Q$ 回のクエリに答えよ
    • $i$ 回目のクエリでは、頂点 $v_i$ に書き込まれた値 $A_{vi}$ を $x_i$ に更新する
    • 各クエリにおいて、更新後の $f(1) \mod{998244353}$ を出力する
  • $2 \le N \le 2 \times 10^5$
  • $1 \le Q \le 2 \times 10^5$
  • $0 \le A_i,v_i \lt 998244353$

解法

1回のクエリの影響が大きくて、更新した頂点から根に至るまでの全ての祖先の $f(v)$ が変わる。
平衡二分木のように高さがそこまで大きくなければよいが、そうとも限らない。

更新方法をよく観察すると、もし「更新頂点からの根に至るまでのパス」がセグメント木に乗っていて、 それ以外の頂点が更新されないと仮定するならば、効率的に更新できる。

      ❶         f(1) = f(2)*f(4) * f(3) + A1
    /|\              ~~~~~~~~~
   ② ❸ ④      f(3) = f(7)      * f(6) + A3
  /   /\                ~~~~
 ⑤  ❻⑦        f(6) =     1     * f(10)+ A7
 /\  |
⑧⑨ ❿          f(10)=     0            + A10
    
                       └────┘       └─┘
                            Xi              Yi
                    更新されないと仮定

$X_i x + Y_i$ という一次関数の合成はモノイドの条件を満たすので、 $(X_i,Y_i)$ をセグメント木に載せることで、❶❸❻❿のいずれかの更新に関しては $O(\log{N})$ でおこなえる。

実際にはそれ以外の頂点に対する更新もあるわけで、それをどうするか?

HL分解

他の頂点も、本流(①③⑥⑩)から外れたところから、再帰的に同じようにパス化してしまえばよい。
子が複数ある頂点からは、最も頂点数の多い子を残すようにパスに分解していく。
すると、どの頂点からも、根に遡るまでに経由するパス(下記[②⑤⑧]や[①③⑥⑩]など)の個数は $O(\log{N})$ に抑えられることが保証される。

      ①
    /|\
   ② ③ ④
  /   /\
 ⑤  ⑥⑦
 /\  |
⑧⑨ ⑩

[①③⑥⑩]
[②⑤⑧]
[④]
[⑦]
[⑨]

⑨を更新するとき、

  • [⑨]のセグメント木で、$Y_9$ を更新(長さ1なのでほぼ無意味だが)
  • →先頭の $f(9)$ を取得
  • $f(9)$ をもって [②⑤⑧] のセグメント木の $X_5$ を更新
  • →先頭の $f(2)$ を取得
  • $f(2)$ をもって [①③⑥⑩] のセグメント木の $X_1$ を更新
  • →先頭の①の $f(1)$ を取得

とすると、更新が発生する箇所全てを辿ることができる。

辿るパス数が $O(\log{N})$ で、各パス(セグ木)の更新も $O(\log{N})$ なので、1クエリ $O(\log^2{N})$ で更新できることになる。

0への対応

今回の場合、問題となるのが、$f(i) \equiv 0 \mod{998244353}$ になり得ることである。

$X_1=f(2) \times f(4) \times ... いっぱい$ であるときに、$f(2)$ の更新を $X_1$ に反映しようとすると、

  • $X_1 / (更新前のf(2)) \times (更新後のf(2))$

とするのが手っ取り早いが、$更新前のf(2)=0$ だとゼロ除算となり、それができない。

ゼロは分けて管理する。$X_i$ の情報を $W_i,Z_i$ に分けて、

  • $W_i=$ かけあわせる $f(c)$ のうち、ゼロでないものだけの積
  • $Z_i=$ かけあわせる $f(c)$ のうち、ゼロであるものの個数

とすると、$Z_i=0$ のとき $X_i=W_i$、$Z_i \gt 0$ のとき $X_i=0$ で、更新も問題なくおこなえる。

よって、$(W_i,Z_i,Y_i)$ の3つの値を、HL分解後のセグメント木に載せるとよい。

計算量は $O(N+Q\log^2{N})$。

(※分けて管理する代わりに、各頂点 $v$ に対して「$v$ の子からなる、1点更新・区間積取得のセグメント木」を作ってもよい)

Python3

解法1.5

解法1で、セグメント木の作り方を敢えてアンバランスにすることにより、logを1つ落とすことができる、らしい。

①--②--③--④--⑤--⑥--⑦--⑧    ←Heavy path
 |   |   |   |   |   |   |
 |   :   :   :   :   :   :        ←Light paths
 |`⑨-⑩-...
 |  `-⑪-...
 `-⑫-⑬-...
 
通常のセグメント木
  1  2  3  4  5  6  7  8
 |----------------------|
 |----------||----------|
 |----||----||----||----|
 |-||-||-||-||-||-||-||-|

アンバランス(例)
  1  2  3  4  5  6  7  8
 |----------------------|    ノードを左右に分割するとき、
 |----||----------------|    「ノード v から伸びる、Heavy path 以外の
 |-||-||-||-------------|      子の部分木サイズ」を Wv として
          |----||-------|    左右で Wv の合計がなるべく等しくなるように分割する
          |-||-||-||----|
                   |-||-|

ものすごくざっくりした理解で言うと、①~⑧がそれぞれの傍流のLight pathによって更新される際、 「そこに至るまでに辿ってきた Light path の個数」や「辿ってきたセグメント木の深さ」(=計算量)って、 多く Light path の子孫を持ってる頂点ほど多くなりやすいはず。

そこで、「Light path を辿ってきた計算量」と「Heavy path 上のセグメント木の深さ」を均すことによって、 最悪ケースが $O(\log{N})$ になる感じ。

ただ、配列で持てなくなるなど、軽い実装はしにくくなるので、 logまるまる1つ分の恩恵があるかというと、そうでもないらしい?(章冒頭の記事参照)。

解法2

公式Editorialでは、Static Top Tree というものを用いることで、$O(N+Q\log{N})$ で解く方法が解説されている。

個々の頂点がバラバラに存在する状態から、元の木になるように統合していく操作は、5種類程度に分類できる。
元の木が平衡木や二分木でなくても、この「統合の過程」なら「なるべく平衡な二分木」で表すことができる。
(具体例は、公式の動画解説や「Static Top Treeの覚書」参照)

なるべく平衡な二分木の深さは $O(\log{N})$(統合操作を表現するため元の $N$ よりノードが増えたり、完全には平衡にできないことで多少非効率な部分ができるにしても、4倍の定数倍がかかる程度)なので、 クエリの更新は Static Top Tree を更新頂点から根まで辿る $O(\log{N})$ でおこなえる。

programming_algorithm/contest_history/atcoder/2024/0427_abc351.txt · 最終更新: 2024/05/01 by ikatakos
CC Attribution 4.0 International
Driven by DokuWiki Recent changes RSS feed Valid CSS Valid XHTML 1.0