えびちゃんの日記

えびちゃん(競プロ)の日記です。

要素の追加・削除と mex を対数時間で処理するよ

コンテスト中に見に来た人へ:コンテスト後、この記事にある方法以外についても復習しておくのをおすすめします。


自然数(\(0\) を含むよ)の集合において、mex というのは、minimum excludant (minimum excluded) の略で、それに含まれない最小の自然数のことです。

以下の方針では、もう少し自由なことができます。 整数の集合に対して「\(n\) 以上であって、集合に含まれない最小の整数は何ですか?」という形式のクエリを処理します。

tl; dr

区間set<pair> で持つやつ。

アイディア

データの持ち方

整数の集合を考えます。例として \(\{-3, -2, 0, 1, 2, 3, 7\}\) を考えましょうか。 これを、愚直に管理するのではなく、一続きの整数を区間として管理します。 [-3, -2, 0, 1, 2, 3, 7] ではなく、[(-3, -2), (0, 3), (7, 7)] のようにということですね。

区間の始端と終端をペアで持っておきます。閉区間か半開区間のどちらを選ぶかは状況に応じて好きにするといいと思いますが、ここでは閉区間とします。

これを、順序つき辞書(C++ で言うところの std::set、Rust で言うところの std::collections::BTreeSet)で管理します。 順序は、ペアの辞書順で定義されるとします。

クエリ処理

まず、x が集合に含まれるかどうかの判定を先に書いておきます。 x が含まれる区間があれば、(x+1, x+1) 未満で最大の区間なので、辞書でそういう操作をします。 必要に応じて、(-inf, -inf)(+inf, +inf) を番兵として入れておくと楽かもしれません。

x を追加する(かつ x が含まれていない)ときは、挿入されるべき箇所の両隣を見て、x-1x+1 があればそれらとくっつけます。 上の集合に 4 を加えると [(-3, -2), (0, 4), (7, 7)] に、さらに -1 を加えると [(-3, 4), (7, 7)] になります。

x 以上で含まれない最小要素は、x を含む区間(なければ x が答え)を持ってきて、その終端に 1 加えたものになります。

x を削除するのも考えるとできます。 条件を考えて追加の逆っぽいことをします。

実装

貼ると、HHKB 2020 C を解けます。

C++ です。 atcoder.jp

Rust がいいですか。 atcoder.jp

Further Queries

さらに、区間追加クエリもできます。それはそうって感じだね。

ならし対数時間になりますが、「x を追加してね」だけではなく「l 以上 u 以下の整数を追加してね」というのもできます。 {2, 4, 6, 8, ..., n-1} のような集合に「1..=n を追加してね」と言われるような場合では一回の処理は \(O(\log(n))\) 時間ではないですが、ならせば大丈夫です。

あとは、区間削除とかもできます。 当然、x を含む区間とかが必要になる問題ではそれも求められます。

こういうの とかが解けますね。

RUQ (for verification)

(追記:2020/11/19)

ここ、mex は関係ないです。verify のための補足です。

この形式で区間の集合を扱うやつを用いて、RUQ を処理できます。 配列 \(a\) に対して、次の処理を行います。

  • 区間 \([s, t)\) の値を \(x\) にしてね
  • \(a_i\) の値を答えてね

答えの上限を \(2^w -1\) とします。リンク先の問題では \(w=31\) です。 区間の集合を \(w\) 個用意します。

\(x\) の \(j\) bit 目が \(1\) のとき、\(j\) 個目の集合に \([s, t)\) を追加します。 \(0\) のとき、\(j\) 個目の集合から \([s, t)\) を削除します。

\(a_i\) の値は、「\(j\) 番目の集合が \([i, i]\) を含むなら \(2^j\)、含まないなら \(0\)」の各 \(j\) における総和です。

最悪計算量はクエリあたり \(O(\log(n)\log(w))\) ですが、実際には各集合の要素数は \(n\) より小さいことも多いので、必ずしもこれだけかかるとは限りません。

その他 verify とか使い道とか

(追記:2021/01/08)

いろいろあると思います。思い出したら足します。

ねこちゃん

ねこちゃんなので。

vector の多次元版をつくるやつ

自分が楽に作れるようになると忘れがちなんですが、どうやらこれを書くのに疲弊する人がまだいるようです。

size_t n1, n2, n3;
// ↑入力を受け取るとかして、値が入るとする
vector<vector<vector<int>>> v(n1,
  vector<vector<int>>(n2,
    vector<int>(n3, x)));

みたいに書くのはさすがに大変だと思います。 タイピングに自信があっても、競技中にこんなのを書く時間があるなら考察をしたいでしょう。

C++17 なら以下のように書けますが、だとしてもまだ長いです。

vector v(n1, vector(n2, vector(n3, x)));

あれ、長いですか? 別にいい気がしますね...(おわり)

書く予定だったこと

auto v = make_vector({n1, n2, n3}, x);
auto v = make_vector<int>({n1, n2, n3});  // 型だけ指定したいとき

auto v = make_vector(n1, n2, n3, x);
auto v = make_vector<int>(n1, n2, n3);  // 型だけ

のように書けるとうれしい気がしたんですよね。

前者は えびちゃんのC++17 未満でも動く版)、後者は すいばかさん・びーとくんの です。 えびちゃん的には、サイズに関する引数が見やすいので前者の方がすきです。 後者は、(値指定と型だけの)二つを両立させられない気がしますが、前者はできます。

(えびちゃんのをコピペしようとする人へ:size_t を常用する人でなければ、関数の中の size_tint に直して使うといいと思います)

運用でカバーすればどうにでもなるので好きな方を使えばいいと思います(タイプ数とかは後者のが短いし)。 型だけ指定したいというのは、初期値は入力から受け取りたいとかを想定していますが、以下のどちらかで対応できるので。

  • 所望の型の適当な値を入れておく(0LL とか 0.0 とか)
  • 入力をするパートまで行う用の関数は別で作っちゃう
    • make_vector_from_input<double>(n1, n2, n3) みたいなこと

生配列使えば?

固定長で大きめのを作る実装だと、「最大ケースでは配列外参照するがサンプルでは気づかない」というのがよくあるので、きらいです。 それでもしたい人はすればいいと思います。

ところで

Rust だと、自前で何かを定義しなくても

let mut v = vec![vec![vec![x; n3]; n2]; n1];

と書けます*1

macro_rules! mulvec {
    ($x:expr; $s:expr) => {
        vec![$x; $s]
    };
    ($x:expr; $s0:expr; $( $s:expr );+) => {
        mulvec![vec![$x; $s0]; $( $s );+ ]
    };
}

とか書けば、

let mut v = mulvec![x; n3; n2; n1];

とかも書けます。Rust のマクロは C++ よりも強力なのでいろいろと便利です。 C++ でよくあるマクロの罠にも苦しめられない上、構文もある程度自由です。 「この文字を区切り文字にして任意回繰り返してねー」というのを指定したり、構文を複数用意しておいて「こっちの構文で呼び出されたらこう展開して、こっちならこうしてねー」みたいなこともできます。

デバッグ出力もいくつか用意されていて、自前で何かを書かなくてもいいです。

eprintln!("{:?}", v);
eprintln!("{:#?}", v);  // 出力形式がちょっと変わる
dbg!(x);  // 行数とかも出力してくれる

ねこちゃん

おわりです。

C++固執したがる競プロ er が Rust を書きたくなりそうな機能の大喜利みたいなのがあるとたのしそう。 人によって違いそうだけど。

*1:これ実は C++17 で書けるやつとあんまり変わらないですね。

初心者向けバグ取り

「バグったたすけてー」と言っている初心者の人のコードを見たとします。 バグを見つけてあげるのは(多くの場合)簡単ですが、それを伝えただけでは成長につながらないかなぁとも思います。

「こういうところをこうやって確認したよ」「そしたらこうなったので見つかったよ」みたいなことを教えてあげるといいと思ったのですが、毎回やると面倒なので、記事にまとめます。 あくまでえびちゃんの方法なので、他の人は違うことをしているかもしれません。

最近あまり他人のデバッグをしていないので、記憶と妄想で書いているところはあります。 追記したいことが発生し次第、追記しようと思います。


記事の本編では C++ を仮定します。

Python の人向けデバッグ

  • numpy か何かの演算でオーバーフローしている
  • 整数の切り捨て除算を浮動小数点数を介していて誤差が生じている
  • その他、あきらめる

他人のコードのデバッグをするときは、まず #define int long long をしているかどうかを確認しておきましょう。 オーバーフローしていそうな箇所を見つけてから「実は long long でした」というのはかなり嫌な気分になるので。

前提

一応 verdict ごとに分けて考えますが、「配列外参照(未定義動作)の結果、TLE が発生した」とかはありえる話なので、「TLE なんだから配列外参照とかではないはず」のような決めつけはいけないですね。

あと心構えの話なのですが、「1 ケース WA だから少し変えるだけでいいはず」のような考え方はやめた方がいいと思います。全くの嘘解法だった場合に気づくのが遅れる原因なので。 逆に言えば「全ケース WA だから少し変えても通らないはず」というのもよくないです*1

とりあえず、まず、コンパイラの警告を見ます。「初期化されてないよ」とか「関数の返り値がないよ」などは、そこが本質的な場合もあるので。 そこが問題なければ、verdict に従い、典型的なミスを探していきます。

確認することたち

TLE

多くの場合は、計算量の見積もりを間違っているんだと思います。

_GLIBCXX_DEBUG

これはあくまでデバッグ用の機能であり、関数によってはオーダーが悪化することもあるので、(わかっていてそうする場合を除いて)提出コードで書くのは避けましょう。 なんのことかわからない人へ:

rsk0315.hatenablog.com

コンテナの無駄なコピー

関数に vector などのコンテナを渡すとき、以下のように書くと、関数を呼び出すたびにコピー(サイズに対して線形時間)が発生します。

int f(vector<int> v) { ... }

関数の中で線形時間の操作をしているなら定数倍の違いしかないので問題がないことは多いです*2。 そうでない場合(一部の要素だけを見て終わる場合など)は、オーダーが悪化してしまうので、以下のように直しましょう。

int f(vector<int>& v) { ... }

v を書き換えないのなら、次のように書き換えてもいいです。

int f(vector<int> const& v) { ... }

イテレータの操作

set<int> s;
lower_bound(s.begin(), s.end(), x);

これは線形時間なんですね〜。

rsk0315.hatenablog.com

WA

mod が間違っている

1000000007 なのに 998244353 を使っているとか、998244353 を書き間違っているとかです。 他のミスに比べてすぐ確認できるはずなので、すぐします。

毎回あまりを計算するたびに %= 998244353 とか書いている人がいたらやめましょうね(確認が面倒なので)。 適当に定数を宣言して、それを使いましょう。

const int mod = 998244353;

大文字小文字が間違っている

これもすぐですね。 YESYes とか、そういうやつです。 特に、場合分けが複雑で、複数回書かされる場合は注意が必要です。

オーバーフロー

掛け算しているところを一通り確かめます。

ある数で割ったあまりを求めるとき、a * b * c % mod だと、a * b * c の時点でオーバーフローするとだめです。 また、c がとても大きいときは a * b % mod * (c % mod) % mod のようにする場合があるでしょう。制約を見ながらちゃんと考えます。

足し算でオーバーフローする問題はあまりない気もしますが、これも気をつけましょう。

精度

浮動小数点数は無限精度ではないので、期待通りに動かないことが多いです。 悪いのは浮動小数点数ちゃんではなくて、変な期待をする人です*3。 たとえば、double で \(2^{53}+1\) などを計算してみましょう。偶数が出力されてびっくりです。

精度が足りなそうなのに過信したようなコードが書かれていたら、そこを注意深く見ます。 該当しそうな問題としては、ルートのやつ とか、掛け算のやつ とかがありますね。

off-by-one エラー

添字などが +1 や -1 の書き忘れや不要な書き足しなどで間違いになっているものの総称です。 極端なケース(サイズが最小の場合、添字が最大の場合など)を考えると、おかしいときは気づきやすいことが多いです。

こういう記事もあります: rsk0315.hatenablog.com

変数名の間違い

xa xb ya yb みたいなのがたくさん出てきてあれこれするコードは、使い間違っていても気づくのが大変です。 出現回数が同じであるべき変数名の出現回数が違ったら「おかしいな」とは思いますが、そうでないと面倒です。いやですね。

謎貪欲などの嘘考察

自分では気づきにくいことが多いです。 あとで紹介する方法と併せて、小さいケースで手で確認すると、「たしかにこの貪欲はおかしいね」となれる場合が多いです。

RE

配列外参照

配列サイズはちゃんと確認しましょう。 処理が複雑な場合は、「アクセスすることになる添字に対してサイズは十分か?」みたいなことを考える必要があると思います。

ゼロ除算

除算のたびに、割る数がゼロではないか確かめても十分なくらいです。数学でそうするのと同様です。 割る数が x-1 のような場合、x1 にならないか確かめるとかですね。 これ とかです。

それ以外

あんまりなさそうです。 ロジックが間違っていて、再帰が無限に発生しているとかはあるかも?

だめだった場合

C++ の仕様の勘違いで間違っているとかはあるかもしれません。 暇だったら これ とか これ とかを見てがんばればいいと思うんですが、そうも言ってられない場合の方針を示します。

愚直解を書きます。 ここで愚直解が正しく書けないとどうしようもないのですが、そこはがんばってほしいです。

ここで言う愚直解というのは、たとえば、「制約が \(n \le 10\) だった場合に書くコード」のようなものを考えてください。 bit 全探索をするとか、8 重ループを書くとか、全順列を next_permutation で試すとか、いくらでもやりようはあるはずです。

...というのを書きます*4

次に、テストケースを自分で生成するコードを書きます。 乱数を発生させるのが大変だったりしますが、手軽には、以下のようなものが考えられます。

#include <iostream>
#include <random>

std::random_device rd;
std::mt19937 rng(rd());

int randint(int lo, int hi) {
  // lo 以上 hi 以下の乱数を返す
  return std::uniform_int_distribution<int>(lo, hi)(rng);
}

int main() {
  // 10 以上 20 以下の乱数を出力する
  std::cout << randint(10, 20) << '\n';
}

こういうのを駆使しながら、該当する問題のフォーマットに沿ったケースを生成するコードを書きましょう。

あとは、これを使って、元々の自分の書いたコードの出力と不一致になるケースを探します。

online-judge-tools を知っていますか? それを導入するのが一番手っ取り早いはずです。 pip3 が使えるとして、以下のコマンドを実行しましょう。

$ pip3 install --user online-judge-tools

online-judge-tools.readthedocs.io

たとえば、元々の解法の実行ファイルが ./X、愚直が ./X-naive、ケースの生成器が ./X-generator だとします。 以下のコマンドを実行すると、不一致になるケースが見つかるまで勝手にがんばってくれます。

$ oj g/i -c ./X-naive --hack ./X ./X-generator

見つかると、そのケース(入力と、想定される出力、それから誤った自分の出力)が出力されるので、それを見ながら、手や頭を使って考えます。 「どう考えてもおかしい考察じゃん」とか、「手では合ってるんだから実装がおかしいはずじゃん」とか気づくはずです。 後者なら、適宜 print デバッグ*5などをするなりして、バグを探してください。

人によっては(chokudai さんとかね)、解法のコードにテストを直接書き込んだりする方法を紹介しています が、えびちゃん的にはあまりおすすめしないです。 テスト部分の消し忘れで入力を読み間違えたとか、愚直の方が実行されるまま提出してしまったとか、そういうミスが懸念されるためです。 というかミスをしないにしても、「テストがうまくいった」の後の「提出用に書き直す」の手間を避けたいです。

前述の方法であれば、./X はテスト用と提出用で書き換える必要がないので、提出が楽でよいです。 提出するのはテストに通ったコードそのままなので、書き直しミスとかを心配する必要もありません。

それでもだめだったら

そんなことある? 自分でコーナーケースっぽいものを考えて、愚直解と比較したらいいんじゃないですか?

小さいケースでは発生しないバグとかもあるとは思います。えーーー、うーん、だったらもう少し愚直を改良して大きめのケースで試せばいいんじゃないですか? 大きいケースだとどう間違っているか調べるのが大変だったりしますが。

あーあと、「こんなの愚直の書きようある? どうしようもなくない?」というのは、どうしようもなさそうです。あきらめて。 構築系の問題とかはある程度大変かもしれませんね。

おわり

AC おめでとう。

ねこ

たぶん、えびちゃん自身は、off-by-one と嘘考察以外だと、上記に当てはまるコードはそもそも最初から書かない気がします。 嘘考察の種類は人によってある程度違うと思うので、「自分が考察の際に漏らしやすいこと一覧」みたいなのをまとめておくといいんじゃないですか? たぶん自分で書くことにも意味はあると思います。

そういえば、ICPC に初めて出たときは、ライブラリに「やりがちなミス一覧」みたいなのを載せてた気がします。

*1:構築で、出力の行数を書き忘れていたり、Yes を書き忘れていたりなど。サンプルも落ちるのはおかしいということで見直すべきですね。

*2:だとしても、わざわざ定数倍の重い書き方をするメリットは、多くの場合無いはずですが。

*3:思想が出ています。

*4:コンテスト後であれば、他人の適当な AC コードを持ってくるとかも考えられますが、訓練だと思って自分で書いてください。自分で書くのに慣れていればコンテスト中に困っても対応できるので。

*5:変数を適当な箇所で出力して、自分の想定通りか確認する方法です。

C++ のコンテナやイテレータの罠

C++ を使うのが悪くて、Rust を使えばいいんじゃないですか?

これ系の記事を乱立させていて申し訳ないです(これこれの話ってどの記事?となりそうなので)が、罠が多い C++ 側に責任があるので、えびちゃんは悪くありません。 それに、一つ長い記事を書いたとしても人々は読まないと思うので。追記したときに差分を探すのも大変そうですし。

イテレータとはなんですか

イテレータをちゃんとは知らない人への説明を書きます。 ある程度 C++ のコードを書いたことがあるくらいのことは仮定しちゃいます。 この仮定がだめだったら、別で記事を書いてリンクを貼ります。

vector などのコンテナについて、その要素を指しているものです*1。 基本的には「次の要素を指して」「指している要素の値を教えて」のような操作ができます。

sort(a.begin(), a.end()); のように書いたことがあると思いますが、この a.begin()a.end() などがイテレータです。 「a.begin() が指す要素から a.end() が指す要素(の直前)までをソートしてね」ということです。

書きました。書いたことにします。

イテレータの無効化

イテレータを取得して変数に入れたりしますね。

auto it = a.begin();
++*it;
cout << *it << '\n';

これなんですが、コンテナ a 自体に特定の操作をした場合、イテレータ it が無効になることがあります。 無効になったイテレータで操作した場合の動作は未定義です。

「未定義動作ということは実行時エラーになるんですか?」「これ見てね

特定の操作というのが何かというのをいくつか紹介します。 コンテナの種類によって違いますが、コンテナ自体の実装に思いを馳せると想像がつくものも多いと思います。

無効化する操作

erase

次のように書くと、そのイテレータが指す要素を消すことができます。

auto it = a.begin();
a.erase(it);

消してしまったら無効になり、使うことはできません。 *it はもちろんのこと、++it などもできません。

メモリに値が残っていると、たまたまうまくいったように動くことはありますが、未定義動作とはそういうものです。

vector::push_back

まず、vector がどう実装されているか考えてみますか? 動的な確保とかどうやっているんでしょう。 サイズ \(i\) のとき \(i+1\) 個の領域を新たに \(\Omega(i)\) 時間かけて確保するのを毎回やっていたら全体で \(\Omega(n^2)\) 時間になってしまいそうですね。

実は vector には size 以外に capacity という概念があります。確保している領域の大きさです。 size == capacity のときに追加しようとすると領域が再確保されます。 元の capacity の 2 倍を新たな capacity とする実装が有名だと思います。 実はこれで償却 \(O(1)\) 時間になっているんですね〜*2

で、push_back の際にこの再確保が起きると、全部の要素へのイテレータが無効になってしまいます。 起きたかどうかは capacity が変わったかどうかを見るとよいです(a.capacity() のように呼び出します)。 また、予めどのくらい増えるかがわかっているのであれば a.reserve(n) とすると capacityn にできるので、増やす前に呼んでおくとよいです。

追記:capacity が変化しない場合、.end() のみが無効化されます。

vector::insert

あまり知られていないかもですが、vector には任意位置に要素を挿入する機能があります。 計算量は、挿入位置以降の要素数に対して線形です*3

挿入位置以降の要素をずらしていくので、挿入位置以降の要素へのイテレータが無効化されます。 特に、.end() も無効化されます。

en.cppreference.com

それ以外の操作は、これの Iterator Invalidation の表を見るとよいです。

コンテナの罠

multiset前に書いた からいいですね。

set 系のイテレータの話も 書きました

map も罠があります。[] でのアクセスは const ではありません。 map の中にあるキーが入っているかを確認するときに、次のように書くことがあります。 ただし、キーが入っているとき、それに結びつく値は 0 でないとします。

map<int, int> a;
if (a[x] == 0) 入っていない;

a[x] += y のように書くのができるように、キーが入っていなければデフォルト値で挿入されて、それが返ってくることになっているんですね〜。

それよりも、次のように書くのが無難でしょう。

map<int, int> a;
if (a.count(x) == 0) 入っていない;
if (a.contains(x)) 入っている; // C++20 から使える

あるいは次のようなことも書けます。C++17 からは if にも初期化文が書けるんですね〜(それ以前なら if の外に書いてください)。

map<int, int> a;
if (auto it = a.find(x); it != a.end()) {
  入っている; // it->second を書き換えたりできる
}

2 つめと 3 つめは要素数が変化しません。

\(n\) 個の要素が入っている map に対して、\(n^2\) 回ループをしてこの処理を行うことを考えてみましょう。 (これは雑な議論ですが)\(\log(n^2) = 2\log(n)\) なので、書き換えないものと比べて 2 倍ほど余分な時間がかかってしまいます。 map 自体が(ポインタ系のデータ構造ですし)定数倍が重いので、なるべくなら定数倍は軽く書きたいです。

余計な要素が挿入されるのを避けたいのは別の事情もあって、後述します。

要素が入っていることがわかっているときは a.at(x) でアクセスすると const なアクセスができます。

range-based for

これ〜〜。ループ中でコンテナに(主にサイズの変更が伴う)操作をすると大抵こわれます。

for (auto ai: a) {
  ねこ;
}

のようなやつです。ちゃんとした説明はリンク先とかを見てね。

en.cppreference.com

C++17 では以下のように書くのと等価でしょう(__ を含む識別子は我々用のものではないです)。

{
  auto&& __range = a;
  auto __begin = __range.begin();
  auto __end = __range.end();
  for (; __begin != __end; ++__begin) {
    auto ai = *__begin;
    ねこ;
  }
}

set で消すやつ

ループ中で a.insert(ai) などを書くと、__begin が無効になってしまいますね。 ということは、++__begin ができなくなるので、だめそうです。 諦めて次のように書くのがよさそうです。

for (auto it = a.begin(); it != a.end();) {
  if (消したい) {
    it = a.erase(it);
  } else {
    ++it;
  }
}

set で追加するやつ

ループ中で a.insert(x) を行い、今の __begin__end の間に要素が入ると、ループの計算量が想定と変わってしまうことがありそうです。 具体的には、a の最大値 +1 などを挿入すると、__end に到達できなくなりそうです(実際には int などは有限なので、オーバーフローでこわれそうです)。

set では、insert をしてもイテレータが無効にはならないので、そこでの未定義動作は大丈夫そうです。

vector で前に入れるやつ

ループ中で先頭に要素を追加する状況を考えます。 いや、そんなことは滅多にしないと思いますが、こわすのは滅多にしない操作をしたときだと思うので、一応考えましょうね。

vectorinsert では、挿入位置以降が無効化されるので、__begin がこわれて、未定義動作です。 __begin と言っていますが、現在指している要素ですね。

vector で後ろに入れるやつ

ループ中で push_back などを呼ぶ状況を考えます。

capacity が十分であれば問題ないはずです。 十分でなければ未定義動作でしょう。

__end はループ前に固定されるので、capacity の変化しない push_back では無効化されません。 また同様の理由で、ループ中に追加された値がこのループで走査されることもありません。

追記:ここ嘘で、.end()push_back で無効化されるので未定義動作だと思います。

map[] をするやつ

これ自体は未定義ではないですが、set の項目と同じ理由で無限ループになりえます。 なにが追加されているかに注意して書くか、別の書き方をしましょう。

おわり

そすうさに感謝

本番でそすうさが事故らせたらかなしい

*1:past-the-end などは「要素」は指していませんが...。

*2:最悪時は \(O(1)\) 時間ではないが、\(m\) 回目までの時間の合計は \(O(m)\) であるということ。

*3:再確保が発生した場合はその分の時間がかかると思います。

最近のえびちゃんの話

興味のある人だけ見てくれたらいいです*1。 えびちゃんが最近なにをしているかを書きます。

まず、競プロをほぼしていません。 ABC に出るくらいはして、下位層の人にはまだ勝てますが、半引退みたいな状態になっています。

お勉強はしています。 Rust を始めようと思いました。これ を読んでいます。 ライブラリが気に食わなくなってきたんですが、どうせ書き直すなら C++ にこだわる必要がなくないか?と思ってしまったのが理由として大きいです。 半引退状態ではあるものの、ライブラリ整備自体はすきなので。

他にも、適当なトピックを選んでお勉強したり、まとめノートを書いたりしています。 えびちゃんでない名義で書いちゃったので、えびちゃんでない名義で公開するかもしれません(どうしよー)。 名義が違うと、書いているときの気分が少し違って面白いです。

あとリステをまた見ました。ニコ生で 一挙放送 してたので。

さっき記事を書きました。 実は昔の記事(未定義のやつとかイディオムのやつとか)もたまに追記したりしています。 ほんとはもっと記事を書きたいです。承認欲求が満たせるので。

えーー、あんまりいろんなことしてないですね。 もっといろんなことをしたい感じがあります。

*1:ほぼ全てのことに言えますが。

添字や境界条件で毎回バグらせてる人かわいそう

off-by-one エラーとか呼ばれるやつです。「1 ズレてたせいでこわれた」というやつですね。

バグらせやすい書き方をするのがよくないです。書き方を改めるのがよいでしょう。

ここでは、えびちゃんがよくやっている書き方を紹介しますが、別にこれが絶対というわけではないです。 「私の今の書き方が私にとっては世界一書きやすい。私はもう真理に達した」という人はすきにしてください。

バグらせにくい書き方をまだわかっていないとか、コンテスト中にそこでバグらせて不本意な結果になったとか、そういう人は考え直すのをおすすめします。

(追記:2021/01/05)あと、何も考えずに AC するまで [i] [i+1] [i-1] などを適当に書き換えるような癖はつけない方がいいと思います。

累積和

配列 \(A = [a_0, a_1, \dots, a_{n-1}]\) に対して \(\sum_i a_i\) たちを並べた配列 \(S\) ですね。

よくないと思う実装

\(S = [s_0, s_1, \dots, s_{n-1}] = [a_0, (a_0+a_1), \dots, (a_0+a_1+\dots+a_{n-1})]\) とするのはおすすめしません。

以下に理由を示します。 よく使う例として、\(a_l+\dots+a_{r-1}\) を求めたい局面を考えてみましょう。 これは \(S_{r -1} - S_{l -1}\) でいいですか? いいえ、\(l = 0\) のときにこわれてしまいますね。 \(S_{-1}\) は定義されていないからです。

和を求めようとするたびに、一々場合分けをするのは面倒ですね。 面倒なことは忘れがちで、バグの原因になりがちです。

いいと思う実装

\(S = [s_0, s_1, \dots, s_n] = [0, a_0, (a_0+a_1), \dots, (a_0+a_1+\dots+a_{n-1})]\) とするのがおすすめです。 上の例では、\(S_r -S_l\) とするだけでよいです。

これを「累積和のときだけは 1-indexed にして、\(s_1 = a_1, s_2 = (a_1+a_2), \dots\) だ」と捉える人がいますが、そう解釈するのは余計に混乱します。

むしろ、0-indexed の \(a\) において、半開区間 \([0, i)\) の和が \(s_i\) と考える方がよいです。 \(s_0\) は \([0, 0) = \emptyset\) の和で \(0\)、\(s_n\) は \([0, n)\) の和、つまり全体の総和です。

先頭に \(0\) を挿入してから累積させるように書いています*1

std::vector<int64_t> a(n);
for (auto& ai: a) std::cin >> ai;
a.insert(a.begin(), 0);
for (size_t i = 1; i <= n; ++i) a[i] += a[i-1];

両側からの累積和

これも半開区間で考えます。 後ろからの累積和を \(T = [t_0, \dots, t_{n-1}, t_n] = [(a_0+\dots+a_{n-1}), \dots, a_{n-1}, 0]\) とします。 つまり、\(t_i\) は半開区間 \([i, n)\) における和です。

よくある使い方として「\(a_i\) だけを \(x\) にしたときの和」がありますね*2。 半開区間 \([0, i)\) の和と \([i+1, n)\) の和がほしいわけですから、\(s_i + x + t_{i+1}\) を求めればよいです*3

配列の両端の \(0\) と \(n\) は固定されているので添字には持ってこず、動ける端(上の例での \(i\))を添字に持ってきているわけです。

二分探索

よくない気がする実装

下の方にあるめぐるちゃんのツイートを見てください。 どうしても閉区間で管理せざるを得ない状況などでは、これを強制される場合があるかもしれません。

64-bit 整数すべての値が探索範囲の場合とか? いや、最大値だけ別で判定してから、必要に応じて半開区間で処理するべきでしょうね。

よい気がする実装

次のようなテンプレを使っています。

int64_t ok = OK_INIT, bad = BAD_INIT;  // ok < bad を考える
while (bad-ok > 1) {
  auto mid = (ok+bad) / 2;
  // f(mid) は、mid で大丈夫のときに true を返すとする
  (f(mid)? ok: bad) = mid;
}

ループの後、ok には f(x)true であるような x の最大値が入っています。 bad == ok+1 です。

さて、これは考えれば当然なのですが、f(BAD_INIT)false である必要があります*4f(BAD_INIT)true なら、得られた値 ok== bad-1)が最大値ではなくなってしまうためです。

また、ループ継続条件を考えると、bad == OK_INIT+1 の場合や ok == BAD_INIT-1 の場合はループが回りませんから、mid の引数の範囲は OK_INIT+1 以上 BAD_INIT-1 以下とわかります。 f の引数の範囲としては、ここだけを想定すればよいということです(逆に言えば、この範囲外だと判定が行われてくれないということです)。

特に、0 でも ok か bad か判定したいのに下限が -1 ではなく 0 になっていると、失敗しそうです。

探索範囲を雑にやるとこわれる話

判定関数の中で乗算をする場合や除算をする場合は、雑にやるとこわれる場合があります。 前者はオーバーフロー、後者はゼロ除算が発生しうるためです。

他にも、配列外参照などが発生する場合も考えられますね。

こういうところは無思考で押し進めようとするとこわしやすいので、ちゃんと気にするようにしましょう。 多くの場合はすぐ済みますし、済まなかった場合は考えるに値するはずなので。

「常に下限は -1e18 で上限は 1e18 にしておけば絶対問題ない」のような思考停止の仕方はよくないと思っています。

(追記:2021/01/05)

めぐる式

  • 半開区間であること
  • 探索区間hilo ではなく okbad のように判定結果で命名すること
  • bad-ok > 1 ではなく abs(bad-ok) > 1 と判定すること
    • 大小関係がどうであれ書き換えなくてよい

半開だけを以って「めぐる式」と言っている人をよく見かけますが、めぐるちゃんに怒られないか心配です。

実数の場合

(追記:2021/01/05)

これは添字とかは関係ないのですが、関連する話題な気がするので。

ここに 書いてます。

尺取り法

落ちついて書けば、特にバグらせる要素はなさそうに思いますが、なぜかバグらせる人は多いですね。

条件を満たす最長の区間を求めていきたいです。 右に伸ばしていく方が自然*5に思えるので、そうします。 左端を固定したとき、ある条件 f(nya)true になる区間の右端はどこまで伸ばせるかを知りたいです。

neko nya;  // [il, ir) における値を管理する。
for (size_t il = 0, ir = 0; il < n; ++il) {
  if (il > ir) ir = il;
  while (ir < n && f(nya + a[ir])) {
    nya += a[ir++];  // 右に一つ追加する
  }
  ... // [il, ir) は valid な区間なので、所望のことをする
  if (il < ir) nya -= a[il];  // 空区間でなければ左側を一つ捨てる
}

逆元を仮定したような書き方をしましたが、捨てられる要素は追加した要素だけなので、SWAG を使う場合でも同じ書き方でできます。 要するに、「il > ir だけど [il, ir)区間の値として辻褄が合う値」のようなものを仮定しなくてよいということです。

ポイントは、左端を固定したいので、for の各ループで il が固定されるようにして考えるということです。 while を使って一度に二つの変数を気にしようとするより、for の方が「固定されている感」を感じやすいです(主観かもしれません)。

逆に、ある条件を満たす最短の区間を知りたいときはどうしましょうか。 条件を満たさない最長の区間がわかれば(適当な足し引きによって)求められますから、判定の真偽を反転させて、上のテンプレを使うのがよいでしょう(たぶん)。

合わせて読みたいながたかなの記事

リンク先の記事で最初に出てくるのが「辻褄が合う値」を仮定するやつで、最後の方に出てくるのが仮定しないやつです。

DP の添字

これも累積和のときと似ています。というか累積和は実質 DP みたいなところがあるので。

\(\mathrm{dp}[i]\) を「\(a_i\) を \(i\) 番目まで見たときのなにか」と思うと、\(a_i\) の indexing によって意味不明になったり、処理前(初期状態)をどこに入れるべきかわからなくなるので、避ける方がよさそうです。

\(a_i\) の添字として考えるのではなく、「前から \(i\) 個処理した」とするとよいです。 初期状態 \(\mathrm{dp}[0]\) は \(0\) 個処理された状態で、最終状態 \(\mathrm{dp}[n]\) は \(n\) 個処理された状態です。

\(i\) 個処理した状態である \(\mathrm{dp}[i]\) と、0-indexed で \(i\) 番目の \(a_i\) から、\(i+1\) 個処理した状態である \(\mathrm{dp}[i+1]\) を計算できます。 そもそも、0-indexed での \(i\) 番目というのは、自分より前に \(i\) 個の要素があることと同じですね。

混乱しそうになったら、\(i\) が \(0\) や \(1\) の場合などを考えるとすぐわかります。

使わない値について

使わない値を保持しておくのがバグ(を発見しにくくする原因)につながることもありますが、適切に使うとむしろ実装が簡単になります。 どういうときはどうするのがいいか、暇なときに一度考えてみてもよいでしょう。

たとえば、セグ木において [0] の要素は使わない方が演算数が減って簡単に書けますね。 他にも、Eratosthenes の篩を書くときに [0][1] を参照することはほぼないですが、ここを詰めて \(i\) が素数かどうかを表す値を [i-2] に入れようとすると、見通しが悪くなって添字のバグにつながりそうです。

より一般的な方針の話

これは特定のトピックではなく、低難易度帯のよくある実装モノです。

「ループを回したあと、答えの変数に一回ぶん余分に足されているはずなので、一回ぶん戻すことで辻褄を合わせる」みたいな実装方針は避けた方が無難だと思います。 条件の考慮漏れなどで余分に足されていなかったり、デバッグの際に気にする箇所が増えたりするためです。

ループ中で条件をちゃんと気にしながら作る方が見通しがよさそうです(うまくいっていない状態のものより、うまくいっている状態のものの方が想像しやすいため)。

(この話題についてなんですが、具体例が思い出せなかったので、思い出したら追記します。)

尺取り法でバグらせる人はそういう実装をしているかも? どうでしょう。 右端を伸ばしていって、一度条件を満たさなくしてから一つ戻すみたいなことを書く人はいる気がします。

より厳密に従うなら、上で紹介した例も il <= ir を常に満たすべきでしょうが、必要以上に冗長になりそうだったので避けました。

おわり

にゃんこ。 バグらせてつらそうにやつあたりしている人を見るとつらくなるので、そういう人が減ったらいいにゃぁ*6

自分に合った方法をきちんと考えておくことで、本番に {焦って, 無思考で, 考慮漏れで} バグらせることが減るんじゃないかなぁと期待しています。

*1:ところで、この例では可換を仮定して書いているので、そうできない場合は適宜書き換えましょう。

*2:ここでは和と言っていますが、最小値とか GCD とかのように、モノイドの演算を指します。逆元が仮定できないということですね。積と呼ぶこともあるっぽいです。

*3:この式が、\(0\le i\lt n\) のどれにおいても有効であることを確かめてください。

*4:実際には呼び出す必要はないです。(考察の上で)false が返ってきてほしい値であればよいです。呼び出すと未定義動作になるとかを気にせずに設計してよいということです。

*5:これは我々の文字体系が左から右なのと関係していますか? 気になります。

*6:添字でバグらせる奴は競プロをやめてしまえ、という意味ではありません。

リバースイテレータの解説するよ

std::sort(a.rbegin(), a.rend());

みたいなやつです。 便宜上 std::vector のようなものを考えますが、std::set とかについても同様です(random-access モノは除く)。

普通のイテレータ

まず普通のイテレータに触れましょう。

[0][1][2]...[n-1][-]
 ^ a.begin()      ^ a.end()

のように要素を指していると見ることもできますが、次のように境界を指しているとも見られます。

|0|1|2|...|n-1|
^ a.begin()   ^ a.end()

普通のイテレータで、* でアクセスすると、その境界の次にある要素を返します。

    v *it
...|i|...
   ^ it

++it をすると、その次の境界を指すようになります。

     v ++it
...|i|i+1|...
   ^ it

++ で進む方向は、begin 側から end 側です。

逆向きイテレータ

std::reverse_iterator というクラスがあって、普通のイテレータを基にしつつ逆向きに動かすようにしたものです。 a.rbegin() で返ってくるものは std::reverse_iterator(a.end()) です。

v a.rend()    v a.rbegin()
|0|1|2|...|n-1|
^ a.begin()   ^ a.end()

で、逆向きイテレータの進む向きは(通常の意味での)end から begin に進む向きです。 rbegin から rend に進む向きです。

逆向きイテレータ++ では、内部で持っているイテレータに対して -- をしています。

reverse_iterator& operator ++() {
  --it;
  return *this;
}

みたいな感じです。

* では、その境界の次(進む向き)にある要素を取り出します。

          v *a.rbegin()
|0|1|...|n-1|...
            ^ a.rbegin()
reference operator *() const {
  auto tmp = it;
  --tmp;
  return *tmp;
}

のような感じです。

という感じで、++* などがうまく定義されているので、逆向きイテレータを使って区間への操作ができます。

std::sort(a.rbegin(), a.rend());

とすると降順ソートされますが、やっていることに忠実に言えば、「後ろから見て昇順ソートする」です。 STL の関数に渡されてしまえば、逆向きだろうが関係ないので、うまくいってくれます。 * でアクセスするたびに -- が余分に呼ばれるので、std::greater<>() を渡す方法よりもほんの少し遅い傾向にありそうです?(あるいはアクセス順とかも関係ある?)

一方で、*a.rbegin() で最後の要素を取得できることから類推して、最後の要素を消したくて次のように書く人がいそうです。

a.erase(a.rbegin());  // ?

これは、できなくて、a.erase の引数は std::reverse_iterator ではなく a 自身のイテレータである必要があるためです。

a.erase(std::prev(a.end()));
// a.erase(a.end()-1);  // valid if random-access iterator

がんばりましょう。

a.rbegin().base() で返ってくるイテレータは所望のイテレータではないので注意です。 a.rbegin()std::reverse_iterator(a.end()) なので、a.end() が返ってきちゃいます。

おわり

イテレータ自体がそもそもわからないという人はというと...