On this page
- ส่วนที่ 1 · อ่านโค้ดบรรทัดนี้ให้ออกก่อน
- ส่วนที่ 2 · ทำไมมันเร็วขึ้น ดูของจริงก่อน
- ส่วนที่ 3 · ขยับทิ้งไปเลยแบบนั้น ไม่พลาดคำตอบเหรอ
- ส่วนที่ 4 · "ไม่พลาดคำตอบ" ไม่ได้แปลว่า "เห็นทุกคู่"
- ส่วนที่ 5 · สามแบบที่ต้องแยกให้ออก
- แบบที่ 1 · หัวกับท้ายเดินเข้าหากัน
- แบบที่ 2 · เดินทางเดียวกัน แต่คนละความเร็ว
- แบบที่ 3 · เดินสองแถวไปพร้อมกัน
- ส่วนที่ 6 · เมื่อไหร่ที่ห้ามใช้ two pointers
- ส่วนที่ 7 · กับดักที่เจอบ่อย
- ทบทวนก่อนไปทำโจทย์
Two Pointers — พื้นฐาน & แนวคิด
ใช้ 'ตัวแปรจำตำแหน่ง' สองตัวเดินไล่บน list (ลิสต์) หรือ string (สตริง) พร้อมกัน — มีสามรูปแบบ คือเดินเข้าหากันจากหัวกับท้าย, เดินทางเดียวกันแต่คนละความเร็ว, และเดินทางเดียวกันบนข้อมูลสองชุด
คำว่า pointer ฟังดูน่ากลัวกว่าที่มันเป็นจริง ๆ มาก ขอเคลียร์ก่อนว่าในหัวข้อนี้มันไม่ใช่อะไรพิเศษเลย
pointer ที่เราจะใช้ คือ ตัวแปรธรรมดา ที่เราตั้งชื่อเอง แล้วเก็บเลขตำแหน่งในลิสต์เอาไว้ เช่น left = 0 ก็แค่ตัวแปรชื่อ left ที่เก็บค่า 0
มันคือตัวเดียวกับตัวแปร i ที่เราใช้ใน for i in range(...) มาตลอด ต่างกันแค่คราวนี้เรา ตั้งชื่อเองและขยับเอง แทนที่จะให้ for loop ขยับให้ทีละ 1 อัตโนมัติ
Two Pointers จึงแปลตรงตัวว่ามีตัวแปรแบบนี้พร้อมกันสองตัว แล้วให้แต่ละตัวเดินคนละจังหวะ คนละทิศ หรือคนละความเร็ว ตามที่โจทย์ต้องการ
ประโยชน์คือ แทนที่จะวนลูปซ้อนสองชั้นเพื่อเทียบทุกคู่ที่เป็นไปได้ เราให้ตัวแปรสองตัวเดินไปพร้อมกันในรอบเดียว แล้วตัดสินใจทีละก้าวว่าจะขยับตัวไหน ผลคือจำนวนรอบลดลงมหาศาล
โค้ดทุกช่องในหน้านี้รันได้จริงและมีผลลัพธ์กำกับไว้ ก่อนดูผลลัพธ์ให้ลองเดาในใจก่อนแล้วค่อยเทียบ · ส่วนที่ 1 ถึง 4 คือเหตุผลว่าทำไมท่านี้ใช้ได้และใช้ไม่ได้ตอนไหน ส่วนที่ 5 คือหัวใจ คือ 3 แบบที่ต้องแยกให้ออก ถ้าอ่านช่วงไหนแล้วรู้สึกหลง ให้ถอยขึ้นไปหนึ่งหัวข้อ ไม่ต้องรีบ
ส่วนที่ 1 · อ่านโค้ดบรรทัดนี้ให้ออกก่อน
ในหัวข้อนี้จะเจอโค้ดหน้าตาแบบ left, right = 0, len(nums) - 1 บ่อยมาก มันคือการเขียนสองบรรทัดรวมกันในบรรทัดเดียว
left = 0
right = len(nums) - 1
# เขียนย่อรวมกันเป็นบรรทัดเดียวได้ ความหมายเหมือนกันทุกประการ:
left, right = 0, len(nums) - 1ถ้าอ่านบรรทัดนี้ไม่ออก โค้ดที่เหลือทั้งหน้าจะดูงงไปหมด ทั้งที่จริง ๆ มันคือการประกาศตัวแปรสองตัวธรรมดา แค่เขียนย่อ · และค่า len(nums) - 1 คือตำแหน่งของตัวสุดท้าย ต้องลบหนึ่งเพราะตำแหน่งเริ่มนับจาก 0
ส่วนที่ 2 · ทำไมมันเร็วขึ้น ดูของจริงก่อน
โจทย์ตัวอย่าง: มีลิสต์ตัวเลขที่เรียงจากน้อยไปมากแล้ว อยากรู้ว่ามีเลขคู่ไหนบวกกันได้ค่าเป้าหมายที่กำหนดไหม
เขียนสองวิธีแล้วนับจำนวนครั้งที่ต้องเทียบ เพื่อดูว่าต่างกันแค่ไหน
nums = [1, 3, 4, 6, 8, 11]
target = 14
count = 0 # นับว่า compare (เทียบ) ไปกี่ครั้งแล้ว
for i in range(len(nums)): # loop ชั้นนอก: เลือกตัวที่ 1
for j in range(i + 1, len(nums)): # loop ชั้นใน: เลือกตัวที่ 2 (อยู่หลัง i เสมอ)
count += 1 # นี่คือการ compare หนึ่งครั้ง
if nums[i] + nums[j] == target:
print("เจอคู่:", nums[i], nums[j])
print("compare ทั้งหมด", count, "ครั้ง")เจอคู่: 3 11
เจอคู่: 6 8
compare ทั้งหมด 15 ครั้งโค้ดข้างบนไม่มีอะไรใหม่ แค่ for สองชั้นซ้อนกัน
ปัญหาคือมันเทียบ ทุกคู่ที่เป็นไปได้ โดยไม่สนใจว่าคู่นั้นมีโอกาสเป็นคำตอบหรือไม่ ลิสต์มี 6 ตัว จึงต้องเทียบถึง 15 ครั้ง
nums = [1, 3, 4, 6, 8, 11]
target = 14
left, right = 0, len(nums) - 1 # left = ตัวชี้หัวแถว, right = ตัวชี้ท้ายแถว
count = 0
while left < right: # วนจนกว่าสองตัวจะเดินมาชนกัน
count += 1
total = nums[left] + nums[right]
if total == target:
print("เจอคู่:", nums[left], nums[right])
left += 1 # ขยับทั้งคู่ เพื่อไปหาคู่ถัดไป
right -= 1
elif total < target:
left += 1 # ผลรวมน้อยไป -> ลองขยับซ้ายให้ค่าใหญ่ขึ้น
else:
right -= 1 # ผลรวมมากไป -> ลองขยับขวาให้ค่าเล็กลง
print("compare ทั้งหมด", count, "ครั้ง")เจอคู่: 3 11
เจอคู่: 6 8
compare ทั้งหมด 4 ครั้งคู่ที่เจอเหมือนกันเป๊ะ แต่จำนวนครั้งที่เทียบลดจาก 15 เหลือ 4
และช่องว่างนี้ถ่างขึ้นเรื่อย ๆ เมื่อลิสต์ยาวขึ้น ถ้าลิสต์มี 10,000 ตัว วิธีวนซ้อนต้องเทียบราว 50,000,000 ครั้ง แต่ two pointers ยังเทียบแค่ราว 10,000 ครั้ง
ในภาษา Big-O คือ O(n²) เทียบกับ O(n) ซึ่งเป็นความต่างระหว่างรันไม่ทันกับรันทันเมื่อข้อมูลใหญ่
คำว่า คู่ที่เจอเหมือนกันเป๊ะ ข้างบนเป็นจริงกับ input ชุดนี้เท่านั้น ส่วนที่ 4 จะแสดงให้เห็นว่ามีกรณีที่สองวิธีให้คำตอบต่างกัน และนั่นคือขอบเขตที่แท้จริงของท่านี้
ส่วนที่ 3 · ขยับทิ้งไปเลยแบบนั้น ไม่พลาดคำตอบเหรอ
นี่คือคำถามที่ทุกคนสงสัยตอนเห็นโค้ดนี้ครั้งแรก และเป็นคำถามที่ถูกต้องมาก เพราะเราขยับตัวชี้ทิ้งข้อมูลไปทั้งชุดโดยไม่ได้ลองดูให้ครบ
คำตอบคือไม่พลาด แต่มีเงื่อนไข และเงื่อนไขนั้นคือสิ่งที่ต้องเข้าใจให้ได้ในส่วนนี้
ไล่จากตัวอย่างข้างบน: nums = [1, 3, 4, 6, 8, 11] และเป้าหมายคือ 14
เริ่มที่ left ชี้เลข 1 ซึ่งเล็กสุด และ right ชี้เลข 11 ซึ่งใหญ่สุด ผลรวมได้ 12 ซึ่งยังน้อยกว่า 14
ตรงนี้คือจุดตัดสินใจ เราจะขยับตัวไหน และทำไม
เพราะลิสต์เรียงจากน้อยไปมากแล้ว เลข 11 ที่ right ชี้อยู่จึงเป็น เพื่อนที่ดีที่สุดที่เลข 1 จะหาได้ ไม่มีตัวไหนใหญ่กว่านี้อีก
ถ้าจับกับเพื่อนที่ดีที่สุดแล้วยังไม่ถึง 14 แปลว่าเลข 1 จับกับตัวอื่นที่เล็กกว่า 11 ก็ยิ่งไม่ถึง
เท่ากับว่าการรู้แค่ว่า 1 บวก 11 ยังไม่พอ ทำให้เราตัด ทุกคู่ที่มีเลข 1 อยู่ด้วย ทิ้งได้หมดในก้าวเดียว โดยไม่ต้องลองทีละคู่
นี่คือเหตุผลที่เราเขียน left += 1 คือเลิกสนใจเลข 1 ไปเลย แล้วไปดูตัวถัดไป
ก่อนขยับ pointer ทุกครั้ง ต้องตอบให้ได้ว่า "ทำไมคู่ที่กำลังจะทิ้งไป ถึงไม่มีทางเป็นคำตอบที่ดีกว่านี้" ถ้าตอบเหตุผลนี้ไม่ได้ชัดเจน แปลว่าโจทย์ข้อนั้นอาจใช้ two pointers ไม่ได้
ส่วนที่ 4 · "ไม่พลาดคำตอบ" ไม่ได้แปลว่า "เห็นทุกคู่"
ส่วนนี้สำคัญที่สุดของหน้า และเป็นส่วนที่บทเรียนส่วนใหญ่ไม่พูดถึง
เพราะ demo ข้างบนให้ผลตรงกันทั้งสองวิธี คนอ่านจึงมักสรุปเองว่า two pointers หาได้ครบทุกคู่ แค่เร็วกว่า ซึ่งไม่จริง
มันตรงกันเพราะ input ชุดนั้นไม่มีเลขซ้ำเท่านั้น ลองใส่เลขซ้ำเข้าไปดูว่าเกิดอะไรขึ้น
def brute(nums, target):
found = []
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
found.append((nums[i], nums[j]))
return found
def two_pointers(nums, target):
found = []
left, right = 0, len(nums) - 1
while left < right:
total = nums[left] + nums[right]
if total == target:
found.append((nums[left], nums[right]))
left += 1
right -= 1
elif total < target:
left += 1
else:
right -= 1
return found
for nums, target in [([1, 3, 4, 6, 8, 11], 14), ([1, 1, 3, 3], 4)]:
bf = brute(nums, target)
tp = two_pointers(nums, target)
print("nums =", nums, "target =", target)
print(" brute force เจอ", len(bf), "คู่:", bf)
print(" two pointers เจอ", len(tp), "คู่:", tp)
print(" ตรงกันไหม:", bf == tp)nums = [1, 3, 4, 6, 8, 11] target = 14
brute force เจอ 2 คู่: [(3, 11), (6, 8)]
two pointers เจอ 2 คู่: [(3, 11), (6, 8)]
ตรงกันไหม: True
nums = [1, 1, 3, 3] target = 4
brute force เจอ 4 คู่: [(1, 3), (1, 3), (1, 3), (1, 3)]
two pointers เจอ 2 คู่: [(1, 3), (1, 3)]
ตรงกันไหม: Falseคู่หายไปครึ่งหนึ่งแบบเงียบ ๆ ไม่มี error ไม่มีสัญญาณเตือนอะไรเลย
สาเหตุอยู่ที่บรรทัด left += 1 กับ right -= 1 ที่ทำพร้อมกันตอนเจอคู่ มันทิ้งทั้งตัวซ้ายและตัวขวาไปในก้าวเดียว
ทั้งที่ตัวซ้ายตัวนั้นยังจับกับตัวขวาอีกตัวได้อยู่ ในเคส [1, 1, 3, 3] เลข 1 ตัวแรกยังจับกับเลข 3 ตัวแรกได้ แต่เราทิ้งมันไปแล้ว
พูดให้ตรงคือ two pointers ตัดคู่ทิ้งเป็นชุด ไม่ได้ไล่ดูทีละคู่ มันจึงไม่เคยเห็นทุกคู่ตั้งแต่แรก
two pointers ตอบคำถามแบบ "มีคู่แบบนั้นอยู่ไหม" หรือ "คู่ที่ดีที่สุดให้ค่าเท่าไหร่" ได้อย่างถูกต้อง เพราะสองคำถามนี้ต้องการแค่คำตอบสุดท้ายคำตอบเดียว แต่มันตอบคำถามแบบ "มีทั้งหมดกี่คู่" หรือ "ขอรายชื่อทุกคู่" ไม่ได้ เพราะมันไม่เคยเห็นทุกคู่ตั้งแต่แรก ถ้าเจอโจทย์ที่ให้นับหรือให้ลิสต์ออกมาทั้งหมด ต้องเปลี่ยนท่า (ปกติคือ hash map นับความถี่ หรือคิดสูตรนับแทนการไล่)
อ่านย้อนกลับไปที่หัวข้อก่อนหน้าอีกครั้งจะเห็นว่าคำว่า "ไม่พลาดคำตอบ" หมายถึงไม่พลาด คำตอบสุดท้าย ไม่ได้หมายถึงไม่พลาดคู่ใด ๆ เลย — สองอย่างนี้ต่างกัน และการแยกให้ออกคือสิ่งที่กันเราจากการเอาท่านี้ไปใช้ผิดโจทย์
ส่วนที่ 5 · สามแบบที่ต้องแยกให้ออก
คำว่า two pointers ไม่ได้หมายถึงท่าเดียว แต่เป็นสามท่าที่ต่างกันชัดเจน ใช้กับโจทย์คนละแบบ
คนที่ติดหมวดนี้ส่วนใหญ่ไม่ได้ติดเพราะเขียนโค้ดไม่ได้ แต่ติดเพราะจำได้แค่ว่า โจทย์นี้ใช้ two pointers แล้วหยิบท่าผิดมาใช้
แต่ละแบบข้างล่างเขียนด้วยโครงเดียวกัน คือ ภาพให้จำ แล้ว ใช้ตอนไหน แล้ว โครงโค้ด แล้ว ของจริงที่รันได้
แบบที่ 1 · หัวกับท้ายเดินเข้าหากัน
ภาพให้จำ: คนสองคนยืนอยู่หัวแถวกับท้ายแถว แล้วเดินเข้าหากันเรื่อย ๆ จนกว่าจะเจอกันตรงกลาง
ใช้ตอนที่ข้อมูลเรียงแล้วและต้องจับคู่ระหว่างตัวเล็กกับตัวใหญ่ หรือตอนที่ต้องเทียบหัวกับท้าย เช่นเช็คว่าอ่านหน้าหลังเหมือนกันไหม
หัวใจของแบบนี้คือเหตุผลในส่วนที่ 3 คือทุกครั้งที่ขยับ ต้องอธิบายได้ว่าทำไมคู่ที่ทิ้งไปไม่มีทางดีกว่า
left, right = 0, len(nums) - 1
while left < right:
if condition: # ตัดสินใจจากค่าที่ทั้งสองตัวชี้อยู่ตอนนี้
left += 1 # อยากได้ค่าฝั่งซ้ายที่ใหญ่ขึ้น
else:
right -= 1 # อยากได้ค่าฝั่งขวาที่เล็กลงแบบที่ 2 · เดินทางเดียวกัน แต่คนละความเร็ว
ภาพให้จำ: คนคัดของที่ใช้สองมือ มือขวาเดินสำรวจของทุกชิ้นไปข้างหน้าเรื่อย ๆ ส่วนมือซ้ายคอยวางเฉพาะของที่อยากเก็บลงกล่อง
ตัวชี้ทั้งสองเริ่มจากด้านเดียวกันคือหัวแถว แต่เดินไม่เท่ากัน ตัวเร็วชื่อ fast ขยับทุกก้าว ส่วนตัวช้าชื่อ slow ขยับเฉพาะตอนเจอของที่ต้องการเก็บ
ใช้ตอนต้องคัดกรองหรือจัดเรียงของในลิสต์เดิมโดยไม่สร้างลิสต์ใหม่ ซึ่งภาษาอังกฤษเรียกว่าทำ in-place
slow = 0
for fast in range(len(nums)):
if keep(nums[fast]): # ตัวนี้ควรเก็บไว้ไหม
nums[slow] = nums[fast] # ถ้าใช่ เขียนมันลงตำแหน่ง slow
slow += 1
# nums[:slow] คือผลลัพธ์ที่กรองแล้ว (0 ถึงก่อนตำแหน่ง slow)โค้ดข้างบนเป็นแม่แบบ ยังรันไม่ได้ เพราะคำว่า keep ไม่ใช่ฟังก์ชันจริงใน Python มันแทนที่เงื่อนไขของโจทย์แต่ละข้อ
ลองเปลี่ยนเป็นของจริง โดยใช้เงื่อนไขง่ายที่สุดคือเก็บเฉพาะเลขคู่ แล้วให้มันพิมพ์สภาพแถวให้ดูทุกก้าว
nums = [4, 7, 2, 9, 6] # อยากเก็บไว้เฉพาะเลขคู่
slow = 0
for fast in range(len(nums)):
if nums[fast] % 2 == 0:
print(f"เก็บ {nums[fast]}: เขียนลงช่อง slow={slow} (ตอนนี้ fast={fast}) -> slow <= fast ? {slow <= fast}")
nums[slow] = nums[fast]
slow += 1
else:
print(f"ทิ้ง {nums[fast]}: slow ค้างอยู่ที่ {slow} ไม่ขยับ (ตอนนี้ fast={fast})")
print("nums ทั้งแถว =", nums)
print("ผลลัพธ์ =", nums[:slow], "| ขยะท้ายแถว =", nums[slow:])เก็บ 4: เขียนลงช่อง slow=0 (ตอนนี้ fast=0) -> slow <= fast ? True
ทิ้ง 7: slow ค้างอยู่ที่ 1 ไม่ขยับ (ตอนนี้ fast=1)
เก็บ 2: เขียนลงช่อง slow=1 (ตอนนี้ fast=2) -> slow <= fast ? True
ทิ้ง 9: slow ค้างอยู่ที่ 2 ไม่ขยับ (ตอนนี้ fast=3)
เก็บ 6: เขียนลงช่อง slow=2 (ตอนนี้ fast=4) -> slow <= fast ? True
nums ทั้งแถว = [4, 2, 6, 9, 6]
ผลลัพธ์ = [4, 2, 6] | ขยะท้ายแถว = [9, 6]คำถามที่ต้องตอบให้ได้ก่อนจะเชื่อโค้ดนี้: บรรทัด nums[slow] = nums[fast] เขียนทับของในแถวที่ยังกวาดไม่จบ แล้วมันจะไปทับของที่ยังไม่ได้อ่านไหม
คำตอบคือไม่มีทาง และหลักฐานอยู่ในคอลัมน์ slow <= fast ที่พิมพ์ออกมาว่า True ทุกครั้ง
เหตุผลง่ายมาก: slow ขยับ เฉพาะตอนที่เก็บของ ส่วน fast ขยับ ทุกก้าว ดังนั้น slow จึงเดินช้ากว่าหรือเท่ากับ fast เสมอ
ทุกช่องที่ slow เขียนลงไป จึงเป็นช่องที่ fast อ่านผ่านไปแล้ว และไม่มีใครต้องใช้อีก
slow <= fast เสมอ คือ invariant (ข้อเท็จจริงที่จริงตลอดการทำงาน) ที่ทำให้ท่านี้แก้ของในที่เดิมได้อย่างปลอดภัย และเป็นคำตอบเวลา interviewer ถามว่า "ทำไมเขียนทับได้" — สังเกตว่ามันคือเหตุผลเดียวกับ write <= read ในข้อ 9 String Compression เป๊ะ ๆ ท่า read/write กับท่า slow/fast คือท่าเดียวกันคนละชื่อ ต่างกันแค่กฎว่า "ตัวไหนควรเก็บ" เท่านั้น
และสังเกตของท้ายแถวที่เหลือคือ [9, 6] ด้วย ของพวกนี้คือเศษของเดิมที่ไม่ถูกทับ
เลข 6 โผล่สองที่เพราะตัวจริงถูกคัดลอกไปไว้ข้างหน้าแล้ว ส่วนตัวที่ค้างอยู่ท้ายแถวคือของเก่าที่ยังไม่มีใครมาเขียนทับ
โจทย์แนวนี้จึงมักบอกให้ return ความยาว หรือสนใจแค่ nums[:slow] เท่านั้น เราไม่ต้องเสียเวลาล้างท้ายแถวให้สะอาด
แบบที่ 3 · เดินสองแถวไปพร้อมกัน
ภาพให้จำ: คนสองแถวยืนรอเข้าประตูเดียวกัน ประตูปล่อยทีละคน โดยเลือกจากคนหัวแถวของสองฝั่งว่าใครควรออกไปก่อน
ต่างจากแบบที่ 2 ที่ตัวชี้สองตัวอยู่บนแถวเดียวกัน แบบนี้แต่ละตัวอยู่บนแถวของตัวเอง
กฎการเดินคือ ขยับเฉพาะตัวที่ถูกใช้ไปในก้าวนั้น ไม่ได้ขยับพร้อมกันทั้งคู่
ใช้ตอนต้องเทียบหรือรวมข้อมูลสองชุดที่เรียงแล้ว ซึ่งเป็นท่าเดียวกับที่ใช้ในข้อ 1 Merge Strings Alternately ของหมวดก่อนหน้า
a = [1, 4, 7]
b = [2, 3, 9]
i = j = 0
merged = []
while i < len(a) and j < len(b): # ต้องเช็คขอบเขตของ ทั้งสองตัว ก่อนอ่านค่า
if a[i] <= b[j]:
merged.append(a[i])
i += 1 # ขยับแค่ตัวที่ถูกใช้ไป
else:
merged.append(b[j])
j += 1
print(f"i={i} j={j} merged={merged}")
merged += a[i:] # ฝั่งที่ยังเหลือ ต่อท้ายได้เลย
merged += b[j:]
print("ผลลัพธ์:", merged)i=1 j=0 merged=[1]
i=1 j=1 merged=[1, 2]
i=1 j=2 merged=[1, 2, 3]
i=2 j=2 merged=[1, 2, 3, 4]
i=3 j=2 merged=[1, 2, 3, 4, 7]
ผลลัพธ์: [1, 2, 3, 4, 7, 9]จุดที่ต้องจำจากแบบที่ 3 คือเงื่อนไข while i < len(a) and j < len(b)
แบบที่ 1 ไม่มีปัญหานี้ เพราะเงื่อนไข left < right คุมขอบเขตให้อยู่แล้วโดยอัตโนมัติ
แต่แบบที่ 3 เราต้องเช็คขอบเขตเองทั้งสองตัว และต้องเช็ค ก่อน อ่าน a[i] หรือ b[j] เสมอ
ถ้าเขียนเป็น while a[i] <= b[j] เฉย ๆ จะได้ IndexError ทันทีที่ตัวใดตัวหนึ่งเดินสุดแถว
และอีกจุดที่ลืมกันบ่อยคือ พอลูปจบต้องเก็บเศษที่เหลือของฝั่งที่ยังไม่หมดด้วย ไม่ใช่จบแค่ในลูป
| สัญญาณในโจทย์ | ใช้แบบไหน | ตัวอย่างข้อ |
|---|---|---|
| array sorted แล้ว + หาคู่ผลรวม (pair sum) | แบบที่ 1 · opposite ends | LC167, LC1679 |
| เลือกสองตำแหน่งให้ได้ค่ามากสุด / พื้นที่มากสุด | แบบที่ 1 · opposite ends | LC11 |
| palindrome (อ่านหน้าหลังเหมือนกัน) | แบบที่ 1 · opposite ends | LC125, LC345 |
| ย้าย / ลบ / กรองของใน array แบบ in-place | แบบที่ 2 · slow/fast (แถวเดียว) | LC283, LC26, LC27 |
| เทียบสอง sequence ว่าตัวหนึ่งอยู่ในอีกตัวไหม | แบบที่ 3 · same direction (สองแถว) | LC392 |
| รวมสองแถวที่เรียงแล้วเข้าด้วยกัน | แบบที่ 3 · same direction (สองแถว) | LC88 |
ส่วนที่ 6 · เมื่อไหร่ที่ห้ามใช้ two pointers
รู้ว่าใช้เมื่อไหร่แล้ว ต้องรู้ด้วยว่าห้ามใช้เมื่อไหร่ ไม่งั้นเวลาเจอโจทย์ใหม่จะหยิบท่านี้ไปใช้ผิดที่แล้วหาไม่เจอว่าผิดตรงไหน
กรณีที่พลาดบ่อยที่สุดคือโจทย์ที่ต้องตอบเป็น ตำแหน่ง ของข้อมูลเดิม
เพราะแบบที่ 1 ต้องให้ข้อมูลเรียงก่อน แต่การเรียงใหม่ทำลายตำแหน่งเดิมทิ้งทั้งหมด
nums = [3, 9, 4, 1]
target = 12 # อยากได้ index ของคู่ที่บวกกันได้ 12
print("nums เดิม :", nums, "-> คำตอบที่ถูกคือ 3 กับ 9 = index (0, 1)")
s = sorted(nums)
print("หลัง sort :", s, "-> index เดิมของแต่ละตัวหายไปหมดแล้ว")
left, right = 0, len(s) - 1
while left < right:
total = s[left] + s[right]
if total == target:
break
elif total < target:
left += 1
else:
right -= 1
print("two pointers ตอบ index:", (left, right), "<- ผิด เพราะเป็น index ของแถวที่ sort แล้ว")nums เดิม : [3, 9, 4, 1] -> คำตอบที่ถูกคือ 3 กับ 9 = index (0, 1)
หลัง sort : [1, 3, 4, 9] -> index เดิมของแต่ละตัวหายไปหมดแล้ว
two pointers ตอบ index: (1, 3) <- ผิด เพราะเป็น index ของแถวที่ sort แล้วสังเกตว่า ค่า ที่มันหาเจอถูกต้องแล้ว เพราะ 3 บวก 9 ได้ 12 จริง
แต่ตำแหน่งที่ตอบคือ (1, 3) ซึ่งเป็นตำแหน่งในแถวที่เรียงใหม่ ไม่ใช่ (0, 1) ที่โจทย์ต้องการ
นี่คือเหตุผลที่โจทย์ LC1 Two Sum ต้องใช้ hash map ไม่ใช่ two pointers ทั้งที่หน้าตาโจทย์เหมือนตัวอย่างแรกของหน้านี้มาก
ต่างกันแค่คำเดียวคือโจทย์ขอ ตำแหน่ง หรือขอ ค่า ซึ่งเป็นบทเรียนเดียวกับที่เจอในข้อ 8 ของหมวดก่อนหน้า
| สถานการณ์ | ทำไมใช้ไม่ได้ | ต้องใช้อะไรแทน |
|---|---|---|
| ต้อง return index ของข้อมูลเดิม แต่ข้อมูลยังไม่เรียง | sort ก่อนก็ทำลาย index / ไม่ sort ก็ตัดคู่ทิ้งไม่ได้ | hash map จำ (ค่า → index) เช่น LC1 Two Sum |
| ข้อมูลไม่เรียงและ sort ไม่ได้ เพราะลำดับเดิมมีความหมาย | เหตุผลที่ใช้ตัดคู่ทิ้งอาศัยความเรียงล้วน ๆ | hash map, prefix sum, หรือ sliding window ตามรูปโจทย์ |
| ต้องได้ทุกคำตอบ / นับทุกคู่ที่เป็นไปได้ | two pointers ตัดคู่ทิ้งเป็นชุด จึงไม่เคยเห็นทุกคู่ | ถ้าเลี่ยงไม่ได้จริงก็ต้องยอมวนซ้อน หรือเปลี่ยนสูตรการนับ |
หนึ่ง ข้อมูลเรียงอยู่แล้วหรือ sort ได้โดยไม่เสียอะไร (ถ้าโจทย์ขอ index แปลว่า sort ไม่ได้) สอง ตอบได้ไหมว่าทำไมคู่ที่กำลังจะทิ้งไม่มีทางดีกว่าคู่ที่เหลือ ถ้าข้อใดข้อหนึ่งตอบไม่ได้ ให้เปลี่ยนไปคิดท่าอื่น
ส่วนที่ 7 · กับดักที่เจอบ่อย
- ใช้ while left <= right ตอนที่ต้องการคู่ของ ตำแหน่งที่ต่างกัน → จะได้คู่ที่ตัวเองจับตัวเอง (left == right) ซึ่งไม่ถูก ให้ใช้ left < right แทน
- ลืมขยับ pointer ในบางแขนงของ if/else → กลายเป็น infinite loop (ลูปที่ไม่มีวันจบ เพราะตัวแปรไม่เปลี่ยนค่าเลย) ตรวจให้ครบว่าทุกแขนงมีการขยับอย่างน้อยหนึ่งตัว
- ใช้ opposite ends กับ array ที่ยังไม่ได้ sort → คำตอบผิด เพราะเหตุผลที่ใช้ตัดคู่ทิ้งอาศัยความเรียง ต้อง sort ก่อน แต่ต้องเช็คด้วยว่าโจทย์ข้อนั้น sort ได้จริงไหม (ถ้าขอ index เดิม แปลว่า sort ไม่ได้ ดูหัวข้อ "เมื่อไหร่ที่ห้ามใช้" ด้านล่าง)
- คิดว่า two pointers ไล่ดูทุกคู่เหมือน brute force → ผิด มันตัดคู่ทิ้งเป็นชุด ใช้ตอบ "มีไหม / ดีที่สุดเท่าไหร่" ได้ แต่ตอบ "มีกี่คู่ / ขอทุกคู่" ไม่ได้
- ในแบบที่ 3 อ่าน a[i] หรือ b[j] ก่อนเช็คว่า i กับ j ยังอยู่ในขอบเขตของแถวตัวเอง → เกิด IndexError (error ที่บอกว่าเราพยายามอ่านตำแหน่งที่ไม่มีอยู่จริงในลิสต์) ต้องเอาเงื่อนไขขอบเขตไว้ซ้ายสุดของ while เสมอ
หมวดนี้มี 4 ข้อ เรียงจากง่ายไปยาก: Move Zeroes → Is Subsequence → Container With Most Water → Max Number of K-Sum Pairs กดถัดไปเลย
ทบทวนก่อนไปทำโจทย์
ถ้าตอบห้าข้อนี้ได้ด้วยคำพูดของตัวเอง แปลว่าพร้อมแล้ว ถ้าข้อไหนตอบไม่ได้ ให้กลับไปอ่านส่วนที่วงเล็บไว้
- ทำไมการขยับตัวชี้ทิ้งข้อมูลไปทั้งชุด จึงไม่พลาดคำตอบ และเงื่อนไขที่ทำให้มันไม่พลาดคืออะไร (ส่วนที่ 3)
- ทำไม two pointers ตอบคำถามแบบ มีไหม หรือ ดีที่สุดเท่าไหร่ ได้ แต่ตอบ มีทั้งหมดกี่คู่ ไม่ได้ (ส่วนที่ 4)
- สามแบบต่างกันยังไง และแต่ละแบบตัวชี้อยู่ที่ไหน เดินยังไง (ส่วนที่ 5)
- ทำไม slow ไม่เคยแซง fast และเรื่องนี้ทำให้เขียนทับแถวเดิมได้อย่างปลอดภัยยังไง (แบบที่ 2)
- ถ้าโจทย์ขอตำแหน่งของข้อมูลเดิม ทำไมจึงห้ามเรียงข้อมูลใหม่ (ส่วนที่ 6)
10 Move Zeroes ใช้แบบที่ 2 (คัดของในแถวเดิม) · 11 Is Subsequence ใช้แบบที่ 3 (เดินสองแถว) · 12 Container With Most Water ใช้แบบที่ 1 (หัวท้ายเข้าหากัน) · 13 Max K-Sum Pairs ใช้แบบที่ 1 หลังเรียงข้อมูล หรือใช้ hash map ก็ได้ ลองสังเกตตอนทำว่าตรงกับที่คาดไว้ไหม