「値」と「次はどこか」を持つノードが数珠つなぎになった入れ物。配列との違いはメモリ上で連続しているかどうかの一点に尽きる。
配列 [0][1][2][3] 隣り合って置かれている
リスト [0|→][1|→][2|→][3|null] バラバラの場所にあり、矢印だけがつなぐ
配列が「3番目」に一発で行けるのは、先頭の番地 + 3 × 1個分のサイズを計算すれば番地が出るから。リストのノードはバラバラの場所にいるのでこの計算が成り立たず、先頭から矢印を辿るしかない(取得 O(n))。
代わりに割り込みが速い。1 と 2 のあいだに 9 を入れるとき、リストは 1の矢印を9へ、9の矢印を2へ、2本書き換えて終わり(O(1))。配列は席が詰まっているので 2 以降を全員ずらす(O(n))。
だから問題でやることも、ほぼ矢印の付け替えに集約される。定石は3つ。
- 付け替える前に、失う参照を別の変数に退避する(18 でやる)
- 先頭が変わる問題は、ダミーノードを1つ前に置くと「先頭だけ特別扱い」が消える
- 中間や循環を調べたいときは、1歩ずつ進むポインタと2歩ずつ進むポインタを同時に走らせる
18. Reverse Linked List
https://neetcode.io/problems/reverse-a-linked-list/question?list=blind75
連結リストを反転し、新しい先頭を返す。
連結リストは矢印(next)でつながった数珠で、反転とは矢印を全部逆向きに付け替えること。値は動かさない。先頭から1本ずつ「今のノードの矢印を、ひとつ前のノードへ」向け直していけばいい。
ただし、ここに罠がある。0 → 1 → 2 → 3 のとき、1 を指している矢印は 0.next ただ1本だけ。0.next を書き換えた瞬間、1 から先へ行く手段がどこにも無くなる(head は 0 を指しているだけ)。だから切る前に次のノードを手でつかんでおく。それが next 変数の役目。
class Solution {
// 例: [0, 1, 2, 3] -> [3, 2, 1, 0]
reverseList(head: ListNode | null): ListNode | null {
let prev: ListNode | null = null; // 反転済みの先頭
let curr = head; // 今つなぎ替えるノード
while (curr) {
// ① 退避: 先に「この先」をつかむ。次行で curr.next は消える
const next = curr.next;
// ② 付け替え: 矢印を後ろ向きにする
curr.next = prev;
// ③ 前進: つかんでおいた next のおかげで、切ったあとも先へ進める
prev = curr;
curr = next;
}
return prev; // curr が null になったとき、prev は元の末尾 = 新しい先頭
}
}
1周なめるだけなので O(n)、変数3つだけなので空間 O(1)。
- 覚えるのは不変条件1つ。「
prevまでは反転済み、currから先は手つかず」。これを保てば4行の順序は自然に決まる
応用: ポインタを付け替える系は、上書きは「消す」のと同じ。書き換える前に「その矢印が唯一の入口になっていないか」を見て、なっているなら先に退避する。
19. Merge Two Sorted Linked Lists
https://neetcode.io/problems/merge-two-sorted-linked-lists/question?list=blind75
昇順に並んだ2本のリストを、1本の昇順のリストにまとめる。
両方ソート済みなので、次に来るのは必ず「2本の先頭のうち小さいほう」。取ったらそのリストだけ1つ進め、また先頭同士を比べる——それだけ。
ただし答えの先頭がどちらから来るかは1回目の比較まで決まらない。そこでダミーノードを1つ手前に置くと「先頭だけ特別扱い」が消え、最後に dummy.next を返せばよくなる。
class Solution {
// 例: list1 = [1, 2, 4], list2 = [1, 3, 5] -> [1, 1, 2, 3, 4, 5]
mergeTwoLists(list1: ListNode | null, list2: ListNode | null): ListNode | null {
const dummy = new ListNode(0); // 答えの1つ手前に置く見張り役
let tail = dummy; // できあがった部分の末尾
// 両方に残りがあるあいだは、小さいほうを末尾につなぐ
while (list1 && list2) {
if (list1.val <= list2.val) {
// 3行とも必要で、この順番でないと壊れる
tail.next = list1; // ① つなぐ: 今の末尾の後ろに list1 を置く(つながりを変えるのはこの行だけ)
tail = list1; // ② 進む: 末尾の印を、いまつないだ list1 へ移す(ないと次も同じ場所を上書き)
list1 = list1.next; // ③ 次へ: list1 を次の候補へ(先にやると tail が1つ先を指してしまう)
// 例: dummy → 1 で list1 = 3 のとき
// ① dummy → 1 → 3 ② tail = 3 ③ list1 = 3 の次
} else {
tail.next = list2; // 同じく ① つなぐ
tail = list2; // ② 進む
list2 = list2.next; // ③ 次へ
}
}
// 片方が尽きたら、残りは丸ごとつなぐだけでいい(そこはもう昇順に並んでいる)
tail.next = list1 ?? list2;
return dummy.next; // dummy の次が本当の先頭
}
}
各ノードを1回ずつ見るので O(n + m)、変数を数個使うだけなので空間 O(1)。
- 空リストの分岐は要らない。
whileに入らずtail.next = list1 ?? list2がそのまま拾う
応用: 先頭が特別になる問題はダミーノードで潰す。「最初の1回だけ違う」形の分岐が出てきたら、1つ手前にダミーを置けないか疑う。
20. Linked List Cycle Detection
https://neetcode.io/problems/linked-list-cycle-detection/question?list=blind75
連結リストに循環(どこかのノードの next が、すでに通ったノードへ戻っている)があるか判定する。
class Solution {
// 例: [1, 2, 3, 4] で末尾が 2 に戻る -> true
hasCycle(head: ListNode | null): boolean {
let slow = head; // 1歩ずつ
let fast = head; // 2歩ずつ
// fast が2歩進めるときだけ回す。fast.next を確かめないと fast.next.next で落ちる
while (fast && fast.next) {
slow = slow!.next;
fast = fast.next.next;
// 値ではなく「同じノードか」を比べる。値は別ノードでも重なりうる
if (slow === fast) return true;
}
// fast が末尾(null)に着いた = 行き止まりがある = 循環なし
return false;
}
}
なぜ while (fast && fast.next) なのか
fast.next.next を読むには、fast と fast.next の両方が null でない必要がある。while (fast) だけだと、奇数個のリストで落ちる。
[1] fast.next が null -> null.next を読んで TypeError
なぜ slow! の ! が要るのか
strict では必須。消すと 'slow' is possibly 'null'. になる。「fast が無事なら slow も無事」という2変数の関係を TypeScript は推論しないので、! で伝える。
なぜ必ず出会うのか
循環があると、2人とも輪に入ったきり出られない。輪の上では fast が slow を後ろから追いかける形になる(トラックの周回遅れと同じ)。
そこで「fast から矢印の向きに何歩で slow に着くか」を距離と呼ぶ。1手ごとに
- slow が1歩逃げる → 距離 +1
- fast が2歩詰める → 距離 −2
差し引き −1。距離は毎回ちょうど1だけ縮む。
1 → 2 → 3 → 4
↑ │
└───────┘
slow fast 距離
開始 1 1 - (slow はまだ輪の外)
1手 2 3 2 (3 → 4 → 2)
2手 3 2 1 (2 → 3)
3手 4 4 0 -> 同じノード。true
1ずつ縮むので、距離は 2 → 1 → 0 と全部の数を順に通り、0 で必ず止まる。
効いているのは「2歩と1歩」ではなく速さの差が1であること。差が2(3歩と1歩)だと 0 をまたぐことがあり、輪の長さによっては永遠に重ならない(長さ4・距離1なら 1 → 3 → 1 → 3 …)。
応用: 「戻ってくるか」「真ん中はどこか」のように全体の長さが分からないまま構造を調べたいときは、速さの違う2つのポインタを走らせる。速度差が情報になるので、記録用のメモリが要らない。
21. Reorder Linked List
https://neetcode.io/problems/reorder-linked-list/question?list=blind75
[0, 1, 2, 3, 4] の並びを [0, 4, 1, 3, 2] に組み替える。値は動かさず、ノードのつなぎ替えだけで行う。
何をやらされているのか
「先頭 → 末尾 → 2番目 → 後ろから2番目 → …」と読むから複雑に見える。半分に折って、前半と後半を交互に差し込むと見ると1行で言える。
前半 0 → 1 → 2
後半 3 → 4 逆から使いたい -> 4 → 3 にひっくり返す
0 1 2
\ \
4 3 交互に差し込む
-> 0 → 4 → 1 → 3 → 2
問題は「末尾から辿る」を単方向リストができない点にある。前のノードへ戻る矢印がないからだ。後半を反転してしまえば「後ろから」が「前から」に変わる——これで解決する。
やることは3つ。すべて既出の問題で、新しい道具は1つも要らない。
| 手順 | 使う道具 |
|---|---|
| ① 中央を見つけて2本に切る | 20 の fast / slow |
| ② 後半を反転する | 18 の矢印の付け替え |
| ③ 交互につなぐ | 19 のマージ(比較の代わりに交互) |
class Solution {
// 例: [1, 2, 3, 4, 5] -> [1, 5, 2, 4, 3]
reorderList(head: ListNode | null): void {
if (!head) return;
// ① 中央を探す。fast は slow の倍進むので、fast が末尾に着くと slow は真ん中にいる。
// fast を1つ先から始めると、前半と後半が同数に割れる(奇数なら前半が1つ多い)
let slow = head;
let fast = head.next;
while (fast && fast.next) {
slow = slow.next!;
fast = fast.next.next;
}
// 開始 slow=1 fast=2
// 1周目 slow=2 fast=4
// 2周目 slow=3 fast=null(4 の2つ先は無い)-> ここで終了
// slow は 3 で止まる。1,2,3 | 4,5 と分かれるので、3 がちょうど前半の末尾
// ② 後半を切り離して反転する(18 と同じ3行)。
// slow は前半の末尾なので、その1つ先 slow.next がそのまま後半の先頭になる
let second = slow.next; // 3 の次 = 4。後半の先頭をここでつかむ
// つかんでから切る。逆にすると後半への入口が消えて辿れなくなる
slow.next = null; // 前半 1 → 2 → 3 | 後半 4 → 5 の2本になる
let prev: ListNode | null = null;
while (second) {
const next = second.next;
second.next = prev;
prev = second;
second = next;
}
// 反転が終わると prev = 5 → 4。前半は 1 → 2 → 3 のまま
// ③ 前半と、反転した後半を交互につなぐ。
// ② で切ったのは slow(=3) の矢印だが、slow は head から続く鎖の途中のノード。
// なので head から辿れるのも 1 → 2 → 3 までになっている(4, 5 へは行けない)
let first: ListNode | null = head;
second = prev;
while (first && second) {
// 上書きする前に、両方の「次」を退避する(18 と同じ罠)。
// 型注釈は省けない。first = next1 と互いを参照して推論が堂々巡りになる(TS7022)
const next1: ListNode | null = first.next;
const next2: ListNode | null = second.next;
first.next = second; // 前半のノードの後ろに、後半のノードを差し込む
second.next = next1; // その後ろに、前半の次のノードを戻す
first = next1;
second = next2;
}
// 1周目 first=1 second=5(退避 next1=2 next2=4)-> 1 → 5 → 2
// 2周目 first=2 second=4(退避 next1=3 next2=null)-> 2 → 4 → 3
// 3周目に入る前に second=null になり終了 -> 1 → 5 → 2 → 4 → 3
}
}
3回なめるだけなので時間 O(n)、空間 O(1)。
① 中央 1 → 2 → 3 | 4 → 5 slow = 3(前半の末尾)、その次の 4 が後半の先頭 = second
② 反転 1 → 2 → 3 | 5 → 4
③ 交互 1 → 5 → 2 → 4 → 3 second が null になって終了
fast を1つ先から始めるのは、偶数のときちょうど半分に割るため。fast は slow の倍進むので、1歩先に出せば1歩早くゴールに着き、その分 slow も1つ手前で止まる。
偶数 [1, 2, 3, 4] ここだけ差が出る
fast = head slow=1 fast=1 -> slow=2 fast=3 -> slow=3 fast=null
1, 2, 3 | 4 3対1
fast = head.next slow=1 fast=2 -> slow=2 fast=4 -> 終了(4 の次が無い)
1, 2 | 3, 4 2対2 <- ぴったり半分
奇数 [1, 2, 3, 4, 5] どちらで始めても同じ
fast = head slow=1 fast=1 -> slow=2 fast=3 -> slow=3 fast=5
1, 2, 3 | 4, 5 3対2
fast = head.next slow=1 fast=2 -> slow=2 fast=4 -> slow=3 fast=null
1, 2, 3 | 4, 5 3対2 <- 真ん中の 3 は前半に入る
応用: 「末尾から辿りたい」は反転して「先頭から」に変える。複雑な組み替えほど、既に解いた問題の組み合わせに分解できないかをまず疑う。