頂点の木が与えられる.木のどの頂点もちょうど1つの頂点対に割り当てられるような 個の頂点対への分割を考える.そのような分割で,どの辺もある組の最短経路上にあるような分割が何通りあるかを求めよ.
制約:
解法1. 包除原理
包除原理までは思いついたのですが動的計画法で解く方法が思いつかなかったので解説を見ました [1].解説のマージの部分が分からなかったので kmjp さんのソースコード を参考にしました.
* kmjpさんの記事がありました:AtCoder ARC #101 : E - Ribbons on Tree - kmjp's blog
木 を考えます.辺 に対して, を最短経路が を通らない頂点対への分割全体とします.このとき, 以外の辺に関してはその辺を通る頂点対の最短経路が存在するかどうかは考えていません.頂点数が偶数 の頂点集合に対して, を頂点対への分割の組合せ数とすると,
となります. が奇数の場合は で, は と等しくなります.
は を通る最短経路が存在しない頂点対への分割なので, から を除去したグラフ上の分割の仕方になります.一般に木から を取り除くと, 個の木に分割されるので,各連結成分の頂点数を とすると,
となります.したがって,包除原理から求める答えは,
となります.包除原理に従って実装すると 時間かかります.
計算時間:
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr ll MOD = 1e9 + 7;
class ProblemE{
public:
ProblemE(int _n) : n(_n), m(n - 1), tree(m), comb(n + 1), visited(n) {
for (int i = 0; i < m; ++i) {
cin >> tree[i].first >> tree[i].second;
--tree[i].first; --tree[i].second;
}
comb[2] = 1;
for (int i = 4; i <= n; i += 2)
comb[i] = ((i - 1) * comb[i - 2]) % MOD;
}
ll Solve() {
ll ans = 0;
for (int s = 0; s < 1 << m; ++s) {
ll sum = 1;
fill(visited.begin(), visited.end(), false);
for (int v = 0; v < n; ++v) {
if (visited[v]) continue;
int num_visited = Bfs(v, s);
sum = (sum * comb[num_visited]) % MOD;
}
if (__builtin_popcount(s) % 2 != 0) ans = ((ans - sum) + MOD) % MOD;
else ans = (ans + sum) % MOD;
}
return ans;
}
private:
int n, m;
vector<pair<int, int>> tree;
vector<int> comb;
vector<bool> visited;
int Bfs(const int cur, const int ban) {
visited[cur] = true;
int res = 1;
for (int i = 0; i < m; ++i) {
int dst = (tree[i].first == cur) ? tree[i].second : -1;
if (tree[i].second == cur) dst = tree[i].first;
if (dst == -1 || visited[dst] || ban >> i & 1) continue;
res += Bfs(dst, ban);
}
return res;
}
};
int main() {
cin.tie(0); ios::sync_with_stdio(false);
int n; cin >> n;
assert(n <= 20);
ProblemE p(n);
cout << p.Solve() << endl;
return 0;
}
頂点 を根とする根付き木 を考えます.また,頂点 を根とする部分木のサイズを とします. の各部分木に対して上の包除原理を適用すること考えるのですが, から根と子をマージするときに必要な情報は,根を含む連結成分のサイズと,取り除いた辺数のパリティであることが分かります.
を頂点 を根とする部分木で, を含む連結成分の頂点数が で,その連結成分以外に対して,どの頂点対の最短経路にも含まれない辺数のパリティが となる分割の組合せ数とします.この漸化式が計算できると答えは, となります.
頂点 に対して を計算します. の子全体を とします( は の子全体に対しての適当な全順序). に対して, の根付き木と頂点 を併せた部分木 に対して までの値が求まっているとして,子 の根付き木 をその部分木にマージすることを考えます.これは, の を含むサイズ の連結成分と, の をサイズ の連結成分に対して次の2通りのマージの仕方があります.
1. 辺 を使用
・
・
2. 辺 を不使用
・
・
2番目のマージで の項を掛けているのは,辺 を使用しないので の を含むサイズ の連結成分の頂点対の分割の仕方が定まるためです.
上の漸化式を深さ優先探索で後順で更新すると計算できます.計算時間は一見すると各頂点で ステップかかり,全体で かかりそうですが解析をすると 時間となります.競技プログラミング界隈では「木 DP 二乗」として有名らしいです(二乗の木 DP - (iwi) { 反省します - TopCoder部).
簡単のため分枝数が2で,左部分木と右部分木のサイズがそれぞれ として計算時間の解析を行います. を部分木のサイズが のときの最悪実行時間とすると,上の漸化式から となります.ある正整数 に対して, を帰納法で示します.帰納段階は,
となるので ということが示せました.一般に分枝数が3以上でも同様の解析で示せます.
分枝数 で各子の部分木のサイズを とすると上の漸化式から,
となります.ここで,
となるので,
となり, ということが示せました.
計算時間:
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr ll MOD = 1e9 + 7;
constexpr int MAX_N = 5050;
ll ch[MAX_N][2], dp[MAX_N][MAX_N][2];
class ProblemE {
public:
ProblemE(int _n) : n(_n), m(n - 1), root(0), tree(n), comb(n + 1), sz(n, 1) {
comb[2] = 1;
for (int i = 4; i <= n; i += 2)
comb[i] = ((i - 1) * comb[i - 2]) % MOD;
}
inline void add_edge(int v[2]) {
tree[v[0] - 1].push_back(v[1] - 1);
tree[v[1] - 1].push_back(v[0] - 1);
}
ll Solve() {
Dfs(root, -1);
ll res = 0;
for (int i = 0; i <= n; ++i) {
res = (res + (dp[root][i][0] * comb[i]) % MOD) % MOD;
res = (res - (dp[root][i][1] * comb[i]) % MOD + MOD) % MOD;
}
return (res % MOD + MOD) % MOD;
}
private:
const int n, m, root;
vector<vector<int>> tree;
vector<ll> comb;
vector<int> sz;
void Dfs(const int cur, const int p) {
dp[cur][1][0] = 1;
for (const int dst : tree[cur]) {
if (dst == p) continue;
Dfs(dst, cur);
for (int i = 0; i <= sz[cur] + sz[dst]; ++i)
ch[i][0] = ch[i][1] = 0LL;
for (int x = 1; x <= sz[cur]; ++x) {
for (int y = 1; y <= sz[dst]; ++y) {
(ch[x + y][0] += dp[cur][x][0] * dp[dst][y][0]) %= MOD;
(ch[x + y][0] += dp[cur][x][1] * dp[dst][y][1]) %= MOD;
(ch[x + y][1] += dp[cur][x][0] * dp[dst][y][1]) %= MOD;
(ch[x + y][1] += dp[cur][x][1] * dp[dst][y][0]) %= MOD;
(ch[x][1] += dp[cur][x][0] * dp[dst][y][0] % MOD * comb[y]) %= MOD;
(ch[x][1] += dp[cur][x][1] * dp[dst][y][1] % MOD * comb[y]) %= MOD;
(ch[x][0] += dp[cur][x][0] * dp[dst][y][1] % MOD * comb[y]) %= MOD;
(ch[x][0] += dp[cur][x][1] * dp[dst][y][0] % MOD * comb[y]) %= MOD;
}
}
for (int i = 0; i <= sz[cur] + sz[dst]; ++i) {
dp[cur][i][0] = ch[i][0];
dp[cur][i][1] = ch[i][1];
}
sz[cur] += sz[dst];
}
}
};
int main() {
cin.tie(0); ios::sync_with_stdio(false);
int n, v[2];
cin >> n;
ProblemE p(n);
for (int i = 0; i < n - 1; ++i) {
cin >> v[0] >> v[1];
p.add_edge(v);
}
cout << p.Solve() << endl;
return 0;
}
まとめ
初めて「木 DP 二乗」を解いたので勉強になりました.上の実装で dp, ch をクラスのメンバー変数として std::vector で管理しようとすると TLE になります.グローバル変数で生配列が早いのは知っているのですが TLE になるほど遅いのは何ででしょうね.
std::vector を返す実装を多くの人がしていたので「木 DP 二乗」の実装に典型があるかもしれません.後で勉強します.