Atcorder ABC132 F-Small Products

リンク

まとめ

  • dpで解ける問題
  • 単純なdpだとテーブルが爆発する
  • 数が少ない時は良いが、大きい数に対応するためにも工夫が必要

問題

K個の整数をならべて、隣り合う整数同士を書けた結果がNを下回るような整数列の数を数える。

例えば K=2,N=3のとき

{1,1},{2,1},{3,1},{1,2},{1,3}で5個

方針

dp[i][j]で"i+2"が"並べた整数の個数"、"j"が"今注目している整数に対して何個の整数列ができるか+それまでの合計値"として組む。

#include <iostream>
#include <vector>

int main() {
  int n, k;
  std::cin >> n >> k;

  --k;
  int size = n + 1;
  std::vector<int64_t> dp(size * k);

  for (int64_t i = 0; i < k; ++i) {
    dp[i * size + 0] = 0;
    for (int64_t j = 1; j < size; ++j) {
      int64_t &now = dp[i * size + j];
      const int64_t &left = dp[i * size + j - 1];
      if (i == 0) {
        now = n / j + left;
      } else {
        now = dp[(i - 1) * size + n / j] + left;
      }
    }
  }

  std::cout << *(dp.end() - 1) << std::endl;

  printf("     |");
  for (int j = 0; j < size; ++j) {
    printf("%5d", j);
  }
  std::cout << "\n----------------------\n";
  for (int i = 0; i < k; ++i) {
    printf("%5d|", i + 2);
    for (int j = 0; j < size; ++j) {
      printf("%5d", dp[i * size + j]);
    }
    std::cout << std::endl;
  }

  return 0;
}

実行結果

input 3 2 output 5 => OK

$ ./a.out 
3 2
5
     |    0    1    2    3
----------------------
    2|    0    3    4    5

input 10 3 output 147 => OK

10 3
147
     |    0    1    2    3    4    5    6    7    8    9   10
----------------------
    2|    0   10   15   18   20   22   23   24   25   26   27
    3|    0   27   49   67   82   97  107  117  127  137  147

分析

input 10 3の時を分析する。 dpがどのように変化しているかはテーブル参照。

整数を2つ並べた時に注目する。

     |    0    1    2    3    4    5    6    7    8    9   10
----------------------
    2|    0   10   15   18   20   22   23   24   25   26   27

今回の値が0だった場合は、何をかけても0にしかならないため、整数列は1つも存在しない。

今回の値が1だった場合は、1,2,3,4,5,6,7,8,9,10のどれをかけても良いため、10個の候補が存在する。

今回の値が2だった場合は、1,2,3,4,5のどれをかけても良いため、5個の候補が存在する。 1だった場合のところに加算して、合計15個の候補が存在する。

これを10まで繰り返すことで2つの整数に関して何個の候補があるかを調べることができる。

ここに3つ目の整数を追加しよう

     |    0    1    2    3    4    5    6    7    8    9   10
----------------------
    2|    0   10   15   18   20   22   23   24   25   26   27
    3|    0   27   49   67   82   97  107  117  127  137  147

今回の値が0だった場合は、何をかけても0にしかならないため、整数列は1つも存在しない。

今回の値が1だった場合は、[x y 1]という整数列になる。 yの値は1\~10だったらなんでもいい。 yが1だった場合はxが1\~10まで。 yが2だった場合はxが1\~5まで。 …

そのため[x y 1]のときに作れる整数列はyを1\~10まで試した時の合計値になる。 今回は合算しているので列の長さ2、値10の時の27を見ればいいことになる。

今回の値が2だった場合も同様に考える。 [x y 2] と並んでいて、yに1\~5の値が入る。 そのため列の長さ2、値5の時の22になる。 1の時に合算して49だ。

問題点と改善

一番の問題点は、nの値が大きいとメモリ確保でコケることだ。

  int n, k;
  std::cin >> n >> k;

  --k;
  int size = n + 1;
  std::vector<int64_t> dp(size * k);

入力例3を入れると一発でわかる。

$ ./a.out 
314159265 35
terminate called after throwing an instance of 'std::bad_alloc'
  what():  std::bad_alloc
中止 (コアダンプ)

Kは100までになっているのでN方向のdpをなんとかしたい。

テーブルから増加幅を確認してみる。

// input 10 3
----------------------
    2|   10    5    3    2    2    1    1    1    1    1
    3|   27   22   18   15   15   10   10   10   10   10

列の長さ2の場合、6\~10が常に1になっている。 列の長さ3の場合、6\~10が常に10になっている。 int(10/6) == int(10/10)は同じになるため、目標値に対する商が同じときは合体させることができそうだ。 この関係はint(10/4) == int(10/5)でも同じことがいえる。

圧縮の方針は、

  • int(N/a) == int(N/b)となるa,bをまとめ上げる
  • そのときの個数を数えておく
  • 数え上げのタイミングで"今回の値で作れる整数列”×"今回の値に圧縮されている数"を積み上げていくようにする。
#include <iostream>
#include <vector>

const int64_t MOD = 1000 * 1000 * 1000 + 7;

int main() {
  int64_t n, k;
  std::cin >> n >> k;

  std::vector<int64_t> cand;
  std::vector<int64_t> num;
  cand.push_back(0);
  num.push_back(0);

  for (int64_t i = 1;;) {
    auto div = n / i;
    cand.push_back(div);
    auto next = n / div + 1;
    num.push_back(next - i);
    i = next;
    if (div == 1) {
      break;
    }
  }

  --k;
  auto size = cand.size();
  std::vector<int64_t> dp(size * k);

  dp[0] = 0;
  for (int64_t j = 1; j < size; ++j) {
    int64_t &now = dp[j];
    const int64_t &left = dp[j - 1];
    now = ((cand[j] * num[j]) % MOD + left) % MOD;
  }

  for (int64_t i = 1; i < k; ++i) {
    dp[i * size + 0] = 0;
    for (int64_t j = 1; j < size; ++j) {
      int64_t &now = dp[i * size + j];
      const int64_t &val = dp[i * size - j];
      const int64_t &left = dp[i * size + j - 1];

      now = ((val * num[j]) % MOD + left) % MOD;
    }
  }

  std::cout << *(dp.end() - 1) << std::endl;

  printf("     |");
  for (int j = 0; j < size; ++j) {
    printf("%5d", j);
  }
  std::cout << "\n----------------------\n";
  for (int i = 0; i < k; ++i) {
    printf("%5d|", i + 2);
    for (int j = 0; j < size; ++j) {
      printf("%5d", dp[i * size + j]);
    }
    std::cout << std::endl;
  }

  return 0;
}

というわけでこのプログラム(の最後のテーブル出力部分を消して)を提出してAC。

10 3の場合で確認しておく。 input 10 3 output 147 => ok

10 3
147
     |    0    1    2    3    4    5
----------------------
    2|    0   10   15   18   22   27
    3|    0   27   49   67   97  147

初期化回りは大きく変更している。

ここで同じ商になる値を探りつつ、その時作れる整数列と重ねることができる値の数を求めている。 こんな変な計算しなくても出せる気がしている。

  for (int64_t i = 1;;) {
    auto div = n / i;
    cand.push_back(div);
    auto next = n / div + 1;
    num.push_back(next - i);
    i = next;
    if (div == 1) {
      break;
    }
  }

ここは1列目(つまり整数が2個の時)の初期化になる。 上の計算のついでに行ってしまえばいいという話もある。

  dp[0] = 0;
  for (int64_t j = 1; j < size; ++j) {
    int64_t &now = dp[j];
    const int64_t &left = dp[j - 1];
    now = ((cand[j] * num[j]) % MOD + left) % MOD;
  }

2列目(3個の整数の列)以降はひたすら合算していく。 valで参照しているのがひとつ前の列の、"今の値に対して何個の整数列が作れるか"になる。 それを条件が重なっている数分掛け算してどんどこ足していく。

  for (int64_t i = 1; i < k; ++i) {
    dp[i * size + 0] = 0;
    for (int64_t j = 1; j < size; ++j) {
      int64_t &now = dp[i * size + j];
      const int64_t &val = dp[i * size - j];
      const int64_t &left = dp[i * size + j - 1];

      now = ((val * num[j]) % MOD + left) % MOD;
    }
  }

もし例3を実行するなら最後のテーブル出力のところは消してから実行したほうがいい。(一敗

numの列とdpが2列あれば計算できるのでメモリはもっと節約できる。