128bit除算のやり方

五月祭お疲れさまでした。私はクイックソートのメモリアクセス系列の可視化の展示をしていましたが、楽しんでいただけたでしょうか……。


C言語には多倍長整数のような便利なものがありません。unsigned long longを使えば64bit整数までは取り扱えますが、それ以上になると面倒です。 ちょっと64bitをはみ出すくらいの整数を扱いたい、128bit整数型があれば……。という場合、gccやclangなら__uint128_tという型が使えます。これはその名前の通り、128bitの整数型です。 ここではx86_64を前提として、64bitマシンでどのように128bitの演算を行っているか見てみます。

足し算・引き算

x86にはadc命令・sbb命令が存在します。これはキャリー付きの足し算・ボロー付きの引き算を行う命令です。 下位64bitの計算を行った後上位64bitの計算を行うとき、下位64bitの計算での繰り上がり・繰り下がり(キャリー・ボロー)を考慮に入れた計算を行うことができます。 このように多倍長演算用の命令が用意されているため、足し算・引き算は簡単に行えます。

掛け算

x86にはmul命令が存在します。これはimul命令と異なり、64bit整数同士の積を128bit整数として求めることができる命令です。 128bitのレジスタはありませんが、結果は上位64bitと下位64bitを異なる二つのレジスタに分けて書き込まれます。

あとは通常の掛け算を3回行い、筆算のように結果を足し合わせれば128bit整数同士の積(の下位128bit分)を求めることができます。

割り算

割り算はそう簡単にはいきません。gccの場合、__udivti3というライブラリ関数呼び出しにコンパイルします。 この関数を逆アセンブルした結果、__udivti3関数は以下のような254Byteのコードになっているようです。 機械語の雰囲気はコンパイラの出力っぽいコードで、プログラマが書いた雰囲気を感じ取ることができませんでした。

   0x0000000000400560 <+0>:     mov    %rcx,%r8
   0x0000000000400563 <+3>:     mov    %rdx,%r9
   0x0000000000400566 <+6>:     mov    %rdx,%r10
   0x0000000000400569 <+9>:     test   %r8,%r8
   0x000000000040056c <+12>:    mov    %rdx,%rcx
   0x000000000040056f <+15>:    jne    0x4005a8 <__udivti3+72>
   0x0000000000400571 <+17>:    cmp    %rsi,%rdx
   0x0000000000400574 <+20>:    ja     0x400628 <__udivti3+200>
   0x000000000040057a <+26>:    test   %rdx,%rdx
   0x000000000040057d <+29>:    jne    0x40058c <__udivti3+44>
   0x000000000040057f <+31>:    mov    $0x1,%eax
   0x0000000000400584 <+36>:    xor    %edx,%edx
   0x0000000000400586 <+38>:    div    %r9
   0x0000000000400589 <+41>:    mov    %rax,%rcx
   0x000000000040058c <+44>:    mov    %rsi,%rax
   0x000000000040058f <+47>:    xor    %edx,%edx
   0x0000000000400591 <+49>:    div    %rcx
   0x0000000000400594 <+52>:    mov    %rax,%rsi
   0x0000000000400597 <+55>:    mov    %rdi,%rax
   0x000000000040059a <+58>:    div    %rcx
   0x000000000040059d <+61>:    mov    %rsi,%rdx
   0x00000000004005a0 <+64>:    retq
   0x00000000004005a1 <+65>:    nopl   0x0(%rax)
   0x00000000004005a8 <+72>:    cmp    %rsi,%r8
   0x00000000004005ab <+75>:    ja     0x400620 <__udivti3+192>
   0x00000000004005ad <+77>:    bsr    %r8,%rax
   0x00000000004005b1 <+81>:    xor    $0x3f,%rax
   0x00000000004005b5 <+85>:    test   %eax,%eax
   0x00000000004005b7 <+87>:    mov    %eax,%r11d
   0x00000000004005ba <+90>:    je     0x400638 <__udivti3+216>
   0x00000000004005bc <+92>:    mov    %eax,%ecx
   0x00000000004005be <+94>:    mov    $0x40,%edx
   0x00000000004005c3 <+99>:    shl    %cl,%r8
   0x00000000004005c6 <+102>:   movslq %eax,%rcx
   0x00000000004005c9 <+105>:   sub    %rcx,%rdx
   0x00000000004005cc <+108>:   mov    %edx,%ecx
   0x00000000004005ce <+110>:   shr    %cl,%r9
   0x00000000004005d1 <+113>:   mov    %eax,%ecx
   0x00000000004005d3 <+115>:   or     %r9,%r8
   0x00000000004005d6 <+118>:   shl    %cl,%r10
   0x00000000004005d9 <+121>:   mov    %rsi,%r9
   0x00000000004005dc <+124>:   mov    %edx,%ecx
   0x00000000004005de <+126>:   shr    %cl,%r9
   0x00000000004005e1 <+129>:   mov    %eax,%ecx
   0x00000000004005e3 <+131>:   mov    %rdi,%rax
   0x00000000004005e6 <+134>:   shl    %cl,%rsi
   0x00000000004005e9 <+137>:   mov    %edx,%ecx
   0x00000000004005eb <+139>:   mov    %r9,%rdx
   0x00000000004005ee <+142>:   shr    %cl,%rax
   0x00000000004005f1 <+145>:   or     %rax,%rsi
   0x00000000004005f4 <+148>:   mov    %rsi,%rax
   0x00000000004005f7 <+151>:   div    %r8
   0x00000000004005fa <+154>:   mov    %rdx,%r9
   0x00000000004005fd <+157>:   mov    %rax,%rsi
   0x0000000000400600 <+160>:   mul    %r10
   0x0000000000400603 <+163>:   cmp    %rdx,%r9
   0x0000000000400606 <+166>:   jb     0x400618 <__udivti3+184>
   0x0000000000400608 <+168>:   mov    %r11d,%ecx
   0x000000000040060b <+171>:   shl    %cl,%rdi
   0x000000000040060e <+174>:   cmp    %rax,%rdi
   0x0000000000400611 <+177>:   jae    0x400658 <__udivti3+248>
   0x0000000000400613 <+179>:   cmp    %rdx,%r9
   0x0000000000400616 <+182>:   jne    0x400658 <__udivti3+248>
   0x0000000000400618 <+184>:   lea    -0x1(%rsi),%rax
   0x000000000040061c <+188>:   xor    %edx,%edx
   0x000000000040061e <+190>:   retq
   0x000000000040061f <+191>:   nop
   0x0000000000400620 <+192>:   xor    %edx,%edx
   0x0000000000400622 <+194>:   xor    %eax,%eax
   0x0000000000400624 <+196>:   retq
   0x0000000000400625 <+197>:   nopl   (%rax)
   0x0000000000400628 <+200>:   mov    %rdi,%rax
   0x000000000040062b <+203>:   mov    %rsi,%rdx
   0x000000000040062e <+206>:   div    %r9
   0x0000000000400631 <+209>:   xor    %edx,%edx
   0x0000000000400633 <+211>:   retq
   0x0000000000400634 <+212>:   nopl   0x0(%rax)
   0x0000000000400638 <+216>:   cmp    %rsi,%r8
   0x000000000040063b <+219>:   jb     0x40064a <__udivti3+234>
   0x000000000040063d <+221>:   xor    %edx,%edx
   0x000000000040063f <+223>:   xor    %eax,%eax
   0x0000000000400641 <+225>:   cmp    %rdi,%r9
   0x0000000000400644 <+228>:   ja     0x4005a0 <__udivti3+64>
   0x000000000040064a <+234>:   xor    %edx,%edx
   0x000000000040064c <+236>:   mov    $0x1,%eax
   0x0000000000400651 <+241>:   retq
   0x0000000000400652 <+242>:   nopw   0x0(%rax,%rax,1)
   0x0000000000400658 <+248>:   mov    %rsi,%rax
   0x000000000040065b <+251>:   xor    %edx,%edx
   0x000000000040065d <+253>:   retq

これを少しは読みやすい疑似コードにしてみたのが以下です。 以下では、上位64bitがh、下位64bitがlであるような128bit変数の意味で(y#l)という表記を使います。変数はすべてレジスタサイズと同じ64bit整数です。

__udivti3( __uint128_t x, __uint128_t y ) {
  (xh#xl) = x;
  (yh#yl) = y;
  if( yh == 0 ) {
    if( xh >= yl ) {
      // 商が64bitに収まらない時
      if (yl == 0) {
        // 零除算例外を発生させる
        rcx = 1 / yl;
      }
      // 長除法
      qh = xh / yl;
      r = xh % yl;
      ql = (r#xl) / yl;
      return (qh#ql);
    } else {
      // 商が64bitに収まる
      return (xh#xl) / yl;
    }
  } else {
    if( xh >= yh ) {
      // yh != 0なので、count_trailing_zerosを計算するbsr命令が使える
      S = 63^count_trailing_zeros( yh );
      if( S != 0 ) {
        // yの上から64bitをyhに集める
        (yh#yl) = (yh#yl) << S;
        // 商の上界値を求める
        q = ((xh#xl) >> (64-S)) / yh;
        r = ((xh#xl) >> (64-S)) / yh;
        (mh#ml) = q * yl;
        if( mh >= r ) {
          // xl << Sは、(xh#xl)>>(64-S)ではみ出した部分
          if( q >= xl << S || mh != r ) { return q; }
        }
        return q - 1;
      } else {
        // (yh#yl)が2の127乗以上の時
        if (xh <= yh && xl < yl) {
          // (xh#xl) < (yh#yl) の時(xh == yh && xl < yl)
          return 0;
        }
        return 1;
      }
    } else {
      // (xh#xl) < (yh#yl) の時(xh < yh) 
      return 0;
    }
  }
}

x86には、被除数が128bit、除数が64bitの割り算命令divが存在します。 そのため、除数が64bitに収まっている場合、単純に長除法(筆算)を行うだけで終わりです。 しかし、除数が64bitに収まらない場合、そのような計算を行う命令がなく、工夫が必要です。

概念的には、次のような計算を行っています。 まずxとyを両方ともSビットシフトして、yの最上位ビットが1になるようにします。こうすることで、除数の値を切り捨てではありますが二進有効数字64桁で表すことができます。 この状態で割り算をすると、除数を切り捨てたことから、商の上界値qが求まります(被除数も切り捨てているので議論がややこしいですが、切り捨てられた部分の大きさは1未満なので割り算の結果に影響を及ぼしません)。 この時、真の商としてあり得るのはqq-1になります(真の除数は切り捨てた部分の影響は2^-63未満であることによります)。 後はylxlの情報を用いてそのどちらかになるかを決定しています(この部分はよく確認していません)。

C言語における移植性のある算術シフトの記述方法

多くのCPUには、算術シフト演算命令が含まれています。そのため、多くのコンパイラは、符号付き整数に対する右シフトを算術シフトにコンパイルします。 しかし、C言語において、符号付き整数を右シフトした場合の挙動は処理系定義です。処理系定義というのは、以下のようなものです。

  • 未定義動作ではなく、決定的な挙動を示す
  • 処理系*1によって挙動が変わるかもしれない
  • 処理系のドキュメントに、どういう動作になるかが記載されている*2

よって、移植性のあるコードを書くためには、符号付き整数に対して右シフトをすることは避けたいところです。

符号付き整数に対する右シフトと等価なコードは、if文の利用等により、書くこと自体はできます。 しかし、せっかくなので、CPUに存在する算術シフト命令を使える方法を考えてみます。

割り算を使う方法

定数での割り算の場合、コンパイラは算術シフト命令へ最適化することがあります。 これを利用できないかと考えてみます。

右シフトの代わりに除算を使う方法は、負数に対して異なる結果を与えるため不適です。 よって、オペランドの正負によって場合分けが必要です。

いろいろとif文の条件を工夫してコンパイラにヒントを与えてみましたが、コンパイラに算術シフト命令を吐かせることはできませんでした。

どうやら、定数除算を算術シフト命令に変換する最適化は、コード丸ごとの置換であり、最適化のかなり後ろの段階で行われているようです。

符号なし整数を利用する方法

clangの場合、以下のコードでやりたいことを見抜いてくれます。

std::int64_t sar( std::int64_t x ) {
    std::uint64_t y = x;
    y += 1ull << 63;
    y >>= 1;
    y -= 1ull << 62;
    return y;
}

このコードは、ystd::int64_tにキャストする際にオーバーフローする可能性があり、完全とは言えません。 分岐を行うことでオーバーフローを防ぐ、完全なコードは以下になります。std::bit_castを使うのも悪くないでしょう。

std::int64_t sar( std::int64_t x ) {
    static constexpr std::int64_t min = -0x7fffffffffffffff - 1;
    static constexpr std::uint64_t bias = 0x8000000000000000;
    std::uint64_t y = x;
    y += bias;
    y >>= 1;
    y -= bias >> 1;
    return y < bias ?  static_cast<std::int64_t>(y)
                    : static_cast<std::int64_t>(y-bias) + min;
}

これらのコードは無意味に複雑ですが、最適化オプション-O1などでコンパイルすれば単なる算術シフト命令になります(x86_64向けclangのいろんなバージョンで確認)。

今後の課題

  • clang系以外(gcc, msvc, icc)に算術シフト命令を吐かせる方法はいまだ不明
  • シフト量に変数を用いる場合の方法が不明

*1:インタプリタとかも含む表現なのですが、実質的にはコンパイラと解釈して間違いないです。

*2:記載されないものは、「未規定」と呼ばれます。

テンプレートクラスを継承したクラステンプレートを作るときの罠

何回も言われていることだけれど、つまづくことが多いところなのでメモしておきます。

以下のようなコードで、コンパイルエラーになるときの対処法です。

template<class U>
class Base {
public:
    int f() { return 1; }
};

template<class T>
class Derived : Base<T> {
  int g() { return f(); }
};

g++では、error: there are no arguments to 'f' that depend on a template parameter, so a declaration of 'f' must be available [-fpermissive]というメッセージが出ます。 clang++では、error: use of undeclared identifier 'f'というメッセージが出ます。これは不親切ですね……。

それに加えて、グローバル空間にf()が定義されていた場合、何の警告も出ずにそっちに誤爆してしまいます。こういうことが起こるとかなり混乱します(デバッガでどの関数が呼ばれているかを確認できれば、バグ取り自体は簡単ですが……)。

このような現象は、以下の条件を全て満たしたとき、起こります。

  • クラステンプレートを書いている(今回は、template<class T>Derivedが該当)
  • そのクラステンプレートが、テンプレートクラス(今回は、Base<T>が該当)を継承している
  • その継承元のテンプレートクラスのテンプレート引数として、書いているクラステンプレートのテンプレート引数(今回は、Tが該当)を使っている
    • Tを使っていない場合、たとえばstd::vector<int>を継承、といった場合は該当しません
  • 継承元のテンプレートクラスの変数や関数を使っている場合
    • ただし、関数の場合、引数によっては問題ないこともあって、混乱のもととなります

解決方法

this->f()のようにthisをつける。あるいは、Base<T>::f()とする。

なぜ問題が発生するのか

  1. 何も修飾されていないf()について、コンパイラは、Derivedメンバ関数だと思って探しに行きますが、ありません。
  2. 継承元クラスのメンバ関数の可能性を検討しますが、Tが確定していない以上、継承元クラスも確定しません。よって、継承元クラスは検索されません。
  3. 順々にスコープを広げて前方宣言があるかを探していきますが、グローバル空間まで行ってもやはりありません。
  4. よってコンパイルエラーになります。

重要なのは、f()はテンプレート引数Tと無関係な風に見えるということです。 こういう場合、コンパイラTが確定していない段階でf()がどの関数に該当するのかを探しに行きます。 テンプレートの中であってもTと無関係であれば、C言語と同様、ソースコードの上の方に探しに行きます。 Baseが上の方に書いてあるから見つけられるはず、という風に考えるのは間違いです。なぜなら、TによってはBase<T>クラスが特殊化される可能性が残されているからです。

テンプレートクラスがstd::vector<int>などのように、確定する場合は、2.の部分で継承元クラスが検索されるため、問題が発生しなくなります。 特殊化される可能性が残されている点は同じですが、使った後に特殊化した場合はコンパイルエラーになります。

なぜthisなどで問題が解決するか

thisの型は、Derived<T>*です。ここには、Tの情報が含まれています。そのような「テンプレート引数に依存したもの」が含まれている場合、Tが確定してからf()がどの関数に該当するのかを探しに行きます(Two Phase Lookup)。

Base<T>::f()とする場合も、「テンプレート引数に依存したもの」が含まれているため、Tが確定してからf()がどの関数に該当するのかを探しに行きます。

循環的な構造の中で大小関係を推定する

3時より8時の方が後にあり、8時より11時の方が後にありますが、11時より3時の方が後にありそうです。 時計のように巡回している場合、局所的には大小関係のようなものがありそうでも、推移律が成り立つような大小関係はうまく定義できません。

その巡回周期の半分以下の差しか現れない場合のみ、局所的な大小関係が正しく求められます。 時計の例でいえば、差が六時間(十二時間の半分)以内の問題は正しく解けるということです。

特定の期間に含まれるかの判定

ある時刻qが、beginから始まりendを含まずに終わる期間に含まれるかの判定は、以下のように行えます。 問題設定よりbeginよりendが後にあることが保証されるので、期間が巡回周期に近いような場合でも正しく問題が解けます。

constexpr bool includes( size_t begin, size_t end, size_t q ) {
    if (begin > end) {
        return begin <=q || q < end;
    } else {
        return begin <= q && q < end;
    }
}

循環バッファを使ったキューの走査

全然関係ないですが、循環バッファを使ったキューを走査するとき、どこを走査するべきかは、先の問題と同一の解き方ができます。

template<class T, std::size_t N>
class Queue {
    std::array<T, N> buf;
    std::size_t head;
    std::size_t tail;
public:
    Queue() : buf{} head(0), tail(0) {}
    bool empty() const { return tail == head; }
    bool full() const { return (tail+1) % N == head; }
    void push(T elem) { assert( !full() ); buf[tail] = std::move(elem); tail = (tail+1) % N; }
    void pop() { assert( !empty() ); head = (head+1) % N; }
    const T& top() const { assert( !empty() );  return buf[head]; }
    void print() const;
};

こんな感じのキューを想定します。tailは次に要素が来た時に挿入するべき位置、headは最も古く入れた要素の位置です。 なお、この実装だと、空っぽか満杯かを区別できないため、N-1要素しか入れることができません。これを防ぐためには、空っぽであるか(あるいは、満杯であるか)のフラグを用意します。

このとき、このキューに入っている要素を全て書き出すには、以下のようにすればよいです(入れた順)。

template<class T, std::size_t N>
void Queue::print() const {
    if (head < tail) {
        for( std::size_t i = head; i < tail; ++i ) { std::cout << buf[i] << std::endl; }
    } else {
        for( std::size_t i =head; i < N; ++i ) { std::cout << buf[i] << std::endl; }
        for( std::size_t i = 0; i < tail; ++i ) { std::cout << buf[i] << std::endl; }
    }
}

最近使った固定要素数を保持する配列の作り方

よく使うデータへのアクセスを高速化するために、そういったデータを高速な記憶媒体に保持しておくことを、キャッシュすると言ったりします。

高速な記憶媒体はそれ相応に高い*1ので、大容量と両立することはできません。

そこで、よく使うデータを選別して、それだけをキャッシュにおいておくことが全体の高速化につながります。

具体的にどのデータが頻繁にアクセスされるかが事前にわかっている場合は、選別を最適化することができます。 しかし、オンライン的にアクセスがくる場合、未来予知をする必要があるため、最適解を求めることは不可能です。

最適解を近似するアルゴリズムに、LRUアルゴリズムがあります。 これは、キャッシュに入っていないデータへのアクセスが来た時、最も古いデータは捨てて代わりにそれを入れるというものです。

最近使っていないデータへのアクセスは、今後も来ないだろうと考えるのが常識的であり、それを採用したアルゴリズムになっています。 LRUというのは、least recent usedの略で、「最近使っていない」(ものを捨てる)という意味合いになっています。

実際には、一定数のデータを巡回的にアクセスする、などのパターンで最悪の性能を示すという弱点があります。 改善案が日々研究されているようですが、ここでは単純なLRUアルゴリズムを使うことにします。

実際にデータの順序を変更する方法

std::array<T, N> cacheについて、データが来るたびにそのデータを先頭に移動させるという方法です。

if( const auto it = std::find_if( cache.begin(), cache.end(), cond ); it != cache.end() ) {
    // cache hit
    std::rotate( cache.begin(), it, it + 1 );
    return cache[0];
} else {
    // cache miss
    // ....
}

のように、std::rotate関数を使うと非常に簡潔に書けます。

また、頻繁に使われるデータが先頭付近に存在する可能性が高くなるため、先頭からの線形探索は思ったより高速に動作する可能性が高いです(連結リスト - Wikipedia)。

この方法は、sizeof(T)が大きい場合に大量のコピーが発生するために非効率です。

LRUオーダーを持つ方法

std::array<std::pair<T, size_t>, N>あるいは、std::pair<std::array<T, N>, std::array<size_t, N>>のような構造にする方法です。

前者はarray of struct (AoS, 構造体の配列)、後者はstruct of array (SoA, 配列の構造体)になっており、どちらが適切かは微妙なところです。

データの位置は変更せず、追加されたsize_t(実際にはもっと小さな型で十分であることが大半です)に順番(LRUオーダー)を記録します。

  • キャッシュに入っているデータにアクセスが来た場合、現在のLRUオーダーより小さい値のLRUオーダーを一ずつインクリメントし、自分のLRUオーダーを0にする
  • キャッシュに入っていないデータにアクセスが来た場合、LRUオーダーがN-1である要素を新たなデータで上書きし、LRUオーダーを0にする。他のLRUオーダーは一ずつインクリメントする

データの移動がないため、参照が無効になることがないのはうれしい点です。 どれを捨てるのかの判断にも線形探索が必要な点は「実際にデータの順序を変更する方法」に劣ります。

CPUのキャッシュは普通この方法で実装されています。

タイムスタンプを持つ方法

「LRUオーダーを持つ方法」では、アクセスのたびにLRUオーダーの書き換えが大量発生していましたが、代わりにタイムスタンプを持つ方法です。

アクセスがあるたびにタイムスタンプを更新し、どれを捨てるかの判断は単なる最小値の線形探索をすることによります。

タイムスタンプがオーバーフローしないようにするため、タイムスタンプ領域のサイズは「LRUオーダーを持つ方法」より大きくなります。 そのため、sizeof(T)が小さい場合にはそのオーバーヘッドが無視できなくなります。


以上の三手法は、sizeof(T)の大きさによって使い分けるとよいです。

sizeof(T)が小さい場合は「実際にデータの順序を変更する方法」が簡単です。一方、タイムスタンプ領域が無視できるほどsizeof(T)が大きいのであれば「タイムスタンプを持つ方法」が便利です。

なお、「タイムスタンプを持つ方法」は、タイムスタンプが最も古いものがどこにいるのかを判別するために最小ヒープを用いることで計算量を改善することが可能です。

ところで、「実際にデータの順序を変更する方法」は追加の領域が必要になっておらず一見不思議です。

これは、集合としておなじ配列に内在する冗長性(順番成分)がlog_2(N)ビット存在することが原因です。 LRUオーダーを覚えるためにこの情報量をかつようしているために追加の領域が必要になっていないのです。

連想配列に適用する方法

先の3つの手法は、キャッシュにデータがあるかの探索は線形検索でした。 しかし、通常の用途では連想配列を用いることが多いです。

さきの3つの手法のうち、「LRUオーダーを持つ方法」と「タイムスタンプを持つ方法」はそのまま連想配列に適用できます。 「実際にデータの順序を変更する方法」は連想配列の場合には使えません。


ところで、ECMAScript 2015で追加されたMapは、挿入順でイテレートするという動作をします。

これを利用すると、追加の記憶なしに「最近使ったことにする」touch(LRUオーダーを0にする)や「古いのを捨てて挿入する」insert関数が以下のように書けます。

性能がベストなのかはよくわかりませんが、それほど悪くないように思われます。

class FullAssociativeLRUTable extends Map {
    constructor(size) {
        super();
        this.maxSize_ = size;
    }

    touch(tag) {
        if (this.has(tag)) {
            // いったん削除して再挿入で、MRU position に移動
            let data = this.get(tag);
            this.delete(tag);
            this.set(tag, data);
        } else {
            console.log(`${tag}は含まれていないのにtouchした`);
        }
    }

    insert(tag, data) {
        if (this.has(tag)) {
            console.log(`${tag}は含まれているのにinsertした`);
        } else if (this.size < this.maxSize_) {
            this.set(tag, data);
            return null;
        } else {
            // keys().next()は最初の要素 (LRU position)
            let minTag = this.keys().next().value;
            let data = this.get(minTag);
            this.delete(minTag);
            this.set(tag, data);
            return data;
        }
    }
}

*1:パレート最適記憶媒体を選んだら当然そうなります

累積和を使っても二分探索を使っても解ける問題

先週話題にした、ABC(AtCoder Beginner Contest)122のC問題は、累積和を用いて解くのが想定解のようですが、二分探索を用いても解けるという話を、少し一般化して考えてみます。

なお、以下では何も言わずに空間計算量だけ書いた場合、時間計算量も同じオーダーであるとします(それだけの量の空間を触るのに最低限必要な時間です)。

Θ(1)時間で計算可能な特性関数 \mathbb{N}→{0,1}が入力長Ο(N)で与えられる場合

元問題は、このクラスです。 元問題では、特性関数の種として長さNの文字列が与えられます。

累積和

  1. 【前処理】各点での特性関数の値のprefix sumになっている配列を構成します。
  2. 配列アクセスにより、端点での値を得ます。
  3. その差が区間内にある特性関数が1になる点の集合のサイズとなります。

この累積和を使った解法では、

  • 前処理にΘ(Nlog N)空間*1
  • クエリ一つあたりにΘ(log N)時間*2

で解けます。

二分探索を用いる解法

  1. 【前処理】特性関数が1になる点の集合を昇順ソート済み配列として構成します。
  2. 端点を二分探索します。下端(始端)はlower_boundのindex、上端(終端)はupper_boundのindexを得ます。
  3. その差が区間内にある特性関数が1になる点の集合のサイズとなります。

この二分探索を使った解法では、

  • 前処理にΘ(Nlog N)空間
  • クエリ一つあたりにΘ(log N)時間

かかります。後者の解法は定数項が大きく、特に優れた点は見受けられませんが、累積和手法を思いつけなかった場合には有用そうです。

各点での重みが0, 1とは限らない自然数の集合Sであり、それをΘ(1)時間で計算可能な \mathbb{N}→Sが入力長Ο(N)で与えられる場合

Sの要素に何が含まれるかが重要となります。各点での重みのとり得る集合Sの要素で最大のものをKとします。

この場合、累積和手法では何も複雑なことはありません。なお、空間計算量はlog K倍悪化します。

二分探索手法では、要素の重複を許す集合として構成すれば、同じ手法が使えます。しかし、前処理の空間計算量が最悪K倍悪化し、クエリ一つあたりにかかる時間もΟ(log K)倍悪化します。

つまり、KがΘ(N)くらいのオーダーであると、前処理の空間計算量がΘ(N2 log N)となり、N=105の時に2秒以内には完了しないアルゴリズムとなります。

特性関数が、特性関数が1になる点(N以下の自然数)のソートされていない列(長さK)として与えられる場合(K<N)

この場合に累積和手法を適用するには、前処理部分に以下の二つの考え方があります。

  1. ソートしてから累積和。
  2. いもす法。

1.の手法の場合、累積和を取る以上、Θ(Nlog N)の空間の確保は避けられません。 Kとしてあり得るのはN未満の自然数なのでソートはバケットソートとするのが計算量のオーダーとして最善になります。

結局両者のやる仕事は同一になります。

一方、二分探索の手法では、前処理として単にΘ(Klog K)(厳密には、さらにΟ(log N)×Θ(log K)倍の時間がかかるはずです)*3時間かかるソートを行うだけで済みます。

つまり、Kの大きさによっては、二分探索手法が生きる局面があり得ます。それは、KがNに比べて十分に小さい場合です。

特性関数が1となる点が自然数とは限らない(doubleで表されたり、有理数となる)場合

この場合には、累積和手法を適用するのは困難となります。一方、二分探索手法はそのまま適用可能です。

これは、Nが非常に大きく、Kがそれに比べて小さいパターンと同一視できます。

各点での重みが0, 1とは限らない自然数の集合Sであり、その点も自然数とは限らない場合

どちらの手法でも困難に思われます。セグメント木を使うと解けそうです。

Sが任意の有理数である場合も同様だと思います。

まとめ

  • 重みが非零となりうる点の数が少なければ、累積和手法は有効。
  • 各点の重みのとり得る値が非負で小さければ、二分探索手法は有効。
  • 元問題はその両方の性質「重みが非零となりうる点の数はΟ(N)」「各店の重みのとり得る値は0か1」を満たしていたためどちらでも解けた。

*1:常考慮外とされますが、N未満の自然数を表すにはlogNビット必要な点に注意してください。

*2:N未満の自然数同士の引き算にはΘ(log N)時間かかる点に注意してください。

*3:Ο(log N)倍は、N未満の自然数(Nビットで表せる)の比較にかかる時間(入力がランダムなら平均Θ(1)ですが、最悪の場合はΘ(log N)です)、Θ(log K)項は、要素へのポインタの移動時に書き換えないといけないビット数です(要素の移動だとΘ(log N)ビットの書き換えが必要ですが、要素数が少ない場合要素へのポインタの移動にしてしまえばΘ(log K)ビットで済みます)。

AtCoder Beginner Contest 122に参加した記録

忘れないうちに書いておきましょう、と思って既に二週間が経過しました……。

この日はちょうど私の所属しているサークルの合宿の日で、サークルのメンバーのうち何人かがAtCoder Beginner Contest (ABC) に参加するという感じで盛り上がっていました。

私はコードを書くのをせかされる感じが嫌なのでコンテストは嫌いだったのですが、まぁ食わず嫌いなのもよくないので参加することにした感じです。

一応、数年前に、競技プログラミングの過去問を解く授業を受けたことはあったし、その授業の教科書(参考書だったかな)として買った蟻本はすべて読んだので、このくらいのレベルの問題は解けないとまずいなぁという思いもありつつ。

(以下、時系列で書いていきますが、少なくともコンテストに関しては初心者中の初心者なので、参考になるところはないと思われます……)

18:30~19:00

サークルメンバーのみんなで夜ご飯を食べました。ABCがあるので参加しようという話が出ました。

20:00~20:45

二分探索難しいとかいう話題で盛り上がりました。C#は書いたことがあるがC++は書いたことのないというメンバーに、最低限の知識(std::cinstd::coutstd::vectorstd::string)を教えました。

20:55

やっぱり参加しようと決断し、ノートパソコンを取り出したものの、ファンが壊れていて異音がします……。うるさいので別室で受験することにしましょう。一時間しか寝ていないのでねむい……。

21:00

ABCスタート。とりあえずD問題をちらっと見てDPっぽいなぁと思いました。

21:01

A問題を解きましょう。特に難しいところはないようですが、if文を書くのはめんどくさいなぁとか、条件演算子を書くとみづらくなるなぁとか、そういうことを考えてやや時間を無駄にした感じがあります。 switch文を使ったコードを書いて正答になりました(3:04)。

なお、sed y/ATGC/TACG/でよいということに気付いたのは、コンテスト終了後でした(後述)。

21:03

B問題を解きましょう。制約が非常に緩いので、全ての部分文字列を列挙する手法が可能だなぁと思いました。この手法は、配列インデックス境界バグ*1を生みにくいので制約が緩い以上こっちの方針でやろうと思いました。実際、その方が読みやすく、つまりバグを埋めにくいようなコードになるはずです。

……と思ったら、C++std::string::substr関数は(何文字目から、何文字分)みたいな指定方法なので結局境界値バグを生みやすい形ですね……。

冷静に考えると、Θ(N)解法があることに気付きました。尺取り法(の退化したバージョン?)を使えばよさそうです。

私の実力では、このあたりになってくると頭の中だけでプログラミングするのは無理です。特に準備もしてこなかったので環境が整っているわけでもなく、wandboxでコンパイルして確かめることにしました。

とりあえずテストケースに成功したので提出してみます(8:52)。

21:08

C問題を解きましょう。クエリですか、なるほどです。クエリといえば、前処理です。これは累積和でできそうですね。 累積和でやるとすると、落とし穴になりそうなところ(コーナーケース)は、

  • 配列境界(-1要素目とか、N要素目とかにアクセスしてはいけない)
    • 'AC'という二文字を考えるので、str[i-1]とかstr[i+1]で失敗しないように気を付ける必要があります
  • 文字列の切れ目がちょうど'AC'にまたがる場合
    • 単純な累積和よりミスを誘うポイントが増えています
  • その複合
    • 文字列の切れ目が、ちょうど1文字目とか、末尾とか、そういうところになった場合、上記二つ同時に気を付けないといけません
    • (番兵を置いておけばこの問題はなくなりますが、このことに気付いたのはコンテスト終了後でした)

あたりでしょうか。

21:10

あれー、B問題がWAになっています。ちらっとコードを見た感じではバグっている箇所を見抜けませんでした。テストケースの後半が全滅しているのもよくわかりませんが……。とりあえず放置してC問題を解く作業に戻りましょう。

クエリの形式が1-originであることとか、いろいろ混乱し悪戦苦闘した結果、30分弱時間を溶かしましたが、なんとか正答です(35:10)。

サンプルケースがコーナーケースを網羅していたのはやさしさでしょうか。

ちなみに、紙とペンを用意していなかったので、以下のようなアスキーアートをメモ帳(notepad.exe)に描いて考えていました。

 A C A C T A C G 
| | | | | | | | |
0 0 1 1 2 2 2 3 3

21:35

B問題の提出コードをもう一度見てみましょう。文字列終端に達した時に、尺取虫のしっぽを引っ込める作業を忘れていることに気付きました。時間を置くと見つかるものですね。正答です(36:44)。

21:36

さてあと一時間ほどしかありませんが、D問題を解きましょう。【違反してない文字列】の末尾に文字をくっつけた時、【違反】が発生するかを考えればよさそうです。なぜなら、【違反している文字列】の末尾に何かを付け加えても、違反していない状態に戻るわけではないからです。よく考察していませんが、たぶん末尾3文字が「状態」であるDPでうまくいくのではないでしょうか。コピペで以下のコードを作り出しました。

std::uint64_t arr[4][4][4][101]
for( int i = 0; i < 4; ++i )
for( int j = 0; j < 4; ++j )
for( int k = 0; j < 4; ++k )
     if( i == 0 && j == 2 && k == 1 ) arr[i][j][k][3] = 0;
else if( i == 0 && j == 1 && k == 2 ) arr[i][j][k][3] = 0;
else if( i == 2 && j == 0 && k == 1 ) arr[i][j][k][3] = 0;
else arr[i][j][k][3] = 1;

std::size_t N;
std::cin >> N;
for( std::size_t t = 4; t <= N; ++t )
for( int i = 0; i < 4; ++i )
for( int j = 0; j < 4; ++j )
for( int k = 0; j < 4; ++k )
     if( i == 0 && j == 2 && k == 1 ) arr[i][j][k][t] = 0;
else if( i == 0 && j == 1 && k == 2 ) arr[i][j][k][t] = 0;
else if( i == 2 && j == 0 && k == 1 ) arr[i][j][k][t] = 0;
else arr[i][j][k][t] = (arr[i][j][0][t-1] + arr[i][j][1][t-1] + arr[i][j][2][t-1] + arr[i][j][k][3][t-1]) % 1'000'000'007;

std::uint64_t sum = 0;
for( int i = 0; i < 4; ++i )
for( int j = 0; j < 4; ++j )
for( int k = 0; j < 4; ++k )
sum = (sum+arr[i][j][k][N]) % 1'000'000'007;

エレガントさのかけらもありませんが、こちらも必死なので少なくとも自分にはぎりぎり読める感じの、わけのわからないコードになってきました。

いちおう、ケチらずにstd::uint64_tを選択したことにより、貰うDPとして書いてもオーバーフローの心配がないのは良かった点ですが、やっぱり配るDPで書いた方がよかったかなぁと思いつつ、しかし貰うDPとして書いた方が直感的だし(?)こっちでよかったかなとか思っていました。

このコードで3を入力すると61が出るのは当然ですが、4を入力すると235が出てきてテストケースと合いません。つまり、何か漏らしている条件があるのですが、まぁおおむね方針は立ちました。

22:01

arr[i][j][k] = (arr[0][i][j][t-1] + arr[1][i][j][t-1] + arr[2][i][j][t-1] + arr[3][i][j][k][t-1]) % 1'000'000'007;

の方が正しいことに気付きました。 233って出てきました。依然、何か見落としています。

22:04

"xxxxAGx"のような文字列の末尾にCを付け加えると条件に違反することに気付けました。

else if( i == 2 &&  k == 1 ) arr[i][j][k][t] = (arr[1][i][j][t-1] + arr[2][i][j][t-1] + arr[3][i][j][t-1]) %  1'000'000'007;

231って出てきました。もう一条件あるのを見落としていそうです。時間間に合わないかもなぁ、とか思いつつ、N=4のケースで違反する26通りを手で列挙するという作業を地道にこなすことにします(というかコード上で考えるのではなく、最初からそうするべきでした)。

22:18

尾三文字が"AGC"、"ACG"、"GAC"になっている場合("AGC"の原因が固まって存在している場合)のほかで違反してしまうパターンがなんとなくわかりました。 つまり、末尾四文字の中に"AGC"の原因が、関係ない一文字を挟んで存在している場合("AGxC"と"AxGC")というパターンでした。

else if( j == 2 &&  k == 1 ) arr[i][j][k][t] = (arr[1][i][j][t-1] + arr[2][i][j][t-1] + arr[3][i][j][t-1]) %  1'000'000'007;

ちゃんと230って出ました。N=100のケースを試してみたところ、ぴったり一致したため、ほぼあっている確信を得て提出、正答でした(79:28)。

22:19

この時点では、287位だったっぽいです。知り合いの順位を調べたり、Twitterをしたりして、暇をつぶします。

22:30ごろ

ふと順位表を見たら300位になっていました。なるほど、WAが一回あったから、WAなし80分完答みたいな参加者に抜かされたわけですね。

22:40

コンテストおわり。サークルメンバーと雑談します。

感想戦

D問題(DPのやつ)

  • やはり貰うDPではなく配るDPの方が筋がよかったらしい
  • あと、初期化のために「文字なし」っていう状態を付け加えた、53状態のDPをやるときれいに書けたらしい

C問題(クエリのやつ)

  • あるメンバーは累積和は思いつかなかったものの、(事前に二分探索の話をしていた影響もあって)二分探索で解く方法にたどり着いたらしい
    • 二分探索でも解けるということ自体が興味深い話に思えるので、後日記事を分けて書こうと思います
  • 累積和でどこを基準にするかみたいな話は、自分の中で何かルールを決めておくと、コンテストの限られた時間で思考をまとめやすいという話を伺いました

A問題(簡単なやつ)

  • こういうのはesolangで解く参加者もいるよね、あー普通にsedで解けるじゃん、みたいな話をしました
    • とはいっても、初めてのコンテストで緊張している中、勝手もわからないesolangを使う気にはなれないような……

コンテスト参加者のC問題の正答例を見て

どうやら、ACの区切れ目を気にするのではなく、Aが直前に存在するCっていう風にとらえてカウントしていけばよかったようです(解説ではCが直後に存在するAって書いてありますが、同じことです)。

さすがに配列境界の問題に警戒しすぎたっていうことなのかな?

反省

見通しを立てるのは数秒でできているのに実装に何十分もかかっているあたり、やはり細かいところを詰めるのが苦手だなぁと改めて実感しました。

B問題でのWAのようなものは、単なるケアレスミスと甘く見ず、本当に意識して練習しないとなおらないということを大学受験で経験している*2ので、ちゃんと反省したいです。

準備不足は、いつでも完璧なコンディションでできるわけではないので、それほど悔いていませんが、最低限紙と鉛筆はあった方がよさそうということは学びました。

結果

  • AtCoder Beginner Contest 122
  • 300th / 3024
  • パフォーマンス 1600
  • レーティング 400 (初参加)
  • 段級位 9級

*1:N要素の配列の(0-originで)N番目にアクセスしてしまうみたいなバグ。私はこのバグを発生がちなので、警戒していました

*2:化学の計算問題で計算ミスを多発させていましたが、「本質はわかっているのだから気を付ければ問題ない」と甘くスルーしてしまっていました。幸い、受験までの残り時間が十分にある時期に、この考えが誤りであることに気付いてなおせたので事なきを得ましたが。