両端に置いた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 の役目が見える。
| i | nums[i] | 的 -nums[i] | 探す範囲 | 出る組 |
|---|---|---|---|---|
| 0 | -4 | 4 | [-1, -1, 0, 1, 2] | なし |
| 1 | -1 | 1 | [-1, 0, 1, 2] | [-1,-1,2]、[-1,0,1] |
| 2 | -1 | 1 | [0, 1, 2] | skip |
| 3 | 0 | 0 | [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 ではない。