งูต้นฉบับจัดแท่งความสูงบนถาดหลายกองและชี้เส้นทางเพิ่มขึ้นที่ยาวที่สุดด้วยริบบิ้นสีส้ม

LeetCode #300 LIS: จาก DP ตารางตรงไปตรงมาถึง patience sorting และ Fenwick tree

พี่โชว์Engineering

Longest Increasing Subsequence ให้หาความยาว subsequence ที่ค่าเพิ่มขึ้นแบบ strictly increasing สมาชิกไม่ต้องติดกัน แต่ต้องรักษาลำดับเดิม ตัวอย่าง [10, 9, 2, 5, 3, 7, 101, 18] ตอบ 4 จากเส้นทางอย่าง [2, 3, 7, 18] คำว่า strict สำคัญ: ค่าเท่ากันต่อกันไม่ได้ครับ

วิธี 1: Dynamic programming O(n²)

กำหนด dp[i] เป็น LIS ที่จบตรงตำแหน่ง i แล้วมองทุก j < i ที่ nums[j] < nums[i] สูตรตรงกับนิยามและย้อนสร้างคำตอบจริงได้ง่ายหากเก็บ parent เพิ่ม เหมาะกับการอธิบายครั้งแรกหรือ n ไม่ใหญ่

เก็บ tails[k] เป็นปลายที่เล็กที่สุดของ subsequence ความยาว k+1 สำหรับแต่ละค่า ใช้ bisect_left หา pile แรกที่ไม่น้อยกว่าค่านั้นแล้วแทนที่ ความยาว tails คือคำตอบ เวลา O(n log n) พื้นที่ O(n) และเป็นคำตอบแนะนำของโจทย์นี้ แต่ tails ไม่จำเป็นต้องเป็น subsequence จริงทั้งชุด

วิธี 3: Fenwick tree หลังบีบพิกัด

บีบค่าตัวเลขเป็น rank แล้ว query LIS สูงสุดของ rank ที่เล็กกว่าค่าปัจจุบัน จากนั้น update rank ปัจจุบัน วิธีนี้ O(n log n) เช่นกันและต่อยอดเป็นงาน prefix maximum/counting ได้ดี แต่โค้ดยาวกว่า patience sorting

from bisect import bisect_left
import unittest


def lis_dp(nums: list[int]) -> int:
    if not nums:
        return 0
    dp = [1] * len(nums)
    for right in range(len(nums)):
        for left in range(right):
            if nums[left] < nums[right]:
                dp[right] = max(dp[right], dp[left] + 1)
    return max(dp)


def lis_patience(nums: list[int]) -> int:
    tails: list[int] = []
    for value in nums:
        index = bisect_left(tails, value)
        if index == len(tails):
            tails.append(value)
        else:
            tails[index] = value
    return len(tails)


def lis_fenwick(nums: list[int]) -> int:
    ranks = {value: index + 1 for index, value in enumerate(sorted(set(nums)))}
    tree = [0] * (len(ranks) + 1)

    def query(index: int) -> int:
        best = 0
        while index > 0:
            best = max(best, tree[index])
            index -= index & -index
        return best

    for value in nums:
        index = ranks[value]
        best = query(index - 1) + 1
        cursor = index
        while cursor < len(tree):
            tree[cursor] = max(tree[cursor], best)
            cursor += cursor & -cursor
    return query(len(ranks))


class LISTests(unittest.TestCase):
    def test_all_methods(self):
        cases = [
            ([10, 9, 2, 5, 3, 7, 101, 18], 4),
            ([0, 1, 0, 3, 2, 3], 4),
            ([7, 7, 7, 7], 1),
            ([], 0),
        ]
        for solve in (lis_dp, lis_patience, lis_fenwick):
            for nums, expected in cases:
                self.assertEqual(solve(nums), expected)


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

Decision table

วิธีเวลาพื้นที่คืน sequence จริงเหมาะกับ
DPO(n²)O(n)เพิ่ม parent ได้ตรงไปตรงมาสอนแนวคิด, input เล็ก
PatienceO(n log n)O(n)ต้องเก็บ predecessor เพิ่มคำตอบ default สำหรับ length
FenwickO(n log n)O(n)ต้องเพิ่ม metadataquery/update ตามช่วงหรือ variant ซับซ้อน

Edge cases และกับดัก

ค่าเท่ากันต้องไม่เพิ่มความยาว จึงใช้ bisect_left ไม่ใช่ bisect_right และ Fenwick ต้อง query ถึง rank - 1 เท่านั้น เทสต์ array ว่าง, ค่าเดียว, ลดลงล้วน, ซ้ำล้วน และมีค่าติดลบ สำหรับ DP อย่าลืมจัดการ input ว่างก่อน max(dp)

ถ้าโจทย์ขอ subsequence จริง ไม่ใช่แค่ความยาว ให้เก็บ predecessor กับ index ปลายของแต่ละความยาวในวิธี patience อย่าคืน tails ตรง ๆ เพราะค่าที่ถูกแทนภายหลังอาจมาจากคนละเส้นทาง ในการสัมภาษณ์เริ่ม DP เพื่อสร้าง recurrence แล้ว optimize เป็น patience sorting จะอธิบายเหตุผลได้ดีกว่าท่อง binary search มาเลย

Recap

  • DP ทำให้ recurrence ชัด แต่โตเป็นกำลังสอง
  • patience sorting เป็น default สำหรับความยาว O(n log n)
  • strict increasing ต้องใช้ตำแหน่งแรกที่ >= value
  • Fenwick tree คุ้มเมื่อโจทย์ต่อยอดเป็น prefix query หรือการนับครับ