ICPC国内予選2023 Problem E - Tampered Records(改竄された史料)
Published on 2025-04-09Last Modified 2025-04-09
Table Of Contents
問題
以下 aoj1668 より引用
改竄された史料
太古の昔, International Collegiate Puzzle Contest (ICPC) というパズル大会が毎年開催されていた. 各大会は午前・午後の 2 つのラウンドで構成されていた. どちらのラウンドでも,参加者はまず自作のパズル 1 つを他の全参加者に提示し,その後に自分自身以外の全参加者が提示したパズルをできるだけ多く解こうとした.
参加者の順位は以下の規則に従って決めていた.
- 午後のラウンドで解いたパズルの数がより多い参加者がより上位となる.
- 午後のラウンドで同数のパズルを解いた参加者のなかでは,午前のラウンドで解いたパズルの数がより多い方が上位となる.
- 両ラウンドで解いたパズルの数が完全に一致する参加者同士の順位はくじびきで決める.
先日,ある年の ICPC の順位表とされる文書が発見された. 他の同様の資料から,この文書は各ラウンドで解いたパズルの数を上位の参加者から順に書き並べたものと考えられる. ところが学者たちは文書は何者かによって改竄されていて,いくつかの数が書き換えられているのではないかという疑いを持っている. もともとの順位表に上述の規則と矛盾がなかったと仮定したとき,少なくともいくつの数が書き換えられているのかを求めてほしい. なお,文書中の解いたパズルの数以外の部分への改竄はなく,参加者の追加や削除もありえない.
ICPCの参加者$n$はひとつのテストケースで$n \leq 6000$となる。含まれるテストケースはひとつのファイルで高々100であり、$n$の合計は$10 ^ 5$を超えない。
問題の言いかえ
問題を競プロ風に言い換える。 $n \leq 6000$個の数のペア$(x _ i, y _ i)$が与えられる。 二つのペア$(x _ i, y _ i), (x _ j, y _ j)$に対して、
- $y _ i < y _ j$または$y _ i = y _ j \land x _ i < x _ j$である場合に限り$(x _ i, y _ i) < (x _ j, y _ j)$
- $y _ i = y _ j$かつ$x _ i = x _ j$である場合に限り$(x _ i, y _ i) = (x _ j, y _ j)$
であるとする。 任意に数を書き換えることで次の条件が成立するようにする。
- 全ての数が$[0, n)$の範囲にある。
- $1 \leq i \leq n - 1$に対して、$(x _ i, y _ i) \leq (x _ {i + 1}, y _ {i + 1})$
書き換える必要のある数の最小値はいくつか?
考察
-
午後の部の成績がより強く効くので、ここを良い感じに揃えることを考えてみる。すると、$i$番目のペアの午後の成績を$n - i$にすることで$n$回で達成できることがわかる。解が$n$を超えることはなさそう。
-
達成できる改変数には単調性がある。つまり、$k$回で達成できたとすると、$k + 1$回でも達成できる。じゃあ二分探索みたいなのに落とし込めるか? -> 判定問題を良い感じに貪欲考えようとしてもかなり厳しそう。そもそも解の上界がかなり小さいから二分探索のうまみが少ない。
-
午後 -> 午前の順にそろえるとしたら? -> 午後の部の成績が同じグループは独立に考えてよい。ひとつのグループが決まっていたら最適な手順はわかる?いや、これだけでも結構厳しそう。しかもグループの決め方で全てが変わってしまうし、グループの分け方を全部探索するのも当然無理。
-
探索ベースの貪欲は厳しそう?端から決めてみたらどうだろうか。 -> 一番シンプルなdpなら$\mathrm{dp}[i][j][k]$が先頭$i$項の順番をそろえて、一番最後のペアが$(j, k)$となる最小コストとなりそう。これなら計算量を無視したら解ける。多分当たり方針っぽい。
-
新しく「最後のペア」を決めるとき、ひとつ前のペアより順番が大きい必要がある。また、出来るだけ小さい方が当然良い。例えば$(1, 1)$とかから変更2回を使って$(n - 1, n - 1)$にわざわざ変更する意味は全くない。変更2回も使うなら当然$(1, 1)$にすべき。これを使ってなんかいい感じにできそう?
-
ナップサックdpの添字を逆転させるテクを用いる。$\mathrm{dp}[i][j]$を「「先頭$i$項の順番をそろえてコストが$j$となるような変更方法で生成されるペア列の末尾のペア」の最小」とする。 遷移は変更0, 1, 2回の3通りを書けばよい。変更0は$(x _ i, y _ i)$がすでに遷移元より大きい場合のみを考える。変更2は遷移元と同じペアに変更してやればよい。変更1はこれらより少し難しいが、「午後の成績を遷移元と同じ or +1にする」と「午前の成績を遷移元と同じ or 0にする」の4通りのうち有効なものをすべて実行すればよさそう。
解法の概要
貪欲法ベースの「最適な決め方」みたいなものはなくて、全探索をベースにするしかない。端から確定させるとすると、一番最後が何だったか以外の情報を捨ててよいため、dpが出来そう。
率直にやると$\mathrm{dp}[i][j][k]$を先頭$i$項を処理して最後が$(j, k)$になる最小の変更回数となりそう。これは遷移先を良い感じに枝狩りしても$\Omega(N ^ 3)$とか。
$\mathrm{dp}[i][x][y] = \mathrm{dp}[i][p][q]$だったとしたら、$(x, y)$と$(p, q)$のうちより小さい方だけ保持しておけばよい。(より大きな方から遷移しても明らかに最適解になりえない。) 添字の入れ替えテクを用いて、同じコストを実現するもののうち、末尾のペアが最小になるものだけを残すようにする。
$\mathrm{dp}[i][j]$は先頭$i$項を処理してコストが$j$となる物のうち、最小の末尾ペアとなる。この時点で$\Omega(N ^ 2)$。配る遷移を考察すると$O(1)$個しかないので、$O(N ^ 2)$となる。(よくあるもらう形式にしてデータ構造でなんとかするみたいなことは無理そうなので無難に配った方がいい。)
実装例
#include <iostream>
#include <vector>
#include <utility>
using namespace std;
constexpr int iINF = 1'000'000'000;
int cmp (const pair<int, int>& x, const pair<int, int>& y) {
if (x.second == y.second) {
if (x.first == y.first) return 0;
return x.first < y.first ? -1 : 1;
}
return x.second < y.second ? -1 : 1;
}
int main () {
while (true) {
int n; cin >> n;
if (n == 0) break;
vector<int> x(n), y(n);
for (int i = 0; i < n; i++) {
cin >> x[i] >> y[i];
}
// dp[i][j] := 昇順にi個処理して、変更j回をしたとき、作れる最も小さいスコア
vector<pair<int, int>> dp(n + 1), ndp(n + 1);
for (int i = 0; i < n + 1; i++) {
dp[i] = make_pair(iINF, iINF);
}
dp[0] = make_pair(0, 0);
for (int i = n - 1; 0 <= i; i--) {
for (int j = 0; j < n + 1; j++) {
ndp[j] = make_pair(iINF, iINF);
}
for (int j = 0; j < n + 1; j++) {
if (dp[j] == make_pair(iINF, iINF)) continue;
auto cur = make_pair(x[i], y[i]);
// 変更0回
if (cmp(dp[j], cur) <= 0) {
if (cmp(cur, ndp[j]) <= 0) {
ndp[j] = cur;
}
}
if (j == n) continue;
// 変更1回
// どちらを変更するかで分ける。
// 午前の部
if (x[i] != 0
&& cmp(dp[j], make_pair(0, y[i])) <= 0
&& cmp(make_pair(0, y[i]), ndp[j + 1]) <= 0) {
ndp[j + 1] = make_pair(0, y[i]);
}
if (x[i] != dp[j].first
&& cmp(dp[j], make_pair(dp[j].first, y[i])) <= 0
&& cmp(make_pair(dp[j].first, y[i]), ndp[j + 1]) <= 0) {
ndp[j + 1] = make_pair(dp[j].first, y[i]);
}
// 午後の部
if (y[i] != dp[j].second
&& cmp(dp[j], make_pair(x[i], dp[j].second)) <= 0
&& cmp(make_pair(x[i], dp[j].second), ndp[j + 1]) <= 0) {
ndp[j + 1] = make_pair(x[i], dp[j].second);
}
if (y[i] != dp[j].second + 1
&& dp[j].second + 1 < n
&& cmp(dp[j], make_pair(x[i], dp[j].second + 1)) <= 0
&& cmp(make_pair(x[i], dp[j].second + 1), ndp[j + 1]) <= 0) {
ndp[j + 1] = make_pair(x[i], dp[j].second + 1);
}
if (j == n - 1) continue;
// 変更2回
// この場合は前とピッタリ合わせればok
if (dp[j].first != x[i] && dp[j].second != y[i]) {
if (cmp(dp[j], ndp[j + 2]) <= 0) {
ndp[j + 2] = dp[j];
}
}
}
swap(dp, ndp);
}
int ans = -1;
for (int i = 0; i < n + 1; i++) {
if (dp[i] != make_pair(iINF, iINF)) {
ans = i;
break;
}
}
cout << ans << "\n";
}
}
感想
ICPCに採用されるだけあって、かなり良い問題だと感じた。 dpの方針が見えるまで3日くらい。dpを詰めながら実装するのに2時間くらいかかった。疲れた。
実装が爆発してしまった。今思うとpair<int, int>
はやめてoperator<=
とかをオーバーロードすべきだったかも(chmin
みたいなことができるので。)