Senの競技プログラミング備忘録

こけた問題を自分用の解説で載せる。けんちょんさんのブログを目指したい。質的にも量的にも。こけた問題だけに限定するけど

MIS.W新歓コンテスト2020 - SABAKAN FEVER

www.hackerrank.com

概要

東京23区の地図(とそれぞれの隣接情報を文字化したもの)が与えられる。
それぞれの区には「幸せ度」という数値が設定されている。いくつかのお互いに隣接しない区を選び、その「幸せ度」の和の最大値を求めよ。

f:id:Sen_comp:20200423170141p:plain

解法

まず、みなさんについて質問ですが、どう解きましたか?


この問題は、答えのみを提出する普段のAtcoderCodeforcesのようなコンテストにはない形式です。答えのみを提出するので、プログラミングが得意ではない人も、コツコツやれば大変ですが、必ず正解できます。

しかし、後述の理由からコンピューターを使って計算することをお勧めします。

hackerrankにはペナルティ数に影響がありませんので、正解かな?と思うようなものがあったら積極的に提出するという手段を取れます。(あまりやりすぎるとサーバー堕ちると思うのでほどほどにしてください)

ではまず、パズルとして解くときのポイントをいくつか解説します。

パズル

f:id:Sen_comp:20200423170141p:plain

まず最初に謝りたいことがあります。この地図の数値配置を作った時間の割にはかなり、貪欲的に解けないような配置、つまりだらけです。本当に申し訳ございませんでした。

答えです。

世田谷(19) 品川(15) 中野(15) 千代田(25) 江東(18) 板橋(13) 葛飾(15) 荒川(22)
Ward Cnt == 8
total == 142

f:id:Sen_comp:20200423183029j:plain

わかります。「クソパズルをさせるな本質コンテストをやれ」という声はもっともです。でもコンテストの時間自体は長いので、こういうのあってもいいじゃない。(圧倒的罵声)(投げ込まれるカッターナイフ)(家に届く怪しい白い粉)(連日要求される切腹)(Sen氏、人生から堂々と退場す)

パズルを解いた方々は、これから羅列する罠要素には十分共感できると思います。

海辺の20以上の4区(渋谷、新宿、千代田、中央)は1つしか使わない

着眼点としての海辺のハイスコア地域、そこからパズルを始めた方は多いのではないでしょうか?ハイスコア4区のうち2つを選んで、そこからと。

千代田区(25)しか使いません。

理由として、ハイスコア地域を2つ以上採用した場合、トータルでは合計で最大でも7区しかとれません。そしてこの数値設定では、絶妙ではありますが、区数優先のほうが強いのです(結果論)

ちなみにハイスコア地域2つ採用した時のベストアンサー

練馬(18) 世田谷(19) 品川(15) 新宿(24) 中央(22) 葛飾(15) 荒川(22)
Ward Cnt == 7
total == 135
端から攻めるがほとんど通用しない

パズルの定番として選択肢の少ないところからスタートしていく、というのがありますが今回では攻める端については、
右端と下端以外は(結果論ですが)不確定要素にまみれていて、かといって
右端も確固たる理由を持って葛飾区、江東区と選べるというわけでもない。

ある程度明確的に選べるのは、大田区を明らかに除外できるとわかったときの世田谷区や品川区だけです。

こういう点からもクソパズルであることはわかります。とても反省しています。

頭を抱えたくなる局所最適解

こちらが少しパラメータ調整して作った局所最適解が以下です。

練馬(18) 世田谷(19) 品川(15) 新宿(24) 江東(18) 台東(14) 北(17) 葛飾(15)
Ward Cnt == 8
total == 140

これに比べて正解はこれ。

世田谷(19) 品川(15) 中野(15) 千代田(25) 江東(18) 板橋(13) 葛飾(15) 荒川(22)
Ward Cnt == 8
total == 142

なんとたったの2しか違いません。そして、この局所最適解は明らかに袋小路であることは次の図をご覧いただければわかります。

140局所最適解:
f:id:Sen_comp:20200423183752j:plain

142正解:
f:id:Sen_comp:20200423183029j:plain

は?

ラソンのデータセットに向いてそう

上端、左端を含みなんと4つも選び方に違いがあります。140の方を引いてしまった方、申し訳ありませんでした。答えは142なんです。

強烈に自己主張されてる港区は使わない

港区のサイトからの引用なので、仕方はありませんが、港区は使用しません。

なんなら次善策でも使用しません。

港区を必ず使った時のベストアンサーです。

杉並(16) 目黒(17) 港(18) 江東(18) 豊島(19) 葛飾(15) 荒川(22)
Ward Cnt == 7
total == 125

以上の要素からわかる通り、このパズルを解こうとするのならば、かなりの労力を使わなければいけません。みなさん、誠に申し訳ありませんでした。

全探索

困ったときの全探索という言葉がある通り、これを全探索で解けないか?と考えてみましょう。まずは状態数を数えてみましょう。

まず、この地図をグラフとしてみなして、隣接してる区とは無向辺でつながるようにします。地図はグラフにモデリングしやすいものとしてよく知られています。

状態数は、多めに見積もっても(隣接してるならばダメを考えずに)、「それぞれの区について、選ぶor選ばないの2択」と考えると、最大でも 2^{23}=8388608通りになります。

それぞれの状態ごとに、隣接してるかどうか?の判定は、すべての辺について両端が使われているか?を判定して、1つでもあるのならば隣り合った区が使われている、ということになります。

これで、使われている状態が与えられたとき、辺の本数を Mとすると、 O(M)で隣接チェックを行えました。では、どのようにして 2^{23}通りもの考えを実装するのでしょうか?

drken1215.hatenablog.com


こちらのけんちょんさんの記事で紹介されている、このように 2^Nのパターンを全部列挙したいときにやる実装法としてbit演算を用いる、という手法があります。これは一般的にはbit全探索と呼ばれていて、ABC-Cでも出題済みです。

この時、計算量を見積もってみましょう。辺の本数(すなわち区の隣接情報の個数)は与えた地図では106本(無向辺を2本の有向辺で表現した)です。すると、全体ではおおよそ次の回数のループが回ります。
 2^{23} \times 106 = 889192448< 9 \times 10^{10}‬

普段のコンテストならば、2秒の制約に間に合うのか非常に怪しいと言えます。実際、Writer解の実行時間は5000ms(Intel Core i5-6200U, 8GB)と1968ms(Intel Core i7-7500U 8GB)でした。これを20倍まで浮動すると考えても、現実的な時間ではあります。

しかし、この問題は、回答だけを提出するタイプの問題。多少長くても、現実的な時間ならば手元で計算すればいいだけです。

BitDP

さらに、全探索から高速化をすることができます。(Writer解は全探索、m_tsubasaさんから言われて気づきました)BitDPをわかってる人向けの解説です。

まず、集合Sについて、最上位の立ってる桁を除いた集合S'を考えます。bitで集合を表現するときに、0からfor文を回せば、Sについての計算をするときに、S'についての答えをメモしていれば、最上位の立ってる桁が表してる頂点と、それ以外の桁の立ってる頂点が隣接か?と隣接判定をする量を大幅に減らせます。これによって、全探索の計算量が O(2^NM)何に対して、 O(2^NN)になります。( Mは辺の数、 Nは頂点の数)

実装

みなさん、大変申し訳ありませんでした。グラフに変換するのが面倒で申し訳ありません。ここでは、Writer解の実装を紹介します。

まず、与えられた区の隣接リストについて、

練馬区: 中野、杉並、豊島、板橋
杉並区: 練馬、中野、世田谷、渋谷
...

を、競プロ風の入力に整形します。

4 中野 杉並 豊島 板橋
4 練馬 中野 世田谷 渋谷
...

このとき、エディタに備わっている置換機能で

  • 全角スペースと「、」->半角スペース

とすれば手間がだいぶ省けると思います。

そして、練馬、杉並、世田谷、太田...をそれぞれ番号で0, 1, 2, 3...と置換します。

4 8 1 17 18
4 0 8 2 7
...

これで、隣接リストを数値化することができました。(Writer解はここまで変換していません)

あとは「幸せ度」のリストの変換ですが、同じようにデータを加工して(「:」は手動で消して、全角スペースを半角スペースに置き換えて、先ほどと同じルールで区を数字に変換する)

0 18
1 16
...

のようにします。

ここまで述べたのは、あくまでグラフへの変換の一例です。この通りに行う必要はありません。

最終的には、Writer解は次のデータセットを読み込んで、実行するものです。

4 中野 杉並 豊島 板橋
4 練馬 中野 世田谷 渋谷
4 杉並 渋谷 目黒 大田
3 世田谷 目黒 品川
4 目黒 港 渋谷 大田
4 大田 世田谷 渋谷 品川
5 品川 渋谷 新宿 千代田 中央
7 品川 港 新宿 中野 杉並 世田谷 目黒
5 杉並 練馬 豊島 新宿 渋谷
6 豊島 中野 渋谷 港 千代田 文京
5 港 中央 台東 文京 新宿
5 港 千代田 台東 墨田 江東
3 中央 墨田 江戸川
3 江東 墨田 葛飾
7 江戸川 葛飾 足立 荒川 台東 中央 江東
5 墨田 中央 千代田 文京 荒川
6 北 荒川 台東 千代田 新宿 豊島
6 練馬 板橋 北 文京 新宿 中野
3 練馬 豊島 北
5 板橋 豊島 文京 荒川 足立
4 北 荒川 墨田 葛飾
3 足立 墨田 江戸川
5 北 足立 墨田 台東 文京
0 18
1 16
2 19
3 12
4 15
5 17
6 18
7 20
8 15
9 24
10 25
11 22
12 18
13 13
14 20
15 14
16 17
71 19
18 13
19 17
20 18
21 15
22 22
#include<iostream>
#include<string>
#include<map>
#include<vector>
#include<algorithm>

using namespace std;
typedef long long ll;

map<string, int> taiou;//都市名->
string taiou_inv[23];

int v[23];

int main() {
	taiou["練馬"] = 0;
	taiou["杉並"] = 1;
	taiou["世田谷"] = 2;
	taiou["大田"] = 3;
	taiou["品川"] = 4;
	taiou["目黒"] = 5;
	taiou["港"] = 6;
	taiou["渋谷"] = 7;
	taiou["中野"] = 8;
	taiou["新宿"] = 9;
	taiou["千代田"] = 10;
	taiou["中央"] = 11;
	taiou["江東"] = 12;
	taiou["江戸川"] = 13;
	taiou["墨田"] = 14;
	taiou["台東"] = 15;
	taiou["文京"] = 16;
	taiou["豊島"] = 17;
	taiou["板橋"] = 18;
	taiou["北"] = 19;
	taiou["足立"] = 20;
	taiou["葛飾"] = 21;
	taiou["荒川"] = 22;

	for (auto it = taiou.begin(); it != taiou.end(); it++) {
		taiou_inv[(*it).second] = (*it).first;
	}

	vector<vector<int>> E(23);
	for (int i = 0; i < 23; i++) {
		int k;
		cin >> k;
		for (int j = 0; j < k; j++) {
			string buf;
			cin >> buf;
			E[i].push_back(taiou[buf]);
		}
	}

	bool L[23][23] = {};
	for (int i = 0; i < 23; i++) {
		for (int j = 0; j < E[i].size(); j++) {
			L[i][E[i][j]] = true;
		}
	}

	for (int i = 0; i < 23; i++) {
		int a;
		cin >> a >> v[i];//最初の0, 1, 2の序数はいらない
	}

	int ans = 0;
	int Cnt = 0;//debug用
	for (int bit = 0; bit < (1 << 23); bit++) {
		vector<bool> used(23);

		bool flg = true;
		int happiness = 0;

		for (int i = 0; i < 23; i++) {
			if ((bit >> i) & 1)used[i] = true;//i番目が使われていたのならば、usedに印をつける
		}
		for (int i = 0; i < 23; i++) {
			if (used[i])happiness += v[i];
			for (int j = 0; j < E[i].size(); j++) {
				int neighbor = E[i][j];
				if (used[i] && used[neighbor])flg = false;//隣接してる区を使ってしまっている
			}
		}
		// 半分debug用
		if (happiness >= 142 && flg) {
			
			for (int i = 0; i < 23; i++)
				if(used[i])cout << taiou_inv[i] << "(" << v[i] << ") ", Cnt++;
			cout << endl;
			cout << "Ward Cnt == " << Cnt << endl;
			cout << "total == " << happiness << endl;
			Cnt = 0;
		}
		
		if (flg)ans = max(ans, happiness);
	}
	cout << ans << endl;
	return 0;
}

これを実行した答えを指定の制約に整形して、提出するとACです。

142
arakawa
itabasi
katusika
koutou
nakano
setagaya
sinagawa
tiyoda

統計&講評

AC数: 15
正解率: 83.3%
First AC(All): kotatsugame(23:50)
First AC(MIS.W内): KKT89(205:01)

こたつがめさん早すぎませんか?これ前に別の問題も解いてる上でこの速度なのすごヨの一言しかありません。
主催してるサークル内でのFAはKKT89さんでした。みんなに敬遠されて解かれるのめちゃくちゃ最後になってしまいましたね。

普段コンテストに出せない、実際の地図上での最適化問題をコンピューターで解くというタイプの問題をやりたかったです。1日も時間あるしいっかwと思って出しましたが、やはり面倒なのか敬遠する人が多かったです。地図からグラフデータへの加工という意味で手間がかかるのは性質上避けられませんが、ハードルを結果上げてしまいました。面倒なパズルになってしまったことも併せて、大変お詫び申し上げます。

ちなみに、提出する際に正解を出した方でも、最初の提出で区の順番を間違えてることが多々ありました。問題文までもわかりづらかったことを重ねてお詫び申し上げます。

最後に

f:id:Sen_comp:20200423195218p:plain

鯖缶は美味しいと思います。アクティブマーケティングですが買いましょう。(アカウントと僕は無関係です)