AtCoder Regular Contest 017 の C 問題
また古い問題を持ち出してきたなぁ、と思うかもしれない。まぁそうなんだ。
今、 ARC のA問題とB問題を毎日解く、みたいなことをやっていて、解けるならC問題まで解くようにしている。
このC問題、今まで僕は半分全列挙の問題をあまり解いたことがなく、解いてみて面白いと感じたので、丁寧に考えてみることにした。
概要
個の品物がある。それぞれの重さは である。合計が になるような組み合わせは何通りか。
何を考えたか
まず制約を見てぱっと思いつくことは、
- が大きい
- はやたら小さい
ということだろうか。
問題文にはナップサック問題っぽいことが書いてあるが、この のサイズだと動的計画法では難しそう、という予感がある(でも1回試しちゃった)。
というわけで、 が小さい点に着目してみる。小さいとは言っても なので、組み合わせを全列挙するのは難しそう( )。そう、全部列挙するなら……。
というわけで、半分全列挙が出てくる。
半分全列挙
典型テクニックの1つだと認識している。
対象を半分に分け、それぞれの中で全列挙して、その後でそれぞれの要素を突き合わせるような手法である。
例えば今回の問題。 は非常に大きい数になってしまうのだが、その半分の 個の組み合わせを全列挙するだけなら、 ですむ。なので、まず半分ずつ全部の組み合わせを試した後、それぞれのグループから1つずつ取り出して合計が になる組み合わせを数え上げれば良い。最後に突き合わせる処理に二分探索を用いることで、計算量を抑えることができるのがポイント。
二分探索
上に挙げたように、最後に組み合わせを数える際に愚直にやってしまうと、結局 かかってしまう。しかし、合計が になればいいのだから、ソートした後に二分探索をすることで の計算量ですませることができる(この時、同じ数値が複数個出てくる場合を考えなければいけないので注意)。
二分探索はあらゆる場面で役に立つのだ。
ハッシュテーブル
この記事を書いている間に気付いたのだが、合計がきっちり になる場合のみ数えればいいのだから、別にソートして二分探索しなくても、ハッシュテーブルに突っ込んでしまえばいい。
これで全体の処理はかなり簡単になる。
実装
実際のコードはこんな感じ。
import std.stdio, std.algorithm, std.conv, std.array, std.string, std.math, std.typecons, std.numeric, std.bigint; long[32] WS; void main() { auto nx = readln.split.to!(int[]); auto N = nx[0]; long X = nx[1]; foreach (i; 0..N) WS[i] = readln.chomp.to!long; auto ns = [0L], nss = [0L]; foreach (i; 0..N/2) { foreach (j; 0..ns.length) ns ~= ns[j] + WS[i]; } foreach (i; N/2..N) { foreach (j; 0..nss.length) nss ~= nss[j] + WS[i]; } int[long] ND; foreach (n; nss) { if (n in ND) { ++ND[n]; } else { ND[n] = 1; } } int res; foreach (n; ns) { auto x = X - n; if (x in ND) { res += ND[x]; } } writeln(res); }
Ruby でも実装してみた。かなり簡潔に書ける。
n, x = gets.split.map(&:to_i) ws = n.times.map { gets.to_i } def zr ws ws.reduce([0]) { |ns, w| ns[0...ns.length].each do |n| ns << w + n end ns } end ns = zr ws[0...n/2] nd = zr(ws[n/2..-1]).reduce(Hash.new { 0 }) { |nd, n| nd[n] += 1 and nd } puts(ns.reduce(0) { |a, n| a + nd[x - n] })
感想
最初やった時は「半分全列挙と二分探索との組み合わせで解けるの、応用っぽくてカッコいい」と思ったのだが、よく考えてみたら二分探索は不要だった(解説では二分探索の利用に触れていたけど)。
半分全探索がかなり綺麗に嵌る問題に見えるので、練習にはとても良かったと思う。