นากกายสีมิ้นต์วางหมุดสองตัวห่างกันบนลูกโซ่แล้วถอดห่วงหนึ่งใกล้ปลายสาย

LeetCode #19 Remove Nth Node From End: เว้นระยะให้ pointer นำทาง

พี่โชว์Engineering

โจทย์ให้ singly linked list และ n ที่รับประกันว่าอยู่ในช่วงถูกต้อง แล้วให้ลบโหนดลำดับที่ n เมื่อนับจากท้ายครับ ความยากคือ list เดินย้อนหลังไม่ได้ เราจึงต้องแปลง “จากท้าย” ให้กลายเป็นข้อมูลที่หาได้ระหว่างเดินไปข้างหน้า

ให้นึกถึงคนสองคนเดินบนทางเส้นเดียว ถ้าคนหน้าออกก่อน n ก้าว เมื่อคนหน้าแตะปลายทาง คนหลังจะยืนก่อนจุดที่ต้องเอาออกพอดี นี่คือหัวใจของ one-pass two pointers

วิธีที่ 1: นับความยาวสองรอบ

รอบแรกนับจำนวนโหนด length รอบที่สองเดินไปยังโหนดก่อนตำแหน่ง length - n แล้วข้ามโหนดเป้าหมาย เวลา O(L), พื้นที่เพิ่ม O(1) แม้เดินสองรอบแต่เข้าใจง่ายและ debug ง่าย เหมาะเมื่อ list อ่านซ้ำได้และความชัดสำคัญกว่า latency หนึ่ง traversal

วิธีที่ 2: Two pointers หนึ่งรอบ — ค่าเริ่มต้นที่แนะนำ

ใส่ dummy ก่อนหัวเพื่อทำให้กรณีลบโหนดแรกเหมือนกรณีทั่วไป เลื่อน fast ไป n + 1 ตำแหน่งจาก dummy เพื่อรักษาช่องว่าง แล้วขยับ fast กับ slow พร้อมกันจน fast เป็น None ตอนนั้น slow.next คือโหนดเป้าหมาย เวลา O(L), พื้นที่ O(1) และเดินข้อมูลรอบเดียว

วิธีที่ 3: เก็บโหนดใน stack

เดิน list แล้วเก็บ reference ทุกโหนดรวม dummy จากนั้นหยิบโหนดก่อนเป้าหมายด้วย index จากท้าย เวลา O(L), พื้นที่ O(L) โค้ดตรงกับภาษาของโจทย์มาก แต่ไม่เหมาะเมื่อ memory จำกัด จุดเด่นคือปรับต่อได้ง่ายหากต้องแก้หลายตำแหน่งจากท้ายในคราวเดียว

import unittest


class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next


def from_list(values):
    dummy = tail = ListNode()
    for value in values:
        tail.next = ListNode(value)
        tail = tail.next
    return dummy.next


def to_list(node):
    result = []
    while node:
        result.append(node.val)
        node = node.next
    return result


def remove_two_pass(head, n):
    dummy = ListNode(0, head)
    length, node = 0, head
    while node:
        length += 1
        node = node.next
    before = dummy
    for _ in range(length - n):
        before = before.next
    before.next = before.next.next
    return dummy.next


def remove_two_pointers(head, n):
    dummy = ListNode(0, head)
    fast = slow = dummy
    for _ in range(n + 1):
        fast = fast.next
    while fast:
        fast = fast.next
        slow = slow.next
    slow.next = slow.next.next
    return dummy.next


def remove_stack(head, n):
    dummy = ListNode(0, head)
    stack, node = [], dummy
    while node:
        stack.append(node)
        node = node.next
    before = stack[-n - 1]
    before.next = before.next.next
    return dummy.next


class RemoveNthTests(unittest.TestCase):
    def test_methods_and_edges(self):
        cases = [
            ([1], 1, []),
            ([1, 2], 1, [1]),
            ([1, 2], 2, [2]),
            ([1, 2, 3, 4, 5], 2, [1, 2, 3, 5]),
        ]
        for values, n, expected in cases:
            for method in (remove_two_pass, remove_two_pointers, remove_stack):
                with self.subTest(method=method.__name__, values=values, n=n):
                    self.assertEqual(to_list(method(from_list(values), n)), expected)


if __name__ == "__main__":
    unittest.main()

เลือกตามสถานการณ์

เงื่อนไขสองรอบTwo pointersStack
input ใหญ่O(L), เดินสองรอบO(L), เดินรอบเดียวO(L) และเก็บทุกโหนด
memory จำกัดO(1)O(1)O(L)
readabilityตรงไปตรงมาต้องรักษาระยะให้ถูกตรงกับการนับจากท้าย
sorted/streaming inputไม่ต้อง sorted; ต้องอ่านซ้ำไม่ต้อง sorted; รับ stream แบบ node chain ได้ไม่ต้อง sorted; ต้องเก็บทั้งหมด
การแก้ inputเปลี่ยน pointerเปลี่ยน pointerเปลี่ยน pointer

best default ภายใต้ constraints ของโจทย์คือ two pointers เพราะผ่าน list ครั้งเดียวและใช้พื้นที่คงที่

Edge cases

dummy node สำคัญเมื่อ n == length เพราะโหนดที่ต้องลบคือหัว ทดสอบ list หนึ่งโหนด, ลบตัวท้าย, ลบตัวแรก และ list หลายโหนด โจทย์รับประกัน n ถูกต้อง; ใน production API ควรตรวจ n > 0 และไม่เกินความยาวก่อนแก้ pointer

Recap

  • เว้นระยะ n + 1 จาก dummy เพื่อให้ slow หยุดก่อนเป้าหมาย
  • dummy ทำให้การลบหัวไม่ต้องมี branch พิเศษ
  • two pointers เป็น default ที่ได้ O(L) time และ O(1) space
  • stack เหมาะเมื่อมีหลาย query จากท้ายและยอมแลก memory ได้