はじめに
GoogleSpreadsheet上で看護現場のシフトを自動作成するツールを開発しました。
背景
世の中では毎月のシフト作成が大変だと言われています。当事者ではない自分は「自動化してラクすればいいのに」と軽く考えていました。これに手を出すまでは。
はじめは興味本位から知人に協力してもらうことにしました。彼の職場でのシフト作成の際の条件を聞き出し、バックトラッキングを使って半日で実装しました。「ほら、できるじゃないか」と調子に乗った私は、業界中でも最高難度と言われる看護師シフトに挑戦したところ、見事一週間かけても完成しないという泥沼にハマりました。
悪戦苦闘と試行錯誤の末、ようやく、すべての制約をクリアする自動作成ツールを形にすることができました。
現場ごとに制約の幅は大きいため単純な汎用化は難しいものの、コアの理論をしっかり実装すればかなり複雑な状況にも応用できるという視界も開けました。
今回は、その開発の経緯とコアとなるアプローチを共有したいと思います。
今回作ったシフト自動作成ツールのスペックと制約
今回のツールでは、現場の要件想定を以下のように定義し、アルゴリズムへ組み込んでいます。
# 現場の複雑な主な制約条件
・スタッフ構成:全25人(主任2人、常勤14人、非常勤9人)
・休日ルール:常勤は月間休数10日(残りが出勤日)、非常勤は週2〜3日で固定
・勤務形態:早番、日勤、夜勤入り、夜勤明け、フリー枠(0〜3人)
・人数・ペアの縛り:早番1人以上、日勤8人以上、夜勤2人以上
・夜勤ペアの条件:必ず資格看護師が必須
・主任の条件:毎日必ず主任が早番または日勤に必須(かつ早番を少なめに調整)
・連続勤務制限:マックス4日までの連勤に制限
・夜勤の偏り防止:25人を3チーム制に分割し、チーム・個人間で夜勤回数が偏らないよう自動調整
・夜勤回数のベース:常勤4回、主任2回、非常勤1回
・鉄板のシフト連鎖:「夜勤入り → 夜勤明け → 公休」のシフト連鎖を確実に固定
# ツールの拡張性・設定パラメータ
・シフト作成月を「年月」で自由に設定可能
・早番、日勤、夜勤の最低人数は自由に設定可能(日毎設定は不可)
・常勤の月のお休み数は一括で自由に設定可能
・連続勤務日数の上限は個々のスタッフごとに自由に設定可能
・非常勤の「週X日」のXは個別に自由に設定可能
・主任の人数、非常勤の人数、チーム編成は自由に設定可能
・職員数も25人から自由に変更可能
・誰が資格看護師かも自由に変更可能
・前月からの連勤実績数は個別に自由に設定可能
・スタッフごとに、特定の日のシフト希望、公休希望、NG指定を自由に設定可能
・スタッフごとの夜勤上限回数は自由に設定可能
約2秒で3万回のシミュレーションを完遂
冒頭のGIF動画ではスプレッドシートの描画に時間を取られていますが、裏側のアルゴリズムによる純粋な計算処理(30,134回の試行)はわずか1.99秒で完了しています。
日毎の必須配置人数をクリア(縦の集計)
シート下部で各日の早番・日勤・夜勤などの合計人数を算出し、日毎の要件を満たしているかを一目で確認できるようにしています。
個人の労働条件や夜勤の偏りを証明(横の集計)
シート右側でスタッフごとの各種シフト回数や連勤数を集計し、そのスタッフの制約をきちんとクリアしていることを可視化しています。
今回の苦労したポイント
1週間の泥沼試行錯誤の正体と、開発の裏側について振り返ります。
📊 開発時間の内訳
- 80%: AI生成コードの修正・バグ取り
- 15%: 大規模開発の試行錯誤
- 5%: アルゴリズムの着想(コア理論)
💡 開発で得た3つの知見
-
AIコントロールの難しさ(時間の80%を消費)
独自のアルゴリズムだったためAIが仕様を無視しがちでした。着想自体は一瞬(5%)で正しさも実証できたものの、AIの手綱を引く作業が最大の壁となりました。 -
「完璧な汎用化」の割り切り
病院ごとのローカルルールの違いから、全現場対応の汎用化は不可能と判断。どこかで割り切る決断が、要件を固めるために必須の作業でした。 -
コアロジックの強力な拡張性
条件違反ルートをバッサリ捨てる構造により、今後現場から複雑な制約が追加されても柔軟に対応できる強い手応えを得ました。
バックトラッキングとは
そもそも今回のツールを作るきっかけとなった「前身ツール(知人のケースに適用したシンプルなシフト作成)」の段階で使ったのが、バックトラッキング(後戻り法)というアルゴリズムです。
一言で言えば、「条件に違反した瞬間にそのルートを捨てて直前の状態に戻る」というループを高速で繰り返し、条件をクリアするまで力技でゴリ押しする探索アルゴリズムの仕組みです。制約が増えても条件判定式を追加すればいいだけなので拡張性が高いです。
簡易的な実装イメージ(JavaScript / GAS)
function generateShift() {
const shiftTypes = ["日勤", "夜入", "夜明", "公休"];
const totalDays = 5;
let schedule = [];
function backtrack(day) {
if (day === totalDays) return true;
for (let shift of shiftTypes) {
// 前日が「夜入」で、今日が「夜明」以外なら即スキップ(枝刈り)
if (day > 0 && schedule[day - 1] === "夜入" && shift !== "夜明") {
continue;
}
schedule.push(shift);
// 次の日を試す。成功ならそのまま完了
if (backtrack(day + 1)) return true;
// ダメだったら取り消して(後戻り)別の候補を試す
schedule.pop();
}
return false;
}
backtrack(0);
return schedule;
}
この「ダメなルートを即座に削って総当たりする」という前身時点でのコアな発想が、今回の超難関な看護師シフトへの挑戦につながっています。
ですが、この「総当たりで探す」という部分は試行回数が膨大となって抜け出せなくなるという危険もはらんでいたため、改良が必要な部分でもありました。
「確率抽選 × NGリスト」アルゴリズム
なぜ従来の「バックトラッキング(総当たり)」では失敗するのか?
看護現場でのシフト作成など、条件が複雑な問題で単純なバックトラッキングを行うと、以下の3つの罠にハマります。
- 解の偏り: 総当たりで探索していくと選択肢が偏り、「週の前半で同じ人ばかり夜勤に配置される」などパターンが固定化します。
- 時間の爆発: 無駄な組み合わせまで愚直に総当たりするため、計算時間が膨れ上がります。
- 解なしにハマる:月のシフトを一遍に作成しようとすると組み合わせ数が爆発します。なので1週間ごとなどにわけて進めるのですが、前半で偏ったシフトを作ってしまうため、後半でどうやっても成功しない選択肢しか残っておらず、解なしの状況に陥ります。
コア思想:5つのステップ
この「探索時間の爆発」と「局所的なハマり」を回避するために改良したのが、以下のステップを基本とする独自ロジックです。
- 確率抽選: 候補の中から確率ベースでランダムに選定(偏りを防止)
- 抽選上限の設定: 同一局面での試行回数に上限を設定
- 条件即時判定: 条件違反が発生したら即座に抽選をやり直す
- 上限到達でバックステップ: 試行上限を迎えたら「一手前」に戻って再抽選
- NGリスト登録(動的枝刈り): 一手戻る際、失敗した組み合わせをNGリストに追加して2度と踏まない
/**
* シフトを階層的・再帰的に生成するコアロジック
* @param {Object} currentState - 現在のシフト配置状態
* @param {number} [depth=0] - 現在の探索の深さ(日数やコマ数)
* @returns {Object|null} 完成したシフト状態、手詰まり時は null
*/
function solveShiftLayer(currentState, depth = 0) {
// 1. すべてのシフト配置が完了していれば成功結果を返却
if (isComplete(currentState)) {
return currentState;
}
const ngSet = new Set();
let attempts = 0;
const MAX_ATTEMPTS = 100; // 1つのステップ内での最大試行回数
while (attempts < MAX_ATTEMPTS) {
// 2. NGリストを除外した上で、確率(重み付き)で候補を選択
const candidate = weightedRandomSelect(currentState, ngSet);
// 選択可能な候補が存在しない(手詰まり)場合はループを脱出
if (!candidate) {
break;
}
// 3. 候補を適用して新しい状態を作成
const nextState = applyCandidate(currentState, candidate);
// 4. シフトの制約条件(連続勤務数やNGパターンなど)をチェック
if (isValid(nextState)) {
// 次のステップへ進む(再帰呼び出し)
const result = solveShiftLayer(nextState, depth + 1);
// 成功ルートが見つかった場合はそのまま結果を上に引き継ぐ
if (result) {
return result;
}
}
// 5. 条件違反、または先へ進んだ結果失敗した場合はNGリストに登録して別候補を試す
ngSet.add(candidate.id);
attempts++;
}
// 6. 試行上限に達したか候補がなくなったため、1手前に戻る(バックトラック)
return null;
}
さらに探索時間を縮める「多重周期」と「階層化」
この基本ロジックに加え、現場レベルの超高速化(2秒完了)を実現するために2つの工夫を組み込んでいます。
- 多重周期の組み合わせ: 異なる周期で再抽選・バックステップを動かすことで、月の後半部分でドハマりする「局所的解なし状況」を回避。
- 階層的なシフト構築: 一気に全体を埋めるのではなく、シフトを何層かに分けて段階的に埋めていくことで、1回あたりの探索空間を極限まで狭める。
🔑 たったの3万回試行で完遂させるコツ
組み合わせ爆発を避ける上で最も重要なのは、「どの順番で条件を埋めていくか」というパイプラインの設計です。
順番を意識せずに取り組むと、後半で「どうやってもあの厳しい制約が満たせない」となってちゃぶ台返しが発動してしまいます。
今回の実装で言えば、非常勤の夜勤と主任の日勤帯必須枠を最優先の制約とみなして最序盤で解決するようにパイプラインを設計しています。
🤖 大規模開発とAIの「手綱引き」で得たリアルな知見
開発時間の80%を占めたのが「AI(LLM)との格闘」でした。特に看護師シフトのような超特殊なロジックを組む際、AIの特性に悩まされながら辿り着いた設計手法と、今後の課題です。
1. 仕様が長大すぎて「一発出し」は不可能
制約が複雑すぎるため、AIに「この仕様でシフト作成プログラム全体を書いて」と投げてもまともなコードは絶対に出てきません。
そこで1処理(1関数)単位で仕様を細かく切り出し、テストコードとセットでAIに書かせるスタイルに切り替えました。
- 関数単位の切り出し: 「全体のシフト作成」ではなく「主任の早番制約チェック関数だけを作る」といった粒度まで分解する。
- テストコードの同時生成: 境界値や違反パターンを含めたテストコードも同時に作らせ、期待通り動くか一関数ずつ潰していく。
/** 📁 AI開発を破綻させない「ファイル分割とレイヤー構造」
├── 🛠 000_Config.gs # グローバル設定・環境定数
├── 📥 001_LoadingFromSheet.gs # スプレからのデータ読み込み関数
│
├── 🏗 010番台: マスタオブジェクト生成群
│ ├── 011_CreateStaffMaster.gs # スタッフマスタの生成
│ ├── 012_CreateConsecutiveMaster.gs # 連勤管理マスタの生成
│ ├── 013_CreateShiftAndForbidden.gs # シフト・NG指定マスタの生成
│ ├── 014_CreateTeamNightMaster.gs # チーム別夜勤マスタの生成
│ └── 015_CreateShiftProbability.gs # 重み付き確率マスタの生成
│
├── 🧪 020番台: バリデーション関数群
│ ├── 021_ValidateStaffRequirements.gs # スタッフ個別条件の即時判定
│ └── 022_ValidateNightNursePair.gs # 夜勤資格ペア条件の判定
│
├── ⚙️ 030番台: コア処理関数群(パイプライン・バックトラック)
│ └── ... (各階層のシフト割り当て・試行ロジック)
│
├── 🧠 040番台: メイン関数
│ └── ... (ctx生成・各パイプライン実行の統括)
│
├── 📊 050番台: ログ・描画関数群
├── ExecuteDemoOutput.gs # 描画&実行ログ出力
└── OutputShiftResultToSheet.gs # スプレッドシートへの一括書き込み
*/
2. アーキテクチャの工夫:「ライン工場のカゴ(ctx)」モデル
関数単位にバラバラに開発すると「状態(ステート)の持ち回り」が破綻しがちです。
そこで、メイン関数側で保持するコンテキスト(ctx)を用意し、各関数にはそれを引き継いで操作させるアーキテクチャにしました。
// デモ時に描画する関数。経過時間を渡してシートに描画
function ExecuteDemoOutput(ctx) {
if (DEMO_MODE) {
const endTime = Date.now(); // 処理終了の時間をミリ秒で取得
// 秒単位に変換して、小数点第2位くらいまでに丸める
const elapSec = ((endTime - ctx.stats.startTime) / 1000).toFixed(2);
// 確定した値を ctx.stats に詰め込む
ctx.stats.elapsedSec = elapSec;
// 実際の描画はこの関数が行う
OutputShiftResultToSheet(ctx);
SpreadsheetApp.flush();
}
}
💡 イメージは「ライン工場のカゴ」
製品(作成中のシフトデータ)、部品(残りスタッフの枠)、工具(NGリストや各種制約パラメータ)を1つのカゴ(ctx)にドサッと入れ、作業ライン(各関数)へ順番に回していくイメージです。各関数はそのカゴの中身を見て、必要な更新だけを行って次の工程に渡します。
この構造にしたことで、AIにコードを書かせる際も「このctxを受け取って、この条件だけを更新して返す関数を作って」と指示が出せるようになり、開発が進められるようになりました。
3. 今後の課題:AIの「一般解引きずられ問題」とプロンプト
今回一番苦労したのが、「特殊な独自アルゴリズムを組みたいのに、AIがWeb上の一般的な回答(標準的なバックトラッキングなど)に引きずられる」点でした。
また、長大な仕様をAIが一回で覚えきれないため、毎回プロンプトに全仕様を貼り付け直すハメになりました。
ここはまだ自分の中でも決定的なコツを掴みきれていません。今回は行いませんでしたが、今後はループエンジニアリングや、コンテキストの渡し方を工夫して改善していきたいポイントです。
おわりに & 今後の展望
今回の開発を通して、「複雑すぎる制約は力技の全探索ではなく、人間の思考プロセスに近い確率抽選と学習(NGリスト)を組み合わせるのが適切」という確信を得られました。
同時に、看護現場のシフトのように複雑すぎて単純な条件分岐ではお手上げなケースでも、今回の違反チェックシステムと繰り返し抽選の手法であれば自動化できることも検証できました。
完璧な汎用化は諦めたものの、コアのアルゴリズムと今回構築した ctx パイプライン設計さえ手元にあれば、別の複雑なドメインにも柔軟に応用が利きそうです。
🚀 次に挑戦したいテーマ
シフト作成の泥沼をようやく抜け出せたので、次は以下のツール開発に取り組みます。また成果があればQiitaにもまとめたいと思います。
-
会議録音 → 自動文字起こし&要約ツール
- WhisperやLLMを活用し、文字起こしから「決定事項」「ToDo」を自動抽出する実用ツール。
-
Qiitaトレンド分析&記事作成サポートツール
- Qiita APIでランキングデータを解析し、伸ばしやすい記事構造やトレンド技術を可視化するツール。
📩 お仕事・開発のご相談について
複雑な制約条件があるシフト作成や業務自動化(GAS/AI活用)のご相談・お見積もり、大歓迎です!
「うちの現場でも解けるか試したい」といった軽いご相談から対応可能ですので、お気軽にご連絡ください。




