Notes & software courses · Free to learn
Aph's Blog
On this page

ข้อ 11 · LC392 Is Subsequence 🟢

👋 อ่านฟรีทั้งหมดบน Aph's Blog — เนื้อหาภาษาไทย ทำตามทีละหน้าใน sidebar ได้เลย หากมีข้อเสนอแนะหรืออยากให้เพิ่มหัวข้อไหน บอกได้เสมอ

ตอบว่า string s เป็น subsequence ของ t หรือไม่ — เลือกตัวอักษรบางตัวจาก t โดยไม่สลับลำดับ

ให้ string (สตริง) s และ t จง return True ถ้า s เป็น subsequence ของ t

subsequence คือ string ที่ได้จากการลบตัวอักษรบางตัว (หรือไม่ลบเลย) ออกจาก t โดย ไม่สลับลำดับ ตัวที่เหลือ — ต่างจาก substring ที่ต้องติดกันเป็นพืด

Example 1
Input:
s = "abc", t = "ahbgdc"
Output:
true
Explanation:
a-h-b-g-d-c → เลือก a (ตัวที่ 1), b (ตัวที่ 3), c (ตัวที่ 6) ได้ตามลำดับ
Example 2
Input:
s = "axc", t = "ahbgdc"
Output:
false
Explanation:
หา x ใน t ไม่เจอเลย
Example 3
Input:
s = "", t = "abc"
Output:
true
Explanation:
string ว่างเป็น subsequence ของทุก string (ไม่ต้องเลือกอะไรเลย)
Example 4
Input:
s = "acb", t = "ahbgdc"
Output:
false
Explanation:
มีทั้ง a, c, b แต่ใน t ตัว b มาก่อน c — ลำดับผิด ห้ามสลับ
Constraints (ข้อจำกัด)
  • 0 <= s.length <= 100
  • 0 <= t.length <= 10^4
  • s และ t เป็นตัวอักษรอังกฤษพิมพ์เล็กเท่านั้น
  • คำถามต่อยอด: ถ้ามี s เป็นล้านตัวมาเช็คกับ t เดิมซ้ำ ๆ จะทำยังไง
โจทย์นี้ถามอะไรจริง ๆ

"เดินไปบน t ทางเดียวไม่ย้อน แล้วเก็บตัวอักษรของ s ให้ครบตามลำดับได้ไหม" — คำว่าไม่ย้อนสำคัญมาก เพราะนี่คือเหตุผลที่ two pointers ใช้ได้

ลองเองก่อน 10–15 นาที

python
def is_subsequence(s: str, t: str) -> bool:
    # เขียนโค้ดของคุณที่นี่
    pass


print(is_subsequence("abc", "ahbgdc"))   # ควรได้ True
print(is_subsequence("axc", "ahbgdc"))   # ควรได้ False
print(is_subsequence("", "abc"))         # ควรได้ True
💡 ใบ้ขั้น 1 — ตั้งคำถามให้ตัวเองก่อน
  1. สมมติ s = "abc" และคุณกำลังอ่าน t ทีละตัวจากซ้ายไปขวา ตอนนี้คุณต้อง "จำ" อะไรไว้แค่อย่างเดียว?
  2. ถ้าเจอ 'a' ตัวแรกใน t คุณควรคว้าไว้เลย หรือควรเก็บไว้เผื่อว่ามี 'a' ตัวหลังที่ดีกว่า?
  3. เงื่อนไขที่บอกว่า "สำเร็จ" คืออะไร — พูดเป็นสมการของตัวแปรที่คุณจำไว้ในข้อ 1

ข้อ 2 คือหัวใจ: คิดให้ออกว่าการคว้าตัวที่เจอก่อนสุด ไม่เคย ทำให้เสียโอกาส

💡 ใบ้ขั้น 2 — เทคนิคที่ต้องใช้ และใช้ทำไม

ใช้ Two Pointers แบบเดินทางเดียวกันทั้งคู่ + คิดแบบ greedy (โลภ: พอเจอตัวที่ต้องการก็คว้าเลยทันที ไม่รีรอ)

  • pointer j — วิ่งบน t ทุกตัว ไม่มีวันย้อน
  • pointer i — บอกว่ากำลังตามหาตัวอักษรตัวที่ i ของ s อยู่ ขยับ เฉพาะตอนเจอตัวที่ตรงกัน
  • จบเมื่อ i == len(s) → เจอครบตามลำดับ

ทำไม greedy ถูก: คว้าตัวซ้ายสุดที่เจอไม่เคยทำให้เสียโอกาส (มีหัวข้อ "ทำไมมันถูกต้อง" พิสูจน์ไว้ด้านล่าง หลังตาราง dry run)

อย่าทำสิ่งนี้

อย่าไปลองทุกวิธีเลือกตัวอักษรจาก t (ลองทุกทางว่า "เอา/ไม่เอา" ตัวนี้) เพราะจำนวนวิธีที่ต้องลองจะเพิ่มเป็นเท่าตัวทุกครั้งที่ t ยาวขึ้นหนึ่งตัว — t ยาวได้ถึง 10,000 ตัว รันไม่จบในชาตินี้

💡 ใบ้ขั้น 3 — โครงโค้ด (pseudocode) มีช่องว่างให้เติม
python
i = 0                          # ตอนนี้กำลังหา s[i]
for c in t:                    # ไล่ทุกตัวอักษรของ t ทางเดียว
    if ______ and ______:      # (1) ยังหาไม่ครบ  และ  (2) ตัวอักษรตรงกัน
        i += 1                 # คว้าไว้ แล้วไปหาตัวถัดไปของ s
return ______                  # (3) เงื่อนไขว่าสำเร็จ

คำเตือน: ลำดับของสองเงื่อนไขในช่อง (1) และ (2) สลับกันไม่ได้ ลองคิดว่าทำไม (คำตอบอยู่ในเฉลย)

ไล่ทีละสเต็ปด้วยมือ (dry run)

ไล่ s = "abc", t = "ahbgdc" ดูว่า i ขยับตอนไหน

ตัวอักษรใน tกำลังหา s[i]ตรงไหมi หลังรอบ
as[0] = aตรง ✅1
hs[1] = bไม่1
bs[1] = bตรง ✅2
gs[2] = cไม่2
ds[2] = cไม่2
cs[2] = cตรง ✅3

จบแล้ว i = 3 = len(s) → return True — และสังเกตว่า i หยุดที่ 3 พอดี ถ้า t ยาวต่อไปอีก เงื่อนไข i < len(s) จะกัน IndexError ให้เรา

ทำไมมันถูกต้อง: พิสูจน์ว่า "คว้าตัวซ้ายสุด" ไม่เคยพลาด

นี่คือส่วนที่สำคัญที่สุดของข้อนี้ และเป็นคำถามที่คนสงสัยจริง ๆ ตอนเห็นโค้ดสั้นแบบนี้: ถ้าเจอตัวอักษรที่ตรงกันแล้วคว้าเลยทันที ไม่เก็บไว้เผื่อว่าจะมีตัวหลังที่ "เหมาะกว่า" มันจะไม่พลาดคำตอบเหรอ

ก่อนอื่นต้องเข้าใจว่าความกลัวนี้มีเหตุผล เพราะในโจทย์แบบเลือกของหลายข้อ การคว้าของชิ้นแรกที่เจอมักทำให้เสียของชิ้นที่ดีกว่าไปจริง ๆ แต่ข้อนี้ไม่เป็นแบบนั้น และเหตุผลพิสูจน์ได้ตรง ๆ

วิธีพิสูจน์เรียกว่า exchange argument (การแลกเปลี่ยน) หลักการคือ: สมมติว่ามีคำตอบที่ถูกต้องอยู่ชุดหนึ่ง แล้วแสดงว่าเราสามารถ ดัดแปลง คำตอบชุดนั้นให้กลายเป็นคำตอบแบบที่โค้ดของเราหาได้ โดยที่มันยังถูกต้องอยู่ ถ้าทำได้ แปลว่าโค้ดของเราไม่เคยพลาด

สมมติมีวิธีจับคู่ที่ถูกต้องอยู่ ซึ่งเลือกตัวอักษร s[0] จากตำแหน่ง q ใน t
และโค้ดของเราจะคว้าตำแหน่ง p ซึ่งเป็นตำแหน่งซ้ายสุดที่ตรงกัน  ->  p <= q

t = . . . [p] . . . [q] . . . . . . . . . .
           ↑         ↑
      เราคว้าตัวนี้   คำตอบสมมติใช้ตัวนี้

ย้ายจาก q มาใช้ p แทน:
  - ตัวอักษรยังตรงกันเหมือนกัน (ทั้งสองตำแหน่งเป็นตัวอักษร s[0])
  - ตัวที่เหลือของ s เดิมหาได้ในช่วงขวาของ q
  - ช่วงขวาของ p ครอบช่วงขวาของ q ทั้งหมด (เพราะ p <= q)
  -> ตัวที่เหลือจึงยังหาได้ครบเหมือนเดิมแน่นอน

ประโยคที่สำคัญที่สุดคือบรรทัดที่สามจากล่าง: เมื่อเราเลือกตำแหน่งที่ซ้ายกว่า พื้นที่ค้นหาที่เหลือทางขวาจะ กว้างกว่าหรือเท่ากับ เดิมเสมอ ไม่มีทางแคบลง ดังนั้นการคว้าตัวซ้ายสุดจึงไม่เคยทำให้เราแย่ลง — อย่างมากก็เท่ากัน อย่างน้อยก็ดีกว่า

ทำซ้ำเหตุผลนี้กับตัวอักษรตัวที่สอง ตัวที่สาม ไปเรื่อย ๆ จะได้ว่าคำตอบสมมติทุกชุดสามารถถูกดัดแปลงมาเป็นคำตอบที่โค้ดเราหาได้ ดังนั้นถ้ามีคำตอบอยู่จริง โค้ดเราจะหามันเจอแน่นอน

invariant ของลูปนี้

พูดในรูป invariant (ข้อเท็จจริงที่จริงเสมอ): ทุกครั้งที่จบรอบของลูป ค่า i คือจำนวนตัวอักษรของ s ที่จับคู่ได้แล้ว โดยใช้ตำแหน่งใน t ซ้ายสุดเท่าที่เป็นไปได้ ประโยค "ซ้ายสุดเท่าที่เป็นไปได้" คือส่วนที่ทำให้มันดีที่สุด เพราะมันเก็บพื้นที่ค้นหาไว้ให้ตัวที่เหลือได้มากที่สุด

จุดที่ต้องแยกให้ออก

greedy ใช้ได้กับข้อนี้ แต่ ใช้ไม่ได้ กับโจทย์ที่ถามว่า "สอง string มีส่วนร่วมกันยาวสุดเท่าไร" (LC1143) เพราะข้อนั้นเราไม่รู้ล่วงหน้าว่าต้องเก็บตัวอักษรตัวไหน จึงไม่มี "ตัวที่กำลังตามหา" ให้คว้า ข้อนั้นต้องใช้ DP ซึ่งเป็นหมวดท้ายคอร์ส — ความต่างอยู่ที่ข้อนี้ s ถูกกำหนดมาแล้วว่าต้องเป็นอะไร เราแค่ตรวจว่าหาได้ไหม

🔓 เปิดเฉลยเต็ม (ลองเองก่อนนะ)พับไว้ด้านใน — คลิกเมื่อพร้อมดู

ไอเดียหนึ่งบรรทัด: กวาด t ทีละตัว เจอตัวที่ตรงกับ s[i] ก็ขยับ i — ถ้า i เดินครบความยาว s ก็แปลว่าใช่

คำตอบสำหรับวางใน LeetCode — ตัวชี้สองตัวบนสองสตริงpython
class Solution:
    def isSubsequence(self, s: str, t: str) -> bool:
        i = 0                              # (1) ตัวชี้บน s = กำลังตามหา s[i]
        for c in t:                        # (2) ตัวชี้บน t กวาดทางเดียว ไม่ย้อน
            if i < len(s) and s[i] == c:   # (3) ยังหาไม่ครบ และตัวอักษรตรงกัน
                i += 1                     # (4) คว้าตัวนี้ แล้วไปหาตัวถัดไปของ s
        return i == len(s)                 # (5) ขยับครบทุกตัว = เจอครบตามลำดับ
นี่คือแบบที่ 3 ของหน้าแนวคิด

ตัวชี้ i อยู่บน s และตัวชี้ที่วนด้วย for อยู่บน t คือตัวชี้อยู่คนละแถวกัน และขยับเฉพาะตัวที่ถูกใช้ไปในก้าวนั้น · ตัวชี้บน t ขยับทุกก้าวเพราะเราต้องดู t ให้ครบ ส่วนตัวชี้บน s ขยับเฉพาะตอนจับคู่ได้

บรรทัดโค้ดทำอะไร / ทำไมต้องมี
(1)i = 0ตัวนับความก้าวหน้าบน s — ค่านี้แปลว่า "จับคู่ s[:i] ได้แล้ว" ตลอดเวลา
(2)for c in tกวาด t ทีละตัวจากซ้าย ไม่ย้อนกลับ — เป็นลูปธรรมดา ไม่ต้องประกาศ pointer ตัวที่สองแบบชัด ๆ เพราะ for ของ Python ไล่ตัวแปร c ให้เราแล้ว
(3)if i < len(s) and s[i] == cต้องเช็ค i < len(s) ก่อน เพราะ Python เช็คเงื่อนไขซ้ายก่อนเสมอ ถ้าเจอ False ก็หยุดเช็คขวาทันที (เรียกว่า short-circuit) ถ้าสลับไปเช็ค s[i] ก่อนจะเกิด IndexError ทันทีที่ i ถึงปลาย s
(4)i += 1greedy คว้าตัวที่เจอก่อนสุด ไม่เก็บไว้เผื่อ เพราะพิสูจน์ได้ว่าไม่เสียโอกาส
(5)return i == len(s)ไม่ต้องเช็คว่า t เหลือเท่าไร สนใจแค่ว่า s ครบหรือยัง — และกรณี s = "" ได้ True อัตโนมัติเพราะ 0 == 0

ทำไมมันถูกต้อง: ทุกครั้งที่จบรอบของลูป ข้อเท็จจริงนี้เป็นจริงเสมอ — "i คือจำนวนตัวอักษรของ s ที่จับคู่ได้แล้วโดยใช้เฉพาะ t ที่กวาดผ่านมา และเป็นการจับคู่ที่ใช้ตำแหน่งใน t ซ้ายสุดเท่าที่เป็นไปได้" การเลือกซ้ายสุดเสมอทำให้ตัวที่เหลือมีพื้นที่ค้นหากว้างที่สุด จึงไม่มีทางแพ้คำตอบอื่น

วิธีจำนวนรอบที่ใช้หมายเหตุ
ลองทุกวิธีเลือกตัวอักษรจาก tเพิ่มเป็นเท่าตัวทุก 1 ตัวของ t ❌รันไม่จบเมื่อ t ยาว 10^4
ตาราง DPยาว s คูณ ยาว tถูก แต่เกินความจำเป็นมาก — DP แก้ปัญหาที่ยากกว่านี้คือ "หาส่วนร่วมยาวสุด" ส่วนข้อนี้แค่ถามว่า "หาได้ครบไหม" ซึ่งตอบด้วยการกวาดรอบเดียวได้ (DP เป็นหมวดท้ายคอร์ส ยังไม่ต้องรู้ตอนนี้)
two pointers greedy (เฉลยนี้)ยาว t รอบเดียว ✅กวาด t รอบเดียว ใช้ตัวแปรตัวเดียวคือ i
ขั้นสูง (ข้ามได้): ถ้ามี s เป็นล้านตัวมาเช็คกับ t เดิมซ้ำ ๆ

ส่วนนี้ใช้ binary search ซึ่งเป็นหัวข้อของบทหลัง ๆ ในคอร์สนี้ ถ้ายังไม่คุ้น ข้ามไปดูโจทย์ข้อถัดไปได้เลย แล้วค่อยย้อนกลับมาอ่านทีหลัง

ถ้า t คงที่แต่ s เปลี่ยนไปเรื่อย ๆ การกวาด t ใหม่ทุกครั้งจะเสียเวลาเท่ากับความยาว t ต่อครั้ง วิธีที่ดีกว่าคือ pre-process (เตรียมข้อมูลล่วงหน้า) — สร้าง dict เก็บว่าตัวอักษรแต่ละตัวโผล่ที่ index ไหนใน t บ้าง แล้วใช้ binary search หาตำแหน่งถัดไปที่มากกว่าตำแหน่งปัจจุบัน

เวอร์ชัน pre-process + binary searchpython
from bisect import bisect_right
from collections import defaultdict


class Matcher:
    def __init__(self, t: str):
        self.pos = defaultdict(list)          # ตัวอักษร -> list ของ index ใน t
        for idx, ch in enumerate(t):
            self.pos[ch].append(idx)

    def is_subsequence(self, s: str) -> bool:
        cur = -1                              # ตำแหน่งล่าสุดที่ใช้ไปใน t
        for ch in s:
            idxs = self.pos.get(ch)
            if not idxs:
                return False                  # ไม่มีตัวอักษรนี้ใน t เลย
            k = bisect_right(idxs, cur)       # หา index แรกที่ > cur
            if k == len(idxs):
                return False                  # มีแต่ตัวที่อยู่ซ้ายกว่าที่ใช้ไปแล้ว
            cur = idxs[k]
        return True


m = Matcher("ahbgdc")
for q in ["abc", "axc", "", "acb", "ahbgdc", "abcd"]:
    print(repr(q), m.is_subsequence(q))
Output
'abc' True
'axc' False
'' True
'acb' False
'ahbgdc' True
'abcd' False

อ่านโค้ดนี้ให้ออกต้องรู้สองอย่าง หนึ่ง self.pos เก็บว่าตัวอักษรแต่ละตัวอยู่ที่ index ไหนบ้างใน t เช่น t = "ahbgdc" จะได้ pos["a"] = [0], pos["h"] = [1], pos["b"] = [2] ไปเรื่อย ๆ ถ้าตัวอักษรซ้ำ list ก็จะมีหลายค่าเรียงจากน้อยไปมาก

สอง bisect_right(idxs, cur) คือการถามว่า "ในลิสต์ idxs ที่เรียงอยู่แล้ว ค่าที่มากกว่า cur ตัวแรกอยู่ตำแหน่งที่เท่าไร" มันตอบได้เร็วเพราะใช้วิธีตัดครึ่งค้นหา (binary search) ไม่ได้ไล่ดูทีละตัว เช่น idxs = [0, 3, 7] และ cur = 3 จะได้ผลลัพธ์ 2 ซึ่งชี้ไปที่เลข 7 — คือตำแหน่งถัดไปที่ใช้ได้ เพราะต้องอยู่ขวากว่าตัวที่ใช้ไปแล้ว ถ้าผลลัพธ์เท่ากับความยาวลิสต์ แปลว่าไม่มีตัวไหนอยู่ขวากว่า cur เลย จึง return False

เตรียมครั้งเดียวใช้เวลาเท่ากับความยาว t แล้วแต่ละ query ที่เหลือจะเร็วกว่าเดิมมาก เพราะไม่ต้องกวาด t ทั้งเส้นอีก ข้ามไปทีละตัวอักษรที่ต้องการเลย — คุ้มมากเมื่อมี query เยอะ นี่คือคำตอบที่ interviewer อยากได้ตอนถาม follow-up

💡 สรุป pattern

จับคู่สอง sequence ด้วยการกวาดทางเดียวแบบ greedy: เจอตัวที่ตามหาก็คว้าเลย ไม่ย้อน ไม่ลองทางอื่น ใช้ได้กับโจทย์ matching/merge สอง string หรือสอง array อีกเยอะ

ต่อยอด (โจทย์พี่น้องกัน)

LC524 Longest Word in Dictionary through Deleting, LC792 Number of Matching Subsequences (ใช้ trick pre-process ข้างบนได้เลย), LC1143 Longest Common Subsequence (อันนี้ต้อง DP)