以下は TypeScript 実装(countPairs(N, arr))が行う各処理を、逐次的な図・表 と 数学的正当性の説明、計算量/メモリ見積もり、実装上の注意点まで丁寧に分解したものです。例として配列 A = [30, 10, 30, 20, 10, 30] を使って、各ステップでの内部状態(Map と合計値)がどう変わるかを示します。
先頭から順に走査し、各要素 v = A[i] に対して「これまでに v が何回現れたか k を取り出す」→答えに k を足す→v の出現回数を k+1 に更新する。
これを全要素で行えば条件 1 ≤ j < i かつ A_j = A_i の組数が得られる。
関数シグネチャ(参照):
function countPairs(N: number, arr: number[]): number;入力: arr = [30, 10, 30, 20, 10, 30]
初期: freq = {} total = 0
i=1: v=30 prev = freq[30] ?? 0 = 0 total += 0 -> freq[30] = 1
i=2: v=10 prev = 0 total += 0 -> freq[10] = 1
i=3: v=30 prev = 1 total += 1 -> freq[30] = 2
i=4: v=20 prev = 0 total += 0 -> freq[20] = 1
i=5: v=10 prev = 1 total += 1 -> freq[10] = 2
i=6: v=30 prev = 2 total += 2 -> freq[30] = 3
最終 total = 4
各行で prev は その時点での過去の出現回数(freq.get(v) の値)を指します。
| i (1-indexed) | A[i] | prev (= freq[A[i]] before) | total (after += prev) | freq[A[i]] (after) |
|---|---|---|---|---|
| 1 | 30 | 0 | 0 | 1 |
| 2 | 10 | 0 | 0 | 1 |
| 3 | 30 | 1 | 1 | 2 |
| 4 | 20 | 0 | 1 | 1 |
| 5 | 10 | 1 | 2 | 2 |
| 6 | 30 | 2 | 4 | 3 |
視覚(Mapの状態を箱で表す):
初期: {}
i=1 処理後: { 30:1 }
i=2 処理後: { 30:1, 10:1 }
i=3 処理後: { 30:2, 10:1 }
i=4 処理後: { 30:2, 10:1, 20:1 }
i=5 処理後: { 30:2, 10:2, 20:1 }
i=6 処理後: { 30:3, 10:2, 20:1 }
ある値 v の出現回数を最終的に c_v とする。v によって生まれる (i,j) の組の個数は、v の異なる2つの出現位置ペアの数、すなわち
です。
逐次加算法では、v の k 回目の出現(k = 1..c_v)で、その時点までに k-1 個の過去出現があるので合計に k-1 を足します。よって v について合計される値は
となり、上の式と一致します。全ての異なる値 v について和をとれば答えになります。
→ 逐次加算法は組合せ式と同値であり正当です。
- 主要ループは配列を 1 回走査:
O(N)。 - 各イテレーションで
Map.getとMap.setを 1 回ずつ:平均 O(1)(ハッシュアクセス)。 → 全体O(N)(N=100,000 まで、タイムリミット 2 秒に余裕で収まる想定)。
1 要素につき get 1 回、set 1 回、足し算 1 回。
N=100,000 のとき概算で ~200,000 Map 操作 + 100,000 加算。JS エンジン上では非常に高速。
- 追加の主要構造は
Map(異なる値数を U とする)。空間O(U)。最悪U = N(全要素が異なる)。 - 実メモリ見積もり(概算・実装依存):
- 各 Map エントリは JS の内部表現でオーバーヘッドがある(ポインタ・ハッシュ構造等)。概算で「数十バイト〜数百バイト/エントリ」。
- 保守的に 64 バイト/エントリ と仮定すると、100,000 エントリで約
64 * 100000 = 6,400,000バイト ≒ 6.4 MB。 - 実際には Node.js のオブジェクト・Map はさらにオーバーヘッドがあり得ますが、数十MB を越えることは通常なく、与えられた 1024 MiB 制限の範囲で十分余裕があります。
注意: 上のバイト数は概算です。JS の内部実装や V8 バージョンで変わります。正確なバイト数を得るにはプロファイラ(--inspect 等)でヒープスナップショットを取る必要があります。
最悪ケース(全要素が同じ)で組数は
N = 100000 のとき逐次的に計算すると:
計算(桁ごとに):
- 100000 × 99999 = 100000 × (100000 − 1) = 100000×100000 − 100000 = 10,000,000,000 − 100,000 = 9,999,900,000
- ½ をとると 4,999,950,000
よって最大でも 4,999,950,000(約 5×10⁹)で、JavaScript の Number(最大安全整数 2^53−1 ≈ 9×10^15)に十分収まります。BigInt は不要。
-
入力読み取り: 提出環境では
fs.readFileSync(0, 'utf8')でまとめて読み込む方法が速くて簡単。N=100,000 程度のサイズならメモリ的にも十分。- ただし非常に大きな入力(例えば数百万行)を扱う場合はストリーム/
readlineを使って逐次処理する方が安全です(今回は不要)。
- ただし非常に大きな入力(例えば数百万行)を扱う場合はストリーム/
-
Map vs Plain Object:
Map<number, number>は数値キーに対して明確で安全。JS オブジェクトはキーが文字列化されるため、数値→文字列の変換コストや潜在的キー衝突が気になります。- 性能差は実装により変わるが、可読性・安全性の点で
Mapを推奨。
-
関数化:単体テストや再利用のために
countPairs(N, arr)のように関数化しておくと良い(引数・戻り値の型注釈を付ける)。
- 長所:
O(N),実装が簡単,入力順に逐次処理できる(ストリーミング可能)。 - 短所:
Mapのオーバーヘッドでメモリが増える(ただしこの問題サイズでは問題にならない)。
- 手順:配列をソート → 連続する同値区間を走査して長さ
cごとにc*(c-1)/2を足す。 - 長所:追加 Map を使わない(もしくは少なくともキー領域を小さくできる)。メモリは in-place sort で
O(1)〜O(log N)追加。 - 短所:ソートの計算量
O(N log N)。N が大きいと速度で不利。標準Array.prototype.sort()は数値ソート時に比較関数が必要で遅くなる場合がある。
- データが非常に大きい場合、外部ソートまたはストリーム処理(頻度をディスク等に分割して計算)を検討。
const prev = freq.get(v) ?? 0; count += prev; freq.set(v, prev + 1);が最もシンプルで一度のgetで済む。- 二度
has→getといった冗長チェックは避ける(余計なハッシュアクセスを増やすため)。 - 入力を
Numberに変換する際は.map(Number)を使うと分かりやすいが、大きな入力だと一時配列の生成コストに注意。
- アルゴリズム:ハッシュカウントを累積加算 →
O(N)。 - 正当性:各値
vに対する累積和がC(c_v,2)と一致するため正しい。 - 時間:
N=100,000なら Node.js で 2 秒制限内に実行可能。 - メモリ:Map のオーバーヘッドはあるが、100k エントリでも数 MB〜数十MB 程度で収まり、1024 MiB の制限に余裕あり。
- 数値安全性:最大でも約
5×10^9(N=100k の場合)で JavaScriptNumberの範囲内。