Linked List

「値」と「次はどこか」を持つノードが数珠つなぎになった入れ物。配列との違いはメモリ上で連続しているかどうかの一点に尽きる。

配列    [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 は前半に入る

応用: 「末尾から辿りたい」は反転して「先頭から」に変える。複雑な組み替えほど、既に解いた問題の組み合わせに分解できないかをまず疑う。