2020年12月28日月曜日

第五回 アルゴリズム実技検定

リアルタイム受験した。4時間15分で全完。

J - 長い長い文字列

各操作で文字列の長さがどうなるかは先頭からシミュレーションすればわかるので、事前に求めておく。(大きな数になりえるので、適当に上限を決めて$\min$を取る。)

$i$番目の操作で長さが$L_i$になるとする。列$(L_i)$で$X$以上である最小の位置$i$を求めて、その位置にアルファベットがあればそれが答えである。数字の場合は、この操作を行う前の長さを$l$とするとき、$X$文字目は$(X-1) \% l$文字目と一致しているはずである。したがって、$X := (X-1) \% l$として同じことを繰り返せばよい。ただし、$0$文字目は$l$文字目と考える。

L - T消し

TDPCのiwiを思い出して見に行ったらそのものだった。

M - 棒の出荷

最小値を決め打って判定問題を解く。$\operatorname{dp}[x] :=$位置$x$で区切れるならtrue、不可能ならfalseというDPを考えると、適当な区間$[l(x), r(x))$について$\operatorname{dp}[x] = \operatorname{dp}[l(x)] \lor \operatorname{dp}[l(x)+1] \lor ... \lor \operatorname{dp}[r(x)-1]$という遷移になる。コンテスト中はすぐにセグ木を貼ったが、累積和上でDPするとか、($[l[x], r[x])$は$x$について単調なので)スライドさせながらDPするとか、なんでもできそう。

O - 通知

愚直に処理すると、次数の大きい頂点が何回も更新されるときに困るが、次数の大きい頂点はそんなに多くなりえないことに注目する。閾値$D$を決めてそれより次数の大きい頂点とそうでない頂点をわけて処理してみる。以下、次数が$D$より大きい頂点をlarge node、小さい頂点をsmall nodeと呼ぶことにする。

通知クエリを処理するとき、small nodeからは周囲に愚直に通知を送り(small nodeなので送るべき頂点は多くない)、large nodeからは送らずに自身にためておいて、消化クエリが来た時に受け取る側から読みに行きたい(large nodeは多くないので毎回全部調べてもよい)。そこで、3つの配列を管理する:

  • $\operatorname{unread}[x] :=$ small nodeから$x$に送られた通知で未読のものの総数
  • $\operatorname{send}[x] :=$ large nodeである$x$が周囲に送った通知の総数
  • $\operatorname{read}[x][y] :=$ 頂点$x$ がlarge node $y$から受け取った通知で既読のものの総数。

具体的な処理は次のようになる:

  • $x$に対する通知クエリ
    • $x$がsmall nodeなら、$x$に接続しているすべての頂点$y$について、$\operatorname{unread}[y] \mathrm{+}{=} 1$する。
    • $x$がlarge nodeなら、$\operatorname{send}[x] \mathrm{+}{=} 1$する。
  • $x$に対する消化クエリ
    • small nodeからの通知の総数を得る: $\operatorname{unread}[x]$を読んだあと、$0$にリセットする
    • large nodeからの通知の総数を得る: $x$に接続しているすべてのlarge node $y$について、$\operatorname{send}[y]$から既読の通知の総数$\operatorname{read}[x][y]$を引いたものの総和を取る。$\operatorname{read}[x][y] := \operatorname{send}[y]$として、既読を更新しておくこと。

計算量について考える。辺が1つ増えると全体の次数が2増えるので、次数の総和は$2M$である。したがって、次数$B$以上の頂点、つまりlarge nodeは、高々$2M/B$個しかない。クエリあたりの計算量は以下のように評価できる:

  • $v$に対する通知クエリ
    • $v$がsmall nodeなら$O(D)$
    • $v$がlarge nodeなら$O(1)$
  • $v$に対する消化クエリ
    • small nodeからの通知の合計を得るのは${O}(1)$
    • large nodeからの通知の合計を得るのは${O}(M/D)$

結局、全体の計算量は${O}(N+M+Q(D+M/D))$と書けて、$D= \sqrt M$なら${O}(N+M+Q\sqrt M$)。

解説の実装は$\operatorname{read}[x][y]$を頂点$x$がlarge nodeから今までに受け取った通知の数の合計として一次元配列にまとめていた。確かにそれでよかった。

2020年12月26日土曜日

CodeChef - Counting Strings

問題: 与えられた文字列$s$について、左から比較しても右から比較しても辞書順で大きい文字列$t$を数える。

$t$の構成を考えると、2つの位置$i, j (i \le j)$を境界にして、以下のように左から順に5つの部分に分解できる。(2番目と4番目は1文字)

  • $s$と完全一致
  • $s[i] < t[i]$
  • 文字自由
  • $s[j] < t[j]$
  • $s$と完全一致

$N := |s|$とし、0-basedで考える。$f(i) :=$ 文字$s[i]$より大きい文字の数($s[i] = \operatorname{a}$なら$f(i)=25$)とすると、$i, j$が異なる場合の数え上げは$\sum_{i < j} f(i)f(j)26^{j-i-1}$である。あとは$i, j$が一致する場合、つまり$s$と$t$がちょうど1字異なる場合を別に数えて$f(0)+...+f(N-1)$を足す。

上の式を見ると、畳み込める形になっている。$g(x) = f(N-x-1)$として一方の添え字を逆順にすると、$26^{N-2}\sum_{i+j \le N-2} f(i)g(j) (1/26)^{i+j}$と変形できて、$f$と$g$をFFTで畳み込んだ後で$1/26$を掛けながら加算すればよい。$\bmod$は$10^9+7$だが、登場する数が十分に小さい(高々$25^2 \cdot 10^5$)ので$998244353$でやっても問題ない。


よく見ると、変形して累積和を取るだけだった。

$$ \sum_{i < j} f(i)f(j)26^{j-i-1} = \frac{1}{26}\sum_{i=0}^{N-1} \biggl( \frac {f(i)}{26^i} \sum_{j = i+1}^{N-1}{f(j)}26^j \biggr ) $$

形を見て畳み込みできそうと思えるのは悪くないとしても、変数を分離できないかまず考えるほうがさすがによい気がする。

CodeChef - Two Closest

問題: 無向グラフ$G=(V, E)$と$V$の部分集合$S$ (special node)が与えられる。$S$上の任意の2点間の距離で最短のものを求めよ。

方針: すべてのspecial nodeから各点への最短距離を調べるために$K$回ダイクストラする。この際、距離のテーブルは毎回使いまわしてよくて、枝刈りができる。また、special nodeを調べる順番をシャッフルすれば期待計算量が保証できる。

正当性について。今、special node $A_1, ..., A_{i-1}$について二頂点間の最短距離の最小値が求まっているとし、距離のテーブル$d$が$d[v] := v$と$A_1, ..., A_{i-1}$との最短距離の最小値、という状態になっているとする。ここからさらにspecial node $A_i$を始点にダイクストラするとする。この際、頂点$u$から$v$を更新しようとした時に$d[u] + w(u, v) \ge d[v]$であれば、$v$は無視してよい。なぜなら$A_i$から任意の頂点$x$に到達する際の最短経路が$v$を通るとしても、それ以下の距離で$A_1, ..., A_{i-1}$のいずれかから$x$に到達できることになるからだ。

計算量について。$K$回ダイクストラをしている間に頂点$v$がキューからポップされる回数の期待値を考える。$v$がキューからポップされるのは、今調べているspecial nodeと$v$の最短距離が以前調べたすべてのspecial nodeと$v$の最短距離より小さい場合である。したがって、この期待値は$K$個のspecial nodeと$v$との最短距離を$d_1, ..., d_K$とした時、$(d_i)$をシャッフルした列のLDSの長さの期待値に等しく、${O}(\sqrt K)$らしい[1]。したがって、ポップに関する期待計算量は${O}(\sqrt KN \log N)$となる。また、ポップした頂点の隣接頂点を調べてdecrease keyする操作の期待計算量は、二分ヒープなら${O}(\sqrt KM \log N)$となる。 したがって、全体の期待計算量は${O}(\sqrt K (M+N)\log N)$ということになる。

waterfallsさんの別の乱択の方針も面白かった。special nodeをランダムに2分割して、一方を始点としたダイクストラをすると、最短距離を与える二頂点が異なるグループに属している時に正しい答えが出る。そうなる確率は1/2なので、何回もやって最小値を取ればよい。このテクニックは無向グラフの最短閉路アルゴリズムで見たことがある: http://research.haifa.ac.il/~raphy/papers/all-cycles.pdf


  1. この評価については https://www.math.ucdavis.edu/~romik/book/ のp.9に簡潔な証明が載っている。ランダム順列のLISの長さの期待値に関する研究はJ. M. Hammersley, A few seedlings of researchが初出らしい。(後者は読んでいない) ↩︎

2020年12月17日木曜日

JOI2019 本選 C - たのしいたのしいたのしい家庭菜園

左から列を作っていくとして、次にR, G, Yを隣に置くためにはまだ使っていないものの中で一番近いものを持ってくればよい。今までにR, G, Yをそれぞれ何個使ったか保持していれば一番近いR, G, Yがどこにあるかは決まるので、$\operatorname{dp}[x][y][z][c] :=$Rを$x$個、Gを$y$個、Yを$z$個使っていて直前に置いた色が$c$である場合の最小コスト、というDPが${O}(N^3)$でできるはず……だが、それ以降のほうが難しかった。

例えば$\operatorname{dp}[x][y][z][c]$から次にGを隣に持ってきて$\operatorname{dp}[x][y+1][z][G]$に遷移するとき、何回スワップが必要か数える必要があるのだが、どうやって数えるかでしばらく悩んでいた。結論から言えば

  1. $\max(0, 次のGの位置以前にあるRの総数 - x)$
  2. $\max(0, 次のGの位置以前にあるYの総数-z)$

の和が間にあるG, Yの総数に等しく、これが必要なスワップの総数に等しい。

2020年12月12日土曜日

HTTF2021 決勝 - マイニング

入力の作り方を見る限り、鉱脈はある程度まとまっているので、鉱脈を連結成分として表現して、連結成分とその間のパスを効率よく取っていくとよさそう……だが言うは易しという感じの方針なので具体的にどう実装するかで悩み続けた。

  • 鉱脈を構成する点を判別していくつかの連結成分にわける
    • 定数$\alpha$を決めて、$p_{ij}-C/\alpha \ge 0$である点$(i, j)$を鉱脈を構成する点と考える。鉱脈を構成する二点で隣接しているものをUnionFindでつなぐ。コスト$C/2$で取れる点がある程度あることを考慮に入れようとして$\alpha = 1.5$くらいで始めたが、最終的には$1.05$になった。後から考えると、これは$1$で問題なかったと思う。
    • 連結成分はだいたい100個前後になる。
  • 2つの連結成分間の距離を求めておく
    • ある連結成分を始点とした場合の、グリッド上の各点への最短距離はダイクストラで求まるので、全連結成分間の距離をあらかじめ求めておく。連結成分は100個程度なので、100回程度ダイクストラすればよい。
    • 「グリッドの外周」もひとつの連結成分とみなして最短距離を求めておく。
    • ダイクストラをする際、鉱脈を構成する点は通れないことにしておく。こうしないと鉱脈を突っ切って別に鉱脈に行ったほうが得という場合にコストの推定を間違えてしまうし、そもそも負辺を含んでいることになるのでダイクストラできない。通れないことにしたせいで連結成分$A$から$C$への移動が連結成分$B$に阻まれても別に問題ない。(次のパートで、必要なら$A$から$B$、$B$から$C$みたいな経路ができるはずなので。)
  • MST+ 焼きなまし
    • 使う連結成分が決まっているとき、推定スコアは、連結成分中の$p_{ij} - C/\alpha$の総和から連結成分を頂点とする無向グラフ上のMSTの総コストを引いたものと考えられる。
    • 連結成分を使う/使わないで焼きなます。毎回クラスカルした。最初は単純に山登りしたが、焼きなましで多少スコアが上がった。
  • 基本の採掘経路を求める
    • 使う連結成分が決まったので、「グリッドの外周」を表す連結成分を根としてMST上をDFSしながら採掘していく。隣接する連結成分へ移動するための具体的な経路は、上と同じようにダイクストラして復元すれば得られる。
      • 頂点を取る順番もスコアに関係しそうだと思ったけれど、つめられず。
  • 貪欲で仕上げ
    • 既に採掘した点の隣接点でスコアが上がるものがあれば取る。
    • 既に採掘した点の隣接点とさらにその隣接点の2点を取ってスコアが上がるなら取る。
    • 以下、同様の貪欲を深さ4までやる。

267万点だった。


太字の部分に気づいて方針が決まるのに4時間、最初の提出までに6時間半かけてしまった。2~4時間のコンテストだと何もできずに終わってそう。

2020年12月5日土曜日

CS Academy Round #66 - Flipping Matrix

操作を逆順に見る。単位行列の行や列を入れ換えていくと、次のことがわかる:

  • 行と列の間に$1$による一対一対応があるという性質は交換で保たれる。(→ 置換行列
  • 任意の二行の交換はある二列の交換と同一操作であり、任意の二列の交換はある二行の交換と同一操作である。

逆に、初期配置から$1$を共通に含む行と列を1対1に対応させることができれば、この対応を1つの置換と考えることができ、互換を繰り返すことによって単位置換(=単位行列)に戻せる。また、あらゆる置換は高々$N-1$個の互換の積で表せるので、$N$回以下という制限は問題にならない。結局、やることは次のようにまとまる:

  1. $N \times N$の二部グラフ$G$をつくり、$A_{ij}=1$なら行に対応する頂点$i$から列に対応する頂点$j$に辺を張る。
  2. $G$の最大二部マッチング$M$を求める。$M$が完全マッチングでなければ実行不能である。
  3. $M$を一つの置換とみなして巡回置換に分解し、さらに互換に分解する。互換に従って行の交換を行う。

2020年12月2日水曜日

JOI2009 春合宿 - stamps

とりあえず最短作業時間は単純なDPで求まりそう。$\operatorname{dp}[x][y] := S$を左から$x$文字作り済みで、次に使える文字がOである($y=0$)あるいはIである($y=1$)場合の最小操作数、として$\operatorname{dp}[N][0]$が求める値である。使える文字と次に作りたい文字が一致していればコスト0で次に遷移できるし、一致していなければ文字を変更または挿入してコスト1で遷移できる。

また、元のIOI文字列をできるだけ短くするためには挿入の回数を最大化すればよくて、これも同様のDPができるが、最短作業時間のほうが優先なので上のDPと同時にやる必要がある。

形式的には、1つのDPと捉えて、DP配列に入っている値を$(最短作業時間, 挿入回数)$として辞書式の大小関係を与えると考えると、わかりやすいかもしれない。

2020年11月29日日曜日

CS Academy Round #76 - Pyramids

ある種の区分線形関数に囲まれた領域の格子点を数える問題。区分線形関数を構成する線分の総数が$O(N)$なので、全部列挙して線分毎に下にある格子点を数えればよさそう。

まずは、与えられた点で折れ線を構成するものだけを左から列挙したい。点を$c$の昇順でソートしてスタックを持ちながら走査する。ある点$(r, c)$による山を処理するとき、スタックの先頭に入っている点(=ひとつ前の山の頂上)による山との関係には次の3通りがある:

  1. 前の山に完全に含まれる
  2. 前の山を完全に含む
  3. 前の山と一部重なるか、遠く離れていて重ならない

今見ている点を$(r, c)$、スタックの先頭に入っている点を$(r', c')$として、

  • $c+r \le c' + r'$なら1、つまり前の山に含まれるので無視
  • $c-r \le c'-r'$なら2、つまり前の山を含むのでスタックをポップして同じことを繰り返す。
  • それ以外は3なので新しい点をスタックにプッシュする

こうするとスタック(の反転)には山の頂点として有効な点だけが左から並ぶので、その間にできる谷に関する格子点をそれぞれ数えればよい。

数え上げパートについて。山の頂点が左から$(r_1, c_1), ..., (r_k, c_k)$と並んでいるとする。頂点$(r_i, c_i)$と$(r_{i+1}, c_{i+1})$の間にできる谷は直線$y=-(x-c_i)+r_i$と直線$y=x-c_{i+1}+r_{i+1}$の交点であり、その$y$座標は$(r_i+r_{i+1}+c_i-c_{i+1})/2$と表せる。そこで、$H= \max(0, \lceil (r_i+r_{i+1}+c_i-c_{i+1})/2 \rceil)$としてまずは下部の長方形の格子点を$H(c_{i+1}-c_i+1)$と数え、さらに上部の三角形の格子点を$(r_i-H)^2 + (r_{i+1}-H)^2$と数える。また、左端の頂点$(r_1, c_1)$の山の左側半分にある格子点$r_1^2$個と右端の頂点$(r_k, c_k)$の山の右側半分にある格子点$r_k^2$個は別に加算する。このとき、各頂点の下にある格子点をちょうど2回ずつ数えているので、$\sum_i r_i$を引けば正しい数になる。

2020年11月27日金曜日

CS Academy Round #79 - Groups

計算量の説明がどこにも書かれていなくて困った。以下は自分の理解。

$i$番目のクエリで与えられる$P_i$人の人間について、代表者の人数を$x_i$、代表者以外の人数を$y_i$とする。$x_i$個のグループそれぞれについて、走査して離脱しないメンバーが見つかったら打ち切るのが想定解らしい。$D=10^5$として1つのクエリで調べるノード数は最大で$\min(x_iy_i, D)$なので、計算量は$\sum_{i=1}^Q \min(x_iy_i, D)$と書ける。これを評価したい。

まず、$\sum_{i=1}^Q P_i = \sum_{i=1}^Q (x_i + y_i) \le D$という制約がある。$x_i + y_i$が一定のとき$x_iy_i$は$x_i = y_i$で最大値を取るので、$\sum_{i=1}^Q x_i \le D/2$という条件下で$\sum_{i=1}^Q \min(x_i^2, D)$を評価する問題ということにいなる。面倒なので、代わりに$\sum_{i=1}^Q x_i \le D$としておく。

$x_i + x_j$が一定のとき、$x_i^2 + x_j^2$は$x_j=0$で最大になる[1]ので、$\sum_{i=1}^Q x_i^2$を大きくするには値をできるだけ偏らせるのが最善である。ただし、1つの変数を$\sqrt D$より大きくしても意味がないので、最大値を取る配分の一つは$(x_1, x_2, ..., x_Q) = (\sqrt D, ..., \sqrt D, 0, ..., 0)$という形になる。$\sum_{i=1}^Q x_i \le D$より、最大で$\sqrt D$個程度の$x_i$を$\sqrt D$にできるから、このとき$\sum_{i=1}^Q \min(x_i^2, D) = D\sqrt D$と見積もれる。


  1. 平面上で、原点を中心とするマンハッタン距離に関する円と、ユークリッド距離に関する円を思い浮かべればよい。 ↩︎

2020年11月26日木曜日

ARC 108 F - Paint Tree

主張: 与えられた木$T$の直径を$D$とし、長さ$D$のあるパスの端点$V_b, V_w$が黒、白に塗られているとする。また、黒に塗られた頂点同士の最大距離を$X$とし、距離$X$の黒に塗られた2頂点$A, B$が与えられているとする。このとき、パス$V_b - A$または$V_b-B$の少なくとも一方は長さ$X$である。

paint_tree

証明: パス$V_b - A, V_b-B$の両方の長さが$X$未満と仮定する。3頂点$V_b, A, B$の間にできる3つのパスに共通して含まれるただ1つの頂点を$c$とすると[1]、$d(c, A) + d(c, B) = X$だから、$d(c, A), d(c, B)$の少なくともいっぽうは$X/2$以上である。$d(c, A) \ge X/2$としてよい。また、パス$V_b - A$が長さ$X$未満という仮定より

$$ d(V_b, c) + d(c, A) < X $$

が成り立つから、$d(V_b, c) < X/2$が導ける。整理すると

$$ \begin{aligned} D & \le d(V_w, c) + d(V_b, c) \\ & < d(V_w, c) + \frac{X}{2} \\ & \le d(V_w, c) + d(c, A) \\ & = d(V_w, A) \end{aligned}$$

$d(V_w, A) > D$が導かれたので、直径より長いパスが見つかったことになり、矛盾する。$\square$


  1. https://en.wikipedia.org/wiki/Tree_(graph_theory)#Properties より、For any three vertices in a tree, the three paths between them have exactly one vertex in common (this vertex is called the median of these three vertices). というやつ。 ↩︎

2020年11月21日土曜日

CODE FESTIVAL 2014 - 宝探し 2

変更なしかつ一次元で考えると種類数の有名問題になるが、お宝が高々100種類しかないので累積和の列を100個用意するだけでよい。また、隣接要素を入れ替える操作が累積和上でどうなっているか考えると、右から左に移動するお宝に関する累積和列の1個所が+1され、左から右に移動するお宝に関する累積和列の同じ個所が-1されるだけなので、特別なデータ構造なしで処理できる。

二次元の場合も、二次元累積和を取って同じことをやればよい。上下の入れ替えでは、下から上に移動するお宝に関する累積和配列の1個所からその行の右方にある数がすべて+1され、上から下に移動するお宝に関する累積和列の同じ個所からその行の右方にある数がすべて-1される。左右の入れ替えも同様に処理できる。計算量は${O}(nmk + q\max(n, m))$。

2020年11月20日金曜日

CS Academy Round #83 - Firestarter

着火クエリ$(T_i, A_i, B_i, X_i)$に対して新しい頂点$V_i$を導入し、辺$\{A_i, B_i\}$を分割して長さ$X_i$の辺$\{A_i, V_i\}$と長さ$L-X_i$の辺$\{V_i, B_i\}$を張る。ひとつの辺上に複数の着火が起こる場合には、3つ以上に分割する。こうしてできたグラフ上で、各頂点に初めて火が到達する時間を求める。具体的には、$V_i$の距離を$d[V_i] := T_i$とセットして多点スタートのダイクストラをする。(形式的には、大頂点$S$を導入して各$V_i$と$S$の間に長さ$T_i$の辺があると考えるのがよさそう。)

こうすると、頂点$v$に初めて火が到達する時間が$d[v]$に入ることになる。ただ、欲しいのはすべての辺が燃えきる時間なので、$\max d[v]$は答えにならない。長さ$l$の辺$\{u, v\}$が燃えきる時刻を$t$とすると$(t-d[u])+(t-d[v]) = l$なので、$t = (l + d[u]+d[v])/2$と求まる。この最大値を答えとすればよい。


時刻を決め打って二分探索をやってしまって、さすがにTLEだった。

2020年11月8日日曜日

第四回 アルゴリズム実技検定

リアルタイム受験した。3時間55分で全完。

L - マンションの改築

差分$d_i := a_i - a_{i+1}$に注目すると、$d_i = 0$になる$i$を数えればよいことがわかる。クエリによる$(d_i)$の変動は以下のようになる:

  • タイプ1のクエリ: 奇数の$i$について$d_i \mathrel{{+}{=}} v$、偶数の$i$について$d_i \mathrel{{-}{=}} v$。
  • タイプ2のクエリ: 奇数の$i$について$d_i \mathrel{{-}{=}} v$、偶数の$i$について$d_i \mathrel{{+}{=}} v$。
  • タイプ3のクエリ: $d_{u-1} \mathrel{{-}{=}}v$、$d_{u} \mathrel{{+}{=}} v$。

$d_i = 0$になる$i$を数えたいので、さらに$\operatorname{table}[x] := d_i=x$である$i$の総数、という情報をハッシュテーブルなどで保持しておくとよさそうだ。しかし、$(d_i)$や$\operatorname{table}$をクエリ毎に愚直に操作すると、タイプ1と2については間に合わない。

偶奇にわければタイプ1, 2はまとめて処理できそうなので、$\operatorname{offset}_奇, \operatorname{offset}_偶$という量を保持することにする:

  • タイプ1のクエリ: $\operatorname{offset}_奇 \mathrel{{+}{=}} v$、$\operatorname{offset}_偶 \mathrel{{-}{=}} v$。
  • タイプ2のクエリ: $\operatorname{offset}_奇 \mathrel{{-}{=}} v$、$\operatorname{offset}_偶 \mathrel{{+}{=}} v$。

$d_i$の真の値は、これらのオフセットを足したものということになる。さらに、$\operatorname{table}_奇[x] := d_i=x$であるような奇数$i$の総数とし、$\operatorname{table}_偶$も同様に定義して、タイプ3のクエリでこれらを更新する:

  • タイプ3のクエリ: $d_{u-1} \mathrel{{-}{=}}v$、$d_{u} \mathrel{{+}{=}} v$。さらに$d_{u-1}$と$d_u$の変更に応じて、$\operatorname{table}_奇, \operatorname{table}_偶$を適切に更新する。

こうすると、各クエリを処理したあとの答えは$\operatorname{table}_偶[- \operatorname{offset}_偶] + \operatorname{table}_奇[- \operatorname{offset}_奇]$で求まることになる。

M - 筆塗り

与えられたグラフ$T$を根付き木とみなす。$i$回目でパス$u-v$を塗るとしたとき、$u$から塗りながら木を上昇していって$\operatorname{LCA}(u, v)$の深さ以下になったらそこで塗りは無効になると考えることができて、$v$についても同様である。そこで、事前処理で$\operatorname{q}[v] :=$一方の端が$v$であるすべてのクエリ番号(のリストや可変長配列)、という情報を用意しておくことにする。

そして、$T$を葉からボトムアップに処理する。頂点$v$を処理する際に「子から$v$に上ってきた時点でまだ有効かもしれないクエリ番号」を番号の降順で優先度付きキューに保持する。具体的に手順を書くと、

  1. $v$の子$c_1, c_2, ..., c_k$の保持する優先度付きキュー$Q_{c_1}, ..., Q_{c_k}$と$\operatorname{q}[v]$をすべてマージしたキュー$Q_v$を作る。
  2. $Q_v$からクエリ番号$i$を取り出し、$i$に対応する$\operatorname{LCA}(u_i, v_i)$の深さを調べ、$v$の深さがそれ以下であれば$i$を捨てることを繰り返す。$v$のほうが深ければストップし、$v$とその親をつなぐ辺がクエリ$i$で塗られていることを確定する。($Q_v$が空になるまで見つからなければこの辺は一度も塗られていないことになる)

ダブリングでLCAを求めてマージ可能なヒープを使った場合、計算量は${O}((N+Q) (\log N + \log Q))$となる。コンテスト中にはpairing heapを使ったが、二分ヒープでもマージテクをすれば$\log Q$が掛かるだけなので十分間に合う。

操作を逆から見ると簡単になるのは確かに……

O - 宝箱

一読して、セグメント木上でin-placeにDPする方法がありそうに見えたので、うまい更新方法を探した。

以下、0-basedで考えて、鍵屋$i$に対応する区間を半開区間$[l_i, r_i)$で持つことにする。とりあえず愚直なDPを考えると、$\operatorname{dp}[x] :=$ 右端(つまり$r_i$)が$x$以下の鍵屋のみを使って得られる最大利益、とするのがよさそう。右端が$x$と一致する鍵屋$i$がただひとつ存在するケースを例にとると、遷移は

$$ \begin{aligned} \operatorname{dp}[x] \leftarrow \max( & \operatorname{dp}[x-1], \\ & \operatorname{dp}[l_i] + a_{l_i} + a_{l_i + 1} + ... + a_{r_i -1}, \\ & \operatorname{dp}[l_i+1] + a_{l_i+1} + ... + a_{r_i -1}, \\ & ... \\ & \operatorname{dp}[r_i - 1] + a_{r_i - 1}) \\ \end{aligned} $$

となる。遷移を見ると、$a_i$を加算している部分が前もって$\operatorname{dp}$に足されていれば、区間$\max$でまとめて処理できそうだ。実際、$\operatorname{dp}[x]$を処理したあとの遷移の式には、$\operatorname{dp}[0], \operatorname{dp}[1], ..., \operatorname{dp}[x]$は常に$a_x$が加算された形しか出てこないので、$\operatorname{dp}$を遅延セグメント木などで持っておいて、必要なタイミングで区間加算すればよい。疑似コードで書くと以下のようになる:

res = 0
for r from 1 to N:
  dp[r] = max(dp[r], dp[r-1])
  dp[0], ..., dp[r-1] += a[r-1] # 区間加算
  for l, c in 右端がrの鍵屋:
    dp[r] = max(dp[r], max(dp[l], ..., dp[r-1]) - c)
  res = max(res, dp[r])

上の$\operatorname{dp}$の定義は$\operatorname{dp}[x]$を処理した瞬間にしか成り立っていないことに注意。

2020年10月30日金曜日

dwangoプログラミングコンテスト D - タクシー

dwangoプログラミングコンテスト D - タクシー

最小費用流なので、とりあえずグラフを二周くらいしながら需要と供給を解消していって、循環流になおす。あとは負閉路を解消すれば最適になるが、このグラフ上の負閉路は環状線を一周するものしかありえないので、ここにできるだけ流せばよい。具体的には、残余グラフ上の負辺を集めて()(上限流量、辺の重み)を上限流量の昇順でソートして、先頭から順に(一周のコストが負である限り)上限流量いっぱいに流すことを繰り返した。

2020年10月23日金曜日

ARC 093 E - Bichrome Spanning Tree

$X$がMSTの重みの総和より小さい場合は実現不可能である。

$X$がMSTの重みの総和に等しい場合、MSTを作れるような塗り方を数えればよい……が、そもそも、だいたいの塗り方においてMSTが作れるように見える。というのも、MSTに使える辺が全部白または全部黒になっているような塗り方を除けばMSTができるからだ。つまり、MSTに使われうる辺が$K$本あるとき、$K$本全部を一色で塗る$2\cdot 2^{M-K}$通りの塗り方以外はMSTを作れる。あとは$K$が求まればよい。これは最小全域木$T$を作った後、$M$本の辺$e = (u, v)$について最適性条件を満たせるか判定すれば数えられる。具体的には、$e$の重みが($T$上の)パス$u - v$上の重みの$\max$以下であれば$e$はMSTに使える[1]。変更がないので、$\max$クエリはダブリング等で処理できる。(制約を$N=10^5$と勘違いしていた。愚直でよい。)

$X$がMSTの重みの総和より大きい場合、MSTに使われうる辺は全部白または全部黒に塗られていなければならないので、とりあえず白に塗られているとする。この時、黒に塗られていてMSTに使えない辺が全域木に少なくとも1本含まれていなければならないが、その1本以外は白い辺も自由に選べる。したがって、MSTに対して辺を挿入してそれによってできたサイクルから別の最大辺を削除して全域木を作った時に、重みの総和が$X$になるものを考えればよい。これにはやはり木上の$\max$クエリで答えられる。「MSTの辺と交換した時の重みの総和」が$X$になる辺を$p$本、$X$より大きくなる辺を$q$本としたとき、$p$本から少なくとも一つを黒い辺にする必要があって、$q$本は好きにぬってよいので、塗り方は$(2^p-1)2^q$通り。白の分と黒の分を足すと$2\cdot(2^p-1)2^q$通りとなる。${O}((M+N)\log N)$。


厳密な証明がよくわからなかった。


  1. path optimality condition ↩︎

2020年10月17日土曜日

ABC 180 F - Unbranched

「最大サイズがちょうど$L$」の代わりに、$f(n, m, l) :=$連結成分のサイズが$l$を超えない$n$頂点$m$辺のグラフの数、を数えることにして、$f(N, M, L)-f(N, M, L-1)$を答えとすればよい。

次数2以下の連結成分はパスかサイクルしかないので、この2つを作りながら数えていくDPができそうだが、頂点の順番をどうするかが問題になる。順番を固定した上で最後に定数を掛けるのはうまくいかなそうだし、固定せずに挿入していくDPもサイズの上限に関する制約を扱うのが困難に見える。

こういう時の典型テクニックの一つとして、「選ぶ頂点の一つは、残っているものの中でもっとも小さい番号とする」というルールを追加してグループ(この場合は連結成分)を作っていくというものがある。この場合はこれがうまくいきそうだ。

$\operatorname{dp}[x][y] :=$頂点を$x$個、辺を$y$個使ってできる(条件を満たす)グラフの総数

とする。残った$N-x$個の頂点からサイズ$s$のパスグラフを作る時、連結成分のうちの1つの頂点は残った頂点の中でもっとも番号の小さいものと決めているので、残りは$N-x-1$個から$s-1$個を選ぶことになる。ラベル付きパスグラフの総数は$s!/2$(ただし$s=1$の場合は例外)なので、

$$ \operatorname{dp}[x+s][x+s-1] \mathrel{{+}{=}} \frac{s!}{2} \binom{N-x-1}{s-1} \operatorname{dp}[x][y] $$

と遷移できる。サイクルを作る場合も同様で、ラベル付きサイクルグラフの総数は$(s-1)!/2$(ただし$s\le 2$の場合は例外)なので、

$$ \operatorname{dp}[x+s][x+s-1] \mathrel{{+}{=}} \frac{(s-1)!}{2} \binom{N-x-1}{s-1} \operatorname{dp}[x][y] $$

と遷移する。ただし、自己ループである$s=1$は許されないことに注意。

2020年10月12日月曜日

CodeChef October Challenge 2020 - Compress all Subsegments

区間$I$に関する$f$の値は、$(A_i)_{i \in I}$中のhighest set bitの位置を$k$として、$k$が立っている数が$(A_i)_{i \in I}$に何個含まれるかでだいたい決まる:

  1. $k$が立っている数が1個でそれ以外が0個の場合、その数自身になる
  2. $k$が立っている数が奇数個の場合、上の場合を除いて$2^k$になる
  3. $k$が立っている数が2個でそれ以外が0個の場合、不定
  4. $k$が立っている数が2個でそれ以外が1個の場合、不定
  5. $k$が立っている数が2個でそれ以外が2個以上の場合、0にできる
  6. $k$が立っている数が4個でそれ以外が$0$個の場合、不定
  7. $k$が立っている数が4個でそれ以外が$1$個以上の場合、0にできる
  8. $k$が立っている数が6個以上の偶数の場合、それ以外が何個でも0にできる

まずは、$k$が立っている数が偶数個の時に常は$f$の値が0になり、$k$が立っている数が奇数個の時は常に$2^k$になるとして、偶奇のみに注目して計算する。そして、後から3, 4, 6のパターンを別に数えて補正する。

前半パートは、$i = 1, 2, ..., N$についてそれぞれ区間$[1, i], [2, i], ..., [i, i]$に関する結果の和を求めるとよい。この際、

  • $\operatorname{odd}[x] :=$ highest set bitが$x$で、$x$が立っている数を奇数個含むような区間の数
  • $\operatorname{even}[x] :=$ highest set bitが$x$で、$x$が立っている数を偶数個含むような区間の数

を保持しながら走査するとまとめて計算できる。${O}(N\log \max A_i)$。

2020年9月14日月曜日

CodeChef September Challenge 2020 - Chefina and Swap

0-basedで考える。列$(S_i)$を$[0, m), [m, N)$に分けた時に、左右の和を等しくするようなスワップが何個あるか数える。まず、右の和のほうが大きいことが有効なスワップが存在する必要条件である。右の和が左の和より$D(m)$だけ大きいとする。このとき、$D(m)$が偶数なら左の区間の$S_i$と右の区間の$S_{i+D(m)/2}$を交換すると和が等しくなる。添え字$i$が有効である条件は$0\le i < m, m \le i+ D(m)/2 < N$で、これは$\max(0, m-D(m)/2) \le i < \min(m, N-D(m)/2)$とまとまる。この区間の長さが有効なスワップの数に等しいので、すべての$m$について足せばよい。ただし$D(m)=0$の場合は区間をまたいで数を移動する必要がないので特別扱いして、左の区間内と右の区間内のスワップの総数を数える。

さて、$0\le D(m) < 2N$が必要なことを考えると、有効な$m$は多くないはずだ。$m$が増加するとき、$D(m)$が正から負に変わる地点を考えると、この地点は全体の半分よりは右にあるはずだから、その付近では$m$が$1$動くごとにおおむね$2 \cdot N/2 = N$程度は$D(m)$が変化することになる。したがって、高々3つ程度の$m$について数えれば十分という見積もりができる。実装の際は$D(m) \ge 0$であるような最大の$m$を二分探索などで求めておいて、そこから下っていけばよい。

CodeChef September Challenge 2020 - Find XOR

仮に$K=0$を尋ねることが可能だとすると、$K=0$による総和と$K=2^e$による総和の差は$(A_i)$の中で第$e$ビットが$1$であるものの総数-第$e$ビットが$0$であるものの総数に等しい。これを利用すると各桁の立っているビットの数がわかるので、偶数ならXORを0、奇数なら1とすればよい。

実際には$K\ge1$という制約があるが、代わりに全ビットを反転したもの(=$2^{20}-1$とXORを取ったもの)を$K$とすれば同じことができる。

また、素朴にやると$K=0, 2^0, 2^1, ..., 2^{19}$を全部尋ねる必要があってクエリの回数が21回になってしまう。$K=2^0, ..., 2^{18}$を調べて$K=0$の場合の生の総和と比較することで、$K=2^{19}$は省略できて20回になる。


$N \le 10^3 < 2^{10}$だから、11ビットくらいで区切れば2つの質問を1つのクエリでできそうと思ったが、分離がかなり複雑になりそうなので方針を変えた。

CodeChef September Challenge 2020 - Rotate the Polyline

$v_i=P_iP_{i+1}$とした時、原点を通る直線$l$によってできた2つの(境界を含まない)半平面のそれぞれに連続した添え字のベクトルが並んでいれば、$l$に直交するベクトルが答えということになる。ただし、答えとなるベクトルは十分に原点に近い格子点を通る必要がある。

整数性の条件をいったん忘れると、直線$l$を表すベクトルの候補$u$は各$v_i$から偏角を左右に微小にずらした$2N$通りが考えられる。そこで偏角順の尺取り法をして、それぞれの$u$から偏角が$180 \degree$以内におさまるすべての$v_i$の添え字を保持することにする。添え字がすべて連続している瞬間があれば$u$を$90 \degree$回転したものを答えにすればよい。管理と判定はBITでできる。以下、実装のポイント:

  • 尺取り法で区間を伸ばす場合の処理: ベクトルの候補$u$と$v_i$のクロス積がマイナスになるまで伸ばしていけばよい。[1]
  • 尺取り法で区間を縮める場合の処理: $u$と$v_i$のクロス積がプラスになるまで縮めればよい。
  • $u$と同一か180度回転した偏角が$(v_i)$に含まれる場合、$u$(を$90\degree$回転したもの)は答えにできない。

さて、$2N$本の直線を用意する上で、整数性の条件を考慮しなくてはならない。単純化のために正数に限定して考える。ベクトル$(x, y)$から反時計回りに微小に偏角を進めたベクトルを得ることは、$y/x < 1$であればファレイ数列$F_{2\cdot 10^9}$において$y/x$の直後にある分数を得ることと同じである。$y/x \ge 1$の場合も、逆数を取って直前の分数を得ると考えればよい。やり方を知らないのでググったらstackexchangeの質問が見つかった。ファレイ数列の隣接項$p/q, c/d$は$cq-dp=1$を満たすので、$c, d$は互除法で求まるようだ。


  1. クロス積がマイナスになるベクトルが存在しなかった場合はこれだとうまく処理できないように見える。こういうことが起こるのは、偏角ソートされた$(v_i)$の隣接項同士が$180\degree$より開いている場合だが、問題設定より全ベクトルを足すと$0$に戻るのでこの範囲に1つもベクトルがないということはありえない。 ↩︎

CodeChef September Challenge 2020 - Divide Candies

べき乗和は$K$の値に関わらず奇数と偶数を交互に足すので、総和は奇、奇、偶、偶、奇、奇、……となる。総和が奇/偶の時の自明な下限がそれぞれ1/0なので、これを達成できれば最善となる。実験してみると、$N$が十分に大きければ常に下限が達成できるという予想が立つ。

$K=1$の場合、長さ4の区間は$0110$とわければよくて、$N$が$4$の倍数ならこれに従って等分できる。倍数でない場合もそれぞれ4つずつ等分した後、残りを適当にわければよい。

$K=2$について。また実験すると、長さ8の区間は$01101001$で等分できることがわかるので、$N$が小さい場合の構成を列挙した上で、以降は$8$個ずつ等分すればよい。

$K=3, 4$の場合も常に等分できるような長さの区間が見つかれば解決しそうだ。

$K=3$の場合は長さ16の区間は$0110100110010110$で等分できる。(ここでようやく、ひとつ小さい次数で得た列をflipしてくっつければよいことに気づく。)やはり$N$が小さい場合を列挙して、以降は$16$個ずつ等分すればよい。$K=4$も同様で、長さ32の区間を$01101001100101101001011001101001$で等分できる。

さて、$N$が小さい場合の解を用意する必要があるのだが、明らかにIPソルバで解けるのでOR-ToolsのCP-SATを使った。$K=3$までは愚直でいいとして、$K=4$はDPや半分全列挙でも簡単ではないように見える。(ちゃんと考えていない)

2020年8月22日土曜日

yukicoder No.1191 数え上げを愛したい(数列編)

条件を満たす数列のすべての要素は互いに異なるので、昇順のものだけ数えて後で$N!$を掛ければよい。

$S_1$が与えられたとき、残りの項は

$f(k) :=$ $1$回の操作で$A$マス以上進むとして、$N-1$回操作してちょうど$k$マス進む場合の数

と考えると形としては$f(A)+f(A+1)+...+f(B)$通りのように求まる。(実際には$S_1+k \le M$であるような$f(k)$までしか足せない)したがって、$f$かあるいはその累積和$F(k) = \sum_{i=1}^kf(i)$が求めれば$S_1 = 1, 2, ..., N$について足せばよいことになる。形式的べき級数で考えると

$$ \begin{aligned} f(k) & = [x^k](x^A+x^{A+1}...)^{N-1} \\ & =[x^k]\Bigl(\frac{x^A}{1-x}\Bigr)^{N-1} \\ & =[x^{k-A(N-1)}](1-x)^{-(N-1)} \\ & = [x^{k-A(N-1)}] \sum_{i=0}^\infty \binom{i+N-2}{N-2}x^i\\ & = \binom{k-A(N-1)+N-2}{N-2} \end{aligned}$$

$F(k)$を求めるなら、$(1-x)^{-1}$をもう一つ掛けることになるので$\binom{k-A(N-1)+N-1}{N-1}$となる。

2020年8月17日月曜日

CodeChef August Challenge 2020 - Smallest KMP

最適な文字列がどういう形になっているか考えると、パターンの前後は$a...ab...b \ ...$と$... \ y...yz...z$のようになっているはずなので、$S$から$P$を除いた上でソートしたものを$S'$とし、$P$を$S'$のどこに挿入するのが最善か調べる。$P$を$S'$の$i$文字目に挿入したものと$S'$の$i+1$文字目に挿入したものを比較するとき、この比較は辞書順に関して下に凸になっているので、最小となる場所を二分探索で探せばよい。

CodeChef August Challenge 2020 - Insanely Hard School Exam

1つの色についてだけ考える。平行な2直線がないとすると、直線が$x$本ある時のtruly-geometric triangle(以下TGT)の総数は$\binom{x}{3}$である。これを元に、$\operatorname{dp}[x][y] =$色$x$まで見て消しゴムの残量が$y$である場合のTGTの総数、として全体で${O}(NK)$のDPが可能なのでsubtask2, 3が解ける。

平行線がある場合について考える。平行関係は同値関係なので、異なる同値類の総数を$p$とし、$i$番目の同値類に属する直線が$L_i$個あるとする。$L_1 \ge L_2 \ge ... \ge L_p$と仮定してよい。これらの直線を消していくとき、同値類の総数をできるだけ減らすように消すのが最善なので、後ろから消していくのがよい。そこで$f(c, z) =$色$c$の直線が$z$本残っている場合のTGTの総数、として$f$を事前に求めておけば、上と同じDPで解ける。順序が固定されているので$f$自体もDPで求まる。

CodeChef August Challenge 2020 - Subsequence Frequency Counting

数列$(A_i)$が整数$x$を$c_x$個含むとする。また、整数$x$が代表になるような部分列でちょうど$k$個の$x$を含むものの総数を$f(x, k)$とする。$f(x, k)$を二項係数で表すと、以下のようになる:

$$ f(x, k) := \biggl( \sum_{i=0}^{k-1} \binom{c_1}{i}...\sum_{i=0}^{k-1}\binom{c_{x-1}}{i} \biggr) \binom{c_x}{k} \biggl(\sum_{i=0}^{k}\binom{c_{x+1}}{i} ... \sum_{i=0}^{k}\binom{c_N}{i} \biggr) $$

$f(x, k)$を$N$通りの引数について足せばよいが、毎回すべての二項係数を掛け算していては間に合わない。そこで、$\operatorname{dp}[x] := \sum_i \binom{c_x}{i}$という形の列をセグメント木で持っておくことを考える。$\operatorname{dp}$のそれぞれの値が適切な$k$に対応していれば、$f(x, k)$は$\operatorname{dp}[1...x-1]$の積と $\binom{c_x}{k}$と$\operatorname{dp}[x+1 ... N]$の積を掛けて求まることになる。$k=0$から$f$を求めていくとして、$\operatorname{dp}$の末尾から$k$が増えていくので、$k$の昇順、タイブレークは$x$の降順に$f(x, k)$を求めつつ$\operatorname{dp}$を更新していくと、一回の更新が${O}(\log N)$でできる。

CodeChef August Challenge 2020 - Chefina and Strange Tree

与えられたグラフを根付き森とみなすと、ある頂点$v$を消す操作は、$v$のそれぞれの子の部分木と$v$の親側の木とをすべて切り離す操作と考えられる。オイラーツアー(pre-order)上で考えると、部分木は連続した区間に対応しているので、区間を切り貼りすることができればよい(下図)。そこでオイラーツアーを平衡二分木で保持することにする。

chefcomp

平衡二分木に$\min$を載せて、さらに遅延更新で加算ができるようにする。町$P_i$についての処理の流れは次のようになる:

  1. $P_i$を含む木$T$(=1つの平衡二分木で表されたオイラーツアー)のすべての頂点の値から$A_{P_i}$を引く。
  2. $T$の$\min$を求めて、$0$以下なら$T$上の二分探索で最小値を取る頂点$v$を探す。$v$が$i$日目に$0$になったと記録し、$v$の値を$+\infty$にセットする(再び$0$にならないようにするため。) $\min$が$0$より大きくなるまでこれを繰り返す。
  3. 上図のように$P_i$の隣接頂点に関する部分木をすべて切り離す。

1を行う際に、$P_i$を含む平衡二分木を得る必要があるが、永続UnionFindを使った。事前に列$(P_i)$を逆順に走査しながら頂点をつないでおく。$P_i$を(正順に)処理する際には、その時点での$P_i$に対応する(UnionFind上の)根に平衡二分木を持たせるようにした。(でも、ちゃんと前処理すれば普通のUnionFindでできるか。)

計算量は全体で${O}(N\log N)$。


明らかに過剰なデータ構造をごちゃごちゃと貼ってしまった。physics0523さんの解説によると、操作の構造自体が木になっていることを利用できるらしい。なるほど……

2020年8月9日日曜日

AGC 037 B - First Second

文字列を反転して条件を言い換えると、文字列$s, t, (|s| \le |t|)$に関する問題の条件は次のようになる。(添え字はPython風に表している)

  1. $t$のprefixに$s$の最後の1文字を除いたもの$s[:-1]$が含まれ、
  2. さらに$s$の最後の1文字$s[-1]$が$t$のそれ以降に存在する

1つ目の条件だけ考える場合、事前にTrieを作っておけば$s[:-1]$をprefixとして含むような文字列の総数は高速に数えられる。この時、$s[:-1]$をTrieで辿った際の終端ノードに2つ目の条件に関する情報があれば、うまく数えられそうに見える。具体的には、そのノードの部分木に含まれる文字列で$s[-1]$を含んでいるものの数が記録されていれば、2つ目の条件も満たすものが数えられる。

そこで、配列$\operatorname{dp_v}$を$\operatorname{dp_v}[c] :=v$の子に属する文字列で以降に文字$c$を含むものの総数、と定めて、Trieの各ノードに持たせればよい。

アルファベットのサイズを$\alpha$とするとき、この$\operatorname{dp}$配列は各ノードについてならし${O}(\alpha)$で作れるので、全体の計算量は${O}(\alpha\sum_i S_i)$。(一つの子ノードについて、親への遷移はちょうど1回で、遷移の計算量が${O}(\alpha)$。)

2020年7月26日日曜日

SBCLのハッシュテーブルに対するハック

SBCLのハッシュテーブルに対して、長さ$N$の列で${O}(N^2)$の時間がかかるようなものを作るメモ。

組み込みハッシュ関数の特性を生かしてキーを衝突させる

ハッシュテーブルの標準の4つのtestに対しては、それぞれハッシュ関数が用意されている:

CL-USER> (sb-impl::eq-hash 123456789)
123456789
NIL
CL-USER> (sb-impl::eql-hash 123456789)
123456789
NIL
CL-USER> (sb-impl::equal-hash 123456789)
1193941381175482768
NIL
CL-USER> (sb-impl::equalp-hash 123456789)
1193941381175482768
NIL

fixnumに対するeqeqlのハッシュ関数はsb-kernel:pointer-hashそのものなので、(非負なら)整数がそのままハッシュ値になる。equalequalpについてはsxhashが適用される。

どれも単純なハッシュ関数だが、これらがハッシュテーブルの中でそのまま使われるわけではなく、常にprefuzz-hashという変換を通る:

(defun prefuzz-hash (hash)
  ;; We're using power of two tables which obviously are very
  ;; sensitive to the exact values of the low bits in the hash
  ;; value. Do a little shuffling of the value to mix the high bits in
  ;; there too. On 32-bit machines, the result is is a positive fixnum,
  ;; but on 64-bit, we use 2 more bits though must avoid conflict with
  ;; the unique value that that denotes an address-based hash.
  (ldb (byte #-64-bit 29 #+64-bit 31 0)
       (+ (logxor #b11100101010001011010100111 hash)
          (ash hash -3)
          (ash hash -12)
          (ash hash -20))))

(defun mask-hash (hash mask)
  (truly-the index (logand mask hash)))

ハッシュ関数の返すハッシュ値はprefuzz-hashで31ビットのハッシュ値に変換される。さらに、このハッシュ値はmask-hashでマスクされ、ハッシュテーブルの内部に保持されている配列のサイズ$L$に応じて$L$未満のインデックスが対応する。(このサイズは常に2の累乗で、ハッシュ値の下位ビットを残すだけで求まる。)

まとめると、オブジェクトに対応するアドレスが計算されるまでの流れは

  1. ハッシュ関数: オブジェクトに対するハッシュ値を決定する(ユーザー定義可能)
  2. prefuzz-hash: ハッシュテーブル内部でハッシュ値を変換する
  3. mask-hash: ハッシュ値から内部のインデックスを決定する

となる。さて、prefuzz-hashのコメントで書かれている通り、この実装全体の明らかな欠点として、上位ビットの変化に鋭敏でないというのがある:

CL-USER> (prefuzz-hash #b10101011101110111011101110111011101110111011101110111011101110)
691516418
CL-USER> (prefuzz-hash #b10111011101110111011101110111011101110111011101110111011101110)
691516418

prefuzz-hashでは$31$ビットでマスクする前に右シフトを複数入れて混ぜることでこの問題をある程度解消しているが、$-20$くらいだと上位11ビットの変化をまったく検知しないことになる。また、実際にはmask-hashでさらにビットが捨てられるので、検知できない範囲はもっと大きくなる。

これを利用して敵対的な入力を作ってみる:

(defun make-killer-sequence (length)
  (labels ((power-of-two-floor (x) ; x以下の最大の2の累乗を返す
             (ash 1 (- (integer-length x) 1))))
    (let* ((min (power-of-two-floor (floor #.(expt 2 62) length))))
      (loop for i from 1 to length
            collect (* min i)))))


(let ((list (make-killer-sequence 200000))
      (table (make-hash-table)))
  (time (dolist (x list)
          (setf (gethash x table) t)))
  table)
;; Evaluation took:
;;   42.093 seconds of real time
;;   42.031250 seconds of total run time (42.015625 user, 0.015625 system)
;;   99.85% CPU
;;   88,248,012,450 processor cycles
;;   22,735,232 bytes consed

(let ((list (loop repeat 200000
                  collect (random most-positive-fixnum)))
      (table (make-hash-table)))
  (time (dolist (x list)
          (setf (gethash x table) t)))
  table)
;; Evaluation took:
;;   0.031 seconds of real time
;;   0.031250 seconds of total run time (0.031250 user, 0.000000 system)
;;   100.00% CPU
;;   82,863,249 processor cycles
;;   22,741,968 bytes consed

ランダムな入力と比べて遅くなっていることがわかる。

乱択で衝突するキーを探す

ハッシュ関数の特性を考えなくても、SBCLの実装をそのまま再現して衝突するキーを探すことは当然できる。(ただし、探索時間はそれなりにかかる)

(defun make-killer-sequence2 (length)
  (declare (optimize (speed 3))
           ((integer 0 #.most-positive-fixnum) length))
  (assert (= 1 (logcount length))) ; 2の累乗
  (let* ((hash-fn (sb-impl::hash-table-hash-fun (make-hash-table :test #'eql)))
         (table (make-hash-table))
         (mask (- length 1)))
    (dotimes (i length)
      (loop
        (let* ((x (random most-positive-fixnum))
               (hash-value (prefuzz-hash (the (integer 0 #.most-positive-fixnum)
                                              (funcall hash-fn x))))
               (masked-value (logand mask hash-value)))
          (when (or (< (hash-table-count table) 2)
                    (gethash masked-value table))
            (push x (gethash masked-value table))
            (return)))))
    (loop for values being each hash-value of table
          append values)))

(let ((list (make-killer-sequence2 262144))
      (table (make-hash-table)))
  (time (dolist (x list)
          (setf (gethash x table) t)))
  table)
;; Evaluation took:
;;   32.281 seconds of real time
;;   32.281250 seconds of total run time (32.281250 user, 0.000000 system)
;;   100.00% CPU
;;   67,652,819,010 processor cycles
;;   32,850,480 bytes consed

(let ((list (loop repeat 262144
                  collect (random most-positive-fixnum)))
      (table (make-hash-table)))
  (time (dolist (x list)
          (setf (gethash x table) t)))
  table)
;; Evaluation took:
;;   0.100 seconds of real time
;;   0.109375 seconds of total run time (0.093750 user, 0.015625 system)
;;   [ Run times consist of 0.031 seconds GC time, and 0.079 seconds non-GC time. ]
;;   109.00% CPU
;;   202,079,224 processor cycles
;;   32,815,008 bytes consed

対応方法など

[少し追記]

1つ目のハックはこの実装特有の問題と言え、もう少しビットシフトを追加すれば難しくなりそうだが、2つ目は決定的ハッシュ全般の問題なので、決定的であることをやめる必要がある。たとえば、ハッシュを求める際にロード時(またはコンパイル時、実行時など)に生成した乱数を使うなどが考えられる。以下はロード時に決定する例[1]:

(declaim ((mod #.most-positive-fixnum) *mask*))
(sb-ext:define-load-time-global *mask* (random most-positive-fixnum))

(defun my-hash (x)
  (declare (optimize (speed 3))
           ((integer 0 #.most-positive-fixnum) x))
  (let ((x (sxhash x)))
    (ldb (byte 62 0) (+ *mask* x (ash x -28) (ash x -36)))))

(make-hash-table :test #'eql :hash-function #'my-hash)

これも「自分には明らかなハックが思いつかない」程度のもので、fixnum以外に対するsxhashはたぶん安全ではないとか、そもそもビットシフトを適当に入れてXORを取るだけで十分なのかわからない、などいろいろ問題はありそう。ちゃんとしたやつについてはこの記事が参考になった。


  1. ただし、起動時の*random-state*そのものが決定的なことに注意。時刻等から適当にシードを与えるか (sb-ext:seed-random-state t) を使う必要がある。 ↩︎

2020年7月13日月曜日

天下一プログラマーコンテスト2014 予選B D - 天下一芸術

設計図上の番号$x$が与えられたとき、$x$を塗るための矩形としては、設計図上のすべての$x$を覆う最小の矩形以外は明らかに考慮する必要がない。また、この矩形に含まれる任意の番号$y (\neq x)$について、$y$は$x$の後に塗らなければならない。このようにして任意の2つの番号の前後関係を調べられるので、あとはそれらをすべて満たすような塗り方があるかどうか調べればよい。

部分点はペンキの順列を${O}(N!)$で全探索すれば取れる。また、違反している制約の数をスコアとして2点スワップの山登りをしたら満点が取れた。


落とせる入力があるかどうか考えてみたけれど思いつかなかった。

2020年7月2日木曜日

天下一プログラマーコンテスト2013 予選B E - 天下一最短路コンテスト

一次元の問題として解ける。直線上に等間隔で$p_{100}, p_1, p_{98}, p_3, p_{96}, p_5, ..., p_4, p_{99}, p_2$と並べればよい。


思いつくのに二時間かかった。第一感でID順を工夫して頑張る問題と思ったのだけど、あまり筋がよくなかったかもしれない。

2020年6月28日日曜日

Introduction to Heuristics Contest

3問もあるのかと思って大急ぎで雑な焼きなましを書いたりしていた……

  • とりあえず1点更新だけの単純な焼きなましを書いた。(81730043)
  • 温度が適当すぎたので調整する。(99859730)
  • 遷移時のスコア再計算を素朴にやっていたのを速くする。(102493050)
  • 定数倍高速化する。 (104637270)
  • 初期解を貪欲で作る。(112775607)
  • 初期解を2日先読みの貪欲で作る。 (115690469)
  • 初期解を3日先読みの貪欲で作る。(116929121)

単純な先読みは3日が限界なので、次はビームサーチをするべきだろうなと思った。この辺でコンテストが終わった。

  • (焼きなましの)ほかの遷移を考える
  • 遷移の平均計算量は双方向リストっぽい持ち方でもう少し頑張れそう?

Bがチュートリアルとわかった時点で完全に無視していたのだけど、あとでCを見たら、一点更新の焼きなまし自体があまりよくないと書いてあった。そう言われるととそうだなという気持ちにはなるけれど、コンテスト中に見ても手が止まってしまっただけな気がする。

2020年6月25日木曜日

Chokudai Contest 002 A - 約数をたくさんつくろう!

バチャをした。

最初のほうに考えたこと:

  • 小さい範囲での全探索の結果は、偶数しかない。
  • 例えば40があったら20を入れるのは意味なさそうだし、逆に20を入れるなら40を入れるべきという話にはなる。
    • 整除関係において極大な数に絞る?
    • 整除関係において極大だからといって、素数を入れる意味はなさそう。
    • いちばん大きな数の半分以下の数を調べなくてよい。(2倍を入れて損はないので。) とすると、$(5\cdot10^8, 10^9]$を探す感じか。
    • $(5\cdot10^8, 10^9]$の中の偶数から探すとすると、$2.5 \times 10^8$個を調べることになる。この程度ならひとつひとつ調べるような方針はありそう。
  • ある程度$d(n)$が大きい整数の集合から愚直焼きなまし、はありえる?

そもそも約数の総数はどのくらいが相場だったかか覚えていない。$10^9$から下って$998000000$あたりまで調べてmaxを探す:

1000000000 100個
999999990 192個
999999840 576個
999999000 1024個
998917920 1080個
...

だいたい相場がわかったので、とりあえず約数が600より多い整数を100個見つけて並べると36000。すでにけっこう高い。

約数の数が700以上、で同じことをやる。(37000)

新たに整数を追加した時のスコアの上昇を調べる:

999999000 1024
999989760 576
999959040 552
999949860 720
999936000 468
999915840 454
999853470 488
999835200 522
999814200 540
999782784 469
999760320 463
999758760 576
999734400 601
999702000 420
999697920 388
999694080 506
999657120 466
999638640 582
999621000 372
999567360 323
999566568 369
999532800 320
999511920 454
999507600 395
999500040 440
999488160 360
999447120 388
999398400 392
999383616 315
999375300 464
999371520 278
999350100 354
999311040 440
999306000 409
999295920 404
999278280 528
999266400 312
999223680 340
999217296 356
999187200 354
999180000 444
999167400 304
999159840 434
999129600 335
999114480 423
999076680 296
999028800 486
999016200 420
998917920 378
998899200 337
998891712 298
998875800 252
998857440 332
998856144 274
998844000 374
998820900 312
998811000 273
998808720 324
998786880 309
998776800 344
998749440 220
998712000 356
998692200 292
998688600 272
998686080 343
998634000 228
998612160 312
998609040 350
998600400 295
998585280 460
998565120 279
998557560 408
998549370 238
998529840 331
998524800 393
998497500 252
998490240 260
998457600 270
998418960 329
998382528 228
998373600 276
998361000 333
998300160 273
998298000 339
998289600 268
998265840 270
998243400 282
998197200 361
998150400 210
998147520 241
998086320 321
998071200 301
998038080 285
998000640 224
997978800 238
997956960 296
997949160 216
997920000 218
997908912 239
997883040 222

追加される約数の数がだんだん減っていく。このカーブを適切に決めて、閾値を超えるものだけ取ればよい?

二次回帰して回帰曲線を超えるものだけ取ろうとしたりする。1時間半ほど経過したところで、そんなことをする必要はないと気づく。できるだけスコアが上がるような貪欲を${O}(N^2)$でやればいいだけだった。

$10^9$から下って約数を400以上持つ整数を6000個列挙して、約数がもっとも増えるものを貪欲に取っていくと41200くらい。そこから山登りして41500くらい。


リアルタイムでchokudaiさんのヒントがあったらしい。でも、気づいていたら逆にこれに囚われてしまって苦戦しそう。

2020年6月15日月曜日

CodeChef June Challenge 2020 - The Delicious Cake

CodeChef June Challenge 2020 - The Delicious Cake

与えられた点集合の凸包を考えるとき、凸包上の点をいくつか省いて内側の多角形に使うことは不可能に見える。そこで、凸包を何回も取ってネストされたレイヤー(凸多角形)を作り、クエリで与えられた頂点がどのレイヤーに含まれるか調べるというのを基本方針として思いつく。

ただ、与えられた点で1つのレイヤーの内部に含まれるものはそれ自身がいずれかのレイヤーに属さなければならないという条件がちょっと厄介に見える。縮退が起きている場合の扱いも書かれていなくてよくわからない。

しかし、とりあえず面倒なことは一切起こらないものとして、単純なコードを提出してみたら通ってしまった。(EPSすらちゃんと考慮していない)

CodeChef June Challenge 2020 - Convenient Airports

辺をつなぎかえて2つの連結成分を1つの連結成分にするとき、サイクルをつぶしながらつなげばノーコストでつなげるので、連結成分ごとにサイクルの数のようなものを数えたい気持ちになる。何を数えるのか突き詰めると、結局「木と比べた時の余剰辺の数」を数えればいいことがわかる。

$K$個の連結成分のサイズを$n_1, ..., n_K$、辺数を$m_1, ..., m_K$とするとき、$m_1-n_1+1, ..., m_K-n_K+1$がそれぞれの余剰辺の数にあたる。連結成分が余剰辺の数に関して降順にならんでいるとする。これらを先頭から連結させていくとき、連結後の余剰辺は$\sum m_i - \sum n_i + 1$個であり、これが0になるまではノーコストで連結できる。余剰辺が0になるのは残っている連結成分がすべて木になる時で、以降は木同士をコスト2でつないでいく必要がある。

ただし、孤立点は(余剰辺のある連結成分に対してもノーコストでつなげないので)特別に扱う必要がある:

  • 連結成分$C$に余剰辺が1つあると、2つの孤立点をコスト2で$C$につなぐことができる。
  • 連結成分$C$に余剰辺がない、あるいは孤立点が1つしかない場合は、1つの孤立点をコスト2で$C$につなぐ必要がある。

さて、スコアを求めるだけではなく実際にシミュレーションしなくてはならない。最初にUnion-Findで辺を走査して、連結成分ごとに「ベースとなる木に含まれる辺」と「余剰辺」に分けた上で、余剰辺の数の降順でソートし、それらを(辺を組み替えながら)併合していった。孤立点は最初から分離しておいて、最後に併合した。

CodeChef June Challenge 2020 - Operations on a Tuple

場合分けがかなりありそうだが、簡単なケースから考えていく。

  • 0手で到達できるのは3対が最初から一致している場合
  • 3手あればかならず到達できる
  • 1手で到達できるのは次の5つの場合
    1. 2対が一致する
    2. 1対が一致して、残り2対の差が等しい
    3. 1対が一致して、残り2対の比が等しく、整数である
    4. 3対の差が等しい
    5. 3対の比が等しく、整数である

あとは2手と3手を区別する問題になるので、2手で到達できる $\Leftrightarrow$上の1-5のうちの少なくとも1つの状態に1手で到達できる、にしたがって考えればよい。とりあえず$p$に対する「意味のありそうな加算・乗算」を全部並べてみる:

  • $p$に整数を足して$a$と等しくする ($a-p$を足す)
  • $p$に整数を掛けて$a$と等しくする ($a/p$を掛ける)
  • $p$に整数を足して、$a-p$と$b-q$を等しくする ($a+q-b-p$を足す)
  • $p$に整数を掛けて、$a-p$と$b-q$を等しくする ($(a+q-b)/p$を掛ける)
  • $p$に整数を足して、$a/p$と$b/q$を等しくする ($(aq-bp)/b$を足す)
  • $p$に整数を掛けて、$a/p$と$b/q$を等しくする ($aq/bp$を掛ける)
  • $p$と$q$に同じ整数を掛けて、$a-p$と$b-q$を等しくする ($(a-b)/(p-q)$を掛ける)
  • $p$と$q$に同じ整数を足して、$a/p$と$b/q$を等しくする ($(bp - aq)/ (a - b)$を足す)

これらの8種類の加算・乗算を$p$には必ず適用するとして$q, r$に同じ操作を適用する/しないの4通りを調べ、それぞれの結果に対してあとで1手で到達できるかを上の基準で判定する。さらに、全体を$p, q, r$の順列6通りに対して調べる。

あとは、ゼロ除算が絡む場合の処理について検討する。「1手で到達できる場合」の3と5については場合分けするとして、後半の8種類の操作について考える。非ゼロ/0のように割り算が不能な場合については、その操作自体が不可能ということなので無視してよい。また、0/0が現れる場合は操作をせずとも目標とする状態が達成されているということなので、ほかの判定で拾われているはず……ということで、やはり無視してよい。

CodeChef June Challenge 2020 - Guessing Game

guessg1

嘘がない場合と同様に、二分探索で範囲を狭めていけそう。まず、上図の[A]のように全体の1/2の位置にある数を尋ねてGと返ってきた状況を考える。(赤線とその隣にあるアルファベットが、質問した位置と答えを表す。LIEは「真の数を含んでいるとすると答えが嘘である」区間を表す。)その次に全体の1/4の位置にある数を尋ねると、二回連続で嘘をつけないという条件により、3段目で×をつけた区間は除外できる。同様に、1回目でLが返ってきた場合は3/4の位置にある数を尋ねると同等の結果が得られる。2回の質問で可能な空間のサイズが3/4になっているので、これを繰り返せば部分点が取れる。

満点の条件では120回の質問で突き止める必要があるが、上の方針では最終的な候補数が$10^9 \times (3/4)^{60} \simeq 32$程度にしかならない。そこで、上図の[B]のようにG→Lという答えが返ってきた場合に、追加で3/4の位置にある数を尋ねるとサイズが1/2になることに注目する。この比率が毎回達成できれば間に合う計算だが、G→Gの場合にうまい追加質問がない。つまり、この区間割ではできればG→L, L→Gと答えてほしくてG→G、L→Lは望ましくないということになるが、それは割り方が最適でないということを意味する。

そこで、区間の割り方を調整して、G→LでもG→Gでも同じレートで空間が縮むようにしたい。2回目の質問で1/4の代わりに位置$x \in [0 ,1]$で区切るとする。このとき、G→Lでは3回の質問で空間が$x+1/4$倍になり、G→Gでは2回の質問で$1-x$倍になる。1回あたりの改善率が同じになる条件は$(x+1/4)^2 = (1-x)^3$なので、$x \simeq 0.31586$と求まる。計算してみると、120回の質問で足りることがわかる。


想定解の改善率は2回あたり33%だった。2回の質問を1セットとして、G→Lの場合には次のセットで前の結果を利用できるらしい。

2020年6月6日土曜日

BBC 004 E - simple finalization

(引き分けを除いて)ちょうど$k$回戦後に$M$人が残る場合の数を考える。最後に残るプレイヤーの選び方が$\binom{N}{M}$通りで、残りの$N-M$人に$k$回のうちのどこで負けるかを割り振る必要がある。これは写像十二相の「ボールも箱も区別できる・各箱に少なくとも1つのボールが入る」場合にあたり、第二種スターリング数で書くなら$k! \cdot S(N-M, k)$。あとは$k$回の各じゃんけんで勝ちになった手がそれぞれ$3$通りありえるので、全体としては$\binom{N}{M}\cdot k! \cdot S(N-M, k) \cdot 3^k$通りと計算できる。これを$k=1, 2, ..., N-M$について足せばよい。

計算量はスターリング数の求め方次第で、Min_25さんの記事によると${O}(N\log N)$でできるらしい。

第三回 アルゴリズム実技検定

リアルタイム受験した。A~Nを解いて辛うじてエキスパートだった。(しかもNはオーダー的に間違っていると知りつつ枝刈りで通した……)

K

次の4つの配列を持ってシミュレーションする:

  • $\operatorname{bottom}[x] :=$ 机$x$の一番下にあるコンテナ
  • $\operatorname{top}[x] :=$ 机$x$の一番上にあるコンテナ
  • $\operatorname{prev}[y] :=$ コンテナ$y$の下にあるコンテナ
  • $\operatorname{next}[y] :=$ コンテナ$y$の上にあるコンテナ

もっと楽にできるらしい。

L

優先度付きキューを二つ用意して、一つは先頭の商品全部が入っている状態を保ち、もう一つは二番目までの商品全部が入っている状態を保つ。(どちらかのキューに既に買われた商品が残っている、は問題ない。買われたものを記録しておいて、既に買われたものを取り出した場合は飛ばせばよい)

N

$x$をスワップするクエリが来ると、区間$[x, x+1]$の順序が乱れる。順序が乱れたところを記録しておいて、ソートクエリが来たらそこだけソートすればよさそうだが、計算量的に「正しい」やりかたを書けなかった。例えば$[10, 30], [40, 50], [100, 120]$が乱れているとして、$[20, 110]$のソートクエリが来たら、$[20, 30], [40, 50], [100, 110]$だけソートすればよい……のだが、それによって正しい順序に戻るのは$[40, 50]$だけで、$[10, 30], [100, 200]$は全体としては乱れたままなのが困りごとだった。(つまり、素朴にやると中途半端な区間を何度もソートさせるような入力でオーダーが改善していない。) この例でいうと、$[10, 30]$のうち$[20, 30]$がソートされたら、もう一度$[20, 30]$や$[21, 30]$のソートクエリが来ても何もする必要がないとか、列を平衡二分木などで保持しておいて、$[18, 30]$のソートクエリが来た時に、$a_{19}, a_{18}$だけ挿入しなおすような処理ができればならしで間に合っているとか、部分的なアイデアは思いつくものの全体としてうまく動くようにまとめきれなかった。(しかし、終了直前に不完全なまま出したら通ってしまった。)

「中途半端なソート」でも転倒ベースで見ればうまく扱える、確かに。しかし、それでも実装はまあまあ大変に見える。

2020年5月28日木曜日

JOI2009 春合宿 - Pyramid

ピラミッドを高さの降順にソートしてBFSのイメージで埋めていく。具体的には$H=3000$から降りながら次を繰り返せばよい:

  1. 頂上の高さが$H$のすべてのピラミッドについて$H$を答えに足し、その座標$(x, y)$を(スタックなりキューなりに)積んでおく。
  2. 前のループで高さ$H+1$として積んでおいた頂点の八方を調べて、それぞれ訪問済みでなければ$H$を答えに足して座標を積んでおく。

メモリ制約が厳しいのでいろいろ使いまわす必要がある。

JOI2010 春合宿 - Sengoku

$(i, j)$を通る傾き$1$の直線の切片は$j-i$で、傾き$-1$の直線の切片は$i+j$になる。

とりあえず重複のことは忘れて番兵毎に数えると、傾き$1$の直線の通るマスは$L - |i-j|$個あり、傾き$-1$の直線の通るマスは$i+j < L$のとき$i+j+1$個、$i+j \ge L$のとき$2L - (i+j+1)$個あることがわかるので、まずはこれらの総和が基本になる。

次に、重複を除くことを考える。まず、切片と傾きが同じ直線は完全に重なるので、登場した切片を記録しておいて、前に見たものは無視することで重複を避けられる。

最後に傾き$1$の直線と傾き$-1$の直線の交点の数を引く必要がある。まず、交点が格子点になるのは切片が偶数同士、奇数同士の場合なので、切片が偶数の直線についてのみ考えることにする。(奇数のものの交点も同様に数えられる。) 傾き$1$で切片が$C$の直線と傾き$-1$の直線が与えられた領域内で交わるのは、後者の切片が$[|C|, 2(L-1) - |C|$]に入っているとき、またその時に限るので、ソートしておいてそれぞれ二分探索で数えられる。

2020年5月25日月曜日

CodeChef May Cook-Off - Chefina on a Trip

無向辺を2つの有向辺と見て、スコアが下がる向きに重み$-1$、上がる向きに$+1$、変わらないなら$0$を付与すると、ある有向パスが上りになっている$\Leftrightarrow \min$クエリが$1$である、下りになっている$\Leftrightarrow \max$クエリが$-1$である、と判定できる。

ダブリングLCAの構築をする時に、同時にmin/maxについてもダブリングのテーブルを作っておけばパス$u - v$がbeautifulかどうかは適当に判定できる:

  1. $u$から根に向けてスコアが上がる限りダブリングで上る。$\operatorname{LCA}(u, v)$の深さより上(inclusive)に行けたら3に行く。
  2. さらに根に向けてスコアが下がる限り上る。$\operatorname{LCA}(u, v)$の深さより上に行けなかったらfalseを返す。$\operatorname{LCA}(u, v)$の深さより上に行けたら頂上が$u- \operatorname{LCA}$の側にあると記録しておく。
  3. $v$から根に向けてスコアが上がる限り上る。$\operatorname{LCA}(u, v)$の深さより上に行けたらtrueを返す。頂上が$u-\operatorname{LCA}$の側にあるのに$\operatorname{LCA}(u, v)$の深さより上に行けなかったらfalse。
  4. さらに根に向けてスコアが下がる限り上る。$\operatorname{LCA}(u, v)$の深さより上に行けたらtrueを返す。
  5. falseを返す。

山のイメージに従って書くだけなので特に難しいとは感じなかった。でも、振り返ってみると、$u$と$v$を完全に対称に扱ったほうがいい気がする。

2020年5月24日日曜日

パ研コンペティション3日目 E - 美しい和音

$f(i, x) := A_1, ..., A_i$を使って作れる総和$x$の美しい和音の総数、とする。$f$の漸化式は$A_i$を使う/使わないの二択の和で$f(i, x) = f(i-1, x) + f(i-2, x-A_i)$と書ける。これでDPすれば部分点が取れる。

また、DPのほとんどの枝で不可能な状態の遷移をしていることに注目すると高速化できる。例えば、$A_1, ..., A_i$から作れる美しい和音の最大スコア$g(i)$がDPで求まるので、これを事前に求めておいて、$g(i)$が$x$より小さくなったら枝刈りしてよい。これで満点が取れた。


でも、計算量がよくわからない。

2020年5月21日木曜日

ふか杯 5th contest E - すごろく

$f(i) :=$ マス$i$からゴールするまでにサイコロを振る回数の期待値、として降順にDPしたいが、振り出しに戻る場合の扱いを考えなくてはならない。

通常、ループを含む確率DPは左辺と右辺の両方に$f(i)$が登場する式が立式できて、$f(i) = ...$となるように整理すると遷移がわかるはずだが、この場合は紙の上で式変形するのは難しそうに見える。そこで、$f(1)$の係数も別にDPで求めることにする。つまり、$f(i) = a(i) + b(i)f(1)$と表して、$a, b$を降順で求める。

$x_i>0$なら

$$ a(i) = a(i + x_i) $$ $$ b(i) = b(i + x_i) $$

$x_i = 0$なら

$$ a(i) = \frac{1}{6}(a(i+1) + ... + a(i+6)) + 1 $$ $$ b(i) = \frac{1}{6}(b(i+1) + ... + b(i+6)) $$

$x_i = -1$なら

$$ a(i) = 0 $$ $$ b(i) = 1 $$

これで$a(1), b(1)$が得られるので、答えは$f(1) = a(1)/(1-b(1))$と求まる。

2020年5月18日月曜日

第一回日本最強プログラマー学生選手権予選 C - Cell Inversion

区間の組み合わせの総数が求まれば、操作の順番の総数は$N!$を掛けて得られるので、前者を求めればよい。

$S$を左から走査して、閉じていない区間を保持しながらDPすることを考える。つまり、

$\operatorname{dp}[x][y] := x$文字目までみて、閉じていない区間が$y$個あるような場合の数

というDPをする。$x$文字目が$W$で$y$が偶数、もしくは$x$文字目が$B$で$y$が奇数なら、ここで$y$個の(閉じていない)区間のどれかを閉じるのが正しいので、

$$ \operatorname{dp}[x+1][y-1] \operatorname{+=} y \cdot \operatorname{dp}[x][y] $$

$x$文字目が$B$で$y$が偶数、もしくは$x$文字目が$W$で$y$が奇数なら、さらに新しい区間を開始して反転を1増やすべきなので

$$ \operatorname{dp}[x+1][y+1] \operatorname{+=} \operatorname{dp}[x][y] $$

このDPは${O}(N^2)$の形になっているが、実際にやってみると各$x$について有効な$y$は高々1つしかないことがわかるので、線形で書ける。


コンテスト当時にはまったくわからなくて、解説を読んでも天才解法に見えた問題だった。今見ると、いわゆる箱根駅伝DPを書けばアドホックな考察をしなくても解けてしまうことがわかる。

ただ、${O}(N^2)$のDPが見えているときに、それの延長として想定解が導ける場合とかなり難しい場合があって、コンテスト中にそちらにどこまで頭を使うかは微妙なところとは思う。今出ても、ムーブ次第ではまりかねない気がする。

ABC 168 F - . (Single Dot)

縦線と横線で分割される$(N+1)(M+1)$個くらいの矩形について、隣接している矩形との連結性を調べながらBFSすればよい……のだが、なかなか通らなかった。

$C_i = C_j$のような場合の扱いが問題だった。そういうケースがありえることは承知していて、間に面積0の矩形があると考えて問題ないと思っていたのだが、$(3, 1) - (5, 1), (4, 1) - (6, 1)$のように縦線が重なっている場合($\Leftrightarrow A_i \le B_j \land A_j \le B_i$)に、前者の左にある矩形→中間の(面積0の)矩形→右の矩形、のように本来移動できないところを迂回して移動できてしまう可能性があることに気づいた。結局、前処理の時点でつながっている線分は1本につなげてしまうことで解決した。

2020年5月17日日曜日

Google Code Jam 2020でのSBCLのトラブル その2

GCJ Round 2に出たが、Round 1に引き続いてSBCL絡みのトラブルにあってしまった。

以下の(特に意味のない)コードをSBCL 1.3.8 ~ 1.4.5に打ち込むとコンパイルが終了しない。

(defun test (n graph)
  (let ((vector (make-array n :initial-element 1)))
    (setf (aref vector 0) 0)
    (let ((value 1))
      (dotimes (i n)
        (dolist (v (list 1))
          (setf (aref vector v) value)
          (dolist (y (aref graph v))
            (print y)))))))

1.3.7→1.3.8と、1.4.5→1.4.6の変化を眺めた感じでは、make-arrayの最適化に関連して低レベルの問題が起こっていそうで、(declare (fixnum n))(declare (notinline make-array))を宣言すると問題が解消する。詳細はわかっていない。

Security Updateのvisibleをとりあえず通しておこうとして起きた現象で、この対処に時間を費やしてしまった。自分の準備にも問題があって、ローカルではバージョン1.4.14を使っていたので単純に問題の把握が遅れたというのがある。(GCJの環境は1.3.14だが、Windowsリリースはないし、CodeChefの1.3.13で問題が起きたことがないので気にしていなかった。) 本格的にWSL環境に移ることを考えたほうがよいのかもしれない。

しかし、こんな基本的なコードが落ちるのはさすがに驚く……


コンテスト後にはさすがに凹んだけれど、去年よりは確実に強くなっている。来年も頑張ろう。

2020年5月11日月曜日

CodeChef May Challenge 2020 - Chef and Bitwise Product

いったん$L$と$R$を無視して考えると、$X$と$Y$の少なくとも一方が立っているビットは$Z$も1にしたほうがよいし、そうでないビットは0にするべきである。

$L$と$R$を導入する場合にもこの考え方が使える。桁DPのように「$L$にはりついている」フラグと「$R$にはりついている」フラグを持ちながら、上位の桁から$Z$を全探索することを考える。両フラグがオフになった枝は残りをどう選んでも$L$より大きく$R$より小さいことが確定するので、残りの桁は上の考え方によって一通りに決めることができる。探索する枝は桁数に対して高々線形になる。

CodeChef May Challenge 2020 - Not a Real World Problem

部分点について。$N \le 15$なら全探索でよい。$H = 2$なら縦2マスの符号の状態(4通り)を持ちながら左から右にDPすればよさそう。

一般の場合について。燃やす埋めるでできたら話が早いと思ってyosupoさんの記事を眺めると、できそうだ。異なる色に塗ると罰金がかかるタイプの問題であることがポイントか。

以下、yosupoさんのフレームワークに従って考える。粒子$i$を正に塗る、負に塗るという言い方を使う。また、粒子$i$の載っているマスに書かれている数を$h_i$とする。

  • $h_i \ge 0$の場合: $i$を正に塗ると$p_ih_i$円得られる。負に塗ると$p_ih_i$円失う。→ 最初から$p_ih_i$円持っているとする。$i$を負に塗ると$2p_ih_i$円失う。
  • $h_i < 0$の場合: $i$を正に塗ると$p_ih_i$円失う。負に塗ると$p_ih_i$円得られる。→ 最初から$p_ih_i$円持っているとする。$i$を正に塗ると$2p_ih_i$円失う。
  • 粒子$i, j$が隣り合っているとき、$i$を正(負)に、$j$を正(負)に塗ると$p_ip_j$円得られる。$i$を正(負)に、$j$を負(正)に塗ると$p_ip_j$円失う。→ 最初から$p_ip_j$円持っているとする。$i$を正(負)に、$j$を負(正)に塗ると$2p_ip_j$円失う。

これをそのままグラフにして、最大フローを流せばよい。


ほとんどの$h_{y, x}$に意味がないのはなんだったんだろう…… 誤読を疑って何度も確認してしまった。

CodeChef May Challenge 2020 - Triple Sort

3つの数のシフト操作は2つの互換でできていて、つまり偶置換なので、与えられた置換が奇置換だとどうやっても$1, 2, ..., N$に戻すことはできない。

偶置換の場合について具体的な手順を与えるために、与えられた置換を巡回置換に分解して考える。長さ$k(\ge 3)$の巡回置換$(p_1 p_2 ... p_k)$があるとき、$p_1, p_2, p_3$に操作を適用すると先頭の2つが正しい場所に収まって長さ$k-2$の巡回置換$(p_3 ... p_k)$が残る。$k=3$の場合はこの操作ですべての数が正しい場所に戻るので、結局$k$が奇数ならこの操作を最後まで繰り返せることになる。いっぽう$k$が偶数の場合は、長さ$2$の巡回置換、つまり互換が残るので、これらの処理を考える。2組の互換$(p_1 p_2), (p_3 p_4)$がある時、操作$p_1, p_2, p_3$と$p_2, p_3, p_4$を順に適用することで、4つの数がすべて正しい位置に戻る。こうして2組ずつ処理していったとき、偶置換であれば最後に端(=1つの互換)が残ることもないので、けっきょく常に$1, 2, ..., N$に戻せることになる。また、操作1回につき2つの数を正しい位置に戻せるので、操作回数の上限も満たせることがわかる。

CodeChef May Challenge 2020 - Sorting Vases

$M=0$なら巡回置換に分解して数える典型問題。また、$N=5$程度なら各順列を頂点として0-1BFSで探索すればよい。

満点について。まず、2つのロボットが与えられたときに、「少なくともいっぽうのインデックスが等しい」という関係が同値関係になっていることに注目する。このため、$N$個のインデックスはノーコストで自由に行き来できるまとまりに分割できる。

具体例を挙げて考える:

1
9 5
5 8 2 4 6 7 9 3 1
2 5
5 6
3 4
7 8
8 9

この入力をグラフで図示すると次のような感じになる:

sortvs1

各頂点は、コスト0で相互に行き来できるインデックスを1つにまとめたものである。また、辺$A \rightarrow B$は$A$に含まれる場所から$B$に含まれる場所にちょうど1つの数を移動させなければならないことを表す。たとえば$5$は最初インデックス$1$にあって、インデックス$5$に移動させたいので、頂点$1$から頂点$5(, 2, 6)$に辺を張っている。

こうしてグラフにしてみると、$M=0$の場合と考え方はあまり変わらないことに気づく。つまり、このグラフ上の長さ$k$の閉路は$k-1$回のスワップで解消できるので、グラフをいくつかの(辺素な)閉路に分割する問題であることがわかる。また、必要なスワップの回数は閉路の総数のみによって決まることも変わらない。

さて、閉路の総数だが、下のグラフのように分割方法によって異なる場合がある。

sortvs2

このグラフでは、長さ2の閉路を3つ選ぶことも長さ3の閉路を2つ選ぶこともできる。したがって、スワップの回数を減らすために、できるだけ多くの閉路に分割する方法を考えなければならない。ここまで考察を進めるとbitDPの形が見えた:

$\operatorname{dp}[S][x][y] :=$ 辺の集合$S$を使用済みで、いま作りかけている閉路(まだ閉じていない)の始点が$x$、終点が$y$である時の、今までに作った閉路の総数の最大値。

遷移が${O}(N)$なので計算量は${O}(N^32^N)$になる。オーダー的には微妙なところだが、大半の状態は無効だし、各$N$がすべて$18$ということもありえないので大丈夫だろう。割と余裕で通った。(計算量の評価がtightじゃないかも) 辺数が${O}(N)$だから隣接リストで持てば${O}(N^2 2^N)$だった。

CodeChef May Challenge 2020 - Chef and Rainbow Road

要約: $a_1, ..., a_N$から (1) 重複を許して選んで (2) どれも少なくとも1個以上選んで (3) 合わせて$N+K-1$個になるように選んで、積を取るとする。それらの総和を求めよ。

最低$1$個以上という条件を外して$K-1$個選ぶことにして、求まった値に$a_1...a_N$を掛ければよい。

多項式で考えるんだろうなという検討はつくが、ほとんど何も知らないのでmaspyさんの記事などを見て式を考える。とりあえず

$(1+a_1x+a_1^2x^2 + ...)(1+a_2x+a_2^2x^2 + ...) ... (1+a_Nx+a_N^2x^2 + ...)$

の第$K-1$項(0-based)を見ればよいことがわかる。この式は$((1-a_1x)(1-a_2x)...(1-a_Nx))^{-1}$と表せて、$(1-a_1x)(1-a_2x)...(1-a_Nx)$は分割統治してFFTすればよく、逆元もFFTで求まるらしいので、$K \le 10^5$の部分点1, 2はそれで取れる。

部分点3からはこの方針では無理そう。ところで、この間のlong challengeのまったく歯が立たなかった問題についてNyaanさんが記事を書いていたのを思い出して見に行ったら、部分分数分解というワードが出てきて使えそうと思う。

$$ \frac{1}{(1-a_1x)(1-a_2x)} = \frac{a_1}{(a_1-a_2)(1-a_1x)} + \frac{a_2}{(a_2-a_1)(1-a_2x)} $$

と分解できるので、全部掛けると$1/(1-a_1 x)$の係数は$a_1^{N-1}/\prod_{j \neq 1} (a_1 - a_j)$になる。対称性により$1/(1-a_i x)$の係数は$a_i^{N-1}/\prod_{j \neq i} (a_i - a_j)$であり、これが$1+a_i x + a_i^2 x^2 + ...$に掛かっているので$x^{K-1}$の係数は$a_i^{K+N-2}/\prod_{j \neq i} (a_i - a_j)$とわかる。この値をすべての$i$について加算すれば答えが得られる。部分点3が取れた。

満点について。$a_i^{K+N-2}/\prod_{j \neq i} (a_i - a_j)$の分母の形を見るとヴァンデルモンド行列式やラグランジュ補間などが思い浮かぶので、ラグランジュ補間の解説を読んでいて$(x-a_1)...(x-a_N)$を微分したものに代入すればいいと気づく。多項式の値を$N$個の点について求める必要があるが、ググると${O}(N\log^2 N)$でできるらしい → 間に合った。


最終日に満点に気づいた。時間がなさすぎたのでbeetさんのライブラリを大急ぎで写経…… 長期コンテストでも終了直前に方針が立つと結局時間に追われることになるな。

CodeChef May Challenge 2020 - Binary Land

$1$が書いてあるマスから$1$が書いてあるマスへのパスの求め方だけ検討すればよい。

2地点間のパスの総数自体は単純なDPで求まる。DPの遷移は$N \times N$の行列で書けるので、行列積をセグメント木などに乗せれば${O}(N^3Q\log Q)$で解ける。また、クエリが単調なことを利用するとSWAGが使えて${O}(N^3Q)$になる。

満点を取るためにはもう少し頑張る必要がある。まず、遷移の行列が三重対角行列になっていることに注目すると、行列積は${O}(N^2)$になる。したがって、SWAGへのpushとpopはこの計算量でできる……が、三重対角行列同士の積が三重対角行列になるわけではないので、SWAGをfoldする時の掛け算はどうしても${O}(N^3)$になってしまう。しかし、pathクエリに対して必要なのは行列そのものではなく一点の値なので、そこだけ求めるのは${O}(N)$で可能だった。これで${O}(N^2Q)$になった。

これでもなかなかACできなかった。行列をなるべく陽に持たずに省略できる部分は省略して、定数倍高速化を徹底するとようやく通った。

CodeChef May Challenge 2020 - Buying a New String

たくさん解かれているのに部分点すらわからないので、書いたことのない文字列アルゴリズムだろうなと検討をつける。Aho-Corasickがいちばんそれっぽいので、貼るととりあえず部分点が取れた。

$|A||B|$個の文字列間で遷移の共通部分が多いことを利用できれば満点が取れそう。そして、そのためにはAho-Corasickをちゃんと理解する必要がありそう。しかし、ei1333さんのコードを見ていても理屈がよくわからないので、明らかな性質だけでなんとかすることにした:

  • Aho-Corasickのマッチは部分文字列の一致をひとつずつカウントする。つまり、まとめてカウントしたりはできないので、素朴にやるとマッチする文字列の長さに対して${O}(N^2)$になる。とりあえず、トライ木を作ったらノード毎に重みの和をまとめておいたほうがよさそう。たぶんそれでオーダーも落ちる。
  • Aho-Corasickの状態は今いるノードだけで決まる。つまり、ノードが同じで以降の文字列が同じならその後の遷移はすべて同じ。

以上に基づくと、次の配列を作っておけばよいはず:

$\operatorname{cumul}_A[i] := A$の区間$[0, i)$を走査した後のスコア
$\operatorname{node}_A[i] := A$の区間$[0, i)$を走査した後のノード
$\operatorname{cumul}_B[i] := B$の区間$[0, i)$を走査した後のスコアを$0$として、そこから最後まで走査して得られるスコア。
$\operatorname{node}_B[i] := B$の区間$[0, i)$を走査した後のノード

$A, B$をそれぞれ$[0, i_1,), [i_2, \operatorname{length}(B))$で切った時のスコアは$\operatorname{cumul}_A[i_1]$に$\operatorname{node}_A[i_1]$から$B[i2, \operatorname{length}(B))$を走査して得られるスコアを足したものになるが、この途中で、$B$の位置$i$を走査する際にノードが$\operatorname{node}_B[i]$と一致していることが確認できたら$\operatorname{cumul}_B[i]$を足して打ち切ってよい。単語の長さが高々26なので、$i_1$からそれだけ遷移すれば一致するはず。計算量はたぶんアルファベットの数を$\alpha$として${O}(\alpha|A||B| + \sum_i S_i)$。


そしてまだAho-Corasickを理解していない……

2020年5月2日土曜日

第二回 アルゴリズム実技検定

リアルタイム受験した。4時間40分かけて全完。前回に比べるとかなり苦戦した。

F

別解っぽいの:

$B_i$の降順に埋められるできるだけ早い日を埋めていく貪欲が成立する。(交換しても損しないことに注目すればよい。) このとき、$\operatorname{next}[i] :=$ $i$日目以降(inclusive)でまだ埋めていない最も早い日、として空きを管理する。$\operatorname{next}$を辿る際にpath compressionするとたぶん${O}(N\log N)$。

K

(を見るとネストの深さが+1され、)を見ると深さが-1されると考えると、文字を削除(=無視)するのは深さを変えないで次に進むことと解釈できる。したがって、次のようなDPが可能である:

$\operatorname{dp}[x][y] :=$ $1, ..., x$文字目まで見て、深さが$y$の場合の最小コスト

答えは$\operatorname{dp}[N][0]$に等しい。${O}(N^2)$。

L

$K$個の要素を選ぶ際、先頭から$x$番目(0-based)の数の元のインデックス$i$(0-based)は$i < N-D(K-x-1)$を満たす必要があり、その中で最小(タイブレークはもっとも左)の数を選べばよい。優先度付きキューでできる。

M

今回いちばん大変だった問題で、最後にACした。

数列$(C_i)$をいくつかつなげたものを走査して、各食事$x$についての情報を累積していく。具体的には、$\operatorname{table}[x]$に(日付, この日付までに食べた料理$x$の数, この日付までに食べた$x$以外の料理の数)の日付に関する昇順配列を作って、料理$x$が出るたびに情報を追加していった。こうすると、ある日付から一巡したときに食べる好みの料理の数と好みでない料理の数は二分探索でわかるので、何周するか計算すればよい。ただし、端の処理をするのがかなりつらかった……

N

平面走査する。$x$座標を圧縮しておいて$y$座標に関して走査した。

O

問題の条件を無視して最小全域木$T$を得たとする。$T$に$T$外の任意の辺$e := (u, v)$を追加すると閉路ができるが、この閉路上でもっとも重みの大きな($e$以外の)辺を除けば求める最小全域木$T_e$が得られる。(正当性は最適性条件により明らか……と言いたいところだが、そんなに簡単な証明を思いつかなかった。下に書いた。)この「辺の重みの最大値」は$T$上のパス$u - v$に関する$\max$クエリを処理すれば得られる。

やることはわかったのだが、そもそも木上のパスクエリを処理した経験があまりない。単純なオイラーツアーではmin/maxの処理ができないように見えるし、HL分解やLC木は持っていないし、Mo's algorithmでは${O}(1)$の遷移が思いつかないし、しばらく困っていた。HL分解を調べて書こうと決めたところで、そもそも更新がないのでLCAのbinary liftingがそのままmin/maxクエリにも使えることに気づいた。やっぱり経験がないとこういうことがぱっとわからないな。

証明: $e$のコストを$w$とし、このコストを$0$に変えたグラフを$G_0$とする。また、$G_0$のMST $T_0$が与えられたとして、そのコストの総和を$W_0$とする。(path optimality conditionより)$T_0$は$e$を必ず含むので、$T_0$は$G$上でコスト$W_0 + w$の全域木になっている。$G$の$e$を含む全域木 $T'$でこれより総コストの低いものが存在すると仮定して、総コストを$W'(< W_0 + w)$とする。$T'$は$G_0$の全域木でもあり、コストの総和は$W'-w$だが、$W'-w < W_0 + w -w = W_0$なので、$T_0$がMSTであるという仮定に反する。したがって、求める全域木は$G_0$のMSTであることがわかった。

あとは、上の作り方でできた全域木$T_e$が$G_0$上でMSTであることを示せばよい。$T_e$外の任意の辺$e'$を追加すると閉路ができるが、この閉路に含まれる$e$以外の辺は($T$の$G$におけるpath optimality conditionより)$e'$以下の重みを持つし、$e$自身は重み$0$なので、やはり$e'$以下の重みを持つ。したがって、$T_e$は$G_0$上でpath optimality conditionを満たす。$\square$

後半は有名事実だと思うけど若干雑…… 書き直すかも

2020年4月21日火曜日

天下一プログラマーコンテスト2012 予選B C - 席が足りない

同じ席に座れる関係を無向グラフ$G$で表すことにすると、可能な席数を求める問題は$G$をいくつかのクリークに分割する問題と解釈できる。したがって、この問題は最小クリーク被覆問題そのものなので、補グラフの彩色数を求めればよい。

彩色数を求める$O(2^nn)$アルゴリズム:
https://ei1333.github.io/luzhiled/snippets/graph/chromatic-number.html
https://codeforces.com/blog/entry/57496
https://www.slideshare.net/wata_orz/ss-12131479

2020年4月12日日曜日

CODE FESTIVAL 2015 E - ショートコーディング

次の3つの変形を適用していく:

  • $!!! = \ !$
  • $-- = \ \operatorname{id}$
  • $!- = \ !$

このとき、可能な解は$!!, !, \operatorname{id}, -!!, -!, -$の6つしかないことがわかる。

2020年4月11日土曜日

Google Code Jam 2020でのSBCLのトラブル

GCJ Round 1Aに出てメモリエラー(?)にハマってしまったのでメモ。

Square Danceのhiddenを落としてしまったのだが、ローカルでいろいろ調べても落ちるケースが見つからない。テストサブミットでしつこく調べていると、どうも入力が大きい時に無条件で落ちているようだった。まさかと思って、dynamic-space-sizeを増やしたSBCLを別に起動して投げてみたら通った。ヘッダを以下のようにする:

(unless (member :child-sbcl *features*)
  (quit
   :recklessly-p t
   :unix-status
   (process-exit-code
    (run-program *runtime-pathname*
                 `("--control-stack-size" "128MB"
                   "--dynamic-space-size" "8GB"
                   "--noinform" "--disable-ldb" "--lose-on-corruption" "--end-runtime-options"
                   "--eval" "(push :child-sbcl *features*)"
                   "--script" ,(namestring *load-pathname*))
                 :output t :error t :input t))))

必要なサイズを調べてみると

  • 1GBに設定 → 落ちる
  • 2GBに設定 → テストケースごとにgcすれば通る
  • 4GBに設定 → 一回gcすれば通る
  • 8GB、16GBに設定 → gcしなくても通る

という感じだった。

しかし、メモリ制約が1GBなのに8GBが必要なのは不可解ではある。たまに起こる現象として、使用量500MBあたりで新たにメモリを確保しようとするときに、1GB以上確保しようとしてheap exhaustedになるというのがあるが、それは2GBや4GBに設定してもだめな理由を説明していない。そもそも、roomとかで調べてもそんなにたくさんメモリを使っているようには見えないし…… ローカルでroswellのsbcl-bin/1.3.14を使っても起きない問題なので、調べるのが難しい。

次のroundの方針は迷うけれど、

  • 最初からdynamice-space-sizeを大きめに設定する。(とりあえず16GB?)
  • 1つ目のテストケースの前に(gc :full t)する。(コンパイルでけっこうメモリを使うので)
  • テストケース数が多すぎなければ一回ごとにgcする。

でいくことにする。

SBCL環境

GCJ 2020のSBCLの詳細は以下の通り:

事前コンパイルはないが、今年も長めのTLでまとめてテストするスタイルなので、コンパイル時間のぶんのロスは気にしなくてよさそう。


上の問題は不運ではあったけれど、そもそもPascal Walkをバグらせてしまったのが悪いとも言える。チャンスはまだあるので、早めに気づけてよかった。

2020年4月4日土曜日

BCU30 F - 数列と計算

区間$[l,r)$に関する掛け算$a_l a_{l+1} ... a_{r-1}$が何回足されるかを数えればよい。区間の右端を揃えて数えると下の表のようになる:

1 2 3 4 5
$a_1 \cdot 2^3$ $a_1a_2 \cdot 2^2$ $a_1a_2a_3 \cdot 2$ $a_1a_2a_3a_4 \cdot 1$ $a_1a_2a_3a_4a_5 \cdot 1$
$a_2 \cdot 2^2$ $a_2a_3 \cdot 2$ $a_2a_3a_4 \cdot 1$ $a_2a_3a_4a_5 \cdot 1$
$a_3 \cdot 2^2$ $a_3a_4 \cdot 2$ $a_3a_4a_5 \cdot 2$
$a_4 \cdot 2^2$ $a_4a_5 \cdot 2^2$
$a_5 \cdot 2^3$

(幅の関係で$N=5$でやったけど、$N=6$くらいのほうがわかりやすいかも)

上の表から、$a_x$を右端とする掛け算の総和から$a_{x+1}$を右端とする掛け算の総和を得るには、だいたい$a_{x+1} / 2$を掛ければよいということがわかる。この係数で帳尻を合わせるために少し改変すると、次のようになる:

0 1 2 3 4 5
$2^4$ $a_1 \cdot 2^3$ $a_1a_2 \cdot 2^2$ $a_1a_2a_3 \cdot 2$ $a_1a_2a_3a_4 \cdot 1$ $a_1a_2a_3a_4a_5 \cdot 1$
$2^3$ $a_2 \cdot 2^2$ $a_2a_3 \cdot 2$ $a_2a_3a_4 \cdot 1$ $a_2a_3a_4a_5 \cdot 1$
$2^3$ $a_3 \cdot 2^2$ $a_3a_4 \cdot 2$ $a_3a_4a_5 \cdot 2$
$2^3$ $a_4 \cdot 2^2$ $a_4a_5 \cdot 2^2$
$2^3$ $a_5 \cdot 2^3$
$2^3$

したがって$\operatorname{dp}[0] := 2^{N-1}$で初期化したあと、$\operatorname{dp}[x] := \operatorname{dp}[x-1]\cdot a_{x}/2 + 2^{N-2}$と更新していけばよさそう。ただし、最後だけは$1/2$を掛けずに$\operatorname{dp}[N] := a_{N}\operatorname{dp}[N-1] + 2^{N-2}$とする。

$\operatorname{dp}[1]$から$\operatorname{dp}[N]$までを、不要な$2^{N-2}$を引きながら足していけば、答えが得られる。


端が含まれると足される回数が変わるということを整理するのに時間がかかった。

2020年3月30日月曜日

ARC 049 C - ぬりまーす

タイプ2の条件はひとまず無視して考える。

タイプ1のそれぞれの条件$(x, y)$について$y$から$x$に辺を張ったグラフ$G$を与えたとき、問われていることは「$G$から入次数が$0$の頂点を次々に消していくとき、最大で何個消せるか?」ということだった。この個数に消す順序は関係ないので、実際に自由に消していって確かめればよい。具体的には、入次数が$0$の頂点をすべてキュー(あるいはスタック)に入れてBFS(あるいはDFS)をすれば${O}(N+A)$でできる。手順としては、キューから頂点を取り出したら、隣接頂点の入次数を1ずつ下げて、入次数が$0$になったものをキューに入れればよい。あるいは、この制約ならBellman-Fordっぽく入次数が0の頂点の隣接頂点の入次数を下げる操作を$N$周繰り返す、でもよさそう。(${O}(NA)$)

タイプ2の条件$(u, v)$については、

  1. $u$を選ばないとする(=頂点$u$は存在しないものとする)
  2. $v$を選ぶ場合には$u$を先に選んでいなくてはならないとする。

の2通りを調べれば十分で、後者はタイプ1と同じなので上の問題に帰着できている。タイプ2の条件は十分に少ないので、$2^B$通りのグラフを調べればよい。${O}((N+A)2^B)$。

2020年3月27日金曜日

競技プログラミングでCommon Lispを使っている人とこれから使うかもしれない人のために

Qiitaからこちらに転載した。AtCoderの環境の更新に合わせて、転載当時の内容から多少の変更を加えている。


表題の通り、競技プログラミングに参加しているLisperとこれから参加するかもしれないLisperのために、必要な情報を一通りまとめています。

コンテストサイトの選択

AtCoderではCommon Lispが使えますし、CLで投稿できる大手コンテストサイトはCodeChefyukicoderCS Academyなど他にもいくつかあります。日本語の問題文があって運営の質が高くてコンテストが頻繁に開催されていて……というような点を考えていくと、競プロに興味を持った日本語圏のLisperにとってAtCoderは第一の選択肢といってよいでしょう。第二はyukicoderとCodeChefです。以下の解説は、特に断りがなければAtCoderを前提にしています。

Lispで競プロすることについて

本題ではありませんが、セクションを立てて言及しておくことにします。というのも、競技プログラミングに熱心なLisperはほぼ皆無に見えるからです。競プロに興味のある人の中にも、そもそもLispに向いているジャンルなのかどうか不透明に思う人がいるかもしれません。

端的に言うと、わたしのレベルではパフォーマンスの面でデメリットを感じたことはありません。より高レートの状況でどうなるかは知りえないことですが、悲観する材料は特に見つかっていません。終わり。

……でもよいのですが、いくつかのポイントに絞って説明してみます。

○: 柔軟な構文

素早く・簡潔に・柔軟にDSLを生やすことにかけてはLispは今なお優れた言語であると言えるでしょう。競プロの文脈でもそれは役には立ちます。もっとも、そういったものは競プロにおけるパフォーマンスにさほど影響しないとも思います。競プロで大事なのは考察力です。

○: スピード

最良ではないが、悪くもないです。AtCoderにおける速さという観点で言語を雑に分類すると

  • A群: C/C++/Rustなどの、システムプログラミングのための言語
  • B群: OCaml/Haskellなど、ネイティブコードへのAOTコンパイルを前提とするその他の言語。Java/C#などの高速VMを持つ言語。
  • C群: Python/Ruby/PHPなどのスクリプト言語

で性能はおおむねA>B>Cということになりますが(あくまで雑な分類です)、LispはBの中で速いほう、くらいの位置づけです。状況にもよるので絶対的な比較は難しいですが、速度面では「Java/C#と同程度かわずかに有利」くらいの認識を持っています。そして、Java/C#専のまま強くなる人は特に珍しくもありませんから、スピードの問題はさほど心配しなくてよいです。

実情を言うと、ビギナーコンテストの問題では正しいロジック(=最悪計算量が制約に適っているロジック)で書けていればTLE(Time Limit Exceeded)することはめったにありません。TLEするのは、最適化が足りないからではなく、計算量の考察がおかしいからと思ってほぼ間違いないです。ほとんどの場合、最適化のための基本的な宣言すら必要ないと思います。それ以上のレベルの問題でも、SBCLが遅くて通せないとか、あるいは最適化しても制限時間ぎりぎりになるという事態に陥ったことは一度もありません。そもそもAtCoderではJavaで通らない問題は出題されないそうなので、SBCLで通せないというケースは考えにくいです。

CS AcademyやHackerEarthなど、事前コンパイルなしで--scriptで実行されるサイトではその分の時間(おおむね50ms~200ms程度)が上乗せされますが、このような環境でも問題になることはまれです。

△: 標準機能・ライブラリの充実度

Common Lispの標準ライブラリは貧弱です。平衡二分木もなければ、優先度付きキューも両端キューもないし、それどころかただのキューすらないので、あらゆるデータ構造をいちいち用意しなければなりません。[1] 良い所もそれなりにはあって、多倍長整数が自然に扱えるのでオーバーフローの処理をちょっとくらいサボってもOKなのは悪くない点ですし、有理数や複素数も競プロでは活躍します。stringが単に文字の配列であるという時代遅れな仕様も、競プロではむしろ望ましいです。しかし、総合的に見てやや不十分なのは否めないでしょう。

もっとも、競プロerはどの言語を使っていてもけっきょくは自前実装のデータ構造を少しは用意することになるので、二分ヒープを自分で書くことくらいは誤差レベルとも言えます。ただ、自分でデータ構造を書く場合に、効率のために要素型で静的ディスパッチするようなコードを書くのが大変という別の問題はあります。CLにはパラメータ多相を自然に導入する方法の決定版がいまだにないので…… (競プロに限った話ではありませんが、ジェネリクスのデファクトがないのはCLのアキレス腱だと思っています。)

△: コンテストサイト・オンラインジャッジの選択肢

Common Lispは最大手のCodeforcesで使えませんし、もちろんTopCoderでも使えません。日本の競プロer御用達のAOJも対応していません。したがって、LisperはAtCoderyukicoderCodeChefを中心に利用し、Google Code Jamがあれば出て、足りなければCS AcademyHackerRankなどを使うことになります。それが不満ならC++やJavaを選んだほうがよいでしょう。ちなみにわたしはまあまあ不満です。

✕: 言語コミュニティの規模

Lisperの数が極めて少ないため、参考にできるコードがありません。AtCoderで400点以上の問題だとLispの提出はゼロであることが多いです。 困ります。もちろん、根幹のロジックを理解するために読むのはなんの言語でもよいので、C++やPythonの提出コードを見ればよいです。しかし、他人のコードを読むのは、整理された間違えにくい書き方を学ぶためという側面が強いので、言語が同じであるほうが参考にしやすいのも確かです。

もっとも、この問題はあなたが参加すれば解消されるとも言えます。ぜひやってみませんか。

いくつかの問題と解決策

実践で生じるトラブルとその回避方法を挙げます。「いくつか」と題しましたが、今のところ重要なものは最初の1つだけです。また、入出力関係の話題はまとめて後のセクションで扱います。

スタックサイズが足りない(重要度:◎)

SBCLのデフォルトのスタックサイズは2MBであり[2]、コールスタックがこのサイズを超えるとエラーになります。(末尾呼び出しはジャンプに変わるので安全と思ってよいです。)

2MBというサイズですが、目安としては105の深さで再帰すると確実にcontrol-stack-exhaustedする、104ならたぶんOK、くらいです。深さを見積もって無理そうなら再帰を使わなければ良いのですが、DFSなどを手動スタック+ループで書くのはやや面倒と感じる人も多いでしょう。幸運なことに、この問題については解決策が確立しています。スタックサイズを増やしたSBCLを子プロセスとして呼び出せばよいのです。ファイルの冒頭に次のフォームを置きましょう。

#-swank
(unless (member :child-sbcl *features*)
  (quit
   :recklessly-p t
   :unix-status
   (process-exit-code
    (run-program *runtime-pathname*
                 `("--control-stack-size" "256MB"
                   "--noinform" "--disable-ldb" "--lose-on-corruption" "--end-runtime-options"
                   "--eval" "(push :child-sbcl *features*)"
                   "--script" ,(namestring *load-pathname*))
                 :output t :error t :input t))))

こうすればスタックサイズの大きいSBCLが代わりに処理してくれます。

  • 終了コード絡みの処理は、子プロセスでエラーが起こった時に親プロセスのエラーにするためにやっています。そうしないと子プロセスでエラーが起こってもREと表示されなかったりします。
  • #-swankは必要ではありませんが、わたしはSLIMEでコーディングしているので、編集中にこのフォームが評価されないようにするために書いています。
  • 直接--scriptで実行する仕様でも、コンパイルしてから実行する仕様でも、どちらでも通るようにしています。ほとんどのコンテストサイトで使えます。forkを呼ぶことになるので、DMOJのようにシステムコールを制限しているジャッジでは使えないようです。
    • 使えることを確認: AtCoder、yukicoder、CodeChef、Toph、CS Academy、HackerRank、HackerEarth、SPOJ、Kattis、GCJ
    • 使えないことを確認: DMOJ
  • 今のところ256MBで足りなかったことはありませんが、128MBで足りなかったことはあります。スタックサイズが足りないとき、必ずしもREと表示されるわけではなく、TLE等になることもあるので気をつけましょう。

やや強引なハックではありますが、2年ほど使ってこの方法の欠陥らしきものは見つかっていません。数ms程度ロスしてわずかにメモリ消費が増えるだけですし、常に置いておいても特に問題ない程度のものです。

また、スタックサイズを書き換えてから新たにスレッドを作るという方法も考えられます:

(defun main () ...)
 
(setf (sb-alien:extern-alien "thread_control_stack_size" sb-kernel::os-vm-size-t)
      (* 256 1024 1024))
(sb-thread:join-thread (sb-thread:make-thread #'main))

ただし、SBCLは起動後にスタックサイズを変更する機能を提供していないので「いちおう動く」だけではあります。最近のランタイムを眺めた限り、普通の使い方では壊れないだろうと思っていますが。(こちらもDMOJでは使えません。)

ベクタのソートが遅い(重要度:〇)

(declare (inline sort))しましょう。SBCLのsortsb-ext:maybe-inlineなので、デフォルトではインライン化されません。[3] インライン化されないと、ベクタの要素型を考慮した静的ディスパッチがされないので、常識的なスピードが出ません。

ただ、SBCLのソートはベクタに対してはヒープソートなので、いずれにせよスピード重視ではないし、標準のソートは範囲指定ができないという問題もあります。[4] マージソートなりクイックソートなりを用意しておくのもよいかもしれません。

リストをsortする場合(マージソート)に関してはさらに(declare (inline sb-impl::stable-sort-list))が必要だったりします。(ソートが間に合うか不安なくらいに入力が大きいケースではリストで持たないほうがよいと思いますが。)

また、普段からSBCLのどの関数がどの場合にインライン化(インライン宣言やdefine-source-transformdeftransformdefine-vopによる変換)されるのか、型を考慮した最適化があるのかを気にしておきましょう。SLIME上なら、標準の関数に対してM-. slime-edit-definitionするとSBCLのソースにジャンプできます。

末尾の改行の扱いが一貫していない(重要度:△)

改行の扱いはコンテストによって異なるのですが、AtCoderのratedに出るだけならあまり気にする必要がないでしょう。あらゆるコンテストに出るなら、一般論としては以下のような指針があります。重要な順に

  1. 出力の末尾に改行がなかったり、余分な改行を入れた時に通るかどうかは不定と思ったほうがよいです。必ず正しい位置に正しい数の改行を送っておきましょう。
  2. 入力の改行コードが#\Linefeed限定と思わないほうがよいです。
  3. 入力の末尾に改行があるという仮定を置かないほうがよいです。

2番目については、CodeChefのようにCRLFが入っているかもしれないことを明言しているサイトもあります。SBCLのread-lineはCRLFをうまく扱えないので、場合によってはstring-trimなどをする必要があるかもしれません。(とはいえ、この辺は「ちゃんとした」コンテストになるほど気にする必要性が下がっていくので、わたしもあまり真剣に対処していません。CodeChefでも最近のratedで困ったことはないです。)

CodeChef: ヒープサイズが小さすぎる(重要度:◎)

CodeChefではメモリは問題に関係なく4GBくらいまで使えるという情報がありますが、SBCLはなぜか--dynamic-space-size 48起動されているのでそのままだと実用するのが厳しいです。上述のスタックサイズを増やすヘッダでヒープサイズも増やせます。(さすがにまずそうな仕様ですが、わたし以外のLisperを見たことがないので報告をさぼっています。)

AtCoder: MPFRが使えない(重要度:△)

double-float以上の精度が想定される問題はほとんど見ないですが、あれば役に立つことはよくあります。SBCLにはMPFRのモジュール(sb-mpfr)がありますが、AtCoderの環境ではrequireしてもlibmpfrが見つからなくて使えません。ジャッジ環境にlibmpfr自体は存在していて、requireの前に(sb-alien:load-shared-object "libmpfr.so.6")でロードするとよいです。自分が使っているヘッダの例はここにあります。

SBCLのバージョン間の差異について(重要度:△)

サイトによってSBCLのバージョンが異なるので、メモしておきます。該当のバージョンより上にある項目が注意点になります。重要な差異は特にないのですが、ジャッジのバージョンと近いものを使ったほうが細かいトラブルが起こりにくいです。Roswell等で切り替えるのがよいでしょう。

  • AtCoder: version 2.0.3
  • 負数に対するmodの返り値が正数であると推論されないことがあります。うまくいかない場合はtheを使いましょう。version 1.5.7でたぶん解決。
  • make-arrayで多次元配列を作る場合、element-typeの推論が正しくできないことがあります。sb-c:make-array-header*のderive-typeをコピペすれば解決しますが、そんなことをしなくても必要なら型を手打ちすればよいです。version 1.5.6で解決
  • yukicoder: version 1.5.5
  • *read-default-float-format*rationalを指定できない。version 1.5.2で解決。
    • rationalが使えるのはSBCL独自の仕様ですが、便利なので載せています
  • maphashが事前指定したサイズに対する${O}(N)$。詳細はここに書きました。version 1.5.5で過去に到達した最大要素数に対する${O}(N)$になりました。どちらの仕様でも、「maphashを何回も呼ぶけど合わせて${O}(N)$」みたいな場合に本当に${O}(N)$かどうか気を付けたほうがよいです。
  • array-element-typeが定数伝搬しません。インライン関数の中で同じelement-typeの配列を作りたい場合等に困るかもしれません。array-element-typeのdeftransformをコピペすれば解決しますが、そんなことをしなくても必要なら型を手打ちすればよいです。version 1.5.0で解決
  • CS Academy: version 1.4.16
  • HackreRank: version 1.4.15
  • CodeChef: version 1.3.13
  • HackerEarth: version 1.3.1

SBCLはかなり昔から安定しているので、処理系が落ちるようなバグはまず無いと思っていいです。私自身は、AtCoderでdynamic-extent絡みのバグを踏んだことが1回ある程度です。(詳細を忘れました)

入出力のTips

大量の入出力をこなす場合(目安としては106文字くらいから)、全部(read)して一行ずつ書き出すような素朴すぎる方法を使うと無視できないロスになることがあります。このセクションでは効率をある程度考慮した方法を紹介します。しかし、ベストプラクティスと言えるような方法は特に確立していないので、以下のポイントを頭に入れつつ各自で好きなようにしましょう。

read-lineを高速化する(使用頻度: △)

read-lineは遅いとよく言われます。遅い理由として、read-lineは呼び出すたびにコンシングするからという説明を見たことがありますが、わたしの知る限りでは、律速しているのはマルチバイト文字を考慮したread-charです。AtCoderでは(おそらく)ASCIIしか出ないので、バイト単位で読んでsimple-base-stringのバッファに読みだす関数が役に立つかもしれません。SBCLの標準IOはbivalentなので、read-byteが使えます:

(declaim (inline read-line-into))
(defun read-line-into (buffer-string &key (in *standard-input*) (term-char #\Space))
  (declare (inline read-byte))
  (loop for c of-type base-char =
           #-swank (code-char (read-byte in nil #.(char-code #\Newline)))
           #+swank (read-char in nil #\Newline)
        for idx from 0
        until (char= c #\Newline)
        do (setf (char buffer-string idx) c)
        finally (when (< idx (length buffer-string))
                  (setf (char buffer-string idx) term-char))
                (return (values buffer-string idx))))

  • term-charは入力終了を表す文字として使います。例えば#\Spaceの場合、1 2 3という行を読むとbuffer-string"1 2 3 "で上書きするわけです。buffer-stringの長さがちょうど5なら"1 2 3"になります。
  • SWANKの標準IOはbivalentでないため、SLIME上で編集している時は代わりにread-charを使う必要があります。
  • 読み込みの終了位置を多値で返しています。
  • (declare (sb-kernel:ansi-stream in))を宣言するとさらによいかもしれません。

上の関数はread-lineよりずっと速いですが、いちいちバッファを作るのが面倒であれば、サイズだけ指定してバッファは自動で用意するという方法もあるでしょう:

(defmacro buffered-read-line (&optional (buffer-size 30) (in '*standard-input*) (term-char #\Space))
  (let ((buffer (gensym))
        (character (gensym))
        (idx (gensym)))
    `(let* ((,buffer (load-time-value (make-string ,buffer-size :element-type 'base-char))))
       (declare (simple-base-string ,buffer)
                (inline read-byte))
       (loop for ,character of-type base-char =
                ,(if (member :swank *features*)
                     `(read-char ,in nil #\Newline) ; on SLIME
                     `(code-char (read-byte ,in nil #.(char-code #\Newline))))
             for ,idx from 0
             until (char= ,character #\Newline)
             do (setf (schar ,buffer ,idx) ,character)
             finally (when (< ,idx ,buffer-size)
                       (setf (schar ,buffer ,idx) ,term-char))
                     (return (values ,buffer ,idx))))))
  • バッファサイズはコンパイル時に与えるべきなので、マクロにしました。
  • (速度を気にしなければ)終了位置はfill-pointerでコントロールしたいところではあります。読み込み時だけarray-storage-vectorを使う、とかいろいろやり方は考えられますが、今のところわたしはあまり頑張っていません。

read-lineとの比較ですが、105行以上のデータを読み込むなら200msくらいは得できるイメージです。最大で800msほど速くなるパターンを経験しています。

ただ、リードにしろパースにしろ、1文字ずつ読むならストリームから直接処理したほうが良くなりますし、read-sequenceなどを使う手もあるでしょう。以下にそのやり方も紹介しますが、read-lineや上の関数が役に立つこともよくあります。ストリングを介した行単位の処理は柔軟で、変則的な入力にも対応しやすいためです。

整数を高速に読み込む(使用頻度: ◎)

大量の整数を読み込んで処理する問題は頻繁に出ます。整数が$10^5$個を超えたあたりからreadに掛かる時間が無視できなくなってきます。

(declaim (ftype (function * (values fixnum &optional)) read-fixnum))
(defun read-fixnum (&optional (in *standard-input*))
  (declare (inline read-byte)
           #-swank (sb-kernel:ansi-stream in))
  (macrolet ((%read-byte ()
               `(the (unsigned-byte 8)
                     #+swank (char-code (read-char in nil #\Nul))
                     #-swank (read-byte in nil 0))))
    (let* ((minus nil)
           (result (loop (let ((byte (%read-byte)))
                           (cond ((<= 48 byte 57)
                                  (return (- byte 48)))
                                 ((zerop byte) ; #\Nul
                                  (error "Read EOF or #\Nul."))
                                 ((= byte #.(char-code #\-))
                                  (setq minus t)))))))
      (declare ((integer 0 #.most-positive-fixnum) result))
      (loop
        (let* ((byte (%read-byte)))
          (if (<= 48 byte 57)
              (setq result (+ (- byte 48) (the (integer 0 #.(floor most-positive-fixnum 10)) (* result 10))))
              (return (if minus (- result) result))))))))

(defun read-bignum (&optional (in *standard-input*))
  (declare (inline read-byte)
           #-swank (sb-kernel:ansi-stream in))
  (macrolet ((%read-byte ()
               `(the (unsigned-byte 8)
                     #+swank (char-code (read-char in nil #\Nul))
                     #-swank (read-byte in nil 0))))
    (let* ((minusp nil)
           (result (loop (let ((byte (%read-byte)))
                           (cond ((<= 48 byte 57)
                                  (return (- byte 48)))
                                 ((zerop byte) ; #\Nul
                                  (error "Read EOF or #\Nul."))
                                 ((= byte #.(char-code #\-))
                                  (setq minusp t))))))
           (mid-result 0)
           (index-mod18 0))
      (declare (fixnum mid-result)
               ((integer 0 19) index-mod18)
               (integer result))
      (loop
        (when (= index-mod18 18)
          (setq result (+ mid-result (* result #.(expt 10 18))))
          (setq mid-result 0)
          (setq index-mod18 0))
        (let ((byte (%read-byte)))
          (unless (<= 48 byte 57) (return))
          (setq mid-result (+ (- byte 48) (* 10 (the (mod #.(expt 10 17)) mid-result))))
          (incf index-mod18)))
      (setq result (+ mid-result (* result (expt 10 index-mod18))))
      (if minusp (- result) result))))
  • 上と同様の理由で、SLIME上とそれ以外で入力方法を変えています。

read-fixnumは、読み込む整数がfixnum(=(signed-byte 63))とわかっている場合のreadの代替であり、わたし自身、ここに挙げた関数の中でもっとも頻繁に使っています。

read-bignumも用意してありますが、ほとんど使ったことがありません。AtCoderでは多倍長整数をそのまま扱えないと厳しい問題はそうそう出ないと思いますし、出ても1つ読むだけならreadが十分速いです。[5] どのくらい大きな整数を扱えるかは感覚で覚えるしかありませんが、目安としては10100000を読むのに200ms掛かると覚えておくとよいです。

出力をstring-output-streamにためる(使用頻度: ○)

出力データが大量にある場合、一行ずつ*standard-output*に出していくとロスが大きくなることがあります。そういう時はstring-output-streamを使いましょう:

(let ((out (make-string-output-stream :element-type 'base-char)))
  (dotimes (i n)
    (princ data out)
    (terpri out))
  (write-string (get-output-stream-string out)))

これは場合によってはかなり効きます。マクロ化したければ次のような感じでしょうか。(わたし自身はマクロではなくスニペットで解決しています。)

(defmacro with-buffered-stdout (&body body)
  (let ((out (gensym)))
    `(let ((,out (make-string-output-stream :element-type 'base-char)))
       (let ((*standard-output* ,out))
         ,@body)
       (write-string (get-output-stream-string ,out)))))

ただし、メモリ制限が厳しい場合は、string-output-streamに全部ためるとMLE(Memory Limit Exceeded)になってしまうことがあります。AtCoderの最近のコンテストでは256MB~1GB以外の制約を見たことがないので、この点を気にする必要はないと思いますが、過去問を解く際には問題になることがあるかもしれません。

小数を出力する(使用頻度: △)

SBCLのformat~F~$は遅いです。特に、~,5F~5$のように小数点以下の桁数を指定した場合、出力だけで時間切れするレベルのボトルネックになったりします。

単純な解決策としてはwriteprincをそのまま使うことが挙げられます。この場合、出力に指数表記が混じることになりますが、AtCoderは基本的には指数表記で回答しても問題なかったはずです。(初期の問題に通らないものがあった気もします。) ただし、指数の記号はもちろんeでないといけません。実数を扱う問題では、精度を考慮するとdouble-floatが必要になることも多いのですが、その場合は*read-default-float-format*を変更する必要があります:

CL-USER> (write 1.4f10)
1.4e10
CL-USER> (write 1.4d10)
1.4d10
CL-USER> (let ((*read-default-float-format* 'double-float))
           (write 1.4d10))
1.4e10

なお、わたし自身はdouble-float通常の表記で高速に出力する関数も用意してはいます。AtCoder以外のサイトだと、writeでも遅すぎたり、あるいは出力桁数を制限しないと出力サイズが上限を上回ってしまったりという経験があります。

小数を高速に読み込む(使用頻度:✕)

小数を大量に入力する問題は意外にもまれです。SBCLのリーダーをいじったものをいちおう用意していますが、あまり試していません。

なお、readで読む場合、デフォルトではsingle-floatでパースされるので意図せず精度が落ちてしまう可能性はあります。これもやはり*read-default-float-format*を変更すればよいです。

小数を有理数として読み込む(使用頻度:✕)

「浮動小数点数の誤差の扱いが面倒だが有理数を使えば解決」という問題はたまにあります。しかし、入力も小数の場合は、readしてからrationalizeするとそもそもreadの時点で誤差が入るので、それについて考える必要が生まれます。SBCL 1.5.2以降であれば*read-default-float-format*rationalが指定できるので、これを使うと何も考えなくてすむかもしれません。


  1. ただし、SBCLではただのキューで良ければsb-queueが十分使えます。これはスレッドセーフなキューなので競プロ用途ではやや「リッチすぎる」わけですが、パフォーマンス上の問題は特にないことを確認しています。また、src/code/target-bst.lisp(バージョン1.5.0より前)かsrc/code/avltree.lisp(1.5.0以降)に平衡二分木が一応あったり、src/code/timer.lispに二分ヒープがあったりしますが、これらはユーザーが使うことを意図したものではないでしょうし、汎用的に作られてはいません。 ↩︎

  2. AtCoder上のSBCLについては、コードテストで(print (sb-alien:extern-alien "thread_control_stack_size" sb-kernel::os-vm-size-t))を送れば確認できます。 ↩︎

  3. SBCLのマニュアルにはなぜか載っていませんがCMUCLのマニュアルにはあります。標準の関数のうち、SBCLでmaybe-inlineなのはacons, both-case-p, digit-char-p, float-precision, get-properties, getf, intersection, keywordp, lower-case-p, nintersection, nset-difference, nset-exclusive-or, nsublis, nsubst, nsubst-if, nsubst-if-not, nthcdr, nunion, remprop, set-difference, set-exclusive-or, sort, stable-sort, sublis, subsetp, subst, subst-if, subst-if-not, tailp, tree-equal, union, upper-case-pです。ソート以外で覚えておきたいのはdigit-char-pでしょうか。また、maybe-inlineと似ていますが、inline宣言されたあとnotinline宣言されている関数としてunread-char, listen, read-char, read-byteがあり、これらも明示すればインライン化されます。 ↩︎

  4. 一応、displaceしたベクタをソートして副作用を利用するという方法はあります。おそらく挙動は未定義ですし、simpleでなくなるので遅いですが、SBCLでは意図通りに動くはずです。 ↩︎

  5. いっぽう、SBCLのparse-integerは多倍長整数をパースするのが遅いので注意。10100000を読むのに2秒以上かかります。 ↩︎