夏のブログリレー 16 日目の記事です。
問題を解いていてこれを使う機会があったのですが、日本語での解説がほとんどなかったので、書いていこうと思います。
Post Image は近代五種です。内容とは全く関係ないです。
Repovive Starter Round 2 - F. のネタバレを含みます。
導入
次の問題を考えます。
長さ の配列 があり、最初すべての に対し です。
以下のようなクエリが 回与えられるので、すべて処理したあとの を求めてください。
- 回目のクエリでは、 が与えられる。 について、 とする。
この問題を、 がつかない計算量で解いてください。

安直にやるなら Lazy Segment Tree を用いると一発ですが、log がついてしまいます。およそ線形時間で求まるようにしたいですね。
Disjoint Union Set であそぼ
これを Disjoint Union Set でやりましょう、という少し不思議な提案をしてみます。
準備 : クエリ先読み
問題を見るに Offline なので、クエリを先読みしてよいです。そうすると、クエリを全部読んで逆から処理していけば、すでに塗られているところを無視することで線形時間で解けそうです。実際に既知の要素を完全に無視できるのならば、すべての配列の要素はそれぞれ 1 回タッチするだけですから高速です。

問題はこれをどう無視するかということですね。
やってみよう
12 マスあって、まず 2~7 番目のマスを青で塗り、次に 4~6 番目のマスを赤で塗るという場合を考えてみましょう。先ほどのように、クエリを逆順に見るので、まず 4~6 番目のマスを赤で塗り、2~7 番目のマスを青で塗るという順番で処理していきます。
以降にあるまだ塗っていないマスのうち、最も左にあるもの
としてみましょう。最初はどのマスも塗っていないので、 です。
マス目を塗ったとき、一個右のマスはまだ塗っていないマスなので という風に変化させます。

でもこの数字、塗ってるマス指してるところあるじゃん!これでちゃんと青塗れるの?
というわけでこちらをご用意いたしました。
int find(int p) {
if (next[p] == p) return p;
else return next[p] = find(next[p]);
}
これで、 マス目を塗ったときに、 と更新してあげます。
たとえば、最初は 4 マス目を塗ったので、 です。
では、次に 2~7 マス目を青で塗ってみましょう。

を更新するときどうなるかというと、
- を見る。 になっている。
- を見る。 になっている。
- を見る。 になっている。
- を見る。 になっている。 を返して 終了。
- に更新して、 を返して 終了。
- に更新して、 を返して 終了。
- に更新して、 を返して 終了。
- に更新する。
という風になります。すると、最初に塗った 4~6 マス目に行くマスがなくなるので、これ以上タッチしなくなります。結果として、3 マス目は次に 7 マス目を指すようになります。
あとは、残った 7 マス目を青く塗って、 に更新します。
以上のように、DSU と同様に辿ってマージをすることができるというわけです。
ただ、マージの仕方が重要な問題です。DSU では、マージの計算量を落とすために、Path Compression と Union by Size (or Rank) を行うのがよくある手法ですが、今回のケースでは Union by Size は行うことができません。というのも、自身より右にある頂点に向かって結合するという向きが重要な役割を果たすので、単にサイズなどを基準にして swap などを行うことはできないのです。
Path Compression しかできないと、マージには かかってしまうのでは?と思われるかもしれませんが、大丈夫です。
Important Fact:
Union Find を構成する木が Line Graph である場合、union by size/rank の処理を行わず、単に Path compression を行うだけでも、結合は で実行できる。
証明は省略しますが、Line Graph のもつ特殊な性質を用いることで示すことができます。よって、 で解くことができました。
Arpa's Trick
長さ の配列 に対して、
を で求めてください。 が empty である場合は としてください。
例えば、 に対し、 です。

なら超簡単ですね。
for (int i = n - 1; i >= 0; i--) {
b[i] = i + 1;
while (b[i] != n && a[i] <= a[b[i]]) {
b[i]++;
}
}
1 行変えてみました!
for (int i = n - 1; i >= 0; i--) {
b[i] = i + 1;
while (b[i] != n && a[i] <= a[b[i]]) {
b[i] = b[b[i]];
}
}
なんと! になるんですね~ 何?
b[i]++ を b[i] = b[b[i]] に変えただけですが、ここがいわゆる Path Compression に相当する操作になります。
つまり、ここでやっていることは、本質的には先ほどの色塗りの問題と変わりません。まず という辺を張って、それに沿ってマージしていくというだけです。こちらも形が Line Graph なので、Path Compression のみを行ってもマージは です。
このように DSU 的な操作を行うことで、線形時間でこの問題を解くテクニックは、考えた人の Handle Name から Arpa's Trick と呼ばれています。
問題
ここからネタバレです。
Repovive Starter Round 2 - F. Pair Increment Ranges
こちらの問題を解いていきたいと思います。
とりあえず問題をまずは日本語に書き直していきます。
2 本の配列 と があります。
ある閉区間 が beautiful であるとは、配列 に対し、ある index を選んで、 から 引いて に 足すという操作を 回以上行うことで、 を満たすことを言います。
beautiful な閉区間の個数を求めてください。 個のテストケースが与えられるので、それぞれに対して答えてください。
Constraint :
が beautiful である条件は
の 2 つが成立することです。 とすると、以上の条件は
に書き換えられます。
を固定して考えます。日本語で説明すれば、自分より右にいて、自分と同じ値で、かつ自分より小さい値が間にないものがカウント対象の要素です。と考えたら、とりあえず末尾から考えていけばよいということが分かります。
を 番目が左端であるような beautiful な閉区間の個数とします。求める答えは です。上の条件を満たすような最小の があったとしたら、 と更新すればよいです。なければ、ないので です。 じゃあその をどうやって探すのかというのが問題です。
というのは皆さんもおなじみかと思いますが、これを言い換えると、 となります。ということは、次の配列を考えてみるといいのかもしれません。
どちらも、集合が empty な場合は を代わりに入れます。
の場合、その場所を とすると であるので です (1)。逆に、 の場合、 とすると ですので、 であり、間に自身より小さい値は存在しません (2)。よって先述の が求められたというわけです。

では、 をどう求めるかという話ですが、この集合の中身は Arpa's Trick の説明で書いたものとほぼ同じです。そのため、条件に注意しながら Arpa's Trick を使えば高速に求めることができます。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
void solve();
int main() {
int t; cin >> t;
while (t--) solve();
}
void solve() {
int n; cin >> n;
vector<ll> a(n), b(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}
for (int i = 0; i < n; i++) {
cin >> b[i];
}
vector<ll> d(n + 1, 0);
for (int i = 0; i < n; i++) {
d[i + 1] = d[i] + (a[i] - b[i]);
}
const ll inf = numeric_limits<ll>::max() / 4;
d.push_back(-inf);
vector<ll> dp(n + 2, 0);
vector<ll> next0(n + 1, 0), next1(n + 1, 0);
ll ans = 0;
for (int i = n; i >= 0; i--) {
next0[i] = i + 1;
while (next0[i] != n + 1 && d[i] < d[next0[i]]) {
next0[i] = next0[next0[i]];
}
next1[i] = i + 1;
while (next1[i] != n + 1 && d[i] <= d[next1[i]]) {
next1[i] = next1[next1[i]];
}
if (next0[i] == next1[i]) dp[i] = 0;
else dp[i] = dp[next0[i]] + 1;
ans += dp[i];
}
cout << ans << endl;
}
おわり
明日のブログリレーは @phi_isct さんです。
参考にした資料
https://cp-algorithms.com/data_structures/disjoint_set_union.html
https://codeforces.com/blog/entry/143633
https://repovive.com/contests/10/problems/F
Harold N.G., Robert E.T., (1983) A linear-time algorithm for a special case of disjoint set union, https://doi.org/10.1016/0022-0000(85)90014-5