学校のクラスの班分けを自動化するスクリプト(GAS)

学校での「班分け」は、希望(誰と同じ班になりたいか)・班長の割り当て・男女比・班人数など複数の条件を同時に満たす必要があり、手作業だと時間もミスも増えがちです。
この記事では、Googleスプレッドシート+Google Apps Script(GAS)で、Googleフォームで集計したアンケートから班分けを自動生成する仕組みをまとめます。


1. 用途(このプログラムでできること)

1.1. 想定する利用者(重要)

このツールは、先生が実行することを想定しています。
各生徒は Googleフォーム から希望(誰と同じ班になりたいか等)を入力し、その回答がスプレッドシート(アンケート)に自動集計され、先生がスクリプトを実行して班分け結果を出力します。

1.2. 対象

1.3. 入力(アンケート)

各生徒(参加者)が以下を回答します。

加えて、班分けに利用する属性として以下があります。

1.4. 性別欄の入力について(運用オプション)

性別欄は、生徒本人が入力する必要はありません
運用としては、次のどちらでもOKです。

どちらの運用でも、最適化の際に性別バランスを考慮できます。

1.5. 出力(班分け結果)


2. スプレッドシート構成

最低限、以下のシートを用意します。


3. アンケートシートの列定義

アンケート シートは次の列構成です。

項目
A 出席番号 1
B 希望1(出席番号) 15
C 希望2(出席番号) 8
D 希望3(出席番号) 24
E 希望4(出席番号) 11
F 希望5(出席番号) 3
G 班長希望 yes / no
H 性別 男 / 女

ポイント


4. 実行手順(Google Apps Script)

4.1. スクリプトの配置

  1. Googleスプレッドシートを開く
  2. メニュー:拡張機能 → Apps Script
  3. エディタに下記のコードを貼り付け
  4. 保存(Ctrl+S)
ソースコード
/**
 * ============================================================
 * 完全版(性別バランス最優先):
 *   性別バランス > 班長希望(希望者が班数以上なら各班>=1) > 残念(0人)最小化 > 満足度
 *
 * 含まれる関数:
 * - solveGroupsOptimization(...)   最適化本体
 * - createGroups()                本番実行(アンケート→結果/分析)
 * - runBenchmark_CustomSettings() シミュレーション(自由設定)※内訳列追加済み
 * - generateMockData_Custom(...)  シミュレーション用ダミーデータ(性別:前半男/後半女)
 * - generateDummyData32()         アンケートシートへ32人分ダミー出力
 * ============================================================
 */

/**
 * 共通ロジック:班分け最適化エンジン(性別バランス最優先)
 */
function solveGroupsOptimization(students, groupCount, opts) {
  opts = opts || {};

  // ---- パラメータ ----
  const INIT_RANDOM_TRIES = opts.initRandomTries || 90;
  const SA_STEPS = opts.saSteps || 30000;
  const T0 = (opts.T0 != null) ? opts.T0 : 8.0;
  const T_END = (opts.TEnd != null) ? opts.TEnd : 0.15;
  const ELITE_KEEP = opts.eliteKeep || 3;
  const TARGET_POOL = opts.targetPool || 18;

  // 目的の重み
  const SAD_WEIGHT = opts.sadWeight || 1000000;
  const GENDER_SOFT_WEIGHT = (opts.genderSoftWeight != null) ? opts.genderSoftWeight : 100000;

  // 満足度
  const WANT_WEIGHTS = opts.wantWeights || [12, 7, 4, 2, 1];
  const MUTUAL_BONUS = (opts.mutualBonus != null) ? opts.mutualBonus : 6;
  const LEADER_FIRST_BONUS = (opts.leaderFirstBonus != null) ? opts.leaderFirstBonus : 10;
  const ZERO_MATCH_PENALTY = (opts.zeroMatchPenalty != null) ? opts.zeroMatchPenalty : 0;

  // ---- 前処理 ----
  const total = students.length;
  const baseSize = Math.floor(total / groupCount);
  const remainder = total % groupCount;

  const capacities = [];
  for (let i = 0; i < groupCount; i++) capacities.push(i < remainder ? baseSize + 1 : baseSize);

  // ID -> student(参照用)
  const sMap = {};
  students.forEach(s => sMap[String(s.id)] = s);

  function normalizeGender(g) {
    if (g == null) return null;
    const s = String(g).trim();
    if (s === "") return null;
    if (s === "男" || s.toLowerCase() === "m" || s.toLowerCase() === "male") return "M";
    if (s === "女" || s.toLowerCase() === "f" || s.toLowerCase() === "female") return "F";
    return null;
  }

  // 正規化
  students.forEach(s => {
    s.id = String(s.id);
    s.wants = (s.wants || []).map(String).map(v => v.trim()).filter(v => v !== "" && v !== s.id);
    s.leaderWants = !!s.leaderWants;
    s.gender = normalizeGender(s.gender); // "M"/"F"/null
    if (s.voteCount == null) s.voteCount = 0;
  });

  // 相互希望判定用
  const wantsSet = {};
  students.forEach(s => wantsSet[s.id] = new Set(s.wants));

  // 班長希望者
  const leaderIds = students.filter(s => s.leaderWants).map(s => s.id);
  const enforceLeaderPerGroup = leaderIds.length >= groupCount;

  // 性別人数
  const maleIds = students.filter(s => s.gender === "M").map(s => s.id);
  const unknownGenderIds = students.filter(s => !s.gender).map(s => s.id);
  const enforceGenderBalanceHard = (unknownGenderIds.length === 0);

  // 班ごとの男数ターゲット(cap比率で配分)
  const genderTargets = computeMaleTargets(capacities, maleIds.length, enforceGenderBalanceHard);
  const enforceGenderBalance = !!genderTargets;

  function computeMaleTargets(caps, maleCount, hardAllowed) {
    if (!hardAllowed) return null;

    const totalCap = caps.reduce((a, b) => a + b, 0);
    if (totalCap !== total) return null;

    const ratio = maleCount / totalCap;
    const base = caps.map(c => Math.floor(c * ratio));

    let sum = base.reduce((a, b) => a + b, 0);
    const frac = caps.map((c, i) => ({ i, f: (c * ratio) - base[i] }));
    frac.sort((a, b) => b.f - a.f);

    let remain = maleCount - sum;
    let idx = 0;
    while (remain > 0 && idx < frac.length) {
      base[frac[idx].i]++;
      remain--;
      idx++;
    }

    for (let i = 0; i < base.length; i++) if (base[i] < 0 || base[i] > caps[i]) return null;
    if (base.reduce((a, b) => a + b, 0) !== maleCount) return null;

    return base;
  }

  // ---- ユーティリティ ----
  function deepCopyGroups(groups) {
    return groups.map(g => ({ id: g.id, cap: g.cap, members: g.members.slice(), leader: g.leader }));
  }

  function buildEmptyGroups() {
    return capacities.map((c, idx) => ({ id: idx + 1, cap: c, members: [], leader: null }));
  }

  function makeIdToGroupIndex(groups) {
    const map = {};
    for (let gi = 0; gi < groups.length; gi++) {
      const mem = groups[gi].members;
      for (let k = 0; k < mem.length; k++) map[mem[k]] = gi;
    }
    return map;
  }

  function countLeadersInGroups(groups) {
    const lc = new Array(groups.length).fill(0);
    for (let gi = 0; gi < groups.length; gi++) {
      let c = 0;
      for (let k = 0; k < groups[gi].members.length; k++) {
        const id = groups[gi].members[k];
        if (sMap[id] && sMap[id].leaderWants) c++;
      }
      lc[gi] = c;
    }
    return lc;
  }

  function countMalesInGroups(groups) {
    const mc = new Array(groups.length).fill(0);
    for (let gi = 0; gi < groups.length; gi++) {
      let c = 0;
      for (let k = 0; k < groups[gi].members.length; k++) {
        const id = groups[gi].members[k];
        if (sMap[id] && sMap[id].gender === "M") c++;
      }
      mc[gi] = c;
    }
    return mc;
  }

  function genderDeviationByCounts(maleCountPerGroup) {
    if (!enforceGenderBalance) return 0;
    let dev = 0;
    for (let gi = 0; gi < maleCountPerGroup.length; gi++) dev += Math.abs(maleCountPerGroup[gi] - genderTargets[gi]);
    return dev;
  }

  function leaderViolationCountByCounts(leaderCount) {
    if (!enforceLeaderPerGroup) return 0;
    let v = 0;
    for (let gi = 0; gi < leaderCount.length; gi++) if (leaderCount[gi] <= 0) v++;
    return v;
  }

  function isGenderHardSatisfiedByCounts(maleCountPerGroup) {
    if (!enforceGenderBalance) return true;
    if (!enforceGenderBalanceHard) return true;
    for (let gi = 0; gi < maleCountPerGroup.length; gi++) {
      if (maleCountPerGroup[gi] !== genderTargets[gi]) return false;
    }
    return true;
  }

  /**
   * 初期解生成(性別最優先)
   */
  function makeInitialGroups() {
    const g = buildEmptyGroups();
    const used = new Set();

    // (A) 性別ターゲットに沿って男を先に配る
    if (enforceGenderBalance) {
      const malePool = maleIds.slice().sort(() => Math.random() - 0.5);
      let ptrM = 0;
      for (let gi = 0; gi < g.length; gi++) {
        const need = Math.min(g[gi].cap, genderTargets[gi]);
        while (g[gi].members.length < need && ptrM < malePool.length) {
          const id = malePool[ptrM++];
          if (!used.has(id)) {
            g[gi].members.push(id);
            used.add(id);
          }
        }
      }
    }

    // (B) 班長希望者が十分なら、班長希望者ゼロの班に1人ずつ補充
    if (enforceLeaderPerGroup) {
      const shuffledLeaders = leaderIds.slice().sort(() => Math.random() - 0.5);
      let li = 0;
      for (let gi = 0; gi < g.length; gi++) {
        const hasLeader = g[gi].members.some(id => sMap[id] && sMap[id].leaderWants);
        if (hasLeader) continue;

        while (li < shuffledLeaders.length && used.has(shuffledLeaders[li])) li++;
        if (li < shuffledLeaders.length && g[gi].members.length < g[gi].cap) {
          const lid = shuffledLeaders[li++];
          g[gi].members.push(lid);
          used.add(lid);
        }
      }
    }

    // (C) 残りを埋める
    const allIds = students.map(s => s.id).sort(() => Math.random() - 0.5);
    let ptr = 0;
    for (let gi = 0; gi < g.length; gi++) {
      while (g[gi].members.length < g[gi].cap) {
        const id = allIds[ptr++];
        if (!used.has(id)) {
          g[gi].members.push(id);
          used.add(id);
        }
      }
    }

    return g;
  }

  // ---- 評価(性別→班長→残念→満足度)----
  function evaluate(groups) {
    const idToGi = makeIdToGroupIndex(groups);

    const maleCount = countMalesInGroups(groups);
    const genderDev = genderDeviationByCounts(maleCount);

    const leaderCount = countLeadersInGroups(groups);
    const leaderV = leaderViolationCountByCounts(leaderCount);

    let sadCount = 0;
    let score2 = 0;

    for (let si = 0; si < students.length; si++) {
      const s = students[si];
      const myGi = idToGi[s.id];

      let matches = 0;
      let add = 0;

      for (let wi = 0; wi < s.wants.length; wi++) {
        const w = s.wants[wi];
        if (idToGi[w] === myGi) {
          matches++;
          add += (WANT_WEIGHTS[wi] != null ? WANT_WEIGHTS[wi] : 1);
          if (wantsSet[w] && wantsSet[w].has(s.id)) add += MUTUAL_BONUS;
        }
      }

      if (matches === 0) {
        sadCount++;
        add -= ZERO_MATCH_PENALTY;
      }

      if (s.leaderWants && s.wants.length > 0) {
        const first = s.wants[0];
        if (idToGi[first] === myGi) add += LEADER_FIRST_BONUS;
      }

      score2 += add;
    }

    // 辞書式を単一スコアへ(性別が最上位)
    const BIG1 = 1000000000000;
    const BIG2 = 1000000000;

    const totalScore =
      -BIG1 * genderDev
      -BIG2 * leaderV
      -SAD_WEIGHT * sadCount
      + score2
      - (enforceGenderBalanceHard ? 0 : (GENDER_SOFT_WEIGHT * genderDev));

    return { genderDev, leaderV, sadCount, score2, totalScore };
  }

  // ---- 初期解探索 ----
  let bestGroups = null;
  let bestEval = null;

  for (let t = 0; t < INIT_RANDOM_TRIES; t++) {
    const g = makeInitialGroups();
    const e = evaluate(g);
    if (!bestEval || e.totalScore > bestEval.totalScore) {
      bestGroups = deepCopyGroups(g);
      bestEval = e;
    }
  }

  // ---- SA(焼きなまし)----
  let curGroups = deepCopyGroups(bestGroups);
  let curEval = evaluate(curGroups);

  let curLeaderCount = countLeadersInGroups(curGroups);
  let curMaleCount = countMalesInGroups(curGroups);

  const elites = [];
  function pushElite(groups, ev) {
    elites.push({ groups: deepCopyGroups(groups), eval: { ...ev } });
    elites.sort((a, b) => b.eval.totalScore - a.eval.totalScore);
    while (elites.length > ELITE_KEEP) elites.pop();
  }
  pushElite(bestGroups, bestEval);

  function temperature(step) {
    const r = step / Math.max(1, SA_STEPS - 1);
    return T0 * Math.pow((T_END / T0), r);
  }

  function pickTroubleStudent(idToGi) {
    const pool = [];
    for (let i = 0; i < students.length; i++) {
      const s = students[i];
      const myGi = idToGi[s.id];
      let matches = 0;
      for (let wi = 0; wi < s.wants.length; wi++) if (idToGi[s.wants[wi]] === myGi) matches++;
      if (matches <= 1) pool.push({ id: s.id, matches });
    }
    if (pool.length === 0) return students[Math.floor(Math.random() * students.length)].id;
    pool.sort((a, b) => a.matches - b.matches);
    const cut = Math.min(TARGET_POOL, pool.length);
    return pool[Math.floor(Math.random() * cut)].id;
  }

  function swapKeepsGenderHard(aId, bId, aGi, bGi, maleCountPerGroup) {
    if (!enforceGenderBalance) return true;
    if (!enforceGenderBalanceHard) return true;

    const aIsM = (sMap[aId] && sMap[aId].gender === "M") ? 1 : 0;
    const bIsM = (sMap[bId] && sMap[bId].gender === "M") ? 1 : 0;

    const ma2 = maleCountPerGroup[aGi] - aIsM + bIsM;
    const mb2 = maleCountPerGroup[bGi] - bIsM + aIsM;

    return (ma2 === genderTargets[aGi] && mb2 === genderTargets[bGi]);
  }

  function swapKeepsLeaderHard(aId, bId, aGi, bGi, leaderCount) {
    if (!enforceLeaderPerGroup) return true;

    const aIsL = (sMap[aId] && sMap[aId].leaderWants) ? 1 : 0;
    const bIsL = (sMap[bId] && sMap[bId].leaderWants) ? 1 : 0;

    const la2 = leaderCount[aGi] - aIsL + bIsL;
    const lb2 = leaderCount[bGi] - bIsL + aIsL;

    return (la2 >= 1 && lb2 >= 1);
  }

  function proposeMove(groups) {
    const idToGi = makeIdToGroupIndex(groups);

    const a = pickTroubleStudent(idToGi);
    const aGi = idToGi[a];
    const aStudent = sMap[a];

    let targetGis = [];
    for (let wi = 0; wi < aStudent.wants.length; wi++) {
      const w = aStudent.wants[wi];
      if (idToGi[w] != null && idToGi[w] !== aGi) targetGis.push(idToGi[w]);
    }
    if (targetGis.length === 0) {
      let bGi = Math.floor(Math.random() * groups.length);
      while (bGi === aGi) bGi = Math.floor(Math.random() * groups.length);
      targetGis = [bGi];
    }

    const bGi = targetGis[Math.floor(Math.random() * targetGis.length)];
    const b = groups[bGi].members[Math.floor(Math.random() * groups[bGi].members.length)];
    return { a, b, aGi, bGi };
  }

  function applySwap(groups, move) {
    const gA = groups[move.aGi];
    const gB = groups[move.bGi];
    const ia = gA.members.indexOf(move.a);
    const ib = gB.members.indexOf(move.b);
    if (ia < 0 || ib < 0) return false;
    gA.members[ia] = move.b;
    gB.members[ib] = move.a;
    return true;
  }

  for (let step = 0; step < SA_STEPS; step++) {
    const T = temperature(step);

    const move = proposeMove(curGroups);

    // ハード制約維持
    if (!swapKeepsGenderHard(move.a, move.b, move.aGi, move.bGi, curMaleCount)) continue;
    if (!swapKeepsLeaderHard(move.a, move.b, move.aGi, move.bGi, curLeaderCount)) continue;

    const nextGroups = deepCopyGroups(curGroups);
    if (!applySwap(nextGroups, move)) continue;

    // leaderCount更新
    const nextLeaderCount = curLeaderCount.slice();
    const aIsL = (sMap[move.a] && sMap[move.a].leaderWants) ? 1 : 0;
    const bIsL = (sMap[move.b] && sMap[move.b].leaderWants) ? 1 : 0;
    nextLeaderCount[move.aGi] = nextLeaderCount[move.aGi] - aIsL + bIsL;
    nextLeaderCount[move.bGi] = nextLeaderCount[move.bGi] - bIsL + aIsL;

    // maleCount更新
    const nextMaleCount = curMaleCount.slice();
    const aIsM = (sMap[move.a] && sMap[move.a].gender === "M") ? 1 : 0;
    const bIsM = (sMap[move.b] && sMap[move.b].gender === "M") ? 1 : 0;
    nextMaleCount[move.aGi] = nextMaleCount[move.aGi] - aIsM + bIsM;
    nextMaleCount[move.bGi] = nextMaleCount[move.bGi] - bIsM + aIsM;

    if (!isGenderHardSatisfiedByCounts(nextMaleCount)) continue;

    const nextEval = evaluate(nextGroups);
    const delta = nextEval.totalScore - curEval.totalScore;

    // SA受理(悪化も確率で受理)[web:16]
    let accept = false;
    if (delta >= 0) accept = true;
    else {
      const p = Math.exp(delta / Math.max(1e-9, T));
      if (Math.random() < p) accept = true;
    }

    if (accept) {
      curGroups = nextGroups;
      curEval = nextEval;
      curLeaderCount = nextLeaderCount;
      curMaleCount = nextMaleCount;

      if (curEval.totalScore > bestEval.totalScore) {
        bestGroups = deepCopyGroups(curGroups);
        bestEval = { ...curEval };
        pushElite(bestGroups, bestEval);
      }
    }
  }

  if (elites.length > 0 && elites[0].eval.totalScore > bestEval.totalScore) {
    bestGroups = deepCopyGroups(elites[0].groups);
    bestEval = { ...elites[0].eval };
  }

  // ---- 結果整理 ----
  const finalMap = {};
  bestGroups.forEach(g => g.members.forEach(id => finalMap[id] = g.id));

  // 班長決定:班内の班長希望者から voteCount 最大
  bestGroups.forEach(g => {
    const candidates = g.members.filter(id => sMap[id] && sMap[id].leaderWants);
    if (candidates.length > 0) {
      candidates.sort((a, b) => (sMap[b].voteCount || 0) - (sMap[a].voteCount || 0));
      g.leader = candidates[0];
    } else {
      g.leader = "(要調整)";
    }
  });

  students.forEach(s => {
    const myGId = finalMap[s.id];
    let matches = 0;
    s.wants.forEach(w => { if (finalMap[w] === myGId) matches++; });
    s.matchedCount = matches;
    s.assignedGroupId = myGId;

    const group = bestGroups.find(g => String(g.id) === String(myGId));
    s.isLeader = group ? (String(group.leader) === String(s.id)) : false;
  });

  return {
    groups: bestGroups,
    students: students,
    eval: bestEval,
    enforceLeaderPerGroup,
    enforceGenderBalance,
    enforceGenderBalanceHard,
    genderTargets
  };
}


/**
 * 本番用:アンケート→結果/分析
 */
function createGroups() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const inputSheet = ss.getSheetByName('アンケート');

  let outputSheet = ss.getSheetByName('結果');
  if (!outputSheet) outputSheet = ss.insertSheet('結果');
  outputSheet.clear();

  let analysisSheet = ss.getSheetByName('分析');
  if (!analysisSheet) analysisSheet = ss.insertSheet('分析');
  analysisSheet.clear();

  const input = Browser.inputBox("班分け", "何班に分けますか?\n(性別バランス最優先)", Browser.Buttons.OK_CANCEL);
  if (input === 'cancel' || input === '') return;
  const groupCount = parseInt(input, 10);
  if (isNaN(groupCount) || groupCount < 1) return;

  const lastRow = inputSheet.getLastRow();
  if (lastRow < 2) return;

  const data = inputSheet.getRange(2, 1, lastRow - 1, 8).getValues();

  let students = [];
  let studentMap = {};

  data.forEach(row => {
    const id = row[0];
    if (id === "" || id == null) return;

    const sid = String(id).trim();
    const wants = [row[1], row[2], row[3], row[4], row[5]]
      .map(v => String(v == null ? "" : v).trim())
      .filter(v => v !== "");

    const s = {
      id: sid,
      wants: wants,
      leaderWants: String(row[6]).trim().toLowerCase() === 'yes',
      gender: String(row[7] == null ? "" : row[7]).trim(), // "男"/"女"
      voteCount: 0
    };
    students.push(s);
    studentMap[sid] = s;
  });

  // voteCount
  students.forEach(s => {
    s.wants.forEach(targetId => {
      const tid = String(targetId).trim();
      if (studentMap[tid]) studentMap[tid].voteCount++;
    });
  });

  const result = solveGroupsOptimization(students, groupCount, { initRandomTries: 90, saSteps: 30000 });
  const bestGroups = result.groups;

  // 男女集計は result.students(正規化済み)参照で0バグ回避
  const resultStudentsMap = {};
  result.students.forEach(s => resultStudentsMap[String(s.id)] = s);

  // 結果シート
  const resultRows = bestGroups.map(g => {
    const male = g.members.filter(id => resultStudentsMap[String(id)] && resultStudentsMap[String(id)].gender === "M").length;
    const female = g.members.filter(id => resultStudentsMap[String(id)] && resultStudentsMap[String(id)].gender === "F").length;
    return [g.id, g.leader, g.members.length, male, female, g.members.join(", "), ""];
  });

  outputSheet.getRange(1, 1, 1, 7).setValues([["班No", "班長", "人数", "男", "女", "メンバー", "備考"]]);
  outputSheet.getRange(2, 1, resultRows.length, 7).setValues(resultRows);

  // 分析シート
  let analysisRows = [];
  let sortedStudents = result.students.slice().sort((a, b) => Number(a.id) - Number(b.id));
  let sadCount = 0;

  sortedStudents.forEach(s => {
    let satisfaction = "";
    if (s.matchedCount === 5) satisfaction = "奇跡";
    else if (s.matchedCount === 4) satisfaction = "完璧";
    else if (s.matchedCount === 3) satisfaction = "最高";
    else if (s.matchedCount >= 1) satisfaction = "叶った";
    else { satisfaction = "残念"; sadCount++; }

    let lr = "-";
    if (s.leaderWants) lr = s.isLeader ? "当選" : "落選";
    else if (s.isLeader) lr = "不本意";

    let firstChoiceMark = "";
    if (s.leaderWants && s.wants.length > 0) {
      let first = String(s.wants[0]);
      let g = bestGroups.find(grp => String(grp.id) === String(s.assignedGroupId));
      if (g && g.members.map(String).includes(first)) firstChoiceMark = "★";
    }

    const genderJa = (s.gender === "M") ? "男" : (s.gender === "F") ? "女" : "-";

    analysisRows.push([
      s.id, genderJa, s.assignedGroupId,
      s.wants[0] || "-", s.wants[1] || "-", s.wants[2] || "-", s.wants[3] || "-", s.wants[4] || "-",
      s.matchedCount, satisfaction, s.leaderWants ? "yes" : "no", lr, firstChoiceMark
    ]);
  });

  analysisSheet.getRange(1, 1, 1, 13).setValues([["出席番号", "性別", "班No", "希望1", "希望2", "希望3", "希望4", "希望5", "一緒数", "判定", "班長希望", "結果", "班長優遇"]]);
  analysisSheet.getRange(2, 1, analysisRows.length, 13).setValues(analysisRows);

  Browser.msgBox(
    `最適化完了(性別最優先)。\n` +
    `残念な人数: ${sadCount}名\n` +
    `性別ズレ合計: ${result.eval.genderDev}\n` +
    `班長違反班数: ${result.eval.leaderV}`
  );
}


/**
 * シミュレーション実行(自由設定)
 * - レポートに「各班の男/女内訳」列を追加
 * - 出力はsetValuesでまとめ書き(高速化)[web:135]
 */
function runBenchmark_CustomSettings() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let reportSheet = ss.getSheetByName('検証レポート(自由設定)');
  if (!reportSheet) reportSheet = ss.insertSheet('検証レポート(自由設定)');
  reportSheet.clear();

  // --- 設定入力 ---
  let inputCount = Browser.inputBox("シミュレーション設定", "生徒の総人数を入力してください(例: 32)", Browser.Buttons.OK_CANCEL);
  if (inputCount === 'cancel' || inputCount === '') return;
  const totalStudents = parseInt(inputCount, 10);

  let inputGroups = Browser.inputBox("シミュレーション設定", "分ける班の数を入力してください(例: 8)", Browser.Buttons.OK_CANCEL);
  if (inputGroups === 'cancel' || inputGroups === '') return;
  const targetGroups = parseInt(inputGroups, 10);

  if (isNaN(totalStudents) || isNaN(targetGroups) || targetGroups < 1 || totalStudents < targetGroups) {
    Browser.msgBox("エラー:有効な数字を入力してください。");
    return;
  }

  // --- ヘッダー ---
  const header1 = [`設定:生徒${totalStudents}名 / ${targetGroups}班分け`, "性別バランス最優先(シミュレーション)"];
  const header2 = [
    "試行No", "データタイプ",
    "残念(0人)", "叶った(1-2人)", "最高(3人)", "完璧(4人)", "奇跡(5人)",
    "性別ズレ合計", "班長違反班数",
    "各班の男/女内訳", // ★追加
    "処理時間(ms)"
  ];
  reportSheet.getRange(1, 1, 1, header1.length).setValues([header1]);
  reportSheet.getRange(2, 1, 1, header2.length).setValues([header2]);

  // --- 20回 ---
  const rows = [];
  for (let i = 1; i <= 20; i++) {
    let dataType = "";
    let rand = Math.random();
    if (rand < 0.33) dataType = "ランダム(標準)";
    else if (rand < 0.66) dataType = "男女分断(固まり)";
    else dataType = "人気集中(難関)";

    let students = generateMockData_Custom(dataType, totalStudents);

    let startTime = new Date();
    let result = solveGroupsOptimization(students, targetGroups, { initRandomTries: 70, saSteps: 22000 });
    let endTime = new Date();

    // 生徒ID -> 生徒(性別はsolveGroupsOptimizationで "M"/"F" に正規化済み)
    const sById = {};
    result.students.forEach(s => sById[String(s.id)] = s);

    // 結果集計
    let sad = 0, ok = 0, happy = 0, perfect = 0, miracle = 0;
    result.students.forEach(s => {
      let m = s.matchedCount;
      if (m === 0) sad++;
      else if (m === 5) miracle++;
      else if (m === 4) perfect++;
      else if (m === 3) happy++;
      else ok++;
    });

    // 各班の男/女内訳(例: "2/2, 3/1, 1/3") ※joinで1セルに格納 [web:147]
    const breakdown = result.groups
      .slice()
      .sort((a, b) => a.id - b.id)
      .map(g => {
        let male = 0, female = 0;
        g.members.forEach(id => {
          const st = sById[String(id)];
          if (!st) return;
          if (st.gender === "M") male++;
          else if (st.gender === "F") female++;
        });
        return `${male}/${female}`;
      })
      .join(", ");

    rows.push([
      i, dataType,
      sad, ok, happy, perfect, miracle,
      result.eval.genderDev,
      result.eval.leaderV,
      breakdown,
      (endTime - startTime)
    ]);
  }

  reportSheet.getRange(3, 1, rows.length, header2.length).setValues(rows);
  Browser.msgBox("検証完了!");
}


/**
 * シミュレーション用ダミーデータ生成
 * - 性別:前半が男、後半が女
 * - 希望:タイプ別(ランダム/男女分断/人気集中)
 */
function generateMockData_Custom(type, totalCount) {
  let students = [];
  let ids = [];
  for (let i = 1; i <= totalCount; i++) ids.push(i);

  const half = Math.ceil(totalCount / 2);

  ids.forEach(id => {
    let wants = [];
    let pool = [];

    if (type === "男女分断(固まり)") {
      let start = (id <= half) ? 1 : half + 1;
      let end = (id <= half) ? half : totalCount;
      for (let k = start; k <= end; k++) if (k !== id) pool.push(k);
    } else if (type === "人気集中(難関)") {
      for (let k = 1; k <= totalCount; k++) {
        if (k !== id) {
          if (k <= 5) { for (let n = 0; n < 10; n++) pool.push(k); }
          else pool.push(k);
        }
      }
    } else {
      for (let k = 1; k <= totalCount; k++) if (k !== id) pool.push(k);
    }

    while (wants.length < 5 && pool.length > 0) {
      let idx = Math.floor(Math.random() * pool.length);
      let target = pool[idx];
      if (!wants.includes(String(target))) wants.push(String(target));
      pool = pool.filter(p => p !== target);
    }

    students.push({
      id: String(id),
      wants: wants,
      leaderWants: (Math.random() < 0.2),
      gender: (id <= half) ? "男" : "女",
      voteCount: 0
    });
  });

  return students;
}


/**
 * アンケートシートに32人分ダミーデータ生成
 */
function generateDummyData32() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();

  let sheet = ss.getSheetByName('アンケート');
  if (!sheet) sheet = ss.insertSheet('アンケート');
  sheet.clear();

  sheet.appendRow(["出席番号", "希望1", "希望2", "希望3", "希望4", "希望5", "班長希望", "性別"]);

  const totalCount = 32;
  const half = 16;
  const rows = [];

  for (let id = 1; id <= totalCount; id++) {
    const pool = [];
    for (let k = 1; k <= totalCount; k++) if (k !== id) pool.push(k);

    const wants = [];
    for (let i = 0; i < 5; i++) {
      const idx = Math.floor(Math.random() * pool.length);
      wants.push(pool[idx]);
      pool.splice(idx, 1);
    }

    const leaderWish = Math.random() < 0.2 ? "yes" : "no";
    const gender = (id <= half) ? "男" : "女";

    rows.push([id, wants[0], wants[1], wants[2], wants[3], wants[4], leaderWish, gender]);
  }

  sheet.getRange(2, 1, rows.length, 8).setValues(rows);
  sheet.autoResizeColumns(1, 8);
  Browser.msgBox("ダミーデータ生成完了(32人)");
}

4.2. 初回のみ:権限付与

最初の実行時に権限確認が出るので許可します(スプレッドシート読み書き、UIダイアログ等)。

4.3. フォーム運用の流れ(例)

  1. 先生がGoogleフォームを作成(出席番号、希望1〜5、班長希望 など)
  2. フォーム回答をスプレッドシートへリンク(自動集計)
  3. 必要に応じて先生が性別列(H列)を入力(名簿から転記、または貼り付け)
  4. createGroups() を実行して班分け結果を生成

4.4. ダミーデータ生成(任意)

4.5. 本番の班分け実行

班分け結果
班分け結果の分析(希望がどの程度反映されたか)

4.6. シミュレーション(品質検証)


5. アルゴリズム

このプログラムは「探索による最適化」です。
単純なルールベース(上から順に割り当て)ではなく、ランダム初期解 → 改善探索で評価を高めます。

5.1. 最適化の優先順位(重要)

今回の版は「性別バランスを最優先」にしています。
目的は複数あるので、単一の“満足度”だけを最大化するより、優先度を決めて段階的に扱います。

優先順位は次の通りです。

  1. 性別バランス(各班の男/女が均等になるように)
  2. 班長希望制約(班長希望者が班数以上いるなら、各班に最低1人は班長希望者を配置)
  3. 残念(0人)の最小化(希望した相手(希望1〜5の出席番号)が同班に1人もいない生徒を減らす)
  4. 満足度の最大化(希望順位・相互希望・班長希望者の第1希望などの加点)

5.2. 探索戦略:焼きなまし(Simulated Annealing)

改善探索には「焼きなまし(Simulated Annealing)」を使います。

5.3. 近傍(変更操作):メンバーswap

探索で試す変更は基本的に以下です。


6. システム全体フロー

flowchart TD
  A[生徒がGoogleフォームに入力<br>出席番号, 希望1-5, 班長希望] --> B[スプレッドシートに自動集計]
  B --> C{性別列は埋まってる?}
  C -- 生徒が入力 --> D[アンケート完成]
  C -- 先生が後で入力 --> D[アンケート完成]
  D --> E[GASを先生が実行]
  E --> F[データ正規化<br>出席番号を文字列化/性別M-F化/希望トリム]
  F --> G[初期解生成<br>性別ターゲット優先<br>班長希望者を各班へ]
  G --> H[焼きなまし探索<br>swapを試行]
  H --> I[最良解確定]
  I --> J[結果出力<br>結果/分析/検証レポート]

 

graph TD
    %% ノードのスタイル定義
    classDef actor fill:#f9f,stroke:#333,stroke-width:2px,color:black;
    classDef google fill:#e8f0fe,stroke:#4285f4,stroke-width:2px,color:black;
    classDef gas fill:#fce8e6,stroke:#ea4335,stroke-width:2px,color:black;
    classDef data fill:#fff,stroke:#34a853,stroke-width:2px,stroke-dasharray: 5 5,color:black;

    %% アクター
    Student((生徒)):::actor
    Teacher((先生)):::actor

    %% Google Workspace環境
    subgraph GWS [Google Workspace Environment]
        direction TB

        Form["Google フォーム<br/>・希望相手<br/>・班長立候補"]:::google

        subgraph Spreadsheet [Google スプレッドシート]
            SheetInput[("📄 アンケートシート<br/>(自動集計 + 性別追記)")]:::data
            SheetOutput[("📊 結果・分析シート<br/>(班分け案 + 満足度)")]:::data
        end

        subgraph GAS [Google Apps Script]
            Script[[⚙️ 班分けスクリプト]]:::gas
            Engine{"最適化エンジン<br/>Simulated Annealing"}:::gas
        end
    end

    %% データフローとアクション
    %% 数字リストのドットをエスケープしています (1\. text)
    Student -- "1\. スマホ/PCで回答" --> Form
    Form -.->|"2\. 自動連携"| SheetInput

    Teacher -- "3\. 性別欄を入力<br/>(名簿からコピペ)" --> SheetInput
    Teacher -- "4\. 実行メニュー選択" --> Script

    Script -- "データの読込" --> SheetInput
    Script -->|"5\. 計算処理"| Engine
    Engine -- "複数回の試行・改善" --> Engine
    Engine -->|"6\. 書き込み"| SheetOutput

    SheetOutput -- "7\. 確認・微調整" --> Teacher

 

sequenceDiagram
    autonumber
    actor Student as 生徒
    participant Form as Googleフォーム
    participant Sheet as スプレッドシート
    actor Teacher as 先生
    participant GAS as GAS(スクリプト)

    Note over Student, Form: 1. データ入力フェーズ
    Student->>Form: 希望・班長立候補を回答
    Form-->>Sheet: 自動連携・集計

    Note over Sheet, Teacher: 2. 準備フェーズ
    Teacher->>Sheet: 性別欄を追記(コピペ等)

    Note over Teacher, GAS: 3. 実行フェーズ
    Teacher->>GAS: メニュー「班分け実行」をクリック
    activate GAS
    GAS->>Sheet: アンケートデータを読み込み
    GAS->>GAS: 最適化計算 (複数回の試行)
    GAS->>Sheet: 「結果」と「分析」シートを出力
    deactivate GAS

    Note over Teacher, Sheet: 4. 確認フェーズ
    Teacher->>Sheet: 結果を確認・必要なら微調整

 

flowchart LR
    %% スタイル定義
    classDef process fill:#fff,stroke:#333,stroke-width:1px;
    classDef decision fill:#f9f,stroke:#333,stroke-width:1px;
    classDef terminator fill:#333,stroke:#333,stroke-width:1px,color:#fff;

    Start([開始]):::terminator --> Read["データ読み込み<br/>・欠損値除去<br/>・正規化"]:::process
    Read --> Init["初期解の生成<br/>ランダム配置"]:::process

    subgraph Optimization [焼きなまし法による最適化ループ]
        direction TB
        Init --> Swap["メンバーSwap試行"]:::process
        Swap --> Calc{スコア評価}:::decision

        Calc -- 改善 --> Update[採用]:::process
        Calc -- 改悪 --> Prob{確率判定}:::decision

        Prob -- 採用 --> Update
        Prob -- 却下 --> Swap

        Update --> Check{終了条件?}:::decision
        Check -- 継続 --> Swap
    end

    Check -- 完了 --> Output["結果出力<br/>・結果シート<br/>・分析シート"]:::process
    Output --> End([終了]):::terminator

 

sequenceDiagram
    autonumber
    participant Main as Main関数<br>(createGroups)
    participant Logic as 最適化エンジン<br>(solveGroupsOptimization)
    participant Sheet as スプレッドシート

    %% データ取得
    Main->>Sheet: getLastRow / getValues
    Sheet-->>Main: アンケート生データ(2次元配列)

    %% 前処理
    Main->>Main: データ整形(欠損値除外・正規化)

    %% 計算呼び出し
    Main->>Logic: 実行(生徒リスト, 班数)
    activate Logic

    Logic->>Logic: 初期解生成(ランダム配置)

    %% 最適化ループ
    loop 焼きなまし法 
        Logic->>Logic: メンバーSwap(入れ替え)
        Logic->>Logic: スコア計算(getScore)
        Logic->>Logic: 採用判定(スコア向上or確率的採用)
    end

    Logic-->>Main: 最適な班分けオブジェクト
    deactivate Logic

    %% 出力
    Main->>Sheet: insertSheet("結果") / setValues
    Main->>Sheet: insertSheet("分析") / setValues

7. FAQ

このツールは誰が使う想定ですか?
生徒はGoogleフォームに希望を入力し、先生が集計済みスプレッドシート上でスクリプトを実行して班分け結果を出力する想定です。
生徒は何を入力すればいいですか?
出席番号、希望1〜希望5(同じ班になりたい相手の出席番号)、班長希望(yes/no)を入力します。性別は生徒入力でも先生の後入力でも運用できます。
性別欄が空欄でも動きますか?
動きます。ただし性別が未入力の生徒がいると「性別バランス最優先」をハードに満たせないため、ズレ最小化(ソフト扱い)になり、班によって偏りが残る可能性があります。
性別バランスは「各班で男/女が完全一致」ですか?
人数配分(班の人数が奇数の班がある等)により完全一致が不可能な場合があります。その場合は、班サイズに応じたターゲット男数を作り、そこへのズレ(合計)を最小にする形で均等化します。
班長希望者が班数より少ない場合はどうなりますか?
全班に班長希望者を必ず配置することは不可能なので、その制約は「可能な限り」に切り替わり、班長は「班内の班長希望者」から選ぶか、いなければ「要調整」として出力します。
「希望が叶った数(満足度)」はどう計算していますか?
生徒ごとに、希望1〜5に挙げた相手が同じ班に何人いるかをカウントします(0人は「残念」扱い)。さらに希望順位や相互希望などで加点する設計です。
なぜ焼きなまし法(Simulated Annealing)を使うのですか?
希望関係・性別・班長など条件が絡むと、単純な貪欲法では局所最適にハマりやすいです。焼きなましは、探索序盤に一時的な改悪も受け入れて解の停滞を避けやすい手法です。
実行時間が長い/重いときはどう調整すればいいですか?
探索回数(例:saSteps)や初期解の試行回数(例:initRandomTries)を下げると短くできます。まずは saSteps を半分にするのが手軽です。
Googleフォームの回答列順が違う(タイムスタンプ列がある等)場合はどうすればいいですか?
スクリプトが参照している列(A〜H)の位置と、実際のフォーム集計列が一致するように調整が必要です。一般的には、フォームの「タイムスタンプ」列が先頭に入るので、読み取り列の開始位置をずらします。
プログラミングの知識が全くありませんが、利用できますか?
はい、利用可能です。 公開されているソースコードをコピー&ペーストするだけで動作するように設計されています。コードの中身を書き換える必要はなく、スプレッドシートのメニューから実行ボタンを押すだけで班分けが完了します。
生徒が出席番号を間違えて入力した場合はどうなりますか?
存在しない番号や、自分自身の番号を入力した場合、その希望は無視(スキップ)されます。 ただし、「出席番号12番のAさんが、Bさん(本当は3番)を指名しようとして誤って4番と入力した」ようなケースはプログラムでは検知できません。フォーム作成時に「出席番号表」を配布するか、プルダウン形式にして入力を制限することをお勧めします。
クラスの人数が班の数で割り切れない場合(例:35人を6班など)はどうなりますか?
自動的に人数を均等に割り振ります。 35人を6班に分ける場合、「6人の班が5つ、5人の班が1つ」のように、人数の差が最大でも1人になるように調整されます。
実行するたびに班分けの結果は変わりますか?
はい、変わります。 このツールは「ランダムな初期配置」からスタートして、パズルのように組み合わせを試行錯誤して最適解を探す仕組み(山登り法/焼きなまし法)を使っています。そのため、実行するたびに微妙に異なる結果が出ます。何度か実行して、最もバランスの良い結果を採用するのが賢い使い方です。
「この2人は絶対に一緒にしてはいけない(犬猿の仲)」という設定はできますか?
今回のコードには標準搭載されていませんが、ロジック的には可能です。 スコア計算部分(getScore関数)に、「特定のペアが同じ班になったらスコアを大幅に減点する(-10000点など)」という処理を追加することで実現できます。
一緒になりたい人の希望を「5人」から「3人」に減らしたいのですが?
そのままでも動作します。 フォーム側で質問を3つに減らし、スプレッドシートのD列・E列(希望4・5)が空欄のままでも、プログラムは自動的に「希望がある部分だけ」を読み取って処理します。コードの変更は不要です。
検証レポートにある「データタイプ」とは何ですか?
プログラムがテスト用に自動生成した「クラスの人間関係のパターン」です。 どんなクラス状況でも偏りなく班分けできるか確認するため、以下の3つのパターンでシミュレーションを行っています。 (1)ランダム(標準) 特定の偏りがなく、クラス全体がまんべんなく仲が良い状態です。【難易度:低】 (2)男女分断(固まり) 「男子は男子のみ」「女子は女子のみ」を指名するような、人間関係が二分されている状態です。【難易度:中】 (3)人気集中(難関) 特定の数名(アイドル的存在)に全員の希望が殺到する状態です。物理的に定員からあふれる生徒が出やすいため、最も調整が難しいパターンです。【難易度:高】