ข้อ 9 · LC443 String Compression 🟡
ย่อกลุ่มตัวอักษรซ้ำติดกันใน chars แบบ in-place (เช่น a,a,b,b,c,c,c → a,2,b,2,c,3) แล้วคืนความยาวใหม่
ให้ลิสต์ของตัวอักษรชื่อ chars โดยหนึ่งช่องเก็บได้หนึ่งตัวอักษร จงย่อมันให้สั้นลง
วิธีย่อคือมองหาตัวอักษรเดิมที่ซ้ำกันและอยู่ติดกันเป็นกลุ่ม แล้วเขียนแทนกลุ่มนั้นด้วยตัวอักษรหนึ่งตัวตามด้วยจำนวนที่นับได้ ถ้ากลุ่มไหนมีแค่ตัวเดียว ให้เขียนแค่ตัวอักษร ไม่ต้องเขียนเลข 1
ต้องแก้ลงใน chars เดิม ห้ามสร้างลิสต์ใหม่มาช่วย และ return ความยาวใหม่ ไม่ใช่ return ลิสต์
ถ้าจำนวนที่นับได้มีหลายหลัก เช่น 12 ต้องแยกลงสองช่อง ช่องหนึ่งเก็บ "1" อีกช่องเก็บ "2"
- Input:
- chars = ["a","a","b","b","c","c","c"]
- Output:
- 6, chars 6 ช่องแรกกลายเป็น ["a","2","b","2","c","3"]
- Explanation:
- กลุ่ม a ยาว 2 → a2, กลุ่ม b ยาว 2 → b2, กลุ่ม c ยาว 3 → c3 ต่อกันได้ a2b2c3 ยาว 6 ช่อง
- Input:
- chars = ["a"]
- Output:
- 1, chars = ["a"]
- Explanation:
- กลุ่มเดียวและยาวแค่ 1 จึงเขียนแค่ "a" ห้ามเขียน "a1"
- Input:
- chars = ["a","b","b","b","b","b","b","b","b","b","b","b","b"]
- Output:
- 4, chars 4 ช่องแรกกลายเป็น ["a","b","1","2"]
- Explanation:
- กลุ่ม a ยาว 1 → เขียนแค่ "a" ส่วนกลุ่ม b ยาว 12 → เขียน "b" แล้วแตกเลข 12 ลงสองช่องเป็น "1" และ "2"
- 1 <= chars.length <= 2000
- chars[i] เป็นตัวอักษรพิมพ์เล็ก พิมพ์ใหญ่ หรือตัวเลข 0-9
- ต้องแก้ใน chars เอง และใช้พื้นที่เพิ่มแค่ O(1)
ข้อนี้เป็นข้อปิดหมวดและรวมทุกอย่างที่เรียนมา ถ้ายังไม่มั่นใจ แนะนำให้ทำข้ออื่นให้ครบก่อนแล้วกลับมา เพราะมันต้องเขียนทับลิสต์เดิมระหว่างที่ยังอ่านไม่จบ ซึ่งฟังดูเหมือนทำไม่ได้
💡 ใบ้ขั้นที่ 1 — แยกสองหน้าที่ออกจากกัน
งานของข้อนี้มีสองหน้าที่ที่เดินคนละจังหวะ คือ อ่านของเดิม กับ เขียนผลลัพธ์
ถ้าใช้ตัวแปรตัวเดียวคุมทั้งสองหน้าที่จะสับสนแน่ ให้ใช้สองตัวแยกกัน เรียกว่า read กับ write
💡 ใบ้ขั้นที่ 2 — จังหวะการเดินของแต่ละตัว
read เดินไปข้างหน้าเพื่อนับว่ากลุ่มนี้ยาวเท่าไหร่ พอนับจบก็จะไปหยุดที่หัวกลุ่มถัดไปพอดี
write ขยับเฉพาะตอนที่เขียนของลงไปจริง ซึ่งจะช้ากว่า read เสมอเมื่อมีการบีบอัด
💡 ใบ้ขั้นที่ 3 — โครงทั้งหมด
write = read = 0
n = len(chars)
while read < n:
ch = chars[read]
count = 0
while read < n and chars[read] == ch: # นับกลุ่มนี้ให้จบก่อน
read += 1
count += 1
chars[write] = ch
write += 1
if count > ___: # เขียนเลขเมื่อไหร่
for d in ___(count): # เลขหลายหลักต้องแตกทีละตัว
chars[write] = d
write += 1
return write🧭 กล่องสอน — ไล่ตั้งแต่ศูนย์ทีละขั้น (เปิดเมื่อพร้อม)
ขั้นที่ 1 · โจทย์นี้ขออะไรจริง ๆ
สิ่งแรกที่ต้องสังเกตคือ โจทย์ให้ chars เป็น ลิสต์ของตัวอักษร ไม่ใช่ string ซึ่งไม่ใช่เรื่องบังเอิญ
เพราะโจทย์บังคับให้แก้ในที่เดิม และ string ในภาษาส่วนใหญ่แก้ในที่เดิมไม่ได้ (ย้อนไปดูส่วนที่ 1 ของหน้าแนวคิด) จึงต้องใช้ลิสต์
อย่างที่สองคือ return ความยาวใหม่ ไม่ใช่ลิสต์ แปลว่าของที่ค้างอยู่ท้ายลิสต์หลังตำแหน่งนั้นไม่มีใครสนใจ เราไม่ต้องล้างให้สะอาด
ขั้นที่ 2 · ปัญหาที่ดูเหมือนทำไม่ได้
ความยากอยู่ที่เราต้องเขียนผลลัพธ์ลงในลิสต์เดียวกับที่ยังอ่านไม่จบ ฟังดูเหมือนต้องทับข้อมูลที่ยังไม่ได้อ่านแน่ ๆ
ทางออกคือใช้ตัวชี้สองตัวเดินคนละจังหวะ ให้นึกภาพคนคัดของที่ใช้สองมือ
มือขวาคือ read หยิบของขึ้นมาดูทีละชิ้นและเดินไปข้างหน้าเรื่อย ๆ · มือซ้ายคือ write คอยวางของที่ย่อแล้วลงในแถวเดิมจากช่องแรก
เพราะของที่ย่อแล้วมีจำนวนน้อยกว่าหรือเท่ากับของที่ดูไปแล้ว มือซ้ายจึงเดินช้ากว่ามือขวาเสมอ และไม่มีทางไปวางทับของที่ยังไม่ได้หยิบ
ขั้นที่ 3 · เดินตามโค้ดทีละสเต็ป
โค้ดข้างล่างพิมพ์ค่า write กับ read ทุกกลุ่ม ให้ดูคอลัมน์สุดท้ายที่ยืนยันว่า write ไม่เคยแซง read
chars = ["a","a","b","b","c","c","c"]
write = read = 0
n = len(chars)
while read < n:
ch = chars[read]
count = 0
while read < n and chars[read] == ch:
read += 1; count += 1
print(f" กลุ่ม {ch!r} ยาว {count} (read เดินถึง {read})")
chars[write] = ch; write += 1
if count > 1:
for d in str(count):
chars[write] = d; write += 1
print(f" เขียนแล้ว -> {chars[:write]} | write={write} read={read} | write<=read? {write<=read}")
print("ความยาวใหม่ =", write, "| ผลลัพธ์ =", chars[:write]) กลุ่ม 'a' ยาว 2 (read เดินถึง 2)
เขียนแล้ว -> ['a', '2'] | write=2 read=2 | write<=read? True
กลุ่ม 'b' ยาว 2 (read เดินถึง 4)
เขียนแล้ว -> ['a', '2', 'b', '2'] | write=4 read=4 | write<=read? True
กลุ่ม 'c' ยาว 3 (read เดินถึง 7)
เขียนแล้ว -> ['a', '2', 'b', '2', 'c', '3'] | write=6 read=7 | write<=read? True
ความยาวใหม่ = 6 | ผลลัพธ์ = ['a', '2', 'b', '2', 'c', '3']สังเกตสองกลุ่มแรก write เท่ากับ read พอดี เพราะกลุ่มยาว 2 เขียนเป็น a2 ก็ใช้ 2 ช่องเท่าเดิม ไม่ได้ประหยัดแต่ก็ไม่ล้น
กลุ่มที่สามยาว 3 เขียนเป็น c3 ใช้แค่ 2 ช่อง ตรงนี้จึงเริ่มประหยัดจริง และ write ตามหลัง read ห่างขึ้น
ผลลัพธ์ของกลุ่มยาว k ตัวไม่เคยกินที่เกิน k ช่อง ไล่ดูได้: k=1 เขียนตัวอักษรเดียวใช้ 1 ช่อง · k=2 เขียน a2 ใช้ 2 ช่อง · k ตั้งแต่ 3 ขึ้นไปเขียนสั้นกว่า k เสมอ และเราเขียนหลังจาก read เดินผ่านกลุ่มนั้นจบแล้ว จึงทับได้แค่ช่องที่อ่านไปแล้ว
ขั้นที่ 4 · กับดักที่ 1 — เขียนเลข 1 ต่อท้าย
โจทย์บอกว่ากลุ่มที่มีตัวเดียวให้เขียนแค่ตัวอักษร ถ้าเผลอเขียนเลขต่อท้ายทุกกลุ่ม ผลลัพธ์จะยาวกว่าต้นฉบับและล้นแถวทันที
def compress(chars, always_write_count):
chars = chars[:]
w = r = 0
n = len(chars)
while r < n:
ch = chars[r]
c = 0
while r < n and chars[r] == ch:
r += 1
c += 1
chars[w] = ch
w += 1
if always_write_count or c > 1:
for d in str(c):
chars[w] = d # ถ้าผลลัพธ์ยาวกว่าต้นฉบับ บรรทัดนี้จะล้นแถว
w += 1
return w, chars[:w]
for cs in [["a"], ["a", "b", "c"]]:
print(f"chars={cs}")
try:
print(" เขียนเลขทุกกลุ่ม ->", compress(cs, True))
except IndexError as e:
print(" เขียนเลขทุกกลุ่ม -> IndexError:", e)
print(" เพราะ 'a' กลายเป็น 'a1' ซึ่งยาว 2 ช่อง แต่แถวเดิมมีแค่", len(cs), "ช่อง")
print(" เขียนเลขเมื่อยาวเกิน 1 ->", compress(cs, False), " <- ถูก")chars=['a']
เขียนเลขทุกกลุ่ม -> IndexError: list assignment index out of range
เพราะ 'a' กลายเป็น 'a1' ซึ่งยาว 2 ช่อง แต่แถวเดิมมีแค่ 1 ช่อง
เขียนเลขเมื่อยาวเกิน 1 -> (1, ['a']) <- ถูก
chars=['a', 'b', 'c']
เขียนเลขทุกกลุ่ม -> IndexError: list assignment index out of range
เพราะ 'a' กลายเป็น 'a1' ซึ่งยาว 2 ช่อง แต่แถวเดิมมีแค่ 3 ช่อง
เขียนเลขเมื่อยาวเกิน 1 -> (3, ['a', 'b', 'c']) <- ถูกIndexError ที่เห็นไม่ใช่เรื่องบังเอิญ แต่เป็นหลักฐานว่าเงื่อนไข count > 1 คือสิ่งที่ทำให้ทั้งวิธีนี้ปลอดภัย
ขั้นที่ 5 · กับดักที่ 2 — เลขหลายหลัก
ถ้ากลุ่มยาว 12 ตัว เราเขียนเลข 12 ลงช่องเดียวไม่ได้ เพราะหนึ่งช่องเก็บได้ตัวอักษรเดียว
ทางแก้คือแปลงเลขเป็น string ด้วย str(count) แล้ววนใส่ทีละหลัก ซึ่งได้ "1" และ "2" คนละช่อง
chars = ["a"] + ["b"]*12
print("chars =", chars, f"(กลุ่ม b ยาว 12)")
w = r = 0; n = len(chars); out = chars[:]
while r < n:
ch = out[r]; c = 0
while r < n and out[r] == ch: r += 1; c += 1
out[w] = ch; w += 1
if c > 1:
for d in str(c):
out[w] = d; w += 1
print("ผลลัพธ์ =", out[:w], "| ความยาว =", w)
print('สังเกตว่า 12 ถูกแยกเป็น "1" กับ "2" คนละช่อง เพราะหนึ่งช่องเก็บได้ตัวเดียว')chars = ['a', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b'] (กลุ่ม b ยาว 12)
ผลลัพธ์ = ['a', 'b', '1', '2'] | ความยาว = 4
สังเกตว่า 12 ถูกแยกเป็น "1" กับ "2" คนละช่อง เพราะหนึ่งช่องเก็บได้ตัวเดียวจุดที่ควรเห็นคือ 13 ช่องถูกย่อเหลือ 4 ช่อง ซึ่งเป็นเหตุผลว่าทำไมท่านี้ปลอดภัยยิ่งขึ้นเมื่อกลุ่มยิ่งยาว
🔓 เฉลยเต็ม พร้อมอธิบายทีละบรรทัด (ลองเองก่อนนะ)พับไว้ด้านใน — คลิกเมื่อพร้อมดู
class Solution:
def compress(self, chars: List[str]) -> int:
write = 0 # ตำแหน่งที่จะเขียนผลลัพธ์ตัวถัดไป
read = 0 # ตำแหน่งที่กำลังอ่านของเดิม
n = len(chars)
while read < n:
ch = chars[read]
count = 0
# นับกลุ่มนี้ให้จบก่อน พอออกจากลูปนี้ read จะไปหยุดที่หัวกลุ่มถัดไปพอดี
while read < n and chars[read] == ch:
read += 1
count += 1
chars[write] = ch # เขียนตัวอักษรของกลุ่ม
write += 1
if count > 1: # เขียนเลขเฉพาะกลุ่มที่ยาวเกิน 1 ไม่งั้นผลลัพธ์จะยาวกว่าเดิม
for d in str(count): # เลขหลายหลักต้องแตกทีละหลัก
chars[write] = d
write += 1
return write # ความยาวใหม่ ของที่ค้างท้ายแถวไม่ต้องสนใจอ่านโค้ดทีละส่วน
- write กับ read แยกกันคนละตัว เพราะทำหน้าที่ต่างกันและเดินคนละจังหวะ
- ลูปในนับกลุ่มให้จบก่อนเขียนอะไรลงไป ทำให้ตอนเขียน read เดินผ่านกลุ่มนั้นไปแล้วเรียบร้อย
- เงื่อนไข read < n ในลูปในต้องอยู่ซ้ายสุด เพื่อไม่ให้อ่าน chars[read] เกินขอบลิสต์
- count > 1 เป็นเงื่อนไขที่รับประกันว่าผลลัพธ์ไม่ยาวกว่าต้นฉบับ จึงไม่ล้นแถว
- str(count) แปลงเลขเป็นข้อความเพื่อวนใส่ทีละหลัก รองรับกลุ่มที่ยาวเป็นสิบเป็นร้อย
- return write คืนความยาวใหม่ ตามที่โจทย์ขอ
ต้นทุน
เวลา O(n) เพราะ read เดินหน้าเท่านั้นไม่เคยย้อนกลับ จึงแตะแต่ละช่องครั้งเดียว · หน่วยความจำเพิ่ม O(1) เพราะใช้ตัวแปรไม่กี่ตัวและเขียนทับในลิสต์เดิม
เช็คลิสต์ก่อนกดส่ง
- ทดสอบ ["a"] ต้องได้ 1 และ chars ยังเป็น ["a"] ไม่ใช่ "a1"
- ทดสอบ ["a","b","c"] ที่ไม่มีกลุ่มซ้ำเลย ต้องได้ 3 และไม่มีเลขโผล่มา
- ทดสอบกลุ่มที่ยาวเกิน 9 เพื่อเช็คว่าแตกเลขหลายหลักถูก
- ตรวจว่ามีเงื่อนไข count > 1 ก่อนเขียนเลข
- ตรวจว่า return ความยาว ไม่ใช่ return ลิสต์
ตัวชี้สองตัวเดินตามกันบนแถวเดียวคือท่าเดียวกับที่หมวด Two Pointers เรียกว่า slow กับ fast pointer ชื่อต่างกันแต่กลไกเดียวกันเป๊ะ ถ้าเข้าใจข้อนี้แล้ว หมวดถัดไปจะง่ายขึ้นมาก