経路探索と計算量
あなたの担当 この章は、bot の中で使う計算の説明です。使うだけなら → 見えない敵を探す
壁にさえぎられて敵が見えないとき、回りこむ道を探すのが経路探索です。 このゲームには 2 つの道具が用意されています。
path = find_path_bfs(state.map.grid, (2, 3), (17, 12)) # 幅優先探索path = find_path_astar(state.map.grid, (2, 3), (17, 12)) # A*どちらも返ってくるのは マスの並びです。
[(2, 3), (3, 3), (4, 3), (4, 4)] # 最初は自分のマス、最後がゴールたどり着けないときは 空のリスト [] が返ります。
必ず len(path) を見てください。
path[0] は自分がいまいるマスなので、進むべきは path[1] です。
def update(state): path = find_path_bfs( state.map.grid, (state.me.grid_x, state.me.grid_y), (state.enemy.grid_x, state.enemy.grid_y), )
if len(path) < 2: return {"drive": "stop", "fire": True, "barrier": False}
nx, ny = path[1] # マスの中心を狙う(+0.5 を忘れると、マスの角へ向かってしまう) want = angle_to(state.me.x, state.me.y, nx + 0.5, ny + 0.5) return {"drive": "forward", "steer": want, "fire": True, "barrier": False}BFS と A* のちがい
Section titled “BFS と A* のちがい”find_path_bfs | find_path_astar | |
|---|---|---|
| 見つける道 | 最短 | 最短 |
| 探しかた | 近いマスから全方向へ広げる | ゴールに近づきそうな方を先に試す |
| 広げるマスの数 | 多い | 少ない |
| 1 マスあたりの手間 | 軽い | 重い |
どちらも最短経路を見つけます。 ちがうのは探しかただけです。
BFS — 波紋のように広げる
Section titled “BFS — 波紋のように広げる”スタートから「1 歩で行けるマス」「2 歩で行けるマス」…と順に広げます。 水面に石を落としたときの波紋のイメージです。
ゴールに当たった時点で止まるので、その道が最短だと保証されます。
広げる順番は 右 → 下 → 左 → 上 に固定してあります。 順番を変えると、同じ長さの道が複数あるときに別の道が返ってしまうからです。 ここも「どちらでもよい」場面を作らないための決めごとです。
A* — ゴールの方角を当てにする
Section titled “A* — ゴールの方角を当てにする”A* は「ここからゴールまで、少なくともこれくらいはかかるはず」という見積もりを足して、 見込みのよいマスから先に調べます。見積もりにはマンハッタン距離(縦横の歩数)を使っています。
そのぶん、広げるマスの数は BFS より減ります。
「組み込みだから速い」は成り立たない
Section titled “「組み込みだから速い」は成り立たない”ここがこのゲームでいちばん面白いところです。
find_path_bfs は Python ではなく TypeScript で書かれています。
ふつうなら「組み込み関数なので一瞬。使い放題」となるはずです。
このゲームでは、そうなっていません。
これはわざとです。 組み込みが無料だと、「毎 tick 全部探し直す」書き方と「1 度探して覚えておく」書き方の差が どこにも表れません。計算量という考え方が学べなくなります。
このページのコードを実際に 4 試合走らせて、1 tick あたりの命令数を測りました。
| 書き方 | 1 tick の平均 | いちばん重い tick |
|---|---|---|
| 毎 tick BFS で探し直す | 1,157 | 1,795 |
| 毎 tick A* で探し直す | 894 | 2,568 |
| 10 tick に 1 回だけ BFS(あとは使い回す) | 213 | 1,847 |
経路探索なし(line_of_sight だけ) | 113 | 113 |
読みどころが 2 つあります。
1. A* は平均では軽いのに、いちばん重い tick は BFS より重い。 探すマスは減るのですが、この実装は「未処理リストからいちばんよいものを選ぶ」のに リストを端から端まで調べています。リストが長くなると、1 回の取り出しが高くつきます。 「探すマスが減っても、1 マスあたりが重くなれば意味がない」—— アルゴリズムの教科書に必ず出てくる話が、そのまま数字になっています。
2. 覚えておくだけで 5 倍以上軽くなる。 経路は 1 tick(0.1 秒)で大きくは変わりません。 10 tick に 1 回だけ探し直して、あとは覚えておいた道を使えば、平均 1,157 → 213 になります。
path = []残り = 0
def update(state): global path, 残り
# 10 tick に 1 回だけ探し直す if 残り <= 0 or len(path) < 2: path = find_path_bfs( state.map.grid, (state.me.grid_x, state.me.grid_y), (state.enemy.grid_x, state.enemy.grid_y), ) 残り = 10 残り = 残り - 1
if len(path) < 2: return {"drive": "stop", "fire": True, "barrier": False}
nx, ny = path[1] want = angle_to(state.me.x, state.me.y, nx + 0.5, ny + 0.5) return {"drive": "forward", "steer": want, "fire": True, "barrier": False}global は「この変数は関数の外のものを使う」という宣言です。
これを書かないと、path は毎回まっさらに戻ってしまいます。
バジェットは 150,000
Section titled “バジェットは 150,000”1 回の update で使える命令数の上限は 150,000 です。
上の表を見返すと、いちばん重い書き方でも 2,568。上限の 2% も使っていません。
超えてしまったときは、その tick は前回の入力が維持されます。 1 試合で 3 回までは許され、4 回目で行動停止になります。
使い分けの目安
Section titled “使い分けの目安”まず line_of_sight、見えないときだけ経路探索。
この順番にするだけで、たいていの場面は軽くなります。
- まず見えるか調べる → 視線判定と DDA
stateに何が入っているか → state リファレンス- なぜ走査順を固定するのか → 決定論と固定小数点