ข้อ 7 · LC238 Product of Array Except Self 🟡
สร้าง array ที่ answer[i] คือผลคูณของทุกตัวใน nums ยกเว้น nums[i] — ห้ามใช้การหาร และต้อง O(n)
ให้ลิสต์ nums จง return ลิสต์ answer ที่ยาวเท่ากัน โดย answer[i] คือผลคูณของสมาชิกทุกตัวใน nums ยกเว้นตัวที่อยู่ตำแหน่ง i
ห้ามใช้การหาร และต้องทำให้เสร็จในเวลา O(n)
- Input:
- nums = [1,2,3,4]
- Output:
- [24,12,8,6]
- Explanation:
- answer[0] = 2×3×4 = 24, answer[1] = 1×3×4 = 12, answer[2] = 1×2×4 = 8, answer[3] = 1×2×3 = 6
- Input:
- nums = [-1,1,0,-3,3]
- Output:
- [0,0,9,0,0]
- Explanation:
- มี 0 อยู่หนึ่งตัว → ทุกตำแหน่งที่ไม่ใช่ตำแหน่งของ 0 จะมี 0 ร่วมวงคูณอยู่ด้วยเสมอ จึงได้ 0 มีแค่ตำแหน่งของ 0 เองที่ไม่โดน 0 คูณ → ได้ (-1)×1×(-3)×3 = 9
- 2 <= nums.length <= 10^5
- -30 <= nums[i] <= 30
- ห้ามใช้การหาร และต้องทำใน O(n)
- ท้าทาย: ใช้พื้นที่เพิ่มแค่ O(1) โดยไม่นับลิสต์คำตอบ
ข้อนี้เป็นข้อแรกในหมวดที่ต้องคิดจริง วิธีที่คิดออกก่อนมักเป็นการคูณทุกตัวแล้วหาร ซึ่งโจทย์ห้ามไว้ ถ้าติดตรงนั้นให้เปิดใบ้ขั้นที่ 1
💡 ใบ้ขั้นที่ 1 — คิดจากตำแหน่งเดียวก่อน
โฟกัสที่ตำแหน่งเดียวก่อน สมมติยืนอยู่ที่ตำแหน่ง i คำตอบของตำแหน่งนี้ประกอบจากอะไรบ้าง
มันคือ ผลคูณของทุกตัวทางซ้ายของ i คูณกับ ผลคูณของทุกตัวทางขวาของ i ซึ่งรวมกันครอบคลุมทุกตัวยกเว้น i พอดี
💡 ใบ้ขั้นที่ 2 — สองค่านั้นหาได้ไม่ต้องวนซ้อน
ผลคูณฝั่งซ้ายของทุกตำแหน่ง หาได้ด้วยการกวาดจากซ้ายไปขวารอบเดียว โดยสะสมค่าไปเรื่อย ๆ
ผลคูณฝั่งขวาก็ทำแบบเดียวกันแต่กวาดย้อนจากขวามาซ้าย รวมสองรอบยังเป็น O(n)
💡 ใบ้ขั้นที่ 3 — โครงที่เติมให้เกือบครบ
n = len(nums)
prefix = [1] * n
for i in range(1, n):
prefix[i] = prefix[i-1] * nums[___] # ตัวที่อยู่ ก่อน i
suffix = [1] * n
for i in range(n-2, -1, -1):
suffix[i] = suffix[i+1] * nums[___] # ตัวที่อยู่ หลัง i
return [prefix[i] * suffix[i] for i in range(n)]🧭 กล่องสอน — ไล่ตั้งแต่ศูนย์ทีละขั้น (เปิดเมื่อพร้อม)
ขั้นที่ 1 · วิธีแรกที่ทุกคนคิดออก และทำไมโจทย์ห้าม
วิธีแรกที่คิดออกคือคูณทุกตัวให้ได้ผลรวมก่อน แล้วเอาไปหารด้วย nums[i] ทีละตำแหน่ง ซึ่งเร็วและสั้นมาก
โจทย์ห้ามใช้การหารไว้ตรง ๆ แต่เหตุผลที่ลึกกว่านั้นคือ วิธีนั้นพังจริงเมื่อในลิสต์มีเลข 0
def wrong(nums):
total = 1
for x in nums:
total *= x
return [total // x for x in nums]
def right(nums):
n = len(nums)
pre = [1]*n
suf = [1]*n
for i in range(1, n):
pre[i] = pre[i-1] * nums[i-1]
for i in range(n-2, -1, -1):
suf[i] = suf[i+1] * nums[i+1]
return [pre[i]*suf[i] for i in range(n)]
print("nums = [1,2,3,4]")
print(" หารได้ ->", wrong([1,2,3,4]), "| กวาดสองรอบ ->", right([1,2,3,4]))
print()
print("nums = [-1,1,0,-3,3] (มีเลข 0)")
try:
print(" หาร ->", wrong([-1,1,0,-3,3]))
except ZeroDivisionError as e:
print(" หาร -> ZeroDivisionError:", e, " <- พังเพราะหารด้วย 0")
print(" กวาดสองรอบ ->", right([-1,1,0,-3,3]), " <- ทำงานได้ปกติ")nums = [1,2,3,4]
หารได้ -> [24, 12, 8, 6] | กวาดสองรอบ -> [24, 12, 8, 6]
nums = [-1,1,0,-3,3] (มีเลข 0)
หาร -> ZeroDivisionError: integer division or modulo by zero <- พังเพราะหารด้วย 0
กวาดสองรอบ -> [0, 0, 9, 0, 0] <- ทำงานได้ปกติเคสแรกให้ผลตรงกัน ทำให้คนคิดว่าวิธีหารใช้ได้ แต่เคสที่สองพังทันที
และถ้าจะฝืนแก้ด้วยการนับจำนวน 0 แล้วแยกเคส โค้ดจะยาวและมีเงื่อนไขเยอะขึ้นเรื่อย ๆ จนไม่คุ้ม
ขั้นที่ 2 · กุญแจของข้อนี้
กุญแจคือเปลี่ยนมุมมองจาก ผลคูณทั้งหมดแล้วเอาตัวเองออก มาเป็น ประกอบขึ้นจากสองฝั่ง
ให้นึกภาพว่าเรายืนอยู่ที่ตำแหน่งหนึ่งในแถว หันซ้ายคูณทุกตัวที่เห็น หันขวาคูณทุกตัวที่เห็น แล้วเอาสองค่ามาคูณกัน
ผลคูณสองฝั่งนี้รวมกันครอบคลุมทุกตัวยกเว้นตัวที่เรายืนอยู่พอดี จึงไม่ต้องหารอะไรเลย
และเราไม่ต้องหันซ้ายหันขวาใหม่ทุกตำแหน่ง เพราะค่าฝั่งซ้ายของตำแหน่งถัดไป ก็คือค่าฝั่งซ้ายของตำแหน่งนี้คูณด้วยตัวที่เพิ่งเดินผ่าน
ขั้นที่ 3 · เดินตามโค้ดทีละสเต็ป
โค้ดข้างล่างพิมพ์การสะสมทุกก้าว ให้ดูว่าค่าใหม่มาจากค่าเดิมคูณอะไร
nums = [1, 2, 3, 4]
n = len(nums)
prefix = [1]*n
for i in range(1, n):
prefix[i] = prefix[i-1] * nums[i-1]
print(f" prefix[{i}] = prefix[{i-1}]({prefix[i-1]}) x nums[{i-1}]({nums[i-1]}) = {prefix[i]}")
print("prefix =", prefix)
suffix = [1]*n
for i in range(n-2, -1, -1):
suffix[i] = suffix[i+1] * nums[i+1]
print(f" suffix[{i}] = suffix[{i+1}]({suffix[i+1]}) x nums[{i+1}]({nums[i+1]}) = {suffix[i]}")
print("suffix =", suffix)
ans = [prefix[i]*suffix[i] for i in range(n)]
for i in range(n):
print(f" answer[{i}] = {prefix[i]} x {suffix[i]} = {ans[i]} (ทุกตัวยกเว้น nums[{i}]={nums[i]})")
print("คำตอบ:", ans) prefix[1] = prefix[0](1) x nums[0](1) = 1
prefix[2] = prefix[1](1) x nums[1](2) = 2
prefix[3] = prefix[2](2) x nums[2](3) = 6
prefix = [1, 1, 2, 6]
suffix[2] = suffix[3](1) x nums[3](4) = 4
suffix[1] = suffix[2](4) x nums[2](3) = 12
suffix[0] = suffix[1](12) x nums[1](2) = 24
suffix = [24, 12, 4, 1]
answer[0] = 1 x 24 = 24 (ทุกตัวยกเว้น nums[0]=1)
answer[1] = 1 x 12 = 12 (ทุกตัวยกเว้น nums[1]=2)
answer[2] = 2 x 4 = 8 (ทุกตัวยกเว้น nums[2]=3)
answer[3] = 6 x 1 = 6 (ทุกตัวยกเว้น nums[3]=4)
คำตอบ: [24, 12, 8, 6]สังเกตว่า prefix[0] เป็น 1 และ suffix[3] เป็น 1 เพราะตำแหน่งริมไม่มีอะไรอยู่ทางนั้น และค่า 1 คือค่ากลางของการคูณที่ไม่เปลี่ยนผลลัพธ์
จุดที่คนพลาดคือ index ในการคูณ ต้องเป็น nums[i-1] ไม่ใช่ nums[i] เพราะเราต้องการทุกตัวที่อยู่ ก่อน i ไม่รวมตัว i เอง
ขั้นที่ 4 · รับคำท้าเรื่อง O(1) พื้นที่เพิ่ม
โจทย์ท้าให้ใช้พื้นที่เพิ่มแค่ O(1) โดยไม่นับลิสต์คำตอบ ซึ่งทำได้ด้วยการยุบสองลิสต์ให้เหลือหนึ่ง
วิธีคือใช้ลิสต์คำตอบเก็บค่า prefix ไปก่อนในรอบแรก แล้วรอบสองใช้ตัวแปรเดียวเดินย้อนกลับ คูณค่า suffix เข้าไปทับทีละตำแหน่ง
เทคนิคนี้ใช้ได้เพราะตอนเดินย้อนกลับ ตำแหน่งที่เรากำลังคูณทับ เราอ่านค่า prefix ของมันไปแล้วเรียบร้อย จึงไม่มีข้อมูลไหนหาย
เขียนแบบสองลิสต์ให้ทำงานถูกก่อน แล้วบอกกรรมการว่าจะยุบเหลือลิสต์เดียวได้และอธิบายว่าทำยังไง วิธีนี้ปลอดภัยกว่าการรีบเขียนแบบยุบแล้วสับสนกลางทาง
🔓 เฉลยเต็ม ทั้งสองแบบ (ลองเองก่อนนะ)พับไว้ด้านใน — คลิกเมื่อพร้อมดู
แบบที่ 1 · สองลิสต์ (อ่านง่าย)
class Solution:
def productExceptSelf(self, nums: List[int]) -> List[int]:
n = len(nums)
prefix = [1] * n # prefix[i] = ผลคูณของทุกตัว "ก่อน" i
suffix = [1] * n # suffix[i] = ผลคูณของทุกตัว "หลัง" i
for i in range(1, n):
prefix[i] = prefix[i-1] * nums[i-1] # nums[i-1] ไม่ใช่ nums[i]
for i in range(n-2, -1, -1):
suffix[i] = suffix[i+1] * nums[i+1] # nums[i+1] ไม่ใช่ nums[i]
return [prefix[i] * suffix[i] for i in range(n)]แบบที่ 2 · ลิสต์เดียว (ตอบคำท้า O(1) พื้นที่เพิ่ม)
class Solution:
def productExceptSelf(self, nums: List[int]) -> List[int]:
n = len(nums)
answer = [1] * n
# รอบที่ 1 — ใช้ answer เก็บ prefix ไปก่อน
for i in range(1, n):
answer[i] = answer[i-1] * nums[i-1]
# รอบที่ 2 — เดินย้อนกลับ คูณ suffix ทับเข้าไปด้วยตัวแปรเดียว
right = 1
for i in range(n-1, -1, -1):
answer[i] *= right # อ่าน prefix ของตำแหน่งนี้ไปแล้ว จึงทับได้
right *= nums[i] # สะสม suffix ไว้ให้ตำแหน่งถัดไปทางซ้าย
return answerอ่านโค้ดทีละส่วน
- ค่าเริ่มต้นเป็น 1 ทุกช่อง เพราะ 1 เป็นค่ากลางของการคูณ ตำแหน่งริมที่ไม่มีเพื่อนบ้านจึงได้ 1 พอดี
- รอบแรกสะสมจากซ้ายไปขวา ใช้ nums[i-1] เพื่อไม่นับตัวเอง
- รอบสองเดินจากขวามาซ้าย แบบที่ 2 ใช้ตัวแปร right แทนลิสต์ทั้งก้อน
- บรรทัด answer[i] *= right ทำก่อน right *= nums[i] เสมอ ถ้าสลับกันจะนับตัวเองเข้าไปด้วย
ต้นทุน
ทั้งสองแบบเวลา O(n) เพราะกวาดสองรอบ · แบบที่ 1 ใช้พื้นที่เพิ่ม O(n) จากสองลิสต์ · แบบที่ 2 ใช้พื้นที่เพิ่ม O(1) เพราะมีแค่ตัวแปร right ตัวเดียว
เช็คลิสต์ก่อนกดส่ง
- ไม่มีเครื่องหมายหารในโค้ดเลย
- ทดสอบด้วยลิสต์ที่มีเลข 0 หนึ่งตัว เช่น [-1,1,0,-3,3]
- ทดสอบด้วยลิสต์ที่มีเลข 0 สองตัว ซึ่งคำตอบต้องเป็น 0 ทุกช่อง
- ตรวจ index ว่าใช้ nums[i-1] และ nums[i+1] ไม่ใช่ nums[i]
- ในแบบที่ 2 ตรวจลำดับว่าคูณลง answer ก่อนแล้วจึงอัปเดต right