第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つのトークンからなります:

  1. 盤面配置(/ で段を区切り、数字は空マスの連続数)
  2. 手番(b = 先手、w = 後手)
  3. 持ち駒(- = なし、または 2P のように枚数+駒文字)
  4. 手数(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();
}

処理の流れを整理すると:

  1. 駒打ちの場合 — 駒を盤上に配置し、持ち駒を減らす
  2. 盤上移動の場合:
    • 移動先に敵駒があれば、成りを解除して持ち駒に加える
    • 移動元から駒を除去し、移動先に配置(成り指定があれば成駒に変換)
  3. 手番を交替し、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]枚数管理
色別BBBitboard color_bb_[2]自陣/敵陣の高速識別
駒種別BBBitboard piece_bb_[2][15]特定の駒の位置を即座に取得
盤面操作add/remove_piece()board_[] とBBの同期を保証
SFENset_sfen() / to_sfen()標準形式での入出力
Zobristsplitmix64 + XOR 差分更新O(1) での局面識別
履歴shared_ptr 連結リスト千日手検出(4回同一局面)

次章では、この局面データの上に構築される攻撃テーブルと利き計算を解説します。 合法手生成の前提となる重要な事前計算の仕組みです。