Algorithms

計算ピラミッドの問題を自動生成する:解が一意になる条件と、難易度の決め方

下段に数を並べ、隣り合う2つを足した数をその上に書く。それを繰り返して頂点までたどり着くパズルを 計算ピラミッド(ピラミッド計算、ピラミッド暗算)と呼びます。小学校のプリントや脳トレアプリでよく見る形式です。

プリントに刷る分には手で作れます。面倒なのはアプリで無限に出すほうで、下段をランダムに決めて適当に穴を開けると、答えが1つに決まらない問題が普通に混ざります。そのあたりの詰まったところを書きます。

ルール

下段が [3, 1, 4, 2] なら、順に足し上げていきます。

  • 2段目: 3+1=41+4=54+2=6
  • 3段目: 4+5=95+6=11
  • 頂点: 9+11=20

積み上がった形はこうです。

20 9 11 4 5 6 3 1 4 2

問題として出すときは、この一部を空欄にして残りから復元させます。

解き方

空欄の位置によって、やることが変わります。

下が2つとも埋まっているなら、足すだけです。これが一番易しい形。

上と、下の片方が埋まっているなら引き算になります。上 = 左 + 右 なので 右 = 上 − 左。暗算パズルとしての難易度が上がるのはここで、足し算しか出てこない問題と引き算が混ざる問題では体感がまるで違います。

周りが埋まっていない場合は、そのセル単独では決まりません。ピラミッド全体を連立方程式として見る必要があります。

全体は下段だけで決まる

ピラミッドの中身は、下段の値だけで決まります。上の段は下から計算されるので当たり前なのですが、これを式にしておくと後が楽になります。

下段を b[0], b[1], ..., b[n-1] とすると、下から r 段目、左から i 番目のセルはこう書けます。

cell(r, i) = Σ[k=0..r] C(r, k) · b[i + k]

C(r, k) は二項係数です。パスカルの三角形がそのまま出てくるわけで、たとえば下から3段目のセルは 1·b[i] + 2·b[i+1] + 1·b[i+2] になります。

どのセルも、下段 n 個を未知数とする線形式になっています。問題を作る作業は、この n 個を復元できるだけのヒントを選ぶ作業と同じです。

素直な作り方と、その落とし穴

まず素直な生成から。

type Pyramid = number[][]; // pyramid[0] が下段 function build(bottom: number[]): Pyramid { const rows: Pyramid = [bottom]; while (rows[rows.length - 1].length > 1) { const below = rows[rows.length - 1]; rows.push(below.slice(0, -1).map((v, i) => v + below[i + 1])); } return rows; }

下段をランダムに決めて積み上げ、いくつかのセルを空欄にする。これで良さそうに見えますが、空欄の選び方次第で答えが複数できます。ヒントが足りず、条件を満たす下段が2通り以上ある状態です。

これはバグとして表に出ます。ユーザーが自分の解いた答えを入れたのに不正解と言われる、という形で。実際に出るまで気づきにくいのがたちが悪いところです。

一意性を判定する

さきほどの線形式が効いてきます。開示するセルそれぞれについて、二項係数の並んだ長さ n の係数ベクトルを作り、それを縦に積んだ行列のランクを見る。ランクが n なら下段が一意に決まり、したがって問題全体の答えも1つになります。

function binomial(n: number, k: number): number { let result = 1; for (let i = 0; i < k; i++) result = (result * (n - i)) / (i + 1); return Math.round(result); } /** 下から row 段目、index 番目のセルを、下段 width 個で表したときの係数。 */ function coefficients(row: number, index: number, width: number): number[] { const v = new Array<number>(width).fill(0); for (let k = 0; k <= row; k++) v[index + k] = binomial(row, k); return v; } /** 行列のランクをガウスの消去法で求める。 */ function rank(matrix: number[][]): number { const m = matrix.map((row) => [...row]); const cols = m[0]?.length ?? 0; let r = 0; for (let c = 0; c < cols && r < m.length; c++) { let pivot = -1; for (let i = r; i < m.length; i++) { if (Math.abs(m[i][c]) > 1e-9) { pivot = i; break; } } if (pivot === -1) continue; [m[r], m[pivot]] = [m[pivot], m[r]]; for (let i = 0; i < m.length; i++) { if (i === r || Math.abs(m[i][c]) < 1e-9) continue; const factor = m[i][c] / m[r][c]; for (let j = c; j < cols; j++) m[i][j] -= factor * m[r][j]; } r++; } return r; } /** 開示セル (row, index) の集合から、解が一意に決まるかを判定する。 */ function hasUniqueSolution(revealed: Array<[number, number]>, width: number): boolean { if (revealed.length < width) return false; const matrix = revealed.map(([row, index]) => coefficients(row, index, width)); return rank(matrix) === width; }

二項係数は段数が増えると急に大きくなるので、浮動小数の消去法は 8 段あたりから怪しくなります。実際のアプリで扱う 3〜6 段なら問題ありませんが、大きくする予定があるなら有理数か整数のまま消去する実装(Bareiss 法など)に替えてください。

applyBlanks は空欄を null にするだけの小さな関数です。

type Puzzle = Array<Array<number | null>>; function applyBlanks(pyramid: Pyramid, blanks: Array<[number, number]>): Puzzle { const puzzle: Puzzle = pyramid.map((row) => [...row]); for (const [r, i] of blanks) puzzle[r][i] = null; return puzzle; }

あとは生成して弾くだけです。

function generate(width: number, blanks: number, maxBottom = 9): Puzzle { for (let attempt = 0; attempt < 200; attempt++) { const bottom = Array.from( { length: width }, () => 1 + Math.floor(Math.random() * maxBottom), ); const pyramid = build(bottom); const cells: Array<[number, number]> = []; pyramid.forEach((row, r) => row.forEach((_, i) => cells.push([r, i]))); // シャッフルして先頭 blanks 個を空欄に for (let i = cells.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [cells[i], cells[j]] = [cells[j], cells[i]]; } const revealed = cells.slice(blanks); if (hasUniqueSolution(revealed, width)) { return applyBlanks(pyramid, cells.slice(0, blanks)); } } throw new Error(`${width} 段で ${blanks} 箇所の空欄は成立しません`); }

失敗し続けるということは、その空欄数が原理的に無理だということです。試行回数で握りつぶさず、例外にして設定側の誤りとして気付けるようにしておくほうが安全です。

「一意」と「暗算で解ける」は別

ランクが n でも、暗算で解ける保証はありません。下段が全部空欄で上だけ開示されている問題は、答えは1つに決まりますが、解くには連立方程式を手で処理することになります。パズルとしては成立していません。

もう一段きつい条件を足します。空欄を1つずつ、足し算か引き算だけで順番に確定させていけること。

/** 埋まっているセルだけを使って、一箇所ずつ確定させられるか。 */ function isSolvableStepwise(known: boolean[][]): boolean { const state = known.map((row) => [...row]); let progress = true; while (progress) { progress = false; for (let r = 1; r < state.length; r++) { for (let i = 0; i < state[r].length; i++) { const above = state[r][i]; const left = state[r - 1][i]; const right = state[r - 1][i + 1]; const filled = [above, left, right].filter(Boolean).length; // 3つのうち2つ分かっていれば、残り1つは足し算か引き算で出る if (filled !== 2) continue; if (!above) { state[r][i] = true; progress = true; } else if (!left) { state[r - 1][i] = true; progress = true; } else if (!right) { state[r - 1][i + 1] = true; progress = true; } } } } return state.every((row) => row.every(Boolean)); }

hasUniqueSolution と両方通ったものだけを採用すれば、答えが1つに決まり、しかも順に埋めていける問題だけが残ります。上級者向けに連立を要求するモードを作りたいなら、こちらだけ外せばいい。

難易度を数値で決める

段数を難易度にしているアプリが多いのですが、これはあまり当てになりません。4段でも引き算だらけの問題は、5段の足し算だけの問題よりずっと重い。

一番はっきり効くのは引き算の回数でした。上と下の片方だけが埋まっている状況が何回出てくるかを数えるだけです。足し算しか出てこない問題とは体感が別物になります。

次が繰り上がりの回数。7 + 83 + 4 より重いので、生成後に全ペアを走査して数えておきます。

もう少し凝るなら、同時に覚えておかないといけない数の個数です。確定していく順序をシミュレートして、「もう計算したが、まだ使い道が残っている値」の最大数を取る。ワーキングメモリへの負荷に一番近い数字が出ます。ただし実装は面倒です。

下段の値の範囲も一応効きますが、1〜9 を 1〜20 に広げても難しくなるというより手間が増えるだけなので、あまり使っていません。

アプリ

この生成ロジックを使ったものを ピラミッド暗算 として公開しています。iOS 版とブラウザ版があります。難易度の段は段数ではなく、上に書いた「同時に覚えておく数の個数」で切っています。