🧠

Brainf**k講座(番外編) atcoderでの処理限界

2024/12/14に公開

はじめに

本記事は、Qiitaの競技プログラミングなんでも Advent Calendar 2024の、シリーズ2 14日目の記事です。
非常に残念なことに、複雑な処理の実装難易度が非常に高くなることもあり、atcoder等競技プログラミングでBrainf**kを使用して高難易度問題を解くのは困難です。
しかしながら、そのこととは別にatcoderではリソースの問題で解ける問題に限界があります。( もちろんそのような限界ができてしまうこと自体はBrainf**kの弱点ですが )
今回は、その限界に触れる事例を紹介します。

環境

先日Brainf**k講座(応用編) atcoderでの頻出処理である程度記載していますが、atcoder では bf という処理系を使用しています。それを踏まえた上でリソース的な環境を挙げると次のようになります。

  • メモリ量
    メモリ量の前に他の要素がネックになるため、実質制限なしと考えて良いです。
  • 処理時間/ステップ数
    取り組める問題だと大抵 2秒が上限となっています。これはステップ数に換算すると、大体10億ステップ数程度の体感となります ( 数倍のブレはあると思います )。
    なお、ここで言うステップ数は単純に命令の数で良いのですが、<>+-のどれか同種の命令が続く時は、まとめて1ステップと換算します。
  • コード容量(文字数)
    提出できるコード容量は、512KiB となっています。そのため、ループ制御で同じ処理を繰り返すところを、同種の命令を重ねることで定数倍のステップ数削減を行うという対策にも制約があるということになります。

事例

それでは、今回は「なんとか制限内で解けたが、これ以上規模が大きくなると厳しそう」という事例を紹介し、制限内に抑えるための対策と限界の見積もりについて考察します。

問題

今回取り上げる問題は、agc007のB問題です。問題の概要は次の通りです。

  • 入力
    1行目にNと、2行目に空白区切りの p_1, p_2, \cdots, p_N が与えられる。( 末尾に改行付き )
  • 出力
    1行目に a_1, a_2, \cdots, a_N を空白区切りで、
    2行目に b_1, b_2, \cdots, b_N を空白区切りで出力する。
    ※末尾の空白は許容される、また2行目の末尾に改行はつけなくてもよい
  • 条件
    1\le a_n, b_n\le 10^9で、数列 \{a_n\} は単調増加、数列 \{b_n\} は単調減少、数列 \{ a_{p_n}+b_{p_n} \} は単調増加であること。条件を満たす数列を構成できれば十分であり、複数の解の候補がある。
  • 制約
    2\le N\le 20,000\{p_n\}1\sim N の並び替え

この問題を解くための方針ですが、仮に \{a_n\} を20,000刻みで増加する等差数列、\{b_n\} を20,000刻みで減少する等差数列として構成すると \{a_{p_n}+b_{p_n}\} が定数数列となることをベースラインとし、後は \{b_n\} を微調整すれば ( 既に20,000刻みで構成しているため ) 単調減少を崩さずに、\{a_{p_n}+b_{p_n}\} が単調増加で微変動するように調整できると考えてみます。
そうすると、次のようなRubyでのコードが解になっていることが分かります。

実装例
n = gets.to_i + 10000
c = 10000
# a_n の出力
while (n -= 1) >= 10000
  print "%5d0000 " % c
  c += 2
end
print "\n"
xs = [0] * 20000
i = 0
# p_n 読み込みと調整値保存
gets.split.each{|q|
  xs[q.to_i - 1] = i
  i += 1
}
# b_n の出力
xs.each{|x|
  break if (c -= 2) < 10000
  print "%9d " % (c * 10000 + x)
}

この方針だと N が決定した時点で \{a_n\} がすぐに決定するので、処理として \{b_n\} の計算に集中できるというメリットがあります。その \{b_n\} の微調整分としては、0\sim N-1\{p_n\} に従い並び替えたものを採用しています。

なお、コード中の c の初期値を 10000 にしているのは \{a_n\} および \{b_n\} の桁数が 9桁で固定されるようにする工夫です。加えて \{b_n\}出力ループの終了条件も、c の万の桁を見るだけで判断することができるようになっています。
※最初に n に 10000 を足しているのも、\{a_n\}出力ループ終了条件の簡略化のためです。
言うまでもないことですが、こういった工夫を行っているのは、Brainf**kに移植した時に実装で楽をするためです。

計算量見積もりと対策

さて、前章で示した実装ですが、大まかに次の3段階に分かれています。

  1. N読み込みと\{a_n\}の構成、出力
  2. \{p_n\}読み込みと配列xsへのi保存
  3. 配列xsの順次アクセスと\{b_n\}の構成、出力

この内、1., 3. は単純に計算量 O(N) の処理であり、Brainf**kで実装した場合も ( 複数桁管理分も込みで ) O(NlogN) のステップ数となります。これであれば、今回の規模ならそれほど問題になりません。
ところが、2. は計算量的には O(N) ながら、配列のランダムアクセスが発生する処理であり、「Brainf**k講座(3) スライド型ループ」の「実例2」にある指定距離移動を単純に適用すると、1回の移動でのステップ数が O(NlogN) になるため、総ステップ数 O(N^2logN) となり、これではTLEが避けられません。

なお、この配列のランダムアクセスは次のような処理イメージとなります。
配列要素を格納する場所を飛び飛びに設け、その間を距離・値をまとめた塊を通していくような感じです。距離・値・配列要素1つのセットで1ブロックとしています。

しかし、atcoderのbfは、同じ処理であれば、例えば >>>> のように移動を連続させるような形で1ステップで実行することができます。このことを利用し、移動のステップ数を削減する対策があります。
ちょうど次の図のように、移動距離を桁で分けて分割して、それぞれを1ステップずつで捌くイメージになります。

これにより、ステップ数を O(Nlog^2N) に落とすことができ、TLEを避ける実装が可能となります。
ただこのためには、万単位の移動をコード上に >>>>…>>>(万単位の文字数) のような形で反映させないとなりません。そのためコード量が膨大なものになりますし、今度は512KiBのコード容量への抵触を気にする必要が出てくるところがトレードオフとなります。

コード全体

それではここで、実際にACした91813B版コードを、コメント付きにしたものを下に挙げます。
ただ、文字数が多いところをそのまま載せると膨大な量となるため、例えば - が48文字続くところは -{48} のようにまとめた形で書いています。

コメント付きコード(同一処理は連続数で代替)
** construct 256w barrier **
-[-[->+<]->]-
** input n **
>>>>>>,+[
 ++>>+>-[----<<<[->]>>-]
 <[-<,+>]
<]
>>>[+]-
** setup for printing As **
>>>>>>>>>>++++[-<s---->]-<+[-<+]+<<<<<<<<<m+>>>>>l
** As printing loop **
l>-[
 <+l[
  -[->+[+++++++>+]->>-<]
  >[<]
 +<+]
 >->+<<<l<<<<<m[ ** continuing **
  m>>>>>l>-
  ** print c as A **
  >>>+[
   +{47}
   .
   -{48}
  >+]
  ** increment c by 2 **
  -<<<<<<++[
   ----------[+++++++++++[-<+]->]
  <+]
  -
 >]
 >[ ** break **
  [>]
 >>]
<<]
** clear n and s **
<l+.[[-]<]+[->+]<s[+]>>-
** Ps input loop **
>>>>>>>,+[
 ** compare **
 ++>>+>-[----<<<[->]>>-]
 <[- ** number: update as duodecimal **
  ** x10 **
  <<<-<<<<<+[-
   [-<++++++++++>]
  >+]
  >[-<<<+>>>]
  ** divmod by 12 loop **
  <<<+[
   [>[+>>>>>]<[<+>>----------->>>>]<<<<<-]
  >-<<]
  ->>[++++++++++++>]
 >>]
 >>[[-] ** delimiter: store at X(p) **
  ** copy J **
  >-<+[-<+]-<<<<<+[-
   [-<+>>>>>>>>>>>>+<<<<<<<<<<<]
  >+]-
  >>>>>+[-
   <<<<<<<[->+<]
  >>>>>>+]-
  ** increment J ( original ) **
  <+[
   ----------[+++++++++++[->+]->]
  <+]-
  ** move block **
  >
  ** 1st move **
  >[
   [
    -[-
     >{16x12x12x12}
     +
     <{16x12x12x12}
    ]
   >+]
     >{16x12x12x12}
  -<<<<<<<<<]
  ** 2nd move **
  >[
   [
    -[-
     >{16x12x12}
     +
     <{16x12x12}
    ]
   >+]
     >{16x12x12}
  -<<<<<<<<]
  ** 3rd move **
  >[
   [
    -[-
     >{16x12}
     +
     <{16x12}
    ]
   >+]
     >{16x12}
  -<<<<<<<]
  ** 4th move **
  >[
   [
    -[-
     >{16}
     +
     <{16}
    ]
   >+]
     >{16}
  -<<<<<<]
  ** store **
  >+[-
   [->>>>>>+<<<<<<]
  >+]
  ** go back **
  >>>>>>-<<+[-<{256}+]
  -[>{16}]+[->+]->>>>>>>>>>
 ]
<<<,+]
** move c **
<<<<<<<<<<<<<-<<<<<+[-
 [-
  >{29}
  +
  <{29}
 ]
>+]
** Bs printing loop **
>{29}-[
 ** decrement c by 2 **
 <+[
  -[->+[++++++++>+]->]
 <+]
 >+<-<-
 ** print B **
 <<<<[ ** c ge 10000: continuing **
  ** copy c for the next loop **
  +[-
   [->>>>>>+>>>>>>>>>>+<{16}]
  >+]
  ** print **
  >[
   +{47}.
  >+]
  +{32}.
 ,+]
>>>>>>-]

焦点となるのは、** move block **** 1st move ** から ** 4th move ** までの分割したブロック移動移動です。これは、p として読み込んだ値を4桁の12進数として展開し、4段階に分割したものです。
※移動する値としての j は10進数として扱っています。

ブロック自体は p:12進数4桁 + ( j:10進数5桁 + 区切り1桁 ) × 2(x分) の16セル単位で扱うため、** 1st move ** の移動単位は 16\times 12^3=27648 これが行き・帰り・行きで実にコード量81KiBを占めます。

実を言うと、長距離移動のステップ数ということでは、配列に値を保存してから次の値を扱うために戻る処理も同じような問題を抱えています。
これも段階ごとにマーカーを設ければステップ数を抑えられるのですが、コード量が更にかさむことになるため、今回は移動幅をブロック単位16でなく、その16倍の256にするという定数倍のステップ数削減でなんとかしています。( ** go back ** の部分 )
移動幅256の受け皿になるため、コードの先頭で 256 のマーカー地帯を設け、どこから戻っても対処できるようにしています。
※なお、データの移動が絡まないため、段階に分けなくてもステップ数 O(N^2) で、若干軽めの処理になっています。

限界の考察

今回の問題規模ですが、

  • N は最大で 20000
  • 処理ステップ O(Nlog^2N)
    O(N^2) の部分もあるが、定数倍のチューニングにより顕在化していない
  • 処理時間 0.7s で、制限の約1/3
  • コード量 91813B で、制限の約1/6
    ※移動処理を段階に分けるにしても、何か所にも適用できるほど余裕はない

ということを考えると、類似の1次元配列操作がある問題で、数万要素規模がatcoderでのBrainf**k実装の限界だろうと推測できます。
逆に言うと、今回は限界直前を垣間見ることができる、丁度いい具合の問題だったのではないでしょうか。

おわりに

今回は、具体的な実装の解説というよりも、問題規模の限界とステップ数・コード量のトレードオフを探るのが主眼の番外編として構成しました。
複雑だったり大規模だったりする問題でBrainf**kでは手が届かないものが多いのも事実ですが、それでも、今回のような実装が、手が届く範囲を見積もるのに役立てられれば幸いです。

Discussion