On this page
ข้อ 11 · LC392 Is Subsequence 🟢
ตอบว่า string s เป็น subsequence ของ t หรือไม่ — เลือกตัวอักษรบางตัวจาก t โดยไม่สลับลำดับ
ให้ string (สตริง) s และ t จง return True ถ้า s เป็น subsequence ของ t
subsequence คือ string ที่ได้จากการลบตัวอักษรบางตัว (หรือไม่ลบเลย) ออกจาก t โดย ไม่สลับลำดับ ตัวที่เหลือ — ต่างจาก substring ที่ต้องติดกันเป็นพืด
- Input:
- s = "abc", t = "ahbgdc"
- Output:
- true
- Explanation:
- a-h-b-g-d-c → เลือก a (ตัวที่ 1), b (ตัวที่ 3), c (ตัวที่ 6) ได้ตามลำดับ
- Input:
- s = "axc", t = "ahbgdc"
- Output:
- false
- Explanation:
- หา x ใน t ไม่เจอเลย
- Input:
- s = "", t = "abc"
- Output:
- true
- Explanation:
- string ว่างเป็น subsequence ของทุก string (ไม่ต้องเลือกอะไรเลย)
- Input:
- s = "acb", t = "ahbgdc"
- Output:
- false
- Explanation:
- มีทั้ง a, c, b แต่ใน t ตัว b มาก่อน c — ลำดับผิด ห้ามสลับ
- 0 <= s.length <= 100
- 0 <= t.length <= 10^4
- s และ t เป็นตัวอักษรอังกฤษพิมพ์เล็กเท่านั้น
- คำถามต่อยอด: ถ้ามี s เป็นล้านตัวมาเช็คกับ t เดิมซ้ำ ๆ จะทำยังไง
"เดินไปบน t ทางเดียวไม่ย้อน แล้วเก็บตัวอักษรของ s ให้ครบตามลำดับได้ไหม" — คำว่าไม่ย้อนสำคัญมาก เพราะนี่คือเหตุผลที่ two pointers ใช้ได้
ลองเองก่อน 10–15 นาที
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 — ตั้งคำถามให้ตัวเองก่อน
- สมมติ s = "abc" และคุณกำลังอ่าน t ทีละตัวจากซ้ายไปขวา ตอนนี้คุณต้อง "จำ" อะไรไว้แค่อย่างเดียว?
- ถ้าเจอ 'a' ตัวแรกใน t คุณควรคว้าไว้เลย หรือควรเก็บไว้เผื่อว่ามี 'a' ตัวหลังที่ดีกว่า?
- เงื่อนไขที่บอกว่า "สำเร็จ" คืออะไร — พูดเป็นสมการของตัวแปรที่คุณจำไว้ในข้อ 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) มีช่องว่างให้เติม
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 หลังรอบ |
|---|---|---|---|
| a | s[0] = a | ตรง ✅ | 1 |
| h | s[1] = b | ไม่ | 1 |
| b | s[1] = b | ตรง ✅ | 2 |
| g | s[2] = c | ไม่ | 2 |
| d | s[2] = c | ไม่ | 2 |
| c | s[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 (ข้อเท็จจริงที่จริงเสมอ): ทุกครั้งที่จบรอบของลูป ค่า i คือจำนวนตัวอักษรของ s ที่จับคู่ได้แล้ว โดยใช้ตำแหน่งใน t ซ้ายสุดเท่าที่เป็นไปได้ ประโยค "ซ้ายสุดเท่าที่เป็นไปได้" คือส่วนที่ทำให้มันดีที่สุด เพราะมันเก็บพื้นที่ค้นหาไว้ให้ตัวที่เหลือได้มากที่สุด
greedy ใช้ได้กับข้อนี้ แต่ ใช้ไม่ได้ กับโจทย์ที่ถามว่า "สอง string มีส่วนร่วมกันยาวสุดเท่าไร" (LC1143) เพราะข้อนั้นเราไม่รู้ล่วงหน้าว่าต้องเก็บตัวอักษรตัวไหน จึงไม่มี "ตัวที่กำลังตามหา" ให้คว้า ข้อนั้นต้องใช้ DP ซึ่งเป็นหมวดท้ายคอร์ส — ความต่างอยู่ที่ข้อนี้ s ถูกกำหนดมาแล้วว่าต้องเป็นอะไร เราแค่ตรวจว่าหาได้ไหม
🔓 เปิดเฉลยเต็ม (ลองเองก่อนนะ)พับไว้ด้านใน — คลิกเมื่อพร้อมดู
ไอเดียหนึ่งบรรทัด: กวาด t ทีละตัว เจอตัวที่ตรงกับ s[i] ก็ขยับ i — ถ้า i เดินครบความยาว s ก็แปลว่าใช่
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) ขยับครบทุกตัว = เจอครบตามลำดับตัวชี้ 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 += 1 | greedy คว้าตัวที่เจอก่อนสุด ไม่เก็บไว้เผื่อ เพราะพิสูจน์ได้ว่าไม่เสียโอกาส |
| (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 หาตำแหน่งถัดไปที่มากกว่าตำแหน่งปัจจุบัน
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))'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
จับคู่สอง 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)