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

ข้อ 7 · LC238 Product of Array Except Self 🟡

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

สร้าง array ที่ answer[i] คือผลคูณของทุกตัวใน nums ยกเว้น nums[i] — ห้ามใช้การหาร และต้อง O(n)

ให้ลิสต์ nums จง return ลิสต์ answer ที่ยาวเท่ากัน โดย answer[i] คือผลคูณของสมาชิกทุกตัวใน nums ยกเว้นตัวที่อยู่ตำแหน่ง i

ห้ามใช้การหาร และต้องทำให้เสร็จในเวลา O(n)

Example 1
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
Example 2
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
Constraints (ข้อจำกัด)
  • 2 <= nums.length <= 10^5
  • -30 <= nums[i] <= 30
  • ห้ามใช้การหาร และต้องทำใน O(n)
  • ท้าทาย: ใช้พื้นที่เพิ่มแค่ O(1) โดยไม่นับลิสต์คำตอบ
⏸ ลองเองก่อน 20 นาที

ข้อนี้เป็นข้อแรกในหมวดที่ต้องคิดจริง วิธีที่คิดออกก่อนมักเป็นการคูณทุกตัวแล้วหาร ซึ่งโจทย์ห้ามไว้ ถ้าติดตรงนั้นให้เปิดใบ้ขั้นที่ 1

💡 ใบ้ขั้นที่ 1 — คิดจากตำแหน่งเดียวก่อน

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

มันคือ ผลคูณของทุกตัวทางซ้ายของ i คูณกับ ผลคูณของทุกตัวทางขวาของ i ซึ่งรวมกันครอบคลุมทุกตัวยกเว้น i พอดี

💡 ใบ้ขั้นที่ 2 — สองค่านั้นหาได้ไม่ต้องวนซ้อน

ผลคูณฝั่งซ้ายของทุกตำแหน่ง หาได้ด้วยการกวาดจากซ้ายไปขวารอบเดียว โดยสะสมค่าไปเรื่อย ๆ

ผลคูณฝั่งขวาก็ทำแบบเดียวกันแต่กวาดย้อนจากขวามาซ้าย รวมสองรอบยังเป็น O(n)

💡 ใบ้ขั้นที่ 3 — โครงที่เติมให้เกือบครบ
python
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

วิธีหารพังเมื่อเจอเลข 0python
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]), "  <- ทำงานได้ปกติ")
Output
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 · เดินตามโค้ดทีละสเต็ป

โค้ดข้างล่างพิมพ์การสะสมทุกก้าว ให้ดูว่าค่าใหม่มาจากค่าเดิมคูณอะไร

ไล่ทีละก้าวทั้งสองรอบ แล้วรวมกันpython
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)
Output
  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 · สองลิสต์ (อ่านง่าย)

คำตอบสำหรับวางใน LeetCodepython
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) พื้นที่เพิ่ม)

คำตอบสำหรับวางใน LeetCodepython
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 เป็นค่ากลางของการคูณ ตำแหน่งริมที่ไม่มีเพื่อนบ้านจึงได้ 1 พอดี
  2. รอบแรกสะสมจากซ้ายไปขวา ใช้ nums[i-1] เพื่อไม่นับตัวเอง
  3. รอบสองเดินจากขวามาซ้าย แบบที่ 2 ใช้ตัวแปร right แทนลิสต์ทั้งก้อน
  4. บรรทัด 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