高専プロコン26競技部門
第26回全国高等専門学校プログラミングコンテスト 競技部門、
同時開催されたNAPROCK7thに産技高専(品川)から参加し準優勝をいただきました。
メンバーは以下の通りです。
@_hokekyo1210(3年)、@Mahito6(3年)、@destiny3141592(3年)
僕は高専プロコンの参加は2回目で、準決勝敗退という苦い経験をしています。その時の悔しさから目標を決勝進出と掲げていたので、今回このような素晴らしい賞を頂けてとても嬉しいです。
また、サレジオ高専さんと合同で実戦同様のシステムを用いた練習も行っていました。この場をお借りしてサレジオ高専さんには感謝を申し上げます。
以下は用意したプログラムや、やったことの解説です。
※文中に出てくるquestXXは全て非公式練習場の問題番号です。
開発環境
メンバー間のデータの共有にはDropboxの共有フォルダを利用していました。
OS:Windows 7,Windows 8.1,MacOSX 10.9.5
言語:Java,C++
エディタ:Eclipse4.4.1,Sublime Text 3
開発した物
総コード量はコード全体の行数であってコミットした全てのコード量ではありません。
問題の簡単化
8行8列のグリッドで構成される石は、左上詰めにしておくことで様々な場面で楽が出来るのでまずそれをしておく(このことをRefineと呼ぶ)。
ノードに過去の設置情報を保持しておく必要はなく、盤面に石番号を格納しておけばあとから解は復元可能なのでそれを利用した。
大まかな段取り
1,ビジュアライザで問題を取得
↓
2,GCCRunnerでパラメータを選択
↓
3,ビームサーチで解を探索し出力
↓
4,Shakerで解答を書き換え、より良いスコアを目指す。
↓
5, 2に戻る
実際の試合ではShakerがほとんど使えず、ビームサーチ勝負になってしまった(インタビューで苦手な傾向の問題と言っていたのはこのため)。
ビームサーチの詳細
ノードの評価値
以下の全ての評価値を足し合わせたものに、親ノードの評価値を足し合わせた値をそのノードの評価値とした。
親ノードの評価値を足し合わせる事により、前の盤面の状態も評価に加えることが出来る。
・ZKスコア
現在埋まっているマスの数に応じて点数を与えた、前半は高く、後半は低くなるようなバイアスが掛けられている。
・盤面複雑度
盤面の入り組んでいたり複雑であったりする部分を前計算し、どれだけ複雑な部分が埋まっているかを計算し点数を与えた。
複雑度の計算はあるマスについて、10.0/(障害物からのマンハッタン距離)^2の総和により求めた。
また盤面の直径を求め、もしその直径が閾値を越えていた場合丁度中点となる部分の複雑度も同様に高めた。これによりquest3や、quest14といった問題において効率の悪い場所から石を置き始めることが無くなった。
・領域分割
盤面全体の分割されている部分(ツイッターでは閉領域と呼ばれていた?)に応じて負の点数を与えた。
基本的に盤面は分割されていないことが望ましいのでこれはかなり重要な評価値だったように思われる。
・接地面積
石を設置する時、既に盤面に存在する障害物やZKへの接地面積がより大きい方が良いので点数を与えた。
石の先読み
基本的に石を設置して閉領域が出来ることは避けたい、しかし今回の課題の場合完全に閉領域の発生を回避することは相当難しい(というより不可能?)であるように思われた。
そこで僕たちはこの問題を解決するため、閉領域が出来た際、その閉領域にピッタリハマる石があった場合その石を予め設置しておくという手法を使った(これをbookingと呼んでいた)。
閉領域の判定にオーダーがかかるがある石がピッタリハマるかどうかは閉領域の一番左上の座標をピンポイントに石と合わせると1回のチェックで判定が出来る。
bookingは障害物の多い問題においては特に効力を発揮し、決勝の問題においても20回前後bookingによる設置が発生していました。
上図のような場合、Blankに12の石を設置する
スキップ
場合により石はスキップした方が良い場合が多くある。そこで、石を置かずに次のノードに遷移するというスキップノードを1ノードにつき1つ、必ず生成した。具体的な遷移の様子は下の図に示す。
この手法により解の幅が増え、より良い解を期待することが出来るようになりました。
最終的にこれら全てを実装し、ビーム幅100~200程度でquest1やquest4といった問題を安定してスコア0で解くことが出来るようになりました。また乱数を使わないことで解の再現性が保たれ、デバッグは比較的容易でした。
ビームサーチの高速化
設置判定のbit演算化
盤面は固定長で32行32列なので、1行の情報をintに収めることが出来る。そこで32要素のint配列を作り、石(これもまた8要素のint配列からなる)のbit情報で&を取ることで障害物との衝突判定を行った。
また、衝突していないことさえ分かっていればあとは石と隣接していれば良いので、隣接が起こるマスを管理したbitboardを先ほどと同様に作り石のbit情報と&を取り高速化を行った。
優先度付きキューのメモリ削減
基本的にビームサーチでは評価をつけた上位N個のノードさえ保持していれば良いので、それ以外のノードをキューに入れるのはオーダー的にもメモリ的にも無駄が多い。しかしこの問題は優先度付きキューを昇順、つまり一番評価値の低いノードをtopに持っていくことで簡単に解決できる。
もしキューの要素数がNより多ければ、Nになるまでpopを行うことで簡単にメモリの削減が出来る。
またこれによりキュー内で最も評価の低いノードはtopを見れば分かるので、キューにpushする前にtopと評価値を比較することでpushする必要があるかどうかも同様に分かる。
これらのメモリ(オーダー)削減は非常に強力で、動作を50倍程度速める事が出来ました。
定数倍の高速化
stlのコンテナをできる限り使わず、使い回せるキューを自作するなどして定数倍の高速化を図った。それ以外にも、出来る限り関数を呼ばない、配列のアクセスにはポインタを用いるなどの細かい高速化を行った。
パラメータの最適化
今回の課題は問題の傾向が複数あり、それぞれによって使うパラメータがかなり変わってくる。具体的に問題の傾向は以下のように分類した。
・広大系(quest1,2,4,13)
・先輩系(quest9,14)
・ぴったり系(quest8)
それぞれについて最適なパラメータを複数見つけることが重要で、本番までに15個程最適化したパラメータを用意しました。この部分の調整は全て@Mahito6がやってくれました。
並列動作
ビームサーチソルバーは1スレッド1プロセスで動作するので、8コアPCを3台用意し8プロセスを同時に実行することで短時間でより多くの解を生成出来るようにした。
並列動作には専用のGUIを用意することで、パラメータの変更を視覚的に行えるようにした。
石個数の削減(おまけ)
解を書き換えて石個数を削減することを目指すソルバーも用意していました。ほとんどおまけ的部分なのでここは読み飛ばしてもいいです。(ぼくたちのチームは本番ではスコア0が狙える問題が出ると踏んで、ここを2ヶ月近く調整し続けていた)
要石の存在
まず今回の問題の性質上、解を書き換えることは比較的容易であり実際に、既に完成した盤面から石を取り外し、他の石を設置し直すということが可能でした。
そこで注意しなければいけないのが、全ての石が取り外せる訳では無いということです。
実際に解の書き換えを試みると、簡単に隣接判定に引っかかってしまいます。つまり、石を外したあとの盤面が、石の隣接ルールに従っている必要があります。
これを深く調べて行くと、外せない石というもののルールが分かってきます。具体的には「ある石(番号はN)について、隣接している石の中で番号がN+1以上であるものを子と呼び、番号がN-1以下であるものを親と呼ぶ。ある石について、隣接している子の中で、親が1つしか無いものがあれば、その石は取り外せない。」となる。このような石のことを以降要石と呼ぶ。
要石の例として、黄緑色が取り外せない石である。
実際に石個数を減らす工夫
2個の石を取り外して出来た閉領域があるとする。これを1個の石で埋め直すことが出来れば、実質石個数を減らす事に成功したことになる。
現実的にこれを全探索で試すのは時間がかかりすぎるので、ここでは5個の石を取り外し、4個で埋めるという作業を高速に行うことを考える。
少し考察すると分かる事実として、「N個で埋められるかどうかは、N-2個の埋め方を全探索して、残りの領域をbookingすることで判定できる。」というものがある。実際にこれは強力で、4個で埋めたいなら2個で埋めるパターンを全探索し、空いた領域をbookingにより埋めればよい。基本的には深さ2のDFSで済むのでこれはかなり高速に動作するし、石番号が昇順に並ぶというメリットがある。
ある石から外し始めて外せる石の数を最大化するという問題もDFSにより求めることが出来る。これにより効率の良い外し方を探し、前述したDFSで効率の良い埋め方を行うことが出来る。
これらを全て実装したものがAIというソルバーで、quest2などの比較的スコア0が出やすく石も減らしやすい問題で平均して20個近くの石を30秒程度で減らせるようになっていました。(非公式練習場や公式練習場にあげている石個数の少ない解は全てこのAIによるもの)
本番での実績
これらは当然ながらスコア0が出ているという前提でのみ動くソルバで、本番で出題されたピッタリ系にはまったく効力を発揮出来ませんでした。
しかし裏番組として行われていたOB戦の第一問において、優勝した八戸高専さんに(石個数では)圧倒的な大差で勝利できたので、得意な問題が来ていれば勝ちの目もあったかな、と感じました。(完全に問題のプロファイリングミスなのでクソザコ)
反省点
・気付けていないビームサーチの評価値があった
・問題の分析を怠った
・1回戦の問題の傾向を見て、ソルバーを鍛え直すという発想に至らなかった
・非公式練習場に気を取られすぎて、今回の競技の本質に気付けなかった
・ビームサーチが弱い
・石が減らせない
・壇上で緊張しすぎ
AOJ0246 Bara-Bara Manju
・基本的な解法はメモ化探索である。
・(1,9),(2,8),(3,5),(4,6),(5,5)はgreedyにまとめていい。
・残りの饅頭の状態は、N<=100なのでbitで持てる(ある意味bitDP?)。
これらのことをして、ハッシュマップでメモ化するとようやくACがもらえます。
#include <iostream> #include <cstdio> #include <cstdlib> #include <math.h> #include <vector> #include <algorithm> #include <string.h> #include <map> using namespace std; #define FOR(I,N) for(int I = 0; I < (int)(N); I++) #define pb push_back #define INF (1 << 30) typedef pair<int, int> P; string sValueOf(int v){stringstream ss;ss<<v;return ss.str();} int parseInt(string v){int i;stringstream ss(v);ss>>i;return i;} int manju[11]; map<int,int> memo; map<int,int> comp; int converter(int m[11]){///7bitで分割するとよさそう int ret = 0; FOR(i,5){ if(comp.find(i)==comp.end())break; int tar = comp[i]; ret+=m[tar]<<(7*i); } return ret; } vector<P> converter2(int bit){ vector<P> ret; FOR(i,5){ if(comp.find(i)==comp.end())break; int tar = comp[i]; int v = (bit>>(i*7))&((1<<7)-1); if(v==0)continue; ret.pb(P(tar,v)); } return ret; } struct Pt{ int pt[11]; Pt(){ FOR(i,11)pt[i] = 0; } }; vector<Pt> allpt; int saiki(int bit){ if(memo.find(bit)!=memo.end()){///メモされてるか確認 return memo[bit]; } int sum = 0; vector<P> ms = converter2(bit); int manj[11];fill_n(manj,11,0); FOR(i,ms.size())manj[ms[i].first] = ms[i].second; FOR(i,allpt.size()){ Pt pt = allpt[i]; bool can = true; FOR(j,11){ if(pt.pt[j]>manj[j]){///饅頭足りない can = false; } } if(!can)continue; FOR(j,11)manj[j]-=pt.pt[j]; sum = max(sum,saiki(converter(manj))+1); FOR(j,11)manj[j]+=pt.pt[j]; } return memo[bit] = sum; } int tmp[11]; void makeAllpt(int sum){ if(sum==10){ Pt p = Pt(); memcpy(p.pt,tmp,sizeof(tmp)); allpt.pb(p); return; } for(int i = 1;i<=8;i++){ if(!manju[i])continue; if(sum+i>10)continue; manju[i]--; tmp[i]++; makeAllpt(sum+i); tmp[i]--; manju[i]++; } return; } int main(){ int i,j; int n,a,sum; while(cin>>n,n){ memo.clear(); comp.clear(); allpt.clear(); sum = 0; FOR(i,11)manju[i] = 0; FOR(i,n){ cin>>a; manju[a]++; } for(int s = 9;s!=4;s--){ while(manju[s]){ if(!manju[10-s])break; if(s==5&&manju[s]==1)break; manju[s]--; manju[10-s]--; sum++; } } int count = 0; FOR(i,8){ if(manju[i+1]&&(i+1)!=5){ comp[count] = i+1; count++; } } comp[count] = 5; fill_n(tmp,11,0); makeAllpt(0); cout<<sum+saiki(converter(manju))<<endl; } return 0; }
楽しくない(直球)
第18回ABC C問題「菱型カウント」
C: 菱型カウント - AtCoder Beginner Contest #018 | AtCoder
解法:30点解法を枝刈りする
O(RCN)(Nはxの数)ぐらいになるので、Nが300以下ぐらいのケースに対して枝刈りを適用すると時間内に解くことができる。
枝刈りは
・確実に範囲外になってしまう座標(計算が結構めんどくさい)
・近くにxが無い座標
どちらかの条件を満たす場合に行う。
#include <iostream> #include <cstdio> #include <cstdlib> #include <math.h> #include <vector> #include <queue> #include <algorithm> #include <sstream> #include <string> using namespace std; #define FOR(I,N) for(int I = 0; I < (int)(N); I++) #define FIN(V) cout<<V<<endl #define pb push_back #define INF (1 << 30) typedef pair<int, int> P; string sValueOf(int v){stringstream ss;ss<<v;return ss.str();} int parseInt(string v){int i;stringstream ss(v);ss>>i;return i;} void fast_io() {cin.tie(0); ios::sync_with_stdio(false);} int map[504][504]; int h,w,k; vector<P> black; int main(){ int i,j,m,l; cin>>h>>w>>k; fill_n(*map,504*504,1); string in; FOR(i,h){ cin>>in; FOR(j,w){ char c = in[j]; if(c=='o'){ map[j+1][i+1] = 0; }else{ map[j+1][i+1] = 1; black.pb(P(j+1,i+1)); } } } int ans = 0; int dis = 2*k-1; int size = black.size(); FOR(i,h)FOR(j,w){ int x = j+1; int y = i+1; if(j<k-1)continue; if(w-j<=k-1)continue; if(h-i<=dis-1)continue; if(size<=300){ int cy = y+k-1; double mini = INF; FOR(m,size){ mini = min(mini,sqrt(pow(black[m].first-x,2.0)+pow(black[m].second-cy,2.0))); } if(mini>k){ans++;continue;} } int width = 1; bool able = true; FOR(m,dis){ if(map[x][y+m]){able = false;break;} } if(!able)continue; FOR(m,dis){ FOR(l,width){ if(map[x+l][y]){able = false;break;} } if(!able)break; y++; if(m>=k-1){///後半 x++; width-=2; }else{ x--; width+=2; } } if(able){ ans++; } } cout<<ans<<endl; return 0; }
PCK2012本選「イヅア辞書」
AOJ0271
想定解よりも良い解が見つかったので
O(RNlogN)
このlogNはかなり小さいのでほぼ無視してよい。
探索により解を見つける場合、可能性はN!通りあるので現実的ではない。
が、同じ文字が複数出現しないことを利用すると計算により位置を求めることができる。
具体的にはN分岐の木構造をイメージすると分かりやすい。
深さDがD文字目の分岐に相当すると考える事で1文字目から順番に計算することで解を導くことが出来る。
また、想定解はBITを用いてO(NlogN)だがBITの代わりにstd:vectorを使うことでO(N^2logN)が達成できる。
vector.erase()のオーダーが重くTLEになってしまうこの解法ですが、RがNに比べ極端に小さい事を利用した座標圧縮を行うことができます。
値が連続する部分をD文字分一まとめに処理(計算)することでこれが実現できる。
最悪のケースを考えてもRは50、つまり圧縮すれば本来のNの部分が1つRに置き換わるのでO(RNlogN)が達成出来る。
実際にこの解法はAOJにおいて最大ケースでも0.11msとかなりの高スコアが出せるのでstd:vectorの応用性の高さに感動できる良い問題だと思いました。
#include <iostream> #include <cstdio> #include <cstdlib> #include <math.h> #include <vector> #include <queue> #include <algorithm> #include <sstream> #include <string> #include <map> using namespace std; #define FOR(I,N) for(int I = 0; I < (int)(N); I++) #define FIN(V) cout<<V<<endl #define pb push_back #define INF (1 << 30) #define MOD (1000000007) #define ll long long int d,n; vector<int> sorted; ll rui[100001]; int array[100001]; int main(){ int i,j; int r,a,b; rui[0] = 1; FOR(i,100001){ if(i!=0){ rui[i] = (rui[i-1]*i)%MOD; } } while(cin>>n,n){ cin>>r; sorted.clear(); FOR(i,n){ array[i] = i; sorted.pb(i); } sort(sorted.begin(),sorted.end()); FOR(i,r){ cin>>a>>b;a--;b--; swap(array[a], array[b]); } ll ans = 0; FOR(i,n-1){ vector<int>::iterator index = lower_bound(sorted.begin(),sorted.end(),array[i]); vector<int>::iterator aft = index; ans+= (index-sorted.begin())*rui[n-i-1]; ans%=MOD; int count = 0; for(j = i+1;j<n-1;j++){///いくつ連続してるか if(array[j]!=array[j-1]+1)break; count++; ans+= (index-sorted.begin())*rui[n-i-1-count]; ans%=MOD; } i = j-1; aft+=count+1; sorted.erase(index,aft+1); } cout<<ans<<endl; } return 0; }
PCK2014予選「バトンリレーゲーム」
Nが非常に大きいこと、クエリ操作が必要なことから高度なデータ構造の問題のように思えるが、交換回数が非常に小さい事に着目すれば実際は尺取法であることに気付く。
M (5 ≤ M < 200000)
ai (1 ≤ ai ≤ 100)
文中の操作とまったく同じ操作を行えるデータ構造を用意すれば
O(M*ai)により余裕をもって間に合う。
またこのような操作を実現するデータ構造を双方向連結リストというらしい(別名Deque)
#include <iostream> #include <algorithm> using namespace std; #define FOR(I,N) for(int I = 0; I < (int)(N); I++) #define FIN(V) cout<<V<<endl #define pb push_back #define INF 1 << 30 #define SIZE 1000000 typedef pair<int, int> P; int deque[SIZE]; int bi,ai; void clearDeque(){ fill_n(deque,SIZE,-1); bi = SIZE/3; ai = bi-1; } void addDeque(int value){ ai++; deque[ai] = value; } void query1(){ ai++; if(ai==SIZE)ai = 0; deque[ai] = deque[bi]; deque[bi] = -1; bi++; if(bi==SIZE)bi = 0; } void query2(){ bi--; if(bi==-1)bi = SIZE-1; deque[bi] = deque[ai]; deque[ai] = -1; ai--; if(ai==-1)ai = SIZE-1; } bool flag[200001]; int main(){ int i,j; int n,m,q,a; cin>>n>>m>>q; clearDeque(); FOR(i,n){ addDeque(i); } fill_n(flag,200001,1); FOR(i,m){ cin>>a; FOR(j,a){ if(a%2==0)query1(); else query2(); } ///cout<<deque[bi]<<endl; flag[deque[bi]] = 0; deque[bi] = -1; bi++; } FOR(i,q){ cin>>a; cout<<flag[a]<<endl; } return 0; }
単純に配列を使ってこのデータ構造を実現しようとすると、操作回数はM*ai=20000000にも及ぶのでメモリオーバーを起こしてしまう。
なので、先頭が-1(index)に到達したら
ai--; if(ai==-1)ai = SIZE-1;
このように先頭を配列の一番後ろに飛ばしてしまうとよい。
SRM649
レート 0→1300
明日JOIの参加記を書く予定