AHC はじめての実装教材。図解・動く演習・C++17コードで、焼きなまし、ビーム探索、観測と推定を学べる教材。

AHCは、制限時間内にできるだけよい答えを求めるコンテストです。「何を保存し、どの順に処理を書くか」を、小さな例と図で学びます。まず共通準備から始め、その後は必要な経路を一つ選んでください。

高校数学とアルゴリズム競プロの基礎を前提にします。全39単元。導入、演習とC++17の完成コードを備えた36単元、二つの実装テンプレートで構成します。

共通準備の後、三つの経路から選ぶ

まず一つの経路を選びます。別の方法が必要になったら、他の経路へ戻れます。

共通準備 1.1〜1.8状態・制約・評価 → 基準の答え → 最良解の保存共通準備から始める →

↓ 必要な経路を一つ選ぶ

↓ どの経路からも、同じ入力で効果を確認する

共通の検証 5.1目的値・実時間・失敗を比べ、改善を確かめる比較の方法を読む →
各章の「次へ」をたどると、選んだ経路の順に進めます。

検証の後は、必要な題材を選ぶ発展や総合練習へ進めます。全体の目次は画面の目次から開けます。

共通準備・経路・発展の読み分け

初めてなら1.1で仕事を移す例を読み、1.2〜1.8で状態・制約・評価・最初の答え・保存をそろえます。その後は経路を一つ選び、その経路の「次へ」をたどってください。各経路の入口に、使う題材・処理の流れ・到達点を示しています。

局所探索なら2.2、ビーム探索なら3.2、観測と判断なら4.1で、まず小さく動く実装を確かめられます。経路を一巡したら5.1で検証し、必要な題材や実装部品へ広げます。

各章は「本文+演習」で開きます。図と説明を読みながら、隣の演習で確かめてください。「本文」「演習・コード」で片方だけにも切り替えられます。狭い画面では本文を先に表示します。新しい題材や条件へ変わる単元では問題文を開いて表示し、実装手順は画面上部から開けます。

前提にすること、詳しく扱うこと

変数・配列・関数、計算量・全探索・ソート・貪欲法・BFSなどの基礎と、高校数学を前提にします。コード例はC++17です。

詳しく扱うのは、何を状態に持つか、一手で何を更新するか、どの候補や行動を選ぶか、正しさと改善をどう検証するかです。共通準備の内容を既に説明できる場合は、選んだ経路から始め、必要な章へ戻れます。

図と演習はこのページだけで試せます。コピーしたコードの動かし方は実行方法にまとめています。

経路ごとの目次と収録状況(全39単元)

共通準備

章学ぶこと収録状況
1.1はじめに:仕事を一つ移す導入の図解・確認問題
1.2制約と評価を分け、実装する条件を整理する本文・演習・完成コード
1.3有効な答えと、その検査処理を先に作る本文・演習・完成コード
1.4探索で変える情報を、状態として持つ本文・演習・完成コード
1.5一手の変更が守る条件を決める本文・演習・完成コード
1.6検査と評価を分け、全計算を基準にする本文・演習・完成コード
1.7貪欲法で初期解を作る本文・演習・完成コード
1.8探索中の状態と、提出する最良解を分ける本文・演習・完成コード

経路1 局所探索

章学ぶこと収録状況
2.1制約に合う近傍を用意する本文・演習・完成コード
2.2山登り法を、コピーして試す形で組む本文・演習・完成コード
2.3試した変更を、元どおりに戻す本文・演習・完成コード
2.4変わった分だけを計算する本文・演習・完成コード
2.5焼きなまし法で、悪化する変更も受け入れる本文・演習・完成コード
2.6処理ごとに測り、ボトルネックを見つける本文・演習・完成コード
2.7焼きなましの実装テンプレート実装テンプレート・完成チェック

経路2 ビーム探索

章学ぶこと収録状況
3.1途中の答えを、何で比べるか考える本文・演習・完成コード
3.2ビーム探索で、途中の候補を複数残す本文・演習・完成コード
3.3ビーム探索の履歴を復元し、重複を取り除く本文・演習・完成コード
3.4ビーム探索のメモリと、多様性を管理する本文・演習・完成コード
3.5ビーム探索の実装テンプレート実装テンプレート・完成チェック

経路3 観測・推定・意思決定

章学ぶこと収録状況
4.1インタラクティブ問題の入出力と、観測履歴を管理する本文・演習・完成コード
4.2ベイズ推定で、観測から仮説の確率を更新する本文・演習・完成コード
4.3質問の情報量と費用を比べ、測定を選ぶ本文・演習・完成コード
4.4プレイアウトとUCBで、行動を比較する本文・演習・完成コード

共通の検証と実装部品

章学ぶこと収録状況
5.1いくつかの入力で、本当によくなったか比べる本文・演習・完成コード
5.2最短路を、ヒューリスティック探索の部品にする本文・演習・完成コード
5.3大まかな計画を、実際の手順へ変える本文・演習・完成コード

変更する答えの形を広げる

章学ぶこと収録状況
6.1行き詰まったら、始め方や一部分を変える本文・演習・完成コード
6.2訪れる順番を、交換したり逆にしたりする本文・演習・完成コード
6.3必要な場所を、選んだもの全部で覆う本文・演習・完成コード
6.4物を置いたとき、はみ出しや重なりを調べる本文・演習・完成コード
6.5辺の変更と、連結性の検査を組み合わせる本文・演習・完成コード

計画・時間・操作列を扱う

章学ぶこと収録状況
6.6大まかに決めてから、細かく直す本文・演習・完成コード
6.7作業の順番から、開始・終了の時刻を決める本文・演習・完成コード
6.8同じ時刻に、同じ場所へ入らないようにする本文・演習・完成コード
6.9繰り返す手順を、短い書き方へまとめる本文・演習・完成コード

確率モデルと評価学習へ広げる

章学ぶこと収録状況
6.10ランダムに動くものの、起こり方を数える本文・演習・完成コード
6.11ログから評価関数を学習し、探索へ戻す本文・演習・完成コード

総合練習

章学ぶこと収録状況
7.1覚えた部品を組み合わせ、一つの問題を解く本文・演習・完成コード
コードも動かしてみたいときは

「C++ 全文をコピー」を押し、普段使うC++の編集・実行画面へ貼り付けます。C++17を選んで実行すると、入力を追加しなくても結果が出ます。コードの欄を開く必要はありません。

自分でコードを読みたいときだけ「完成コードを開く」を押してください。画面の設定を変えると、コピーするコードの値も変わります。

端末へ貼り付けて実行する場合

g++が使える環境では、「実行一式をコピー」を下で選んだ端末へ貼り付けられます。これはコード本文に加え、保存・コンパイル・実行の指示も含むものです。C++の編集欄へ貼る場合は「C++ 全文をコピー」を使ってください。

実行一式は、一時フォルダへコードを保存し、C++17でコンパイルして実行します。

g++の準備がまだなら、まずページ内の実験から進められます。

ページ内の実験と、コピーするコードの関係

ページ内ではJavaScriptで計算します。コピーするC++も、同じ設定で同じ結果になるように作っています。自由に編集したC++をこのページ内で実行する機能はありません。

自動コピーを使えないブラウザでは、全文を選択した画面を表示します。そこからCtrl+Cなどでコピーできます。

完成コードで確認する、各処理の役割

各章のコードは、その章の処理を単独で試せる形にしています。共通準備で状態と評価をそろえ、各経路で次の役割を組み合わせます。局所探索では2.2で一つの探索ループになります。

役割確認する点
状態を持つ固定入力と変更する情報が分かれているか。追加で保存した情報は、状態から再計算した結果と一致するか。
一手を適用する前提条件を満たさないとき、状態を変えずに失敗するか。
検査・評価する制約を満たす候補だけを比較しているか。全計算で評価値を再現できるか。
採用・保存する採用時に状態と評価値をそろえて更新し、棄却時に変更を残さないか。最良の答えを提出できるか。

画面の設定はコード内の定数へ反映されます。まず同じ設定で実行結果を照合し、その後に一つの処理を変更すると、結果が変わった原因を追いやすくなります。

1. 共通準備:状態・評価・答えの保存

1.1の小例から始め、1.2〜1.8で三つの経路に共通する部品をそろえます。探索方法を選ぶ前に、有効な答えを作り、値を計算し、よかった答えを保存できるようにします。

共通準備 / 1段階目・全8段階

1.1 はじめに:仕事を一つ移す

重さ8・7・6・5・4・3の六仕事を、A・B・Cの三人へ分けます。重さは手間の大きさです。各仕事を一人に任せ、負担の偏りを小さくします。

担当 A
87
合計 15
担当 B
65
合計 11
担当 C
43
合計 7

Aの負荷は15、Cは7です。Aの重さ7の仕事をCへ移すと、何が変わるか考えてみましょう。

注目コード:担当を一か所だけ変更する初期設定の抜粋 · 左は完成コードの行番号
assign[item] = destination;

ここを見る:assignは担当表です。item=1(重さ7の仕事)、destination=2(C)なら、この一行でAからCへ担当を移します。負荷や評価値は、この変更後の表から求めます。

完成コードの L23 を見る →

よさを数字で比べる

各人の負荷をLA・LB・LCとし、この問題では二乗和S=LA²+LB²+LC²を小さくします。よさを比べるこの数が評価値です。負荷の総和33はどの割当でも同じなので、総和だけでは比べられません。

変更前:S=15²+11²+7²=395

変更後も同じ式で計算します。演習で、担当と負荷の変化、評価値の増減を確かめてください。

二乗和で偏りを比べられる理由

平均11は固定なので、S=(LA−11)²+(LB−11)²+(LC−11)²+363です。二乗和の最小化は、平均からの偏差平方和の最小化と同じです。

8と3、7と4、6と5を組にすると全員11、下限363になります。実際のコンテストでは、指定された採点方法に合わせて評価式を作ります。

次の単元から、状態を持つ → 変更する → 条件と値を確かめる処理を順に実装します。

一仕事を移して考える

Aの重さ7の仕事をCへ移すと、各人の負荷と評価値はいくつになりますか。変わらない情報も挙げてください。

答えと図を見る
担当 A
8
合計 8
担当 B
65
合計 11
担当 C
437
合計 14

変わるのは重さ7の担当と、A・Cの負荷です。変更後は(8,11,14)。Bの負荷11と全体の総和33は変わりません。

変更後:S=8²+11²+14²=381

差分Δ=381−395=−14なので改善です。

負荷も保存する実装では、担当表だけでなくA・Cの負荷も更新します。

共通準備 / 2段階目・全8段階

1.2 制約と評価を分け、実装する条件を整理する

前の単元と同じ仕事割当です。設定の読み直しは不要です。

必要なら、問題設定を読み直す

六つの仕事を三人に分け、負担の偏りを小さくする

前の単元と同じ六仕事・三人の割当問題です。設定の読み直しは不要です。ここから、制約を検査する実装へ進みます。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。一人が担当する仕事数の上限は設けません。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品重さ7の仕事だけを取り出し、担当者が0人・1人・2人の場合の有効性を判定します。探索や得点計算はまだ行いません。

制約は答えが必ず守る条件、評価は答えのよさを数で比べることです。条件の検査と、よさの計算を別々に作ります。

図:同じ答えを、二つの基準で見る

固定入力

重さ 8, 7, 6, 5, 4, 3

決める情報

各仕事の担当者

① 制約の検査

全仕事が一人ずつに割当済み?
いいえ → 比較しない

② 有効な答えの評価

負荷 15, 11, 7
二乗和 395 を小さくする

評価値が小さくても、制約違反なら採用しません。検査と評価は別の処理です。

1. 制約と評価を分ける

仕事の重さと人数は入力、担当者は自分で決める情報です。各仕事を一人に任せるのが制約、負荷の二乗和を小さくするのが目的です。制約を満たす答えを実行可能解、最小化・最大化する式を目的関数、その値を目的値と呼びます。

2. 制約を調べる情報を選ぶ

担当者ごとに仕事一覧を持つなら、全一覧を通じて各仕事が一回ずつ現れるか調べます。負荷の合計だけでは、重複や欠落を検出できません。

1.4のように各仕事へ担当者IDを一つ持たせれば、重複割当は構造上起こりません。状態の表現によって、必要な検査も変わります。

注目コード:守る条件を判定にする初期設定の抜粋 · 左は完成コードの行番号
const int owners = 1;
bool ok = (owners == 1);

ここを見る:この小例で変えるのは、一仕事を担当する人数ownersです。評価値がよくても、okがfalseなら答えとして使えません。

完成コードの L6–7 を見る →

3. 制約を一つ壊して試す

演習では一仕事だけ担当者数を0・1・2へ変え、欠落と重複を拒否できるか確かめます。ほかの五仕事は正しいままです。次の1.3で、この検査を答え全体へ広げます。

ここまでの確認

担当者ごとの仕事一覧で答えを持つ場合、各仕事の重複や欠落は何を調べれば検出できますか?

答えと理由を見る

全員の一覧を横断して、各仕事の出現回数がちょうど1か調べます。担当者ごとの負荷だけを見ても、この制約は確認できません。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点探索はまだ書かず、固定した一つの答えについて「使えるか」と「よさ」を判定するところから始めます。

用意するデータと処理
固定入力
仕事の個数・重さ・担当者数。探索中は書き換えない。
判定の結果
有効かどうかと、違反した条件。有効な場合だけ目的値を求める。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 条件を判定式へ写す

    まず「各仕事を一人に任せる」を一条件として書きます。担当人数を数える処理と、負荷を比べる処理を別の場所へ置きます。

    ここで確認担当人数0・1・2の三例で、1だけが有効になる。

  2. 2. 最小の例をmainで動かす

    内蔵のownersを使い、一条件の判定を出力します。この段階では、得点がよいかを採否の条件へ混ぜません。

    ここで確認未担当や二重担当を、負荷が小さいという理由で通していない。

  3. 3. 答え全体の検査へ広げる

    次の1.3では担当表を入力として受け取り、まず長さ、次に各担当者の範囲を調べる形へ移します。

    ここで確認制約を追加したら、その条件だけを破る入力を一つ用意できる。

次に足すなら:一人あたりの上限を足す

担当表から人数や負荷を集計した後に上限判定を追加します。目的値の関数へ大きな罰点を足す前に、有効性の条件として確定させます。

追加後の確認:上限ちょうどは通り、一つ超えた例は拒否される。

完成コードで見る場所:owners:担当人数の小例 / bool ok:有効性だけを判定する場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

実行可能解
全ての制約を満たす答え。ここでは全仕事がちょうど一人に割り当てられている状態。
目的関数
実行可能解のよさを比較する基準。この小問題では負荷の二乗和を最小化する。

小例の補足

仕事の重さは8,7,6,5,4,3です。各仕事を三人の誰か一人に割り当て、各人の負荷の二乗和を最小化します。仕事を未割当にして値を下げても、有効な答えにはなりません。

この章で扱う実装の範囲

  • Input に入る固定情報の一覧
  • 有効な出力の条件と境界条件
  • 最大化・最小化の向き、目的値と公式得点の関係
  • 小さい入力と手計算による期待結果
  • 入力生成の傾向と、最初から既知の情報・後で分かる情報の区別

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.1 はじめに:仕事を一つ移す

理解できたら:次に広げること

1.4では、各仕事の担当者を一つだけ保存する表現を使います。表現の選び方によって、毎回調べずに済む制約があります。

別の問題でも使うなら

訪問順を決める問題でも、「全地点を一度ずつ訪れる」という制約と「総距離を短くする」という目的を分けます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

1.2 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

0人は未担当、2人は二重の担当です。1人だけが条件を満たします。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
using namespace std;

int main() {
    // 重さ7の仕事を、何人に任せたか。ほかの5個は一人ずつに担当済み。
    const int owners = 1;
    bool ok = (owners == 1);
    cout << "owners=" << owners << "\n";
    cout << "valid=" << ok << "\n";
    return 0;
}

共通準備 / 3段階目・全8段階

1.3 有効な答えと、その検査処理を先に作る

前の章と同じ仕事割当です。設定の読み直しは不要です。

必要なら、問題設定を読み直す

六つの仕事を三人に分け、負担の偏りを小さくする

六仕事・三人の入力、各仕事を一人へ任せる条件、負荷の二乗和を小さくする目的は同じです。前の章で理解していれば、問題設定を読み直す必要はありません。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。一人が担当する仕事数の上限は設けません。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品担当表を一つ出力し、要素数の不足と担当者IDの範囲外を検出します。よい担当表を探す前の、出力と検査の部品です。

ベースラインは、改良の効果を比べる基準となる実装です。まず有効な答えを必ず出す簡単な方法と、その答えの検査を作ります。

図:検査が失敗を見つける場所

長さが5

担当の欠落
↓
COUNT

長さ6・担当者3あり

個数は正しいがIDが範囲外
↓
PERSON

長さ6・全IDが0〜2

この表現の制約を満たす
↓
OK

正常例だけでなく、異なる条件を壊した例を用意すると、検査の抜けを発見できます。

1. 有効な答えを先に作る

まず assign = {0,0,1,1,2,2} を返します。担当者IDはA=0、B=1、C=2です。これを基準に、後で加える探索の効果を比べます。

一般の入力でも有効解を作れる簡単な方法を用意し、探索が途中で終わっても、その答えを返せるようにします。

注目コード:答えの形を固定する初期設定の抜粋 · 左は完成コードの行番号
vector<int> assign = {0, 0, 1, 1, 2, 2};

ここを見る:要素の位置が仕事ID、値が担当者IDです。この対応を、出力・検査・評価で共通に使います。

完成コードの L18 を見る →

2. 答えを独立に検査する

担当表の長さが6で、全IDが0〜2なら、各仕事に一人の担当がいます。個数違いは COUNT、範囲外は PERSON、通過は OK を返します。

生成と検査を別にしておけば、初期解や候補の作り方を変えた後も、同じ基準で正しさを確かめられます。

注目コード:個数を確認してから中身を読む初期設定の抜粋 · 左は完成コードの行番号
if (assign.size() != 6) {
    cout << "check=COUNT\n";
    return 0;
}
for (int person : assign) {
    if (person < 0 || person >= 3) {
        cout << "check=PERSON\n";
        return 0;
    }
}

ここを見る:仕事の書き忘れと、存在しない担当者を別々に検出します。評価を始める前の入口に、この検査を置きます。

完成コードの L24–33 を見る →

3. 正常例と失敗例を通す

正常な表に加え、担当を一つ消した表と、担当者3を含む表を渡します。それぞれ正しい理由で拒否されるか確認してください。正常例だけでは、常にOKを返す不具合を見抜けません。

answer= などは教材用の診断表示です。提出時は指定の出力形式を守り、診断情報は標準エラー出力などへ分けます。

ここまでの確認

答えを作る処理の中で担当者の範囲を確認していても、答え全体の検査を別に持つ利点は何ですか?

答えと理由を見る

生成方法や変更処理を差し替えても、同じ検査を使えることです。途中の操作が正しくても、最終的な答えに欠落などがないか独立に確認できます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点固定の担当表を出力できる最小プログラムを用意し、その出力を検査できるようにします。

用意するデータと処理
assign
仕事ごとの担当者ID。出力する答えの正本。
検査処理
答えを読み、成功か、長さ不足・範囲外などの理由を返す。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 正常な出力を一つ作る

    六仕事の担当表を固定値で持ち、print_arrayで出力します。実問題に移す際は、この出力部分を指定形式に置き換えます。

    ここで確認探索なしで一つの有効な答えを出せる。

  2. 2. 要素を読む前に検査する

    長さの確認を先に書き、合っている場合だけ各担当者IDを調べます。条件違反を見つけたら理由を返して処理を止めます。

    ここで確認要素を一つ削っても範囲外アクセスせずCOUNTになり、担当者3ならPERSONになる。

  3. 3. 提出出力と記録を分ける

    教材のcheckなどの表示は動作確認用です。提出版では出力関数に答えだけを渡し、検査結果や計測ログは標準エラーへ送ります。

    ここで確認検査用ログを増やしても、問題が読む出力形式は変わらない。

次に足すなら:制約の検査を関数へ切り出す

mainの長さ・範囲の判定を、担当表を受け取る関数へ移します。その後、候補生成後と最終出力前の両方から呼びます。

追加後の確認:正常例と二種類の不正例が、切り出す前と同じ判定になる。

完成コードで見る場所:print_array:出力する処理 / assign.size():長さを先に調べる場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

ベースライン
改善前の比較対象となる実装。まずは有効な答えを安定して出せるものを用意する。
答え全体の検査
完成した答えを受け取り、制約違反がないか確認する処理。生成処理と分けておく。

小例の補足

最初の答えを [0,0,1,1,2,2] とします。A=0、B=1、C=2です。担当の欠落と範囲外の担当者を含む答えも用意し、検査が区別できることを確かめます。

この章で扱う実装の範囲

  • read_input と write_answer
  • 実行コマンド、標準入力・標準出力・標準エラーの接続
  • run_case が返す終了状態・所要時間・得点
  • 合法性を確認する独立した validator

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.2 制約と評価を分け、実装する条件を整理する

理解できたら:次に広げること

教材のコードは入力を内蔵しています。実際の問題へ移すときは、入力の読取りと出力形式を合わせ、診断用の表示は提出用の標準出力に混ぜません。

別の問題でも使うなら

訪問順の問題なら、距離の計算とは別に、全地点が一度ずつ含まれていることを検査します。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

1.3 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

COUNTは個数、PERSONは担当者番号の間違いです。正常な答えへ戻すとOKになります。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void print_array(const char* name, const vector<int>& a) {
    cout << name << "=";
    for (int i = 0; i < int(a.size()); ++i) {
        if (i > 0) cout << ",";
        cout << a[i];
    }
    cout << "\n";
}

int main() {
    // 0: 正常、1: 一つ書き忘れ、2: 存在しない担当者。
    const int example = 0;
    vector<int> assign = {0, 0, 1, 1, 2, 2};
    if (example == 1) assign.pop_back();
    if (example == 2) assign[1] = 3;
    print_array("answer", assign);

    // 要素を読む前に、個数を確認する。
    if (assign.size() != 6) {
        cout << "check=COUNT\n";
        return 0;
    }
    for (int person : assign) {
        if (person < 0 || person >= 3) {
            cout << "check=PERSON\n";
            return 0;
        }
    }
    cout << "check=OK\n";
    return 0;
}

共通準備 / 4段階目・全8段階

1.4 探索で変える情報を、状態として持つ

前の章と同じ仕事割当です。設定の読み直しは不要です。

必要なら、問題設定を読み直す

六つの仕事を三人に分け、負担の偏りを小さくする

六仕事・三人の入力、各仕事を一人へ任せる条件、負荷の二乗和を小さくする目的は同じです。前の章で理解していれば、問題設定を読み直す必要はありません。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。一人が担当する仕事数の上限は設けません。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品担当表の一要素を変更します。仕事の重さと、変更できる担当を別のデータとして持ちます。

探索状態は、候補を表し、次の変更を試すために持つ情報です。この章では仕事の担当表を状態とし、負荷も保存する場合との違いを考えます。

図:何を保存し、何を計算するか

固定入力 weight

仕事ID → 重さ
[8,7,6,5,4,3]

探索状態 assign

仕事ID → 担当者ID
[0,0,1,1,2,2]

担当者ごとの負荷 load

[15,11,7]
ここでは毎回計算する

探索で直接変えるのはassignです。loadも保存する設計にすると、移動と取消しの両方で整合性を保つ必要があります。

1. 入力と状態を分ける

weight[job] は固定の重さ、assign[job] は変更する担当者です。候補を作るときはassignだけをコピーします。担当者ごとの仕事一覧や負荷は、重さと担当表から求められます。

注目コード:固定入力は状態から分ける初期設定の抜粋 · 左は完成コードの行番号
const vector<int> weight = {8, 7, 6, 5, 4, 3};

ここを見る:探索で変えるのは担当です。仕事の重さweightは固定し、候補ごとに変更しません。

完成コードの L15 を見る →

2. 一つの表から始める

各仕事に担当者IDを一つ持たせれば、重複割当は起きません。長さとIDの範囲を保ち、仕事1をCへ移すなら assign[1] = 2; とします。

担当者別の一覧も持つ場合は、移動元から削除し、移動先へ追加する更新が必要です。まず担当表だけで実装し、遅い操作が分かってから補助情報を足します。

注目コード:状態の変更箇所を一つに絞る初期設定の抜粋 · 左は完成コードの行番号
assign[item] = destination;

ここを見る:担当表assignの一要素だけを変えます。この段階では負荷の配列を持たず、必要になったら担当表から計算します。

完成コードの L23 を見る →

3. 補助情報の更新も数える

演習の負荷は担当表から毎回計算します。負荷も保存する設計なら、移動時に担当表と二人分の負荷を更新し、取消しでも全て戻す必要があります。保存する情報を増やした分だけ、整合性を保つ更新も増えます。

ここまでの確認

担当表に加えて、担当者ごとの仕事一覧も保存すると、一つの仕事を移すときに何を更新する必要がありますか?

答えと理由を見る

担当表、移動元の一覧、移動先の一覧をそろえて更新する必要があります。取得は速くなりますが、更新漏れが起きる場所も増えます。最初は担当表だけから始めます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点1.3の担当表を使い、一回の代入で答えを変えられる状態にします。

用意するデータと処理
weight
固定された仕事の重さ。
assign
探索で変更する担当表。負荷の合計は最初は保存しない。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 固定入力を状態から分ける

    weightとassignを別々に宣言します。変更前のassignを出力し、どの情報を書き換えるかを一つに絞ります。

    ここで確認担当を変えてもweightの内容が変わらない。

  2. 2. 一要素だけ変更する

    探索や乱数を加える前に、itemとdestinationを固定してassign[item]を変更します。

    ここで確認変更前後を比べると指定した一要素だけが変わる。

  3. 3. 集計は正本から作る

    負荷が必要になったらassignとweightから毎回集計します。速さのためにloadを保存するのは、この集計と照合できるようになってからです。

    ここで確認同じassignから、いつでも同じ負荷を再計算できる。

次に足すなら:loadを状態へ追加する

初期化時に担当表からloadを作り、一手で変わる二人の負荷も更新します。検証中は毎回、担当表からの再計算と照合します。

追加後の確認:担当表は正しいのにloadだけ古い、という不一致を検出できる。

完成コードで見る場所:weight:固定入力 / assign[item]:状態を変える一行

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

探索状態
探索中に変更する情報のまとまり。この章ではassignだけを持つ。
状態から計算できる情報
担当者ごとの負荷など。保存すれば計算を省ける一方、状態を変えるたびに整合性を保つ必要がある。

小例の補足

固定入力は weight=[8,7,6,5,4,3]、探索状態は assign=[0,0,1,1,2,2] です。assign[job] に担当者を保存すれば、一つの仕事が二人に重複して割り当てられることはありません。

この章で扱う実装の範囲

  • Problem:変化しない重みや盤面サイズ
  • State:割当・配置など探索中に変わる情報
  • 派生情報:担当者ごとの合計など、元データから再計算できる値
  • ID・座標・番兵の規約と状態の不変条件

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.2 制約と評価を分け、実装する条件を整理する

理解できたら:次に広げること

2.3・2.4では負荷も保存し、変更と取消しで更新します。まずは情報を重複して保存しない実装を、正しさの基準として作ります。

別の問題でも使うなら

訪問順ならorder[pos]に地点IDを持ちます。担当表とは添字・値の役割が違うため、変更処理がどちらを扱うかを明確にします。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

1.4 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

番号1は重さ7です。同じ担当者を指定した場合は、代入しても表の数字が変わりません。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void print_array(const char* name, const vector<int>& a) {
    cout << name << "=";
    for (int i = 0; i < int(a.size()); ++i) {
        if (i > 0) cout << ",";
        cout << a[i];
    }
    cout << "\n";
}

const vector<int> weight = {8, 7, 6, 5, 4, 3};

int main() {
    const int item = 1;
    const int destination = 2;
    vector<int> assign = {0, 0, 1, 1, 2, 2};
    print_array("before", assign);
    // この章の設定では、itemは0〜5、destinationは0〜2。
    assign[item] = destination;
    cout << "job=" << item << "\n";
    cout << "weight=" << weight[item] << "\n";
    print_array("after", assign);
    return 0;
}

共通準備 / 5段階目・全8段階

1.5 一手の変更が守る条件を決める

前の章と同じ仕事割当です。設定の読み直しは不要です。

必要なら、問題設定を読み直す

六つの仕事を三人に分け、負担の偏りを小さくする

六仕事・三人の入力、各仕事を一人へ任せる条件、負荷の二乗和を小さくする目的は同じです。前の章で理解していれば、問題設定を読み直す必要はありません。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。一人が担当する仕事数の上限は設けません。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品一仕事の担当を変える関数を作ります。仕事IDや担当者IDが不正な場合は、変更せずに失敗を返します。

一手は「仕事1をCへ移す」のような一回の変更です。状態に一手を適用する関数を作り、失敗時には状態を変えないようにします。

図:失敗時には状態を変えない

変更前

[0,0,1,1,2,2]

仕事1 → 担当者2

適用する
[0,2,1,1,2,2]

仕事6 → 担当者2

拒否する
[0,0,1,1,2,2]

仕事IDは0〜5です。不正な指定は書き換え前に拒否し、呼び出し側へ失敗を返します。

1. 一手の選択と適用を分ける

移動は仕事IDと移動先IDで指定します。apply は状態と指定を受け取り、適用できたかを返します。どの一手を選ぶかは探索側の役割です。固定の一手列でapplyを検証でき、選び方を乱択へ変えても使い回せます。

2. 検査してから書き換える

仕事IDが0〜5、移動先が0〜2かを確認し、通った場合だけ担当を更新します。失敗時は状態を変えないと決めると、呼出し側の処理が簡単になります。容量などの制約を足す場合も、書換え前に検査するか、失敗時に全変更を取り消します。

注目コード:検査が済むまで状態を変えない初期設定の抜粋 · 左は完成コードの行番号
bool apply(vector<int>& assign, int item, int destination) {
    if (item < 0 || item >= int(assign.size())) return false;
    if (destination < 0 || destination >= 3) return false;
    assign[item] = destination;
    return true;
}

ここを見る:どちらかの番号が範囲外なら、書き換え前にreturnします。失敗を返したときに状態が元のまま、という約束を守ります。

完成コードの L15–21 を見る →

3. 戻り値と状態を確認する

仕事6や移動先3では applied=0 となり、状態が変わらないことを確かめます。有効な指定では、指定した担当だけが変わることを確認します。

同じ担当者への移動も、この実装では成功です。ただし状態は変わりません。こうした候補を探索から除くかは、生成側で決めます。

注目コード:成功したかを呼出し側で受け取る初期設定の抜粋 · 左は完成コードの行番号
bool applied = apply(assign, item, destination);

ここを見る:appliedが成功・失敗、assignが変更後の状態です。戻り値だけでなく、失敗時にassignが変わっていないことも確かめます。

完成コードの L28 を見る →

ここまでの確認

変更を拒否したとき、戻り値以外に何を確かめる必要がありますか?

答えと理由を見る

状態が変更前と完全に一致することです。一部を書き換えた後で失敗を返すと、呼び出し側が気付かないまま壊れた状態で探索を続けてしまいます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点担当表の一要素を変える処理を、失敗しても状態を壊さない関数へまとめます。

用意するデータと処理
一手の指定
仕事IDと移動先ID。
apply
担当表と一手を受け取り、適用できたかを返す。失敗時は変更しない。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 引数を検査する

    applyの先頭に仕事IDと担当者IDの範囲判定を書きます。どちらも通るまで担当表へ触れません。

    ここで確認仕事ID−1・6、移動先−1・3を渡しても配列外を読まない。

  2. 2. 変更を一か所へ集める

    検査の後に代入を置き、成功を返します。呼び出し側は返り値を見て、失敗した操作を評価へ送らないようにします。

    ここで確認不正な操作の後も、担当表が変更前と完全一致する。

  3. 3. 一手の実行と採否を分ける

    この関数は有利な変更かを選びません。採否は外側で、コピーした候補の検査・評価を終えてから決めます。

    ここで確認悪化するが合法な一手もapply自体は実行できる。

次に足すなら:交換操作を足す

二つの仕事IDを受け取る操作を追加し、両方の範囲を検査してから交換します。移動か交換かを選ぶ処理は呼び出し側へ置きます。

追加後の確認:同じ仕事同士なら変化せず、一方のIDが不正なら両方とも変更されない。

完成コードで見る場所:bool apply:一手の窓口 / bool applied:成功・失敗を受け取る場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

一手
一回の変更を指定する情報。移動なら仕事IDと移動先IDを持つ。
applyの役割
一手を状態へ適用する。前提条件を満たさなければ状態を変えずに失敗を返す。よくなるかの判断は別に行う。

小例の補足

仕事1を担当者2へ移すと、assignは [0,0,1,1,2,2] から [0,2,1,1,2,2] へ変わります。仕事6や担当者3への指定は拒否し、状態を変えません。

この章で扱う実装の範囲

  • Move:変更内容を表す小さいデータ
  • is_legal(state, move)
  • apply(state, move) と一ターンの処理順
  • replay(input, moves) と終端判定

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.3 有効な答えと、その検査処理を先に作る、1.4 探索で変える情報を、状態として持つ

理解できたら:次に広げること

この章では同じ担当者への移動も受け付けますが、状態は変化しません。探索ではこのような候補を生成時に除く選択もあります。

別の問題でも使うなら

長方形を動かす処理なら、境界と重なりを確認したうえで更新する、といった前提条件を決めます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

1.5 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

仕事の番号を6、移動先を3へ、それぞれ変えてみましょう。拒否したときに元の表が残るかも見ます。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void print_array(const char* name, const vector<int>& a) {
    cout << name << "=";
    for (int i = 0; i < int(a.size()); ++i) {
        if (i > 0) cout << ",";
        cout << a[i];
    }
    cout << "\n";
}

bool apply(vector<int>& assign, int item, int destination) {
    // 全ての確認が終わるまで、配列を変更しない。
    if (item < 0 || item >= int(assign.size())) return false;
    if (destination < 0 || destination >= 3) return false;
    assign[item] = destination;
    return true;
}

int main() {
    const int item = 1;
    const int destination = 2;
    vector<int> assign = {0, 0, 1, 1, 2, 2};
    print_array("before", assign);
    bool applied = apply(assign, item, destination);
    cout << "applied=" << applied << "\n";
    print_array("after", assign);
    return 0;
}

共通準備 / 6段階目・全8段階

1.6 検査と評価を分け、全計算を基準にする

前の章と同じ仕事割当です。設定の読み直しは不要です。

必要なら、問題設定を読み直す

六つの仕事を三人に分け、負担の偏りを小さくする

六仕事・三人の入力、各仕事を一人へ任せる条件、負荷の二乗和を小さくする目的は同じです。前の章で理解していれば、問題設定を読み直す必要はありません。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。一人が担当する仕事数の上限は設けません。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品担当表から負荷と二乗和を全計算します。担当者が欠けている表は、計算へ入る前に拒否します。

全計算は、以前の計算結果を使わず、状態から評価値を計算し直す方法です。担当表から負荷と二乗和を求め、後の高速化の答え合わせに使います。

図:担当表から評価値を全計算する

担当表

[0,0,1,1,2,2]

集計した負荷

[15,11,7]

A:15²
225
B:11²
121
C:7²
49

225 + 121 + 49 = 395

棒の長さは各担当者の二乗の値です。負荷が大きい担当者ほど、評価値への寄与も大きくなります。

1. 毎回ゼロから評価する

担当表を検査し、負荷を0から load[assign[job]] += weight[job]; で集計します。過去の負荷や評価値は使いません。同じ表から常に同じ値を返す全計算を、後で差分評価の答え合わせに使います。

注目コード:担当表から負荷を集計する初期設定の抜粋 · 左は完成コードの行番号
vector<int> get_load(const vector<int>& assign) {
    vector<int> load(3, 0);
    for (int job = 0; job < int(weight.size()); ++job) {
        load[assign[job]] += weight[job];
    }
    return load;
}

ここを見る:毎回loadを0から作るので、以前の候補の値が混ざりません。この全計算を、後で差分評価を検証する基準にします。

完成コードの L25–31 を見る →

2. 最小化と型を決める

score は負荷の二乗和です。初期表は395、重さ7の移動後は381、均等なら363。小さい値を改善とし、採用条件と差分の符号をそろえます。

この入力はintで足ります。大きい入力では負荷と二乗和の上限を見積もり、乗算の途中も収まる型を使います。必要なら long long にします。

注目コード:評価値を一か所で計算する初期設定の抜粋 · 左は完成コードの行番号
int score(const vector<int>& assign) {
    vector<int> load = get_load(assign);
    int value = 0;
    for (int total : load) value += total * total;
    return value;
}

ここを見る:scoreは担当表を変更せず、負荷の二乗和を返します。この小入力ではintで足りますが、大きな入力では積と合計の上限から型を選びます。

完成コードの L33–38 を見る →

3. 不正な表は採点しない

未担当−1が残る表は拒否し、NOT_EVALUATED とします。0を返すと、最小化では不正な表が最良に見えてしまいます。未完成の状態を探索する場合は、完成解とは別の評価を設計します。

ここまでの確認

未割当の状態に評価値0を返すと、この最小化問題では何が起きますか?

答えと理由を見る

本来使えない状態が、全ての有効な答えよりよい候補に見えてしまいます。制約を満たすかどうかは、評価値とは別に扱います。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点有効な担当表を一つ渡すと、全体の目的値を返す基準の計算を作ります。

用意するデータと処理
valid
担当表を検査する。無効なら採点しない。
get_load / score
担当別の負荷を集め、その二乗和を返す。状態は変更しない。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 集計関数を先に作る

    人数分のloadを0で初期化し、各仕事の重さを担当者へ加えます。途中の集計を使い回さず、呼ぶたびに作り直します。

    ここで確認初期表から負荷15・11・7、総和33が得られる。

  2. 2. 目的値だけ返す関数を作る

    scoreからget_loadを呼び、各負荷の二乗を足します。最小化か最大化かを呼び出し側とそろえます。

    ここで確認初期表395、均等な表363になる。

  3. 3. 検査してから採点する

    mainではvalidが偽なら終了し、真の場合だけscoreを呼びます。範囲外の担当者を集計の添字に使わせません。

    ここで確認未担当−1の例は、負の良い得点ではなくNOT_EVALUATEDになる。

次に足すなら:別の評価項を足す

既存のscoreを基準として残し、追加項を単独で計算する関数を用意してから合算します。公式の目的値と探索用の補助評価は別に返します。

追加後の確認:各項を表示して総和を再現でき、追加項だけが変わる入力を確認できる。

完成コードで見る場所:get_load:負荷の全計算 / score:目的値の全計算

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

目的値
問題の目的関数の値。この小問題では小さい方がよい。
全計算
状態から必要な情報を全て再計算する方法。高速化した計算の検証にも使う。

小例の補足

各人の負荷15,11,7から二乗和395を計算します。重さ7を移した状態では381です。一方、未割当の仕事が残る状態は、完成した答えの比較から除きます。

この章で扱う実装の範囲

  • validate:制約の検査
  • objective_full:正本から目的値を全計算
  • evaluate:途中状態に対する比較用の値
  • 目的値と公式得点の変換・丸め・比較方向

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.5 一手の変更が守る条件を決める

理解できたら:次に広げること

3.1では探索用の評価と最終的な目的値を分けます。未完成の状態を扱う場合にも、完成した答えと同じ基準で比較できるとは限りません。

別の問題でも使うなら

経路の問題なら、訪問順から全区間の距離を合計する関数を、差分計算の検証用に残します。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

1.6 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

三つの正常例の値は395・381・363です。担当なしの例には、比較する値を付けません。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void print_array(const char* name, const vector<int>& a) {
    cout << name << "=";
    for (int i = 0; i < int(a.size()); ++i) {
        if (i > 0) cout << ",";
        cout << a[i];
    }
    cout << "\n";
}

const vector<int> weight = {8, 7, 6, 5, 4, 3};

bool valid(const vector<int>& assign) {
    if (assign.size() != weight.size()) return false;
    for (int person : assign) {
        if (person < 0 || person >= 3) return false;
    }
    return true;
}

vector<int> get_load(const vector<int>& assign) {
    vector<int> load(3, 0);
    for (int job = 0; job < int(weight.size()); ++job) {
        load[assign[job]] += weight[job];
    }
    return load;
}

int score(const vector<int>& assign) {
    vector<int> load = get_load(assign);
    int value = 0;
    for (int total : load) value += total * total;
    return value;
}

int main() {
    const int example = 0;
    vector<vector<int>> examples = {
        {0, 0, 1, 1, 2, 2}, // 元の答え
        {0, 2, 1, 1, 2, 2}, // 重さ7をCへ
        {0, 1, 2, 2, 1, 0}, // 全員11
        {0, 0, 1, 1, 2, -1} // 重さ3の担当を書き忘れ
    };
    vector<int> assign = examples[example];
    print_array("assign", assign);
    bool ok = valid(assign);
    cout << "valid=" << ok << "\n";
    if (!ok) {
        cout << "score=NOT_EVALUATED\n";
        return 0;
    }
    print_array("load", get_load(assign));
    cout << "score=" << score(assign) << "\n";
    return 0;
}

共通準備 / 7段階目・全8段階

1.7 貪欲法で初期解を作る

前の章と同じ仕事割当です。設定の読み直しは不要です。

必要なら、問題設定を読み直す

六つの仕事を三人に分け、負担の偏りを小さくする

六仕事・三人の入力、各仕事を一人へ任せる条件、負荷の二乗和を小さくする目的は同じです。前の章で理解していれば、問題設定を読み直す必要はありません。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。一人が担当する仕事数の上限は設けません。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品仕事を一つずつ負荷の小さい人へ割り当て、探索の出発点になる有効解を作ります。

初期解は、改善を始める出発点の答えです。ここでは貪欲法で仕事を一つずつ割り当て、処理順による違いを比べます。

図:重い順に構築する六手

  1. 1手目重さ8 → A負荷 (8, 0, 0)
  2. 2手目重さ7 → B負荷 (8, 7, 0)
  3. 3手目重さ6 → C負荷 (8, 7, 6)
  4. 4手目重さ5 → C負荷 (8, 7, 11)
  5. 5手目重さ4 → B負荷 (8, 11, 11)
  6. 6手目重さ3 → A負荷 (11, 11, 11)
各手で選ぶのは、その時点で負荷が最小の担当者です。最終的な担当をまとめて決めているわけではありません。

1. 負荷が小さい人へ順に割り当てる

担当を全て−1、負荷を0で始め、各仕事を最も負荷の小さい人へ任せます。未担当を許すのは構築中だけです。完成後に1.6の検査と評価を通します。

一般の入力では、仕事IDを重さでソートした順に処理します。重さと担当表の対応は保ち、処理順だけを変えます。

注目コード:最小負荷の担当者を選んで更新する初期設定の抜粋 · 左は完成コードの行番号
int person = 0;
for (int p = 1; p < 3; ++p) {
    if (load[p] < load[person]) person = p;
}
assign[job] = person;
load[person] += weight[job];

ここを見る:各仕事jobについてこの処理を行います。担当の確定と負荷の更新を続けて行い、次の仕事は更新後の負荷で選びます。

完成コードの L49–55 を見る →

2. 順序と同点規則を固定する

負荷が同じなら小さい担当者IDを選びます。構築順と同点規則を固定すれば初期解を再現でき、その後の探索を比較できます。

N仕事・M人なら全員を調べる構築はO(NM)です。今回は三人なので走査で十分です。大きい場合は優先度付きキューも選べますが、構築後に残る探索時間も見て判断します。

注目コード:処理順を先に用意する初期設定の抜粋 · 左は完成コードの行番号
vector<int> order = {0, 1, 2, 3, 4, 5};
if (!heavy) reverse(order.begin(), order.end());

ここを見る:この入力ではID順が重い順です。heavyを外すと軽い順へ変わります。担当の選び方を固定して、順序だけの効果を比べられます。

完成コードの L43–44 を見る →

3. 構築結果を比較する

重い順では負荷11・11・11で363、軽い順では9・11・13で371です。「次の一手」で割当の違いを追ってください。

この例で重い順が最適でも、一般の入力や探索後の優劣は保証されません。2.2では改善を観察するため、偏りのある固定解から始めます。

ここまでの確認

この小入力で最適値363が出たことだけで、同じ貪欲法を他の入力でも最適解を求める処理として扱えますか?

答えと理由を見る

扱えません。この構築法に最適性の保証はありません。初期解として使い、その後の探索も含めた結果と計算時間で比較します。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点1.6の検査・全評価を残したまま、固定の答えを作る部分を貪欲な構築へ置き換えます。

用意するデータと処理
order
仕事を処理するIDの順番。
assign / load
未担当を含む構築中の表と、割当済みの負荷。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 空の状態を初期化する

    assignを未担当−1、loadを0で作り、まず仕事ID順のorderを用意します。未完成な表はscoreへ渡しません。

    ここで確認一仕事も割り当てていないとき、全負荷が0になる。

  2. 2. 一仕事を置く処理を書く

    現在のloadから最小の担当者を選び、assignとloadを同時に更新します。同点なら小さいIDを選ぶと決めます。

    ここで確認一件追加するたび、負荷の総和がその仕事の重さだけ増える。

  3. 3. 全件の完了後に検査する

    orderの全仕事を処理し終えてからvalidとscoreを呼びます。その後で重い順・軽い順にorderだけを変えます。

    ここで確認全仕事が一回ずつ処理され、保存したloadと全計算が一致する。

次に足すなら:割当先の容量制約を足す

最小の担当者を探すループで、容量を超える候補を除外します。一人も置けない場合は構築失敗を返し、別の順番から再試行します。

追加後の確認:置ける担当者がない例で、−1を残したまま完成と報告しない。

完成コードで見る場所:vector<int> order:処理順 / load[person] +=:割当と負荷をそろえる場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

初期解
改善を始める出発点となる答え。品質だけでなく構築時間も、その後の探索に影響する。
同点時の規則
同じ評価の選択肢をどの順に選ぶか。この例では担当者IDの小さい方を選び、実行結果を再現しやすくする。

小例の補足

重い仕事から、現在の負荷が最小の人へ割り当てます。同点ならA・B・Cの順です。最初の8,7,6を配った後、重さ5は負荷6のCへ割り当てます。

この章で扱う実装の範囲

  • 配置する対象の順序
  • 候補の列挙と合法性の判定
  • 一手の評価と同点の扱い
  • 候補がない場合の処理と終了条件
  • 重み順・ランダム順など複数の構築順と、やり直し時の最良解保存

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.6 検査と評価を分け、全計算を基準にする

理解できたら:次に広げること

この六つの仕事では重い順で全員11となるため、2.2では改善を観察できるよう、偏りのある1.3の答えから始めます。

別の問題でも使うなら

被覆でも、未被覆の対象を多く覆う候補を選んで初期解を作り、削除・交換による改善へつなげられます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

1.7 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

重い順で363、軽い順で371です。一手ずつ見て、どの仕事を誰に配ったか説明してみましょう。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void print_array(const char* name, const vector<int>& a) {
    cout << name << "=";
    for (int i = 0; i < int(a.size()); ++i) {
        if (i > 0) cout << ",";
        cout << a[i];
    }
    cout << "\n";
}

const vector<int> weight = {8, 7, 6, 5, 4, 3};

bool valid(const vector<int>& assign) {
    if (assign.size() != weight.size()) return false;
    for (int person : assign) {
        if (person < 0 || person >= 3) return false;
    }
    return true;
}

vector<int> get_load(const vector<int>& assign) {
    vector<int> load(3, 0);
    for (int job = 0; job < int(weight.size()); ++job) {
        load[assign[job]] += weight[job];
    }
    return load;
}

int score(const vector<int>& assign) {
    vector<int> load = get_load(assign);
    int value = 0;
    for (int total : load) value += total * total;
    return value;
}

int main() {
    const bool heavy = true;
    // この入力は番号0から順に重い。逆順なら軽い順になる。
    vector<int> order = {0, 1, 2, 3, 4, 5};
    if (!heavy) reverse(order.begin(), order.end());
    vector<int> assign(6, -1);
    vector<int> load(3, 0);
    print_array("order", order);
    for (int job : order) {
        int person = 0;
        for (int p = 1; p < 3; ++p) {
            // <なので、同点なら先に選んだ人を残す。
            if (load[p] < load[person]) person = p;
        }
        assign[job] = person;
        load[person] += weight[job];
        cout << "step=" << job << "," << person << ","
             << load[0] << "," << load[1] << "," << load[2] << "\n";
    }
    print_array("assign", assign);
    print_array("load", load);
    cout << "valid=" << valid(assign) << "\n";
    cout << "score=" << score(assign) << "\n";
    return 0;
}

共通準備 / 8段階目・全8段階

1.8 探索中の状態と、提出する最良解を分ける

前の章と同じ仕事割当です。設定の読み直しは不要です。

必要なら、問題設定を読み直す

六つの仕事を三人に分け、負担の偏りを小さくする

六仕事・三人の入力、各仕事を一人へ任せる条件、負荷の二乗和を小さくする目的は同じです。前の章で理解していれば、問題設定を読み直す必要はありません。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。一人が担当する仕事数の上限は設けません。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品順に調べた担当表の中から、最良の表とその評価値を組で保存します。候補の作り方は固定して比較します。

ここでいう最良解は、これまでに見つけた有効な答えのうち最もよいものです。探索をいつ止めても返せるよう、状態と評価値を組で保存します。

図:現在値と最良値は別々に進む

一つ目

現在 395
最良 395

二つ目:改善

現在 381
最良 381

三つ目:悪化

現在 389
最良 381

最良値を更新するときは、その値を出した担当表も保存します。三つ目を調べても381の担当表を失いません。

1. 現在の状態と最良解を分ける

current は今調べる状態、best は提出用の最良解です。演習では評価値395・381・389の表を順に調べ、最後の表に替わっても381を残せるか確かめます。悪化する変更を試したり、別の答えからやり直したりする場合にも使えます。

注目コード:探索を始める前に最良解を持つ初期設定の抜粋 · 左は完成コードの行番号
vector<int> best = answers[0];
int best_value = score(best);

ここを見る:最初の有効解を状態と値の組で保存します。後続の候補を一つも調べられなくても、この組を返せます。

完成コードの L41–42 を見る →

2. 状態と値を一緒に保存する

有効な初期解をbestへコピーし、その値を best_value に保存します。改善時は両方を更新します。値だけ381に替えても、表が395のままでは正しい最良解を返せません。保存した表を全計算し、値との一致を検査します。

注目コード:改善したときに状態と値を保存する初期設定の抜粋 · 左は完成コードの行番号
if (!keep_best || value < best_value) {
    best = current;
    best_value = value;
}

ここを見る:通常はkeep_best=trueなので、改善時だけ二つを更新します。falseは毎回上書きする不具合を試す演習用の切替です。

完成コードの L46–49 を見る →

3. 途中で止めても返せるようにする

一表だけ調べたら395、二表以上なら381が残ります。「よくなったときだけ保存」を外すと、三表目の389で上書きする失敗を確認できます。

まず固定回数で候補列をそろえて検証します。乱数の初期設定値をseedと呼びます。時間で打ち切る版は試行数が環境で変わるため、seedだけでは再現できません。常に最良解を保存し、出力時間を残して終了します。

ここまでの確認

best_valueだけを381へ更新し、bestの担当表を更新し忘れると、どのような不整合が起きますか?

答えと理由を見る

記録上の最良値と、実際に提出する答えの値が一致しなくなります。保存時に状態と評価値をまとめて更新し、最後に状態から再計算すると確認できます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点候補を作る方法は固定し、複数の答えを受け取って最良の一つを保存する処理だけを作ります。

用意するデータと処理
current / value
今回調べる答えとその評価値。
best / best_value
これまでで最良の答えのコピーと、その評価値。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 最初の有効解から初期化する

    bestに最初の担当表をコピーし、その表をscoreで評価してbest_valueへ入れます。

    ここで確認探索を0回で終えても、出力可能な答えが残る設計になっている。

  2. 2. 改善したときだけ二つを更新する

    候補を評価する場所の直後に比較を置きます。値が小さければ、bestとbest_valueを同じ分岐の中で更新します。

    ここで確認381の後に389を調べても、保存されている値は381のまま。

  3. 3. 保存した表から出力する

    終了時は最後のcurrentではなくbestを出します。検証用にbestを全計算し、best_valueと比べます。

    ここで確認数値381だけでなく、その値を出す担当表も保存できている。

次に足すなら:複数回の再出発へ使う

一回の探索が終わるごとにこの保存処理を呼びます。再出発するcurrentだけを初期化し、外側のbestは消さないようにします。

追加後の確認:最後の探索が悪い結果でも、以前の最良解を提出できる。

完成コードで見る場所:best_value:保存する評価値 / best = current:答えそのものをコピーする場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

current / best
探索を進めるための状態と、提出用に保存する最良の状態。探索が悪化を許す場合は、特に区別が必要になる。
seedと再現性
乱数seedに加え、入力・候補の順序・試行回数もそろえる。時間による打ち切りでは同じseedでも試行回数が変わりうる。

小例の補足

候補の値が395→381→389と変わったとします。最後に調べた候補は389ですが、提出用には381の担当表を残す必要があります。

この章で扱う実装の範囲

  • 乱数生成器と探索 seed
  • 反復回数による終了と単調な時計による期限
  • current と best の別々の保存
  • 初期解・探索・復元・出力への時間配分

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.7 貪欲法で初期解を作る

理解できたら:次に広げること

まず固定回数で動作を確認します。時間制限を使う版では、出力までの余裕を残して探索を打ち切り、常に保存済みの有効な答えを返します。

別の問題でも使うなら

焼きなましや複数の初期解からの探索でも、最良値とその状態を保存する部分は共通して使えます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

1.8 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

二つ目まで調べれば381が見つかります。毎回上書きする設定では、三つ目で389に戻ってしまいます。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void print_array(const char* name, const vector<int>& a) {
    cout << name << "=";
    for (int i = 0; i < int(a.size()); ++i) {
        if (i > 0) cout << ",";
        cout << a[i];
    }
    cout << "\n";
}

const vector<int> weight = {8, 7, 6, 5, 4, 3};

vector<int> get_load(const vector<int>& assign) {
    vector<int> load(3, 0);
    for (int job = 0; job < int(weight.size()); ++job) {
        load[assign[job]] += weight[job];
    }
    return load;
}

int score(const vector<int>& assign) {
    vector<int> load = get_load(assign);
    int value = 0;
    for (int total : load) value += total * total;
    return value;
}

int main() {
    const int count = 3;
    // falseは、最良の答えまで毎回上書きしてしまう失敗例。
    const bool keep_best = true;
    vector<vector<int>> answers = {
        {0, 0, 1, 1, 2, 2},
        {0, 2, 1, 1, 2, 2},
        {0, 1, 2, 2, 2, 1}
    };
    vector<int> best = answers[0];
    int best_value = score(best);
    for (int i = 0; i < count; ++i) {
        vector<int> current = answers[i];
        int value = score(current);
        if (!keep_best || value < best_value) {
            best = current; // 数字だけでなく、担当表も保存する。
            best_value = value;
        }
        cout << "step=" << i + 1 << "," << value << "," << best_value << "\n";
    }
    print_array("saved_assign", best);
    cout << "saved_value=" << best_value << "\n";
    return 0;
}

共通準備から選べる経路 · 経路1

2. 局所探索:完成した答えを少しずつ変える

有効な答えを一つ作れて、その一部分を変更しながら改善したい。

局所探索は、現在の答えを少しずつ変えて、よい答えを探す方法です。仕事の担当を移したり交換したりする例で、改善だけを採用する山登りから、悪化も確率で採用する焼きなましへ進みます。

完成した答えを一つ持つ↓変更 → 評価 → 採用または取消し↻ 同じ処理を繰り返す

共通準備で作った担当表を出発点にします。各試行でも全仕事の担当が決まった状態を持ち、その一部を変更します。

入口:共通準備 1.1〜1.8の状態・制約・評価・答えの保存。共通準備の後、この経路の最初の章から始められます。

  1. 2.1 制約に合う近傍を用意する変更の種類を決める
  2. 2.2 山登り法を、コピーして試す形で組む山登りを動かす
  3. 2.3 試した変更を、元どおりに戻す変更を取り消す
  4. 2.4 変わった分だけを計算する差分で評価する
  5. 2.5 焼きなまし法で、悪化する変更も受け入れる焼きなましへ広げる
  6. 2.6 処理ごとに測り、ボトルネックを見つける処理時間を測る
  7. 2.7 焼きなましの実装テンプレート実装テンプレートで完成を確認する

到達点:最良の答えを保存しながら探索を回し、採否・差分・取消しの正しさと処理時間を確認できる。

経路1を始める → 三つの経路を見比べる

経路1 局所探索 / 1段階目・全7段階

2.1 制約に合う近傍を用意する

今回変わる条件参照:1.8 の問題設定
変更後の問題設定と具体例を確認する

六つの仕事を三人に分け、負担の偏りを小さくする

共通準備の仕事割当を使います。基本ルールの読み直しは不要ですが、追加する件数制約を確認してください。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。演習の「各人2件」を有効にすると、各人が担当する仕事数もちょうど2件に制限します。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品一仕事の移動と二仕事の交換を試し、条件を守れる候補かを確認します。ここでは一手を作るところまでです。

近傍は一回の変更で作る候補の集まりです。制約を保ち、別の答えへ進める変更を選びます。

図:同じ制約でも、使える近傍は異なる

変更前

A:8,7 / B:6,5 / C:4,3
仕事数 (2,2,2)

7をAからCへ移動

A:8 / C:4,3,7
仕事数 (1,2,3)
× 個数の制約を破る

Aの7とCの4を交換

A:8,4 / C:7,3
仕事数 (2,2,2)
✓ 個数の制約を保つ

どちらも仕事の重複・欠落は起こしません。追加された個数制約に対して、使える変更が違います。

1. 一手を小さなデータで表す

移動は (item, destination)、交換は (item, other) とします。一手を状態と別に表すと、生成・適用・記録を分けられます。重さ7と4の交換なら、Aの負荷は15→12、Cは7→10になります。

2. 制約を保つ変更を選ぶ

「各人ちょうど2件」では、別の人への移動は必ず不正ですが、交換は件数を保ちます。不正な候補ばかり作ると、検査が正しくても探索は進みません。

負荷の上限などは交換でも破ることがあります。近傍が自動的に保つ条件と、変更後に検査する条件を分けます。

注目コード:移動と交換を切り替える初期設定の抜粋 · 左は完成コードの行番号
vector<int> candidate = current;
if (swap_move) swap(candidate[item], candidate[other]);
else candidate[item] = destination;

ここを見る:交換は二仕事の担当者を入れ替えるため、各人の担当件数を保てます。移動は一要素だけを書き換えるので、件数が変わる場合があります。

完成コードの L22–24 を見る →

3. 生成と採用を分けて確かめる

演習はコピーに一手を適用し、有効か調べるところまでです。評価と採用は2.2でつなぎます。

同じ仕事同士・同じ担当者の仕事同士の交換や、同じ担当者への移動は無変化です。検証では試し、探索では必要に応じて生成から除きます。

注目コード:作った候補の件数を調べる初期設定の抜粋 · 左は完成コードの行番号
vector<int> counts(3, 0);
for (int person : candidate) ++counts[person];
bool allowed = true;
if (exact_two) {
    for (int count : counts) if (count != 2) allowed = false;
}

ここを見る:候補生成と合法性の判定は別処理です。exact_two=trueなら、各人2件でない候補を採用へ渡しません。

完成コードの L25–30 を見る →

ここまでの確認

「一人ちょうど2個」を守るため、不正な移動を全て棄却するだけで探索は進みますか?

答えと理由を見る

別の担当者への移動は全て棄却され、同じ担当者への移動は状態を変えないため、進みません。個数を保つ交換など、別の近傍が必要です。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点1.5の一手の適用を使い、現在の答えからどんな候補を作るかを定義します。

用意するデータと処理
一手
移動なら仕事と移動先、交換なら二つの仕事。
候補と検査
currentをコピーして変更し、人数などの制約を調べる。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 移動だけをコピー上で試す

    候補をcurrentからコピーし、一仕事の担当を変えます。変更方法の選択と、候補へ適用する処理を分けて書きます。

    ここで確認試すだけではcurrentは変わらない。

  2. 2. 人数制約を検査へ加える

    候補から担当人数を数え、「各人2件」の場合に移動が拒否されることを確認します。

    ここで確認合法率が低い原因を、評価値でなく人数の違反として説明できる。

  3. 3. 交換を別の分岐として足す

    二仕事の担当者を交換する操作を追加します。人数が保たれても、他の制約があれば共通の検査へ通します。

    ここで確認移動で行けない担当変更へ、人数を保ったまま進める。

次に足すなら:近傍の混合比を導入する

候補を生成する入口だけで、移動と交換を選ぶ割合を変更します。種類ごとに試行数・合法数・採用数を数えます。

追加後の確認:変更の種類を増やした効果と、不正な候補が増えただけの場合を区別できる。

完成コードで見る場所:swap_move:変更の種類の切替 / counts:制約を検査する集計

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

近傍
現在の状態から、決めた変更規則で作る候補の集まり。規則の選び方で到達できる状態が変わる。
制約を保つ変更
有効な状態に適用すれば、その制約が自動的に保たれる変更。交換は各人の仕事数を保つ。

小例の補足

仕事を一つ移す近傍と、二つの仕事の担当を交換する近傍を比べます。「一人ちょうど2個」の制約を足すと、別の人への移動は使えませんが、交換は使えます。

この章で扱う実装の範囲

  • Move の種類と必要な引数
  • 移動・交換・挿入・区間反転の候補生成
  • 変更に関係する制約と更新範囲
  • 候補が空のときの処理と近傍ごとの試行数

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.8 探索中の状態と、提出する最良解を分ける

理解できたら:次に広げること

訪問順の区間反転や木の辺の交換も、状態と制約に合わせて近傍を設計する例です。それぞれの題材で更新箇所を具体化します。

別の問題でも使うなら

訪問順の二地点を交換すれば、各地点が一度ずつ現れる性質は保たれます。ただし訪問の順序制約まで保てるとは限りません。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

2.1 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

最初の設定の交換では、全員二つのままです。移動へ切り替えると、移動元は一つ、移動先は三つになります。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void print_array(const char* name, const vector<int>& a) {
    cout << name << "=";
    for (int i = 0; i < int(a.size()); ++i) {
        if (i > 0) cout << ",";
        cout << a[i];
    }
    cout << "\n";
}

int main() {
    const bool swap_move = true;
    const int item = 1;
    const int other = 4;
    const int destination = 2;
    const bool exact_two = true;
    vector<int> current = {0, 0, 1, 1, 2, 2};
    vector<int> candidate = current;
    if (swap_move) swap(candidate[item], candidate[other]);
    else candidate[item] = destination;
    vector<int> counts(3, 0);
    for (int person : candidate) ++counts[person];
    bool allowed = true;
    if (exact_two) {
        for (int count : counts) if (count != 2) allowed = false;
    }
    print_array("before", current);
    print_array("candidate", candidate);
    print_array("counts", counts);
    cout << "allowed=" << allowed << "\n";
    return 0;
}

経路1 局所探索 / 2段階目・全7段階

2.2 山登り法を、コピーして試す形で組む

今回変わる条件参照:2.1 の問題設定
変更後の問題設定と具体例を確認する

六つの仕事を三人に分け、負担の偏りを小さくする

題材は同じ仕事割当ですが、初期設定では「各人ちょうど2件」の制約を外します。変更後の条件を確認し、一手の確認を繰り返す探索へつなぎます。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。演習の「各人2件」を有効にすると、各人が担当する仕事数もちょうど2件に制限します。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品移動・交換で候補を作り、条件を守り二乗和が減る場合だけ採用する探索を組みます。

山登り法は、今の答えを少し変え、よくなる変更を採用することを繰り返す探索です。この章では悪化と同点を採用せず、コピー上で候補を試します。

図:候補を試し、採用時だけ現在の状態を更新する

現在の状態

評価値 395
ここから毎回コピーする

7をAからCへ:381

改善 → 採用
現在の状態と値を 381 へ

5をBからCへ:405

悪化 → 棄却
現在の状態と値は 395 のまま

図の二つの候補は、どちらも同じ初期状態395から作った例です。実際の探索では、採用後の状態から次の候補を作ります。

1. コピーに一手を試す

「山登り」という名前でも、この最小化問題では評価値を下げる向きに進みます。現在の状態をコピーし、変更・検査・全評価を行います。allowed && value < current_value なら採用し、同点は棄却します。コピーと全計算は遅くても検証しやすいため、高速化の比較用に残します。

注目コード:元の状態を残して候補を作る初期設定の抜粋 · 左は完成コードの行番号
vector<int> candidate = current;
if (move[0] == 0) candidate[move[1]] = move[2];
else swap(candidate[move[1]], candidate[move[2]]);

ここを見る:moveは「種類・仕事ID・移動先または交換相手」です。candidateだけを変更するため、棄却してもcurrentを戻す処理は要りません。

完成コードの L50–52 を見る →

2. 候補を順番に調べる

仕事0〜5と移動先A・B・Cの18通り、交換ありなら続けて仕事ペア15通りを試します。一巡したら戻り、指定回数で止めます。

改善はその場で採用するので、次の候補は更新後の状態から作ります。全近傍から最良の一手を選ぶ方式ではありません。順序を固定して動きを追います。

3. 採用時だけ状態と値を更新する

採用ならcurrentとcurrent_valueを両方置き換え、棄却なら触れません。山登り法では現在値は増えないので、増えたら採否か更新を疑います。2.5のように悪化も許す探索では、最良解を別に保存します。

注目コード:合法で改善する場合だけ採用する初期設定の抜粋 · 左は完成コードの行番号
bool accepted = allowed && value < current_value;
if (accepted) {
    current = candidate;
    current_value = value;
}

ここを見る:allowedで制約、valueで改善を判定します。採用した場合だけ、状態currentと対応する評価値を同時に更新します。

完成コードの L62–66 を見る →

4. 回数を増やす前に近傍を見る

「各人2件」で交換を無効にすると、有効な移動がなく395から進みません。交換を有効にして比べてください。試行数を増やす前に、有効で状態を変えられる候補があるかを確認します。

使っている近傍のどの有効な候補も改善しない状態を局所最適と呼びます。全体で最もよい答えとは限らず、別の変更方法や2.5の焼きなまし法を試す理由になります。

ここまでの確認

候補を棄却した後に、次の候補を作る元の状態と評価値には、どのような条件が必要ですか?

答えと理由を見る

棄却した変更が残らず、その候補を試す前の状態・評価値と一致していることです。コピー方式なら、採用時以外にcurrentへ触らないことで保てます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点一手の適用、候補の検査、全評価を用意し、最初は状態をコピーする山登りループへつなぎます。

用意するデータと処理
current / current_value
採用済みの答えと評価値。
candidate
一試行だけ使うコピー。棄却したら捨てる。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 一試行を直列に書く

    コピー→一手を適用→検査→全評価→改善なら採用、をmainに一回だけ書きます。不正な候補は評価比較を飛ばします。

    ここで確認改善候補では表と値が両方更新され、悪化候補では両方とも元のまま。

  2. 2. 固定列の一手で繰り返す

    検証しやすいmovesを順に試すループへ囲みます。この段階では乱数や時間制限を入れず、同点は棄却すると決めます。

    ここで確認同じ試行回数なら採否の列を再現でき、現在値は増えない。

  3. 3. 変更の種類を追加する

    交換をmovesへ加え、採用処理は共通のままにします。各人2件の制約を有効にして、合法な候補が得られるか確認します。

    ここで確認移動だけで止まった場合でも、交換を加えると探索できる。

次に足すなら:乱数と時間終了を足す

候補を選ぶ箇所をseed付き乱数へ置き換えます。固定回数で再現を確認してから、外側の終了条件を時計へ変えます。

追加後の確認:時間版も初期解を必ず保存し、期限が短くても有効な答えを返す。

完成コードで見る場所:vector<vector<int>> moves:一手の一覧 / if (accepted):候補を確定する場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

山登り法
今の答えを少し変え、よくなる変更を採用することを繰り返す探索。この章では同点も棄却する。
局所最適
使っている近傍のどの有効な候補も改善しない状態。全体の最適解とは限らない。
候補と現在の状態
candidateは採用前の試行用、currentは次の候補を作る基準。採用時だけcandidateをcurrentへ反映する。

小例の補足

1.3の偏った担当から始めます。重さ7をAからCへ移す候補は395→381なので採用します。重さ5をBからCへ移す候補は395→405なので棄却します。

この章で扱う実装の範囲

  • current をコピーした candidate
  • 候補への変更と objective_full
  • 改善時の採用と best の更新
  • 停止条件、試行数・改善数のログ

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:2.1 制約に合う近傍を用意する

理解できたら:次に広げること

正しく動くこの版を基準に、2.3で取消し、2.4で差分計算を導入します。高速化の前後で同じ変更列を試し、結果が一致するか確認できます。

別の問題でも使うなら

経路や配置でも、コピー・変更・検査・評価・採用の流れは共通です。状態の表現と近傍を題材に合わせて差し替えます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

2.2 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

追加ルールなしでは、移動だけなら18回でも60回でも値365です。交換も使う60回では363へ進みます。次に「一人ちょうど二つ」を加えると、移動だけでは395のまま、交換を使う60回では363です。回数だけでなく、変更の種類も大切だと確かめられます。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void print_array(const char* name, const vector<int>& a) {
    cout << name << "=";
    for (int i = 0; i < int(a.size()); ++i) {
        if (i > 0) cout << ",";
        cout << a[i];
    }
    cout << "\n";
}

const vector<int> weight = {8, 7, 6, 5, 4, 3};

vector<int> get_load(const vector<int>& assign) {
    vector<int> load(3, 0);
    for (int job = 0; job < int(weight.size()); ++job) {
        load[assign[job]] += weight[job];
    }
    return load;
}

int score(const vector<int>& assign) {
    vector<int> load = get_load(assign);
    int value = 0;
    for (int total : load) value += total * total;
    return value;
}

int main() {
    const int trials = 60;
    const bool with_swap = false;
    const bool exact_two = false;
    vector<int> current = {0, 0, 1, 1, 2, 2};
    int current_value = score(current);
    // {種類, 仕事番号, 移動先または交換相手}。0は移動、1は交換。
    vector<vector<int>> moves;
    for (int job = 0; job < 6; ++job) {
        for (int person = 0; person < 3; ++person) moves.push_back({0, job, person});
    }
    if (with_swap) {
        for (int a = 0; a < 6; ++a) {
            for (int b = a + 1; b < 6; ++b) moves.push_back({1, a, b});
        }
    }
    for (int step = 0; step < trials; ++step) {
        vector<int> move = moves[step % moves.size()];
        vector<int> candidate = current; // コピー側だけを変更する。
        if (move[0] == 0) candidate[move[1]] = move[2];
        else swap(candidate[move[1]], candidate[move[2]]);
        vector<int> counts(3, 0);
        for (int person : candidate) ++counts[person];
        bool allowed = true;
        if (exact_two) {
            for (int count : counts) if (count != 2) allowed = false;
        }
        // -1はルール違反で計算しない目印。答えの値として比べない。
        int value = allowed ? score(candidate) : -1;
        int before = current_value;
        bool accepted = allowed && value < current_value;
        if (accepted) {
            current = candidate;
            current_value = value;
        }
        cout << "step=" << step + 1 << "," << before << "," << value << ","
             << allowed << "," << accepted << "," << current_value << "\n";
    }
    print_array("assign", current);
    cout << "score=" << current_value << "\n";
    return 0;
}

経路1 局所探索 / 3段階目・全7段階

2.3 試した変更を、元どおりに戻す

前の章と同じ仕事割当です。基本ルールの読み直しは不要です。

  • この演習では「各人2件」の追加制約は使いません。
必要なら、問題設定を読み直す

六つの仕事を三人に分け、負担の偏りを小さくする

同じ仕事割当の、一手を試して元へ戻す部分を取り出します。基本の問題設定を理解していれば、読み直す必要はありません。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。一人が担当する仕事数の上限は設けません。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品担当表と負荷を直接変更し、不採用のときに両方を元へ戻す部品を作ります。

undo(取消し)は、試した変更を元に戻す処理です。現在の状態を直接変更し、不採用なら記録した旧値を書き戻すことで、全体のコピーを省きます。

図:担当表と負荷を、同じ時点へ戻す

① 変更前

担当 0,0,1,1,2,2

負荷 15,11,7

担当Aと、A・Cの負荷を記録する。

② 仕事1をCへ

担当 0,2,1,1,2,2

負荷 8,11,14

担当表と負荷を一緒に更新する。

③ 棄却して取消し

担当 0,0,1,1,2,2

負荷 15,11,7

保存した値で両方を戻す。

②から③へ戻すとき、担当表だけを戻すと負荷が食い違います。図の①と③が一致することを検査します。

1. 直接変更し、必要なら戻す

2.2のコピーを省き、現在の状態を直接変更します。不採用なら旧値を書き戻します。今回は負荷も保存するため、常に load == get_load(assign) を保ちます。

2. 旧値を記録してから変更する

仕事1をAからCへ移すなら、Undo に仕事ID・移動元・移動先・二人の旧負荷を保存します。

変更:assign[1]: 0→2、load[0]: 15→8、load[2]: 7→14

戻す値:担当0、Aの負荷15、Cの負荷7

逆向きに加減算する方法もありますが、まずは旧値をそのまま書き戻す形にします。

注目コード:旧値を保存してから書き換える初期設定の抜粋 · 左は完成コードの行番号
const int from = state.assign[item];
Undo log{item, from, to, state.load[from], state.load[to]};
state.assign[item] = to;
state.load[from] -= weight[item];
state.load[to] += weight[item];
return log;

ここを見る:logには変更前の担当者と二人分の負荷を保存します。書き換え後に記録すると、元へ戻すための値を失います。

完成コードの L44–49 を見る →

3. 棄却なら全項目を戻す

採用なら変更を保持し、棄却ならundoします。演習は取消しの確認用なので、毎回戻します。「負荷も戻す」を外すと表だけ戻り、負荷は8・11・14のままです。表から再計算して不一致を検出します。

注目コード:担当と集計値を両方戻す初期設定の抜粋 · 左は完成コードの行番号
void undo(State& state, const Undo& log, bool restore_load) {
    state.assign[log.item] = log.from;
    if (restore_load) {
        state.load[log.from] = log.old_from_load;
        state.load[log.to] = log.old_to_load;
    }
}

ここを見る:通常はrestore_load=trueです。falseにすると担当表だけが戻り、補助情報loadとの不一致を観察できます。

完成コードの L52–59 を見る →

4. 整合性と復元を別に検査する

consistent は表と負荷の整合性、restored は変更前との完全一致です。両方1を確認します。別の有効な表になっていても、復元成功ではありません。

before の全コピーは検証用です。異なる担当者への移動でも一致を確認してから、探索ではUndoだけを使います。複数手は逆順に戻し、評価値などの補助情報を追加したら、それらも取消し対象にします。

ここまでの確認

担当表だけを戻し、各人の合計を戻し忘れると、何が起きますか?

答えと理由を見る

担当表と合計が食い違います。担当表から計算すると395なのに、保存した合計からは381になる、といった誤りが起きます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点2.2のコピー版を比較用に残し、一手の適用と取消しだけを先に往復させます。

用意するデータと処理
State
担当表と保存した負荷。後で補助情報を足したら、ここへまとめる。
Undo
書き換える前の担当者と負荷。適用前に作って返す。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 書き換える欄を列挙する

    applyが触るassign[item]、load[from]、load[to]を列挙し、変更前の値をUndoへ入れてから書き換えます。

    ここで確認記録の作成が変更後になっていない。移動元と移動先が同じ場合も扱える。

  2. 2. 取消しを単独で検証する

    undoで記録した値を代入し直します。探索へ入れる前に、各一手についてapply→undoを実行します。

    ここで確認担当表・負荷が元のコピーと一致し、get_loadによる全計算とも一致する。

  3. 3. 棄却側へ接続する

    コピー版の候補作成を直接適用に置き換え、検査失敗と評価での棄却の両方からundoを呼びます。採用時は変更を残します。

    ここで確認同じ一手列に対する採否と最終状態がコピー版と一致する。

次に足すなら:ハッシュなどの補助情報を追加する

新しい情報を書き換える場所と、Undoへ保存して戻す場所を同時に追加します。状態を比較する検査にもその欄を足します。

追加後の確認:担当表が同じでも補助情報だけ壊れたケースを検出できる。

完成コードで見る場所:struct Undo:取消し用の記録 / void undo:記録を戻す処理

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

undo
変更前に記録した値を使い、試行前の状態へ戻す処理。担当表だけでなく、負荷や評価値などの保存情報も対象になる。
保存した負荷の整合性
loadはassignから再計算した負荷と一致している必要がある。適用後と取消し後の両方で確認する。

小例の補足

ここからは、各人の負荷も配列へ保存します。重さ7の仕事をAからCへ移す前に、元の担当Aと、A・Cの負荷15・7を記録します。取り消すときは、担当と二人の負荷を元へ戻します。

この章で扱う実装の範囲

  • Undo:上書きする値の変更前の記録
  • apply の返り値としての Undo
  • undo(state, log)
  • 状態比較と全再計算による照合

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:2.2 山登り法を、コピーして試す形で組む

理解できたら:次に広げること

変更した値が増えた場合も、その全てを戻す必要があります。複数の手を戻すときは、最後の手から逆順に戻します。

別の問題でも使うなら

対象を覆う問題では、担当者の合計の代わりに「何個の候補が覆うか」という数も戻します。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

2.3 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

仕事1をCへ移し、負荷の復元を外してください。担当表は戻っても負荷は8・11・14のままです。次に同じ担当者Aへの移動を試すと、不具合が表れません。状態が変わるケースも検証に必要です。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void print_array(const char* name, const vector<int>& a) {
    cout << name << "=";
    for (int i = 0; i < int(a.size()); ++i) {
        if (i > 0) cout << ",";
        cout << a[i];
    }
    cout << "\n";
}

const vector<int> weight = {8, 7, 6, 5, 4, 3};

vector<int> get_load(const vector<int>& assign) {
    vector<int> load(3, 0);
    for (int job = 0; job < int(weight.size()); ++job) {
        load[assign[job]] += weight[job];
    }
    return load;
}

int score(const vector<int>& assign) {
    vector<int> load = get_load(assign);
    int value = 0;
    for (int total : load) value += total * total;
    return value;
}

struct State {
    vector<int> assign;
    vector<int> load;
};

struct Undo {
    int item, from, to;
    int old_from_load, old_to_load;
};

Undo apply(State& state, int item, int to) {
    // この教材の設定ではitemとtoは範囲内。記録は書き換え前に作る。
    const int from = state.assign[item];
    Undo log{item, from, to, state.load[from], state.load[to]};
    state.assign[item] = to;
    state.load[from] -= weight[item];
    state.load[to] += weight[item];
    return log;
}

void undo(State& state, const Undo& log, bool restore_load) {
    state.assign[log.item] = log.from;
    // falseは更新漏れを観察するための、意図した不具合。
    if (restore_load) {
        state.load[log.from] = log.old_from_load;
        state.load[log.to] = log.old_to_load;
    }
}

int main() {
    const int item = 1;
    const int destination = 2;
    const bool restore_load = true;
    vector<int> initial = {0, 0, 1, 1, 2, 2};
    State state{initial, get_load(initial)};
    const State before = state; // 検証用。探索で毎回コピーする必要はない。
    print_array("before_assign", state.assign);
    print_array("before_load", state.load);
    Undo log = apply(state, item, destination);
    print_array("trial_assign", state.assign);
    print_array("trial_load", state.load);
    undo(state, log, restore_load);
    print_array("after_assign", state.assign);
    print_array("after_load", state.load);
    cout << "consistent=" << (state.load == get_load(state.assign)) << "\n";
    cout << "restored=" << (state.assign == before.assign && state.load == before.load) << "\n";
    return 0;
}

経路1 局所探索 / 4段階目・全7段階

2.4 変わった分だけを計算する

前の章と同じ入力・制約・目的値です。設定の読み直しは不要です。

必要なら、問題設定を読み直す

六つの仕事を三人に分け、負担の偏りを小さくする

入力・制約・目的値は前の章と同じです。問題設定を読み直す必要はありません。今回は一手で変わる評価値だけを計算します。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。一人が担当する仕事数の上限は設けません。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品一仕事の移動について、二乗和の変化だけを計算し、変更前後の全計算と照合します。

差分評価は、一手で影響を受ける項だけから評価値の変化を求める方法です。仕事を移す二人分だけを計算し、全計算と一致するか確かめます。

図:全ての項を計算せず、変わる二項だけを見る

移動元A:15 → 8

8² − 15²
−161

B:11 → 11

11² − 11²
0

移動先C:7 → 14

14² − 7²
+147

Δ = −161 + 147 = −14
更新後の値 = 395 − 14 = 381

移動元の改善だけを見ると、移動先の悪化を見落とします。影響する全ての項を含め、全計算と照合します。

1. 変わる二項だけを計算する

重さwを担当者aからbへ移すと、二人の負荷だけが変わります。a≠bなら、二乗和の差は次の式です。

Δ = {(La−w)²−La²} + {(Lb+w)²−Lb²}

他の項は相殺されます。new_value = old_value + delta と更新し、Δ<0を改善とします。

注目コード:変わる二人分だけ差を取る初期設定の抜粋 · 左は完成コードの行番号
if (from == to) return 0;
long long delta = square(load[from] - w) - square(load[from]);
if (include_destination) {
    delta += square(load[to] + w) - square(load[to]);
}
return delta;

ここを見る:wは移す仕事の重さです。移動元の減少と移動先の増加を足します。include_destination=falseは後者を落とす不具合の実験です。

完成コードの L37–43 を見る →

2. 同じ担当者への移動を分ける

a=bなら何も変わらずΔ=0です。二人分として上の式へ代入せず、delta_move の先頭で0を返します。交換や反転でも、同じ対象や短い区間を確認します。

3. 全計算と照合する

予測値 predicted_after と全計算 full_after を比べ、改善・悪化・無変化の全てで delta_ok=1 を確かめます。

移動先の項を省くと、初期例はΔ=−161、予測234となり、正しい381とずれます。小さい入力で毎回照合してから、本番用の全計算を減らします。

注目コード:差分を使わない値と照合する初期設定の抜粋 · 左は完成コードの行番号
cout << "predicted_after=" << before + delta << "\n";
cout << "full_after=" << full_after << "\n";
cout << "delta_ok=" << (before + delta == full_after) << "\n";

ここを見る:full_afterは変更後の担当表をscoreで全計算した値です。差分からの予測と一致して初めて、高速な計算を探索へ組み込みます。

完成コードの L62–64 を見る →

4. 一試行全体の費用を見る

N仕事・M人の全評価はO(N+M)。負荷を保存した一仕事の移動は、差分・更新ともO(1)です。ただし区間反転などは、距離差分がO(1)でも配列の更新に時間がかかります。

2.3へ組み込むなら、差分を求めてから変更し、棄却時は表と負荷を戻して評価値も元のままにします。

差分式が全計算と一致することを確認できたら、2.6で一試行全体の処理を計測します。状態コピーなど、評価以外に残る処理も比べます。

ここまでの確認

Bの項11²を計算し直さなくてよいのは、なぜですか?

答えと理由を見る

Bの負荷は変わらず、差分を取るとその項が相殺されるためです。更新対象をA・Cに限定できる根拠になります。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点1.6のscoreを正解確認用に残し、候補の採否を変えずに評価計算だけを差分へ置き換えます。

用意するデータと処理
load
変更前の担当別負荷。
delta_move
変更前の負荷と一手を読み、変更後−変更前の差を返す。状態は変えない。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 影響する項だけ取り出す

    移動元と移動先の二乗項を列挙し、それぞれ新しい項−古い項を足します。同じ担当者への移動は先に0を返します。

    ここで確認重さ7をAからCへ動かす差が−14になる。

  2. 2. 探索へ入れる前に全照合する

    候補をコピー上に作り、score(candidate)−score(current)とdelta_moveを全ての仕事・移動先で比較します。

    ここで確認移動先の項を外すと不一致を検出し、元と先が同じ場合は差0になる。

  3. 3. 評価を呼ぶ場所だけ置き換える

    一致したら、探索中の候補評価をcurrent_value+deltaへ置き換えます。採用時だけ保存値へdeltaを加え、棄却時は加えません。

    ここで確認状態の全評価、保存した評価値、採否が基準版と一致する。

次に足すなら:交換の差分を足す

一人から引く重さと加える重さをまとめ、影響する担当者の集合から各項を一回だけ計算します。単純な二移動の差を同じ古い状態で足さないようにします。

追加後の確認:同じ担当者の二仕事を交換する場合と、重さが等しい場合も全計算と一致する。

完成コードで見る場所:delta_move:差だけを求める関数 / full_after:独立した全計算による照合

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

差分評価
変更で影響を受ける項だけから評価値の変化を求める方法。どの項が変わるかを特定することが実装の中心になる。
delta / Δ
この教材では変更後−変更前と定義する。最小化なのでΔ<0が改善。差分の符号と採用条件をそろえる。

小例の補足

重さ7の仕事をAからCへ移すと、Aは15→8、Cは7→14になります。評価値の差分は (8²+14²)−(15²+7²)=−14。元の395へ加えると381です。

この章で扱う実装の範囲

  • 全評価を比較用に残す
  • affected:変化する項の列挙と重複排除
  • delta = 新しい寄与 − 古い寄与
  • 採用後の集計値の更新と、再計算との照合
  • 目的値を項へ分解した依存関係、影響する項の重複排除

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:2.3 試した変更を、元どおりに戻す

理解できたら:次に広げること

別の題材への応用は、この計算が説明できてから開きます。速い計算を作った後も、全計算を答え合わせ用に残します。

別の問題でも使うなら

経路では変わった道の費用を、被覆では覆う数が変わる対象を調べます。同じ数式を使うのではなく、変わる部分を見つける手順を使います。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

2.4 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

仕事1をCへ移すと、移動元の寄与は−161、移動先は+147で、差分は−14です。移動先を省くと予測値234になり、全計算381と一致しません。同じ担当者への移動では差分0の専用分岐が働きます。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void print_array(const char* name, const vector<int>& a) {
    cout << name << "=";
    for (int i = 0; i < int(a.size()); ++i) {
        if (i > 0) cout << ",";
        cout << a[i];
    }
    cout << "\n";
}

const vector<int> weight = {8, 7, 6, 5, 4, 3};

vector<int> get_load(const vector<int>& assign) {
    vector<int> load(3, 0);
    for (int job = 0; job < int(weight.size()); ++job) {
        load[assign[job]] += weight[job];
    }
    return load;
}

int score(const vector<int>& assign) {
    vector<int> load = get_load(assign);
    int value = 0;
    for (int total : load) value += total * total;
    return value;
}

long long square(long long x) { return x * x; }

long long delta_move(const vector<int>& load, int from, int to,
                     int w, bool include_destination) {
    // 同じ担当者への移動では負荷は変わらない。
    if (from == to) return 0;
    long long delta = square(load[from] - w) - square(load[from]);
    // falseは移動先の寄与を落とす、意図した不具合。
    if (include_destination) {
        delta += square(load[to] + w) - square(load[to]);
    }
    return delta;
}

int main() {
    const int item = 1;
    const int destination = 2;
    const bool include_destination = true;
    vector<int> assign = {0, 0, 1, 1, 2, 2};
    const vector<int> load = get_load(assign);
    const long long before = score(assign);
    const long long delta = delta_move(load, assign[item], destination,
                                      weight[item], include_destination);
    vector<int> candidate = assign;
    candidate[item] = destination;
    const long long full_after = score(candidate); // 差分とは独立に全計算。
    print_array("before_load", load);
    print_array("after_load", get_load(candidate));
    cout << "before=" << before << "\n";
    cout << "delta=" << delta << "\n";
    cout << "predicted_after=" << before + delta << "\n";
    cout << "full_after=" << full_after << "\n";
    cout << "delta_ok=" << (before + delta == full_after) << "\n";
    return 0;
}

経路1 局所探索 / 5段階目・全7段階

2.5 焼きなまし法で、悪化する変更も受け入れる

前の章と同じ問題です。探索の出発点だけ変えます。基本ルールの読み直しは不要です。

必要なら、問題設定を読み直す

六つの仕事を三人に分け、負担の偏りを小さくする

問題の入力・制約・目的値は同じなので、読み直す必要はありません。採用判定を変える効果が見えるよう、探索の出発点だけを変えます。

何をする問題か

六つの仕事を三人に分担してもらいます。重い仕事ほど担当者の負担が大きくなるため、できるだけ負担が偏らない分け方を探します。仕事を行う順序や開始時刻は、この問題では決めません。

仕事IDは仕事を区別する番号、重さはその仕事の負担です。担当者の番号とは別物です。入力として仕事の重さが決まっており、答えとして各仕事の担当者を選びます。

具体例で使うデータ
仕事ID重さある答えでの担当
08A(担当者ID 0)
17A(0)
26B(1)
35B(1)
44C(2)
53C(2)
答えと評価を、具体例で追う
  1. 担当表を読む

    この表の答えは[0,0,1,1,2,2]です。配列の左から仕事0、仕事1、…の担当者を並べています。例えば二つ目の0は「重さ0」ではなく「仕事1をAへ任せる」という意味です。

  2. 負荷と評価値を求める

    Aは8+7=15、Bは6+5=11、Cは4+3=7を担当します。評価値は15²+11²+7²=395です。全仕事の重さの合計33は配り方によらないため、負荷の総和だけでは答えのよさを比べられません。

  3. 別の答えと比べる

    仕事1をAからCへ移すと答えは[0,2,1,1,2,2]、負荷は(8,11,14)、評価値は381になります。通常のルールでは395よりよい答えです。さらに8+3、7+4、6+5と分ければ全員11、評価値363にできます。

ここで間違えやすいこと全仕事を一人ずつへ割り当てることが必須です。担当が欠けた表や、存在しない担当者3を含む表は採点しません。「各人2件」の追加条件を使う章では、上の一仕事の移動はAが1件・Cが3件になるため不正です。

ルールと、この章での扱いを確認する
与えられるもの
仕事ID 0〜5の重さは順に8・7・6・5・4・3。担当者A・B・CのIDは0・1・2です。
決めること・求めるもの
仕事ごとの担当者を並べた六要素の表です。例えば[0,0,1,1,2,2]なら、Aが8と7、Bが6と5、Cが4と3を担当します。
守る条件
各仕事をちょうど一人に任せます。仕事を分割したり、担当を書き忘れたりしてはいけません。一人が担当する仕事数の上限は設けません。
評価・確認の基準
各人の負荷をL_A・L_B・L_Cとし、S=L_A²+L_B²+L_C²を小さくします。この表では負荷(15,11,7)、S=395です。

この章で作る部品一仕事の担当を変える探索に、温度に応じて悪化も採用する判定を加えます。提出する表は探索中の最良を保存します。

焼きなまし法は、悪化する変更も確率で採用し、その採用しやすさを徐々に下げながら探す方法です。採用しやすさを調整する値を温度と呼び、高温で広く試してから低温で改善を重ねます。

図:一度悪化する経路を通って、よりよい答えへ進む

出発点:365

A:8,3 / B:7,5 / C:6,4
負荷 (11,12,10)

5をBからCへ:395

A:8,3 / B:7 / C:6,4,5
Δ = +30

4をCからBへ:363

A:8,3 / B:7,4 / C:6,5
Δ = −32

現在値:365 → 395 → 363
保存する最良値:365 → 365 → 363

移動だけでは出発点365から直接改善できません。この図は悪化を一度受け入れる経路の例です。乱数を使う実験が必ずこの二手を選ぶわけではありません。

1. 悪化を通って別の答えへ進む

初期値365は一仕事の移動では改善しません。しかし重さ5をBからCへ移して395に悪化した後、重さ4をCからBへ移すと363になります。このような経路も試すため、悪化を確率で採用します。成功が保証されるわけではありません。

2. 温度から採用確率を求める

Δ=変更後−変更前、温度T>0とします。

Δ≤0なら採用。Δ>0なら p=exp(−Δ/T)。

一様乱数 u∈[0,1) を引き、u<pなら採用。

Δ=30、T=20ならp≈0.223。u=0.1なら採用、0.7なら棄却です。評価式を100倍にしたら、同じ確率を保つ温度も100倍にします。

2.2と異なり同点は採用します。「悪化も採用」を外した比較でも、同点の扱いは変えません。

注目コード:差分から採用確率を決める初期設定の抜粋 · 左は完成コードの行番号
const int delta = delta_move(current, item, to);
const double probability = delta <= 0 ? 1.0 :
    (allow_worse ? exp(-double(delta) / temperature) : 0.0);
const Undo log = apply(current, item, to);
const bool take = u < probability;

ここを見る:uは[0,1)の乱数です。改善・同値は確率1、悪化は温度に応じた確率にします。この完成版は先に適用し、棄却時にundoします。

完成コードの L94–98 を見る →

3. 進捗に合わせて温度を下げる

試行の進捗rに対し T = T_start + (T_end − T_start) × r とし、初期設定は20→1です。序盤は悪化も受け入れ、終盤は改善を中心にします。温度の下げ方を冷却スケジュールと呼び、この例は直線的に下げます。

始終を同温度にすれば固定温度を試せます。まず固定回数で確認し、時間制限を使う版ではrを経過時間の割合へ替えます。

注目コード:試行の進捗で温度を下げる初期設定の抜粋 · 左は完成コードの行番号
const double progress = trials == 1 ? 0.0 : double(i) / (trials - 1);
const double temperature = start_temp + (end_temp - start_temp) * progress;

ここを見る:iは0始まりの試行番号です。最初と最後を指定温度に合わせ、1試行だけの場合も0除算を避けます。

完成コードの L92–93 を見る →

4. 取消し・差分・最良保存をつなぐ

差分を計算 → 旧値を記録して変更 → 採否判定、の順です。採用なら current_value += delta、棄却ならundoし、評価値は変えません。bestは改善時だけ更新するので、現在値が悪化しても最良解は残ります。

完成コードは毎回全計算とも照合し、consistent=1 を確認します。この検証を残した実行では、差分化の速度効果は測れません。

注目コード:採用・最良保存・取消しをつなぐ初期設定の抜粋 · 左は完成コードの行番号
if (take) {
    current_value += delta;
    if (delta > 0) ++accepted_worse;
    if (current_value < best_value) {
        best = current.assign;
        best_value = current_value;
    }
} else {
    undo(current, log);
}

ここを見る:悪化を採用してもbestは上書きしません。棄却時は状態をundoし、まだ変更していないcurrent_valueはそのまま残します。

完成コードの L99–108 を見る →

5. 条件をそろえて比べる

毎試行「仕事・移動先・採否」の三乱数を消費し、seedと回数を固定して再現します。120回・seed42・温度20→1では363、悪化なしでは365ですが、seedによっては改善しません。5.1で複数入力・seed・同じ予算で比較します。

確率だけを確かめるには温度と悪化幅の小実験を使えます。

ここまでの確認

悪い変更を試して今の値が増えたら、最良の答えも上書きしますか?

答えと理由を見る

上書きしません。今の答えは次を試すためのもの、最良の答えは最後に出すためのものです。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点改善限定の探索と最良解の保存を用意します。差分・取消しを使うなら、それぞれの照合を済ませておきます。

用意するデータと処理
採用判定
最小化の差delta、温度T、区間[0,1)の乱数uを受け取り採否を返す。
current / best
現在の探索位置と提出用の最良解を分ける。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 採用条件だけを差し替える

    delta≤0を採用し、悪化ならu<exp(−delta/T)で採用する処理を、以前の改善判定の場所へ置きます。まず正の固定温度で試します。

    ここで確認同じ悪化量なら温度が高い方が採用されやすく、符号を反転していない。

  2. 2. 採否後の状態と保存をつなぐ

    採用したら現在値を更新し、bestは最良時だけ更新します。棄却したら適用した変更を取り消します。

    ここで確認現在値が一時的に悪化しても、最良値は悪化しない。

  3. 3. 温度を進捗から計算する

    固定回数で乱数の消費順をそろえた後、進捗0〜1から温度を求めます。試行1回の除算と、終了温度が正であることも確認します。

    ここで確認同じseed・設定で採否を再現し、最終状態の全計算も一致する。

次に足すなら:実時間の温度へ変える

温度計算へ渡す進捗だけを「経過時間/探索時間」に変更し、0〜1へ収めます。最良解を返すための時間は探索予算から先に除きます。

追加後の確認:固定回数版を再現用に残し、時間版は複数seedの最終得点で比較できる。

完成コードで見る場所:probability:悪化を採る確率 / best_value:最良解を別に保つ場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

焼きなまし法
悪い変更も確率で受け入れながら探す方法。時間が進むにつれて受け入れにくくする使い方が基本です。
温度
悪い変更をどれだけ試すかを調整する数。高いほど受け入れやすくなります。

小例の補足

負荷11・12・10の状態は評価値365で、仕事を一つ移すだけでは改善しません。重さ5をBからCへ動かして395へ悪化した後、重さ4をCからBへ動かすと363へ改善できます。

この章で扱う実装の範囲

  • 最小化なら Δ = cost_new − cost_old の符号規約
  • 改善の採用と、悪化時の exp(-Δ/T)
  • 初期温度・終温度・進捗に応じた温度
  • current と best、受理率と改善率のログ

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:2.4 変わった分だけを計算する

理解できたら:次に広げること

本編では線形に温度を下げます。発展では幾何的な冷却、実行時間による進捗、悪化幅からの温度設定を比較します。必ず改善する方法ではありません。

別の問題でも使うなら

移動距離を比べる問題へ移すときは、値の大きさも変わります。温度の数字はそのまま流用せず、悪い変更が採用される割合を確かめます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの仕事を三人に分け、負担の偏りを小さくする — 問題設定・具体例を読む

2.5 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

既定の120回・seed42・温度20→1では最良値363になります。「悪化も採用」を外すと365のままです。seed1では既定の温度・回数でも365なので、悪化の採用が成功を保証しないことも確かめられます。一手送りで悪化の採用と棄却後の状態を見てください。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

#include <cstdint>
#include <cmath>
#include <iomanip>

void print_array(const char* name, const vector<int>& a) {
    cout << name << "=";
    for (int i = 0; i < int(a.size()); ++i) {
        if (i > 0) cout << ",";
        cout << a[i];
    }
    cout << "\n";
}

const vector<int> weight = {8, 7, 6, 5, 4, 3};

vector<int> get_load(const vector<int>& assign) {
    vector<int> load(3, 0);
    for (int job = 0; job < int(weight.size()); ++job) {
        load[assign[job]] += weight[job];
    }
    return load;
}

int score(const vector<int>& assign) {
    vector<int> load = get_load(assign);
    int value = 0;
    for (int total : load) value += total * total;
    return value;
}

struct Random {
    uint32_t x;
    uint32_t next() {
        x ^= x << 13;
        x ^= x >> 17;
        x ^= x << 5;
        return x;
    }
};

struct State {
    vector<int> assign;
    vector<int> load;
};
struct Undo {
    int item, from, to, old_from_load, old_to_load;
};
int delta_move(const State& s, int item, int to) {
    const int from = s.assign[item], w = weight[item];
    if (from == to) return 0;
    const int a = s.load[from], b = s.load[to];
    return (a-w)*(a-w) + (b+w)*(b+w) - a*a - b*b;
}
Undo apply(State& s, int item, int to) {
    const int from = s.assign[item];
    Undo log{item, from, to, s.load[from], s.load[to]};
    s.assign[item] = to;
    s.load[from] -= weight[item];
    s.load[to] += weight[item];
    return log;
}
void undo(State& s, const Undo& log) {
    s.assign[log.item] = log.from;
    s.load[log.from] = log.old_from_load;
    s.load[log.to] = log.old_to_load;
}

int main() {
    const int trials = 120;
    const uint32_t seed = 42;
    const double start_temp = 20, end_temp = 1;
    const bool allow_worse = true;
    vector<int> initial = {0, 1, 2, 1, 2, 0}; // 負荷11,12,10。評価値365。
    State current{initial, get_load(initial)};
    vector<int> best = initial;
    int current_value = score(initial), best_value = current_value;
    int accepted_worse = 0;
    bool consistent = true;
    Random rng{seed};
    cout << fixed << setprecision(6);
    // stepの列: 手数,仕事ID,移動先,差分,温度,採用確率,乱数,採用,current,best
    for (int i = 0; i < trials; ++i) {
        // 比較しやすくするため、改善手でも毎回3回の乱数を消費する。
        const int item = rng.next() % 6;
        const int to = rng.next() % 3;
        const double u = rng.next() / 4294967296.0;
        const double progress = trials == 1 ? 0.0 : double(i) / (trials - 1);
        const double temperature = start_temp + (end_temp - start_temp) * progress;
        const int delta = delta_move(current, item, to);
        const double probability = delta <= 0 ? 1.0 :
            (allow_worse ? exp(-double(delta) / temperature) : 0.0);
        const Undo log = apply(current, item, to);
        const bool take = u < probability;
        if (take) {
            current_value += delta;
            if (delta > 0) ++accepted_worse;
            if (current_value < best_value) {
                best = current.assign;
                best_value = current_value;
            }
        } else {
            undo(current, log);
        }
        // 教材用の照合。高速化の効果を測る版では頻度を減らす。
        consistent = consistent && current.load == get_load(current.assign)
            && current_value == score(current.assign) && best_value == score(best);
        cout << "step=" << i+1 << "," << item << "," << to << "," << delta
             << "," << temperature << "," << probability << "," << u
             << "," << take << "," << current_value << "," << best_value << "\n";
    }
    print_array("current_assign", current.assign);
    print_array("best_assign", best);
    cout << "current_value=" << current_value << "\n";
    cout << "best_value=" << best_value << "\n";
    cout << "accepted_worse=" << accepted_worse << "\n";
    cout << "consistent=" << consistent << "\n";
    return 0;
}

経路1 局所探索 / 6段階目・全7段階

2.6 処理ごとに測り、ボトルネックを見つける

今回変わる条件参照:2.5 の問題設定
変更後の問題設定と具体例を確認する

仕事の割当を変える処理に、どれだけ時間がかかるか測る

同じ割当の計算を測ります。問題の考え方は読み直さず、入力サイズと実行方法の違いを確認してください。

何をする問題か

計算を速くする方法を比べるには、全ての方式に同じ処理をさせる必要があります。この章では、仕事の割当を決められた順番で変更し、評価値を求める処理だけを比べます。

下の六仕事の並びを繰り返してN仕事へ広げます。仕事IDは0〜N−1で、重さも担当者も六仕事ごとに同じ並びです。初期設定のN=120なら、この組が20回現れます。

具体例で使うデータ
組の中の仕事重さ初期担当
0・18・7A・A
2・36・5B・B
4・54・3C・C
答えと評価を、具体例で追う
  1. 変更列を固定する

    試行番号tを0から数え、仕事t%Nを、担当者(t+1)%3へ移します。したがって最初は仕事0をBへ、次は仕事1をCへ動かします。どの方式でもこの列を変えません。

  2. 同じ値を別の方法で求める

    全計算なら毎試行N仕事を読み直します。差分なら移動元・移動先の負荷を使います。担当表のコピーを行う設定では、評価が差分でもN要素のコピーが残ります。

  3. 結果と費用を別に確認する

    最終評価値と途中評価値の合計checksumが一致していることを先に確認します。その後で、参照数・コピー数と、手元のC++が出す実時間を比較します。

ここで間違えやすいことこの計測用ループは、評価値が悪くなる変更も全て実行します。ここで最終値が大きくなったことは、計測方式の不具合とは限りません。探索としての強さを比べるときは、別途同じ時間予算で評価します。

ルールと、この章での扱いを確認する
与えられるもの
N個の仕事と三人の担当者。重さは8・7・6・5・4・3の繰り返しで、最初は二仕事ずつA・B・Cへ繰り返し割り当てます。初期設定はN=120です。
決めること・求めるもの
同じ仕事を同じ順に移した結果、評価値の一致、配列を読む回数・コピーする要素数・実時間を求めます。
守る条件
比較する全ての方式で、入力と変更列と試行回数をそろえます。計測用のループでは改善したかに関係なく全変更を適用します。
評価・確認の基準
割当自体の目的値は負荷の二乗和です。この章で比べるのは、その同じ値を求めるための仕事量と時間です。

この章で作る部品全計算/差分、コピーあり/なしを切り替えます。ブラウザーでは仕事量、手元のC++では実時間も確認します。

処理ごとの回数や時間を測ることをプロファイリングと呼びます。全体の速度を大きく左右する処理(ボトルネック)を見つけ、そこから改善します。

図:評価が速くなっても、別の処理が残る

候補生成

今回の操作を決める
仕事ID・移動先

状態コピー

N要素を複製
差分化しても残る

評価

全計算:N仕事
差分:最大2人の負荷

2.4は評価式の差分化。2.6は処理ごとの仕事量と実時間を測り、次に速くする場所を選ぶ章です。

1. 同じ処理を測る

2.4の差分評価を使い、一試行全体の処理量を測ります。全計算/差分、コピーあり/なしを切り替え、全方式に同じ変更列を適用します。採否で経路が変わらないよう、変更は全て実行します。

2. 参照とコピーを分けて数える

画面では、評価で参照した要素数とコピー数を別に数えます。全評価はN仕事、差分は変更する二人の負荷を参照します。

差分を使っても担当表のコピーにはN要素分の処理が残ります。仕事数を増やし、評価がO(1)でも一試行はO(N)になりうることを確かめます。

注目コード:評価以外のコピーも数える初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
if (copy_state) {
    vector<int> candidate = assign;
    copied += n;
    candidate[item] = to;
    assign.swap(candidate);
}

ここを見る:差分評価を使っていても、このコピーには仕事数nに比例する処理が残ります。copiedで評価の参照数とは別に数えます。

完成コードの L17 を見る →

3. 実時間を手元で測る

C++は steady_clock で測り、elapsed_us を標準エラーへ出します。ブラウザが示すのは処理量であり、参照数の比は速度比ではありません。

入力・試行数・コンパイル設定をそろえて複数回測り、中央値とばらつきを見ます。短すぎる測定や測定中の大量ログを避け、結果のchecksumも照合します。

注目コード:ループ全体の経過時間を求める初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
auto elapsed = chrono::duration_cast<chrono::microseconds>(
    chrono::steady_clock::now() - start).count();

ここを見る:startは試行ループの直前に取得しています。全試行の終了後に差を取り、評価だけでなく候補のコピーや更新も含めて測ります。

完成コードの L33 を見る →

4. 一つずつ替えて比較する

コピーだけ外す、次に差分へ替える、と一要素ずつ比べます。最終値とchecksumの一致が前提です。実際の探索で直接変更する場合は、取消しや変更前の採否判定も必要です。

候補生成・ソート・メモリ確保が遅ければ、そこを改善します。候補数を減らす変更は探索範囲も変えるので、同じ時間での最終得点まで比べます。

ここまでの確認

差分評価をO(1)にしても、毎回N要素の担当表をコピーしています。一試行全体もO(1)になったと言えますか?

答えと理由を見る

言えません。担当表のコピーにO(N)の仕事量が残ります。評価の高速化と、一試行全体の計測を分けて考えます。実時間も同じ条件で測り、次に改善する処理を選びます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点2.4の正しい差分評価を基準にし、同じ一手列を処理する比較用のプログラムを用意します。

用意するデータと処理
仕事量のカウンタ
候補数・コピー要素数・評価で読む要素数。
計測結果
実時間、コンパイル条件、設定、結果の検査値。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 計算結果が同じ比較を作る

    同じ入力・一手列を用意し、全計算と差分、コピーありとなしを切り替えます。まず探索の採否を入れず結果をそろえます。

    ここで確認scoreとchecksumが切替前後で一致する。

  2. 2. 測る区間と単位を決める

    visitsとcopiedを更新し、まとめた試行の前後をsteady_clockで測ります。出力は計測区間の外へ出します。

    ここで確認仕事数を増やしたとき、どのカウンタが増えたか説明できる。

  3. 3. 一つだけ直して測り直す

    支配的な処理を一つ選びます。コピーを減らす場合は取消しの正しさを再確認し、同じ条件で複数回計測します。

    ここで確認結果一致を保ったまま実時間が改善し、別の処理が支配的になっていないか分かる。

次に足すなら:確保回数を減らす

候補配列の生成箇所にカウンタを置き、毎試行の確保が多いなら作業用バッファをループ外で確保します。clearとreserveの役割を分けます。

追加後の確認:古い要素が残らず、速度だけでなく同じ実時間の試行数・最終得点も比較できる。

完成コードで見る場所:checksum:同じ変更列の結果を比べる値 / steady_clock:実時間を測る区間

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

プロファイリング
処理ごとの実行回数や時間を測ること。
ボトルネック
全体の速度を大きく左右する処理。改善する場所を実測から選ぶ。
キャッシュ
保存した計算結果を再利用すること。結果が有効な条件と、更新するタイミングを決める。
一試行のコスト
探索回数を増やすには、変更・評価・コピー・取消しのうち、頻度と所要時間が大きい処理を特定する。

小例の補足

同じ変更列で、全計算/差分評価と、状態コピーあり/なしを切り替えます。評価の参照数とコピー数、手元の実時間をそれぞれ比べます。

この章で扱う実装の範囲

  • 比較条件:同じ入力・変更列・設定で、処理結果を保つ高速化を比較する。
  • 計測:候補生成・コピー・評価・取消し・選別の頻度と仕事量を分ける。
  • 実時間:steady_clockで計測し、複数回の中央値やばらつき、コンパイル条件を記録する。
  • 改善対象:支配的な処理を選び、メモリ確保・データ配置・事前計算・逆引きなどを検討する。
  • 検証:計算結果の一致を確認し、同じ実時間での探索回数と最終得点へ戻って評価する。

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:2.4 変わった分だけを計算する

理解できたら:次に広げること

逆引き配列、bitset、距離の事前計算などを、頻繁に行う操作に合わせて選びます。計算量の削減だけでなく、実測で増えた試行回数と最終的な解の品質も確認します。

別の問題でも使うなら

別の問題でも、速くする前後で同じ入力を使い、結果が一致するかを確認する流れは共通です。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材仕事の割当を変える処理に、どれだけ時間がかかるか測る — 問題設定・具体例を読む

2.6 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

まず差分評価を有効にしたまま、コピーだけを外します。評価の参照数は同じでもコピー数が減ります。次に全計算へ戻し、仕事数を増やしてみてください。手元のelapsed_usは標準エラー出力に出ます。参照数の比をそのまま速度比とは考えません。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <chrono>
#include <iostream>
#include <vector>
using namespace std;
int main() {
    const int n=120, trials=200;
    const bool cached=true, copy_state=true;
    vector<int> weight(n), assign(n);
    vector<long long> load(3,0);
    for(int i=0;i<n;i++){weight[i]=8-i%6;assign[i]=(i/2)%3;load[assign[i]]+=weight[i];}
    long long value=0; for(auto x:load)value+=x*x;
    long long visits=0, copied=0, checksum=0;
    auto start=chrono::steady_clock::now();
    // 探索の採否は入れず、同じ変更列を全て適用して評価計算だけを比べる。
    for(int t=0;t<trials;t++){
        const int item=t%n, to=(t+1)%3, from=assign[item], w=weight[item];
        if(copy_state){vector<int> candidate=assign;copied+=n;candidate[item]=to;assign.swap(candidate);}
        if(cached){
            if(from!=to){
                visits+=2;
                value-=load[from]*load[from]+load[to]*load[to];
                load[from]-=w;load[to]+=w;
                value+=load[from]*load[from]+load[to]*load[to];
            }
            assign[item]=to;
        }else{
            assign[item]=to;load.assign(3,0);
            for(int i=0;i<n;i++){load[assign[i]]+=weight[i];visits++;}
            value=0;for(auto x:load)value+=x*x;
        }
        checksum+=value;
    }
    auto elapsed=chrono::duration_cast<chrono::microseconds>(chrono::steady_clock::now()-start).count();
    vector<long long> reference(3,0);
    for(int i=0;i<n;i++)reference[assign[i]]+=weight[i];
    long long full=0;for(auto x:reference)full+=x*x;
    cout<<"score="<<value<<"\nchecksum="<<checksum<<"\nvisits="<<visits<<"\ncopied="<<copied
        <<"\nconsistent="<<(reference==load && full==value)<<"\n";
    // 実測時間は環境で変わるため、ページとの照合対象に混ぜない。
    cerr<<"elapsed_us="<<elapsed<<"\n";
}

経路1 局所探索 / 7段階目・全7段階

2.7 焼きなましの実装テンプレート

状態・評価・一手の作り方が決まれば、焼きなましのループを組めます。まずは状態をコピーし、毎回全評価する構成で完成させます。

新しい問題は使いません。これまでの仕事割当を例に、自分の問題へ置き換える部品を整理します。負荷の二乗和を小さくする設定は2.5と同じなので、読み直しは不要です。

自分の実装をチェックする →

1. 最小限持つ情報

担当表がState、一仕事の移動が候補生成、負荷の二乗和がcostに当たります。以下は最小化の形です。最大化なら採否と最良値の比較をそろえて反転します。

持つもの用途
固定入力重さ・距離・制約など。候補をコピーするときに複製する必要はありません。
State / current / bestStateは答えを復元できる情報。currentは探索中の答え、bestは見つけた最良の有効解のコピーです。
current_cost / best_costそれぞれの状態を評価した値。状態と値を必ず組で更新します。
試行予算・温度・乱数まず試行回数、開始温度・終了温度(ともに正)、乱数seedを設定します。

2. 問題に合わせて作る処理

関数名は役割を表す例です。手元のコードでは、同じ役割を担う処理へ対応させてください。

部品入出力と守る条件
make_initial()有効な完成解を一つ作る。探索0回でも提出できる答えにする。
valid(state)全制約を検査する。不正な状態を評価・採用へ渡さない。
cost(state)状態を変えずに目的値を返す。最初は全計算し、後の高速化の基準にする。
propose(current, rng)currentのコピーに一手を適用して候補を返す。候補なしは失敗として返し、currentを変えない。
write_answer(best)保存したbestを指定形式へ変換する。必要なら操作列を復元し、再生して検査する。
注目コード:候補は元の状態からコピーする初期設定の抜粋 · 左は完成コードの行番号
vector<int> candidate = current;
if (move[0] == 0) candidate[move[1]] = move[2];
else swap(candidate[move[1]], candidate[move[2]]);

ここを見る:2.2のコピー方式です。これがproposeの中身に当たります。次に候補を検査・全評価し、採用する場合だけcurrentへ戻します。

完成コードの L50–52 を見る →

3. ループにつなぐ

図:最小構成の処理順

  1. 初期化有効な初期解をcurrentとbestへコピーし、両方の値を求める。
  2. 候補を作るコピーに一手 → 制約を検査。不正・候補なしなら次の試行へ。
  3. 評価・採否全評価してΔを計算。温度と乱数から採用するか決める。
  4. 保存して繰り返す採用時だけcurrentを更新。最良ならbestもコピーして更新。
  5. 予算で終了bestを検査し、bestから答えを出力する。
問題ごとに作る部品を、この順につなぎます。

最小化では Δ = candidate_cost − current_cost。Δ≤0なら採用し、Δ>0なら exp(−Δ / T) の確率で採用します。Tは正に保ち、試行の進捗に合わせて下げます。

乱数uを[0,1)から取り、u < exp(−Δ / T) で判定します。棄却時はcurrentに触れません。bestの更新は、best_costを改善したときだけです。

注目コード:悪化を許す判定を追加する初期設定の抜粋 · 左は完成コードの行番号
const double probability = delta <= 0 ? 1.0 :
    (allow_worse ? exp(-double(delta) / temperature) : 0.0);

ここを見る:2.5から採用確率だけを抜き出しています。コピー方式ならdeltaは候補の全評価値−現在値です。allow_worse=trueとし、乱数uがprobability未満なら採用します。

完成コードの L95–96 を見る →
注目コード:採用後も最良解は別に保存する初期設定の抜粋 · 左は完成コードの行番号
if (current_value < best_value) {
    best = current.assign;
    best_value = current_value;
}

ここを見る:2.5ではcurrent.assignが担当表です。自分のStateでは、答えを復元するのに必要な状態全体をbestへコピーします。

完成コードの L102–105 を見る →

4. この順に実装する

  1. まず一つ出力する

    make_initial → valid → cost → write_answerをつなぎ、探索0回で有効な答えを出します。

  2. コピー方式の山登りを通す

    候補を一つ作り、合法で改善する場合だけ採用します。採用・棄却・候補なしを固定した例で確認します。

  3. 温度と悪化の採用を足す

    正の温度、進捗、乱数比較を追加します。悪化してもbestが残るか、同じ入力・seed・回数で動きを追います。

  4. 終了処理を固める

    保存したbestの再評価・出力検査を通します。時間制限へ替える場合は、一試行の所要時間と出力時間も見込んで終了します。

5. 最小構成の完成後に足すもの

差分評価・undo・近傍の混合・温度調整は、基準版の完成後に一つずつ足します。特に差分と取消しは全計算・コピー版と照合し、同じ時間での改善を確認します。

実装の完成チェック

自分のコードで確認できた項目にチェックしてください。全項目を確認できたら、最小構成の完成です。

0 / 12 項目を確認
必要な部品
ループの接続
完成の確認

チェックはこのページを開いている間、保持されます。

最小構成を説明する

差分評価もundoも未実装ですが、コピーした候補を全評価し、温度による採否・最良解の保存・出力まで動いています。焼きなましの最小構成は完成していますか?

答えと理由を見る

完成しています。差分評価とundoは高速化の選択肢です。まず上の完成チェックで正しさを確かめ、処理時間を測ってから追加します。探索が正しく動くことと、得点が十分高いことは別に検証します。

演習・完成コードを参照する

コードは参照先で、閉じたまま全文をコピーできます。

共通準備から選べる経路 · 経路2

3. ビーム探索:途中の答えを複数残す

手順や選択を一つずつ決める問題で、途中の一候補に決め切らず先へ進めたい。

ビーム探索は、途中の候補から一手先を作り、評価のよい候補を一定数だけ残す方法です。数を「選ぶ・選ばない」と順に決めながら、この操作を繰り返します。

途中の答えを複数持つ↓一段展開 → 途中評価 → 上位候補を残す↻ 同じ処理を繰り返す

1.7では、その時点でよい選択を一つ決めました。この経路では複数の選択肢を残します。まず3.1で、未完成の状態を比べる評価が必要になる理由を考えます。

入口:共通準備 1.1〜1.8の状態・制約・評価・答えの保存。共通準備の後、この経路の最初の章から始められます。

  1. 3.1 途中の答えを、何で比べるか考える途中評価を決める
  2. 3.2 ビーム探索で、途中の候補を複数残す展開と選別を動かす
  3. 3.3 ビーム探索の履歴を復元し、重複を取り除く復元と重複排除を加える
  4. 3.4 ビーム探索のメモリと、多様性を管理する多様性と履歴を管理する
  5. 3.5 ビーム探索の実装テンプレート実装テンプレートで完成を確認する

到達点:層ごとに候補を選び、親の記録から手順を復元し、重複排除・多様性・記録の寿命を確認できる。

経路2を始める → 三つの経路を見比べる

経路2 ビーム探索 / 1段階目・全5段階

3.1 途中の答えを、何で比べるか考える

今回の問題

8・6・5をそれぞれ使うか選び、合計と11の絶対差を小さくします。今回は8と6を処理した途中から、合計8と合計6の二候補を比べます。どちらにも最後の5が残っています。

入力の表・詳しいルール・計算例を確認する

数を選んで、合計を目標値へ近づける

ここから数を選ぶ問題へ移ります。仕事の担当表ではなく、各数を使うかどうかを答えます。経路1を読んでいなくても、この設定から始められます。

何をする問題か

数8・6・5について、各数を選ぶか選ばないかを決め、選んだ合計を11に近づけます。合計が11を超えても不正ではなく、11からの距離で評価します。

この章は、8と6を検討し終えた途中から始めます。「8だけを選んだ合計8」と「6だけを選んだ合計6」の二候補を比較します。どちらにも最後の5を選ぶ余地が残っています。

具体例で使うデータ
途中の合計最後の5完成した合計目標11との誤差
8選ばない83
8選ぶ132
6選ばない65
6選ぶ110
答えと評価を、具体例で追う
  1. 今の差だけで比べる

    合計8は目標との差が3、合計6は差が5なので、現在値だけなら8の方がよく見えます。

  2. 残りの選択を考える

    しかし残り5を使って完成させると、合計8からは最善でも誤差2です。合計6からなら11を作れます。途中では不利だった候補の方が、完成後にはよくなります。

  3. この章で返すものを決める

    完成解そのものを探すループではなく、この二候補へ順位を付ける途中評価を作ります。残りの数と目標を変えた場合も、完成後の誤差と途中評価を別に確かめます。

ここで間違えやすいこと「合計8〜13まで届きそう」という範囲には11が含まれますが、残りが5一個なら実際の選択肢は8と13だけです。範囲の下界が0でも、誤差0の答えが存在するとは限りません。

ルールと、この章での扱いを確認する
与えられるもの
数8・6・5と目標11です。この章では途中の合計が8または6の二候補を用意し、残りの数5を選べる段階だけを比べます。目標と残りの数は演習で変更できます。
決めること・求めるもの
各数を選ぶかどうか。完成した合計sに対して、|s−目標|を求めます。
守る条件
各数は高々一回使えます。残り5からは「選ばない」か「5を加える」かの二択です。
評価・確認の基準
完成後の絶対誤差を最小化します。目標11なら合計8の候補は最善でも誤差2、合計6の候補は5を加えて誤差0です。

この章で作る部品途中候補を比較する評価を作ります。完成後の誤差と、途中で順位を付ける値を分けます。

評価関数は、候補のよさを比較する数を返す関数です。途中評価では未完成の候補の見込みを数にし、完成後の実際のよさとは分けて扱います。

図:途中の順位と、完成後の順位が逆転する

候補A:現在の合計8

今の誤差 3 → 近く見える
残り5を使わない:8(誤差3)
残り5を使う:13(誤差2)
完成後の最良誤差 2

候補B:現在の合計6

今の誤差 5 → 遠く見える
残り5を使わない:6(誤差5)
残り5を使う:11(誤差0)
完成後の最良誤差 0

今の誤差で選ぶ → Aを残す
残りの二通りを見て選ぶ → Bを残す

目標は11。比較するのは、既に用意した二つの途中状態です。完成後の誤差を実測し、途中で使う評価がどの候補を落としたか調べます。

1. 途中評価と最終誤差を分ける

合計8と6から、残り5を使うか選びます。目標11への現在の差は8が有利ですが、完成後は6+5=11が有利です。完成解の目的値 abs(final_sum − target) が、そのまま途中の順位付けに向くとは限りません。

2. 三つの評価を試す

現在の合計s、目標t、残り一個rに対し、次の値が小さい方を残します。同点なら候補Aです。

  1. 現在の差:|s−t|。
  2. 到達範囲の下界:max(s−t, 0, t−(s+r))。区間[s,s+r]内を自由に選べると仮定した見積もりです。
  3. 二通りを試す:min(|s−t|, |s+r−t|)。この例では完成後の最小誤差を正確に求めます。

残りが多い場合は、先読みを限定する、貪欲に埋めるなど、精度と計算時間を両立する評価に替えます。

注目コード:途中状態を三つの基準で比べる初期設定の抜粋 · 左は完成コードの行番号
int evaluate(int sum, int target, int remaining, int mode) {
    if (mode == 0) return final_cost(sum, target);
    if (mode == 1) {
        return max({sum - target, 0, target - (sum + remaining)});
    }
    return min(final_cost(sum, target), final_cost(sum + remaining, target));
}

ここを見る:mode=0は現在の誤差、1は到達範囲からの下界、2は残り一個を使う・使わないの比較です。いずれも候補を残す順位付けに使います。

完成コードの L11–19 を見る →

3. 下界は到達を保証しない

Aの範囲[8,13]、Bの範囲[6,11]はどちらも11を含み、下界は0です。しかしAが作れるのは8か13だけ。Bは6か11を作れます。下界による順位付けでは同点規則でAが残り、最適解を失います。

下界は実際の最小誤差以下の値です。下界が既知の最良値以上なら、その先を調べなくても改善しません。このように不要な分岐を省くのが枝刈りです。単に下界の順位で候補を捨てる場合は、最適解を失うことがあります。

4. 選択後に完成させて検証する

evaluate で選んでから完成させ、final_cost で誤差を測ります。途中評価0を最終誤差0として記録しません。全候補での最小誤差 reference_error は答え合わせ専用で、候補の差し替えには使いません。

初期設定では現在の差と下界がAを残して誤差2、二通りの先読みはBを残して0です。残りを1へ変えるとAが有利になります。

注目コード:選別用の値と答え合わせを分ける初期設定の抜粋 · 左は完成コードの行番号
const int chosen = evaluation[1] < evaluation[0] ? 1 : 0;
const int reference = min(completed_cost[0], completed_cost[1]);

ここを見る:chosenは途中評価だけで決めます。全候補の完成後の値から求めたreferenceは検証用で、選び直しには使いません。

完成コードの L38–39 を見る →

5. 見分けにくい候補は複数残す

評価の改良に加え、3.2のビーム探索で複数候補を残す方法も使えます。未来の入力が未知なら、既知の残りを試すこの例とは条件が変わります。予測して先を試す方法は4.4で扱います。

ここまでの確認

今の目標との差だけで合計8を残すと、何を見落としますか?

答えと理由を見る

合計6から11に届く道を見落とします。途中では遠く見えても、残りの選択によって逆転することがあります。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点完成した答えの目的値を残し、未完成の候補を並べるための評価を別に作ります。

用意するデータと処理
途中状態
現在の合計と、残りの数・手数など未来の選択肢。
途中評価
候補を残すための値。完成後の実際の目的値とは別に持つ。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 二候補だけを用意する

    partialの二候補から先を全て試し、完成後の最小誤差を答え合わせとして求めます。まず探索ループは書きません。

    ここで確認合計8と6を、残り5でどう完成させられるか手で確認できる。

  2. 2. 評価を差し替え可能にする

    evaluateへ途中状態を渡し、現在の差・到達範囲の下界・小例の正確な先読みを切り替えます。全候補で同じ規則を使います。

    ここで確認下界は実際の最小誤差を超えないが、下界0でも誤差0へ届くとは限らない。

  3. 3. 選別の直前へ入れる

    途中評価と同点規則だけで残す候補を選び、完成後の値は選別後の答え合わせに使います。この比較器を3.2の選別へ渡します。

    ここで確認正解を知ってから候補を選び直す処理が混ざっていない。

次に足すなら:特徴量を一つ追加する

途中評価の内訳へ残り資源など一項だけ加え、同じ候補集合で順位を比較します。その後、同じ探索予算で完成後の目的値を比べます。

追加後の確認:途中評価だけが改善し、完成後の結果は悪化する場合も記録できる。

完成コードで見る場所:evaluate:途中評価の窓口 / completed_cost:完成後の答え合わせ

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

評価関数
候補を比べるための数を計算する関数。途中では、完成後のよさを予想する数を使うこともあります。

小例の補足

目標11に近い合計を作り、最後に5を足すか選べるとします。今の合計8は11に近いですが、最後は8か13。今の合計6なら5を足して11にできます。

この章で扱う実装の範囲

  • 目的値と特徴量の個別ログ
  • 項ごとの単位と大きさの調整
  • 進捗・残作業・将来の見積もり
  • 補助項を一つずつ外す比較
  • 硬い制約を守る近傍/不正候補の棄却/修復/探索中だけの罰則の使い分け
  • 実行可能な best を別に保持し、罰則付き評価の最小値をそのまま出力しない

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.7 貪欲法で初期解を作る

理解できたら:次に広げること

ルール違反に罰点を付けて探索する方法は後半の話です。その場合も、提出用にはルールを守る答えを別に保存します。

別の問題でも使うなら

作業の順序を選ぶ問題では、これまでの時間だけでなく、残った作業に必要な時間も見積もります。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材数を選んで、合計を目標値へ近づける — 問題設定・具体例を読む

3.1 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

目標11・残り5では「現在の差」と「下界」がAを残し、完成後の誤差は2です。「二通りを試す」ではBを残して0になります。残りを1に変えるとAが有利になります。途中評価の値と、実際の完成後の誤差を別々に比べてください。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

#include <cstdlib>

int final_cost(int sum, int target) {
    return abs(sum - target);
}
int evaluate(int sum, int target, int remaining, int mode) {
    if (mode == 0) return final_cost(sum, target);
    if (mode == 1) {
        // 区間[sum, sum+remaining]までの距離。区間内の全値が作れるとは限らない。
        return max({sum - target, 0, target - (sum + remaining)});
    }
    // この小問題では残りが一個なので、二通りを正確に比較できる。
    return min(final_cost(sum, target), final_cost(sum + remaining, target));
}

int main() {
    const int target = 11;
    const int remaining = 5;
    const int mode = 0; // 0:現在の差、1:区間の下界、2:最後の二通りを比較
    const vector<int> partial = {8, 6}; // A:8だけを選択、B:6だけを選択した途中状態。
    vector<int> evaluation, completed_sum, completed_cost;
    for (int sum : partial) {
        const int value = evaluate(sum, target, remaining, mode);
        const bool take = final_cost(sum + remaining, target) < final_cost(sum, target);
        const int finished = sum + (take ? remaining : 0);
        evaluation.push_back(value);
        completed_sum.push_back(finished);
        completed_cost.push_back(final_cost(finished, target));
        cout << "candidate=" << sum << "," << value << "," << finished
             << "," << completed_cost.back() << "\n";
    }
    // 評価が同点ならAを残す。最終誤差を見て選び直さない。
    const int chosen = evaluation[1] < evaluation[0] ? 1 : 0;
    const int reference = min(completed_cost[0], completed_cost[1]);
    cout << "chosen=" << (chosen == 0 ? "A" : "B") << "\n";
    cout << "evaluation=" << evaluation[chosen] << "\n";
    cout << "final_sum=" << completed_sum[chosen] << "\n";
    cout << "final_error=" << completed_cost[chosen] << "\n";
    cout << "reference_error=" << reference << "\n";
    cout << "kept_best=" << (completed_cost[chosen] == reference) << "\n";
    return 0;
}

経路2 ビーム探索 / 2段階目・全5段階

3.2 ビーム探索で、途中の候補を複数残す

前の章と同じ数選びです。設定の読み直しは不要です。

必要なら、問題設定を読み直す

選ぶ・選ばないを順に決め、複数の候補を残す

数8・6・5から選んで11へ近づける、前の章と同じ問題です。設定の読み直しは不要です。途中の二候補の比較から、最初から最後までの探索へ進みます。

何をする問題か

3.1と同じ数選びを、まだ何も選んでいない状態から解きます。入力の順に8、6、5を見て、それぞれについて「選ばない」「選ぶ」を決めます。数の並べ替えは答えに含めません。

答えは各数の採否を並べた列です。0は選ばない、1は選ぶという意味で、三つの数なら長さ3になります。ビーム幅Wは、この途中の列を同時に何個持つかという探索側の設定です。

具体例で使うデータ
採否の列選んだ数合計誤差
[1,0,0]883
[1,0,1]8と5132
[0,1,1]6と5110
[0,0,0]なし011
答えと評価を、具体例で追う
  1. 一つ目の数を処理する

    空の選択列から、8を選ばない合計0と、選ぶ合計8が生まれます。幅1で今の差が小さいものだけを残すと、合計0の候補を失います。

  2. 次の数へ進む

    合計0の候補が残っていれば、6を選んで合計6へ進めます。次に5を選ぶことで合計11を作れます。

  3. 完成してから評価する

    最後の5まで検討した列を完成解とし、|合計−目標|を計算します。途中で目標に近い候補を選ぶ規則と、完成後に比較する値を区別します。

ここで間違えやすいこと途中の列を残す数Wを増やすのは、問題のルールを緩めることではありません。計算時間と記憶量を使って、より多くの選択を後まで検討することです。

ルールと、この章での扱いを確認する
与えられるもの
初期設定では、順に処理する数8・6・5と目標11。演習では数の一覧、目標、残す候補数Wを変えられます。
決めること・求めるもの
三つの数をそれぞれ選ぶかどうかの列と、その合計です。6と5を選べば合計11になります。
守る条件
各数を一回だけ検討し、選んだ場合だけ合計へ加えます。Wは問題の制約ではなく、探索が保持する候補数の上限です。
評価・確認の基準
全ての数を検討した後の|合計−目標|を小さくします。途中では現在の合計と目標の差で候補を並べます。

この章で作る部品一段ずつ候補を展開し、上位W件を残すビーム探索を作ります。途中の順位が完成後の順位と一致するとは限りません。

ビーム探索は、複数の途中候補から一手先を作り、評価のよい候補を一定数だけ残すことを繰り返す探索です。各段階で残す数の上限をビーム幅と呼びます。

図:幅3で候補を残すと、捨てずに済む経路がある

8を処理した段階

残す合計:8, 0
0も次の段へ渡す

6を処理した段階

残す合計:8, 14, 6
0から6へ進める

5を処理した段階

残す合計:11, 13, 8
6から11へ到達

残した各候補から次の候補を作り、途中評価と同点規則で上位3件を選びます。幅1では最初の0を捨て、6+5へ進む経路を失います。

1. 同じ段階の候補をまとめる

同じ段階の候補の集まりを層、候補から次の候補を作ることを展開と呼びます。この例は一段で数を一つ処理し、状態に合計と選択列を保存します。beam の各状態から「使わない・使う」を next へ作り、全て展開してから選別します。展開中にbeamを書き換えると、段数が混ざります。

注目コード:親を変えずに次の層へ子を作る初期設定の抜粋 · 左は完成コードの行番号
std::vector<State> next;
for (const State& state : beam) {
    next.push_back({state.sum, state.path + "0"});
    next.push_back({state.sum + value, state.path + "1"});
}

ここを見る:beamが今の層、nextが次の層です。valueを使わない子と使う子を作り、pathに選択を記録します。

完成コードの L37–41 を見る →

2. 上位W件を残す

abs(target − sum) の昇順、同点は選択列の辞書順に並べ、幅Wまで残します。現在の誤差が小さくても、完成後によいとは限りません。

分岐数Bなら候補は最大BW件、全ソートはO(BW log(BW))です。状態のコピーも別にかかります。まず全ソートで検証し、必要なら部分選択やヒープへ替えます。

注目コード:全ての親を展開してから幅で切る初期設定の抜粋 · 左は完成コードの行番号
std::sort(next.begin(), next.end(), better);
if (int(next.size()) > WIDTH) next.resize(WIDTH);
beam = next;

ここを見る:betterで途中評価と同点規則をそろえ、上位WIDTH件だけを次の探索対象にします。親ごとに幅を切る処理ではありません。

完成コードの L42–44 を見る →

3. 失った経路を追う

幅1は最初に8だけを残し、最終的に13・誤差2です。幅3なら0→6→11を残せます。演習の「次の段階」で親と選別結果を追ってください。幅を増やしても、評価・同点規則・予算次第で改善しない場合があります。

4. 完成判定と復元を用意する

この例は全数を処理したら完成です。一般の問題では途中の完成や手詰まりも扱い、完成解を別に保存します。next.empty() でも未完成の状態を答えにしません。

完成コードは選択列をコピーして持ちます。path=011 は6と5を選んだ答えです。3.3では親IDから復元し、履歴の寿命も管理します。

注目コード:残した状態から選択列を出す初期設定の抜粋 · 左は完成コードの行番号
std::cout << "path=" << beam.front().path << '\n';

ここを見る:この小例では全数を処理した時点で全候補が完成しています。最良候補が保持するpathをそのまま取り出せるため、親IDはまだ不要です。

完成コードの L55 を見る →

5. 重複・多様性へ広げる

同じ層・同じ合計なら残りの数も同じで、一方を代表にできます。層が違えばまとめられません。3.3で状態比較とハッシュ、3.4で共有履歴と種類別上限を扱います。一手の進捗が異なる問題では、手数ではなく進捗別に層を作ります。

ここまでの確認

最初の8を選ばない候補を残すと、どんな答えを作れますか?

答えと理由を見る

次の6と5を選んで、ちょうど11を作れます。見本では残す数を1と3に変え、どの段階で候補が消えるかを見ます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点一段で何を決めるかを固定し、3.1の途中評価と、一候補から子を作る処理を用意します。

用意するデータと処理
beam / next
今回展開する層と、次の層の候補。別の配列にする。
better
評価値と同点規則をまとめた比較器。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 一段の展開を完成させる

    根をbeamへ入れ、各候補から選ぶ・選ばないの二候補をnextへ追加します。走査中のbeamへ追加しないようにします。

    ここで確認一段だけなら全ての子を列挙でき、親の状態が書き換わっていない。

  2. 2. 展開後に幅を制限する

    nextを同じ比較器で並べ、幅W件まで残してからbeamと入れ替えます。次の段ではnextを空にします。

    ここで確認幅1では一候補、幅を十分大きくすれば小さい全探索と同じ候補が残る。

  3. 3. 終端と予算を接続する

    全段の後で完成解を実目的値で比較します。一般の問題では完成解を別に保存し、nextが空の場合や期限終了でも返す答えを決めます。

    ここで確認幅を増やすと候補数・時間・保存量も増えることを測れる。

次に足すなら:重複排除を追加する

3.3の完全な状態比較を、nextを生成した後、幅で切る前へ入れます。層ごとに重複判定表を作り直します。

追加後の確認:同じ状態が幅を占有せず、異なる未来を持つ状態まで消していない。

完成コードで見る場所:bool better:評価と同点規則 / next.resize:次の層の幅を制限する場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

ビーム探索
途中候補から一手先を作り、評価のよい候補を一定数だけ残すことを繰り返す探索。
ビーム幅
各段階で残す候補の数の上限。幅3なら最大3個を残す。
層と展開
層は同じ段階の候補の集まり。展開は候補から次の候補を作ること。

小例の補足

8,6,5を順に「選ぶ・選ばない」と決め、合計を11へ近づけます。最初に合計8の候補だけを残すと、6と5だけを選ぶ道が消えます。合計0の候補も残せば、その答えを作れます。

この章で扱う実装の範囲

  • 層と状態:何を一段と数えるか、状態に何を保存するかを決める。
  • 展開と選別:currentからnextへ全候補を作り、途中評価と同点規則で上位W件を残す。
  • 終了処理:完成した候補と未完成の候補を分け、候補がなくなった場合も扱う。
  • 計算予算:幅・分岐数・段数・状態のコピー量から時間とメモリを見積もる。
  • 層の選び方:手数でそろえる型と、完成した作業数などの進捗でそろえる型を比較する。

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.4 探索で変える情報を、状態として持つ、3.1 途中の答えを、何で比べるか考える

理解できたら:次に広げること

同点なら決めた順で選びます。幅を増やすと時間も使うため、必ずよい答えになるとは限りません。

別の問題でも使うなら

作業を一つずつ追加する問題でも、「次の一つを選ぶ→いくつか残す」という部分を使えます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材選ぶ・選ばないを順に決め、複数の候補を残す — 問題設定・具体例を読む

3.2 の教材体験見本 この場で動きます

残す候補を増やすと、答えはどう変わるか

8、6、5を順に、選ぶか選ばないか決め、合計を11へ近づけます。途中では、今の合計が11に近い候補から残します。同じ差なら、選び方を0と1で書いた列の小さい順です(0は選ばない、1は選ぶ)。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方
  1. 既定の [8,6,5]・目標11 で、幅1の結果を確認する。
  2. 幅3へ変え、「次の段階」で候補6が残る場面を探す。6+5=11へつながる。
  3. 目標値や入力順を変更する。幅を広げても、いつでも同じだけ改善するわけではない。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>

// 各数を「選ぶ / 選ばない」で、合計を TARGET に近づけます。
// この小例は、途中評価による枝の選択を学ぶためのものです。
const std::vector<int> VALUES = { 8, 6, 5 };
constexpr int TARGET = 11;
constexpr int WIDTH = 3;

struct State {
    int sum;
    std::string path; // この段階では履歴をコピー。0=選ばない、1=選ぶ。
};

bool better(const State& a, const State& b) {
    const int ca = std::abs(TARGET - a.sum), cb = std::abs(TARGET - b.sum);
    if (ca != cb) return ca < cb;
    return a.path < b.path; // 同点の扱いも固定して再現します。
}

int main() {
    if (VALUES.empty() || VALUES.size() > 8 || TARGET < 0 || TARGET > 80 ||
        WIDTH < 1 || WIDTH > 6) {
        std::cerr << "Invalid experiment settings\n";
        return 1;
    }
    for (int value : VALUES) if (value < 1 || value > 20) {
        std::cerr << "Use values from 1 to 20\n";
        return 1;
    }
    std::vector<State> beam = {{0, ""}};
    int step = 0;
    for (int value : VALUES) {
        std::vector<State> next;
        for (const State& state : beam) {
            next.push_back({state.sum, state.path + "0"});
            next.push_back({state.sum + value, state.path + "1"});
        }
        std::sort(next.begin(), next.end(), better);
        if (int(next.size()) > WIDTH) next.resize(WIDTH);
        beam = next;
        ++step;
        std::cout << "step=" << step << " sums=";
        for (int i = 0; i < int(beam.size()); ++i) {
            if (i) std::cout << ',';
            std::cout << beam[i].sum;
        }
        std::cout << '\n';
    }
    std::cout << "best_sum=" << beam.front().sum << '\n';
    std::cout << "error=" << std::abs(TARGET - beam.front().sum) << '\n';
    std::cout << "path=" << beam.front().path << '\n';
}

経路2 ビーム探索 / 3段階目・全5段階

3.3 ビーム探索の履歴を復元し、重複を取り除く

今回変わる条件参照:3.2 の問題設定
変更後の問題設定と具体例を確認する

同じ合計へ進んだ候補をまとめ、選択の履歴を復元する

数選びのルールは同じです。重複する状態が生まれるよう入力を一つ増やすため、その違いだけ確認してください。

何をする問題か

数選びの入力を8・6・5・5へ変更します。値が等しい二つの5にも別々のIDを付け、それぞれ使うかどうかを決めます。答えは長さ4の採否の列です。

同じ合計へ至る別々の選び方が生じます。最後の合計を保存するだけでは、何を選んだ答えだったのか分からないため、親の候補と今回の選択も記録します。

具体例で使うデータ
採否の列選んだ要素合計
[0,1,1,0]6と一つ目の511
[0,1,0,1]6と二つ目の511
[0,0,1,1]二つの510
答えと評価を、具体例で追う
  1. 同じ状態かを判断する

    同じ段階で合計が同じなら、この問題では残りの数も評価基準も同じです。その二候補は一方を代表にできます。

  2. 答えへ戻れる記録を残す

    各候補に親IDと今回の0/1を保存します。終端から親をたどると選択が逆順に集まるので、反転すれば答えの列になります。

  3. 復元した答えを再計算する

    例えば[0,1,0,1]なら8を使わず、6を使い、一つ目の5を使わず、二つ目を使います。合計6+5=11を、復元した列から再現します。

ここで間違えやすいこと段階が違う合計は、その後に使える数が違います。また、ハッシュの値が同じでも合計が違う場合があります。「同じ層・同じ合計」を確認してから候補をまとめます。

ルールと、この章での扱いを確認する
与えられるもの
順に8・6・5・5、初期設定の目標11。二つの5は別の要素で、それぞれ一回ずつ使えます。
決めること・求めるもの
各要素の採否を示す四つの選択と、その合計です。合計だけでなく、どの要素を選んだかも出力します。
守る条件
同じ段階では残りの要素が共通なので、合計が同じ候補は以後の選択肢も同じです。違う段階の同じ合計は区別します。
評価・確認の基準
最終的な|合計−目標|を最小化します。目標11には6と、どちらか一つの5で到達できます。

この章で作る部品親IDから選択列を復元し、同じ層の重複を取り除きます。ハッシュが一致しただけで同じ状態と決めないようにします。

復元は、最終候補に至る選択の列を取り出すことです。今後の選択と結果が同じになる候補を一つにまとめる重複排除と合わせ、必要な履歴を保存します。

図:残す候補と、復元に必要な記録は異なる

探索用の一覧

現在残す記録ID
ソート・選別してよい

履歴の保存場所

各IDの親と操作
参照中の記録は残す

終端から逆にたどる

親 → 親 → 根
操作列を反転して再生

候補を落としても、その子が残るなら親の記録は必要です。ハッシュは重複候補を探すために使い、復元用IDとは分けます。

1. 記録とビームを分ける

候補の記録をノード、その候補を生んだ一つ前の記録を親と呼びます。保存領域 pool に合計・親ID・直前の選択を保存し、beam は残すIDだけを持ちます。記録のIDを固定すれば、ビームをソートしても親をたどれます。祖先はビームから消えても必要なので、最初は全記録を最後まで保持します。

注目コード:子の状態に親IDと操作を記録する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
Node node{pool[parent].sum + bit * values[step], parent, bit};
int id = pool.size();
pool.push_back(node);

ここを見る:parentは履歴配列pool内のID、bitは今回使うかどうかです。ビーム内の位置とは分け、並べ替えても親をたどれるようにします。

完成コードの L18–19 を見る →

2. 同じ未来を持つ候補をまとめる

この問題は同じ層・同じ合計なら残りの数も目的も同じで、一方を代表にできます。層をまたぐ比較では段階もキーに必要です。別の問題では残り資源や訪問済み集合など、以後の選択を変える情報も含めます。

3. ハッシュの後で完全比較する

ハッシュは状態を短い値へ写したものです。異なる状態が同じ値になる衝突もあります。演習は sum % buckets で候補を分け、同じハッシュの中で合計を比較します。buckets=1でも完全比較があれば区別できます。比較を外すと、衝突しただけの候補まで消えます。

盤面には、各要素と値に割り当てた乱数をXORでまとめるZobrist hashも使えます。一要素の変更は hash ^= table[item][old] ^ table[item][new]。全状態からの再計算と照合し、ハッシュ一致だけを状態一致の証明にしません。

注目コード:ハッシュが同じでも状態を比較する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
for (int other : bucket) {
    if (pool[other].sum == node.sum) {
        duplicate = true;
        break;
    }
    collisions++;
    if (!exact) { duplicate = true; break; }
}

ここを見る:同じ層なので、合計sumが同じなら残りの選択肢も同じです。exact=falseは、衝突しただけで異なる合計も消す不具合を試す設定です。

完成コードの L21–25 を見る →

4. 復元した列を再生する

終端から親をたどり、選択列を反転して再計算します。replay_ok は終端の合計との一致です。復元に成功しても重複排除で有望な候補を失う場合があるため、両者は別に検証します。

この版のプール量は選別前の候補数にも比例します。状態のコピー、履歴、比較用キーを分けて数え、3.4の記録共有へ進みます。

注目コード:親を逆にたどり、操作列を反転する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
int best = beam.front();
string path;
for (int id = best; pool[id].parent != -1; id = pool[id].parent)
    path += char('0' + pool[id].bit);
reverse(path.begin(), path.end());

ここを見る:終端から集めるため、操作は最後のものから並びます。反転してから初期状態へ適用し、保存した合計と一致するか確かめます。

完成コードの L37–39 を見る →

ここまでの確認

親IDにビーム配列の添字を使い、その配列を次の段階で上書きすると、何が起きますか?

答えと理由を見る

同じ添字が別の候補を指し、誤った手順を復元する可能性があります。復元用の記録に安定したIDを付け、ビームの並び順とは分けます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点コピー版のビームを残し、まず復元を通してから重複排除を追加します。同時に両方を変えません。

用意するデータと処理
pool
状態・親ID・直前の操作を持つ記録。追加したIDを安定させる。
beam / seen
選別するIDの一覧と、その層の重複候補を探す表。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 親子の記録を保存する

    子を生成するときに親IDと操作をpoolへ追加し、beamにはIDを入れます。ソートするのはID一覧で、記録本体は並べ替えません。

    ここで確認beamの順番を変えても、親IDが別の記録を指さない。

  2. 2. 復元と再生を先に通す

    終端IDから親をたどり、集めた操作列を反転します。根から操作を再実行して終端状態と照合します。

    ここで確認出力した操作列から、保存した合計と誤差を再現できる。

  3. 3. 重複候補を完全比較する

    次の層を幅で切る前に、ハッシュが同じ候補の状態を比較します。この小例では同じ層の同じ合計をまとめられます。

    ここで確認ハッシュの種類数1でも、完全比較ありなら異なる合計を残せる。

次に足すなら:状態の項目を増やす

残り資源などを加えたら、完全比較とハッシュの両方へその項目を足します。先に全記録を保持する版で正しさを確認し、解放の最適化は3.4へ分けます。

追加後の確認:盤面や合計が同じでも、次に使える手が異なる二状態を区別できる。

完成コードで見る場所:struct Node:親と操作の記録 / map<int,vector<int>> seen:層ごとの重複判定

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

復元用の記録
候補を生んだ親IDと操作を保存する領域。ビームから外れた親も、子から参照される間は必要になる。
復元と再生の照合
復元した操作列を初期状態から実行し、探索で選んだ状態と目的値に一致するか確認する。

小例の補足

8・6・5・5から数を選びます。候補は親IDを持つ記録へ保存し、同じ層の同じ合計をまとめます。ハッシュをわざと衝突させ、完全比較の有無も試します。

この章で扱う実装の範囲

  • 履歴と復元:親ID・直前の操作を記録し、候補を削除した後も必要な親を残す。
  • 状態の等価性:盤面だけでなく、残り手数・資源・今後の選択肢を含めて同一性を決める。
  • 重複排除:同じ未来を持つ候補をまとめ、残す経路を目的値や資源量で選ぶ。
  • ハッシュ:Zobrist hashなどで比較対象を絞り、衝突時は完全な状態比較で確認する。
  • 検証:復元した操作列を再生し、終端状態・評価値・差分更新したハッシュを照合する。

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:3.2 ビーム探索で、途中の候補を複数残す

理解できたら:次に広げること

導入例は整数の合計を完全比較します。大きな状態ではZobrist hashなどで候補を絞り、衝突を完全比較で確認します。履歴の寿命と共有は3.4へ進みます。

別の問題でも使うなら

最短路の経路復元と同様に親をたどりますが、ビームでは候補の削除や配列の再利用があるため、記録の寿命を別に管理します。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材同じ合計へ進んだ候補をまとめ、選択の履歴を復元する — 問題設定・具体例を読む

3.3 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

ハッシュの種類数1でも、完全比較ありなら異なる合計を区別できます。完全比較を外すと全て衝突扱いになり、必要な候補を失います。復元成功と探索結果のよさは別に確認してください。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <cstdlib>
#include <iostream>
#include <map>
#include <string>
#include <vector>
using namespace std;
struct Node{int sum,parent,bit;};
int main(){
    const int width=3, buckets=1, target=11;
    const bool exact=true;
    const vector<int> values={8,6,5,5};
    vector<Node> pool={{0,-1,-1}}; vector<int> beam={0};
    int duplicates=0, collisions=0;
    for(int step=0;step<int(values.size());step++){
        vector<int> next;map<int,vector<int>> seen;
        for(int parent:beam)for(int bit=0;bit<2;bit++){
            Node node{pool[parent].sum+bit*values[step],parent,bit};
            int id=pool.size();pool.push_back(node);
            auto& bucket=seen[node.sum%buckets];bool duplicate=false;
            for(int other:bucket){
                if(pool[other].sum==node.sum){duplicate=true;break;}
                collisions++;
                if(!exact){duplicate=true;break;} // 意図した誤り:ハッシュ一致だけで消す。
            }
            if(duplicate){duplicates++;continue;}
            bucket.push_back(id);next.push_back(id);
        }
        sort(next.begin(),next.end(),[&](int a,int b){
            int x=abs(pool[a].sum-target), y=abs(pool[b].sum-target);
            return x!=y?x<y:a<b;
        });
        if(int(next.size())>width)next.resize(width);
        beam=next;
        cout<<"layer="<<step+1<<","<<pool.size()<<","<<beam.size()<<"\n";
    }
    int best=beam.front();string path;
    for(int id=best;pool[id].parent!=-1;id=pool[id].parent)path+=char('0'+pool[id].bit);
    reverse(path.begin(),path.end());int replay=0;
    for(int i=0;i<int(values.size());i++)replay+=(path[i]-'0')*values[i];
    cout<<"path="<<path<<"\nsum="<<pool[best].sum<<"\nerror="<<abs(pool[best].sum-target)
        <<"\nduplicates="<<duplicates<<"\ncollisions="<<collisions
        <<"\nreplay_ok="<<(replay==pool[best].sum)<<"\n";
}

経路2 ビーム探索 / 4段階目・全5段階

3.4 ビーム探索のメモリと、多様性を管理する

候補4〜9の六つから、評価値が小さい順に幅3まで残します。種類は順に0・0・0・1・1・2。「各種類1件まで」を加えると、残る候補は4・5・6から4・7・9へ変わります。

入力の表・詳しいルール・計算例を確認する

ビームの一段から、残す候補と履歴を選ぶ

前の章のビーム探索から、候補の選別と履歴の保持だけを切り出します。数選びの入力をそのまま使うのではなく、固定した六候補の表へ置き換えるので、表の意味を確認してください。

何をする問題か

ビーム探索の一段で、次へ残す候補が六つできた場面を考えます。今回は数を選ぶ処理自体を省き、候補の評価値・大まかな種類・履歴だけを固定データとして与えます。

「種類」は候補を粗く分類したラベルです。同じ種類だからといって、同じ状態とは限りません。「完成後の費用」は選別結果を後から比較するための教材データです。

具体例で使うデータ
候補ID種類途中評価完成後の費用(設定0)
401030
501131
601232
71138
81149
92157
答えと評価を、具体例で追う
  1. 上位だけを残す

    幅3で種類の上限がなければ、途中評価10・11・12の候補4・5・6を残します。三つとも種類0で、完成後の最良費用は30です。

  2. 種類別の上限を使う

    各種類1件までなら、候補4・7・9を残します。設定0では完成後に費用7の候補が得られます。ただし設定1では候補5の完成費用が5なので、上限で有望な候補を捨てます。

  3. 必要な履歴も数える

    候補4〜6は親1、7〜8は親2、9は親3を持ち、親1〜3は全て根0を指します。候補4・7・9を残すなら、祖先も含め{0,1,2,3,4,7,9}の7記録が必要です。

ここで間違えやすいこと選別で読むのは途中評価と種類です。完成後の費用を見て残す候補を選び直すと、未知の未来を使うことになり、多様性を入れる効果を比較できません。

ルールと、この章での扱いを確認する
与えられるもの
候補ID 4〜9の途中評価は10・11・12・13・14・15。種類は順に0・0・0・1・1・2で、各候補は親を介して根につながっています。
決めること・求めるもの
次へ残す候補IDの一覧と、その履歴を復元するために保持すべき記録の集合です。
守る条件
総数は幅W以下、種類ごとの上限を使う場合はその件数以下にします。残した候補の祖先も保持し、完成後の値を選別には使いません。
評価・確認の基準
途中評価は小さい順に選びます。完成後の費用も小さい方がよいですが、これは選別後の答え合わせ用です。必要な記録数も別に数えます。

この章で作る部品似た候補への偏りを抑える処理と、必要な祖先を特定する処理を作ります。数選びや盤面の探索全体を動かす演習ではありません。

多様性は、異なる選び方の候補が残っていることです。似た候補だけでビーム幅を使い切らないようにし、候補が共有する履歴も管理します。

図:同じ種類で幅が埋まる場合と、種類別に枠を設ける場合

上限なし・幅3

種類0・種類0・種類0
途中評価10・11・12

種類ごとに1件

種類0・種類1・種類2
途中評価10・13・15

残す履歴

選んだ候補+祖先
共通の親は共有する

種類は粗い特徴です。同じ種類でも別の未来を持つため、完全な重複排除とは意味が違います。

1. 似た候補と同一状態を分ける

途中評価の上位だけでは、似た候補で幅が埋まる場合があります。六候補を三種類に分類し、種類ごとの上限を設けます。同じ種類でも未来は異なるので、3.3の重複排除とは別の選別です。

2. 種類別に採用数を数える

評価順に走査し、その種類が上限未満なら残します。幅に達したら終了し、候補が足りなくても無理には埋めません。上限0は制限なしです。

完成後の費用は答え合わせ専用です。設定0では多様性が有利ですが、設定1では同種の有望な候補を失います。上限の有無を実際の結果で比べます。

注目コード:種類別の上限を超えた候補を飛ばす初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
for (int id = 4; id < 10; id++) {
    int g = pool[id].group;
    if (cap > 0 && counts[g] >= cap) continue;
    kept.push_back(id);
    counts[g]++;
    if (int(kept.size()) == width) break;
}

ここを見る:この入力のID 4〜9は評価値のよい順です。全体の幅widthと、種類gごとの上限capを別々に管理します。

完成コードの L14–17 を見る →

3. 残す候補の祖先を集める

根0、親1〜3、候補4〜9の履歴から、残す候補と全祖先を集合にします。共通の親を一度だけ数えることで記録を共有できます。ビームから消えた親も、子が残れば必要です。

割当済み記録数は10、必要数はこの集合の大きさです。ノード数の比較であり、実メモリ量や時間とは区別します。

注目コード:残す候補の祖先も生存扱いにする初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
for (int id : kept) {
    best = min(best, pool[id].finished);
    for (int p = id; p != -1; p = pool[p].parent) alive.insert(p);
}

ここを見る:aliveには候補自身から根までのIDを入れます。共有する祖先は集合で一度だけ数え、どの候補からも参照されない記録と区別します。

完成コードの L19 を見る →

4. 共有と高速化を加える

演習は必要な記録の特定までです。プール再利用では不要な記録だけを解放します。共有木をたどる実装なら、親へundo、子へapplyして状態コピーを減らせます。

選別の部分選択・ヒープ化や、進捗別のビームも選択肢です。同じ候補集合と同点規則で結果を照合してから、時間・メモリを測ります。

ここまでの確認

大まかな特徴が同じなら、完全に同じ候補として消してよいですか?

答えと理由を見る

必ずしもよくありません。残りの手や資源が違うかもしれません。「似ている」と「同じ」を分けて扱います。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点3.3の復元できるビームを基準にし、候補を残す処理と記録を保持する処理を別々に改善します。

用意するデータと処理
種類別の件数
似た候補への上限に使う。完全な重複の判定とは別。
必要な記録の集合
残った候補だけでなく、その全ての祖先も含む。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 上限なしの選別を残す

    評価順の候補から幅W件を選ぶ版を基準にします。教材の候補は評価順に並んでいますが、一般の入力では先に並べます。

    ここで確認種類別上限0で、以前と同じ候補を残せる。

  2. 2. 選別の中で種類数を数える

    候補を評価順に読み、種類の件数が上限未満なら追加します。完成後の結果finishedは答え合わせ専用で、選別には使いません。

    ここで確認種類を分散できる一方、同種の有望な候補を捨てる場合も確認できる。

  3. 3. 生きている記録を調べる

    選別後の各IDから親をたどり、必要な祖先を印付けします。まず解放せず、全記録数と必要数だけを比べます。

    ここで確認選別で落ちた候補でも、残った子の親なら必要な記録に含まれる。

次に足すなら:記録領域を再利用する

必要な記録の印付けが正しいことを確認してから、不要IDだけを空きリストへ入れます。vectorの途中削除で他のIDをずらさない設計にします。

追加後の確認:複数段にわたり再利用しても、全保持版と復元した操作列が一致する。

完成コードで見る場所:counts:種類ごとの選別上限 / set<int> alive:祖先を含めた必要な記録

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

多様性
ここでは、異なる選び方の候補が残っていること。

小例の補足

途中評価10・11・12の候補は同じ種類で、13・14は別の種類、15はさらに別の種類です。幅3の選択に種類別上限を加え、完成後の結果と必要な祖先記録を比べます。

この章で扱う実装の範囲

  • 選択器:全ソート・部分選択・上位候補を保持するヒープを、候補数に合わせて選ぶ。
  • メモリ:状態プールと親IDの寿命を管理し、参照中の記録を再利用しない。
  • 共有する探索木:apply / undoで親子間を移動し、状態全体のコピーを減らす。
  • 多様性:完全な重複排除と、似た候補への種類別上限を別々に実装する。
  • 進捗別ビーム:処理量の違う手を進捗ごとの層へ振り分け、各層の幅と時間を管理する。

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:3.3 ビーム探索の履歴を復元し、重複を取り除く

発展の実装で参照する章:2.3 試した変更を、元どおりに戻す、2.6 処理ごとに測り、ボトルネックを見つける。この章の導入例を読むための必修ではありません。

理解できたら:次に広げること

候補が増えてメモリを使いすぎる場合は、共通する履歴を共有する方法も学びます。まずコピーする版を比較用に残します。

別の問題でも使うなら

作業計画なら、早く終える候補と資源を多く残す候補を分けて残すことを考えます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材ビームの一段から、残す候補と履歴を選ぶ — 問題設定・具体例を読む

3.4 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

幅3で上限0なら同じ種類の三候補、上限1なら三種類が残ります。結果の設定を1へ変えると、同種内の有望な候補を上限で落とす例になります。多様性は必ず有利になる規則ではありません。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <iostream>
#include <set>
#include <vector>
using namespace std;
struct Node{int parent,group,score,finished;};
int main(){
    const int width=3, cap=1, scenario=0;
    // ID0が根、1〜3が共通の親、4〜9が比較する候補。
    vector<Node> pool={{-1,-1,0,0},{0,0,0,0},{0,1,0,0},{0,2,0,0},
        {1,0,10,30},{1,0,11,scenario?5:31},{1,0,12,32},
        {2,1,13,8},{2,1,14,9},{3,2,15,7}};
    vector<int> kept,counts(3,0);
    for(int id=4;id<10;id++){
        int g=pool[id].group;if(cap>0 && counts[g]>=cap)continue;
        kept.push_back(id);counts[g]++;if(int(kept.size())==width)break;
    }
    set<int> alive;int best=1000;
    for(int id:kept){best=min(best,pool[id].finished);for(int p=id;p!=-1;p=pool[p].parent)alive.insert(p);}
    cout<<"kept=";for(int i=0;i<int(kept.size());i++){if(i)cout<<",";cout<<kept[i];}
    cout<<"\ngroups="<<counts[0]<<","<<counts[1]<<","<<counts[2]
        <<"\nallocated_nodes="<<pool.size()<<"\nlive_nodes="<<alive.size()<<"\ncopied_history_nodes="<<3*kept.size()
        <<"\nfinished_best="<<best<<"\n";
}

経路2 ビーム探索 / 5段階目・全5段階

3.5 ビーム探索の実装テンプレート

一段で何を決めるか、候補をどう広げて比べるかが決まれば、ビーム探索を組めます。まずは各候補に状態と操作列をコピーして持たせます。

新しい問題は使いません。3.2の数選び(8・6・5から選んで11へ近づける)を例に、別の問題でも使う部品を整理します。設定の読み直しは不要です。

自分の実装をチェックする →

1. 最小限持つ情報

Stateは合計と選択列、一段は数を一つ処理することです。子は「使う・使わない」の二つ。途中評価と完成後の目的値は、この例ではどちらも11との差ですが、役割は分けて実装します。

持つもの用途
Stateと操作列途中の答え・進捗・次の選択に必要な情報。最初は選択列をStateと一緒にコピーします。
beam / next現在の層と、次の層の候補を別の配列で持ちます。同じ層では進捗の意味をそろえます。
幅W・探索予算・同点規則Wは1以上。最大段数や期限、同じ評価の候補を残す順を決めます。
best_complete / final_cost有効な完成解とその目的値。基準の完成解を先に用意できれば、打切り時もそれを返せます。
注目コード:状態に答えの履歴も持たせる初期設定の抜粋 · 左は完成コードの行番号
struct State {
    int sum;
    std::string path;
};

ここを見る:3.2の最小構成です。合計だけでなく選択列pathもコピーするので、親IDを導入しなくても答えを取り出せます。

完成コードの L13–16 を見る →

2. 問題に合わせて作る処理

関数名は役割を表す例です。手元のコードでは、同じ役割を担う処理へ対応させてください。

部品入出力と守る条件
make_root()最初の途中状態を作る。既に完成している場合も判定する。
expand(state)一段先の子を列挙する。親を変えず、途中状態としての制約を守る子だけを返す。
rank(state)未完成候補を残す順を決める途中評価。同点規則と合わせて比較する。
is_complete(state)必要な決定が全て済んだか判定する。完成後は全制約も検査する。
cost(complete)有効な完成解の実際の目的値。途中評価と混同せず、完成解同士を比較する。
restore / write_answer候補の操作列を取り出し、初期状態から再生・検査して出力する。

3. ループにつなぐ

図:最小構成の処理順

  1. 初期化根を作る。根が完成なら保存し、未完成ならbeamへ入れる。
  2. 一段展開nextを空にし、beamの全候補から子を作る。親は書き換えない。
  3. 完成解を保存有効な完成した子を、実目的値でbest_completeへ保存する。
  4. 未完成候補を選別未完成の子をnextへ集め、途中評価で上位W件を残す。
  5. 層を更新・終了beamをnextへ替える。候補なし・予算終了なら、保存した完成解を出す。
問題ごとに作る部品を、この順につなぎます。

完成した子は、幅で切る前に保存します。途中評価が悪くても、完成解としては有効な場合があります。全ての候補が同じ段で完成する問題なら、最終段を実目的値で比較する形でも構いません。

期限までに完成解が見つからなければ、有効な基準解を返します。基準解も用意できない設計では「完成解なし」を明示し、途中状態を答えとして出しません。

注目コード:全展開・選別・層の更新を順に行う初期設定の抜粋 · 左は完成コードの行番号
std::vector<State> next;
for (const State& state : beam) {
    next.push_back({state.sum, state.path + "0"});
    next.push_back({state.sum + value, state.path + "1"});
}
std::sort(next.begin(), next.end(), better);
if (int(next.size()) > WIDTH) next.resize(WIDTH);
beam = next;

ここを見る:一つの数valueを処理する部分です。この小例は全候補が同じ最終段で完成します。途中でも完成する問題では、完成した子を選別前に別保存する処理を加えます。

完成コードの L37–44 を見る →

4. この順に実装する

  1. 根から一段だけ展開する

    一段の意味を固定し、全ての子と操作列を手計算で確かめます。親が変更されていないことも確認します。

  2. 幅を制限して次の層へ進む

    全親の展開後にnextを並べ、上位W件を残して入れ替えます。同点の順を固定し、次の段ではnextを空にします。

  3. 完成解の保存と終了をつなぐ

    完成判定と実目的値の比較を追加します。途中の完成・候補なし・期限終了の全てで返す答えを決めます。

  4. 出力を再生し、小さい全探索と比べる

    保存した操作列から状態を再現し、制約と目的値を照合します。小入力で幅を十分広げ、全候補を残した結果とも比較します。

5. 最小構成の完成後に足すもの

親IDによる履歴共有・重複排除・多様性の上限・選別の高速化は、基準版の完成後に足します。重複をまとめるなら将来の選択も等しいことを確認し、履歴を共有するなら参照中の祖先を残します。

実装の完成チェック

自分のコードで確認できた項目にチェックしてください。全項目を確認できたら、最小構成の完成です。

0 / 12 項目を確認
必要な部品
層と完成解の管理
完成の確認

チェックはこのページを開いている間、保持されます。

最小構成を説明する

重複排除と親IDは未実装ですが、各候補が操作列をコピーして持ち、一段展開・幅での選別・完成解の保存・出力まで動きます。ビーム探索の最小構成は完成していますか?

答えと理由を見る

完成しています。重複排除は候補枠の使い方を、親IDは履歴の持ち方を改善する追加実装です。まず完成チェックを通し、コピー量や候補数を測ってから必要なものを足します。幅を増やしただけで最適解が保証されるわけではありません。

演習・完成コードを参照する

コードは参照先で、閉じたまま全文をコピーできます。

共通準備から選べる経路 · 経路3

4. 観測と判断:見えない情報を推定して行動する

隠れた値や未来が分からず、返事や観測を得ながら次の行動を決めたい。

推定は、観測から見えない情報を見積もること、意思決定は、その見積もりを使って行動を選ぶことです。箱の中のカードの色を測る例で、追加測定と回答のどちらを選ぶか考えます。

観測履歴と推定を持つ↓質問・行動 → 観測 → 推定と判断を更新↻ 同じ処理を繰り返す

共通準備の固定入力に加えて、まだ分からない情報を扱います。実装を「返事を受け取る窓口」「推定の更新」「次の判断」に分け、順に作ります。

入口:共通準備 1.1〜1.8の状態・制約・評価・答えの保存。共通準備の後、この経路の最初の章から始められます。

  1. 4.1 インタラクティブ問題の入出力と、観測履歴を管理する通信の窓口を作る
  2. 4.2 ベイズ推定で、観測から仮説の確率を更新するベイズ更新を動かす
  3. 4.3 質問の情報量と費用を比べ、測定を選ぶ質問と停止を選ぶ
  4. 4.4 プレイアウトとUCBで、行動を比較する先読みとUCBで行動を比べる

到達点:通信を守り、観測から推定を更新し、質問・停止・先読みの行動比較をそれぞれ実装できる。

経路3を始める → 三つの経路を見比べる

経路3 観測・推定・意思決定 / 1段階目・全4段階

4.1 インタラクティブ問題の入出力と、観測履歴を管理する

今回の問題

箱の中のカードは赤か青。最大3回質問し、返事の多数決で答えます。模擬相手は指定した一回だけ誤答し、途中で返事が来なくなる場合もあります。質問と返事の順序を守る練習です。

入力の表・詳しいルール・計算例を確認する

箱の中のカードが赤か青かを、センサーに質問して答える

ここから、箱の中のカードの色を、センサーへの質問から当てる問題を使います。値を受け取る順番も問題のルールに含まれます。経路1・2の履修は前提にしません。

何をする問題か

箱の中のカードは赤か青のどちらか一色で、途中で色は変わりません。センサーへ質問すると「赤」か「青」と返ります。センサーは誤る場合があるので、一回の返事だけで色を断定しません。

完成コードでは相手も同じプログラム内に作ります。カードの色・誤る位置・受信可能数は相手側の設定で、答える側のsolveには渡しません。コードではRED=赤、BLUE=青として扱います。

具体例で使うデータ
初期設定の項目意味
カードは青模擬相手だけが知る実際の色
質問上限3送信できる質問は最大3回
反転位置10始まりで二回目だけ反対の色を返す
受信可能数5初期設定の三質問では途中終了しない
答えと評価を、具体例で追う
  1. 一往復ずつ記録する

    質問?を送ると返事BLUE、次は誤ったRED、三回目はBLUEが返ります。質問→受信→記録の順番を三回繰り返します。

  2. 最終回答を作る

    返事は青・赤・青。多数決で青を選び、最終回答! BLUEを一回送ります。同数なら赤、質問上限0でも赤を基準回答にします。

  3. 途中で返事が来なくなる場合を考える

    受信可能数を1へ変えると、二回目の質問後に入力終了となります。赤・青の返事として数えず、中断し、最終回答も送らない規則です。

ここで間違えやすいことこの章の誤答は「指定した一回を反転する」固定の仕組みです。次のベイズ更新で用いる「毎回独立に一定確率で誤る」モデルとは区別してください。

ルールと、この章での扱いを確認する
与えられるもの
相手だけが知るカードの色と、質問回数の上限。自分が使える情報はセンサーから受信した返事だけです。
決めること・求めるもの
上限まで質問した後、赤か青を最終回答します。導入版は返事の多数決を使い、同数・観測なしなら赤を答えます。
守る条件
質問を送ってから返事を読みます。返事はRED(赤)かBLUE(青)。入力終了や不正な応答では中断し、追加質問も最終回答も送りません。
評価・確認の基準
最終回答が実際の色と合うかを確認します。この章では測定費用を含む最適化は行わず、通信を正しく終えることを先に確かめます。

この章で作る部品同じプログラム内の模擬相手と通信します。指定した一回だけ反対の色を返す設定は通信確認用で、4.2の確率的な測定モデルとは別です。

インタラクティブ問題(対話型問題)は、質問や行動を出し、返事を受け取りながら解く形式です。通信の形式・順序・終了条件を定めた規則をプロトコルと呼びます。

図:通信の順番と、異常時に止まる場所

解く側のプログラム

予算を確認
質問を送信する

通信の窓口

返事を読む
終了・不正形式なら停止

内部状態

観測を追加
次の質問か最終回答

模擬ジャッジには真値があっても、ソルバが使うのは通信の窓口と観測履歴だけです。

1. 通信の窓口を分ける

解く側のプログラムをソルバ、質問に返事をする問題側をジャッジと呼びます。Judge は質問するaskと最終回答のfinishを公開します。ソルバはこの窓口と予算だけを受け取り、真値は受け取りません。まず相手を手元で再現する模擬ジャッジ(MockJudge)で、返事の反転や受信打切りを試します。4.2の確率的な誤差とは別の設定です。

注目コード:質問と回答だけを窓口にする初期設定の抜粋 · 左は完成コードの行番号
struct Judge {
    virtual int ask() = 0;
    virtual void finish(int answer) = 0;
    virtual ~Judge() = default;
};

ここを見る:ソルバが受け取るのはこの窓口です。MockJudgeが持つ実際のカードの色を、推定処理へ直接渡しません。

完成コードの L7–11 を見る →

2. 質問→受信→更新を守る

予算を確認して質問し、返事を読んでから観測を更新します。この例は多数決で答え、同数や観測なしなら赤です。推定法を替えても通信順序は保ちます。

回数は送信時に増やします。受信に失敗しても、送った質問は予算へ戻しません。

3. 終了や不正な返事で止まる

RED・BLUE以外や入力終了を受けたら中断し、以後の質問も最終回答も送りません。予算3・受信可能数1で、二回目の受信後に止まるか確認します。正常終了時は最終回答を一回だけ送ります。実問題では、その終了規則に合わせます。

注目コード:返事を検査してから観測に加える初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
while (queries < budget) {
    queries++;
    int y = judge.ask();
    if (y != RED && y != BLUE) return {-1, queries, true};
    if (y == RED) reds++;
    else blues++;
}

ここを見る:不正な返事や入力終了は、askから赤・青以外として返ります。ここでsolveを終了するので、後続の質問や最終回答へ進みません。

完成コードの L35–39 を見る →

4. 本番の入出力へ差し替える

同梱のStreamJudgeは ?・! 形式を使います。flushは、出力バッファに残った内容を送り出す処理です。質問の出力後に行ってから返事を読み、読取り成功を確認します。mainは模擬相手を使うので単独で実行できます。

本番ではコマンド形式を合わせ、診断出力を標準出力へ混ぜません。真値や未来の返事をソルバへ渡さず、4.2の推定、4.3の質問選択へつなぎます。

注目コード:送信してから受信する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
cout << "?\n" << flush;
string y;
if (!(cin >> y)) return -1;

ここを見る:StreamJudgeのask内です。flushで質問を送り出し、その後で返事を読みます。読めなければ失敗を呼出し側へ返します。

完成コードの L27 を見る →

ここまでの確認

練習用の相手が知っている実際の色を、答えを選ぶ処理へ渡してよいですか?

答えと理由を見る

いけません。本番では見えない情報を使ってしまいます。測定の返事と実際の色は、別々に管理します。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点推定を入れる前に、質問・受信・終了を一巡できる通信の窓口を作ります。

用意するデータと処理
Judge
質問askと最終回答finishの窓口。
solve
窓口と予算だけを受け取り、観測から判断する。本当の値は引数にしない。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 模擬相手で一往復する

    MockJudgeを作り、solveからaskを一回呼んで返事を記録します。まず固定の返事で接続を確かめます。

    ここで確認ソルバが使える情報は返ってきた値だけで、真値へ直接アクセスしていない。

  2. 2. 終了条件を先に入れる

    質問予算を送信前に確認し、入力終了・不正な応答では更新せず停止します。最後の回答を送る条件も決めます。

    ここで確認予算0で質問せず、受信が途中で切れたら無効な値で推定を続けない。

  3. 3. 本番入出力を差し替える

    StreamJudgeへ問題の質問形式、受信形式、flushを実装します。solveの判断処理を変えずに窓口だけを入れ替えます。

    ここで確認送信後に返事を受け取れ、デバッグログが質問の出力へ混ざらない。

次に足すなら:ベイズ更新を追加する

多数決している観測の集計箇所を、4.2の推定更新へ置き換えます。通信ログから同じ返事列を再生し、窓口の変更と推定の変更を別々に検証します。

追加後の確認:同じ観測列なら、模擬通信と再生で同じ事後分布になる。

完成コードで見る場所:Result solve:ソルバと相手を分ける窓口 / StreamJudge:本番の入出力へつなぐ場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

インタラクティブ問題(対話型)
質問や行動を出し、返事を受け取りながら解く形式。
プロトコル
通信の形式・順序・終了条件などの規則。
ソルバ / ジャッジ
解く側のプログラムと、質問に返事をする問題側。
flush
出力バッファに残った内容を送り出す処理。質問後に行ってから返事を待つ。

小例の補足

箱の中のカードは赤か青です。模擬ジャッジは一回だけ反対の返事をしたり、途中で受信を終了したりします。ソルバは質問の窓口と予算だけを受け取り、返事を多数決に使います。

この章で扱う実装の範囲

  • 通信の窓口:質問・応答・最終回答を関数へ分け、模擬ジャッジと本番の入出力を差し替える。
  • プロトコル:出力形式・flush・質問回数・予算・終了応答・入力終了を問題の仕様どおり扱う。
  • 情報の分離:観測履歴、推定状態、模擬ジャッジの真値を分け、ソルバへ真値を渡さない。
  • 再現:送受信と状態更新を順にログへ残し、同じ応答列で意思決定を再生する。
  • 逐次判断:未来の入力を読めない問題と、自分で質問を選べる問題を区別する。

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.5 一手の変更が守る条件を決める、1.8 探索中の状態と、提出する最良解を分ける

理解できたら:次に広げること

まず一つのプログラム内で相手とのやり取りを練習し、その後に実際の入出力へつなぎます。

別の問題でも使うなら

要求が一つずつ到着する問題でも、未来の要求を先に読まず、今までに見えた情報で判断します。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材箱の中のカードが赤か青かを、センサーに質問して答える — 問題設定・具体例を読む

4.1 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

質問上限3、受信可能数1なら二回目に入力終了を受け、最終回答を送らず止まります。真値を切り替えても、ソルバへ渡されるのは返事だけです。返事を反転すると、多数決の答えが変わる場合もあります。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <algorithm>
#include <string>
using namespace std;
enum Color { RED=0, BLUE=1 };
const char* color_name(int c){return c==RED?"RED":c==BLUE?"BLUE":"NONE";}
struct Judge {
    virtual int ask()=0;
    virtual void finish(int answer)=0;
    virtual ~Judge()=default;
};
class MockJudge:public Judge {
    int truth,flip,available,turn=0; // 箱の中の色と未来の返事はソルバから見えない。
public:
    MockJudge(int truth,int flip,int available):truth(truth),flip(flip),available(available){}
    int ask() override {
        cout<<"send=?\n";
        if(turn>=available){cout<<"recv=EOF\n";return -1;}
        int reply=(turn++==flip)?1-truth:truth;
        cout<<"recv="<<color_name(reply)<<"\n";return reply;
    }
    void finish(int answer) override {cout<<"send=!"<<color_name(answer)<<"\n";}
};
// この教材の ? / ! 形式の通信アダプタ。本番では問題の形式へ合わせる。
struct StreamJudge:Judge {
    int ask() override {
        cout<<"?\n"<<flush;string y;if(!(cin>>y))return -1;
        if(y=="RED")return RED;if(y=="BLUE")return BLUE;return -1;
    }
    void finish(int answer) override {cout<<"! "<<color_name(answer)<<"\n"<<flush;}
};
struct Result{int answer,queries;bool aborted;};
Result solve(Judge& judge,int budget){
    int reds=0,blues=0,queries=0;
    while(queries<budget){
        queries++;int y=judge.ask();
        if(y!=RED && y!=BLUE)return {-1,queries,true};
        if(y==RED)reds++;else blues++;
    }
    int answer=reds>=blues?RED:BLUE; // 同数・観測なしは固定の基準回答RED。
    judge.finish(answer);return {answer,queries,false};
}
int main(){
    const int truth=1, budget=3, flip=1, available=5;
    MockJudge judge(truth,flip,available);
    Result r=solve(judge,budget);
    cout<<"queries="<<r.queries<<"\nanswer="<<color_name(r.answer)<<"\naborted="<<r.aborted<<"\n";
}

経路3 観測・推定・意思決定 / 2段階目・全4段階

4.2 ベイズ推定で、観測から仮説の確率を更新する

今回変わる条件参照:4.1 の問題設定
変更後の問題設定と具体例を確認する

センサーの返事から、カードが赤・青である確率を更新する

カードが赤か青という題材は同じですが、測定の誤り方が変わります。前の章の反転設定と混同しないよう、次の条件を確認してください。

何をする問題か

同じカードを使い、今度はセンサーの誤り方が確率で分かっているものとします。カードの色は固定ですが、測定ごとに正しい色を返すか、反対の色を返すかが変わります。

初期設定では赤・青を半々と考えます。正答率75%なので、カードが赤なら返事は赤75%・青25%、カードが青なら青75%・赤25%です。

具体例で使うデータ
仮説最初の確率「赤」が返る確率積
カードは赤1/23/43/8
カードは青1/21/41/8
答えと評価を、具体例で追う
  1. 返事を一つ受け取る

    「赤」という返事は実際に赤の場合に出やすいものの、青でも出ます。各仮説の元の確率と、その返事が出る確率を掛けます。

  2. 二つを同じ尺度へ戻す

    積の合計は3/8+1/8=1/2です。各積をこの合計で割ると、更新後の確率は赤3/4、青1/4になります。

  3. 初期設定の観測列を最後まで読む

    赤→赤→青の順に更新すると、カードが赤である確率は1/2→3/4→9/10→3/4と変わります。最後に「青」と返っても、過去の二回の「赤」が消えるわけではありません。

ここで間違えやすいこと求めるのは、今ある情報から見積もった色の確率です。初期設定では赤をより有力と判断しますが、確率3/4は正解を保証する数字ではありません。

ルールと、この章での扱いを確認する
与えられるもの
初期設定では赤・青の事前確率は各1/2。センサーは実際の色を3/4、反対の色を1/4の確率で返します。カードの色は固定で、各回の誤りは独立とします。
決めること・求めるもの
一回返事を受け取るたびに、カードが赤である確率と青である確率を更新します。
守る条件
実際の色は更新処理へ渡しません。新しく得た観測だけを一度ずつ反映し、二つの確率の合計を1に保ちます。
評価・確認の基準
この章で求めるのは事後確率です。初期設定で「赤」を一回観測すると、カードが赤である確率は3/4になります。

この章で作る部品尤度を掛けて正規化する更新と、長い観測列でも計算しやすい対数での実装を作ります。測定するかの判断は4.3で扱います。

ベイズ推定は、観測前の確率と観測の起こりやすさを使い、仮説の確率を更新する方法です。ここでは「カードは赤」「カードは青」という二つの仮説を、返事を得るたびに比べ直します。

図:更新後の確率を、次の観測の出発点にする

観測前の確率

H=赤:50%
H=青:50%

返事「赤」を観測

H=赤:75%
H=青:25%

もう一度「赤」

H=赤:90%
H=青:10%

次は「青」

H=赤:75%
H=青:25%

センサーの正答率75%、真値を固定したときの誤りは各回独立という設定です。反対の返事が来れば、以前に有力だった仮説の確率も下がります。

1. 観測モデルを決める

Hはカードの色、Yは返事で、どちらも赤か青です。センサーが正しい色を返す確率をq、反対を1−qとします。仮説ごとの観測前の確率を事前分布、観測後の確率を事後分布と呼びます。Hを仮定したとき、実際に得た返事が出る確率が尤度です。

仮説返事「赤」の尤度返事「青」の尤度
H=赤q1−q
H=青1−qq

Hとqを固定したとき各回の誤差は独立とします。別々の観測で同じ値を得ることと、一つの観測の二重取込みは区別します。

注目コード:返事と仮説ごとに尤度を作る初期設定の抜粋 · 左は完成コードの行番号
const double likelihood_red = reply == RED ? accuracy : 1.0-accuracy;
const double likelihood_blue = reply == BLUE ? accuracy : 1.0-accuracy;

ここを見る:replyは受け取った色です。カードが赤という仮説と青という仮説に対し、この返事の起こりやすさを別々に求めます。

完成コードの L28–29 を見る →

2. 尤度を掛け、合計で割る

返事yに対し w[h] = prior[h] × likelihood(y | h) を計算し、合計Zで割って総和を1にします。これを正規化と呼びます。

P(H=h | Y=y) = P(Y=y | H=h) P(H=h) / Z

Z = Σh P(Y=y | H=h) P(H=h)

事前1/2ずつ、q=3/4、返事「赤」なら、重み3/8・1/8から事後3/4・1/4になります。これを次の事前に使います。確率はモデルの下での推定なので、高くなっても真値の確定ではありません。

3. 対数で小さい数を扱う

積が小さくなりすぎるのを避け、log_probability に対数尤度を加えます。更新後の対数重みをa、最大値をmとして、「指数の和の対数」を安定して求めるlog-sum-expを使って正規化します。

log Z = m + log(Σ exp(a[h] − m))

最大値を引くと少なくとも一項が1になり、数値が安定します。log_probability[h] = a[h] − logZ とし、表示時だけ指数関数で確率へ戻します。

注目コード:対数のまま掛け算と正規化を行う初期設定の抜粋 · 左は完成コードの行番号
const double a = log_probability[0] +
    (likelihood_red > 0 ? log(likelihood_red) : negative_infinity);
const double b = log_probability[1] +
    (likelihood_blue > 0 ? log(likelihood_blue) : negative_infinity);

ここを見る:log_probabilityは前回までの確率の対数です。尤度の対数を足すことで、通常の確率での掛け算に対応させます。

完成コードの L30–33 を見る →
注目コード:合計の対数を引いて総和を1に戻す初期設定の抜粋 · 左は完成コードの行番号
const double log_normalizer = top + log(exp(a-top) + exp(b-top));
log_probability = {a-log_normalizer, b-log_normalizer};
posterior = {exp(log_probability[0]), exp(log_probability[1])};

ここを見る:topはaとbの最大値です。最大値を引いてから指数を取り、正規化した対数確率を保存します。posteriorは表示・判断に使う通常の確率です。

完成コードの L39–41 を見る →

4. 説明できない観測で止める

正答率100%なのに「赤→青」と返ると、固定したどちらの色でも説明できず、全重みが0になります。mismatch_at と posterior=NOT_DEFINED を出して止め、モデルや読取りを確認します。0で割ったり一様分布へ戻したりしません。

演習では正答率50%で更新なし、75%で更新あり、100%で矛盾の検出を確認できます。

注目コード:全仮説の重みが0なら止める初期設定の抜粋 · 左は完成コードの行番号
const double top = max(a, b);
if (!isfinite(top)) {
    mismatch_at = used;
    break;
}

ここを見る:二つとも対数重みが−∞なら、どちらの色でも説明できない観測です。正規化せずに、矛盾が出た観測番号を保存します。

完成コードの L34–38 を見る →

5. 通信と判断につなぐ

演習は観測済みの列を再生し、真値を推定側へ渡しません。4.1の返事ごとに一回更新し、4.3へ事後分布を渡す形で接続できます。

ここでは有限仮説を実装しました。連続値の平均・分散、観測式、最小二乗へ広げる場合も、推定値と不確かさを判断へ渡します。

ここまでの確認

最初が1/2ずつなら、「赤」という返事の後はどんな計算になりますか?

答えと理由を見る

赤には1/2×3/4=3/8、青には1/2×1/4=1/8が付きます。合計1/2でそれぞれ割ると、赤は3/4、青は1/4になります。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点観測済みの返事列を入力にし、二仮説の更新を一回ずつ確かめます。通信は4.1の窓口へ任せます。

用意するデータと処理
仮説ごとの重み
仮説IDと確率、または対数確率。
尤度と更新
仮説と観測から尤度を求め、全重みを更新して正規化する。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 一観測の計算を固定する

    まず二仮説・事前1/2・正答率3/4で、事前×尤度を計算して総和で割る基準版を作ります。

    ここで確認返事「赤」の後に確率3/4と1/4になり、総和が1になる。

  2. 2. 観測を一度ずつ反映する

    更新した事後を次の事前にします。更新関数は新しい観測一つだけを受け取る形にし、履歴全体を再度掛けないようにします。

    ここで確認2→2で9/10へ更新され、同じ一観測を二重に反映していない。

  3. 3. 対数へ移して比較する

    長い観測列向けに、積を対数の加算へ置き換え、最大の対数重みを引いて正規化します。全て負の無限大なら矛盾として返します。

    ここで確認短い列では基準版と一致し、起こりえない観測を無理に正規化しない。

次に足すなら:仮説を二つから多数へ増やす

二変数の更新を、仮説ごとの配列とループへ置き換えます。尤度の計算を別関数にし、最大値・指数の和・正規化を配列全体で行います。

追加後の確認:仮説の並びを変えても対応する確率は同じで、確率0の仮説も扱える。

完成コードで見る場所:log_probability:保持する対数重み / log_normalizer:正規化と矛盾検出の付近

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

ベイズ推定
観測前の確率と観測の起こりやすさを使い、仮説の確率を更新する方法。
事前分布 / 事後分布
仮説ごとの観測前の確率と、観測後の確率。更新した事後分布を、次の観測の事前分布にする。
尤度
候補が本当だと仮定したとき、今の測定結果が出る確率。
正規化
この例では、二つの値を合計で割り、全体を1にそろえること。

小例の補足

カードは赤か青で、最初は同じくらいありそうだとします。センサーは一回ごとに3/4の確率で実際の色を返す設定です。「赤」という返事を得たら、赤の方を有力にします。

この章で扱う実装の範囲

  • 有限仮説のベイズ更新:事前分布・観測モデル・尤度・正規化・事後分布を実装する。
  • 観測モデル:誤差の分布、仮説を固定したときの観測の独立性、複数の量をまとめて測る場合を定義する。
  • 数値安定性:対数で重みを持ち、log-sum-expで正規化する。全仮説で確率0の観測を検出する。
  • 連続値の推定:平均・分散を持つ型、最小二乗で複数の測定をまとめる型へ広げ、残差を確認する。
  • 不確実性の保持:一点の推定値に潰さず、次の質問や最終判断に分布を渡す。

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:4.1 インタラクティブ問題の入出力と、観測履歴を管理する

理解できたら:次に広げること

この計算はセンサーの誤り方を仮定しています。測定を重ねる例では、各回の誤りが独立に起きる設定を使います。

別の問題でも使うなら

道の長さを推定する場合は、返事が一つの道の長さなのか、通った複数の道の合計なのかを先に決めます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材センサーの返事から、カードが赤・青である確率を更新する — 問題設定・具体例を読む

4.2 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

事前50%・正答率75%で返事が赤→赤→青なら、H=赤の確率は50%→75%→90%→75%です。正答率50%では更新されません。正答率100%・返事が赤→青では二つ目でモデル矛盾になります。一手送りで事前・尤度・事後を対応させてください。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

#include <cmath>
#include <iomanip>
#include <limits>

enum Color { RED=0, BLUE=1 };
const char* color_name(int c) { return c==RED ? "RED" : "BLUE"; }
int main() {
    const double prior_red = 50 / 100.0;
    const double accuracy = 75 / 100.0;
    const int sequence = 3;
    // 観測済みの返事だけを入力にする。隠れた真値は使わない。
    const vector<vector<int>> examples = {
        {RED}, {RED,RED}, {RED,BLUE}, {RED,RED,BLUE}, {BLUE,BLUE,RED},
        {RED,RED,RED,RED,RED,RED,RED,RED,BLUE,BLUE,BLUE,BLUE,BLUE,BLUE,BLUE,BLUE}
    };
    vector<double> log_probability = {log(prior_red), log(1.0-prior_red)};
    vector<double> posterior = {prior_red, 1.0-prior_red}; // 赤、青の順
    const double negative_infinity = -numeric_limits<double>::infinity();
    int mismatch_at = 0, used = 0;
    cout << fixed << setprecision(6);
    for (int reply : examples[sequence]) {
        ++used;
        const double likelihood_red = reply == RED ? accuracy : 1.0-accuracy;
        const double likelihood_blue = reply == BLUE ? accuracy : 1.0-accuracy;
        const double a = log_probability[0] +
            (likelihood_red > 0 ? log(likelihood_red) : negative_infinity);
        const double b = log_probability[1] +
            (likelihood_blue > 0 ? log(likelihood_blue) : negative_infinity);
        const double top = max(a, b);
        if (!isfinite(top)) {
            mismatch_at = used; // 全仮説で確率0。正規化せず、モデルの矛盾を報告。
            break;
        }
        const double log_normalizer = top + log(exp(a-top) + exp(b-top));
        log_probability = {a-log_normalizer, b-log_normalizer};
        posterior = {exp(log_probability[0]), exp(log_probability[1])};
        cout << "step=" << used << "," << color_name(reply) << "," << exp(log_normalizer)
             << "," << posterior[0] << "," << posterior[1] << "\n";
    }
    cout << "mismatch_at=" << mismatch_at << "\n";
    if (mismatch_at) {
        cout << "posterior=NOT_DEFINED\n";
    } else {
        cout << "posterior=" << posterior[0] << "," << posterior[1] << "\n";
        // 数値的な同率は同率として扱い、微小な丸め誤差で断定しない。
        cout << "favours_red=" << (posterior[0] > posterior[1] + 1e-12) << "\n";
    }
    return 0;
}

経路3 観測・推定・意思決定 / 3段階目・全4段階

4.3 質問の情報量と費用を比べ、測定を選ぶ

今回変わる条件参照:4.2 の問題設定
変更後の問題設定と具体例を確認する

もう一回測る費用と、色を答え間違える損失を比べる

二つの仮説とベイズ更新は前の章と同じです。確率を求めるだけでなく、質問を選ぶために費用と損失を加えます。

何をする問題か

同じカードの色について、今度は「もっと測る価値があるか」を決めます。測定は有料なので、正確さだけでなく、減らせる誤答の損失と費用を比べます。

一回だけ先を考えます。今すぐ色を答えるか、センサーAまたはBで一回測ってから答えるかを選びます。測った後は返事に応じて確率を更新し、確率の高い色を最終回答します。

具体例で使うデータ
選択正答率測定費用誤答時の損失
今すぐ答える追加測定なし010
センサーAを使う75%110
センサーBを使う90%210
答えと評価を、具体例で追う
  1. 測らない場合を評価する

    事前確率が半々なら、赤・青どちらを答えても誤答率は1/2です。期待損失は10×1/2=5になります。

  2. 測った場合を評価する

    事前が半々なら、測定後に返事と同じ色を答えるのが最善です。Aなら1+10×1/4=3.5、Bなら2+10×1/10=3なので、Bを選びます。

  3. 既に有力な仮説がある場合を考える

    赤の確率が90%なら、今すぐ赤と答える損失は10×0.1=1です。初期の測定費用では、追加測定するより安くなります。

ここで間違えやすいこと「情報が増える」と「費用を払う価値がある」は同じではありません。事前が偏った場合は、返事をそのまま答えると決めず、返事ごとの事後確率から色を選び直します。

ルールと、この章での扱いを確認する
与えられるもの
初期設定は赤の確率50%、誤答損失10。センサーAの正答率は75%・費用1、Bは90%・費用2です。誤る場合は反対の色を返します。
決めること・求めるもの
測らず答えるか、どちらのセンサーを使うかを決めます。測った場合は返事に応じて推定を更新し、赤か青を答えます。
守る条件
この演習で追加できる質問は一回です。質問を選ぶ時点では、まだ来ていない返事や実際の色は使えません。
評価・確認の基準
測定費用+最終回答の期待誤答損失を最小化します。正答したときの誤答損失は0です。

この章で作る部品可能な返事ごとに確率と事後を仮計算し、期待損失で三択を比較します。情報利得も表示しますが、採否の基準は費用込みの損失です。

情報利得は、観測によって不確実さがどれだけ減るかを表す量です。よく分かる質問が得とは限らないため、ここでは測定費用も含めた期待損失で質問か停止かを選びます。

図:測定の返事ごとに、最終的な判断まで考える

測らず答える

事前50%ずつ
期待誤答損失5

測定A:費用1

正答率75%
費用込み期待損失3.5

測定B:費用2

正答率90%
費用込み期待損失3

誤答損失10の例です。情報が増えた量だけでなく、測定費用と、測定後にどの答えを選ぶかも含めて比べます。

1. 測らない場合を基準にする

H=赤の確率p、誤答損失Lなら、確率の高い方を今答える期待損失は L × min(p, 1−p) です。これと、AかBで一回測って答える費用を比べます。追加質問だけを比べると、不要な測定を続けてしまいます。

注目コード:質問しない場合の損失を持つ初期設定の抜粋 · 左は完成コードの行番号
vector<double> risk = {loss * min(p, 1-p)};

ここを見る:pはカードが赤である確率です。有力な色を答えると誤答確率はmin(p,1−p)なので、誤答時の損失lossを掛けます。

完成コードの L13 を見る →

2. 返事ごとの損失を足す

返事yに対し a=p P(y|H=赤)、b=(1−p) P(y|H=青) とします。返事の確率はa+b、事後のH=赤の確率はa/(a+b)です。

返事後に確率の高い仮説を答えると、期待誤答損失への寄与は L × min(a,b)。全返事について足し、測定費用を加えます。

注目コード:返事ごとの期待誤答損失を足す初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
double a = p * (reply == RED ? q : 1-q),
       b = (1-p) * (reply == BLUE ? q : 1-q), z = a+b;
double posterior = a/z;
future_loss += loss * min(a,b);

ここを見る:a・bは「仮説と今回の返事が同時に成り立つ確率」です。min(a,b)には返事の確率zも含まれるため、zをさらに掛ける必要はありません。

完成コードの L18–20 を見る →

3. 情報利得と費用を区別する

エントロピーは確率分布の不確実さを表す数です。二仮説なら H(p)=−p log₂p−(1−p)log₂(1−p) で、半々のとき最大、どちらかに確定すると0です。観測による減少量の期待値が情報利得です。ただし不確実さが減っても、回答が変わらない、費用が高い、といった場合があります。

この教材は費用込み期待損失で選びます。情報利得・分散・予測の食い違いを、最終損失の代わりに比較する代理指標にする場合も、最終損失との関係を確認します。

4. 測定をやめる判断も試す

事前50%・損失10・費用A=1、B=2なら、測らない5、Aは3.5、Bは3です。事前90%なら測らない損失1が最小になります。同点でも測らない方を優先します。

ここでは一回先まで比較します。繰り返すなら観測後に再判断し、複数回先まで考えるなら事後分布を状態にして、質問・停止の分岐を展開します。

注目コード:費用込みで最小の選択肢を残す初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
int chosen = 0;
for (int i = 1; i < 3; i++)
    if (risk[i] < risk[chosen] - 1e-12) chosen = i;

ここを見る:riskの0は測らない場合、1・2はA・Bの費用と期待誤答損失の合計です。同率では先にある「測らない」を残します。

完成コードの L27 を見る →

ここまでの確認

正答率が高い測定は、いつでも選ぶべきですか?

答えと理由を見る

いいえ。すでに答えがほぼ決まっていれば、追加の費用に見合わないことがあります。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点4.2の分布と観測モデルを使い、まず「今すぐ答える」場合の期待損失を計算します。

用意するデータと処理
質問候補
費用と、仮説ごとの返答の起こり方。
質問の評価
返答の確率×その後の最小損失を合計し、質問費用を足した値。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 停止を候補に含める

    現在の分布から最良の回答と期待損失を求め、riskの先頭へ入れます。測定候補がなくても選べるようにします。

    ここで確認事前90%のとき、低い誤答確率を反映した停止値になる。

  2. 2. 返事ごとの仮更新を作る

    質問ごとに可能な返答を列挙し、返答確率と仮の事後を求めます。現在の分布を上書きせず、その事後で最良の回答を選び直します。

    ここで確認返答確率の合計が1になり、質問を評価しただけでは現在の分布が変わらない。

  3. 3. 費用を足して選ぶ

    各返答後の損失を返答確率で重み付けし、質問費用を一度だけ足します。停止を含む最小値の候補を採用します。

    ここで確認測定精度が高くても、費用が大きければ停止を選べる。

次に足すなら:残り予算を加える

質問候補を列挙した直後に予算不足の候補を除外します。実際に質問したときだけ予算を減らし、受信後に推定を更新して再選択します。

追加後の確認:仮の質問評価で予算を消費せず、最後の一回の後には停止できる。

完成コードで見る場所:vector<double> risk:停止も含めた候補比較 / future_loss:返答分岐の期待損失。コードのa・bは仮説と返答の同時確率なので、loss*min(a,b)で既に返答確率の重み付けを含む

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

エントロピー
確率分布の不確実さを表す数。この二仮説の例では半々で最大、片方に確定すると0。
情報利得
観測によるエントロピーの減少量の期待値。
情報の価値
ここでは、追加で調べたことで最後の判断がどれだけよくなるか、という意味で使います。

小例の補足

事前50%ずつ・誤答損失10なら、測らない期待損失は5。正答率75%・費用1の測定Aは3.5、90%・費用2のBは3です。事前を90%へ変えると、測らず答える方がよくなります。

この章で扱う実装の範囲

  • 質問候補:どの仮説でどの返答が起きるか、尤度と費用の表を作る。
  • 情報利得:返答ごとの確率と事後分布から、エントロピーの期待減少を計算する。
  • 意思決定の価値:測定後に選ぶ答えと誤答損失まで含め、期待損失を比較する。
  • 停止判断:測定の費用、残り予算、測らず答える場合を比べて終了する。
  • 代理指標の比較:分散や仮説間の予測差を使う簡単な規則を、情報利得・最終得点と区別する。

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:4.2 ベイズ推定で、観測から仮説の確率を更新する

理解できたら:次に広げること

候補をよく区別できる質問と、実際の答えを変える質問は、いつも同じではありません。まず候補が二つの例で確かめます。

別の問題でも使うなら

仕事の重さを測る場合は、測定によって担当の決め方が変わるかまで調べます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材もう一回測る費用と、色を答え間違える損失を比べる — 問題設定・具体例を読む

4.3 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

事前50%・損失10では費用込みでBが最小です。事前を90%にすると、既に有力な答えがあるため測らず答えます。情報利得が正でも、費用に見合うとは限りません。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <cmath>
#include <iomanip>
#include <iostream>
#include <vector>
using namespace std;
enum Color { RED=0, BLUE=1 };
const char* color_name(int c){return c==RED?"RED":"BLUE";}
double entropy(double p){return (p>0?-p*log2(p):0)+(p<1?-(1-p)*log2(1-p):0);}
int main(){
    const double p=50/100.0, loss=10; // p = カードが赤である事前確率
    const vector<double> accuracies={.75,.9},costs={ 1, 2 };
    vector<double> risk={loss*min(p,1-p)};
    cout<<fixed<<setprecision(6)<<"stop_risk="<<risk[0]<<"\n";
    for(int i=0;i<2;i++){
        double future_loss=0,expected_entropy=0,q=accuracies[i];
        for(int reply=0;reply<2;reply++){
            double a=p*(reply==RED?q:1-q),b=(1-p)*(reply==BLUE?q:1-q),z=a+b;
            double posterior=a/z;
            future_loss+=loss*min(a,b);
            expected_entropy+=z*entropy(posterior);
            cout<<"branch="<<i<<","<<color_name(reply)<<","<<z<<","<<posterior<<"\n";
        }
        risk.push_back(costs[i]+future_loss);
        cout<<"query="<<i<<","<<risk.back()<<","<<entropy(p)-expected_entropy<<"\n";
    }
    int chosen=0;for(int i=1;i<3;i++)if(risk[i]<risk[chosen]-1e-12)chosen=i;
    cout<<"chosen="<<chosen<<"\nexpected_loss="<<risk[chosen]<<"\n";
}

経路3 観測・推定・意思決定 / 4段階目・全4段階

4.4 プレイアウトとUCBで、行動を比較する

今回の問題

容量3を使って仕事を受けます。今の仕事は容量2で報酬3、次の仕事は容量2で報酬が8か0(半々)です。今受けると次は受けられません。二回の合計報酬を大きくする行動を選びます。

入力の表・詳しいルール・計算例を確認する

今の仕事を受けるか、次の仕事を待つか決める

前の測定問題から、容量を使って仕事を受ける問題へ移ります。ここでは未知の固定値を当てるのではなく、まだ来ていない仕事を見越して今の行動を選びます。

何をする問題か

使うと戻らない容量3を持っていて、仕事を受けるたびに容量を消費して報酬を得るとします。仕事は今と次の二回だけ来ます。今の選択が次を受けられるかを変えます。

今の仕事の内容は分かっていますが、次の報酬はまだ見えません。分かっているのは、8と0が半々で出るという生成モデルです。

具体例で使うデータ
仕事必要な容量得られる報酬
今の仕事2確定で3
次の仕事2確率1/2で8、1/2で0
答えと評価を、具体例で追う
  1. 今受ける場合を追う

    容量3から2を使うので、残りは1です。次の仕事に必要な2を確保できず、合計報酬は必ず3で終わります。

  2. 今断る場合を追う

    容量3が残るため、次の仕事を受けられます。合計報酬は8か0で、期待値は(8+0)/2=4です。

  3. 実行前に仮の未来を何回か試す

    「今受ける」と「待つ」を本物の状態のコピー上で試します。待つ側は試行ごとに8か0が出るため、平均が4付近へ近づくまでにはばらつきがあります。

ここで間違えやすいこと試行の回数は実際に受けた仕事の数ではありません。二つの行動を比較するためのシミュレーション回数です。最後に一つの行動を選んだ段階で、本物の意思決定を行います。

ルールと、この章での扱いを確認する
与えられるもの
残り容量は3。今の仕事は容量2・報酬3です。次に来る仕事は容量2で、報酬8または0が各1/2の確率で生じます。
決めること・求めるもの
今の仕事を受けるか断るか。断れば次の仕事を受けられます。今受けると容量が足りず、次は受けられません。
守る条件
使った容量は戻りません。実際に次に来る報酬は、今の判断では見えません。試行中は本物の容量を減らしません。
評価・確認の基準
合計報酬の期待値を最大化します。この小例では今受けると3、待つと期待値4で、試行による推定の答え合わせができます。

この章で作る部品二行動を交互に試す方法とUCBによる試行配分を比べ、最後は試行で得た平均報酬から行動を選びます。

プレイアウトは、仮の状態で先の行動を進め、最後の結果を調べることです。UCBは、成績のよい候補と、まだ試行が少ない候補の両方へ試行を振り分けるための指標です。

図:本物の状態を変えず、候補ごとに未来を試す

今、受ける

容量2・報酬3
次の要求は入らない

今、断る

容量3を保持
次の報酬8または0

試行結果を集約

平均・試行数を保存
次に試す候補を選ぶ

小例の真の期待報酬は3と4ですが、実装では仮の将来を試して推定します。平均だけでなく試行数も残します。

1. 仮の未来を最後まで試す

今受けると報酬3、断って次を待つと報酬8か0で期待値4です。行動後の仮の状態から未来を生成し、最後の報酬を測って比べます。

本物の状態は変更しません。この小例の厳密な期待値は答え合わせに使い、複雑な未来はシミュレータと簡単な行動規則で試します。

注目コード:仮の報酬だけを統計へ加える初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
int reward = arm == 0 ? 3 : (rng.next()/4294967296.0 < .5 ? 8 : 0);
counts[arm]++;
totals[arm] += reward;

ここを見る:arm=0は今受ける、1は次を待つ行動です。この小例は先の報酬を直接生成します。試行結果はcounts・totalsに記録し、本物の残り容量は変更しません。

完成コードの L23–24 を見る →

2. 候補ごとに平均を保存する

報酬合計と試行数を持ち、未試行を一回ずつ試して平均を求めます。予算1では最初の候補しか試せません。交互に試す版を基準にし、「待つ」側の報酬列は配分によらず同じseedから生成します。

3. UCBで試行を配分する

UCB(Upper Confidence Bound)は、平均に「試行が少なく、まだ期待できる分」を加えた指標です。報酬を0〜1にそろえ、試行済み候補を次の値で比べます。

平均 + c × sqrt(log(全試行数) / 候補の試行数)

平均が高い候補と、試行が少ない候補を両方調べる指標です。cを大きくすると試行の少ない側へ配分しやすくなります。最後の行動は平均報酬で選びますが、有限回では選び間違いもあります。

注目コード:平均と未探索の度合いを足す初期設定の抜粋 · 左は完成コードの行番号
double a = totals[0]/(8*counts[0]) + c*sqrt(log(double(t))/counts[0]);
double b = totals[1]/(8*counts[1]) + c*sqrt(log(double(t))/counts[1]);
arm = b > a ? 1 : 0;

ここを見る:この部分は両候補を一回以上試した後に実行します。平均報酬を最大報酬8で正規化し、試行回数が少ない候補ほど大きい加算項を付けます。

完成コードの L18–20 を見る →

4. 探索木へ広げる

モンテカルロ木探索(MCTS)は、行動の分岐を木で持ち、仮の試行結果を使って有望な枝を詳しく調べる方法です。候補選択→子の展開→プレイアウト→祖先への結果反映を繰り返します。未試行ノードの扱いと、試行後に状態を戻す処理も必要です。

誤った未来モデルは、試行を増やしても直りません。モデルの仮定、推定のばらつき、選んだ行動の実得点を分けて検証します。

ここまでの確認

候補を試すたびに、本物の残り容量も減らしてよいですか?

答えと理由を見る

いけません。試すのはコピー側です。本物を変えるのは、採用する行動を決めた後の一回だけです。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点各行動を同じ回数だけ試す版を作り、未来の生成・試行・集計を分けてからUCBへ進みます。

用意するデータと処理
行動ごとの統計
試行回数countsと報酬合計totals。平均はこの二つから求める。
試行用の状態
現在の状態のコピーと仮の未来。本物の状態や未観測の真値を使わない。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 一試行で一つの報酬を返す

    行動を一つ適用し、仮の未来で最後まで進め、報酬を返す処理を用意します。まず行動を交互に試します。

    ここで確認試行しても本物の資源は減らず、countsの総和が試行数になる。

  2. 2. 試す行動の選択だけ変える

    未試行の行動を先に選び、その後だけUCB値を比較します。報酬の尺度と探索係数をそろえます。

    ここで確認回数0で割らず、予算1でも終了できる。

  3. 3. 最終の行動を別に選ぶ

    予算を使い終えたら、探索ボーナスを含まない平均報酬で行動を選びます。有限回の推定結果を、複数seedで比較します。

    ここで確認試行を配る指標と、最後に実行する行動の基準が混ざっていない。

次に足すなら:複数手の木へ広げる

MCTSにするならノードへ子ID・回数・報酬合計を持たせ、選択→未展開の子を作る→そこから試行→通った祖先へ結果を加える順に足します。初めは状態コピーを使います。

追加後の確認:各試行で通ったノードだけを一度ずつ更新し、深さ1では今の行動比較へ戻る。

完成コードで見る場所:counts:行動ごとの試行数 / int chosen:試行終了後の選択

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

プレイアウト
仮の状態で先の行動を進め、最後の結果を見ること。
UCB
候補の平均報酬に、試行が少ない候補を調べるための加点を付けた指標。次の試行先を選ぶために使う。
モンテカルロ木探索(MCTS)
行動の分岐を木で持ち、仮の試行結果を使って有望な枝を詳しく調べる探索。

小例の補足

今受ける行動の報酬は3。次を待つ行動は、確率1/2ずつで8または0です。各行動を交互に試す方法とUCBで配分する方法を、同じ試行予算で比べます。

この章で扱う実装の範囲

  • 先読み:観測済みの情報から将来を生成し、コピー上でプレイアウトする。
  • 比較:各行動の試行回数・平均・ばらつきを記録する。
  • 試行配分:未試行の行動を先に試し、UCBなどで探索と活用を配分する。
  • 探索木への拡張:選択・展開・評価・結果の反映を分け、MCTSへつなげる。
  • 検証:真の将来列を使った分析結果と、本番で使える将来モデルによる結果を分ける。

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:4.1 インタラクティブ問題の入出力と、観測履歴を管理する、4.3 質問の情報量と費用を比べ、測定を選ぶ

発展の実装で参照する章:3.1 途中の答えを、何で比べるか考える。この章の導入例を読むための必修ではありません。

理解できたら:次に広げること

まず各候補を同じ回数試します。有望な候補へ試行を多く割り当てる方法や、先読みを何段も続ける方法は後から扱います。

別の問題でも使うなら

新しい仕事が後から来る日程作成でも、仮の到着を作って、今の受け入れ方を比べられます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材今の仕事を受けるか、次の仕事を待つか決める — 問題設定・具体例を読む

4.4 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

試行予算1では一方しか試せません。予算を増やし、交互の試行とUCBの回数配分を比べます。seedや探索係数で選択が変わります。解析上の期待報酬は、今受ける3、次を待つ4です。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <cmath>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <vector>
using namespace std;
struct Random{uint32_t x;uint32_t next(){x^=x<<13;x^=x>>17;x^=x<<5;return x;}};
int main(){
    const int budget=40, seed=42;const double c=100/100.0;
    const bool use_ucb=true;
    Random rng{uint32_t(seed)};vector<int> counts(2,0);vector<double> totals(2,0);
    cout<<fixed<<setprecision(6);
    for(int t=0;t<budget;t++){
        int arm;
        if(counts[0]==0)arm=0;else if(counts[1]==0)arm=1;
        else if(!use_ucb)arm=t%2;
        else{
            double a=totals[0]/(8*counts[0])+c*sqrt(log(double(t))/counts[0]);
            double b=totals[1]/(8*counts[1])+c*sqrt(log(double(t))/counts[1]);
            arm=b>a?1:0;
        }
        // 容量3で今の容量2・報酬3を受けるか、次の容量2・報酬8/0を待つか。
        int reward=arm==0?3:(rng.next()/4294967296.0<.5?8:0);
        counts[arm]++;totals[arm]+=reward;
        cout<<"step="<<t+1<<","<<arm<<","<<reward<<","<<counts[0]<<","<<counts[1]<<"\n";
    }
    double a=counts[0]?totals[0]/counts[0]:0,b=counts[1]?totals[1]/counts[1]:0;
    int chosen=counts[1] && b>a?1:0;
    cout<<"counts="<<counts[0]<<","<<counts[1]<<"\nmeans="<<a<<","<<b<<"\nchosen="<<chosen<<"\n";
}

5. 共通の検証と実装部品

三つの経路は、ここで同じ比較の手順へ戻ります。局所探索なら近傍や温度、ビーム探索なら幅や評価、観測と判断なら質問の選び方を変え、同じ入力・実行予算で効果を比べます。

共通の検証と実装部品

5.1 いくつかの入力で、本当によくなったか比べる

今回の問題

同じ四入力の結果を比べます。変更前は100・120・110・90、変更後は90・130・失敗・85です。値は小さい方がよいものとし、対応する成功例の平均と失敗数を別に集計します。

入力の表・詳しいルール・計算例を確認する

同じ入力で動かした二つの版を比較する

三つの経路から共通して読む集計の練習です。新しい解を作るのではなく、改良前後の実行結果が入力ごとに与えられるところから始めます。

何をする問題か

新しい実装がよくなったかを見るには、同じ入力に対する改良前後の結果を並べます。この章は、既に実行して保存した結果の集計だけを扱います。

入力IDは比較するケースの番号です。例えばID1の値120と130は同じ入力を二つの実装で解いた結果であり、違う入力の値を並べたものではありません。

具体例で使うデータ
入力ID変更前変更後(初期設定)判定
010090改善
1120130悪化
2110実行失敗比較対象から外し失敗として数える
39085改善
答えと評価を、具体例で追う
  1. 対応する成功例を選ぶ

    両方が成功した入力は0・1・3の三つです。前の値も、この同じ三入力だけを取り出します。

  2. 同じ対象で平均を計算する

    変更前は(100+120+90)/3=約103.33、変更後は(90+130+85)/3=約101.67です。成功例の範囲では平均が小さくなっています。

  3. 失敗を加えて判断する

    改善2件・悪化1件・失敗1件と報告します。この教材の全体改善判定は「失敗なし、かつ平均改善」なので、初期設定では全体改善とはしません。

ここで間違えやすいこと失敗を解除した場合の入力2の値は100です。失敗を0点扱いにすると、最小化では不正な実行が最もよい値に見えてしまいます。

ルールと、この章での扱いを確認する
与えられるもの
入力ID 0〜3の変更前の値は100・120・110・90。変更後は90・130・100・85を基に、一つを実行失敗へ変えられます。初期設定では入力2が失敗です。
決めること・求めるもの
成功した同じ入力同士の平均、改善・悪化・同点の件数、失敗件数です。
守る条件
前後を同じ入力IDで対応させます。失敗を目的値0へ置き換えず、平均の対象から外した件数を別に残します。
評価・確認の基準
この演習では、全件成功かつ全件の平均が減ったときに全体改善と判定します。成功例の平均だけで全体の改善を決めません。

この章で作る部品保存済みの記録を集計する部分を作ります。表示する数字は教材用で、実際のコンテスト入力を計測した結果ではありません。

同じ入力で改良前後を比べ、目的値だけでなく悪化や失敗も記録します。

図:平均へまとめる前に、入力ごとの結果を残す

入力0

100 → 90
改善

入力1

120 → 130
悪化

入力2

110 → 実行失敗
平均から消して終えない

同じ成功入力同士の平均と、失敗件数を別に報告します。この図の数値は集計方法を学ぶための例です。

1. 入力と設定を結果に残す

入力集合・seed・実行予算をそろえ、入力ID、実装の版、設定、終了状態、目的値、時間を記録します。演習の四入力は集計用の架空データです。各結果を改善・悪化・同点・失敗に分けます。

2. 同じ成功入力で平均を取る

変更後の入力2が失敗なら、前後とも成功した0・1・3の平均を比べます。変更前だけ四入力の平均にすると、入力構成の差が混ざります。

失敗数は別に報告します。この演習の overall_improved=1 は「失敗なし、かつ全件平均が改善」です。実問題に失敗時の採点規則があれば、それに従う集計も行います。

注目コード:失敗を数え、同じ入力の成功例を足す初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
if (i == failed) { failures++; cout << "FAILED\n"; continue; }
cout << after[i] << "\n";
paired++;
old_sum += before[i];
new_sum += after[i];

ここを見る:失敗した入力は平均の両側から除き、failuresへ残します。beforeとafterを同じ入力IDで足すため、異なるケース集合の平均を比べずに済みます。

完成コードの L13–14 を見る →

3. 平均以外も見る

改善・悪化の件数、差の大きい入力、時間、失敗率も見ます。乱択なら複数seedで試し、調整用と最終確認用の入力を分けます。同じ試行数での比較と同じ実時間での比較を区別し、C++の時間は手元で測ります。

注目コード:成功例の改善と全体の判定を分ける初期設定の抜粋 · 左は完成コードの行番号
cout << "overall_improved=" << (failures == 0 && new_sum < old_sum) << "\n";

ここを見る:平均が下がっただけでは全入力での改善とはしません。この教材の判定では、失敗0件も必要です。

完成コードの L20 を見る →

4. 変更を一つずつ比較する

近傍・温度・評価項を一度に替えず、追加前後やその要素を外した版を比べます。版と設定を保存すれば、悪化した入力を再実行できます。

自動化するときは、複数実行・タイムアウト・出力検査・保存を行うランナーへつなぎます。失敗も記録に残す規則を先に決めます。

ここまでの確認

実行に失敗した入力を平均から消して、改善したと報告してよいですか?

答えと理由を見る

いけません。成功した例だけの平均では実際の使いやすさを判断できません。失敗の件数と、その入力も残します。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点改良前後のソルバを同じ入力で実行し、まず入力ごとの結果を保存するところから作ります。

用意するデータと処理
一実行の記録
入力ID・seed・設定・目的値・実時間・成功か失敗か。
対応づける集計
同じ条件の前後を結び、差・成功件数・失敗件数を出す。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 比較のキーをそろえる

    入力IDとseedをキーに前後の記録を対応づけます。最初は教材の固定した四行で集計処理を確かめます。

    ここで確認配列の並びが違っても、別の入力の値を比較しない。

  2. 2. 成功と失敗を先に分ける

    実行失敗・不正出力・時間超過を記録し、成功した同じ入力同士だけで差を集計します。失敗件数は必ず別に残します。

    ここで確認失敗を小さな得点0へ置き換えたり、平均から消しただけで改善と報告しない。

  3. 3. 内訳を見てからまとめる

    改善・悪化・同点の件数と前後の平均を出します。大きく悪化した入力へ戻れるよう、生の記録を保持します。

    ここで確認平均が下がった理由を入力ごとの変化から追える。

次に足すなら:複数seedの比較を足す

同じ入力で複数seedを回す外側ループを追加し、記録のキーへseedを含めます。速度比較は同じコンパイル条件・同じ実時間予算で行います。

追加後の確認:一度だけよかったseedを選ばず、入力別の傾向とばらつきを確認できる。

完成コードで見る場所:failures:失敗件数の管理 / paired:対応した成功例の件数

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

ケース
一つの入力例と、それに対する実行。
再現
同じ入力と設定を使って、結果を確かめ直せること。

小例の補足

四入力の変更前100・120・110・90と、変更後90・130・失敗・85を比べます。成功した同じ入力の平均と、失敗件数を別々に記録します。

この章で扱う実装の範囲

  • 入力 ID・探索 seed・設定・実行版を記録したケース別結果
  • 終了状態、目的値、公式得点、実時間の保存
  • 同じケース集合での変更前後の比較
  • 調整用と確認用のケース分割、要素を外した比較

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.3 有効な答えと、その検査処理を先に作る、1.8 探索中の状態と、提出する最良解を分ける

理解できたら:次に広げること

設定を調整する入力と、最後の確認に使う入力を分けます。Pythonでの自動実行は、まず数件を手で比較してから導入します。

別の問題でも使うなら

題材が変わっても同じ比較表は使えます。ただし、距離と金額のように意味が違う値を、そのまま一つの平均にはしません。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材同じ入力で動かした二つの版を比較する — 問題設定・具体例を読む

5.1 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

失敗IDを2から−1へ変えてください。成功例の平均が小さくても、失敗を無視した全体改善とは報告できません。入力1の値を上げれば、他の改善を打ち消す場合もあります。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iomanip>
#include <iostream>
#include <vector>
using namespace std;
int main(){
    const int failed=2, changed=130;
    // 実行済みログを表す教材用データ。実際の性能測定結果ではない。
    vector<int> before={100,120,110,90}, after={90,changed,100,85};
    int failures=0, improved=0,worsened=0,tied=0,paired=0;
    double old_sum=0,new_sum=0;
    for(int i=0;i<4;i++){
        cout<<"case="<<i<<","<<before[i]<<",";
        if(i==failed){failures++;cout<<"FAILED\n";continue;}
        cout<<after[i]<<"\n";paired++;old_sum+=before[i];new_sum+=after[i];
        if(after[i]<before[i])improved++;else if(after[i]>before[i])worsened++;else tied++;
    }
    cout<<fixed<<setprecision(6)<<"paired_before="<<old_sum/paired<<"\npaired_after="<<new_sum/paired
        <<"\nfailures="<<failures<<"\nimproved="<<improved<<"\nworsened="<<worsened<<"\ntied="<<tied<<"\n";
    // 失敗を除いた平均を、全入力での改善として扱わない。
    cout<<"overall_improved="<<(failures==0 && new_sum<old_sum)<<"\n";
}

共通の検証と実装部品

5.2 最短路を、ヒューリスティック探索の部品にする

今回の問題

3行4列のマスに、左上から0〜11の番号を付けます。初期の壁はマス5だけ。上下左右へ動き、0から11への最短経路を求めます。壁で到達できない場合も扱います。

入力の表・詳しいルール・計算例を確認する

壁を避けて、始点から終点への最短経路を求める

ここでは壁のある盤面で、一つの始点から終点まで移動する問題を使います。前の実行結果の集計とは別の題材です。

何をする問題か

盤面の左上から右下へ移動する経路を一つ求めます。動けるのは隣接する上下左右で、壁のあるマスへは入れません。目的は移動回数を少なくすることです。

マス番号は上段の左から振ります。番号が連続していても、3と4のように行をまたぐ二マスは隣ではありません。初期設定では5だけが壁です。

具体例で使うデータ
行左から並ぶマス
上段0(始点) 1 2 3
中段4 5(壁) 6 7
下段8 9 10 11(終点)
答えと評価を、具体例で追う
  1. 経路を答えとして書く

    例えば[0,1,2,3,7,11]なら、右へ三回、下へ二回進んで終点へ着きます。経路の配列は六マスですが、移動回数は五回です。

  2. 条件を一辺ずつ確認する

    隣り合う二要素が上下左右の隣であり、壁を通っていないことを確認します。[0,1,5,9,10,11]は5を通るため使えません。

  3. 壁の変更後を考える

    配置2では7と10も壁になります。終点11の隣が両方塞がるので到達できません。前に求めた五歩の経路をそのまま使うと、存在しない道を答えたことになります。

ここで間違えやすいことこの部品が求めるのは、一つの始点と終点の間の経路です。複数の目的地をどの順に回るかは、別の計画として決めます。

ルールと、この章での扱いを確認する
与えられるもの
3行4列の盤面です。マスIDは上段から0〜3、4〜7、8〜11。始点は0、終点は11で、壁の配置は演習で切り替えます。
決めること・求めるもの
始点から終点へのマスIDの列と移動回数。到達できない場合は経路なしを返します。
守る条件
一回で上下左右の隣のマスへ移動できます。盤面の外や壁へは入れません。初期設定ではマス5が壁です。
評価・確認の基準
一移動の費用を1とし、移動回数を最小化します。経路なしの表示値−1は、短い経路を意味しません。

この章で作る部品BFSと経路復元を作り、壁を変えた後に古い経路を使い回してよいかを検査します。

ヒューリスティック探索は、最適解の保証より、限られた時間でよい答えを見つけることを重視する探索です。その中でも、二地点間の最短路のように正確に解ける部分は既存アルゴリズムへ任せます。

図:距離表の再利用には、盤面が同じという前提がある

固定盤面でBFS

距離と親を保存
SからGへ5歩

壁を変更

Gの隣を塞ぐ
以前の経路は通れない

再計算・再生

到達不可を返す
古い距離5を採用しない

最短路のアルゴリズムを知っていても、外側の探索が何を変えるかによって保存した計算結果を再利用できる範囲は変わります。

1. 正確に解く部分を切り出す

訪問順は外側の探索で変え、固定盤面の二地点間はBFSへ任せます。部分問題の関数は、距離と出力用の経路を返します。演習ではマスIDを 行×4+列 とし、各移動の費用を1にします。

注目コード:訪問時に直前のマスを保存する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
int v = r*4+c;
if (wall[v] || parent[v] != -1) continue;
parent[v] = u;
q.push(v);

ここを見る:uから隣のvへ進む場面です。parentが未設定のマスだけを追加し、最短経路を戻れるように直前の位置も記録します。

完成コードの L20–22 を見る →

2. 到達不可を先に判定する

到達不可なら空の経路と距離−1を返します。−1を最小費用として採用せず、外側で到達可否を検査します。壁配置2では終点の隣を塞ぎ、この失敗を確認できます。

注目コード:到達できた場合だけ経路を復元する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
if (parent[11] == -1) return {};
vector<int> path;
for (int v = 11; ; v = parent[v]) {
    path.push_back(v);
    if (v == 0) break;
}
reverse(path.begin(), path.end());
return path;

ここを見る:目的地11が未訪問なら空の経路を返します。到達できた場合は親を始点0までたどり、始点からの順に反転します。

完成コードの L25–27 を見る →

3. 保存結果の有効条件を決める

計算結果を保存して再利用することをキャッシュと呼びます。壁を変えても古い経路を使うと、距離5と表示されても新しい壁を通ります。変更後の盤面で再生して検出します。

固定盤面なら始点ごとの事前計算が使えます。壁や辺重みが変わるなら、更新番号をキーに含める、変更時に破棄する、影響範囲を再計算する、といった方針を決めます。

4. 条件に合うアルゴリズムへ替える

非負重みならDijkstra、小さい選択範囲ならDP、指定頂点をつなぐなら最小全域木などを使えます。正確に最小化できるのは固定条件下の部分問題です。全体の訪問順や頂点集合も最適とは限りません。5.3で結果を操作列へつなぎます。

ここまでの確認

二地点間の最短路が分かれば、三地点を訪れる順番も最良になりますか?

答えと理由を見る

なりません。各区間が最短でも、訪問する順番で合計距離が変わります。順番を選ぶ処理は別に必要です。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点まず現在の盤面だけを受け取り、始点から終点への経路を一つ返すBFSを作ります。

用意するデータと処理
盤面とBFS用配列
固定の壁、毎回初期化する親・訪問済み情報。
経路の結果
到達できれば頂点列、到達不可なら明示した失敗。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 一回のBFSを完成させる

    親を未訪問で初期化し、始点を登録します。範囲内・壁でない・未訪問の順に調べて隣を追加します。

    ここで確認端のマスから外へ出ず、到達不可では空の経路を返す。

  2. 2. 経路を復元して再生する

    親を終点からたどり、反転します。現在の盤面で全ての辺が隣接し、壁を通らず、始点・終点が合うか別に調べます。

    ここで確認距離だけ正しく見えても、古い壁配置の経路を検出できる。

  3. 3. 固定部分だけ保存する

    訪問順だけを変えるなら距離や経路を事前計算します。壁や移動費用が変わったら、影響する保存結果を破棄して再計算します。

    ここで確認保存結果が、どの盤面・移動規則に対するものか追える。

次に足すなら:複数地点の距離表へ広げる

BFSの始点・終点を引数へ移し、必要な各始点から一回ずつ探索します。到達不可は通常の距離と分け、訪問順評価側でも検査します。

追加後の確認:小盤面で表の値と毎回の探索が一致し、到達不可を短い経路として扱わない。

完成コードで見る場所:shortest:現在の壁から経路を求める関数 / parent:経路復元の記録

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

内側で正確に解く部分
訪問順を固定したときの各区間の最短路。全体を近似して探索しながら、一部は既知のアルゴリズムで正確に解く。
事前計算の有効範囲
壁や移動コストが変わらなければ距離表を再利用できる。探索がそれらも変更するなら、再計算の条件が必要になる。

小例の補足

3行4列の盤面で、0から11への経路をBFSで求めます。壁を変えた後、以前の経路を再利用すると不正になる例を試します。

この章で扱う実装の範囲

  • 小さい状態空間・頂点・辺の定義
  • 等コスト移動の BFS と親の記録
  • 状況に応じた Dijkstra・DP・最小全域木
  • 部分問題の結果を外側へ返すインターフェース
  • bitmaskでの小集合列挙と、割当・マッチング・フローを使う条件の見取り図(実装は選択補講)

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.6 検査と評価を分け、全計算を基準にする

理解できたら:次に広げること

非負の重み付きならDijkstra法へ替えるなど、内側の条件に合うアルゴリズムを選びます。DPや最小全域木も、固定した条件の下で部分問題を解く部品として利用します。

別の問題でも使うなら

外側で大まかな選び方を変え、内側で小さい計算を正確に解く分け方は、配達や設備の接続でも使えます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材壁を避けて、始点から終点への最短経路を求める — 問題設定・具体例を読む

5.2 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

配置2では目的地へ到達できません。キャッシュを使い回すと距離5を報告しますが、表示された経路は新しい壁を通っています。距離だけでなく経路の再生も確認します。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
vector<int> walls(int layout){
    vector<int> w(12,0);w[5]=1;
    if(layout==1)w[6]=1;
    if(layout==2)w[7]=w[10]=1;
    return w;
}
vector<int> shortest(const vector<int>& wall){
    vector<int> parent(12,-1);queue<int> q;q.push(0);parent[0]=0;
    const int dr[]={-1,0,1,0}, dc[]={0,1,0,-1};
    while(!q.empty()){
        int u=q.front();q.pop();
        for(int k=0;k<4;k++){
            int r=u/4+dr[k], c=u%4+dc[k];
            if(r<0||r>=3||c<0||c>=4)continue;
            int v=r*4+c;
            if(wall[v]||parent[v]!=-1)continue;
            parent[v]=u;q.push(v);
        }
    }
    if(parent[11]==-1)return {};
    vector<int> path;for(int v=11;;v=parent[v]){path.push_back(v);if(v==0)break;}
    reverse(path.begin(),path.end());return path;
}
int main(){
    const int layout=0;const bool reuse=false;
    auto wall=walls(layout), actual=shortest(wall);
    auto used=reuse?shortest(walls(0)):actual;
    bool valid=!used.empty();for(int v:used)if(wall[v])valid=false;
    cout<<"reported="<<(used.empty()?-1:int(used.size())-1)
        <<"\nactual="<<(actual.empty()?-1:int(actual.size())-1)<<"\npath=";
    for(int i=0;i<int(used.size());i++){if(i)cout<<",";cout<<used[i];}
    cout<<"\npath_valid="<<valid<<"\n";
}

共通の検証と実装部品

5.3 大まかな計画を、実際の手順へ変える

原点から訪問先0=(4,0)、1=(0,3)、2=(4,3)を回り、原点へ戻ります。訪問順[0,1,2]を上下左右の一歩ずつの命令へ変換し、全訪問・帰還・移動回数22を再生で確かめます。

入力の表・詳しいルール・計算例を確認する

訪問順を、実際の上下左右の移動へ直す

前の章の経路部品を、訪問順から操作を作る考え方へつなぎます。ただし、ここでは壁のない座標平面と三つの訪問先へ変更します。始点・地点IDの意味を確認してください。

何をする問題か

三つの場所へ立ち寄って出発点へ戻る計画を、実際の移動命令へ変換します。訪問順の配列だけでは一歩ずつ動く機械は実行できないので、上下左右の操作列へ展開します。

前の格子盤面とは別の設定で、壁はありません。出発点は(0,0)ですが、訪問先ID 0は(4,0)です。出発点と訪問先0を区別してください。

具体例で使うデータ
名前座標
出発点(0,0)
訪問先0(4,0)
訪問先1(0,3)
訪問先2(4,3)
答えと評価を、具体例で追う
  1. 訪問順を区間へ分ける

    [0,1,2]なら、出発点→訪問先0→訪問先1→訪問先2→出発点です。区間ごとの距離は4、7、4、7で、合計22になります。

  2. 各区間を操作へ直す

    完成コードは各区間で横へ動き、その後に縦へ動きます。上の順なら、RRRR、LLLLUUU、RRRR、LLLLDDDをつなぎます。R/Lはx、U/Dはyを一つずつ増減します。

  3. 操作列だけから再現する

    初期位置に戻して一文字ずつ実行し、全地点を訪れたことと帰還を確かめます。順を[0,2,1]にすると距離は4+3+4+3=14になります。

ここで間違えやすいこと帰路を消した列は短くなりますが、問題の答えではありません。この問題では途中で別の訪問先を通った場合も訪問済みとなり、同じ地点を再び通っても構いません。

ルールと、この章での扱いを確認する
与えられるもの
出発点(0,0)、訪問先0=(4,0)、1=(0,3)、2=(4,3)。壁はありません。
決めること・求めるもの
まず三地点の訪問順を決め、それをU・D・L・Rの操作列へ変換します。地点ID 0は出発点ではなく、座標(4,0)の訪問先です。
守る条件
全地点を一回以上訪れ、出発点へ戻ります。同じマスや地点を途中で再び通ることは許されます。
評価・確認の基準
上下左右へ一歩動く費用を1とし、帰路を含む操作数を小さくします。区間の距離はマンハッタン距離です。

この章で作る部品固定した訪問順から操作列を作り、初期位置から再生して訪問・帰還・距離を検査します。

デコーダは、訪問順などの大まかな計画を、実行できる細かい手順へ変換する処理です。探索では短い計画を変え、出力する操作列はデコーダに作らせます。

図:短い計画と、実際に提出する手順を照合する

計画

訪問する地点IDの順序
0 → 2 → 1

デコーダ

各区間を移動へ展開
右・上・左・下…

再生して検査

全地点を訪問
出発点へ帰還・費用一致

この例では計画を変えると操作列全体を再生成します。計画上の評価値と、操作列の長さを別々に求めて照合します。

1. 探索するのは短い計画にする

状態は三地点の訪問順です。各地点へ一回以上立ち寄り、原点へ戻ります。同じ場所の通過は繰り返して構いません。一歩ずつの操作列より、順番だけを持つ方が小さな状態になります。

2. 計画を操作列へ展開する

デコーダは各区間を横、次に縦へ進む命令にし、帰路も加えます。訪問順0→1→2なら22歩、0→2→1なら14歩です。

壁があれば区間移動を5.2へ替え、到達不可を失敗として返します。優先順から作業の開始時刻を作る処理も、同じ計画→手順の型です。

注目コード:一つの区間を具体的な命令へ変換する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
auto go = [&](int a, int b) {
    while (x < a) { operations += 'R'; x++; }
    while (x > a) { operations += 'L'; x--; }
    while (y < b) { operations += 'U'; y++; }
    while (y > b) { operations += 'D'; y--; }
};

ここを見る:現在位置(x,y)から目的地(a,b)まで、操作列operationsと位置を一緒に更新します。この題材には壁がないため、横・縦の順で進めます。

完成コードの L16–19 を見る →

3. 計画の評価と実行結果を照合する

計画の区間距離を計算し、別に生成した操作列を原点から再生します。全訪問・帰還・歩数を調べ、計画上の値と照合します。帰路を省く不具合を試し、valid と cost_match の両方を確認してください。

注目コード:出力した命令を初期位置から再生する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
x = y = 0;
vector<int> visited(3, 0);
for (char op : operations) {
    if (op == 'R') x++;
    if (op == 'L') x--;
    if (op == 'U') y++;
    if (op == 'D') y--;
    for (int i = 0; i < 3; i++)
        if (points[i] == make_pair(x,y)) visited[i] = 1;
}

ここを見る:計画orderではなく、実際に出すoperationsを検査します。再生後の位置とvisitedから、帰還と全地点の訪問を確認します。

完成コードの L23–30 を見る →

4. 最良の手順を再現できるようにする

乱数や時間制限を使うデコーダでは、探索時と提出時で結果が変わりえます。処理を決定的にする、seedを保存する、最良の操作列を保存するなどで対応し、最後の出力も再生します。

短い計画では作れない操作列もあります。その制限が探索の弱点になっていないかも比べます。

ここまでの確認

計画の評価と、実際に出す手順の距離が違っていたら、そのまま使えますか?

答えと理由を見る

使えません。比較した答えと提出する答えが別物になっています。手順を作る規則と、距離の計算を確認します。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点短い計画を探索する前に、固定した訪問順を出力操作へ変換できるようにします。

用意するデータと処理
計画
訪問先IDの順列。
変換と再生
計画から操作列を作る処理と、初期状態から操作列を実行する検査。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 一区間を操作へ展開する

    まず現在位置から一地点へ動くgoを作ります。位置更新と、追加した操作の対応を確かめます。

    ここで確認一移動の後の座標が目標と一致する。

  2. 2. 区間をつなぎ、帰還を加える

    訪問順にgoを呼び、最後に倉庫へ戻します。戻り先を含む全ルートを、計画上でも別に評価します。

    ここで確認帰路を含む期待距離と、生成した操作数が一致する。

  3. 3. 生成処理とは別に再生する

    座標と訪問記録を初期化し直し、操作列だけを読み直します。この検査を、探索で採用する計画の出力前にも通します。

    ここで確認帰路を省く不具合を入れると、短くても不正な出力だと分かる。

次に足すなら:障害物を加える

goの中身を5.2の経路探索へ置き換え、経路がなければ変換失敗を返します。失敗した計画は探索側で拒否し、途中までの操作列を提出しません。

追加後の確認:生成と再生の両方が同じ壁の規則を使い、到達不可を扱える。

完成コードで見る場所:auto go:一区間の操作生成 / visited:初期状態からの再生検査

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

計画の表現
実際の操作列より短い訪問順などを探索状態に持つ。どの計画を扱えるかは、手順へ変換する規則にも依存する。
デコーダ
計画を具体的な手順へ変える処理。評価と出力が同じ変換規則を使うようにする。

小例の補足

出発点(0,0)から(4,0)・(0,3)・(4,3)を訪れて戻ります。訪問順を操作列へ展開し、順番号0では22歩、1では14歩になります。帰路を省く不具合も再生で検出します。

この章で扱う実装の範囲

  • Plan:探索で変更する情報
  • decode(plan):操作列と実行結果
  • 未完了・停止・制約違反を表す結果
  • 同じ計画の再現性と最終 replay

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.5 一手の変更が守る条件を決める、1.6 検査と評価を分け、全計算を基準にする、5.2 最短路を、ヒューリスティック探索の部品にする

理解できたら:次に広げること

途中で行き詰まった計画は、完成できた計画と分けて扱います。手順を作る処理に乱数を使う場合は、再実行の条件も保存します。

別の問題でも使うなら

作業の優先順から開始時刻を決める処理も、計画から実際の手順へ変える例です。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材訪問順を、実際の上下左右の移動へ直す — 問題設定・具体例を読む

5.3 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

順番号0は0→1→2で距離22、1は0→2→1で距離14です。帰路を省くと短く見えても、出発点へ戻る制約を満たしません。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <cstdlib>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
using namespace std;
int main(){
    const int permutation=0;const bool omit_return=false;
    vector<pair<int,int>> points={{4,0},{0,3},{4,3}};
    vector<int> order={0,1,2};for(int i=0;i<permutation;i++)next_permutation(order.begin(),order.end());
    int expected=0,px=0,py=0;
    for(int i:order){auto [a,b]=points[i];expected+=abs(a-px)+abs(b-py);px=a;py=b;}
    expected+=abs(px)+abs(py);
    string operations;int x=0,y=0;
    auto go=[&](int a,int b){
        while(x<a){operations+='R';x++;}while(x>a){operations+='L';x--;}
        while(y<b){operations+='U';y++;}while(y>b){operations+='D';y--;}
    };
    for(int i:order){auto [a,b]=points[i];go(a,b);}
    if(!omit_return)go(0,0);
    // 出力する操作列を初期状態から再生して、別に検査する。
    x=y=0;vector<int> visited(3,0);
    for(char op:operations){
        if(op=='R')x++;
        if(op=='L')x--;
        if(op=='U')y++;
        if(op=='D')y--;
        for(int i=0;i<3;i++)if(points[i]==make_pair(x,y))visited[i]=1;
    }
    bool valid=x==0 && y==0 && count(visited.begin(),visited.end(),1)==3;
    cout<<"order="<<order[0]<<","<<order[1]<<","<<order[2]<<"\noperations="<<operations
        <<"\nexpected="<<expected<<"\nactual="<<operations.size()<<"\nvalid="<<valid
        <<"\ncost_match="<<(int(operations.size())==expected)<<"\n";
}

6. 必要に応じて選ぶ発展

ここは通読する一本の経路ではありません。今の問題で必要な項目を選び、各章の前提を確認します。ビーム探索と観測・推定の導入は、それぞれ独立した経路にまとめてあります。

確率モデルと評価学習へ広げる

必要に応じて選ぶ発展

6.1 行き詰まったら、始め方や一部分を変える

今回変わる条件参照:2.2 の問題設定
変更後の問題設定と具体例を確認する

四つの仕事を二人へ分け直す

局所探索で使った仕事割当の小さな別入力です。割当と二乗和の考え方は同じなので、人数・仕事数・初期表の違いを確認すれば進めます。

何をする問題か

仕事の分担を、四仕事・二人に縮めて考えます。前と同じく重さは固定で、答えは各仕事の担当者です。仕事の個数と人数を小さくしたのは、改善できない理由を全候補について確認できるようにするためです。

初期状態ではAが8と7、Bが6と5を担当します。一仕事を反対側へ動かすだけでは、どの変更も偏りを大きくしてしまいます。

具体例で使うデータ
仕事ID重さ初期担当
08A(0)
17A(0)
26B(1)
35B(1)
答えと評価を、具体例で追う
  1. 今の評価値を確認する

    負荷は15と11なので15²+11²=346です。重さ5をBからAへ動かすと20と6になり、評価値436へ悪化します。

  2. 別のよい答えを確かめる

    8と5をA、7と6をBへ任せれば、13と13で評価値338です。一仕事の改善がなくても、もっとよい担当表は存在します。

  3. 外してから戻す

    仕事の一部を未担当−1にし、残した割当の負荷を集計してから、外した仕事を配置し直します。全て担当が決まった後で山登りを再開します。

ここで間違えやすいこと未担当を含む表は修復の途中です。その負荷が小さく見えても、完成した解の評価値として最良記録へ保存してはいけません。

ルールと、この章での扱いを確認する
与えられるもの
重さ8・7・6・5の四仕事と、担当者二人。初期担当は[0,0,1,1]で、負荷は15と11です。
決めること・求めるもの
各仕事をどちらへ任せるかを示す四要素の担当表です。
守る条件
完成した答えでは各仕事をちょうど一人が担当します。作り直す途中だけ未担当−1を使い、完成解として採点する前に埋めます。
評価・確認の基準
二人の負荷の二乗和を最小化します。初期値346に対し、8+5と7+6なら負荷13ずつで338です。

この章で作る部品山登りの外側へ、ランダムな再出発と、一部の割当を外して修復する処理を加えます。

始める答えを変えて探索を繰り返すのが多点スタート、答えの一部を外して作り直すのが部分再構築です。一手では改善できなくなったところから、別の答えへ移るために使います。

図:改善する一手がなくても、別の出発点を作れる

現在の答え

8+7 / 6+5
一つ移すと悪化

一部を外して作り直す

未担当を−1で記録
残した負荷から再配置

探索を再開

現在値は変わってよい
最良値346→338を保存

取り壊した途中状態は完成解として採点せず、修復・検査の後で探索へ戻します。

1. 山登りが止まる例を作る

負荷15・11、評価値346は、一仕事の移動では改善しません。13・13なら338なので、別の割当に移る余地はあります。

climb はID順で最初の改善を採用し、先頭から調べ直します。全候補が改善しなければ終了。この関数の外側に再出発を加えます。

2. 全体か一部を作り直す

ランダム再出発は全担当を乱数で決め直します。部分再構築は選んだ仕事だけを−1にし、残りの負荷を再計算してから、未担当を重い順に負荷の小さい人へ戻します。

−1をscoreへ渡しません。外した仕事を記録し、全件を戻せたか検査してから山登りを再開します。

注目コード:残した仕事の負荷を集計してから埋め直す初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
int load[2] = {0,0};
for (int i = 0; i < 4; i++)
    if (current[i] >= 0) load[current[i]] += weight[i];
for (int i = 0; i < 4; i++) if (current[i] < 0) {
    int g = load[0] <= load[1] ? 0 : 1;
    current[i] = g;
    load[g] += weight[i];
}

ここを見る:未担当を−1で表します。既存の担当を保ったまま未担当だけを埋め、−1がなくなってからscoreで評価します。

完成コードの L37–43 を見る →

3. 出発点と最良解を分ける

再構築で悪化しても、この例では新しい出発点にします。bestは改善時だけ更新します。四仕事全てを外すと貪欲法で13ずつになりますが、一仕事だけなら元へ戻る場合もあります。

外す数を増やすと探索範囲と修復時間が増え、よい構造も壊れます。大きいほどよいとは限りません。

注目コード:再出発しても最良解は別に残す初期設定の抜粋 · 左は完成コードの行番号
climb(current);
if (score(current) < score(best)) best = current;

ここを見る:再構築したcurrentを山登りで仕上げ、その結果が過去のbestを改善したときだけ保存します。出発点を変えてもbestは失いません。

完成コードの L45–46 を見る →

4. 同じ予算で比較する

外した直後・修復後・山登り後を追い、seed、再出発回数、外す数を変えます。bestが悪化しないことを確認し、実問題では同じ実時間で比べます。

外した部分を全探索やDPで埋める型も使えます。一部の決定を外し、その部分を解き直してまとめて変更する方法を大規模近傍探索(LNS)と呼びます。

ここまでの確認

作り直しに失敗したら、取り出した仕事を未担当のままにしてよいですか?

答えと理由を見る

いけません。試す前の答えへ戻します。最初から作り直す方法でも、提出用の答えは別に残します。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点改善しなくなるまで動くclimbを先に完成させ、その外側へ再出発・再構築を追加します。

用意するデータと処理
外す仕事の一覧
変更対象のID。未担当の表と、元へ戻すための有効解を分ける。
修復処理
部分的な割当を受け取り、全仕事を戻せたかを返す。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 山登りの停止を再現する

    初期解からclimbを呼び、単独移動では346で止まることを確認します。bestは外側に保存します。

    ここで確認一手も改善しないことと、全体によりよい答えがないことを区別できる。

  2. 2. 外す・戻すを一組で作る

    対象だけを−1へ戻し、残した仕事から負荷を再計算します。未担当だけを再配置し、全て戻してからscoreを呼びます。

    ここで確認対象外の担当が変わらず、途中の−1を添字として使わない。

  3. 3. 外側のループへ組み込む

    再構築後に検査してclimbを呼び直します。新しい出発点をcurrentへ渡し、bestは改善時だけ更新します。

    ここで確認現在の出発点が悪化しても、保存した最良値は悪化しない。

次に足すなら:少数の仕事を全探索で修復する

未担当を埋める部分だけを全列挙へ置き換えます。残した仕事を固定し、対象が多すぎる場合は貪欲な修復へ戻します。失敗時は元の有効解を使います。

追加後の確認:全仕事を探索し直さず、外した部分だけの組合せを試している。

完成コードで見る場所:void climb:内側の山登り / current[i]<0:未担当だけを修復する場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

多点スタート
始め方をいくつか変えて試すこと。
部分再構築
答えの一部分を取り除き、そこを作り直すこと。

小例の補足

重さ8・7・6・5を二人に配ります。8+7と6+5は一仕事の移動では改善しませんが、一部を外して戻すと13ずつの割当を作れる場合があります。

この章で扱う実装の範囲

  • 変更対象の選び方と固定する境界
  • 取り除く処理と復元ログ
  • 貪欲・DP・限定列挙による修復
  • 再構築への計算予算と採用判定

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:2.2 山登り法を、コピーして試す形で組む

理解できたら:次に広げること

数手だけわざと乱してから改善し直す方法も比べます。これは摂動と呼ばれますが、最初は名前より「何をどこまで変えるか」を追います。

別の問題でも使うなら

経路の一部分を作り直す場合は、前後の道とつながるように両端を固定します。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材四つの仕事を二人へ分け直す — 問題設定・具体例を読む

6.1 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

山登りのみでは346で止まります。再構築で四つ全てを外すと、8+5と7+6の13ずつへ配り直せます。一つだけ外す場合やseedを変えた再出発とも比べ、最良値が悪化しないことを確認してください。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <cstdint>
#include <iostream>
#include <vector>
using namespace std;
struct Random {
    uint32_t x;
    uint32_t next(){x^=x<<13; x^=x>>17; x^=x<<5; return x;}
};
const vector<int> weight={8,7,6,5};
int score(const vector<int>& a){
    int load[2]={0,0};
    for(int i=0;i<4;i++)load[a[i]]+=weight[i];
    return load[0]*load[0]+load[1]*load[1];
}
void climb(vector<int>& a){
    while(true){
        bool changed=false;
        for(int i=0;i<4;i++){
            auto next=a; next[i]^=1;
            if(score(next)<score(a)){a=next;changed=true;break;}
        }
        if(!changed)return;
    }
}
int main(){
    const int mode=2, rounds=4, removed=4, seed=42;
    Random rng{uint32_t(seed)};
    vector<int> current={0,0,1,1},best=current;
    const int initial=score(current);
    for(int t=0;t<rounds;t++){
        if(mode==1){
            for(int& owner:current)owner=rng.next()%2;
        }else if(mode==2){
            int start=rng.next()%4;
            for(int j=0;j<removed;j++)current[(start+j)%4]=-1;
            int load[2]={0,0};
            for(int i=0;i<4;i++)if(current[i]>=0)load[current[i]]+=weight[i];
            // 未担当だけを重い順に戻す。-1の状態にはscoreを呼ばない。
            for(int i=0;i<4;i++)if(current[i]<0){
                int g=load[0]<=load[1]?0:1;
                current[i]=g;load[g]+=weight[i];
            }
        }
        climb(current);
        if(score(current)<score(best))best=current;
        cout<<"step="<<t+1<<","<<score(current)<<","<<score(best)<<"\n";
    }
    cout<<"initial="<<initial<<"\nbest="<<score(best)<<"\nassign=";
    for(int i=0;i<4;i++){if(i)cout<<",";cout<<best[i];}
    cout<<"\nvalid="<<all_of(best.begin(),best.end(),[](int g){return 0<=g&&g<2;})<<"\n";
}

必要に応じて選ぶ発展

6.2 訪れる順番を、交換したり逆にしたりする

今回の問題

倉庫0から配達先1〜4を各一回訪れ、倉庫へ戻ります。答えは訪問順[1,3,2,4]のような配列です。各区間のマンハッタン距離(横と縦の移動量の和)を合計し、小さい順番を探します。

入力の表・詳しいルール・計算例を確認する

四つの配達先を一回ずつ訪れ、倉庫へ戻る

ここから一台の車の訪問順を扱います。前の仕事割当とは、答えの形も費用の計算も変わります。倉庫と配達先、順列の位置を最初に確認してください。

何をする問題か

一台の車が倉庫を出発し、四つの配達先へ配ってから倉庫へ戻ります。全配達先へ行くことは必須で、配達先の座標は動かせません。工夫できるのは訪問する順番です。

ここでは一つの訪問順を四要素の配列で表します。要素は配達先IDであり、配列の位置とは別です。入力の座標から区間の費用を計算し、その合計で順番のよさを比べます。

具体例で使うデータ
地点ID役割座標
0倉庫(0,0)
1配達先(4,0)
2配達先(4,3)
3配達先(0,3)
4配達先(2,1)
答えと評価を、具体例で追う
  1. 答えを一つ具体化する

    [1,3,2,4]という答えなら、倉庫0→1→3→2→4→倉庫0と進みます。倉庫を配列に入れないのは、どの答えでも出発点・帰着点が倉庫に固定されているためです。

  2. 区間ごとの費用を足す

    通常の費用は縦横の移動量の和です。1→3なら|4−0|+|0−3|=7。全区間は4+7+4+4+3=22で、最後の4→倉庫の3も含めます。

  3. 順番を一か所変更する

    位置a=1、b=2は、二つ目の地点3と三つ目の地点2を指します。その区間を反転すると[1,2,3,4]になり、費用は4+3+4+4+3=18へ改善します。

ここで間違えやすいこと[1,2,2,4]は地点3が欠け、地点2が重複するため不正です。非対称の設定ではu>vの区間に費用2を足し、元の順は26、反転後は20になります。通常の距離の18と混ぜて比較しないでください。

図:場所は固定、答えは訪問順
倉庫と四つの配達先の座標倉庫0は原点。配達先1は4,0、2は4,3、3は0,3、4は2,1。格子一目盛りは距離1。 xy1234123 倉庫0 (0,0)1 (4,0)2 (4,3)3 (0,3)4 (2,1)
答えの配列[1, 3, 2, 4]実際に回る順番倉庫0 → 1 → 3 → 2 → 4 → 倉庫0
通常の費用では、この順の合計は4+7+4+4+3=22です。例えば1→3は横4+縦3で7。斜めの直線距離では数えません。位置1・2を反転して[1,2,3,4]にすると合計18になります。
ルールと、この章での扱いを確認する
与えられるもの
倉庫0=(0,0)。配達先は1=(4,0)、2=(4,3)、3=(0,3)、4=(2,1)です。道路や障害物の制約はありません。
決めること・求めるもの
配達先ID 1〜4を並べた訪問順です。[1,3,2,4]なら、倉庫0→1→3→2→4→倉庫0と回ります。配列には倉庫を入れません。
守る条件
四つの配達先をそれぞれ一回ずつ訪れ、最後に倉庫へ戻ります。訪問先の欠落や重複は認めません。配列の位置a・bは0始まりです。
評価・確認の基準
全区間の移動費用を最小化します。通常は|x_u−x_v|+|y_u−y_v|。非対称の設定では、さらにIDが大きい地点から小さい地点へ進む区間に費用2を加えます。

この章で作る部品訪問順を一回だけ交換・挿入・反転し、有効性と費用の変化を検査します。巡回路を繰り返し改善する探索ループは7.1で組みます。

2-optは、巡回路の二辺を別のつなぎ方へ替える変更です。向きのない巡回路では区間反転で表せます。この章は交換・挿入も含め、訪問順を変える近傍を作ります。

図:同じ順列に、三種類の変更を用意する

交換

二つの位置を入れ替える
長さは変わらない

挿入

一つ抜いて別の位置へ
挿入位置は変更後の添字

区間反転

連続区間を逆順にする
内部の辺も向きが逆になる

始点と終点の倉庫は順列に含めません。費用には倉庫との往復も加えます。

1. 地点IDと配列の位置を分ける

訪問順は [1,3,2,4]。倉庫は配列に入れず、評価時に出発・帰還の辺を加えます。位置1の要素は地点3です。逆引き position[id] も保存するなら、順列の変更時に更新します。

2. 交換・挿入・反転を定義する

交換は二位置を入れ替えます。挿入は位置aから抜き、変更後の位置bへ入れます。反転は閉区間[min(a,b),max(a,b)]です。C++のreverseは終端を含まないため、右端にr+1を渡します。

a=b、先頭、末尾を試し、長さと訪問先の集合が変わらないか確かめます。

注目コード:取り出してから、変更後の位置へ挿入する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
if (move == 1) {
    int v = after[a];
    after.erase(after.begin()+a);
    after.insert(after.begin()+b, v);
}

ここを見る:a・bは地点IDではなく配列の位置です。bを削除後の配列での挿入位置と決め、実装と演習の意味をそろえます。

完成コードの L23–26 を見る →

3. 変わる辺から差分を求める

対称な費用では、反転区間の内側の費用は同じで、出入口の二辺だけが変わります。非対称の設定はu>vの辺に2を加えるため、内側の向きの差も必要です。

反転の予測差分を全計算と照合します。交換・挿入は全計算から始め、差分化するときは影響する辺を列挙して重複を除きます。

注目コード:非対称なら区間内部の向きも数える初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
predicted = boundary;
for (int i = min(a,b); i < max(a,b); i++)
    predicted += distance_between(before[i+1], before[i], directed)
               - distance_between(before[i], before[i+1], directed);

ここを見る:boundaryは反転区間の出入口の差です。内部も全て逆向きになるため、その差を加えます。対称距離なら内部の加算は0になります。

完成コードの L36–38 を見る →

4. 評価と配列更新を別に測る

距離差分がO(1)でも、反転や挿入には動かす要素数分の時間がかかります。採用時は順列と評価値を更新し、棄却時は元を保ちます。演習は一手の生成と照合まで。7.1で探索ループへ組み込みます。

ここまでの確認

行きと帰りで費用が違う道でも、反転した区間の中の費用を無視してよいですか?

答えと理由を見る

いけません。中の道を逆向きに通るので、その費用も変わります。動く見本ではこの違いを切り替えられます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点順列を一つ固定し、倉庫との往復を含む全費用を求めてから変更操作を足します。

用意するデータと処理
order
訪問先IDの順列。位置とIDを混同しない。
変更操作
交換・削除後の位置への挿入・閉区間反転。候補のコピーへ適用する。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 全費用と検査を作る

    倉庫→各地点→倉庫の費用をcostで求めます。順列の長さ・範囲・重複・欠落を調べる検査も用意します。

    ここで確認訪問順が同じなら同じ費用になり、帰還の一辺を落としていない。

  2. 2. 変更を一種類ずつ足す

    交換を動かした後に挿入、次に反転を追加します。先頭・末尾・同じ位置を含む例で、変更後の配列そのものを確認します。

    ここで確認挿入した要素が変更後の位置bへ入り、各IDが一回ずつ残る。

  3. 3. 反転の差分を照合する

    コピー上の全費用と、出入りの辺の差を比較します。非対称な費用なら内側の辺の向きの差も足してから比較します。

    ここで確認全区間の反転でも、予測差分が変更後−変更前と一致する。

次に足すなら:逆引きpositionを足す

順列変更を確定する場所で、動いたIDの逆引きも更新します。最初は全順列から作り直してよく、一致を確認してから更新範囲を絞ります。

追加後の確認:全IDについてorder[position[id]]がidになり、拒否時は両方とも元に戻る。

完成コードで見る場所:distance_between:方向を含む辺の費用 / boundary:反転で出入りする辺の差

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

順列の表現
order[i]に、i番目に訪れる町の番号を保存します。交換や区間反転なら、要素の重複・欠落を起こさず更新できます。
2-opt
二本の道のつなぎ方を替える方法。向きのない巡回路では、間の訪問順を逆にする変更で表せます。

小例の補足

倉庫0と四地点を結ぶ順列[1,3,2,4]を、交換・挿入・反転で変更します。変更後の順列と、行き帰りが非対称な場合の差分も確かめます。

この章で扱う実装の範囲

  • orderとposの相互整合
  • 閉路の端点と循環添字
  • 交換・挿入・区間反転
  • 対称距離の端点差分と反転にかかる時間

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:2.1 制約に合う近傍を用意する、2.4 変わった分だけを計算する

理解できたら:次に広げること

行き帰りの費用が同じなら、反転区間の中の費用は変わりません。ただし、配列そのものを反転する作業には区間の長さに応じた時間がかかります。

別の問題でも使うなら

荷物が増えると移動費用も増える問題では、同じ距離の式を使えないため、運んでいる量も追います。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材四つの配達先を一回ずつ訪れ、倉庫へ戻る — 問題設定・具体例を読む

6.2 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

最初の反転は訪問順1→3→2→4の中央を入れ替えます。非対称の費用では区間内部の辺も向きが変わります。交換・挿入へ切り替え、a=bの何も変わらない場合も試してください。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <cstdlib>
#include <iostream>
#include <utility>
#include <vector>
using namespace std;
const vector<pair<int,int>> point={{0,0},{4,0},{4,3},{0,3},{2,1}};
int distance_between(int u,int v,bool directed){
    return abs(point[u].first-point[v].first)+abs(point[u].second-point[v].second)
           +(directed&&u>v?2:0);
}
int cost(const vector<int>& order,bool directed){
    int total=0,last=0;
    for(int v:order){total+=distance_between(last,v,directed);last=v;}
    return total+distance_between(last,0,directed);
}
int main(){
    const int move=2, a=1, b=2;
    const bool directed=false;
    vector<int> before={1,3,2,4},after=before;
    int boundary=0;
    if(move==0)swap(after[a],after[b]);
    if(move==1){
        int v=after[a];after.erase(after.begin()+a);
        after.insert(after.begin()+b,v); // bは変更後の位置。
    }
    if(move==2){
        int l=min(a,b),r=max(a,b);
        int prev=l?before[l-1]:0,next=r+1<4?before[r+1]:0;
        boundary=distance_between(prev,before[r],directed)+distance_between(before[l],next,directed)
                -distance_between(prev,before[l],directed)-distance_between(before[r],next,directed);
        reverse(after.begin()+l,after.begin()+r+1);
    }
    int delta=cost(after,directed)-cost(before,directed),predicted=delta;
    if(move==2){
        predicted=boundary;
        for(int i=min(a,b);i<max(a,b);i++)
            predicted+=distance_between(before[i+1],before[i],directed)-distance_between(before[i],before[i+1],directed);
    }
    auto sorted=after;sort(sorted.begin(),sorted.end());
    cout<<"order=";for(int i=0;i<4;i++){if(i)cout<<",";cout<<after[i];}
    cout<<"\nbefore="<<cost(before,directed)<<"\nafter="<<cost(after,directed)<<"\ndelta="<<delta
        <<"\nboundary_delta="<<boundary<<"\npredicted="<<predicted
        <<"\ndelta_ok="<<(predicted==delta)<<"\nvalid="<<(sorted==vector<int>{1,2,3,4})<<"\n";
}

必要に応じて選ぶ発展

6.3 必要な場所を、選んだもの全部で覆う

今回の問題

対象0〜5を、五つの設備候補から選んで全て覆います。候補0は{0,1,2}、候補2は{3,4,5}を覆い、各費用4なので、この二つなら総費用8です。削除後の未被覆を修復し、安い有効な集合を作ります。

入力の表・詳しいルール・計算例を確認する

六つの場所を、候補の組合せで全て覆う

訪問順から、設備候補の組合せへ題材を変えます。対象の番号と候補の番号を分けて読み、何を選べば全対象を覆えるかを確認してください。

何をする問題か

六つの場所へサービスを届けるため、設備候補を幾つか採用します。一候補を選ぶと、その候補に書かれた場所へまとめて届くものとします。場所の座標や、候補を巡る順序は使いません。

入力は「各候補が覆う対象の一覧」と「採用費用」です。出力は選んだ候補の集合であり、全対象が少なくとも一候補で覆われることが必要です。

具体例で使うデータ
候補ID覆う対象費用
00・1・24
12・32
23・4・54
30・53
41・43
答えと評価を、具体例で追う
  1. 有効な集合を作る

    候補0と2を選ぶと、{0,1,2}と{3,4,5}で全対象を覆えます。費用は4+4=8。演習の初期集合5は、ビット0と2が立った値1+4を意味します。

  2. 候補を一つ外す

    候補0を外すと費用は4へ下がりますが、0・1・2が未被覆になります。このままでは安い有効解とは言えません。

  3. 足りない対象を覆い直す

    初期の費用なら候補0を戻すと三対象を費用4で覆えます。候補1なら新しく覆うのは2だけ、候補3なら0だけ、候補4なら1だけなので、新しく覆う数当たりの費用で選びます。

ここで間違えやすいことある対象を二候補が覆っていれば、一方を外しても未被覆にはなりません。削除を扱うために「覆われているか」だけでなく、覆う候補の個数を数えます。

ルールと、この章での扱いを確認する
与えられるもの
対象は0〜5。候補0〜4の担当範囲は順に{0,1,2}、{2,3}、{3,4,5}、{0,5}、{1,4}で、費用は4・2・4・3・3です。候補0の費用は変更できます。
決めること・求めるもの
採用する候補の集合です。初期設定では候補0と2を選び、六対象を費用8で覆っています。
守る条件
全対象が一つ以上の選択候補に含まれる必要があります。同じ対象を複数候補で覆ってもよく、同じ候補を重ねて選ぶことはありません。
評価・確認の基準
選んだ候補の費用の合計を最小化します。未被覆が残る集合は、費用が小さくても完成した答えではありません。

この章で作る部品一候補を削除し、足りなくなった場所を追加候補で覆い直します。対象ごとの被覆数を更新し、全計算で確認します。

被覆は、選んだ設備などで必要な対象を覆うことです。一つも覆っていない対象を未被覆と呼び、候補を追加して制約を満たす状態へ戻す処理を修復と呼びます。

図:選択集合と、対象ごとの被覆数を対応させる

一候補を削除

その候補が覆う対象の
countを1減らす

未被覆を調べる

count=0の対象が残る
費用だけで採用しない

候補を追加して修復

費用/新たに覆う数で選ぶ
countを1増やす

同じ対象を複数候補が覆うため、覆うかどうかのboolだけでは削除を扱えません。

1. 対象ごとに被覆数を持つ

状態は候補の選択集合、補助情報は対象ごとの count[u] です。一候補を外したら、それが覆う対象だけ1減らします。別の候補も覆っていれば0にはなりません。入力の範囲と費用は冒頭の表で確認できます。

注目コード:設備の削除で被覆数を減らす初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
if (selected[remove]) {
    selected[remove] = 0;
    for (int u : covers[remove]) count[u]--;
}

ここを見る:removeは設備候補ID、uは覆う対象IDです。count[u]が0になった対象を修復の対象にし、別設備で覆われている対象と区別します。

完成コードの L12 を見る →

2. 未被覆のまま採用しない

候補0と2で費用8です。0を外すと対象0・1・2が未被覆になります。修復を無効にすると、この安く見える不正状態を確認できます。修復後に missing==0 となってから完成解として扱います。

3. 新たに覆う数で候補を選ぶ

未選択候補について、新たに覆う対象数gainを数え、費用/gainが最小の候補を追加します。gain=0は除外し、全候補が0なら修復失敗です。失敗時に戻れる有効解を保存しておきます。

比は cost[a]*gain[b] < cost[b]*gain[a] で比較し、同率なら小さいIDを選びます。大きい入力では積のオーバーフローにも注意します。

注目コード:新たに覆える数に対する費用を比較する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
int gain = 0;
for (int u : covers[i]) gain += count[u] == 0;
if (gain > 0 &&
    (chosen < 0 || cost[i]*chosen_gain < cost[chosen]*gain)) {
    chosen = i;
    chosen_gain = gain;
}

ここを見る:gainは未被覆を何個減らせるかです。費用÷gainの比較を交差積にし、gain=0の候補は除きます。この小入力では積はintに収まります。

完成コードの L17–18 を見る →

4. 修復後を全計算で検査する

選択集合からcount・未被覆数・費用を再計算し、差分更新と照合します。処理が止まっただけでは成功とはしません。

貪欲な修復は最小費用を保証しません。複数候補の削除・再構築や、不要な候補の削除へ広げられます。逆引きでgainを更新する高速化も可能です。

ここまでの確認

ある場所を覆う数が2→1になったら、その場所は覆われなくなりますか?

答えと理由を見る

なりません。まだ一つが覆っています。1→0になったときに、初めて覆うものがなくなります。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点選択集合から被覆数を全計算できるようにし、一候補の削除と修復を順に足します。

用意するデータと処理
selected / covers
選んだ候補と、各候補が覆う対象の一覧。
count / missing
対象ごとの被覆数と、被覆数0の対象数。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 選択集合から集計する

    countを0から作り、選んだ候補の対象へ1ずつ加えます。候補内に同じ対象が重複しない表現にします。

    ここで確認同じ対象を二候補で覆えば2となり、一つ削除しても1が残る。

  2. 2. 削除と追加を対称に書く

    削除では選択中だけ1減らし、追加では未選択だけ1増やします。missingを差分管理するなら、1→0と0→1だけで更新します。

    ここで確認同じ候補を二度消して負数にならず、全計算の被覆数と一致する。

  3. 3. 削除後へ修復を接続する

    未選択候補のgainを求め、費用/gainの小さい候補を加えます。gain=0を除外し、全て覆えたかを最後に判定します。

    ここで確認修復が止まっただけで成功にせず、未被覆が残れば失敗として扱う。

次に足すなら:gainを保存して更新する

対象から候補への逆引きを用意し、対象の被覆数が0かどうか変わったときだけ関係するgainを更新します。最初は毎回の全計算と比べます。

追加後の確認:2→1ではgainが変わらず、1→0で未選択候補のgainが増える。

完成コードで見る場所:chosen_gain:修復に使う新しい被覆数 / vector<int> full:補助情報の全計算による照合

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

被覆
必要な対象を、選んだ候補で覆うこと。
cover_count
各場所を何個が覆うか、という数を入れる配列名。

小例の補足

六対象を五候補で覆います。一候補を削除して未被覆数を調べ、費用/新たに覆う数が小さい候補を追加して修復します。

この章で扱う実装の範囲

  • 候補から対象、対象から候補への二方向の一覧
  • selectedとcover_count
  • 新しく覆う利益と最後の被覆を失う損失
  • 追加・削除・交換・不足の修復

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.7 貪欲法で初期解を作る、2.4 変わった分だけを計算する

理解できたら:次に広げること

費用が減っても、必要な場所が覆われなくなれば完成した答えにできません。覆う数と費用を別々に確認します。

別の問題でも使うなら

必要な機能を満たす道具の選択でも、場所を機能へ読み替えると同じ数え方を使えます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材六つの場所を、候補の組合せで全て覆う — 問題設定・具体例を読む

6.3 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

修復を外すと、未被覆が残った安い候補を観察できます。初期集合0、候補0の費用7で、追加順が変わることも確認します。新たに覆う数が0の候補は、比の計算へ入れません。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
int main(){
    const int mask=5, remove=0, cost0=4;
    const bool repair=true;
    const vector<vector<int>> covers={{0,1,2},{2,3},{3,4,5},{0,5},{1,4}};
    const vector<int> cost={cost0,2,4,3,3};
    vector<int> selected(5),count(6,0);
    for(int i=0;i<5;i++)if((mask>>i)&1){selected[i]=1;for(int u:covers[i])count[u]++;}
    if(selected[remove]){selected[remove]=0;for(int u:covers[remove])count[u]--;}
    int missing_after_remove=count_if(count.begin(),count.end(),[](int x){return x==0;});
    while(repair&&find(count.begin(),count.end(),0)!=count.end()){
        int chosen=-1,chosen_gain=0;
        for(int i=0;i<5;i++)if(!selected[i]){
            int gain=0;for(int u:covers[i])gain+=count[u]==0;
            if(gain>0&&(chosen<0||cost[i]*chosen_gain<cost[chosen]*gain)){chosen=i;chosen_gain=gain;}
        }
        if(chosen<0)break; // 一つも未被覆を減らせなければ修復失敗。
        selected[chosen]=1;for(int u:covers[chosen])count[u]++;
        cout<<"add="<<chosen<<","<<chosen_gain<<"\n";
    }
    vector<int> full(6,0);int total=0,final_mask=0;
    for(int i=0;i<5;i++)if(selected[i]){
        total+=cost[i];final_mask|=1<<i;for(int u:covers[i])full[u]++;
    }
    int missing=count_if(full.begin(),full.end(),[](int x){return x==0;});
    cout<<"missing_after_remove="<<missing_after_remove<<"\nmask="<<final_mask<<"\ncounts=";
    for(int i=0;i<6;i++){if(i)cout<<",";cout<<count[i];}
    cout<<"\ncost="<<total<<"\nmissing="<<missing<<"\nvalid="<<(missing==0)<<"\nconsistent="<<(full==count)<<"\n";
}

必要に応じて選ぶ発展

6.4 物を置いたとき、はみ出しや重なりを調べる

今回の問題

8×6の箱で、長方形0(左下(1,1)・3×2)を動かします。長方形1(左下(5,1)・2×3)は固定。箱の内側に収まり、他方と重ならない変更だけを反映します。辺・頂点の接触は初期設定では許します。

入力の表・詳しいルール・計算例を確認する

箱の中で長方形を動かしても、配置条件を守れるか調べる

ここでは長方形の位置と向きを扱います。費用の最小化へ進む前に、箱の内側・重なり・接触という配置の条件を判定します。

何をする問題か

箱の中に置いた一つの長方形を動かしたとき、はみ出したり他の長方形へ重なったりしないかを調べます。この章は配置の採点方法を作る前の、合法な変更かどうかを調べる練習です。

座標(x,y)は長方形の左下、wとhは幅と高さです。占める範囲は横がxからx+w、縦がyからy+hになります。回転は幅と高さを入れ替える90度回転です。

具体例で使うデータ
物左下幅×高さ占める範囲
箱(0,0)8×6x:0〜8、y:0〜6
長方形0(1,1)3×2x:1〜4、y:1〜3
長方形1(5,1)2×3x:5〜7、y:1〜4
答えと評価を、具体例で追う
  1. 初期設定の移動を描く

    長方形0を(2,1)へ動かすと、横2〜5・縦1〜3を占めます。箱からはみ出さず、長方形1とはx=5の辺で触れるだけです。

  2. 接触と重なりを区別する

    接触を許すなら、この変更は合法です。xを3へ変えると横の共通部分が5〜6、縦が1〜3となり、面積1×2=2だけ重なって不正になります。

  3. 反映する状態を決める

    全ての条件を満たした場合だけ長方形0を候補へ置き換えます。例えばx=8なら右端が11となり、箱の幅8を超えるため、元の位置を保ちます。

ここで間違えやすいこと「接触も禁止」を選ぶと、面積0でも辺や頂点が触れる候補を拒否します。回転する場合は、変更後の幅・高さで箱と相手の両方を検査します。

ルールと、この章での扱いを確認する
与えられるもの
箱は幅8・高さ6。長方形0は左下(1,1)・幅3・高さ2、長方形1は左下(5,1)・幅2・高さ3です。
決めること・求めるもの
長方形0の移動先の左下座標と、90度回転させるかどうか。長方形1は固定です。
守る条件
両方を箱の内側へ収め、内部を重ねません。辺や頂点だけの接触は初期設定では許し、演習で禁止へ切り替えられます。
評価・確認の基準
この章では面積などの目的値は最適化せず、候補を合法に適用できるかを判定します。

この章で作る部品はみ出し・内部の重なり・境界の接触を分けて検査し、全条件を通った場合だけ位置と向きを反映します。

移動・回転した長方形が、箱の内側に収まり、他の物と重ならないかを検査します。

図:範囲内・接触・重なりを別に判定する

箱の範囲

x≥0、y≥0
x+w≤W、y+h≤H

内部の重なり

横の共通幅>0
かつ縦の共通幅>0

接触の規則

共通幅が0の境界
許すかどうかを先に決める

回転後の幅・高さで全てを判定します。途中で座標だけを反映しません。

1. 座標と接触の規則を決める

長方形を左下(x,y)と幅w・高さhで持ち、範囲を[x,x+w)×[y,y+h)とします。箱の壁への接触は許し、長方形同士の辺・頂点の接触は設定で切り替えます。整数座標なので許容誤差は不要です。

2. はみ出しと重なりを検査する

箱の条件はx≥0、y≥0、x+w≤8、y+h≤6。二長方形の共通幅dx・dyが両方正なら重なり、面積はmax(0,dx)×max(0,dy)です。

片方が0、他方が非負なら境界の接触です。接触禁止ならこれも拒否します。面積0だけでは合法と判定できません。

注目コード:各軸の重なり幅から面積を出す初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
int dx = min(candidate.x+candidate.w, other.x+other.w)
       - max(candidate.x, other.x);
int dy = min(candidate.y+candidate.h, other.y+other.h)
       - max(candidate.y, other.y);
int overlap = max(0,dx)*max(0,dy);

ここを見る:dx・dyが負なら、その軸では離れています。面積が0でも接触している場合があるので、接触の可否は別に判定します。

完成コードの L10–12 を見る →

3. 候補全体を確かめて反映する

90度回転は幅と高さを交換します。変更後の位置・幅・高さで箱と相手を検査し、通った候補だけ現在値へ代入します。拒否したらstateに元の長方形が残ることを確認してください。

注目コード:全条件を満たしたときだけ反映する初期設定の抜粋 · 左は完成コードの行番号
bool touching = dx >= 0 && dy >= 0 && overlap == 0;
bool legal = inside(candidate) && overlap == 0 && (allow_touch || !touching);
if (legal) current = candidate;

ここを見る:箱の内側、面積の重なりなし、接触の規則をまとめて確認します。一つでも違反したらcurrentを変更しません。

完成コードの L13–15 を見る →

4. 全比較から高速化する

物が多い場合も、まず変更した一つと他の全てを比較します。次に格子で近くの候補を絞り、複数セルにまたがる物や境界を越す移動を漏らさず更新します。少数例では全比較と照合します。

円なら中心距離と半径、線分なら交差判定へ替えます。連続座標へ移る場合は、丸めと境界の規則も決め直します。

ここまでの確認

辺が触れている二つの長方形は、重なる面積が0なら常に有効ですか?

答えと理由を見る

接触を許す規則かどうかで変わります。この演習では切り替えられるため、面積の重なりと接触の条件を別に判定します。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点一つの長方形候補と箱・他の長方形を受け取り、有効性を返す判定から作ります。

用意するデータと処理
Rect
左下座標x・y、幅w、高さh。
配置の検査
箱の内側、内部の重なり、接触の許否を別々に判定する。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 境界の判定を独立に書く

    insideで箱の四条件を調べます。次に二長方形の共通幅を求め、面積の重なりと辺・頂点の接触を分けます。

    ここで確認離れている・接している・内部が重なる、の三例を区別できる。

  2. 2. 候補全体を作って検査する

    位置を変え、必要なら幅と高さを交換した候補を別に作ります。全条件が通った後だけ現在の長方形へ代入します。

    ここで確認回転後の寸法で箱を検査し、拒否された候補が状態へ残らない。

  3. 3. 他の全ての物を検査する

    物を増やしたら、変更対象以外との全比較をループにします。最初はここを正解確認用の基準版として残します。

    ここで確認離れた物だけでなく、ちょうど境界に接する物も判定対象になる。

次に足すなら:空間を格子に分けて絞る

各長方形をまたがる全セルへ登録し、候補の範囲に関係するIDを集めて重複を除いて比較します。接触禁止なら境界で隣接するセルも拾う規則にします。

追加後の確認:境界をまたぐ移動・回転を含め、全比較で検出する衝突を一つも見逃さない。

完成コードで見る場所:struct Rect:座標と寸法 / bool legal:全条件をそろえて反映する場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

重なり判定
この例では、x方向とy方向の両方で正の長さの重なりがあれば衝突と判定します。境界の接触だけなら許します。

小例の補足

8×6の箱で二つの長方形を置きます。一つの位置と向きを変え、箱の内外・面積の重なり・辺や頂点の接触を別々に判定します。

この章で扱う実装の範囲

  • 半開区間で表す長方形と領域内判定
  • 全組の重なり検査、線分の向き・交差・接触
  • 移動・回転・拡縮の近傍と目的値
  • 空間分割や格子化の前に全検査を比較用に残す

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:2.1 制約に合う近傍を用意する

理解できたら:次に広げること

線が交差するかの計算や、近くの物だけを探す方法は別の節で扱います。整数では計算できる大きさ、実数では丸め誤差にも注意します。

別の問題でも使うなら

長方形を点や線へ替えたとき、保存する値と「重なる」の意味を考え直します。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材箱の中で長方形を動かしても、配置条件を守れるか調べる — 問題設定・具体例を読む

6.4 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

x=2、y=1、回転なしでは別の長方形に辺で接します。接触を許すかで採否が変わります。x=3なら面積が重なり、x=8なら箱からはみ出します。拒否したときは元の位置を保存します。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <iostream>
using namespace std;
struct Rect{int x,y,w,h;};
bool inside(Rect r){return r.x>=0&&r.y>=0&&r.x+r.w<=8&&r.y+r.h<=6;}
int main(){
    const int x=2,y=1;
    const bool rotate=false,allow_touch=true;
    Rect current{1,1,3,2},other{5,1,2,3},candidate{x,y,rotate?2:3,rotate?3:2};
    int dx=min(candidate.x+candidate.w,other.x+other.w)-max(candidate.x,other.x);
    int dy=min(candidate.y+candidate.h,other.y+other.h)-max(candidate.y,other.y);
    int overlap=max(0,dx)*max(0,dy);
    bool touching=dx>=0&&dy>=0&&overlap==0;
    bool legal=inside(candidate)&&overlap==0&&(allow_touch||!touching);
    if(legal)current=candidate;
    cout<<"inside="<<inside(candidate)<<"\noverlap_area="<<overlap<<"\ntouching="<<touching
        <<"\nlegal="<<legal<<"\nstate="<<current.x<<","<<current.y<<","<<current.w<<","<<current.h<<"\n";
}

必要に応じて選ぶ発展

6.5 辺の変更と、連結性の検査を組み合わせる

今回の問題

五つの町を双方向の道路でつなぎます。初期は0—1—2—3—4の四道路を選び、費用は各2で合計8。道路を削除・追加した後も全町へ到達できるか、費用はいくらかを調べます。

入力の表・詳しいルール・計算例を確認する

五つの町を、選んだ道路でつなぐ

ここでは町をつなぐ道路を選びます。配達先を訪れる順番ではなく、使う辺の集合が答えです。道路IDと町IDを区別して読みます。

何をする問題か

五つの町の間に、建設できる道路の候補があります。どの道路を採用するかを選び、全町を行き来できるようにしつつ、建設費用を減らしたい問題です。道路を通る順序は答えに含めません。

道路IDは辺そのものの番号です。道路1は町1と町2を結ぶ辺で、「町1」という頂点を削除する操作とは異なります。全道路は双方向に通れます。

具体例で使うデータ
道路ID結ぶ町費用
00–12
11–22
22–32
33–42
40–47
51–43
60–24
答えと評価を、具体例で追う
  1. 初期の道路集合を確認する

    道路0・1・2・3を選ぶと0–1–2–3–4がつながり、費用は8です。どの町からでも他の全町へ、選んだ道路だけで到達できます。

  2. 削除の影響を見る

    道路1を削除すると、町{0,1}と{2,3,4}に分かれます。費用は6になりますが、この状態は連結という条件を満たしません。

  3. 追加後にまとめて検査する

    さらに道路5(1–4)を加えると、0から1→4→3→2と通って全町へ届きます。費用は9です。合法になりましたが、初期の8より安くなったわけではありません。

ここで間違えやすいこと演習は変更後の連結性を検査して反映するところまでです。費用が改善した場合だけ採用する探索へ広げるときは、その比較を別に加えます。

ルールと、この章での扱いを確認する
与えられるもの
町は0〜4。道路ID 0〜6の端点と費用は、(0–1,2)、(1–2,2)、(2–3,2)、(3–4,2)、(0–4,7)、(1–4,3)、(0–2,4)です。道路は双方向です。
決めること・求めるもの
使う道路の集合です。最初は道路0・1・2・3を選び、0–1–2–3–4の道で全てをつないでいます。
守る条件
選んだ道路だけを通って全ての町へ到達できる必要があります。閉路があってもよく、木であることは必須にしていません。
評価・確認の基準
選んだ道路の費用の合計を最小化します。初期費用は8です。安くても町が分断されていれば不正です。

この章で作る部品辺を削除・追加した候補の連結性を検査します。演習は合法なら反映する段階までで、改善した候補だけを採用する処理は別に足します。

辺を削除・追加した後も、全頂点がつながっているかを検査します。

図:辺一本の削除が、全体の到達性を変える

0—1—2—3—4

辺1(1—2)を削除
二つの成分に分かれる

別の辺を加える

1—4を追加
迂回して全頂点へ届く

候補を検査する

BFSで到達数を数える
その後に費用を比べる

追加に強いUnion-Findでも、一般の辺削除をそのまま取り消せるわけではありません。

1. 選んだ辺から連結性を調べる

状態は辺の集合です。候補から隣接リストを作り、頂点0からBFSして全5頂点へ届くか確認します。一辺の変更でも遠い頂点への到達性が変わります。この問題は閉路を許し、木である必要はありません。

注目コード:選ばれた道路だけで隣接リストを作る初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
for (int i = 0; i < 7; i++) if ((mask >> i) & 1) {
    adj[edges[i].u].push_back(edges[i].v);
    adj[edges[i].v].push_back(edges[i].u);
}

ここを見る:maskのビットiが道路iの採否です。この問題は双方向なので両向きを登録し、このグラフで到達数を数えます。

完成コードの L9 を見る →

2. 削除と追加をまとめて検査する

辺1を削ると{0,1}と{2,3,4}へ分かれますが、辺5を加えると再びつながります。予定した削除・追加を終えてから検査します。

削除だけなら費用6の不正状態、追加後は費用9の有効状態です。初期費用8より悪くても、この演習は有効なら反映します。改善時だけ採用する探索は別に加えます。

注目コード:削除と追加を済ませた候補を検査する初期設定の抜粋 · 左は完成コードの行番号
int current = 15, candidate = current & ~(1 << remove);
if (add >= 0) candidate |= 1 << add;
bool connected = reached(candidate) == 5, accepted = !check || connected;
if (accepted) current = candidate;

ここを見る:途中で一度分断しても、追加後に全5町へ届けば候補は有効です。通常はcheck=true。外す設定は、検査を省く不具合の実験です。

完成コードの L20–23 を見る →

3. 削除は改めて検査する

通常のUnion-Findは辺の追加に向きますが、削除後の分断はunionの逆操作では分かりません。まずBFSで再検査します。一般グラフの橋の判定や、処理順を設計したロールバック付きUnion-Findなどは、その後の選択肢です。

4. 木なら構造を利用する

木が条件なら、連結性と辺数n−1を保ちます。一辺追加でできる閉路から別の辺を削れば、連結性を保つ交換になります。一辺削除で分かれた二成分をつなぐ型も使えます。一般ネットワークの検査と木専用の生成を区別します。

ここまでの確認

削除した道の近くに別の道があれば、全体もつながっていますか?

答えと理由を見る

それだけでは判断できません。離れた部分が切り離されることもあるので、まず全体をたどって確認します。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点辺の集合から隣接リストを作り直し、到達数と費用をそれぞれ求める基準版を作ります。

用意するデータと処理
選択した辺
固定の端点・費用とは別に、辺を使うかを保存する。
reached / cost
到達数と、選択した辺の合計費用を別々に返す。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 全体を調べる検査を作る

    選んだ辺だけで隣接リストを作り、頂点0からBFSします。費用の良し悪しと関係なく、全頂点へ届くかを返します。

    ここで確認初期の道は5頂点へ届き、途中の一辺だけを削除すると到達数が減る。

  2. 2. 複数の辺変更を一候補にする

    辺集合をコピーし、削除と追加を両方終えてから検査します。削除直後だけの連結性で棄却しないようにします。

    ここで確認削除で一度分かれても、追加でつなぎ直した候補は通る。

  3. 3. 探索側で費用を比較する

    教材の例は有効な変更を反映する段階までです。山登りへつなぐ場合は、有効な候補にだけcostを呼び、改善なら採用します。

    ここで確認分断した費用6を採らず、有効でも費用9なら改善とは扱わない。

次に足すなら:木だけを探索する

初期解を木にし、非木辺を一つ足してできた閉路の経路を探し、その中の別の辺を削除します。最初は毎回BFSでも検査します。

追加後の確認:辺数n−1と連結性を同時に保ち、関係ない辺の削除で分断していない。

完成コードで見る場所:int reached:到達性の全検査 / candidate:削除・追加をまとめる候補

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

連結性の全体検査
辺の変更後に必要な頂点へ到達できるか調べる。局所的な辺の確認だけでは、離れた部分の切断を見落とす。
木の辺交換
辺を一つ除いた二つの成分を、新しい辺でつなぐ。連結性と木である性質を保ちながら費用を変えられる。

小例の補足

0—1—2—3—4から辺1—2を削除すると分断します。辺1—4を加えてつなぎ直し、到達数と費用を別に確認します。

この章で扱う実装の範囲

  • BFS/DFSによる全体の連結検査
  • 追加のみのDSUと削除後の再構築
  • 親・深さ・部分木の範囲
  • 辺交換と循環の検出、固定グラフでのMST

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:2.1 制約に合う近傍を用意する、5.2 最短路を、ヒューリスティック探索の部品にする

理解できたら:次に広げること

通常のDSUは辺追加には使えますが、自由な削除には対応しません。毎回の再構築、変更の取消し、木の構造を使う方法を、近傍と計算時間に合わせて選びます。

別の問題でも使うなら

白いマスが一つにつながっているか調べる場合も、上下左右の白いマスを順にたどる方法が使えます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材五つの町を、選んだ道路でつなぐ — 問題設定・具体例を読む

6.5 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

追加を−1にすると、辺1の削除で分断します。連結性の検査を外すと費用6の不正なネットワークを反映してしまいます。辺5を加えた有効な変更は費用9なので、有効性と改善は別です。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
struct Edge{int u,v,cost;};
const vector<Edge> edges={{0,1,2},{1,2,2},{2,3,2},{3,4,2},{0,4,7},{1,4,3},{0,2,4}};
int reached(int mask){
    vector<vector<int>> adj(5);
    for(int i=0;i<7;i++)if((mask>>i)&1){adj[edges[i].u].push_back(edges[i].v);adj[edges[i].v].push_back(edges[i].u);}
    vector<int> seen(5);queue<int> q;q.push(0);seen[0]=1;int count=0;
    while(!q.empty()){
        int u=q.front();q.pop();count++;
        for(int v:adj[u])if(!seen[v]){seen[v]=1;q.push(v);}
    }
    return count;
}
int cost(int mask){int sum=0;for(int i=0;i<7;i++)if((mask>>i)&1)sum+=edges[i].cost;return sum;}
int main(){
    const int remove=1,add=5;const bool check=true;
    int current=15,candidate=current&~(1<<remove);
    if(add>=0)candidate|=1<<add;
    bool connected=reached(candidate)==5,accepted=!check||connected;
    if(accepted)current=candidate;
    cout<<"candidate="<<candidate<<"\nreached="<<reached(candidate)<<"\ncandidate_cost="<<cost(candidate)
        <<"\nconnected="<<connected<<"\naccepted="<<accepted<<"\nfinal="<<current
        <<"\nfinal_connected="<<(reached(current)==5)<<"\n";
}

必要に応じて選ぶ発展

6.6 大まかに決めてから、細かく直す

今回の問題

整数x=0〜16を選び、費用を小さくします。教材では費用を固定表で与えます。粗く調べてから有望な周辺を細かく調べ、評価回数と見つかった費用を比べます。

入力の表・詳しいルール・計算例を確認する

一つのパラメータを、粗い刻みから細かく調べる

ここでは一つの整数パラメータを調整します。前の道路の選択とは別で、評価にかかる計算を減らすための、小さな費用表を使う練習です。

何をする問題か

整数パラメータxを一つ選ぶと、その結果の費用が得られる問題です。本来は費用を求めるシミュレーションが重い場面を想定しますが、教材では計算の代わりに小さい表を引きます。

x=0〜16の全17候補に費用が決まっています。入力の表は答え合わせにも使えますが、ここでは粗い位置から調べる方法がどこを見落とすかを観察します。

具体例で使うデータ
x0・1・2・34・5・6・78・9・10・1112・13・14・15・16
費用20・15・10・52・5・9・1312・9・4・03・8・14・18・22
答えと評価を、具体例で追う
  1. 粗い候補だけを見る

    刻み8ならx=0・8・16を評価し、費用は20・12・22です。この段階だけを見るとx=8が最善なので、中心を一つ残すなら8を選びます。

  2. 刻みを細かくする

    次は刻み4で0・4・8・12・16を調べ、費用2のx=4を残します。さらに刻み2、1と細かくすると、その周辺だけを調べるためx=4に落ち着きます。

  3. 残す中心を増やす

    中心を二つ残すと、別の谷の周辺も引き継げます。この例では費用0のx=11へ届く一方、調べる候補数も増えます。

ここで間違えやすいこと刻みを1まで細かくしたことは、全17候補を調べたことを意味しません。どの範囲を残したかと、評価した回数を合わせて確認します。

ルールと、この章での扱いを確認する
与えられるもの
xは0〜16。各xの費用は順に[20,15,10,5,2,5,9,13,12,9,4,0,3,8,14,18,22]です。この表を、高価なシミュレーション評価の代わりに使います。
決めること・求めるもの
採用するxを一つ選びます。演習では最初の刻み幅と、次の段へ残す中心の個数を変えます。
守る条件
xは範囲内の整数です。各段階では列挙した候補の費用だけを比較し、全域の最適値は答え合わせ専用に使います。
評価・確認の基準
選んだxの費用を最小化します。x=4には費用2の谷、x=11には費用0の谷があり、一つの周辺へ絞ると後者を見失う場合があります。

この章で作る部品刻みを半分にして候補を作る処理と、複数の中心を残す処理を組み、評価回数と得られた費用を比較します。

全域を粗く調べてから有望な周辺を細かく調べ、評価回数を抑えます。

図:刻みを細かくするとき、どの場所を引き継ぐか

刻み8で見る

0・8・16を評価
候補の中心を残す

刻み4、2へ

中心の周辺を調べ直す
一つの谷へ絞りすぎない

刻み1へ

残った周辺だけを細かく
全域の最適性とは別

中心を複数残すと候補数が増えます。評価回数を含めて比較します。

1. 全域を粗く調べる

最初の刻み8ではx=0・8・16を評価します。以後は有望な中心の周辺だけを調べます。状態は中心の一覧、設定は刻み幅と残す数です。費用関数を分ければ、固定表を実際のシミュレーションへ替えられます。

2. 刻みを半分にして候補を作る

各中心から前の刻み幅だけ左右へ広げ、新しい刻みで列挙します。範囲外と重複を除き、費用順・同点なら小さいx順で中心を残します。

演習の費用は全段で同じです。粗い盤面や少ない試行数で評価する実問題では、最後に細かい条件で再評価します。

注目コード:最初は全域、その後は残した点の周囲を調べる初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
if (centers.empty())
    for (int x = 0; x <= 16; x += step) candidates.insert(x);
else for (int center : centers)
    for (int x = max(0,center-previous);
         x <= min(16,center+previous); x += step) candidates.insert(x);

ここを見る:stepが現在の刻み幅、previousが前の幅です。絞込み後はcentersの周囲だけが対象になるので、離れた谷を見落とす可能性があります。

完成コードの L13–14 を見る →
注目コード:有望な点を残し、刻み幅を半分にする初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
if (step == 1) break;
previous = step;
step /= 2;

ここを見る:候補を評価して上位keep件をcentersに保存した後の処理です。刻み1まで来たら終了し、それ以外は周囲をより細かく調べます。

完成コードの L21–22 を見る →

3. 絞込みで失う谷を見る

中心一つではx=8→4と進み、費用2の谷に残ってx=11の費用0を見落とします。中心二つなら、この例では11へ届きます。

全費用曲線は答え合わせ用です。中心の選別には、その段階で実際に調べた値だけを使います。

4. 評価回数も比べる

中心を増やすと範囲は広がり、評価回数も増えます。演習では別段で同じ位置を調べ直す場合も一回と数えます。粗い探索・細かい探索・最終確認へ時間を配分し、刻みだけでなく調べた範囲と最終費用を比べます。

ここまでの確認

刻み1まで細かくしたら、全域の最小費用を見つけたと言えますか?

答えと理由を見る

言えません。細かく調べたのは残した中心の周辺だけです。前段で失った範囲に、よりよい位置が残っている場合があります。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点位置を受け取り費用を返す関数を固定し、粗い候補生成と細かい候補生成を同じ評価へつなぎます。

用意するデータと処理
centers / step
次の段へ残す中心と、現在の刻み幅。
候補の集合
各中心の周辺から集めた位置。重複を除いてから評価する。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 粗い全域の候補を作る

    範囲の両端と刻みを決め、最初の候補を列挙します。費用と同点規則で並べ、中心を指定個数だけ残します。

    ここで確認刻み8では0・8・16を評価し、その三点の値だけで中心を選ぶ。

  2. 2. 段を進める処理を足す

    前の刻みを保存してから半分にし、残した中心の周辺だけを列挙します。範囲外を除き、集合で位置の重複を消します。

    ここで確認候補が重複せず、最後は刻み1で停止する。

  3. 3. 評価回数も記録する

    各段で調べた候補数と、残した中心を記録します。小例では全位置を列挙した最小値と比較します。

    ここで確認細かくした範囲の外に、見落とした谷がある場合を説明できる。

次に足すなら:粗い評価器を使い分ける

粗い段では短いシミュレーションを呼び、絞った候補だけを精密な評価へ送る形にします。最後は精密な条件で再順位付けします。

追加後の確認:粗い値を最終得点として保存せず、評価費用を含めた同じ予算で比較する。

完成コードで見る場所:vector<int> centers:段をまたいで残す情報 / previous=step:細かい段へ移る場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

交互最適化
決めるものを分け、片方を固定してもう片方を改善することを交互に行う方法。

小例の補足

整数位置0〜16の費用表を、刻み8・4・2・1で調べます。中心を一つ残すと費用2の谷にとどまり、二つ残すと費用0の谷へ届きます。

この章で扱う実装の範囲

  • 粗い状態と細かい状態の対応
  • 解像度変更時の引き継ぎと再評価
  • 構造・順序・パラメータの別々の近傍
  • 段階ごとの予算とキャッシュ無効化
  • 変数の一部を固定して別の変数を解き直す交互最適化

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:5.3 大まかな計画を、実際の手順へ変える

理解できたら:次に広げること

大まかな段階で選べなかった形へ進めないこともあります。分け方を変えた場合との比較も行います。

別の問題でも使うなら

設備を選ぶ部分と設備をつなぐ部分を分け、片方ずつ改善する形へ移してみます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材一つのパラメータを、粗い刻みから細かく調べる — 問題設定・具体例を読む

6.6 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

最初の刻み8、中心1個ではx=4の費用2へ進みます。中心2個なら別の谷も残り、x=11の費用0を見つけられます。評価回数と最終費用を一緒に比べてください。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <iostream>
#include <set>
#include <vector>
using namespace std;
int main(){
    const int level=2,keep=1;
    const vector<int> cost={20,15,10,5,2,5,9,13,12,9,4,0,3,8,14,18,22};
    int step=1<<(level+1),previous=step,evaluations=0;
    vector<int> centers;
    while(true){
        set<int> candidates;
        if(centers.empty())for(int x=0;x<=16;x+=step)candidates.insert(x);
        else for(int center:centers)for(int x=max(0,center-previous);x<=min(16,center+previous);x+=step)candidates.insert(x);
        evaluations+=candidates.size();
        vector<int> sorted(candidates.begin(),candidates.end());
        sort(sorted.begin(),sorted.end(),[&](int a,int b){return cost[a]!=cost[b]?cost[a]<cost[b]:a<b;});
        if(int(sorted.size())>keep)sorted.resize(keep);
        centers=sorted;
        cout<<"layer="<<step<<","<<candidates.size()<<","<<centers[0]<<","<<cost[centers[0]]<<"\n";
        if(step==1)break;
        previous=step;step/=2;
    }
    int reference=min_element(cost.begin(),cost.end())-cost.begin();
    cout<<"x="<<centers[0]<<"\ncost="<<cost[centers[0]]<<"\nevaluations="<<evaluations
        <<"\nreference_x="<<reference<<"\nreference_cost="<<cost[reference]<<"\n";
}

必要に応じて選ぶ発展

6.7 作業の順番から、開始・終了の時刻を決める

今回の問題

作業0〜3を二台の機械で処理します。同じ機械は同時に使えず、0の完了後に2、1の完了後に3を始めます。優先順から開始時刻を作り、全作業の完了時刻を求めます。

入力の表・詳しいルール・計算例を確認する

四つの作業を、決められた機械で順番に処理する

ここから機械で作業する日程を扱います。仕事割当の「誰に任せるか」とは異なり、使用する機械は固定で、作業の順番と開始時刻を決めます。

何をする問題か

四つの作業を二台の機械で処理します。どの機械を使うかは作業ごとに固定されており、別の機械へ移せません。また、先に完了させる必要がある作業が指定されています。

答えとして欲しいのは各作業の開始・終了時刻です。探索では作業の優先順を持ち、その順に「今配置できる作業」を選んで、実行可能な日程へ変換します。

具体例で使うデータ
作業ID使用機械時間先に終える作業
003なし
112なし
214作業0
302作業1
答えと評価を、具体例で追う
  1. 初期の優先順で置く

    優先順[0,1,2,3]で機械が時刻0から使えるなら、作業0は機械0の0〜3、作業1は機械1の0〜2へ置けます。別の機械なので同時進行できます。

  2. 二つの待ち条件を合わせる

    作業2の機械1は時刻2に空きますが、先行作業0が終わるのは3です。そのため作業2は3〜7になります。作業3は先行作業1の終了2と機械0の空き3を見て3〜5に置きます。

  3. 全体の完了時刻を求める

    終了時刻は3・2・7・5で、全作業が終わる時刻は最大値7です。同じ機械の作業は重ならず、先行関係も守った日程になっています。

ここで間違えやすいこと優先順[2,3,0,1]でも、作業2を無条件で最初に実行する意味ではありません。先行作業が未配置なので、置ける作業0・1から処理します。機械1の利用開始を遅らせる設定も、この開始時刻へ影響します。

図:作業のつながりと、使用する機械
作業0機械0・3時間
→
作業2機械1・4時間
作業1機械1・2時間
→
作業3機械0・2時間
初期設定の所要時間です。矢印は「左の作業が終わってから右を始める」。別の行でも同じ機械を使う作業は、同時に処理できません。
ルールと、この章での扱いを確認する
与えられるもの
作業0〜3の機械IDは順に0・1・1・0、所要時間は初期設定で3・2・4・2です。作業0→2、作業1→3の先行関係があります。
決めること・求めるもの
作業を配置する優先順から、四作業の開始・終了時刻を生成します。先行作業が未配置なら、その作業をいったん飛ばします。
守る条件
同じ機械で作業時間を重ねず、先行作業の終了後に開始します。中断はしません。機械1が利用可能になる時刻も演習で変更できます。
評価・確認の基準
全作業の終了時刻の最大値を小さくします。どれか一作業だけを早く終えることが目的ではありません。

この章で作る部品優先順を日程へ変換し、先行関係と機械の重複を独立に検査します。作業を別の機械へ割り当て直す探索は含みません。

スケジューリングは、資源や順序の制約を守って作業の日程を決めることです。この章は機械の同時使用と先行関係(ある作業の完了後に別の作業を始める条件)を扱います。

図:開始できる時刻は、二つの下限の最大値

先行作業

作業0が終わる時刻3
それ以前に作業2は始めない

機械の空き

機械1は時刻2から空く
同じ機械を重ねて使わない

開始時刻

max(3,2)=3
終了=開始+所要時間

順列は優先順位を表します。先行作業が未配置の作業は、実行可能な候補になるまで待ちます。

1. 優先順から日程を作る

使用機械は固定です。探索では開始時刻ではなく作業の優先順を持ち、デコーダが未配置かつ先行作業を配置済みの作業を順に選びます。優先順[2,3,0,1]でも、準備できている0・1から置きます。

注目コード:優先順の中から、開始可能な作業を選ぶ初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
for (int j : priorities[priority]) if (start[j] < 0) {
    if (ignore_precedence || parent[j] < 0 || finish[parent[j]] >= 0) {
        chosen = j;
        break;
    }
}

ここを見る:start[j]<0は未配置です。通常はignore_precedence=falseなので、先行作業が未配置なら飛ばします。優先順の先頭を無条件に実行しません。

完成コードの L13–15 を見る →

2. 二つの時刻の最大値から始める

開始は max(先行作業の終了, 機械が空く時刻)。所要時間を足して終了とし、機械の空き時刻も更新します。先行作業が複数なら、その最大終了時刻を使います。

このデコーダは機械ごとに末尾へ追加します。途中の空きへ挿入する方式ではないため、空白が残る場合もあります。

注目コード:先行作業と機械の両方が空く時刻を使う初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
int j = chosen, ready = 0;
if (!ignore_precedence && parent[j] >= 0) ready = finish[parent[j]];
start[j] = max(ready, free_at[machine[j]]);
finish[j] = start[j] + duration[j];
free_at[machine[j]] = finish[j];

ここを見る:readyは先行作業の終了、free_atは機械が空く時刻です。大きい方から開始し、配置後に機械の空き時刻を更新します。

完成コードの L17–20 を見る →

3. 先行関係と同時使用を検査する

各i→jで finish[i]≤start[j]、同じ機械の全作業ペアで時間区間の内部が重ならないことを確認します。終了と次の開始が同時なら合法です。

先行関係を無視すると、機械が重ならなくても不正です。未配置なのに置ける作業がなければ、循環や入力不備を含むデコード失敗として返します。

4. 後続への影響も再計算する

作業0を長くすると、依存する作業や機械を共有する後続も遅れます。最初は全体をデコードし直し、作業時間帯を横棒で表すガント図と最大終了時刻を比べます。

途中から再計算する高速化は全デコードと照合します。時間枠・複数資源・段取り費用を加える場合も、生成条件と生成後の検査を分けます。

ここまでの確認

先行作業が時刻3に終わり、機械が時刻5まで埋まっていたら、いつ始められますか?

答えと理由を見る

時刻5です。二つの条件を両方満たす必要があります。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点探索する優先順と、そこから生成する開始・終了時刻を分け、まず一つの優先順を復元できるようにします。

用意するデータと処理
固定の作業条件
機械、所要時間、先行作業。
生成中の時刻
各機械が空く時刻、作業ごとの開始・終了、配置済みかどうか。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 次に置ける作業を選ぶ

    優先順を走査し、未配置で先行作業が配置済みの作業を選びます。見つからなければ循環などのデコード失敗を返す設計にします。

    ここで確認優先順の先頭が未準備でも、その後の実行可能な作業を選べる。

  2. 2. 開始・終了を更新する

    先行作業の終了と機械の空きの最大値を開始にし、終了と機械の空き時刻を同時に更新します。全作業が配置されるまで繰り返します。

    ここで確認同じ機械へ二作業を同時に置かず、先行作業の終了を待てる。

  3. 3. 独立に検査してから評価する

    生成した時刻から全ての先行関係と機械上の重なりを調べます。有効なら最大終了時刻を返し、優先順を変える探索へつなぎます。

    ここで確認先行関係を無視して短くなった日程を、有効な改善として採用しない。

次に足すなら:共通する先頭部分を再利用する

まず全デコードを残します。新旧の優先順で実際に選ばれる作業列を比べ、最初に違う作業を選ぶ直前の機械の空き・配置済み・終了時刻から再計算します。優先順での変更位置を、そのまま再計算の開始位置にしないようにします。

追加後の確認:一作業だけを直して止めず、先行関係で選択順が変わる例でも全デコードと一致する。

完成コードで見る場所:parent:先行作業の条件 / start:開始・終了時刻の生成と検査

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

先行関係
ある作業が終わってから、別の作業を始めるという順序の条件。
ガント図
作業する時間帯を横棒で表した図。機械ごとに並べると重なりが見えます。

小例の補足

四作業を二台の機械へ配置します。先行作業の終了と機械の空きを両方見て開始時刻を決め、優先順を変えて完了時刻を比較します。

この章で扱う実装の範囲

  • 依存DAGと実行可能な作業の集合
  • 機械の空き時刻と最早開始
  • 先行作業の終了・資源容量・期限の検査
  • 優先順の交換と後続の再シミュレーション

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.7 貪欲法で初期解を作る、5.3 大まかな計画を、実際の手順へ変える

理解できたら:次に広げること

一つの作業を遅らせると、後続も遅れることがあります。最初は後ろまで全て計算し直し、影響する範囲を確かめます。

別の問題でも使うなら

部屋の予約でも、同じ部屋を使う時間帯が重ならないかを確認する部分を使えます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材四つの作業を、決められた機械で順番に処理する — 問題設定・具体例を読む

6.7 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

標準の順では作業2は作業0の終了時刻3まで待ちます。先行関係を無視すると機械は重ならなくても、この条件を破ります。優先順2では、実行可能な作業から順に選ぶことも確認できます。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
int main(){
    const int priority=0,duration0=3,release=0;
    const bool ignore_precedence=false;
    const vector<vector<int>> priorities={{0,1,2,3},{1,0,3,2},{2,3,0,1},{0,2,1,3}};
    const vector<int> machine={0,1,1,0},duration={duration0,2,4,2},parent={-1,-1,0,1};
    vector<int> start(4,-1),finish(4,-1),free_at={0,release};
    for(int n=0;n<4;n++){
        int chosen=-1;
        for(int j:priorities[priority])if(start[j]<0){
            if(ignore_precedence||parent[j]<0||finish[parent[j]]>=0){chosen=j;break;}
        }
        if(chosen<0){cout<<"decode_failed=1\n";return 0;}
        int j=chosen,ready=0;
        if(!ignore_precedence&&parent[j]>=0)ready=finish[parent[j]];
        start[j]=max(ready,free_at[machine[j]]);finish[j]=start[j]+duration[j];
        free_at[machine[j]]=finish[j];
        cout<<"job="<<j<<","<<machine[j]<<","<<start[j]<<","<<finish[j]<<"\n";
    }
    bool precedence=true,resource=true;
    for(int j=0;j<4;j++){
        if(parent[j]>=0&&start[j]<finish[parent[j]])precedence=false;
        if(machine[j]==1&&start[j]<release)resource=false;
        for(int k=0;k<j;k++)if(machine[j]==machine[k]&&max(start[j],start[k])<min(finish[j],finish[k]))resource=false;
    }
    cout<<"makespan="<<*max_element(finish.begin(),finish.end())<<"\nprecedence_ok="<<precedence
        <<"\nresource_ok="<<resource<<"\nvalid="<<(precedence&&resource)<<"\n";
}

必要に応じて選ぶ発展

6.8 同じ時刻に、同じ場所へ入らないようにする

今回の問題

通路0—1—2で、Aは0から2、Bは2から0へ進みます。1の横に待避場所3があります。Aの経路を固定し、同時刻の同じ位置と、同じ辺の逆向き通行を避けるBの経路を探します。

入力の表・詳しいルール・計算例を確認する

狭い通路で二台を衝突させずに行き違わせる

機械の日程から、二台の移動体が通路を共有する問題へ変わります。予約という考え方は共通ですが、位置の衝突と辺のすれ違いを新しく定義します。

何をする問題か

二台の移動体A・Bが、一本の細い通路を反対方向へ進みます。位置1の横に待避場所3があり、片方がそこへ避ければ行き違える場合があります。

Aの経路を先に決め、Bだけの経路を探します。初期設定ではAは一単位待ち、0→1→2と進みます。Bは位置2から0へ向かい、一単位時間ごとに隣へ進むか、その場で待ちます。

具体例で使うデータ
時刻Aの位置安全なBの例Bの動き
002出発位置
101中央へ進む
213横へ避ける
321中央へ戻る
420目的地へ着く
5・620到着後もその位置に留まる
答えと評価を、具体例で追う
  1. 同じ位置への進入を避ける

    時刻2ではAが位置1へ来るため、Bはそこで待てません。表の例では時刻1から2の間に、Bが位置1から3へ移ります。

  2. すれ違いも確認する

    Aが0→1、Bが1→0を同じ時間区間に移動すると、時刻の端では位置が違っても途中で衝突します。頂点の占有だけでなく逆向きの辺使用も禁止します。

  3. 到着を確定する

    表のBは時刻4に到着し、その後は位置0を占有します。経路を探すときも再生するときも、到着した車を消えたものとして扱いません。

ここで間違えやすいこと経路なしは、固定したAの経路・使える待避場所・時刻上限6の下で見つからないという意味です。Aの待ち時間や計画順を変えた場合まで不可能だとは言えません。

図:通路と待避場所
二台が反対方向に進む通路0、1、2が横につながり、1の下に待避場所3がある。Aは0から2へ、Bは2から0へ移動する。 0123 A:0 → 2 / B:2 → 0待避場所
位置3へは位置1からだけ出入りできます。先に決めたAの経路に合わせて、Bが進む・待つ・横へ避けるタイミングを探します。
ルールと、この章での扱いを確認する
与えられるもの
通路は0–1–2で、位置1の横に待避場所3があります。Aは0から2、Bは2から0へ向かいます。時刻上限は6です。
決めること・求めるもの
Aの経路を先に固定し、それを避けるBの位置列を求めます。Aの出発待ち時間と、待避場所を使えるかを変更できます。
守る条件
一単位時間に隣の位置へ動くか待機します。同時刻の同じ位置と、同じ辺を逆向きに通るすれ違いは禁止です。到着後も目的地を占有します。
評価・確認の基準
このAの予約の下で、Bが最も早く位置0へ到着する経路を探します。見つからない場合は失敗を返します。

この章で作る部品時刻を状態に含むBFSと、頂点・辺の衝突検査を作ります。検査を外す設定は不具合の再現用で、問題の衝突規則は変わりません。

予約表は、各時刻に誰がどの場所や辺を使うかを記録する表です。先に決めた経路を予約し、次の移動体がその予約と衝突しない経路を探します。

図:衝突には、頂点と辺の二種類がある

頂点の衝突

同じ時刻に
AもBも位置1

辺のすれ違い

A:0→1 と B:1→0
途中ですれ違う

時刻を含めた探索

状態=(位置,時刻)
移動と待機を展開

目的地へ到着した後の占有も予約します。現在の位置だけでは安全性を判定できません。

1. 位置と時刻を状態にする

Aの経路を固定し、Bを状態(位置,時刻)のBFSで探します。次の時刻へ隣接移動か待機を選びます。同じ位置でも時刻が違えば別状態です。探索上限は時刻6です。

注目コード:位置と時刻を合わせて次の状態にする初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
int next = (t+1)*4+v;
if (parent[next] >= 0) continue;
parent[next] = id;
q.push(next);

ここを見る:各時刻に4位置あるので、時刻×4+位置をIDにします。同じ位置でも到着時刻が違えば別状態になり、待機を含む探索ができます。

完成コードの L22–24 を見る →

2. 位置と辺の衝突を除く

Bが時刻tのuからt+1のvへ動くとき、Aがt+1にvにいれば拒否します。Aが同じ区間でv→uへ動く場合も、途中ですれ違うため拒否します。

初期設定のBは2→1→3→1→0と避けます。辺の検査を外すと短い不正経路ができるので、独立の再生検査で確かめます。

注目コード:同じ位置への進入と逆向き通行を除く初期設定の抜粋 · 左は完成コードの行番号
if (v == a[t+1]) continue;
if (edge_check && u == a[t+1] && v == a[t]) continue;

ここを見る:Bがuからvへ動き、Aの予約位置がaです。一行目は同時刻の同位置、二行目は同じ辺のすれ違いを拒否します。通常はedge_check=trueです。

完成コードの L20–21 を見る →

3. 到着後と失敗を扱う

到着後も目的地を時刻6まで占有します。経路なしは空配列・到着時刻−1です。これは固定したAの経路と時間上限の下での失敗で、全体に解がない証明ではありません。Aの待機や計画順を変える余地があります。

4. 複数の移動体へ広げる

頂点予約 occupied[t][v] と辺予約 used[t][u][v] を管理し、経路を取り消すときはその予約も消します。共有を許す規則なら、数や主体IDも必要です。

全主体の同時探索や衝突からの制約分岐へ広げても、衝突の定義・到着後の占有・再生検査は共通です。

ここまでの確認

二台が隣のマスを同時に交換する移動は、許されますか?

答えと理由を見る

この小問題では許されません。移動後のマスが違っていても、途中ですれ違うためです。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点固定した一主体の経路を予約として受け取り、もう一主体の経路を時刻付きBFSで作ります。

用意するデータと処理
位置と時刻
状態IDを時刻×頂点数+位置とし、親IDを持つ。
予約
各時刻の頂点占有と、各時間区間の有向辺の使用。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 待機できる時間展開を作る

    移動先に隣接頂点と現在位置を含め、時刻を一つ進めます。時間上限で展開を止め、未訪問を位置だけで判定しないようにします。

    ここで確認同じ位置でも違う時刻の候補を残し、待機を経由できる。

  2. 2. 二種類の衝突を除く

    移動先が次時刻に占有されているかを検査し、さらに同区間の逆向き移動を拒否します。復元後にも二条件を再検査します。

    ここで確認頂点は別でも、辺のすれ違いがある経路を検出できる。

  3. 3. 到着後と失敗を処理する

    到着後も目的地を占有させます。一般の経路では到着以降の予約も確認してから成功にし、見つからなければ経路なしを返します。

    ここで確認失敗を「全体に解がない」と決めつけず、この予約と時間上限での失敗として扱う。

次に足すなら:三主体以上へ増やす

一経路が確定したときだけ頂点・辺予約へ登録します。途中で失敗したら、計画順や先の経路を見直し、その経路の予約を取り消して再計画します。

追加後の確認:古い予約が残らず、全主体を同時に再生して二種類の衝突がない。

完成コードで見る場所:parent((horizon+1)*4:位置と時刻を含む探索記録 / edge_check:すれ違いの検査

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

予約表
ある時刻に、どの場所や資源を誰が使うかを記録した表。

小例の補足

0—1—2の通路と、位置1の横の待避場所3を使います。先に決めたAの移動に対し、Bの待機と迂回を時刻付きBFSで決めます。

この章で扱う実装の範囲

  • 作業の先行関係と資源・容量
  • 位置と時刻を組にした状態
  • 占有・予約・待機の扱い
  • 部分的な再計画と全体の再生検証

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:5.2 最短路を、ヒューリスティック探索の部品にする、6.7 作業の順番から、開始・終了の時刻を決める

理解できたら:次に広げること

経路が見つからない場合は、先に決めた主体の待機や計画順も見直します。全主体を同時に探索する方法へ広げる場合も、到着後の占有と二種類の衝突検査を保ちます。

別の問題でも使うなら

機械を使う時間帯を予約する問題でも、時間が重なるかを調べる部分を使えます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材狭い通路で二台を衝突させずに行き違わせる — 問題設定・具体例を読む

6.8 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

待ち時間1・待避場所ありなら、Bは横へ避けてAを通せます。すれ違い検査を外すと、同時刻に同じ頂点がなくても衝突する経路が出ます。待ち時間0ではAを固定したこの計画順で経路が見つかりません。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
int main(){
    const int delay=1;const bool bay=true,edge_check=true;
    const int horizon=6;
    vector<int> a(horizon+1);
    for(int t=0;t<=horizon;t++)a[t]=t<=delay?0:t==delay+1?1:2;
    const vector<vector<int>> adj={{0,1},{1,0,2,3},{2,1},{3,1}}; // 待機を含む。
    vector<int> parent((horizon+1)*4,-1);queue<int> q;
    q.push(2);parent[2]=2;int goal=-1;
    while(!q.empty()){
        int id=q.front();q.pop();int t=id/4,u=id%4;
        if(u==0){goal=id;break;}
        if(t==horizon)continue;
        for(int v:adj[u]){
            if(!bay&&v==3)continue;
            if(v==a[t+1])continue;
            if(edge_check&&u==a[t+1]&&v==a[t])continue;
            int next=(t+1)*4+v;
            if(parent[next]>=0)continue;
            parent[next]=id;q.push(next);
        }
    }
    vector<int> b;
    if(goal>=0){
        for(int id=goal;;id=parent[id]){b.push_back(id%4);if(id==2)break;}
        reverse(b.begin(),b.end());
    }
    int arrival=b.empty()?-1:int(b.size())-1;
    if(!b.empty())b.resize(horizon+1,0); // 到着後も目的地を占有する。
    bool valid=!b.empty();
    if(valid)for(int t=0;t<=horizon;t++){
        if(a[t]==b[t])valid=false;
        if(t<horizon&&a[t]==b[t+1]&&a[t+1]==b[t])valid=false;
    }
    cout<<"A=";for(int t=0;t<=horizon;t++){if(t)cout<<",";cout<<a[t];}
    cout<<"\nB=";for(int t=0;t<int(b.size());t++){if(t)cout<<",";cout<<b[t];}
    cout<<"\nfound="<<(goal>=0)<<"\narrival="<<arrival<<"\nvalid="<<valid<<"\n";
}

必要に応じて選ぶ発展

6.9 繰り返す手順を、短い書き方へまとめる

今回の問題

操作列ABABABACを、X=ABと定義してXXXACと書きます。定義と呼出し列の計7文字に登録固定費用を加え、元の8文字より得かを比べます。展開後の操作列は元と完全一致させます。

入力の表・詳しいルール・計算例を確認する

繰り返す操作を一つの名前にまとめ、総費用を減らす

ここでは既に与えられた操作列を短く表します。移動する経路や訪問先を変えるのではなく、同じ操作を再現できる記述を作ります。

何をする問題か

実行したい操作がA・B・Cの文字列で与えられます。繰り返すまとまりへ名前Xを付け、Xの一文字でその列を呼べるようにして、記述の総費用を減らします。

Xの定義も伝える必要があるため、呼出し列だけが短くなればよいわけではありません。定義と呼出し列を両方保存し、展開すると元の操作を同じ順に実行できることを確認します。

具体例で使うデータ
保存するもの初期設定の内容費用
元の列のままABABABAC8
Xの定義AB2+登録固定費用
Xを使った呼出し列XXXAC5
答えと評価を、具体例で追う
  1. 左から重ならず置換する

    ABABABACの先頭から、ABを三回Xへ置き換えるとXXXACです。末尾のACは定義と違うため、そのまま残します。

  2. 元へ戻して確かめる

    XXXACの各XをABへ展開するとABABABACへ戻ります。内容や順番が変わった短い列は、圧縮に成功した答えではありません。

  3. 登録込みで採否を決める

    固定費用0なら2+5=7なので元の8より得です。固定費用2なら2+2+5=9となり、Xを登録せず元の列を使う方がよくなります。

ここで間違えやすいこと例えばAAAAにAAを登録するなら、重ならない二組をXXにします。開始位置0・1・2の三一致を同時に置き換えると、同じ文字を重ねて消費してしまいます。

ルールと、この章での扱いを確認する
与えられるもの
初期の操作列はABABABAC、登録するまとまりはAB。元の列・登録する列・登録時の固定費用を変更できます。
決めること・求めるもの
Xの定義と、置き換えた呼出し列です。X=ABなら呼出し列XXXACを展開して元の列へ戻せます。
守る条件
元の操作列と完全一致するように復元できる必要があります。マクロは一種類で、定義内にXを含めず、重なる一致は同時に置き換えません。
評価・確認の基準
呼出し列の文字数+定義の文字数+登録の固定費用を最小化します。登録せず元の列を使う選択もあります。初期設定では5+2=7です。

この章で作る部品置換・復元・費用計算を作り、登録費用込みで元の列より得かどうかを判定します。

マクロは、操作のまとまりに名前を付け、一回の呼出しでまとめて実行する仕組みです。呼出しを元の操作列へ戻すことを展開と呼び、元の列との一致を検査します。

図:短い列だけでなく、登録と復元も確認する

元の操作列

ABABABAC
8文字

X=ABを登録

呼出し列XXXACは5文字
定義2文字で計7

元の列へ展開

XをABへ戻す
内容一致と総費用を別に検査

この小例では一種類のマクロ、再帰なし、Xは元の列に出ないという規則を使います。

1. 定義も含めて費用を数える

マクロXの費用は、呼出し列の長さ+定義長+登録固定費用です。X=ABならABABABAC→XXXACで、固定費用0なら5+2=7。使わなければ登録費用は不要です。今回は再帰や定義内のXを許しません。

注目コード:定義と登録費用も含めて採否を決める初期設定の抜粋 · 左は完成コードの行番号
int candidate_cost = encoded.size() + macro.size() + fee;
int estimated = ignore_registration ? int(encoded.size()) : candidate_cost;
bool used = estimated < int(original.size());

ここを見る:encodedは呼出し列、macroは登録する操作列です。通常はignore_registration=falseとし、短く見える呼出し列だけで採用しません。

完成コードの L14–16 を見る →

2. 重ならない一致を置換する

左から一致を見つけたらXを出し、定義長だけ進みます。不一致なら一文字出して進みます。AAAA中のAAは開始位置0・1・2にありますが、三回同時には置換できません。複数マクロでも、頻度だけでなく実際の符号化費用で比べます。

注目コード:一致した分だけ読み進める初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
for (int i = 0; i < int(original.size()); ) {
    if (original.substr(i,macro.size()) == macro) {
        encoded += 'X';
        i += macro.size();
    } else encoded += original[i++];
}

ここを見る:一致時は登録列の長さだけ進めるので、同じ文字を重ねて利用しません。不一致なら元の一文字を残します。

完成コードの L10–13 を見る →

3. 復元と改善を別に検査する

展開結果が元の列と完全一致するか検査し、その後に費用を比べます。固定費用2なら5+2+2=9で元の8より高く、復元成功でも改善ではありません。登録費用を無視する不具合で、expanded_ok と improved の違いを確認します。

4. 規則そのものを探索する

マクロの定義・呼出し列に加え、入力と内部状態から次の行動を決める状態機械の規則も探索対象にできます。候補ごとに出力サイズや規則数を検査し、初期状態から実行して評価します。元の列との一致が不要な問題なら、到達地点など実行結果を検査します。

ここまでの確認

呼び出す列が短くなれば、費用も必ず減りますか?

答えと理由を見る

必ずではありません。まとまりを登録する費用もかかるルールなら、それを含めて比べます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点固定した一つのマクロで、符号化・復元・総費用の計算を別々に作ります。

用意するデータと処理
original / macro
元の操作列と、登録する操作のまとまり。
encoded / restored
呼出し列と、その展開結果。元の列とは別に保持する。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 重ならない一致を置き換える

    元の列を左から読み、一致ならマクロ長だけ、非一致なら一文字だけ進めます。候補は別の文字列へ追加します。

    ここで確認AAAAにAAを使うと二回の置換となり、重なる三一致を二重計上しない。

  2. 2. 復元を先に確かめる

    呼出し列を左から読み、マクロ名を定義の列へ戻します。採否を決める前に、元の操作列と完全一致するかを調べます。

    ここで確認短くなっていても、文字の欠落や順番の変化を見逃さない。

  3. 3. 登録込みの費用で選ぶ

    呼出し列の長さ・定義の長さ・固定費用を足します。使わない場合の費用と比較し、候補が高ければ元の列を返します。

    ここで確認復元成功と費用改善を別に判定できる。

次に足すなら:複数の定義候補を試す

マクロ候補を列挙する外側ループを追加し、各候補を元の列から独立に符号化します。実費用が最良の定義と呼出し列を組で保存します。

追加後の確認:前の候補の圧縮結果を次の入力にせず、定義と呼出し列の対応が保たれる。

完成コードで見る場所:original.substr:一致と置換位置を進める場所 / candidate_cost:登録も含めた費用

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

マクロ
いくつかの操作をまとめ、一つの呼び出しで実行する仕組み。
展開
短い記号や名前を、元の操作の並びへ戻すこと。

小例の補足

ABABABACでX=ABを定義し、XXXACへ置き換えます。元の列への復元と、定義・固定費用を含む総費用を別々に確認します。

この章で扱う実装の範囲

  • 操作列と展開器
  • 部分列の一致・重なり・反復
  • 辞書登録費用と呼出費用
  • 状態×観測の遷移表、規則の衝突検査

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:5.3 大まかな計画を、実際の手順へ変える

理解できたら:次に広げること

次は、今の観測と内部の記憶から行動を決める小さな規則表へ進みます。同じ見た目でも、記憶が違えば別の行動が必要になることがあります。

別の問題でも使うなら

移動ロボットでは、文字列が同じかだけでなく、実際に動かした結果が同じかも確認します。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材繰り返す操作を一つの名前にまとめ、総費用を減らす — 問題設定・具体例を読む

6.9 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

ABABABACをX=ABで置き換えるとXXXACの5文字、定義2文字で計7です。固定費用を2に上げると元の8文字より高くなります。登録費用を無視すると、復元は正しくても費用の採否を誤ります。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iostream>
#include <string>
#include <vector>
using namespace std;
int main(){
    const int dataset=0,pattern=0,fee=0;
    const bool ignore_registration=false;
    const vector<string> texts={"ABABABAC","AAAAAAAA","ABCABABCAB"},patterns={"AB","AA","ABC"};
    string original=texts[dataset],macro=patterns[pattern],encoded;
    for(int i=0;i<int(original.size());){
        if(original.substr(i,macro.size())==macro){encoded+='X';i+=macro.size();}
        else encoded+=original[i++];
    }
    int candidate_cost=encoded.size()+macro.size()+fee;
    int estimated=ignore_registration?int(encoded.size()):candidate_cost;
    bool used=estimated<int(original.size());
    string output=used?encoded:original,restored;
    for(char c:output){if(c=='X')restored+=macro;else restored+=c;}
    int real_cost=used?candidate_cost:int(original.size());
    cout<<"encoded="<<encoded<<"\nrestored="<<restored<<"\nexpanded_ok="<<(restored==original)
        <<"\nestimated="<<estimated<<"\nreal_cost="<<real_cost<<"\nused="<<used
        <<"\nimproved="<<(real_cost<int(original.size()))<<"\n";
}

必要に応じて選ぶ発展

6.10 ランダムに動くものの、起こり方を数える

今回の問題

駒は位置0から出発し、毎回1/2で一つ進み、1/2で留まります。位置2に着いたら動きません。三回後に各位置にいる確率と、位置の期待値を求めます。

入力の表・詳しいルール・計算例を確認する

確率で進む駒が、何回か動いた後の位置を求める

ここでは操作した後の位置が確率で変わります。測定から未知値を推定する問題とは別で、動く確率を既知として結果を計算します。

何をする問題か

駒が位置0から出発し、同じ操作を何回か受けます。各操作で、確率pで一つ先へ進み、残りの確率でその位置へ留まります。位置2へ着いたら、それ以降は必ず位置2に留まります。

操作の結果はランダムなので、一つの到着位置ではなく、各位置にいる確率を答えます。pは既知で、測定結果から推定する問題ではありません。

具体例で使うデータ
操作回数(p=1/2)位置0位置1位置2
0100
11/21/20
21/41/21/4
31/83/81/2
答えと評価を、具体例で追う
  1. 一回目を計算する

    最初は位置0に確率1でいます。半分が0へ留まり、半分が1へ進むので[1/2,1/2,0]です。一回だけで位置2へは行けません。

  2. 別の経路で届く確率を足す

    二回目に位置1へいるのは「止まってから進む」と「進んでから止まる」の二通りで、各1/4を足して1/2です。各操作の間で現在と次の配列を分けます。

  3. 求める値を取り出す

    三回後の位置2への到達確率は1/2です。位置の期待値は0×1/8+1×3/8+2×1/2=11/8になります。

ここで間違えやすいこと確率の合計が1でも、更新が正しいとは限りません。今位置1へ届いた分を同じ操作中にさらに位置2へ動かすと、一回で二歩進む誤った分布になります。

ルールと、この章での扱いを確認する
与えられるもの
位置は0・1・2、最初は必ず0にいます。一操作で確率pだけ一つ進み、確率1−pで留まります。初期設定はp=1/2・操作3回です。
決めること・求めるもの
指定回数後に各位置へいる確率、位置2への到達確率、位置の期待値を求めます。
守る条件
一操作では高々一つしか進みません。位置2は終点で、到着後はそこに留まります。
評価・確認の基準
この章は最適化の候補を探すのではなく、固定された操作の結果を評価します。初期設定の分布は[1/8,3/8,1/2]です。

この章で作る部品現在と次の分布を別配列にして更新します。後で操作列を探索する際に、その候補を比較する評価器として使う部品です。

各位置にいる確率を更新し、ランダムな移動後の結果を計算します。

図:今の分布を読み、次の分布へ加算する

時刻t

確率[1,0,0]
一操作だけ進める

二つの遷移

確率1−pで同じ位置
確率pで一つ先

時刻t+1

[1−p,p,0]
まだ位置2へは届かない

同じ配列を左から更新すると、今到着した確率を同じ操作でもう一度動かしてしまいます。

1. 位置ごとの確率を持つ

既知の移動確率pから、将来の分布を計算します。観測から未知値を推定する4.2とは別です。p=1/2で三回後は[1/8,3/8,1/2]、位置の期待値はΣ位置×確率です。

2. 次の配列へ加算する

nextを0にし、位置xの確率の(1−p)倍をnext[x]、p倍をnext[x+1]へ足します。位置2は全量をnext[2]へ残します。全位置を処理してから現在配列を置き換えます。複数経路が合流するので代入ではなく加算します。

注目コード:旧分布だけを読み、新しい配列へ足す初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
vector<double> advance_distribution(const vector<double>& old, double p) {
    vector<double> next(3,0);
    for (int x = 0; x < 2; x++) {
        next[x] += old[x]*(1-p);
        next[x+1] += old[x]*p;
    }
    next[2] += old[2];
    return next;
}

ここを見る:位置0・1から進む場合と留まる場合を分配します。位置2は吸収状態なので、その確率を全て位置2へ残します。oldは途中で更新しません。

完成コードの L9–13 を見る →

3. 総和だけで判定しない

同じ配列を左から更新すると、0→1に届いた分を同じ操作で1→2へ動かしてしまいます。総和1でも、一操作で二歩進む不具合です。

一回・p=1/2なら位置2は0か、p=0・p=1・操作0回は正しいかを確認します。二配列の基準計算と比べ、総和・期待値・最大誤差を出します。

注目コード:確率の総和と分布の一致を別々に見る初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
sum += distribution[x];
expected += x*distribution[x];
reference_expected += x*reference[x];
error = max(error, abs(distribution[x]-reference[x]));

ここを見る:各位置xについて集計します。sumが1でも遷移が正しいとは限らないため、基準分布referenceとの最大誤差errorも確認します。

完成コードの L29–30 を見る →

4. 大きい状態空間へ広げる

疎な遷移は行ける先だけ列挙します。固定した操作列の期待得点を返す評価器にすれば、外側で操作列を探索できます。

状態が多すぎれば、乱数で一部の結果を取り出すサンプリングや、分布を簡略化する方法を使い、ばらつきや捨てた確率量を確認します。小例の厳密計算を誤差の基準に残します。

ここまでの確認

一つ進む確率1/2で、3回後に位置2にいる確率はいくつですか?

答えと理由を見る

1/2です。2回後に位置2にいた1/4に、位置1から進む分1/2×1/2=1/4を足します。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点位置0の確率だけ1にした分布から、一操作の遷移を純粋な関数として作ります。

用意するデータと処理
old / next
現在の分布と、0で初期化した次の分布。
advance_distribution
現在の分布と進む確率を受け取り、次の分布を返す。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 一操作だけ計算する

    各位置の確率を、止まる分と進む分に分けてnextへ加えます。現在の配列は読み取り専用にし、吸収状態はそのまま残します。

    ここで確認一操作・確率1/2で[1/2,1/2,0]になる。

  2. 2. 反復の外で配列を交換する

    一操作の全位置を処理し終えてからnextを現在の分布にします。操作0回は初期分布をそのまま返します。

    ここで確認今届いた確率を同じ操作で再び動かしていない。

  3. 3. 分布と評価を別に確認する

    確率の総和・非負性・簡単な到達確率を検査してから期待位置を計算します。小例は基準の分布とも照合します。

    ここで確認総和1という条件だけで、誤った遷移を正しいと判定しない。

次に足すなら:操作ごとに遷移を変える

一操作の関数へ操作の種類を引数として足します。固定した操作列を最後まで評価する窓口を作ってから、列を変える探索へ接続します。

追加後の確認:各行の遷移確率の総和が1で、短い操作列は全分岐の列挙と一致する。

完成コードで見る場所:advance_distribution:一操作の分布更新 / reference_expected:基準分布からの期待値

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

確率分布の更新
prob[x]に位置xにいる確率を保存し、遷移先の配列へ確率を加算します。現在の配列と次の時刻の配列を分けます。

小例の補足

位置0から、確率pで一つ進みます。位置2は吸収状態です。今の配列と次の配列を分けた更新を、同じ配列を更新する不具合と比べます。

この章で扱う実装の範囲

  • 状態と遷移確率の表
  • 次時刻用の確率配列と吸収状態
  • 各時刻の期待報酬と累積報酬
  • サンプル平均、試行数、固定seedによる照合

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.6 検査と評価を分け、全計算を基準にする

理解できたら:次に広げること

この章は動く確率が分かっている例です。分からない値を測定から推測する4.2とは、作る処理を分けます。

別の問題でも使うなら

分岐する機械の中を物が流れる問題でも、分かれ道ごとに確率を分けて足していけます。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材確率で進む駒が、何回か動いた後の位置を求める — 問題設定・具体例を読む

6.10 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

操作回数1・確率50%で不具合を有効にすると、位置2へも確率が流れます。総和は1のままなので、総和の検査だけでは見つかりません。確率0%・100%、操作0回も試してください。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <cmath>
#include <iomanip>
#include <iostream>
#include <vector>
using namespace std;
// 表示だけを小数第6位へ四捨五入する。遷移の計算には丸め前の値を使う。
double display_value(double value){return round(value*1000000)/1000000;}
vector<double> advance_distribution(const vector<double>& old,double p){
    vector<double> next(3,0);
    for(int x=0;x<2;x++){next[x]+=old[x]*(1-p);next[x+1]+=old[x]*p;}
    next[2]+=old[2];return next;
}
int main(){
    const int steps=3,percent=50;const bool in_place=false;
    double p=percent/100.0;vector<double> distribution={1,0,0},reference=distribution;
    cout<<fixed<<setprecision(6);
    for(int t=0;t<=steps;t++){
        cout<<"step="<<t;for(double v:distribution)cout<<","<<display_value(v);cout<<"\n";
        if(t==steps)break;
        reference=advance_distribution(reference,p);
        if(in_place){
            // 意図した誤り:今の時刻と次の時刻の値を混ぜている。
            for(int x=0;x<2;x++){double moved=distribution[x]*p;distribution[x]-=moved;distribution[x+1]+=moved;}
        }else distribution=advance_distribution(distribution,p);
    }
    double sum=0,expected=0,reference_expected=0,error=0;
    for(int x=0;x<3;x++){
        sum+=distribution[x];expected+=x*distribution[x];reference_expected+=x*reference[x];
        error=max(error,abs(distribution[x]-reference[x]));
    }
    cout<<"mass="<<display_value(sum)<<"\nexpected="<<display_value(expected)<<"\nreference_expected="<<display_value(reference_expected)
        <<"\nmax_error="<<display_value(error)<<"\nmatches="<<(error<1e-12)<<"\n";
}

必要に応じて選ぶ発展

6.11 ログから評価関数を学習し、探索へ戻す

今回の問題

残り仕事数xから完成費用yを予測する直線を学習します。学習用は(1,3)・(2,5)・(3,7)・(4,9)、確認用は(5,15)・(6,17)。確認用を学習へ混ぜず、予測誤差を測ります。

入力の表・詳しいルール・計算例を確認する

残り仕事数から、完成までの費用を予測する

ここでは入力と完成費用の記録から予測器を作ります。前の確率分布の更新とは異なり、学習用と確認用に分けたデータを使います。

何をする問題か

探索途中の状態を完成させるには、どれくらい費用がかかりそうかを予測します。導入例では状態の情報を「残り仕事数x」一つに絞り、実測した完成費用yとの関係を学びます。

下の各行は別々の入力ケースです。費用を作る規則は教材のデータ生成用で、予測器は学習に許された行のxとyから係数を求めます。

具体例で使うデータ
用途xy(追加費用4の初期設定)
学習13
学習25
学習37
学習49
確認515
確認617
答えと評価を、具体例で追う
  1. 学習側だけで係数を決める

    最初の四行はy=2x+1なので、線形モデルŷ=ax+bを当てるとa=2、b=1になります。確認用の15と17は、この計算へ混ぜません。

  2. 未使用の入力へ予測する

    x=5・6の予測は11・13ですが、実際の費用は15・17です。誤差は両方−4、二乗誤差の平均は(16+16)/2=16です。

  3. 基準と比較する

    学習しない基準予測2xなら10・12となり、二乗誤差の平均は25です。確認用で16まで減りましたが、探索の得点がどれだけ変わるかは別途測ります。

ここで間違えやすいこと確認用を学習へ混ぜれば、この二入力への誤差が減ることがあります。しかしそれは、まだ見ていない入力での精度を確かめたことにはなりません。

ルールと、この章での扱いを確認する
与えられるもの
六つの入力ケースで残り仕事数x=1〜6。費用はy=2x+1で作り、確認用のx=5・6だけ追加費用を加えます。初期設定の追加費用は4です。
決めること・求めるもの
予測式ŷ=ax+bの係数a・bを求め、確認用の二入力に対する費用を予測します。
守る条件
学習にはx=1〜4の四入力だけを使います。確認用を学習へ混ぜる設定は、検証の不備を再現するためのものです。
評価・確認の基準
確認用二入力の予測誤差の二乗平均を比べます。基準予測2xとも比較し、この誤差だけで最終的なAHCの得点改善とは判断しません。

この章で作る部品データ分割、学習、推論、誤差集計を作ります。探索の途中評価へ組み込むことは次の追加実装として扱います。

教師あり学習は、入力と正解の組から予測の規則を作る方法です。ここでは状態の情報と完成後の費用を記録し、途中候補のよさを予測する評価関数に使います。

図:学習と確認を、入力ケースの境界で分ける

学習用の入力

ケース0〜3
残り仕事数と実測費用

係数を学習

予測 = a × 残り仕事数 + b
C++でも同じ式を使う

確認用の入力

ケース4〜5
予測誤差と探索への効果

確認用の入力を学習にも混ぜた誤差は、未見入力への予測性能を示しません。

1. 何を予測するか決める

残り仕事数xから完成費用yを予測する ŷ=ax+b を作ります。六ケースの最初の四つで学習し、残り二つで確認します。費用は教材用の生成データです。

予測の入力に使う数値を特徴量、正解として学ばせる実測結果を教師値と呼びます。この例ではxとyです。特徴量には探索時点で得られる情報だけを使います。

2. 学習と同じ式で推論する

xとyの偏差の積和をxの偏差平方和で割ってaを求め、b=平均y−a×平均x とします。学習した規則で新しい入力を予測することを推論と呼び、この例はa*x+bです。正規化する場合は平均・尺度も保存します。

基準予測2*xと、確認用データの平均二乗誤差(MSE)を比べます。学習側とC++推論側で特徴量・尺度・係数をそろえます。

注目コード:学習した係数をそのまま予測に使う初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
double predicted = a*x[i]+b, err = predicted-y[i];
mse += err*err;

ここを見る:a・bは学習用データから求めた直線の係数です。確認用データでは係数を更新せず、予測値と正解yとの差だけを集計します。

完成コードの L18 を見る →

3. 入力ケースごとに分ける

同じ入力から得た状態は似ているため、状態単位のランダム分割では確認側にも似た例が混ざります。入力ケースごとに分け、確認用を学習に使いません。

混ぜる設定では誤差が下がっても independent=0 です。追加費用を変え、学習範囲外で規則が変わったときのずれも確認します。

注目コード:確認用ケースを学習から外す初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
int count = leak ? 6 : 4;
double mx = 0, my = 0;
for (int i = 0; i < count; i++) { mx += x[i]; my += y[i]; }
mx /= count;
my /= count;

ここを見る:通常はleak=falseで先頭4件だけを学習に使います。最後の2件まで混ぜると、確認用の正解を見て係数を決める不適切な比較になります。

完成コードの L10–11 を見る →

4. 探索の得点で確認する

予測が正確でも、推論が遅く探索回数が減れば得点は悪化しえます。基準版と同じ時間で走らせ、目的値・失敗率・推論回数を比べます。MSEだけで得点改善とは判断しません。

正解行動を手本にする模倣学習や、行動後の報酬から学ぶ強化学習へ広げる場合も、利用可能な情報、学習と確認の分離、実行時コストを守ります。

ここまでの確認

同じ入力から得た似た状態を学習側と検証側へ分けると、何が問題になりますか?

答えと理由を見る

既に見た入力に近い状態で評価し、新しい入力への予測性能を過大に見積もる可能性があります。入力単位で分けます。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点探索へ学習器をつなぐ前に、入力ごとの特徴量と完成費用を集め、学習と確認を分けます。

用意するデータと処理
記録
入力ID、途中状態から作れる特徴量、完成後に得た教師値。
学習済みモデル
係数と、使う場合は正規化の尺度。推論はこの値だけを読む。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 予測対象と分割を決める

    何を予測するかを一つ決め、入力IDで学習側・確認側へ分けます。同じ入力からの状態を両側へ混ぜません。

    ここで確認探索時に見えない未来の値を、特徴量へ入れていない。

  2. 2. 学習と推論を別に動かす

    学習側だけから係数を求め、確認側は推論にだけ渡します。まず線形モデルで、手計算や既存の単純な予測と照合します。

    ここで確認同じ特徴量・尺度で学習時と実行時の予測が一致する。

  3. 3. 途中評価の呼出しへ入れる

    確認誤差を調べた後、候補を並べる直前の評価関数を差し替えます。最終目的値の計算と有効性の検査は引き続き実測します。

    ここで確認予測誤差だけでなく、同じ時間での試行数と最終得点も比較できる。

次に足すなら:特徴量を一つ増やす

記録時と推論時の特徴量生成を同じ定義に更新し、学習データから係数を求め直します。正規化の統計も学習側だけから計算して保存します。

追加後の確認:入力ごとの分割を保ち、新しい特徴量の追加で得点と推論時間がどう変わるか分かる。

完成コードで見る場所:variance:係数を学習する集計 / predicted:保存した係数で予測する場所

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

特徴量
状態から取り出す予測用の数値。学習時と探索時で定義と尺度をそろえる。
教師値
予測したい実測結果。途中評価の値をそのまま正解として使わず、何を学ばせるか明確にする。

小例の補足

六入力の残り仕事数と完成費用から、最初の四入力で線形モデルを学習します。最後の二入力で予測誤差を測り、確認用を学習へ混ぜた場合との違いを見ます。

この章で扱う実装の範囲

  • 教師データ:状態の特徴量と、残り費用などの予測対象を探索ログから作る。
  • 分割:同じ入力から作った状態が学習側と検証側へ混ざらないよう、入力単位で分ける。
  • 学習:手作業の重みを基準に、線形回帰など小さいモデルから比較する。
  • 推論:特徴量・尺度・重みをC++側とそろえ、学習時と同じ予測値を再現する。
  • 探索での検証:予測誤差に加え、推論時間と最終得点を測る。模倣学習・強化学習は発展課題とする。

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:1.6 検査と評価を分け、全計算を基準にする、5.1 いくつかの入力で、本当によくなったか比べる

発展の実装で参照する章:3.1 途中の答えを、何で比べるか考える、4.4 プレイアウトとUCBで、行動を比較する。この章の導入例を読むための必修ではありません。

理解できたら:次に広げること

まず線形モデルで処理を一通り作ります。小さい学習器、模倣学習、強化学習は、推論コストも比較する発展課題です。

別の問題でも使うなら

残り費用の予測を、候補を最後まで試したときの成功率の予測へ変え、特徴量と教師値を定義し直します。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材残り仕事数から、完成までの費用を予測する — 問題設定・具体例を読む

6.11 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

確認用の入力を学習へ混ぜると誤差が下がっても、未見入力の検証にはなりません。追加費用を0へ戻すと線形モデルが正確に合う例、正の値では学習範囲外でずれる例になります。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <iomanip>
#include <iostream>
#include <vector>
using namespace std;
int main(){
    const int shift=4;const bool leak=false;
    vector<double> x={1,2,3,4,5,6},y;
    for(double v:x)y.push_back(2*v+1+(v>=5?shift:0));
    // 各点を別の入力ケースとする。最後の2ケースは確認用。
    int count=leak?6:4;double mx=0,my=0;
    for(int i=0;i<count;i++){mx+=x[i];my+=y[i];}mx/=count;my/=count;
    double variance=0,covariance=0;
    for(int i=0;i<count;i++){variance+=(x[i]-mx)*(x[i]-mx);covariance+=(x[i]-mx)*(y[i]-my);}
    double a=variance>0?covariance/variance:0,b=my-a*mx;
    double mse=0,baseline=0;
    cout<<fixed<<setprecision(6)<<"a="<<a<<"\nb="<<b<<"\n";
    for(int i=4;i<6;i++){
        double predicted=a*x[i]+b,err=predicted-y[i];mse+=err*err;
        double base=2*x[i]-y[i];baseline+=base*base;
        cout<<"case="<<i<<","<<x[i]<<","<<y[i]<<","<<predicted<<"\n";
    }
    cout<<"mse="<<mse/2<<"\nbaseline_mse="<<baseline/2<<"\nindependent="<<!leak<<"\n";
}

7. 総合練習:部品を組み合わせる

配送を題材に、計画・変更・検査・評価・比較をつなぐ課題です。状態・候補生成・検査・評価・採用・再生をつないだ完成コードと演習を収録しています。三つの探索方法を全て使うことを必須にはしていません。

総合練習

7.1 覚えた部品を組み合わせ、一つの問題を解く

今回変わる条件参照:6.2 の問題設定
変更後の問題設定と具体例を確認する

二台の車で六つの配達先を分担して回る

訪問順の問題を二台の分担へ広げます。1.2の割当と6.2の巡回路を組み合わせる課題なので、変更点を確認してください。

何をする問題か

一台で全て回る巡回路を、二台で分担する問題へ広げます。各車の訪問順だけでなく、どの配達先をどちらの車へ任せるかも選べます。

この章のID 0〜5は全て配達先で、倉庫は別に(0,0)として持ちます。各配達先を一台が一回担当し、どちらの車にも少なくとも一件を任せます。

具体例で使うデータ
配達先ID座標初期の担当
0(1,0)車0
1(2,0)車0
2(0,1)車0
3(0,2)車1
4(2,2)車1
5(3,1)車1
答えと評価を、具体例で追う
  1. 計画を二つの配列で表す

    初期計画は車0が[0,1,2]、車1が[3,4,5]です。それぞれの前後へ倉庫を補い、二本の巡回路として解釈します。

  2. 合計費用を求める

    車0は倉庫→0→1→2→倉庫で1+1+3+1=6。車1は倉庫→3→4→5→倉庫で2+2+2+4=10。目的値は二台の合計16です。

  3. 担当と順番を別々に変える

    車内反転なら担当集合は変わらず、並びだけが変わります。一地点を車間で移せば件数も変わり、交換なら件数を保ったまま担当を変えられます。

ここで間違えやすいこと配達先を一件消して距離を短くしても、不正です。また「各車ちょうど3件」を有効にした場合は、一地点の移動で2件と4件になる候補を拒否します。距離は短い方の車や最大値ではなく、二台分の合計で比べます。

ルールと、この章での扱いを確認する
与えられるもの
倉庫は(0,0)。配達先ID 0〜5は順に(1,0)、(2,0)、(0,1)、(0,2)、(2,2)、(3,1)です。この章では倉庫に配達先IDを付けません。
決めること・求めるもの
車ごとの訪問順を二つの配列で表します。初期計画は車0が[0,1,2]、車1が[3,4,5]です。
守る条件
各配達先をどちらか一台が一回担当し、各車は一件以上を担当します。各車3件の追加制約も選べます。移動中に他の地点を通過しても配達とは数えません。
評価・確認の基準
マンハッタン距離で、二台の走行距離の合計を最小化します。両車とも出発・帰還の区間を含めます。

この章で作る部品初期計画・候補生成・検査・評価・最良保存・再生をつないで探索を動かします。車同士の衝突や時間・積載量の制約は、この課題にはありません。

計画・変更・検査・評価をつなぎ、一つの探索プログラムを完成させます。

図:一つのソルバに、検証できる部品をつなぐ

計画を作る

車ごとの訪問順
全配送先を一回ずつ担当

候補を作って比較

コピー→変更→検査→評価
改善したときに採用

最良の計画を出す

初期位置から再生
費用と全探索の基準を照合

全探索はこの六地点の小例の答え合わせ専用です。探索途中の候補選択には使いません。

1. 二台の訪問順を状態にする

初期計画は車0が[0,1,2]、車1が[3,4,5]です。各配達先を一台が一回担当し、両車とも一件以上を回って倉庫へ戻ります。途中で別の配達先を通過しても配達とは数えず、列に指定した訪問だけを担当とします。

2. 検査・評価・候補生成を分ける

valid は空の車、ID範囲、重複・欠落、「各車3件」の追加条件を検査します。cost は倉庫との往復を含む二台の合計距離です。不正候補は採点しません。

現在の計画をコピーし、車内反転・車間移動・車間交換で候補を作ります。一件しかない車からの移動などは検査で拒否します。

注目コード:担当の重複・欠落をまとめて検査する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
bool valid(const Plan& p, bool balanced) {
    vector<int> count(6,0);
    for (const auto& route : p) {
        if (route.empty() || (balanced && route.size() != 3)) return false;
        for (int id : route) {
            if (id < 0 || id >= 6) return false;
            count[id]++;
        }
    }
    return all_of(count.begin(), count.end(), [](int x){ return x == 1; });
}

ここを見る:pは車ごとの訪問順です。空の担当と範囲外IDを拒否し、全配達先の出現数が1であることを確認します。balancedは各車3件の追加制約です。

完成コードの L12–19 を見る →

3. 改善する候補だけを採用する

有効で現在より安い候補を採用し、同点は棄却します。最良計画も更新して、指定回数後に出力します。xorshift乱数は、変更方法によらず毎試行四つ使います。

一手送りで候補・検査・採否を追います。現在値と最良値はこの山登りでは一致しますが、焼きなましへ替えるなら別に保存します。

注目コード:候補の合法性を確認してから採用する初期設定の抜粋 · 左は完成コードの行番号同じ番号は、元の一行を分けた箇所です。
bool legal = valid(candidate, balanced);
int value = legal ? cost(candidate) : -1;
bool take = legal && value < cost(current);
if (!legal) rejected_invalid++;
if (take) { current = candidate; accepted++; }
if (cost(current) < cost(best)) best = current;

ここを見る:不正な候補の−1は評価しない目印です。legalと組で判定するため、−1を「小さくてよい値」と誤って採用しません。

完成コードの L70–74 を見る →

4. 再生と小例の全探索で検証する

replay_cost で原点から一歩ずつ再生し、帰還と歩数を評価式と照合します。六地点なら全順列と分割位置を列挙し、最小費用も答え合わせできます。

全探索の結果は候補選択や出力の差し替えに使いません。追加制約を変えたら全探索側にも適用し、探索で得た値と最適値との差を測ります。

5. 一つずつ改良する

試行0回、反転のみ、移動のみ、交換のみ、混合を比べます。反転は担当車を変えられず、「各車3件」では一地点移動も使えません。

同じseed・回数で動作を確認した後、複数入力・seed・同じ実時間で比較します。差分評価・再構築・焼きなましは一つずつ足し、変更の効果を記録します。

ここまでの確認

方法をたくさん入れれば、この課題は完成ですか?

答えと理由を見る

数では判断しません。答えがルールを守り、改善を比較でき、各処理が何をするか説明できれば完成へ近づいています。

演習で試す →自分で実装する手順を見る

手を動かすとき:実装の型と追加する手順

出発点初期計画をそのまま出せる版を最初に作り、候補生成・検査・評価を一つずつ接続します。

用意するデータと処理
Plan / valid / cost
二台の訪問順、有効性の検査、倉庫との往復を含む全費用。
探索と再生
currentとbest、試行予算・seed、出力計画を再生する検査。

自分のプログラムへ組み込むときの分け方です。完成コードでは、短い処理をmain内に置いている場合もあります。

  1. 1. 試行0回の基準版を通す

    初期計画をvalidで検査し、costとreplay_costの両方で距離を求めて出力します。この時点で出力の形式を確定します。

    ここで確認配送先の重複・欠落がなく、帰還を含む二つの距離が一致する。

  2. 2. 一種類の変更でループを作る

    まずコピー上の反転だけを実装し、検査→全評価→改善なら採用→最良保存を固定回数で繰り返します。

    ここで確認一手ごとの採否を再現でき、変更しない候補でも処理が壊れない。

  3. 3. 担当変更を一種類ずつ足す

    別の車への移動、次に車間交換を追加し、共通のvalidへ通します。「3件ずつ」の条件を加え、拒否理由と採用数を記録します。

    ここで確認空の車や件数違反を拒否し、最終解の再生も成功する。

次に足すなら:差分評価を組み込む

各変更の影響する辺を列挙して差を計算し、コピー版の全費用と照合します。一致してから評価の呼出しだけを置き換え、次に計測します。焼きなましなどの採否変更はその後に分けます。

追加後の確認:同じ一手列で採否・最終計画が基準版と一致し、小例の全探索は答え合わせ専用のまま。

完成コードで見る場所:bool valid:候補が満たす条件 / replay_cost:出力計画の独立した再生

この章の完成コードを開く →

調べたいとき:用語・前提・この章の範囲

用語と実装上の要点

部品
ここでは、担当を変える・距離を計算する・答えを検査する、といった一つの役割を持つ処理。

小例の補足

六配送先を二台で分担します。車ごとの順列をコピーして反転・移動・交換し、有効な改善だけを残します。最終計画の再生と小例の全探索も照合します。

この章で扱う実装の範囲

  • 仕様カード・状態設計図・操作の契約
  • 独立した検査器と小さい手計算例
  • 初期解と一つの改善手法
  • 出力の再生、ケース別比較、判断の記録

小例はこの範囲への入口です。収録済みの本編と、今後広げる実装は章内で区別します。

この章の前提:5.3 大まかな計画を、実際の手順へ変える、5.1 いくつかの入力で、本当によくなったか比べる

理解できたら:次に広げること

この完成例は、局所探索を配送計画へ組み込んだものです。ビーム探索なら担当や訪問先を一つずつ決める状態、観測と判断なら未確定の移動費用を加えるなど、次の総合課題へ広げられます。

別の問題でも使うなら

車の積載量に上限を足し、担当変更の後に新しく何を調べる必要があるか説明します。

この章の演習では、設定を変えると図・結果・コピーする完成コードが対応して変わります。試してほしい変更は、演習の下の折りたたみに示しています。

この演習の題材二台の車で六つの配達先を分担して回る — 問題設定・具体例を読む

7.1 の教材体験見本 この場で動きます

設定を変えて確かめる

結果を予想して「実験する」を押します。設定は完成コードにも反映されます。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方

反転だけでは各車の担当集合が変わりません。移動・車間交換を加え、同じ試行数とseedで比較します。ちょうど3件の制約を有効にすると移動候補は不正となるので、車間交換が役立ちます。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <numeric>
#include <utility>
#include <vector>
using namespace std;
using Plan=vector<vector<int>>;
const vector<pair<int,int>> point={{1,0},{2,0},{0,1},{0,2},{2,2},{3,1}};
struct Random{uint32_t x;uint32_t next(){x^=x<<13;x^=x>>17;x^=x<<5;return x;}};
bool valid(const Plan& p,bool balanced){
    vector<int> count(6,0);
    for(const auto& route:p){
        if(route.empty()||(balanced&&route.size()!=3))return false;
        for(int id:route){if(id<0||id>=6)return false;count[id]++;}
    }
    return all_of(count.begin(),count.end(),[](int x){return x==1;});
}
int cost(const Plan& p){
    int result=0;
    for(const auto& route:p){
        int x=0,y=0;
        for(int id:route){auto [nx,ny]=point[id];result+=abs(x-nx)+abs(y-ny);x=nx;y=ny;}
        result+=abs(x)+abs(y);
    }
    return result;
}
// 評価式とは別に、一歩ずつ移動して帰還を含む歩数を数える。
int replay_cost(const Plan& p){
    int steps=0;
    for(auto route:p){
        route.push_back(-1);int x=0,y=0;
        for(int id:route){
            auto [tx,ty]=id<0?make_pair(0,0):point[id];
            while(x!=tx){x+=x<tx?1:-1;steps++;}
            while(y!=ty){y+=y<ty?1:-1;steps++;}
        }
    }
    return steps;
}
int exact_reference(bool balanced){
    vector<int> order(6);iota(order.begin(),order.end(),0);int best=100000;
    do{
        for(int split=1;split<6;split++){
            Plan p={vector<int>(order.begin(),order.begin()+split),vector<int>(order.begin()+split,order.end())};
            if(valid(p,balanced))best=min(best,cost(p));
        }
    }while(next_permutation(order.begin(),order.end()));
    return best;
}
int main(){
    const int trials=80,seed=42,mode=3;const bool balanced=false;
    Random rng{uint32_t(seed)};Plan current={{0,1,2},{3,4,5}},best=current;
    const int initial=cost(current);int accepted=0,rejected_invalid=0;
    for(int t=0;t<trials;t++){
        // モードを変えても、一試行で乱数を四つ使う。
        int random_kind=rng.next()%3,from=rng.next()%2;
        int i=rng.next()%current[from].size();uint32_t raw=rng.next();
        int kind=mode==3?random_kind:mode;Plan candidate=current;
        if(kind==0){
            int j=raw%candidate[from].size(),l=min(i,j),r=max(i,j);
            reverse(candidate[from].begin()+l,candidate[from].begin()+r+1);
        }else if(kind==1){
            int id=candidate[from][i],to=1-from,pos=raw%(candidate[to].size()+1);
            candidate[from].erase(candidate[from].begin()+i);candidate[to].insert(candidate[to].begin()+pos,id);
        }else{
            int to=1-from,j=raw%candidate[to].size();swap(candidate[from][i],candidate[to][j]);
        }
        bool legal=valid(candidate,balanced);int value=legal?cost(candidate):-1;
        bool take=legal&&value<cost(current);
        if(!legal)rejected_invalid++;
        if(take){current=candidate;accepted++;}
        if(cost(current)<cost(best))best=current;
        cout<<"step="<<t+1<<","<<kind<<","<<legal<<","<<value<<","<<take<<","<<cost(current)<<"\n";
    }
    cout<<"initial="<<initial<<"\nbest="<<cost(best)<<"\naccepted="<<accepted<<"\nrejected_invalid="<<rejected_invalid<<"\n";
    for(int v=0;v<2;v++){
        cout<<"route"<<v<<"=";
        for(int i=0;i<int(best[v].size());i++){if(i)cout<<",";cout<<best[v][i];}cout<<"\n";
    }
    cout<<"valid="<<valid(best,balanced)<<"\nreplay_cost="<<replay_cost(best)<<"\nscore_ok="<<(replay_cost(best)==cost(best))
        <<"\nreference="<<exact_reference(balanced)<<"\n";
}

補助の実験室:分野別に設定を比べる

各章の演習に加えて、仕事の重さを変えた差分評価、採用確率、経路、被覆を試せます。必要な見本を選んでください。ビーム探索・観測と判断の演習は、それぞれの経路の本編にあります。

  1. ① 値や条件を変える
  2. ② 結果を予想する
  3. ③ 実験して観察する
  4. ④ コードをコピーする
  5. ⑤ 手元の結果と照合する

コードのコピーと実行方法へ戻る

1.5・2.3・2.4 の教材体験見本 この場で動きます

仕事を移すと、何を更新する必要があるか

冒頭と同じ仕事を使います。仕事には左から0〜5の番号を付け、番号0・1をA、2・3をB、4・5をCへ任せます。各人の負荷の二乗和で評価し、小さい方をよい分け方とします。まず設定を変えずに「実験する」を押しても構いません。

2.3で試す:変更を取り消すときの設定

外すと、担当だけ戻して合計を戻し忘れた場合を試せます。最初は変更しなくて構いません。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方
  1. 最初の設定で、395 → 381、14減ることを確かめる。
  2. 移動先をAにする。同じ人のままなので、変化は0になる。
  3. 2.3を読んだら、移動先をCに戻し、「各人の合計も元へ戻す」を外す。結果の「変更を取り消した後も調べる」を開き、担当だけを戻しても合計が戻らないことを確かめる。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <array>
#include <iostream>

// ここを変更して実験できます。追加の入力やファイルは不要です。
constexpr std::array<int, 6> WEIGHTS = { 8, 7, 6, 5, 4, 3 };
constexpr int ITEM = 1;              // 仕事の ID: 0〜5
constexpr int DESTINATION = 2; // 担当者: 0=A, 1=B, 2=C
constexpr bool RESTORE_LOAD = true;   // false は取り消し漏れの実験

struct State {
    std::array<int, 6> assign = {0, 0, 1, 1, 2, 2};
    std::array<int, 3> load = {0, 0, 0};
};

// assign が正本。補助情報 load に頼らず再計算します。
int objective_full(const State& state) {
    std::array<int, 3> load = {0, 0, 0};
    for (int i = 0; i < 6; ++i) load[state.assign[i]] += WEIGHTS[i];
    int cost = 0;
    for (int value : load) cost += value * value;
    return cost;
}

int objective_cached(const State& state) {
    int cost = 0;
    for (int value : state.load) cost += value * value;
    return cost;
}

int main() {
    if (ITEM < 0 || ITEM >= 6 || DESTINATION < 0 || DESTINATION >= 3) {
        std::cerr << "ITEM / DESTINATION is out of range\n";
        return 1;
    }
    for (int w : WEIGHTS) if (w < 1 || w > 20) {
        std::cerr << "Use weights from 1 to 20\n";
        return 1;
    }
    State state;
    for (int i = 0; i < 6; ++i) state.load[state.assign[i]] += WEIGHTS[i];
    const State before = state; // 比較用。実用版では変更部分だけを記録できます。
    const int from = state.assign[ITEM];
    const int w = WEIGHTS[ITEM];
    int delta = 0;
    if (from != DESTINATION) {
        const int a = state.load[from], b = state.load[DESTINATION];
        delta = (a - w) * (a - w) + (b + w) * (b + w) - a * a - b * b;
    }
    state.load[from] -= w;
    state.load[DESTINATION] += w;
    state.assign[ITEM] = DESTINATION;
    const int after = objective_full(state);
    std::cout << "before=" << objective_full(before) << '\n';
    std::cout << "delta=" << delta << '\n';
    std::cout << "after=" << after << '\n';
    std::cout << "delta_ok=" << (objective_full(before) + delta == after) << '\n';

    // 取り消し。load も戻す必要があります。
    state.assign = before.assign;
    if (RESTORE_LOAD) state.load = before.load;
    std::cout << "undo_ok=" << (state.assign == before.assign && state.load == before.load) << '\n';
    std::cout << "final_full=" << objective_full(state) << '\n';
    std::cout << "final_cached=" << objective_cached(state) << '\n';
}

2.5 の教材体験見本 この場で動きます

悪くなる変更を、どのくらい試すか

同じ差分に対する採用判定を繰り返し、exp(−差分/温度)と観測した採用率を比べます。温度・seed・試行回数を変えて確かめてください。探索全体は2.5の本編で扱います。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方
  1. 値が10増える設定のまま、温度を5から20へ上げる。計算上の採用確率は約13.53%から60.65%へ増える。
  2. 温度と乱数の開始設定を同じにして、試す回数だけ変える。計算上の確率と、実際に採用された割合を比べる。
  3. 値の増減を−10にし、よくなる変更は温度によらず採用されることを確かめる。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <cmath>
#include <cstdint>
#include <iomanip>
#include <iostream>

// 最小化の例。DELTA は「変更後 - 変更前」です。
constexpr int DELTA = 10;
constexpr double TEMPERATURE = 5;
constexpr std::uint32_t SEED = 42;
constexpr int TRIALS = 1000;

// ブラウザと同じ 32bit の乱数列を使います。
std::uint32_t next_random(std::uint32_t& x) {
    x ^= x << 13;
    x ^= x >> 17;
    x ^= x << 5;
    return x;
}

int main() {
    if (DELTA < -20 || DELTA > 50 || !std::isfinite(TEMPERATURE) ||
        TEMPERATURE < 0.1 || TEMPERATURE > 40 ||
        SEED < 1 || SEED > 1000000 || TRIALS < 1 || TRIALS > 10000) {
        std::cerr << "Invalid experiment settings\n";
        return 1;
    }
    const double probability = DELTA <= 0 ? 1.0 : std::exp(-DELTA / TEMPERATURE);
    std::uint32_t random = SEED;
    int accepted = 0;
    double first_u = 0;
    bool first_accept = false;
    for (int i = 0; i < TRIALS; ++i) {
        const double u = next_random(random) / 4294967296.0;
        const bool take = u < probability;
        accepted += take;
        if (i == 0) { first_u = u; first_accept = take; }
    }
    // 毎回同じ候補を独立に試す実験です。解全体の探索ではありません。
    std::cout << std::fixed << std::setprecision(6);
    std::cout << "probability=" << probability << '\n';
    std::cout << "accepted=" << accepted << '\n';
    std::cout << "trials=" << TRIALS << '\n';
    std::cout << "observed_rate=" << double(accepted) / TRIALS << '\n';
    std::cout << "first_u=" << first_u << '\n';
    std::cout << "first_accept=" << first_accept << '\n';
}

仕事の分け方以外でも、変わった部分を探す

仕事を移すときは、二人分の合計が変わりました。訪問順や候補の選び方を変える場合は、どこが変わるでしょうか。6.2・6.3を読んだら、この二つを試してみてください。

6.2・2.4 の教材体験見本 この場で動きます

訪問順の一部を逆にすると、どの費用が変わるか

地点0〜4の座標は順に (0,0),(4,0),(4,3),(0,3),(2,5) です。最初の訪問順は 0→2→1→3→4→0、費用はマンハッタン距離です。反転範囲は地点IDではなく、訪問順配列の添字で指定します。添字1〜2の反転なら、地点2→1が地点1→2になります。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方
  1. 最初の設定で一致を確認する。行きと帰りの距離が同じなので、逆順にした内側の費用は変わらない。
  2. 位置1〜2のまま、行きと帰りで費用を変える。点2→1が点1→2となり、内側の費用も3増える。
  3. 逆順にする範囲を変える。偶然一致する場合があっても、いつも内側を省略できるとは限らない。

出入口だけで増減を計算できても、配列を逆順に並べ直す作業は別です。並べ直す範囲が長ければ、その作業には多くの時間がかかります。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <algorithm>
#include <array>
#include <cstdlib>
#include <iostream>

// 点はID順に (0,0),(4,0),(4,3),(0,3),(2,5)。始点へ戻る閉路です。
constexpr int LEFT = 1;
constexpr int RIGHT = 2;
constexpr bool DIRECTED = false;
const std::array<int, 5> X = {0, 4, 4, 0, 2};
const std::array<int, 5> Y = {0, 0, 3, 3, 5};

int distance(int a, int b) {
    // 有向モードでは、IDの小さい点から大きい点へ向かう辺に3を加えます。
    return std::abs(X[a]-X[b]) + std::abs(Y[a]-Y[b])
        + (DIRECTED && a < b ? 3 : 0);
}
int objective_full(const std::array<int, 5>& order) {
    int total = 0;
    for (int i = 0; i < 5; ++i) total += distance(order[i], order[(i+1)%5]);
    return total;
}
int main() {
    std::array<int, 5> order = {0, 2, 1, 3, 4};
    const int before = objective_full(order);
    const int a=order[LEFT-1], b=order[LEFT];
    const int c=order[RIGHT], d=order[(RIGHT+1)%5];
    const int boundary_delta = distance(a,c)+distance(b,d)-distance(a,b)-distance(c,d);
    int internal_delta = 0;
    for (int i=LEFT; i<RIGHT; ++i) {
        internal_delta += distance(order[i+1],order[i])-distance(order[i],order[i+1]);
    }
    const int delta = boundary_delta + internal_delta;
    // 差分計算と、この配列反転の計算量は別です。
    std::reverse(order.begin()+LEFT, order.begin()+RIGHT+1);
    const int after = objective_full(order);
    std::cout << "before=" << before << '\n'
              << "boundary_delta=" << boundary_delta << '\n'
              << "internal_delta=" << internal_delta << '\n'
              << "delta=" << delta << '\n'
              << "after=" << after << '\n'
              << "boundary_ok=" << (before+boundary_delta == after) << '\n'
              << "delta_ok=" << (before+delta == after) << '\n';
}

6.3・2.4 の教材体験見本 この場で動きます

一つ削除しても、まだ全ての場所を覆えるか

場所は0〜5の六つです。幾つかの場所をまとめて覆う候補を選びます。候補0は場所0・1・2を費用4で、候補1は2・3を費用2で、候補2は3・4・5を費用4で、候補3は0・5を費用3で覆います。全ての場所を少なくとも一つの候補で覆い、費用を減らすのが目標です。

JavaScript が有効なブラウザで、値を変えて実験できます。

試してほしい変更と考え方
  1. 最初の候補0・2に候補1を追加する。二つの候補に覆われる場所を探す。
  2. 最初に選ぶ候補を0・1・2へ変え、候補1を削除する。覆う数が2から1に減っても、まだ覆われている。
  3. 最初の候補を0・2へ戻し、候補0を削除する。費用は減るが、覆われない場所ができる。この答えは完成したものとして使えない。

費用を減らすことと、全てを覆うルールを守ることの両方を確認します。

必要な処理と入力例を全て含むC++17のコードです。画面で選んだ設定も反映されます。

完成コードを開く — 閉じたままでもコピーできます
#include <array>
#include <iostream>
#include <vector>

constexpr int MASK = 5;  // bit s が1なら候補sを選択
constexpr int TOGGLE = 1;
const std::array<std::vector<int>,4> COVERS = {{{0,1,2}, {2,3}, {3,4,5}, {0,5}}};
const std::array<int,4> COST = {4,2,4,3};
struct State {
    std::array<bool,4> selected{};
    std::array<int,6> count{};
    int cost = 0;
    int missing = 6;
};
State rebuild(const std::array<bool,4>& selected) {
    State s;
    s.selected=selected;
    for (int i=0;i<4;++i) if (selected[i]) {
        s.cost += COST[i];
        for (int u:COVERS[i]) ++s.count[u];
    }
    s.missing=0;
    for (int c:s.count) s.missing += (c==0);
    return s;
}
int main() {
    std::array<bool,4> selected{};
    for (int i=0;i<4;++i) selected[i]=(MASK>>i)&1;
    State state=rebuild(selected);
    const State before=state; // 最初はコピーをundoの基準にします。
    const int sign=state.selected[TOGGLE] ? -1 : 1;
    for (int u:COVERS[TOGGLE]) {
        const int old=state.count[u], next=old+sign;
        state.missing += (next==0)-(old==0);
        state.count[u]=next;
    }
    state.selected[TOGGLE]=!state.selected[TOGGLE];
    state.cost += sign*COST[TOGGLE];
    const State full=rebuild(state.selected);
    std::cout << "before_cost=" << before.cost << '\n'
              << "before_missing=" << before.missing << '\n'
              << "cost_delta=" << state.cost-before.cost << '\n'
              << "after_cost=" << state.cost << '\n'
              << "after_missing=" << state.missing << '\n'
              << "legal=" << (state.missing==0) << '\n'
              << "counts=";
    for (int u=0;u<6;++u) std::cout << (u ? "," : "") << state.count[u];
    std::cout << '\n' << "delta_ok=" << (state.count==full.count && state.cost==full.cost && state.missing==full.missing) << '\n';
    state=before;
    const State restored=rebuild(state.selected);
    std::cout << "undo_ok=" << (state.count==restored.count && state.cost==restored.cost && state.missing==restored.missing) << '\n';
}

困っていることから、使える型を探す

ここは後から引くための一覧です。最初に12種類を覚える必要はありません。「訪問順を変えたい」など、自分の問題に近い項目から章へ進めます。

仕事を誰に任せるか決める

仕事を三人に分ける。人ごとにまとめた数字を見て、仕事を移す。

作る部品:担当の表 → 人ごとの合計 → 移動 → 前後の比較

移した二人以外も変わるルールなら、その影響も調べます。

1.4 探索で変える情報を、状態として持つ / 2.1 制約に合う近傍を用意する / 2.4 変わった分だけを計算する / 6.7 作業の順番から、開始・終了の時刻を決める

関連する小問題と、実際のAHCの例

この教材の小問題 T01

AHC017・AHC025・AHC031・AHC053・AHC069

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

訪問する順番を決める

倉庫から四つの配達先を全て訪れ、倉庫へ戻る。順番の一部を逆にする。

作る部品:訪問順の配列 → 距離 → 並べ替え → 合計の比較

行きと帰りで費用が違えば、逆にした区間の内側も計算し直します。

6.2 訪れる順番を、交換したり逆にしたりする / 2.4 変わった分だけを計算する / 5.2 最短路を、ヒューリスティック探索の部品にする

関連する小問題と、実際のAHCの例

この教材の小問題 T02

AHC002・AHC006・AHC027・AHC034・AHC049・AHC062

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

必要な場所を、少ない候補で覆う

幾つかの場所を一度に担当する候補から、全てを覆う組合せを選ぶ。

作る部品:選んだかの表 → 場所ごとの覆う数 → 追加・削除

一つ消しても別の候補が覆っていることがあります。覆う数を記録します。

6.3 必要な場所を、選んだもの全部で覆う / 1.7 貪欲法で初期解を作る / 2.4 変わった分だけを計算する / 6.1 行き詰まったら、始め方や一部分を変える

関連する小問題と、実際のAHCの例

この教材の小問題 T03

AHC004・AHC005・AHC020・AHC043・AHC071

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

物を重ならないように置く

二つの長方形を箱へ入れる。一方の位置や向きを少し変える。

作る部品:座標と大きさ → 重なりの確認 → 移動・回転

辺に触れてよいか、箱からはみ出してよいかを先に決めます。

6.4 物を置いたとき、はみ出しや重なりを調べる / 6.6 大まかに決めてから、細かく直す

関連する小問題と、実際のAHCの例

この教材の小問題 T05

AHC001・AHC012・AHC019・AHC031・AHC039・AHC040・AHC071

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

道をつないで、全ての町へ行けるようにする

町を結ぶ道を選ぶ。道を一つ変え、つながりと費用を調べる。

作る部品:隣の町の一覧 → 到達できる町の確認 → 道の変更

近くの道だけ見ても、遠い町が切り離されたことを見落とします。

6.5 辺の変更と、連結性の検査を組み合わせる / 5.2 最短路を、ヒューリスティック探索の部品にする / 6.1 行き詰まったら、始め方や一部分を変える

関連する小問題と、実際のAHCの例

この教材の小問題 T04

AHC007・AHC013・AHC018・AHC020・AHC024・AHC037・AHC041・AHC045

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

途中の候補をいくつか残す

数を選んで11に近づける。今は不利な候補も少し残す。

作る部品:候補の一覧 → 一手先の候補 → 比較 → 残す数を制限

今の値だけでは、最後に良い答えになるか分からないことがあります。

1.7 貪欲法で初期解を作る / 3.2 ビーム探索で、途中の候補を複数残す / 3.3 ビーム探索の履歴を復元し、重複を取り除く / 3.4 ビーム探索のメモリと、多様性を管理する

関連する小問題と、実際のAHCの例

この教材の小問題 T07

AHC014・AHC021・AHC026・AHC032・AHC042・AHC055・AHC072

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

大まかな計画から、細かい手順を作る

訪問順を先に決め、地点間の道を後から探す。

作る部品:計画 → 手順へ変える処理 → 実行して確認

計画は短くても、手順に直したときに実行できるとは限りません。

5.2 最短路を、ヒューリスティック探索の部品にする / 5.3 大まかな計画を、実際の手順へ変える / 6.6 大まかに決めてから、細かく直す

関連する小問題と、実際のAHCの例

この教材の小問題 T06

AHC036・AHC038・AHC048・AHC052・AHC056・AHC065・AHC066・AHC073

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

時間や場所を取り合わないようにする

二台の機械で作業する。同じ機械に作業を重ねない。

作る部品:使う物 → 開始・終了の時刻 → 重なりの確認

一つを遅らせると、後の作業まで遅れることがあります。

6.7 作業の順番から、開始・終了の時刻を決める / 6.8 同じ時刻に、同じ場所へ入らないようにする / 5.3 大まかな計画を、実際の手順へ変える

関連する小問題と、実際のAHCの例

この教材の小問題 T06

AHC006・AHC017・AHC023・AHC033・AHC049・AHC057・AHC064・AHC069

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

繰り返す手順を短くまとめる

ABをXと呼ぶことにして、長い操作列を短く書く。

作る部品:元の列 → まとめる規則 → 短い列 → 元へ戻して確認

呼び出しが減っても、規則を登録する費用が増える場合があります。

6.9 繰り返す手順を、短い書き方へまとめる / 3.3 ビーム探索の履歴を復元し、重複を取り除く / 5.3 大まかな計画を、実際の手順へ変える

関連する小問題と、実際のAHCの例

この教材の小問題 T09

AHC004・AHC028・AHC044・AHC047・AHC056・AHC060・AHC066

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

ランダムに動いた後の結果を比べる

半分の確率で進む駒が、三回後にどこにいるか考える。

作る部品:各位置の確率 → 一回分の移動 → 確率を足し合わせる

動き方の確率は分かっている、という前提から始めます。

6.10 ランダムに動くものの、起こり方を数える / 4.4 プレイアウトとUCBで、行動を比較する

関連する小問題と、実際のAHCの例

この教材の小問題 T10

AHC009・AHC016・AHC035・AHC047・AHC050・AHC051

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

測って、見えない値を考える

箱の中のカードは赤か青。ときどき間違うセンサーの返事から色を推定する。

作る部品:観測した値 → 候補の重み → 更新 → 次に測るか決める

本当の値と、センサーが返した値を別の変数にします。

4.1 インタラクティブ問題の入出力と、観測履歴を管理する / 4.2 ベイズ推定で、観測から仮説の確率を更新する / 4.3 質問の情報量と費用を比べ、測定を選ぶ

関連する小問題と、実際のAHCの例

この教材の小問題 T08

AHC003・AHC018・AHC022・AHC025・AHC030・AHC040・AHC045

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

先の展開を試してから、今の行動を決める

今仕事を受けると、後の大きな仕事を受けられないかもしれない。

作る部品:今の候補 → 仮の将来 → 最後まで試す → 結果を比べる

未来はまだ見えません。仮に作った将来を、本当の未来として扱いません。

4.1 インタラクティブ問題の入出力と、観測履歴を管理する / 4.4 プレイアウトとUCBで、行動を比較する / 5.1 いくつかの入力で、本当によくなったか比べる

関連する小問題と、実際のAHCの例

この教材の小問題 T08

AHC007・AHC008・AHC015・AHC029・AHC054・AHC061・AHC069

同じ種類の条件が現れる例です。同じ解き方が最適であることを意味しません。

練習で使う、小さい問題のルール

代表的な十の題材をまとめています。各章の冒頭にも、その章の入力・答え・条件・評価方法を記載しています。同じ種類の題材でも、個数や条件を変える章では、その違いを明示しています。

T04 五つの町を道でつなぐ

町0〜4をつなぐ道路を選びます。最初は0—1—2—3—4の各辺を費用2ずつで使います。別の道路への置換で、全町への到達性と合計費用がどう変わるか調べます。

6.5 辺の変更と、連結性の検査を組み合わせる

教材の見直しと出典

初学者を想定し、どこでつまずくかを確認した記録

これは高校数学・プログラミング・アルゴリズム競プロの基礎を理解した、AHC初学者を想定する机上確認です。実際の読者に学習してもらった結果ではありません。各章を順にたどり、「前提知識と、それまでに説明したAHCの実装の知識で、設計の理由と確認問題を理解できるか」を見直しました。

読む場面つまずくと想定した点説明の直し方理解を確かめる行動
冒頭35章・12分類を先に読むと、最初に何をするか分からない。最初に一つの担当変更を紙で追う。分類や出典は後半へ移す。担当の例を見て、重さ7の仕事の移動先を答える。
1.2〜1.4配列にはできても、何を状態に保存するかの判断が見えにくい。固定入力・探索状態・再計算できる情報を分け、表現で保証できる制約も示す。担当表に負荷や仕事一覧を追加したとき、更新箇所がどう増えるか説明する。
1.5〜1.6良い変更を選ぶ話と、変更する処理の区別がまだない。決めた一手を動かしてから、ルールと良さを別々に調べる。担当Dへの移動は変更せず拒否する。
1.5の図担当表だけを保存する本文に、未学習の合計配列の更新が混ざっていた。この章の図は担当表の一か所だけを変える。合計の保存は2.3から導入する。番号1の値だけが0から2へ変わると説明する。
数学の前提高校数学を履修済みの読者に、二乗や座標の定義から説明していた。高校数学の用語・記法を前提とし、評価式の意味と実装への対応に説明を集中する。二乗和を選ぶ理由と、load配列から計算する手順を説明する。
プログラミングの前提競プロ経験のある読者に、演算子・配列・関数などの基礎を説明していた。文法の説明を省き、変更の前提条件、失敗時の状態、検査・評価・採用の分担を扱う。棄却後に何が一致すべきかと、全計算を残す理由を説明する。
1.6負荷の二乗和を最小化する理由と、実装する処理の関係が見えにくい。平均からの偏差平方和との関係を示し、負荷の集計と評価値の計算を分ける。評価式をload配列とscore関数へ対応させる。
1.7〜1.8・2.1〜2.2乱数・時計・保存・近傍を同時に導入すると追えない。固定回数と最良解の保存を先にする。移動・交換以外の変更は後へ分ける。コピー側を変え、不採用なら元を残す流れを説明する。
1.7から2.2へこの小入力では1.7で既に全員11となり、その答えからは改善を体験できない。2.2は偏りのある1.3の担当表から始める、と明記する。重さ7を移すと395から381へ改善し、重さ5を移すと405へ悪化すると比べる。
1.8の小例保存の練習なのに、まだ学んでいない候補の作り方まで必要になる。三つの担当表を示し、それを順に調べて最良の答えを残す。今の値が389でも、381の担当表を保存して出せる。
2.3〜2.4差分やundoの名前だけでは、省略する計算と戻す情報が分からない。担当表と合計を分け、変更前後の数を並べる。合計を戻し忘れた例と、Bの項を省ける理由を説明する。
2.5採用確率の式と、温度・乱数による判定の関係が見えにくい。温度の役割を説明し、指数関数の式をexpと乱数比較へ対応させる。同じ差分で温度を上げたとき、採用確率がどう変わるか説明する。
2.6・3.1〜3.3途中評価・幅・層・親・重複が一度に増える。8,6,5の一例を共有する。候補を残す話、手順をたどる話、重複の話を順にする。8を選ばない道が6+5へつながることを追う。
3.3・5.1〜5.3・7.1経路復元やBFSを知っていても、探索の中でどう使うかが見えにくい。復元用の記録の寿命と、距離表を再利用できる条件を具体化する。候補を削除しても必要な親を残す理由と、壁を変更した場合に必要な再計算を説明する。
3.4・6.1・6.4・6.6・6.2〜6.3・6.5・6.7・6.9新しい題材へ移った途端に抽象語が増える。長方形、四つの仕事、三つの町などの具体例から各章を始める。その章の一つの変更について、前後と失敗例を説明する。
4.1〜4.4・6.8・6.10確率を使う全ての問題を、未知の値の推定と混同しやすい。測定から考える例と、動き方の確率が既知の例を分ける。観測後の3/4と、3回後の到達確率1/2を別の計算として説明する。
1.2〜1.8・2.1〜2.4の図解処理の分岐や更新する配列を文章だけでは追いにくい。各章に処理の関係図を置き、2.3は担当表と負荷の三時点、2.4は評価値の項別の変化を示す。不具合を有効にし、どの情報が一致しなくなるかを図から特定する。
網羅性導入例だけを章の範囲にすると、ビーム管理・推定・評価学習が見えなくなる。実装範囲を導入例と別に保存し、三つの経路と6.11の学習による評価を明示する。ビームの重複排除、質問選択、学習と検証の分割を、対応する章から探せる。
手元で実行本文とコードの対応を確認するのに、複数のファイルを集める手間がある。内蔵入力付きの完成コードと、保存・コンパイル・実行を行うスクリプトをそれぞれコピーできるようにする。画面と同じ設定で実行し、出力を照合する。
2.4と2.6差分評価の説明が両章で重なり、次に学ぶ理由が分からない。2.4は差分式と全計算の照合、2.6はコピーと評価を分けた処理量・実時間の計測へ分担する。差分評価を有効にしたまま仕事数を増やし、コピーの仕事量だけが増える設定を説明する。
3.3・3.4重複排除と、多様性のために候補を落とす操作を混同する。完全な状態一致、ハッシュ衝突、粗い種類分けを異なる演習で扱う。衝突だけで消す不具合と、種類別上限で有望な候補を落とす例を再現する。
4.1〜4.4通信、推定、質問選択、未来の試行を一度に読むと役割が見えない。窓口→観測から更新→費用を含めた質問・停止→仮の未来の試行、の順で別の完成コードを使う。真値をソルバへ渡さず、情報利得があっても停止を選ぶ条件を説明する。
5.1〜5.3・6.11短い経路・小さい平均・小さい予測誤差を、そのまま改善と受け取る。現在の壁での再生、帰還条件、失敗件数、学習と確認の分離を図と結果に表示する。古い経路、帰路省略、実行失敗、確認用入力の混入をそれぞれ検出する。
三つの経路の境界経路図は分かれているが、本文と「次へ」は章番号順なので、局所探索の途中でビーム探索へ移ってしまう。共通準備・三つの経路・共通の検証・選択する発展へ本文を再配置し、入口・到達点・経路内の前後移動をそろえる。2.5から2.6へ、3.3から3.4へ進めることと、3.4・4.4を他経路の必修なしで読めることを確認する。
章番号と経路章の記号から所属する学習経路が分からない。共通準備を1から始め、経路1・2・3はそれぞれ2.x・3.x・4.xとする。共通部品・発展・総合練習も続けて採番する。目次・本文・図・演習・前提章・次へが同じ番号を使い、番号だけでも経路を判別できることを確認する。
6.1・6.2・6.3再構築中の未完成な答えを、そのままよい解として採用しやすい。取り外し・修復・検査を分け、各時点の担当表や被覆数を表示する。一つだけ外す場合と全て外す場合、未被覆が残る場合を説明する。
6.4・6.5面積0や費用減少を、制約を満たした証拠と受け取りやすい。接触の許否と連結性を、面積・費用から独立に判定する。接触だけで拒否される候補と、安くなった分断状態を再現する。
6.6・6.7・6.8細かい探索なら最適、短い日程や経路なら有効、と思い込みやすい。調べた範囲、先行関係、頂点衝突とすれ違いを図にする。失った谷・先行関係違反・辺のすれ違いを、設定を変えて見つける。
6.9・6.10復元一致や確率の総和1という一条件だけで、正しく改善したと判断しやすい。登録費用を含めた採否と、一操作で到達可能な位置を別に検査する。復元できても高くなる圧縮と、総和1でも間違った分布を再現する。
7.1多数の関数が並ぶと、候補生成から出力までのつながりを追えない。一手ごとの候補・有効性・評価・採否を並べ、最終解を再生して照合する。試行0回から始め、3件ずつの制約で移動が拒否される理由を説明する。
各章へ直接入る場面頂点・順列・作業時間が出てきても、何を決める問題なのか分からない。全章の冒頭で入力・答え・条件・評価・作る部品を明示し、演習からも問題設定へ戻れるようにする。題材一覧も現在の演習に合わせる。6.2だけを開き、四配達先を回って倉庫へ戻ること、配列に倉庫を含めないこと、初期経路の費用22を図から説明できる。

今後は実際の初学者にも試してもらい、説明を読み返した場所や、答えに迷った問題を記録して直します。

単独のHTMLと、コピーできるコードの作り方

説明・図・操作できる演習・入力例・コードを、このHTMLに含めています。読者が作者のフォルダを開く必要はありません。コードは閉じたまま全文をコピーでき、設定を変えるとコピーされるコードも変わります。

現在の40種類の完成コードは、必要な処理と小さい入力を全て含み、追加ファイルなしで動きます。各章の導入例を実行でき、さらに広げる実装は発展課題として区別しています。

ページ内の実験はJavaScriptで計算します。コピーするC++のコードは、手元のC++実行環境で動かします。二つの結果が対応することを検査しています。

AHCの問題を見比べ、典型を選んだ根拠

AHC001〜073の公式問題文の設定・操作・採点方法を見比べ、繰り返し現れる形を教材の十二の型へ整理しました。どの解法が何回使われたかを数えた調査ではありません。次の一覧は出典確認用で、学習の前に読む必要はありません。

過去問から何を「典型」と判断したか

2026年10月26日時点の番号付き AHC001〜AHC073の全73回について、公式問題文の問題設定・操作・目的を横断確認しました。下の分類は教材を設計するための整理です。全提出の解法調査や手法の使用率の集計ではありません。番号のない大会と開催前のAHC074は対象外です。公式の過去大会一覧

問題文から分かるのは必要な構造です。「この問題は焼きなましが最善」までは分かりません。そのため、複数時期・複数題材で現れる構造を拾い、手法の使い方は公式入門解説・個別の一次解説・既存実装と照合しました。下の典型の分け方と学習優先順位は、その確認をもとにした教材上の判断です。

範囲読み取った共通構造構造を確認できる公式問題文
001–020配置・経路・観測・被覆・接続が既に併存AHC001・AHC003・AHC006・AHC007・AHC020
021–040操作列・期間制約・推定と設計の組合せを確認AHC023・AHC025・AHC030・AHC033・AHC038・AHC040
041–060木、規則、期待値、資源の累積など評価の形が多様AHC041・AHC044・AHC047・AHC050・AHC051・AHC056・AHC058
061–073干渉、設計と運用、逐次割当、被覆の複合例も確認AHC064・AHC065・AHC066・AHC069・AHC071・AHC073
典型に採用する基準

① 別の問題にも現れる構造である。② 保存する情報・操作・検証を小さく説明できる。③ 適用条件と反例を示せる。④ 他の部品と組み合わせられる。登場回数だけで順位を付けず、検証や時間管理のように多くの実装を支える部品も共通基礎に置きます。

73回の構造分類と出典を開く(追加学習用)

一つの問題に複数のタグを付けています。全ての制約を列挙した表ではなく、教材へ取り出す構造の索引です。本文の小題材は独立した仕様なので、これらのリンクを開く必要はありません。

回・公式問題文抽出した構造分類
AHC001点を含む長方形の面積調整と非重複配置配置・形状
AHC002同じタイルを再訪しない得点付き経路順序・経路/操作列・状態遷移
AHC003未知の辺重みを経路の観測値から推定順序・経路/グラフ・連結/未知情報・逐次観測
AHC004複数の文字列を格子の中に含める配置文字列・規則/配置・形状/選択・被覆
AHC005道路を見渡す地点の選択と巡回選択・被覆/順序・経路/グラフ・連結
AHC006配達する注文の選択と受取・配送の順序選択・被覆/順序・経路/時間・資源
AHC007順に判明する辺費用に対する接続の決定グラフ・連結/未知情報・逐次観測
AHC008動く相手を観測しながら領域を仕切る配置・形状/グラフ・連結/操作列・状態遷移/未知情報・逐次観測
AHC009操作が確率的に失われる移動列順序・経路/操作列・状態遷移/確率的な結果
AHC010タイルの向きを選び大きな閉路を作る配置・形状/グラフ・連結
AHC011スライド操作で木を表す配置へ近づける配置・形状/グラフ・連結/操作列・状態遷移
AHC012直線で点集合を分割し個数を調整する配置・形状/割当・分割
AHC013機器の移動と交差しない接続配置・形状/グラフ・連結/操作列・状態遷移
AHC014既存の点・辺を使い図形を順に増やす配置・形状/操作列・状態遷移
AHC015次の配置位置を観測しながら盤面を操作配置・形状/操作列・状態遷移/確率的な結果/未知情報・逐次観測
AHC016ノイズのあるグラフ通信の設計と識別グラフ・連結/設計と運用/確率的な結果/未知情報・逐次観測
AHC017辺を工事日へ割り当て迂回費用を減らす割当・分割/グラフ・連結/時間・資源
AHC018未知の硬さの土地を掘って水路を接続グラフ・連結/未知情報・逐次観測/時間・資源
AHC019投影を満たす立体の共通部品を構成配置・形状/選択・被覆/グラフ・連結
AHC020対象を覆う設備の選択・出力調整・接続選択・被覆/配置・形状/グラフ・連結
AHC021隣接交換で大小関係を満たす配置を作る操作列・状態遷移/配置・形状
AHC022測定用の配置を設計し未知の対応を識別設計と運用/配置・形状/未知情報・逐次観測/確率的な結果
AHC023作物の期間と作業時の到達可能性を両立割当・分割/時間・資源/グラフ・連結/配置・形状
AHC024色の連結性と隣接関係を保ち領域を縮小配置・形状/グラフ・連結
AHC025集合の大小比較で未知の重さを均等分割割当・分割/未知情報・逐次観測
AHC026積み重なった物を順番に取り出す操作列操作列・状態遷移/順序・経路
AHC027再訪間隔が評価に影響する巡回経路順序・経路/時間・資源
AHC028単語の重なりと打鍵位置を考える順序文字列・規則/順序・経路/選択・被覆
AHC029逐次補充される選択肢に対する資源運用時間・資源/操作列・状態遷移/未知情報・逐次観測/確率的な結果
AHC030ノイズのある集合観測から隠れた配置を推定配置・形状/未知情報・逐次観測/確率的な結果
AHC031日ごとの区画割当と変更費用の両立配置・形状/割当・分割/時間・資源
AHC032局所的な加算操作を回数制限内で組み合わせる配置・形状/操作列・状態遷移
AHC033複数台で干渉を避け指定順に物を搬出操作列・状態遷移/時間・資源/順序・経路
AHC034積載量で費用が変わる運搬経路順序・経路/時間・資源/操作列・状態遷移
AHC035確率的な継承を見込んで隣接配置を決める配置・形状/確率的な結果/未知情報・逐次観測
AHC036信号制御用の配置と訪問操作を設計設計と運用/文字列・規則/グラフ・連結/順序・経路
AHC037中間物を共有して複数の目標を生成グラフ・連結/操作列・状態遷移
AHC038木構造の装置を設計して物を運ぶ設計と運用/グラフ・連結/配置・形状/操作列・状態遷移
AHC039周長制約のある単純な領域で対象を囲む配置・形状/選択・被覆
AHC040未知寸法の測定と長方形の梱包配置・形状/未知情報・逐次観測/確率的な結果
AHC041高さ上限のある根付き森を構成グラフ・連結
AHC042行・列の操作で残す物と除く物を分ける操作列・状態遷移/配置・形状
AHC043資金と収入を考慮して被覆・接続を拡張選択・被覆/グラフ・連結/時間・資源/操作列・状態遷移
AHC044遷移規則で訪問回数の目標を実現文字列・規則/グラフ・連結/操作列・状態遷移
AHC045問い合わせで得る情報と分割・接続の設計割当・分割/グラフ・連結/未知情報・逐次観測
AHC046移動環境も変更できる順序付き訪問順序・経路/操作列・状態遷移/配置・形状
AHC047確率的な文字列生成器の設計文字列・規則/設計と運用/確率的な結果
AHC048連続量の混合と容器・操作順の設計配置・形状/設計と運用/操作列・状態遷移/時間・資源
AHC049累積損傷を制約とする積載と運搬順序・経路/時間・資源/操作列・状態遷移
AHC050位置分布を変える配置順の期待値評価順序・経路/配置・形状/確率的な結果
AHC051確率分岐する非交差ネットワークの構成グラフ・連結/配置・形状/確率的な結果
AHC052共通操作を割り当て複数体で全域を訪問設計と運用/文字列・規則/操作列・状態遷移/選択・被覆
AHC053事前に数を設計し観測後に分割する設計と運用/割当・分割/未知情報・逐次観測
AHC054相手の逐次行動に応じ通路を変更するグラフ・連結/配置・形状/未知情報・逐次観測/確率的な結果
AHC055獲得する資源を使い次の対象を処理する順序・経路/時間・資源/操作列・状態遷移
AHC056色と内部状態の遷移規則で巡回を実現設計と運用/文字列・規則/順序・経路/操作列・状態遷移
AHC057時刻で変わる位置と結合後の運動を考えるグラフ・連結/時間・資源/割当・分割
AHC058投資順と生産の累積効果を評価する順序・経路/時間・資源/操作列・状態遷移
AHC059スタックの組合せ条件を満たす回収経路順序・経路/操作列・状態遷移
AHC060グラフ上で種類の異なる列を作り納品する文字列・規則/グラフ・連結/順序・経路/操作列・状態遷移
AHC061複数の相手の行動を観測して領域を争う配置・形状/操作列・状態遷移/未知情報・逐次観測/確率的な結果
AHC062訪問順に応じた報酬を持つ全点訪問順序・経路
AHC063形状と色の列が変化する移動操作順序・経路/文字列・規則/操作列・状態遷移
AHC064干渉を避けながら複数の列を並べ替える操作列・状態遷移/時間・資源/順序・経路
AHC065循環装置を設計し順序付き搬出に使う設計と運用/グラフ・連結/操作列・状態遷移
AHC066繰り返す操作を記録して短く表現する文字列・規則/設計と運用/順序・経路/操作列・状態遷移
AHC067状態を持つ通路を設計し最短到達を遅らせるグラフ・連結/設計と運用/配置・形状/操作列・状態遷移
AHC068領域単位の交換で目標配置へ変換する配置・形状/操作列・状態遷移/グラフ・連結
AHC069逐次到着する要求に期間付き領域を割り当てる割当・分割/配置・形状/時間・資源/未知情報・逐次観測
AHC070移動規則の設計と時系列の施設配置設計と運用/順序・経路/選択・被覆/時間・資源
AHC071支持条件と非重複制約のある被覆選択・被覆/配置・形状
AHC072積み重ねと移動を使う色別の搬送操作列・状態遷移/順序・経路
AHC073初期配置と環境を変える移動の組合せ設計と運用/配置・形状/順序・経路/操作列・状態遷移
手法の確認に使った解説と、教材へ取り出す範囲

公式入門解説(wata):合法な出力と評価から始め、貪欲・局所探索・差分計算・焼きなましへ進む段階を、共通基礎の設計に反映しました。

AHC032公式解説:途中状態を残す探索と、確定部分・残り資源を評価する考え方を確認しました。加算が可換であることや盤面を確定する順序は、この問題の性質として扱います。

AHC020参加者解説(poka):使う対象の選択、接続、出力強度を分ける一実装例を確認しました。これを「外側の設計と内側の解き直し」の例とし、その分解で常に全体最適になるとは説明しません。