第II部 局面管理と合法手生成
第4章 局面管理 — 基礎編
前章まででビットボードと型の基礎を学びました。本章では、それらを組み合わせて「局面(Position)」全体を管理する仕組みを見ていきます。 将棋エンジンにおいて局面クラスはまさに中核であり、盤面の状態保持、指し手の適用、SFEN の読み書き、そしてハッシュキーの管理まで、 あらゆる処理がここを起点に動きます。
4.1 Position クラスの全体設計
Position クラスは position.h に宣言されています。
公開インターフェースとして「局面の設定」「合法手生成」「指し手適用」「状態問い合わせ」の4カテゴリの関数を提供し、
内部データとしてボード配列・持ち駒・ビットボード・ハッシュキー・履歴を保持します。
class Position {
public:
Position();
void set_startpos();
bool set_sfen(const std::string& sfen);
bool apply_usi_move(const std::string& move_text);
std::vector<Move> generate_legal_moves() const;
bool do_move(const Move& move);
TerminalStatus terminal_status() const;
Color side_to_move() const { return side_to_move_; }
int piece_at(int square) const { return board_[square]; }
std::uint64_t position_key() const { return position_key_; }
int hand_count(Color color, PieceType type) const;
bool is_in_check(Color color) const;
int find_king(Color color) const;
// ... 他多数
};
コンストラクタは set_startpos() を呼び出し、平手初期局面を設定します。
Position::Position() {
set_startpos();
}
4.2 盤面配列 board_[81]
盤面は int 型の 81 要素配列で表現されます。
std::array<int, kSquareCount> board_{};
各マスに格納される値は符号付き駒コードです。第2章で解説した encode_piece() により、
先手(Black)の駒は正の値、後手(White)の駒は負の値、空マスは 0 となります。
// types.h より
inline int encode_piece(Color color, PieceType type) {
if (type == PieceType::Empty) return 0;
return color == Color::Black ? static_cast<int>(type)
: -static_cast<int>(type);
}
たとえば先手の歩は +1、後手の飛は -7、先手の馬は +13 です。
この符号方式のおかげで、piece_color() は符号を見るだけ、piece_type() は絶対値を取るだけで判定できます。
配列のインデックスは square = row * 9 + col です。
row 0 が1段目(先手の成りゾーン側)、col 0 が9筋に対応します。
4.3 持ち駒 hands_[2][8]
持ち駒は2色 x 8駒種の2次元配列で枚数を管理します。
std::array<std::array<int, kHandPieceKinds>, 2> hands_{};
kHandPieceKinds は 8 で、インデックス 0 は未使用(Empty に対応)、1 が歩、2 が香、...、7 が飛です。
hand_index() は成駒を元の駒種に戻してからインデックスに変換します。
int Position::hand_count(Color color, PieceType type) const {
return hands_[static_cast<int>(color)][hand_index(type)];
}
持ち駒の追加・削除は Zobrist ハッシュの更新と連動しています(後述)。
4.4 色別ビットボードと駒種別ビットボード
高速な利き計算・合法手生成のため、board_[] と並行してビットボードを管理します。
std::array<Bitboard, 2> color_bb_{}; // 色別
std::array<std::array<Bitboard, 15>, 2> piece_bb_{}; // 色別×駒種別
Bitboard occupied_; // 全駒の合成
color_bb_[0] は先手の全駒のビットボード、color_bb_[1] は後手の全駒のビットボードです。
piece_bb_[color][type] は特定の色・駒種の駒が立っているマスを表します。
occupied_ は両色のビットボードの OR で、スライダー利き計算時に占有判定として使います。
さらに玉の位置は頻繁にアクセスされるため、専用の配列でキャッシュしています。
std::array<int, 2> king_square_ = {-1, -1};
4.5 盤面操作: clear(), add_piece(), remove_piece()
盤面データの一貫性を保つため、駒の配置・除去はすべて専用関数を経由します。
clear()
すべてのデータをゼロクリアします。
void Position::clear() {
board_.fill(0);
for (auto& hand : hands_) {
hand.fill(0);
}
for (auto& color_bits : piece_bb_) {
for (auto& piece_bits : color_bits) {
piece_bits = Bitboard{};
}
}
color_bb_[0] = Bitboard{};
color_bb_[1] = Bitboard{};
occupied_ = Bitboard{};
king_square_ = {-1, -1};
side_to_move_ = Color::Black;
ply_count_ = 0;
position_key_ = 0;
history_.reset();
}
add_piece()
指定マスに駒を配置します。board_[] への書き込みと同時に、3種のビットボード(occupied_、color_bb_、piece_bb_)をセットし、
Zobrist ハッシュキーも XOR で更新します。玉の場合は king_square_ も更新します。
void Position::add_piece(int square, Color color, PieceType type) {
position_key_ ^= zobrist().board[static_cast<int>(color)]
[static_cast<int>(type)][square];
board_[square] = encode_piece(color, type);
occupied_.set(square);
color_bb_[static_cast<int>(color)].set(square);
piece_bb_[static_cast<int>(color)][static_cast<int>(type)].set(square);
if (type == PieceType::King) {
king_square_[static_cast<int>(color)] = square;
}
}
remove_piece()
add_piece() の逆操作です。board_[] から駒コードを読み取り、該当するビットボードとハッシュキーを元に戻します。
void Position::remove_piece(int square) {
const int piece = board_[square];
if (is_empty(piece)) return;
const Color color = piece_color(piece);
const PieceType type = piece_type(piece);
position_key_ ^= zobrist().board[static_cast<int>(color)]
[static_cast<int>(type)][square];
occupied_.reset(square);
color_bb_[static_cast<int>(color)].reset(square);
piece_bb_[static_cast<int>(color)][static_cast<int>(type)].reset(square);
board_[square] = 0;
if (type == PieceType::King) {
king_square_[static_cast<int>(color)] = -1;
}
}
この設計のポイントは、board_[] とビットボードが常に同期していることです。
直接 board_[] を書き換えるコードは存在せず、必ず add_piece() / remove_piece() を経由します。
4.6 SFEN 文字列の読み込み — set_sfen()
SFEN(Shogi Forsyth-Edwards Notation)は将棋の局面を1行の文字列で表現する標準形式です。
set_sfen() はこの文字列をパースして局面を構築します。
SFEN 文字列は空白区切りで4つのトークンからなります:
- 盤面配置(
/で段を区切り、数字は空マスの連続数) - 手番(
b= 先手、w= 後手) - 持ち駒(
-= なし、または2Pのように枚数+駒文字) - 手数(1始まり)
盤面パース部分
int row = 0;
int col = 0;
bool promoted = false;
for (char ch : tokens[0]) {
if (ch == '/') {
if (col != kBoardSize) return false;
++row;
col = 0;
continue;
}
if (ch == '+') {
promoted = true; // 次の駒文字が成駒
continue;
}
if (std::isdigit(static_cast<unsigned char>(ch))) {
col += ch - '0'; // 空マスをスキップ
promoted = false;
continue;
}
// 駒文字の処理
const auto base = piece_from_letter(ch);
if (!base.has_value()) return false;
PieceType type = base.value();
if (promoted) type = promote(type);
const Color color = std::isupper(static_cast<unsigned char>(ch))
? Color::Black : Color::White;
add_piece(make_square(row, col), color, type);
++col;
promoted = false;
}
大文字が先手、小文字が後手という規約です。+ プレフィクスがあれば promote() で成駒に変換します。
手番と持ち駒の読み込み
// 手番
if (tokens[1] == "b") {
side_to_move_ = Color::Black;
} else if (tokens[1] == "w") {
side_to_move_ = Color::White;
position_key_ ^= zobrist().side_to_move; // 後手番なら手番キーを反映
}
// 持ち駒
if (tokens[2] != "-") {
int count = 0;
for (char ch : tokens[2]) {
if (std::isdigit(static_cast<unsigned char>(ch))) {
count = count * 10 + (ch - '0'); // 複数桁に対応
continue;
}
const auto piece = piece_from_letter(ch);
const Color color = std::isupper(static_cast<unsigned char>(ch))
? Color::Black : Color::White;
for (int remaining = std::max(count, 1); remaining > 0; --remaining) {
add_hand_piece(color, piece.value());
}
count = 0;
}
}
最後に手数をパースし、rebuild_history() で履歴の初期ノードを作成してパースは完了です。
4.7 SFEN 出力 — to_sfen()
to_sfen() は現在の局面から SFEN 文字列を生成します。set_sfen() の逆変換で、ラウンドトリップが保証されています。
std::string Position::to_sfen() const {
std::string sfen;
for (int row = 0; row < kBoardSize; ++row) {
if (row > 0) sfen += '/';
int empty_count = 0;
for (int col = 0; col < kBoardSize; ++col) {
const int piece = board_[make_square(row, col)];
if (is_empty(piece)) {
++empty_count;
continue;
}
if (empty_count > 0) {
sfen += static_cast<char>('0' + empty_count);
empty_count = 0;
}
const PieceType type = piece_type(piece);
if (is_promoted(type)) sfen += '+';
char letter = piece_letter(type);
if (piece_color(piece) == Color::White)
letter = static_cast<char>(
std::tolower(static_cast<unsigned char>(letter)));
sfen += letter;
}
if (empty_count > 0)
sfen += static_cast<char>('0' + empty_count);
}
// 手番・持ち駒・手数を追加(省略)
return sfen;
}
4.8 USI 指し手の適用 — apply_usi_move()
USI プロトコルでは指し手を "7g7f"(移動)や "P*3d"(駒打ち)のような文字列で表現します。
apply_usi_move() はこの文字列を受け取り、合法手リストと照合して適用します。
bool Position::apply_usi_move(const std::string& move_text) {
const auto legal_moves = generate_legal_moves();
const auto it = std::find_if(
legal_moves.begin(), legal_moves.end(),
[&](const Move& move) {
return move_to_usi(move) == move_text;
});
if (it == legal_moves.end()) return false;
do_move_unchecked(*it);
return true;
}
合法手リストから一致する手を検索し、見つからなければ false を返します。
見つかった場合は do_move_unchecked() を呼び出して局面を進めます。
この方式により、不正な指し手は自動的に拒否されます。
4.9 do_move() の処理フロー
do_move() はバリデーション後に do_move_unchecked() を呼びます。
後者の処理フローは以下のとおりです。
void Position::do_move_unchecked(const Move& move) {
const Color mover = side_to_move_;
if (move.drop) {
// (1) 駒打ち: 持ち駒から盤上へ
add_piece(move.to, mover, move.piece);
remove_hand_piece(mover, move.piece);
} else {
// (2) 盤上の移動
const int moving_piece = board_[move.from];
const PieceType moving_type = piece_type(moving_piece);
const int captured_piece = board_[move.to];
// (2a) 捕獲処理
if (!is_empty(captured_piece)) {
const PieceType captured_type =
unpromote(piece_type(captured_piece));
add_hand_piece(mover, captured_type); // 持ち駒に追加
remove_piece(move.to); // 盤上から除去
}
// (2b) 移動元を除去し、移動先に配置
remove_piece(move.from);
add_piece(move.to, mover,
move.promote ? promote(moving_type) : moving_type);
}
// (3) 手番交替・手数更新・履歴追加
++ply_count_;
side_to_move_ = opposite(side_to_move_);
position_key_ ^= zobrist().side_to_move;
append_history();
}
処理の流れを整理すると:
- 駒打ちの場合 — 駒を盤上に配置し、持ち駒を減らす
- 盤上移動の場合:
- 移動先に敵駒があれば、成りを解除して持ち駒に加える
- 移動元から駒を除去し、移動先に配置(成り指定があれば成駒に変換)
- 手番を交替し、Zobrist キーの手番ビットを反転させ、履歴に追加する
すべてのステップで add_piece() / remove_piece() を使うため、
board_[]・ビットボード・Zobrist ハッシュが自動的に整合します。
4.10 Zobrist ハッシュ
Zobrist ハッシュは、局面を 64 ビットの整数値(ハッシュキー)で高速に識別する手法です。 置換表(第9章)や千日手検出に不可欠です。
ZobristTables の構造
struct ZobristTables {
// board[色][駒種][マス] — 盤上の駒に対応する乱数値
std::array<std::array<std::array<std::uint64_t, kSquareCount>, 15>, 2> board{};
// hand[色][駒種インデックス][枚数] — 持ち駒に対応する乱数値
std::array<std::array<std::array<std::uint64_t, kMaxHandCount>, kHandPieceKinds>, 2> hand{};
// 手番用の乱数値
std::uint64_t side_to_move = 0;
};
splitmix64 による乱数生成
テーブルの初期化には splitmix64 という高品質な擬似乱数生成器を使います。 固定シードから決定的に生成されるため、実行ごとに同じテーブルが得られます。
std::uint64_t splitmix64(std::uint64_t& state) {
std::uint64_t value = (state += 0x9E3779B97F4A7C15ULL);
value = (value ^ (value >> 30)) * 0xBF58476D1CE4E5B9ULL;
value = (value ^ (value >> 27)) * 0x94D049BB133111EBULL;
return value ^ (value >> 31);
}
テーブルの構築
ZobristTables build_zobrist() {
ZobristTables zobrist;
std::uint64_t seed = 0x7F4A7C159E3779B9ULL;
for (int color = 0; color < 2; ++color) {
for (int type = 0; type < 15; ++type) {
for (int square = 0; square < kSquareCount; ++square) {
zobrist.board[color][type][square] = splitmix64(seed);
}
}
for (int index = 0; index < kHandPieceKinds; ++index) {
for (int count = 0; count < kMaxHandCount; ++count) {
zobrist.hand[color][index][count] = splitmix64(seed);
}
}
}
zobrist.side_to_move = splitmix64(seed);
return zobrist;
}
テーブルは static ローカル変数として一度だけ構築され、以降は参照のみで使われます。
ハッシュキーの更新
Zobrist ハッシュの最大の利点は差分更新です。駒を追加するときも除去するときも、
同じ乱数値を XOR するだけで正しいキーが得られます(a ^ a = 0 の性質を利用)。
add_piece() と remove_piece() の冒頭で行われている
position_key_ ^= zobrist().board[...][...][...] がまさにこの差分更新です。
持ち駒の場合は、枚数をインデックスとしたテーブルを使います。 枚数変更前の値で XOR してから枚数を変更することで、累積的に正しいキーを維持します。
void Position::add_hand_piece(Color color, PieceType type) {
const int index = hand_index(type);
int& count = hands_[static_cast<int>(color)][index];
position_key_ ^= zobrist().hand[static_cast<int>(color)][index][count];
++count;
}
void Position::remove_hand_piece(Color color, PieceType type) {
const int index = hand_index(type);
int& count = hands_[static_cast<int>(color)][index];
--count;
position_key_ ^= zobrist().hand[static_cast<int>(color)][index][count];
}
4.11 HistoryNode 連結リスト — 千日手検出
将棋では同一局面が4回出現すると千日手(引き分け)となります。 Hayanagi はこの判定のために、局面のハッシュキーを連結リストで保持します。
struct HistoryNode {
std::uint64_t key = 0; // 局面のハッシュキー
Color side_to_move = Color::Black;
bool side_in_check = false; // 王手がかかっているか
std::shared_ptr<const HistoryNode> previous; // 前の局面へのリンク
};
std::shared_ptr を使った不変連結リストです。局面をコピーしても履歴は共有されるため、
探索木の各ノードで独立した履歴チェーンを安全に参照できます。
履歴の構築と追加
void Position::rebuild_history() {
history_ = std::make_shared<HistoryNode>(
HistoryNode{repetition_key(), side_to_move_,
is_in_check(side_to_move_), nullptr});
}
void Position::append_history() {
history_ = std::make_shared<HistoryNode>(
HistoryNode{repetition_key(), side_to_move_,
is_in_check(side_to_move_), history_});
}
rebuild_history() は SFEN 読み込み時に呼ばれ、previous を nullptr にしてチェーンを開始します。
append_history() は do_move_unchecked() の最後に呼ばれ、現在の履歴を previous に設定した新ノードを追加します。
千日手判定
bool Position::is_repetition_draw() const {
if (history_ == nullptr) return false;
int occurrences = 0;
for (auto node = history_; node != nullptr; node = node->previous) {
if (node->key == history_->key && ++occurrences >= 4) {
return true;
}
}
return false;
}
現在のハッシュキーと一致するノードを履歴チェーンの先頭から遡って数え、4回以上出現していれば千日手と判定します。 ハッシュキーが同じであれば高い確率で同一局面であるため、この簡潔な判定で十分な精度が得られます。
4.12 まとめ
本章のポイントを整理します。
| 要素 | 実装 | 目的 |
|---|---|---|
| 盤面 | int board_[81] | 符号付き駒コードで高速な駒判定 |
| 持ち駒 | int hands_[2][8] | 枚数管理 |
| 色別BB | Bitboard color_bb_[2] | 自陣/敵陣の高速識別 |
| 駒種別BB | Bitboard piece_bb_[2][15] | 特定の駒の位置を即座に取得 |
| 盤面操作 | add/remove_piece() | board_[] とBBの同期を保証 |
| SFEN | set_sfen() / to_sfen() | 標準形式での入出力 |
| Zobrist | splitmix64 + XOR 差分更新 | O(1) での局面識別 |
| 履歴 | shared_ptr 連結リスト | 千日手検出(4回同一局面) |
次章では、この局面データの上に構築される攻撃テーブルと利き計算を解説します。 合法手生成の前提となる重要な事前計算の仕組みです。