またしても、最後の最後がよく詰めきれず... (でもその最後のところの詰めの大変さが、この難易度帯の特徴なんだよね)
問題概要
頂点のツリーが与えられる。根ノードの番号を 1 とする。各ノード について、以下のクエリに答えよ:
- 初期状態を集合 { } とする
- 今得られている集合に含まれるノードに隣接するすべてのノードのうち、番号が最も小さいノードを新たに集合に加える
- この操作を行ったとき、ノード 1 が集合に加えられるまでの操作回数を答えよ
制約
考えたこと
各クエリを十分高速にやらないといけない。ツリーの各ノードに対して求める問題なのだから、
頂点 に対して、その親ノード の結果を利用して高速に求める
という感じになっている気がして来る。そのことを意識した上で、ノード 単体でクエリに答えようとしたらどうなるのかを考察してみる。その過程で、ノード の結果を使えそうな部分を模索する感じで。
から 1 までは全部通っていくわけだが、その過程で一番大きいノード のやつがボトルネックになりそう。 より下にあるノードのうち、 から順に下に下がっていったときに、 より小さいノードのみを使って辿れるノードはすべて集合に入って行くことになる。
というわけで、
- f(v, c) := v を根とする部分木の中を、v から出発して番号が c より小さいノードのみを使って辿っていったときに辿れるノードの個数
を高速に求めたいという気持ちになる。でもこれ、 かかるような気がしてしまう。。。(ここで詰まってた)
考察の足りてなかったところ
この f(v, c) の計算が、実は必要なところだけをやれば でできるのだが、それは解説を見て「そうか!」となった。...とその前に、上の考察において、
- v より少し祖先にあるノード u について、u の部分木 (v 方向以外) の頂点はどのくらい取られるのか
という部分についてはちゃんと考えてなかった。実は一般に、
u の部分木 (v 方向を含まず) のうち、集合に加えられるノードは、u より上にあるノードのうち最大のノード番号を m(u) として、m(u) より小さいノードのみを使って辿れるノード
となることがわかる。この個数は、v について考えているときも、p について考えているときも等しいことがわかる。このことに気づくと明快になる。つまり、
v についての答えと、p についての答えの差分は、v の部分木上のノードにしかない
ということが言える。いよいよ、v についての答えと、p についての答えの差分がどうなるかを考察する。
m(p) < v のとき、すなわち p のどの祖先よりも v の方が大きいとき
p についての答えには、v は一切含まれない。よってその差分は
- f(v, m(v))
となる。
m(p) > v のとき、すなわち p のある祖先 m(p) が存在してそれが v より大きいとき
p についての答えにも v は含まれる。
最初この場合は、p についての答えと v についての答えが一致すると勘違いしてしまった。だが微妙な差分があることがある。
v の部分木について
- p についての答えに含まれるノード数は f(v, m(p))
- v についての答えに含まれるノード数は f(v, m(v))
これらは一般には一致しない。差分は、-f(v, m(p)) + f(v, m(v))
f(v, c) の計算
メモ化再帰で単純に計算できるのだが、一見 に見えるが実はすべての c についてこれを計算するわけではないから、全体として になるという話。
ちょっとこれはなかなか今の僕の力量だと気付き難い。。。
ざっくり、c は根からの葉までいたる経路上の累積 max に登場するものしか使わない。だから根から葉に向かってノード番号が単調増加になっているケースが最悪に見えるのだが、そんなことなくて、f(v, c) のツリー DP を具体的に見ると
map<pint,int> ma; int rec2(int v, int p, int c) { if (ma.count(pint(v, c))) return ma[pint(v, c)]; int res = 1; for (auto ch : G[v]) { if (ch == p) continue; if (ch > c) continue; res += rec2(ch, v, c); } return ma[pint(v, c)] = res; }
という感じになるわけだが、if (ch > c) continue; によって一瞬で枝刈りされて終わる。なので結果的に になる。
どんな場合でも になる気がして来る。根から葉への経路上の累積 max 値が更新されるたびに、それが葉へと向かうときに、次の累積 max に到達した時点で枝刈りされる。なので、結局全体として、ツリー全体を 1 回舐めるような感じになる (枝刈り時に参照される分を含めると最悪 2 回ずつ)。
実際は、m(v, m(p)) と m(v, m(v)) の二種類が見られるので、ツリー全体を最悪でも 3 回舐める程度の計算量になる。よって全体として でできる。
#include <iostream> #include <vector> #include <map> using namespace std; using pint = pair<int,int>; int N; vector<vector<int> > G; map<pint,int> ma; int rec2(int v, int p, int c) { if (ma.count(pint(v, c))) return ma[pint(v, c)]; int res = 1; for (auto ch : G[v]) { if (ch == p) continue; if (ch > c) continue; res += rec2(ch, v, c); } return ma[pint(v, c)] = res; } vector<int> res; void rec(int v, int p, int mp) { if (mp > v) { res[v] = res[p] - rec2(v, p, mp) + rec2(v, p, max(mp, p)); for (auto ch : G[v]) { if (ch == p) continue; rec(ch, v, max(mp, p)); } } else { res[v] = res[p] + rec2(v, p, max(mp, p)); for (auto ch : G[v]) { if (ch == p) continue; rec(ch, v, max(mp, p)); } } } int main() { cin >> N; G.assign(N, vector<int>()); for (int i = 0; i < N-1; ++i) { int a, b; cin >> a >> b; --a, --b; G[a].push_back(b); G[b].push_back(a); } res.assign(N, 0); for (auto v : G[0]) rec(v, 0, -1); for (int v = 1; v < N; ++v) { cout << res[v]; if (v != N-1) cout << " "; } cout << endl; }