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

Two Pointers — พื้นฐาน & แนวคิด

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

ใช้ 'ตัวแปรจำตำแหน่ง' สองตัวเดินไล่บน 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 บ่อยมาก มันคือการเขียนสองบรรทัดรวมกันในบรรทัดเดียว

python
left = 0
right = len(nums) - 1

# เขียนย่อรวมกันเป็นบรรทัดเดียวได้ ความหมายเหมือนกันทุกประการ:
left, right = 0, len(nums) - 1
ทำไมต้องรู้เรื่องนี้ก่อน

ถ้าอ่านบรรทัดนี้ไม่ออก โค้ดที่เหลือทั้งหน้าจะดูงงไปหมด ทั้งที่จริง ๆ มันคือการประกาศตัวแปรสองตัวธรรมดา แค่เขียนย่อ · และค่า len(nums) - 1 คือตำแหน่งของตัวสุดท้าย ต้องลบหนึ่งเพราะตำแหน่งเริ่มนับจาก 0

ส่วนที่ 2 · ทำไมมันเร็วขึ้น ดูของจริงก่อน

โจทย์ตัวอย่าง: มีลิสต์ตัวเลขที่เรียงจากน้อยไปมากแล้ว อยากรู้ว่ามีเลขคู่ไหนบวกกันได้ค่าเป้าหมายที่กำหนดไหม

เขียนสองวิธีแล้วนับจำนวนครั้งที่ต้องเทียบ เพื่อดูว่าต่างกันแค่ไหน

วิธีที่ 1: brute force (ลองทุกคู่แบบตรง ๆ)python
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, "ครั้ง")
Output
เจอคู่: 3 11
เจอคู่: 6 8
compare ทั้งหมด 15 ครั้ง

โค้ดข้างบนไม่มีอะไรใหม่ แค่ for สองชั้นซ้อนกัน

ปัญหาคือมันเทียบ ทุกคู่ที่เป็นไปได้ โดยไม่สนใจว่าคู่นั้นมีโอกาสเป็นคำตอบหรือไม่ ลิสต์มี 6 ตัว จึงต้องเทียบถึง 15 ครั้ง

วิธีที่ 2: two pointers (ตัวแปรสองตัวไล่เข้าหากัน)python
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, "ครั้ง")
Output
เจอคู่: 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 ชุดนั้นไม่มีเลขซ้ำเท่านั้น ลองใส่เลขซ้ำเข้าไปดูว่าเกิดอะไรขึ้น

เลขซ้ำทำให้สองวิธีให้คำตอบต่างกันpython
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)
Output
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

two pointers ตอบคำถามแบบ "มีคู่แบบนั้นอยู่ไหม" หรือ "คู่ที่ดีที่สุดให้ค่าเท่าไหร่" ได้อย่างถูกต้อง เพราะสองคำถามนี้ต้องการแค่คำตอบสุดท้ายคำตอบเดียว แต่มันตอบคำถามแบบ "มีทั้งหมดกี่คู่" หรือ "ขอรายชื่อทุกคู่" ไม่ได้ เพราะมันไม่เคยเห็นทุกคู่ตั้งแต่แรก ถ้าเจอโจทย์ที่ให้นับหรือให้ลิสต์ออกมาทั้งหมด ต้องเปลี่ยนท่า (ปกติคือ hash map นับความถี่ หรือคิดสูตรนับแทนการไล่)

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

ส่วนที่ 5 · สามแบบที่ต้องแยกให้ออก

คำว่า two pointers ไม่ได้หมายถึงท่าเดียว แต่เป็นสามท่าที่ต่างกันชัดเจน ใช้กับโจทย์คนละแบบ

คนที่ติดหมวดนี้ส่วนใหญ่ไม่ได้ติดเพราะเขียนโค้ดไม่ได้ แต่ติดเพราะจำได้แค่ว่า โจทย์นี้ใช้ two pointers แล้วหยิบท่าผิดมาใช้

แต่ละแบบข้างล่างเขียนด้วยโครงเดียวกัน คือ ภาพให้จำ แล้ว ใช้ตอนไหน แล้ว โครงโค้ด แล้ว ของจริงที่รันได้

แบบที่ 1 · หัวกับท้ายเดินเข้าหากัน

ภาพให้จำ: คนสองคนยืนอยู่หัวแถวกับท้ายแถว แล้วเดินเข้าหากันเรื่อย ๆ จนกว่าจะเจอกันตรงกลาง

ใช้ตอนที่ข้อมูลเรียงแล้วและต้องจับคู่ระหว่างตัวเล็กกับตัวใหญ่ หรือตอนที่ต้องเทียบหัวกับท้าย เช่นเช็คว่าอ่านหน้าหลังเหมือนกันไหม

หัวใจของแบบนี้คือเหตุผลในส่วนที่ 3 คือทุกครั้งที่ขยับ ต้องอธิบายได้ว่าทำไมคู่ที่ทิ้งไปไม่มีทางดีกว่า

python
left, right = 0, len(nums) - 1
while left < right:
    if condition:      # ตัดสินใจจากค่าที่ทั้งสองตัวชี้อยู่ตอนนี้
        left += 1      # อยากได้ค่าฝั่งซ้ายที่ใหญ่ขึ้น
    else:
        right -= 1     # อยากได้ค่าฝั่งขวาที่เล็กลง

แบบที่ 2 · เดินทางเดียวกัน แต่คนละความเร็ว

ภาพให้จำ: คนคัดของที่ใช้สองมือ มือขวาเดินสำรวจของทุกชิ้นไปข้างหน้าเรื่อย ๆ ส่วนมือซ้ายคอยวางเฉพาะของที่อยากเก็บลงกล่อง

ตัวชี้ทั้งสองเริ่มจากด้านเดียวกันคือหัวแถว แต่เดินไม่เท่ากัน ตัวเร็วชื่อ fast ขยับทุกก้าว ส่วนตัวช้าชื่อ slow ขยับเฉพาะตอนเจอของที่ต้องการเก็บ

ใช้ตอนต้องคัดกรองหรือจัดเรียงของในลิสต์เดิมโดยไม่สร้างลิสต์ใหม่ ซึ่งภาษาอังกฤษเรียกว่าทำ in-place

python
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 มันแทนที่เงื่อนไขของโจทย์แต่ละข้อ

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

slow/fast ของจริง — กรองเก็บเฉพาะเลขคู่ในแถวเดิม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:])
Output
เก็บ 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 อ่านผ่านไปแล้ว และไม่มีใครต้องใช้อีก

จุดที่ต้องเข้าใจให้ได้ก่อนไปข้อ 10

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 ของหมวดก่อนหน้า

แบบที่ 3 — รวมสองแถวที่เรียงแล้วให้เป็นแถวเดียวpython
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)
Output
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 endsLC167, LC1679
เลือกสองตำแหน่งให้ได้ค่ามากสุด / พื้นที่มากสุดแบบที่ 1 · opposite endsLC11
palindrome (อ่านหน้าหลังเหมือนกัน)แบบที่ 1 · opposite endsLC125, LC345
ย้าย / ลบ / กรองของใน array แบบ in-placeแบบที่ 2 · slow/fast (แถวเดียว)LC283, LC26, LC27
เทียบสอง sequence ว่าตัวหนึ่งอยู่ในอีกตัวไหมแบบที่ 3 · same direction (สองแถว)LC392
รวมสองแถวที่เรียงแล้วเข้าด้วยกันแบบที่ 3 · same direction (สองแถว)LC88

ส่วนที่ 6 · เมื่อไหร่ที่ห้ามใช้ two pointers

รู้ว่าใช้เมื่อไหร่แล้ว ต้องรู้ด้วยว่าห้ามใช้เมื่อไหร่ ไม่งั้นเวลาเจอโจทย์ใหม่จะหยิบท่านี้ไปใช้ผิดที่แล้วหาไม่เจอว่าผิดตรงไหน

กรณีที่พลาดบ่อยที่สุดคือโจทย์ที่ต้องตอบเป็น ตำแหน่ง ของข้อมูลเดิม

เพราะแบบที่ 1 ต้องให้ข้อมูลเรียงก่อน แต่การเรียงใหม่ทำลายตำแหน่งเดิมทิ้งทั้งหมด

หลักฐานว่า sort ทำลายคำตอบ (นี่คือ LC1 Two Sum ที่ขอ index)python
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 แล้ว")
Output
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 ตัดคู่ทิ้งเป็นชุด จึงไม่เคยเห็นทุกคู่ถ้าเลี่ยงไม่ได้จริงก็ต้องยอมวนซ้อน หรือเปลี่ยนสูตรการนับ
คำถามคัดกรอง 2 ข้อ ก่อนหยิบ 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 กดถัดไปเลย

ทบทวนก่อนไปทำโจทย์

ถ้าตอบห้าข้อนี้ได้ด้วยคำพูดของตัวเอง แปลว่าพร้อมแล้ว ถ้าข้อไหนตอบไม่ได้ ให้กลับไปอ่านส่วนที่วงเล็บไว้

  1. ทำไมการขยับตัวชี้ทิ้งข้อมูลไปทั้งชุด จึงไม่พลาดคำตอบ และเงื่อนไขที่ทำให้มันไม่พลาดคืออะไร (ส่วนที่ 3)
  2. ทำไม two pointers ตอบคำถามแบบ มีไหม หรือ ดีที่สุดเท่าไหร่ ได้ แต่ตอบ มีทั้งหมดกี่คู่ ไม่ได้ (ส่วนที่ 4)
  3. สามแบบต่างกันยังไง และแต่ละแบบตัวชี้อยู่ที่ไหน เดินยังไง (ส่วนที่ 5)
  4. ทำไม slow ไม่เคยแซง fast และเรื่องนี้ทำให้เขียนทับแถวเดิมได้อย่างปลอดภัยยังไง (แบบที่ 2)
  5. ถ้าโจทย์ขอตำแหน่งของข้อมูลเดิม ทำไมจึงห้ามเรียงข้อมูลใหม่ (ส่วนที่ 6)
ข้อไหนใช้แบบไหน

10 Move Zeroes ใช้แบบที่ 2 (คัดของในแถวเดิม) · 11 Is Subsequence ใช้แบบที่ 3 (เดินสองแถว) · 12 Container With Most Water ใช้แบบที่ 1 (หัวท้ายเข้าหากัน) · 13 Max K-Sum Pairs ใช้แบบที่ 1 หลังเรียงข้อมูล หรือใช้ hash map ก็ได้ ลองสังเกตตอนทำว่าตรงกับที่คาดไว้ไหม