ウェブエンジニア問題集
第3章

配列と文字列 — 二つのポインタ・尺取り法・累積和

5
この章の目次開く

Part 2はデータ構造編です。初回は最頻出の配列と文字列。paizaでもAtCoderのA〜C問題でも、出題の半分以上は配列の処理と言っても過言ではありません。

この章のゴールは、素朴に書くとO(N²)になる処理を、3つの道具でO(N)に落とせるようになることです。

  1. 二つのポインタ(two pointers)
  2. 尺取り法(スライディングウィンドウ)
  3. 累積和

前提: TypeScriptの配列操作おさらい

コーディングテストで多用する操作を、計算量つきで整理しておきます。

const arr = [3, 1, 4, 1, 5];
 
arr.push(9);              // 末尾に追加 O(1)
arr.pop();                // 末尾を削除 O(1)
arr.sort((a, b) => a - b);   // 昇順ソート O(N log N) ※比較関数を忘れない
arr.reduce((s, v) => s + v, 0);  // 合計 O(N)
Math.max(...arr);         // 最大値 O(N) ※要素数が多すぎるとスタックエラー注意
 
const s = "hello";
s.split('');              // 1文字ずつの配列に O(N)
s.charCodeAt(0);          // 文字コード('a'は97)
[...s].reverse().join('');   // 反転
typescript

sort() に比較関数を渡さないと辞書順[10, 2, 1][1, 10, 2])になるのは、TypeScript勢が最初に踏む罠です。数値のソートは必ず (a, b) => a - b を付けてください。

パターン1: 二つのポインタ

ソート済み配列の両端から、2つの添字を内側に詰めていくテクニックです。定番問題で見てみます。

問題: 昇順ソート済みの配列から、合計がちょうどXになる2要素のペアがあるか判定せよ。(N ≤ 10^5)

制約から2重ループは不可(第2章)。ソート済みという条件を活かします。

function hasPairSum(sorted: number[], x: number): boolean {
  let left = 0;
  let right = sorted.length - 1;
  while (left < right) {
    const sum = sorted[left] + sorted[right];
    if (sum === x) return true;
    if (sum < x) left++;    // 合計が足りない → 小さい方を進めて増やす
    else right--;           // 合計が大きすぎる → 大きい方を戻して減らす
  }
  return false;
}
typescript
学習者学習者

これ、どうして候補を見逃さないの?leftを進めた時点で、そのleftと組むはずだった相手を飛ばしちゃう気がするんだけど。

いい疑問です。sum < x でleftを進めるとき、いまのleftは最大の相手(right)と組んでも足りなかったわけです。もっと小さい相手と組んだらさらに足りません。つまり「いまのleftは誰と組んでもダメ」と確定してから捨てている——だから見逃しがないんです。両ポインタは合計N回しか動かないのでO(N)です。

パターン2: 尺取り法(スライディングウィンドウ)

**連続する区間(部分配列)**に関する問題の主役です。窓の右端を伸ばし、条件を壊したら左端を縮める——尺取り虫のような動きをします。

問題: 正の整数の配列で、合計がK以下になる連続部分配列の最大の長さを求めよ。(N ≤ 10^5)

function maxLength(nums: number[], k: number): number {
  let left = 0;
  let sum = 0;
  let best = 0;
  for (let right = 0; right < nums.length; right++) {
    sum += nums[right];               // 窓を右に広げる
    while (sum > k) {                 // 条件が壊れたら
      sum -= nums[left];              // 左から縮める
      left++;
    }
    best = Math.max(best, right - left + 1);
  }
  return best;
}
typescript

「区間の全列挙はO(N²)ある。でも各要素は窓に入るのが1回、出るのが1回だけだからO(N)」——これが尺取り法の計算量の理屈です。

窓を伸び縮みさせるイメージ

適用条件は「窓を広げると条件が悪化し、縮めると改善する」という単調性があること。要素に負の数が混ざると単調性が崩れて使えないので、その場合は次の累積和の出番です。

パターン3: 累積和

「区間の合計」を何度も聞かれる問題の決定版です。

問題: 長さNの配列に対して、Q個のクエリ「l番目からr番目までの合計は?」に答えよ。(N, Q ≤ 10^5)

毎回ループで足すとO(NQ) = 10^10でTLE。そこで前処理をします。

// prefix[i] = 先頭からi個の合計(prefix[0] = 0)
const prefix = new Array(nums.length + 1).fill(0);
for (let i = 0; i < nums.length; i++) {
  prefix[i + 1] = prefix[i] + nums[i];
}
 
// 区間 [l, r](0-indexed・両端含む)の合計はO(1)で出る
const rangeSum = (l: number, r: number) => prefix[r + 1] - prefix[l];
typescript

前処理O(N)を1回やれば、各クエリはO(1)。合計O(N + Q)です。「区間の合計=2つの累積の差」という発想は、2次元(グリッドの矩形合計)にも拡張できる汎用テクニックです。

文字列の頻出処理

文字列問題も、実体は「文字の配列」として同じパターンで解けます。加えて覚えておくと速いのが出現回数カウントです。

// 各文字の出現回数を数える(アナグラム判定などの基本部品)
function countChars(s: string): Map<string, number> {
  const count = new Map<string, number>();
  for (const ch of s) {
    count.set(ch, (count.get(ch) ?? 0) + 1);
  }
  return count;
}
typescript

「アナグラム判定」「最頻出文字」「k種類以下の文字からなる最長区間(尺取り法との合わせ技)」など、カウント+Mapは文字列問題の基本部品です。Mapの詳細は次章で扱います。

この章のパターンの見分け方

問題文のシグナル使うパターン
「ソート済み配列で、条件を満たすペア」二つのポインタ
「条件を満たす連続部分配列の最長/最短/個数」尺取り法
「区間の合計を何度も」「部分配列の和」累積和
「アナグラム」「文字の種類・回数」カウント + Map
メンターメンター

「連続する」という単語が出たら尺取り法か累積和、と反応できるようになるのが第一目標です。この「単語→パターン」の反射は第12章で総仕上げしますが、各章の最後のこの表を毎回意識しておくと定着が早いですよ。

まとめ

  • 数値ソートは (a, b) => a - b を忘れない。shiftincludes のループ内使用は隠れO(N²)(第2章)
  • 二つのポインタ: ソート済み配列の両端から詰める。「もうダメと確定した側を捨てる」からO(N)
  • 尺取り法: 連続区間の問題で、右で伸ばし左で縮める。単調性が適用条件
  • 累積和: 区間合計の繰り返しクエリを「前処理O(N) + クエリO(1)」に変える
  • 問題文の「連続する」「区間」「ペア」がパターン選択のシグナル

次章は、第2章の解法3でも登場した「探索をO(1)にする」道具——ハッシュマップとSetを本格的に使いこなします。