Two Pointers

両端に置いた2つの添字を、中央へ寄せていく。1周なので O(n)、しかも追加のメモリを持たない O(1)。

Arrays & Hashing が時間をメモリで買う型だったのに対して、こちらはメモリを払わずに済ませる型。

9. Valid Palindrome

https://neetcode.io/problems/is-palindrome/question?list=blind75

英数字だけを見て、大文字小文字を無視したとき回文(前から読んでも後ろから読んでも同じ)かを判定する。

素直に書くと、英数字だけを小文字で抜き出した文字列を作り、反転したものと比べる形になる。

const cleaned = s.toLowerCase().replace(/[^a-z0-9]/g, '');
return cleaned === [...cleaned].reverse().join('');

読みやすいが、同じ長さの文字列を2本作るので O(n) のメモリを使う。回文は「先頭と末尾が同じ」の繰り返しなので、作らずに両端から突き合わせれば済む。

// 英数字1文字か判定する。
// 末尾の i(ignore case)が大文字小文字の差を無視させる。/[a-zA-Z0-9]/ と同じ
const ALPHA_NUM = /[a-z0-9]/i;

class Solution {
    // 例: s = "Was it a car or a cat I saw?"  ->  true
    isPalindrome(s: string): boolean {
        let l = 0;
        let r = s.length - 1; // 添字は 0 始まりなので -1

        while (l < r) {
            // 英数字でなければ、その側だけ1つ内側へ寄せてやり直す
            if (!ALPHA_NUM.test(s[l])) { l++; continue; }
            if (!ALPHA_NUM.test(s[r])) { r--; continue; }

            // ここに来た時点で両端とも英数字。比べられる
            if (s[l].toLowerCase() !== s[r].toLowerCase()) return false;

            l++;
            r--;
        }

        // すれ違うまで一度もぶつからなかった = 回文
        return true;
    }
}

応用: 「作り直してから比べる」と書きたくなったら、両端から寄せながらその場で比べられないかを疑う。回文・2数の和(ソート済み)・容器の水量など、対称性か順序がある並びはたいていこの形に落ちる。

10. 3Sum

https://neetcode.io/problems/three-integer-sum/question?list=blind75

配列から、足して 0 になる3数の組をすべて返す(同じ組は1回だけ)。

3数を同時に考えると詰む。1つを for で固定すれば、残りは「ソート済みの並びから和が -nums[i] になる2数」、つまりソート済み版の 2Sum に落ちる。添字は3本あるが、i は two pointers の片割れではない。動くのは l と r だけで、i は1つずつ右へずれる柱。

sort 済み:  [ -4,  -1,  -1,   0,   1,   2 ]
               ↑    └────────────────┘
               i         l →     ← r
             固定     この区間だけが two pointers
class Solution {
    // 例: nums = [-1,0,1,2,-1,-4]  ->  [[-1,-1,2],[-1,0,1]]
    threeSum(nums: number[]): number[][] {
        nums.sort((a, b) => a - b);
        const res: number[][] = [];

        // i の後ろに l と r の2席が要るので、i は「後ろから3番目」まで。
        // -2 が無くても答えは同じ。l と r が重なって空回りする回を作らないだけ
        for (let i = 0; i < nums.length - 2; i++) {
            // 1番目が正なら残りも全部正。以降ずっとダメなので break
            if (nums[i] > 0) break;

            // 同じ値を1番目に使う回は「先頭の1回」でいい。2回目以降は
            // 「同じ的 -nums[i] を、前回の一部でしかない範囲で」探し直すだけ(後述)。
            // i > 0 は nums[-1](undefined)と比べないため。continue なのは次の値は試すから
            if (i > 0 && nums[i] === nums[i - 1]) continue;

            // ここから先は「和が -nums[i] になる2数を探す」= ソート済み版の 2Sum
            let l = i + 1;
            let r = nums.length - 1;

            while (l < r) {
                const sum = nums[i] + nums[l] + nums[r];

                if (sum > 0) r--;      // 大きすぎる -> 右端をひとつ小さい値へ
                else if (sum < 0) l++; // 小さすぎる -> 左端をひとつ大きい値へ
                else {
                    res.push([nums[i], nums[l], nums[r]]);
                    l++;
                    // 2番目が同じ値なら三つ組も同じになるので飛ばす
                    while (l < r && nums[l] === nums[l - 1]) l++;
                }
            }
        }

        return res;
    }
}

計算量は O(n²)。ソートの O(n log n) は飲み込まれる。

[-1,0,1,2,-1,-4] を sort した [-4, -1, -1, 0, 1, 2] を追うと、2つの if の役目が見える。

inums[i]的 -nums[i]探す範囲出る組
0-44[-1, -1, 0, 1, 2]なし
1-11[-1, 0, 1, 2][-1,-1,2]、[-1,0,1]
2-11[0, 1, 2]skip
300[1, 2]なし
  • - 2: i は 3 で打ち止め。i = 4 だと l = r = 5 で while が1回も回らない。空回りする回を作らないだけで、答えは変わらない
  • skip: i = 2 は i = 1 と的が同じで、範囲はその一部。同じ的を狭い場所で探し直すだけなので新しい組は出ない(消すと [-1,0,1] が2回出る)。ただし**「範囲が狭いから」ではない**。値が変われば的も変わるので、狭い範囲からでも新しい組は出る。潰しているのは「同じ的を2回狙う回」だけ

よくある間違い: 両端を外側に置いて真ん中を探す

l と r を最初から両端に置き、-(nums[l] + nums[r]) が間にあるかを探す形は動かない。two pointers が成立する条件は動かす向きが一意に決まることで、この形は見つからなかったときに l を進めるか r を戻すかの根拠がない。しかも両端が寄るだけでは n 通りのペアしか訪れず、全ペアを見られない。外側は総当たり、内側だけが two pointers。

応用: N 個の和を問われたら、1つ固定して N-1 に落とす。3Sum は 2Sum に、4Sum は 3Sum に帰着する。重複除去は毎回「ソートして、隣と同じ値なら飛ばす」で済む。

11. Container With Most Water

https://neetcode.io/problems/max-water-container/question?list=blind75

棒の高さの配列から2本選び、その間に溜められる水の量 min(2本の高さ) × 幅 の最大値を返す。

9 と同じ形。両端から始めて、低いほうの棒を内側へ動かす。

class Solution {
    // 例: heights = [1, 7, 2, 5, 4, 7, 3, 6]  ->  36
    maxArea(heights: number[]): number {
        let l = 0;
        let r = heights.length - 1;
        let max = 0;

        while (l < r) {
            const height = Math.min(heights[l], heights[r]); // 低いほうから水が溢れる
            max = Math.max(max, height * (r - l));

            // 低いほうを捨てる。同じ高さならどちらでもいい
            if (heights[l] <= heights[r]) l++;
            else r--;
        }

        return max;
    }
}

1周で終わるので時間 O(n)、追加のメモリを持たないので空間 O(1)。

なぜ「低いほう」を動かすのか

水量の上限を決めているのは低いほうの棒。そして l と r は寄る一方なので、幅はこの先ずっと縮む。

つまり低い側の棒を残したままでは、高さは今より上がらず(上限は据え置き)、幅は必ず減る。その棒を含む組み合わせは、もう今の値を超えられない。だから捨ててよい。

逆に高いほうを動かすと、上限を決めている低い側が残ったまま幅だけ減るので、必ず損をする。

heights = [1, 7, 2, 5, 4, 7, 3, 6]
           l                    r     min(1, 6) × 7 = 7   -> 低いのは左。l++
              l                 r     min(7, 6) × 6 = 36  -> 低いのは右。r--
              l              r        min(7, 3) × 5 = 15  -> r--
              l           r           min(7, 7) × 4 = 28  -> 同値。l++
                 l        r           min(2, 7) × 3 = 6   -> l++
                    l     r           min(5, 7) × 2 = 10  -> l++
                       l  r           min(4, 7) × 1 = 4   -> l++ ですれ違い終了

-> 36

捨てる根拠が一意に決まる(低いほうは常に捨てられる)から two pointers が成立する。

よくある間違い: r を巻き戻す

l を for で回し、内側の while で r を右端まで戻す書き方は、10 で挙げたのと同じ罠。

for (let l = 0; l < r; l++) {
    while (l < r) { /* ... */ r--; }
    r = heights.length - 1; // ここで巻き戻している = 全ペアを試している
}

答えは合うが、これはすべてのペアを試す二重ループそのもので O(n²)。制約は n <= 100,000 なので、最悪 100,000² / 2 = 50 億回。ジャッジの制限時間内に終わらず Time Limit Exceeded(実行時間の上限を超えたので、ジャッジ側がプロセスを強制終了した合図)になる。

ポインタは戻さない。戻した時点で、それは two pointers ではなく総当たり。

応用: two pointers が使えるかは「片方を捨てる根拠が毎回一意に決まるか」で判断する。10 は和の大小、11 は高さの低さがその根拠だった。根拠が作れなければ two pointers ではない。