0Pricing
DSA Interview Prep · บทเรียน

เซตย่อยและเพาเวอร์เซต

สร้างเซตย่อยทั้งหมดของเซตด้วยการย้อนกลับและการใช้บิตมาสก์ โดยจัดเรียงข้อมูลและข้ามสมาชิกที่ซ้ำกัน

เซตย่อยและเพาเวอร์เซต เป็นบทเรียน DSA Interview Prep ฟรีบน CoddyKit นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน DSA Interview Prep และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส DSA Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน

เซตย่อยและเพาเวอร์เซต

เพาเวอร์เซตของเซต S คือกลุ่มของเซตย่อยที่เป็นไปได้ทั้งหมดของ S ซึ่งรวมเซตว่างและ S เองด้วย เซตที่มีองค์ประกอบ n ตัวจะมีเซตย่อยทั้งหมด בדיוק 2ⁿ ชุด สำหรับ [1, 2, 3] เซตย่อยทั้ง 8 ชุดคือ: [], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3] นี่คือปัญหาพื้นฐานด้านการนับเชิงจัดหมู่ ซึ่งปรากฏในคำถามสัมภาษณ์เกี่ยวกับการค้นหาชุดผสม การแบ่งส่วน หรือทางเลือกที่เป็นไปได้ทั้งหมด

# A set of n elements → 2^n subsets
for n in range(5):
    print(f'n={n}: {2**n} subsets')
# n=0: 1  (just the empty set)
# n=1: 2  ([], [x])
# n=2: 4  ([], [a], [b], [a,b])
# n=3: 8  (as enumerated above)
# n=4: 16

การสร้างเซตย่อยด้วยการย้อนกลับ

ใช้แม่แบบเลือก-สำรวจ-ยกเลิกการเลือก การตัดสินใจด้านการออกแบบที่สำคัญคือ ในการเรียกซ้ำแต่ละครั้ง ให้เพิ่มเส้นทางบางส่วนปัจจุบันลงในผลลัพธ์ทันที (ก่อนเลือกองค์ประกอบเพิ่มเติม) ด้วยวิธีนี้ ทุกสถานะ — ว่าง บางส่วน และเต็ม — จะถูกเก็บเป็นเซตย่อยที่ถูกต้อง เลื่อนดัชนี start เพื่อพิจารณาเฉพาะองค์ประกอบทางขวาขององค์ประกอบที่เลือกครั้งล่าสุดเท่านั้น ซึ่งช่วยให้ไม่มีค่าซ้ำและรักษาลำดับไว้

def subsets(nums):
    result = []
    def backtrack(start, path):
        result.append(list(path))   # every state is a valid subset
        for i in range(start, len(nums)):
            path.append(nums[i])    # CHOOSE
            backtrack(i + 1, path)  # EXPLORE (advance start)
            path.pop()              # UNCHOOSE
    backtrack(0, [])
    return result

print(subsets([1, 2, 3]))
# [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

แนวทางการใช้บิตมาสก์

ทางเลือกอื่นนอกเหนือจากการย้อนกลับคือการใช้บิตมาสก์: เซตย่อยแต่ละชุดสอดคล้องกับตัวเลข n บิต โดยบิต i ที่เป็น 1 หมายถึงมีการรวมองค์ประกอบ i ไว้ ให้ทำซ้ำตั้งแต่ 0 ถึง 2ⁿ - 1 และสำหรับแต่ละตัวเลข ให้แยกบิตออกมาเพื่อสร้างเซตย่อย วิธีนี้เป็นแบบวนซ้ำ มักเร็วกว่าในทางปฏิบัติ และเขียนโค้ดได้ง่ายมาก อย่างไรก็ตาม วิธีนี้ไม่สามารถขยายไปยังปัญหาที่มีข้อจำกัด (เช่น ขีดจำกัดผลรวม) ได้อย่างเป็นธรรมชาติเท่าใดนัก

def subsets_bitmask(nums):
    n = len(nums)
    result = []
    for mask in range(1 << n):  # 0 to 2^n - 1
        subset = []
        for i in range(n):
            if mask & (1 << i):  # bit i is set
                subset.append(nums[i])
        result.append(subset)
    return result

print(subsets_bitmask([1, 2, 3]))
# Same 8 subsets, order may differ

การสร้างเซตย่อยแบบวนซ้ำ

แนวทางแบบวนซ้ำจะค่อย ๆ สร้างเพาเวอร์เซตทีละองค์ประกอบ เริ่มด้วย [[] ] (เซตว่าง) สำหรับองค์ประกอบใหม่แต่ละตัว ให้ทำสำเนาเซตย่อยที่มีอยู่ทั้งหมด แล้วใช้ append องค์ประกอบใหม่ต่อท้ายสำเนาแต่ละชุด หลังประมวลผลองค์ประกอบ n ตัวแล้ว ผลลัพธ์จะมีเซตย่อยทั้งหมด 2ⁿ ชุด วิธีนี้เทียบเท่ากับการใช้บิตมาสก์ แต่ผู้ที่ไม่คุ้นเคยกับการดำเนินการระดับบิตจะอ่านได้ง่ายกว่า

def subsets_iterative(nums):
    result = [[]]  # start with empty set
    for num in nums:
        # For each existing subset, create a new subset with num added
        result += [subset + [num] for subset in result]
    return result

print(subsets_iterative([1, 2, 3]))
# After num=1: [[], [1]]
# After num=2: [[], [1], [2], [1,2]]
# After num=3: [[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]

เซตย่อย II: การจัดการค่าซ้ำ

เมื่อข้อมูลเข้ามีค่าซ้ำ แนวทางแบบตรงไปตรงมาจะสร้างเซตย่อยซ้ำ สำหรับ [1, 2, 2] การปรากฏของ 2 ทั้งสองครั้งจะสร้าง [1, 2] แยกจากกัน วิธีแก้คือจัดเรียงอาร์เรย์ก่อนด้วย sort จากนั้นข้ามตัวเลือกในระดับปัจจุบัน หากตัวเลือกนั้นเท่ากับตัวเลือกก่อนหน้าในระดับเดียวกัน โดยเฉพาะในลูป: if i > start and nums[i] == nums[i-1]: continue

def subsets_with_dups(nums):
    nums.sort()  # sort to group duplicates together
    result = []
    def backtrack(start, path):
        result.append(list(path))
        for i in range(start, len(nums)):
            # Skip duplicates at the same tree level
            if i > start and nums[i] == nums[i-1]:
                continue
            path.append(nums[i])
            backtrack(i + 1, path)
            path.pop()
    backtrack(0, [])
    return result

print(subsets_with_dups([1, 2, 2]))
# [[], [1], [1,2], [1,2,2], [2], [2,2]]  — no duplicate subsets

เหตุใดการข้ามค่าซ้ำจึงทำงานได้

เงื่อนไข i > start and nums[i] == nums[i-1] จะข้ามค่าซ้ำเฉพาะที่ ระดับการเรียกซ้ำเดียวกัน (มี start เดียวกัน) เท่านั้น ไม่ได้ป้องกันการเลือกค่าเดียวกันที่ระดับความลึกต่างกัน สำหรับ [1, 2, 2]: ที่ระดับ 0 เราเลือก 2 ตัวแรก (ดัชนี 1) จากนั้นที่ระดับถัดไป (start=2) เราเลือก 2 ตัวที่สองเพื่อสร้าง [2, 2] แต่หากพยายามเลือก 2 ตัวที่สองซ้ำอีกครั้งที่ระดับ 0 เงื่อนไขนี้จะตรวจพบและข้ามไป

# Visual: [1, 2, 2] sorted
# Level 0 (start=0): pick nothing, pick 1, pick first-2, pick second-2 (SKIP)
# Level 1 after picking 1 (start=1): pick first-2, pick second-2 (SKIP)
# Level 2 after picking 1,first-2 (start=2): pick second-2
# → [1,2,2] is generated but only once

nums = [1, 2, 2]
nums.sort()
result_set = set(tuple(sorted(s)) for s in subsets_with_dups(nums[:]))
result_naive = set(tuple(sorted(s)) for s in subsets(nums))
print('With dedup:', sorted(result_set))
print('Same results:', result_set == result_naive)

def subsets(nums):
    result = []
    def bt(start, path):
        result.append(list(path))
        for i in range(start, len(nums)):
            path.append(nums[i]); bt(i+1, path); path.pop()
    bt(0, [])
    return result

def subsets_with_dups(nums):
    result = []
    def bt(start, path):
        result.append(list(path))
        for i in range(start, len(nums)):
            if i > start and nums[i] == nums[i-1]: continue
            path.append(nums[i]); bt(i+1, path); path.pop()
    bt(0, [])
    return result

print(len(subsets_with_dups([1,2,2])), 'unique subsets')  # 6

เซตย่อยขนาดคงที่ (การจัดหมู่ขนาด k)

การสร้าง subsets ที่มีขนาดเท่ากับ k เท่านั้น (LeetCode 77: การจัดหมู่) จะเพิ่มเงื่อนไขยุติก่อนกำหนด หากสมาชิกที่เหลือไม่สามารถเติมเส้นทางให้มีขนาด k ได้ ก็ให้ตัดกิ่งทิ้ง เงื่อนไขที่ใช้ตัดกิ่งคือ i > n - (k - len(path)): หากมีสมาชิกเหลือไม่พอ ให้หยุดก่อน เงื่อนไขนี้ช่วยลดพื้นที่ค้นหาได้อย่างมากเมื่อเทียบกับการสร้าง subsets ทั้งหมดแล้วคัดกรองภายหลัง

def combine(n, k):
    result = []
    def backtrack(start, path):
        if len(path) == k:
            result.append(list(path))
            return
        # Prune: need (k - len(path)) more elements from [start..n]
        # At most (n - start + 1) elements remain
        if n - start + 1 < k - len(path):
            return  # not enough elements left
        for i in range(start, n + 1):
            path.append(i)
            backtrack(i + 1, path)
            path.pop()
    backtrack(1, [])
    return result

print(combine(4, 2))  # [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
print(len(combine(10, 3)))  # C(10,3) = 120

การประยุกต์ใช้เพาเวอร์เซต

รูปแบบเพาเวอร์เซตปรากฏในโจทย์สัมภาษณ์หลายรูปแบบ: (1) แบ่งเป็นเซตย่อยสองเซตที่มีขนาดเท่ากัน — ตรวจสอบว่ามีเซตย่อยใดมีผลรวมเท่ากับ total/2 หรือไม่ (2) ค่า XOR สูงสุดของเซตย่อยสองชุด — ลองจับคู่เซตย่อยทุกคู่ (3) ต้นทุนต่ำสุดในการเลือกสมาชิก k รายการ — แจกแจงเซตย่อยขนาด k แม้การแจกแจงโดยตรงจะมีความซับซ้อนแบบเอ็กซ์โพเนนเชียล แต่โจทย์จำนวนมากในกลุ่มนี้สามารถใช้คำตอบแบบ DP ได้เมื่อมองเห็นโครงสร้างของโจทย์ การมองโจทย์ในรูปแบบเพาเวอร์เซตช่วยให้ระบุพื้นที่สถานะได้ แม้ภายหลังจะปรับให้มีประสิทธิภาพมากขึ้นก็ตาม

def max_subset_sum(nums, k):
    '''Maximum sum of any k elements (for comparison: O(n log n) alternative)'''
    # Backtracking approach: enumerate all k-subsets
    max_s = [float('-inf')]
    def bt(start, path, curr_sum):
        if len(path) == k:
            max_s[0] = max(max_s[0], curr_sum)
            return
        remaining_spots = k - len(path)
        for i in range(start, len(nums)):
            if len(nums) - i < remaining_spots: break  # prune
            bt(i+1, path+[nums[i]], curr_sum+nums[i])
    bt(0, [], 0)
    return max_s[0]

# Much faster: just sort and take top k
def max_subset_sum_fast(nums, k):
    return sum(sorted(nums, reverse=True)[:k])

nums = [3, 1, 4, 1, 5, 9, 2, 6]
print(max_subset_sum(nums, 3))       # 20 (9+6+5)
print(max_subset_sum_fast(nums, 3))  # 20

การตรวจสอบผลรวมของเซตย่อย

ผลรวมของเซตย่อย ตั้งคำถามว่า มีเซตย่อยใดของอาร์เรย์ที่มีผลรวมเท่ากับเป้าหมายหรือไม่ ปัญหานี้แก้ได้ด้วยการย้อนกลับ (เอ็กซ์โพเนนเชียล) หรือ DP (พหุนาม) วิธีการย้อนกลับตรงไปตรงมา แต่จะใช้งานจริงได้ยากเมื่ออินพุตมีขนาดใหญ่ ส่วนวิธี DP (ตารางบูลีน dp[target+1]) เป็นแนวทางที่ควรเลือกใช้ในการสัมภาษณ์ การเข้าใจทั้งสองวิธีช่วยให้คุณอธิบายข้อแลกเปลี่ยนได้ว่า การย้อนกลับให้คำตอบทั้งหมด ขณะที่ DP ตอบปัญหาการตัดสินใจได้อย่างมีประสิทธิภาพ

# Backtracking version: finds a subset if it exists
def subset_sum_bt(nums, target):
    def bt(start, remaining):
        if remaining == 0: return True
        if remaining < 0 or start == len(nums): return False
        # Include nums[start]
        if bt(start + 1, remaining - nums[start]): return True
        # Exclude nums[start]
        return bt(start + 1, remaining)
    return bt(0, target)

# DP version: O(n * target) time
def subset_sum_dp(nums, target):
    dp = {0}
    for num in nums:
        dp |= {s + num for s in dp}
    return target in dp

print(subset_sum_bt([3, 1, 4, 1, 5], 6))  # True (1+5 or 1+1+4)
print(subset_sum_dp([3, 1, 4, 1, 5], 6))  # True

ความซับซ้อนของการแจกแจงเซตย่อย

การสร้าง subsets ทั้งหมดมีความซับซ้อนด้าน time เป็น O(n × 2ⁿ) อย่างหลีกเลี่ยงไม่ได้ — มี subsets จำนวน 2ⁿ ชุด และแต่ละชุดมีขนาดเฉลี่ย n/2 ไม่มีอัลกอริทึมใดทำได้ดีกว่านี้เมื่อร้องขอ subsets ทั้งหมด หากโจทย์ต้องการเพียงเซตย่อยหนึ่งชุดที่มีคุณสมบัติบางอย่าง (เช่น ผลรวมสูงสุด) ควรเลือกใช้ DP หรือวิธีโลภ ข้อสังเกตสำคัญในการสัมภาษณ์คือ ต้องถามเสมอว่าจำเป็นต้อง แจกแจง subsets ทั้งหมดหรือเพียงตรวจสอบว่า มีเซตย่อยใด ที่ตรงตามเงื่อนไขหรือไม่ คำตอบจะเป็นตัวกำหนดว่ายอมรับความซับซ้อนแบบเอ็กซ์โพเนนเชียลหรือพหุนามได้หรือไม่

import time

def count_subsets(n):
    nums = list(range(n))
    result = []
    def bt(start, path):
        result.append(None)  # count without storing
        for i in range(start, len(nums)):
            path.append(i); bt(i+1, path); path.pop()
    bt(0, [])
    return len(result)

for n in [10, 15, 20]:
    start = time.time()
    cnt = count_subsets(n)
    elapsed = time.time() - start
    print(f'n={n}: {cnt} subsets ({2**n} expected) in {elapsed:.3f}s')

เปรียบเทียบแนวทางทั้งสาม

สำหรับการสร้าง subsets ทั้งหมด: การย้อนกลับ ปรับใช้กับโจทย์ได้กว้างที่สุด และปรับให้รองรับค่าซ้ำหรือข้อจำกัดต่าง ๆ ได้ง่าย การใช้มาสก์บิต กระชับและรวดเร็ว แต่จำกัดอยู่ที่ n ≤ 30 (ตามขนาดของจำนวนเต็ม) แบบวนซ้ำ เข้าใจง่ายและไม่ต้องมีค่าใช้จ่ายจากการเรียกซ้ำ ทั้งสามวิธีให้ผลลัพธ์ขนาด O(n × 2ⁿ) ในการสัมภาษณ์ การย้อนกลับแสดงให้เห็นว่าคุณเข้าใจกระบวนการตัดสินใจแบบเรียกซ้ำ ซึ่งนำไปประยุกต์กับโจทย์ที่ยากขึ้นได้ เมื่ออธิบายแนวทาง ควรกล่าวถึงทั้งสามวิธี

# All three approaches for [1,2,3]
nums = [1, 2, 3]

# 1. Backtracking
def bt(start, path, res):
    res.append(list(path))
    for i in range(start, len(nums)):
        path.append(nums[i]); bt(i+1, path, res); path.pop()
res1 = []; bt(0, [], res1)

# 2. Bit masking
res2 = [[nums[i] for i in range(len(nums)) if mask & (1<<i)]
        for mask in range(1<<len(nums))]

# 3. Iterative
res3 = [[]]
for num in nums:
    res3 += [s+[num] for s in res3]

print('All produce', len(nums)**2, '-ish subsets:',
      len(res1), len(res2), len(res3))  # all 8

ตรวจสอบความเข้าใจ

ทดสอบความเข้าใจแนวคิดเรื่องโครงสร้างข้อมูล & อัลกอริทึม — การเตรียมตัวสัมภาษณ์การเขียนโปรแกรมจากบทเรียนนี้

สรุปบทเรียน

ในบทเรียนนี้ คุณได้เรียนรู้ว่า: การย้อนกลับจะสร้าง subsets ทั้งหมดโดยเพิ่มเส้นทางบางส่วนแต่ละเส้นทางลงในผลลัพธ์ก่อนสำรวจต่อ ค่าซ้ำจัดการได้ด้วยการเรียงลำดับและข้ามค่าที่ซ้ำกันในระดับความลึกของการเรียกซ้ำเดียวกันด้วยเงื่อนไข i > start and nums[i] == nums[i-1] และ การใช้มาสก์บิตเป็นทางเลือกแบบวนซ้ำที่กระชับ โดยแต่ละเซตย่อยจะแทนด้วยมาสก์บิตที่ไม่ซ้ำกัน บทถัดไปเราจะจัดการกับการเรียงสับเปลี่ยนและการจัดหมู่ ซึ่งเป็นโจทย์การแจกแจงที่เกี่ยวข้องกันแต่มีข้อจำกัดแตกต่างกัน

คำถามที่พบบ่อย

บทเรียน “เซตย่อยและเพาเวอร์เซต” ฟรีหรือไม่

ใช่ — ข้อความเต็มของ “เซตย่อยและเพาเวอร์เซต” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส DSA Interview Prep ให้อัปเกรดเป็น CoddyKit PRO คอร์ส DSA Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน

คุณจะเรียนรู้อะไรในบทเรียน “เซตย่อยและเพาเวอร์เซต”

สร้างเซตย่อยทั้งหมดของเซตด้วยการย้อนกลับและการใช้บิตมาสก์ โดยจัดเรียงข้อมูลและข้ามสมาชิกที่ซ้ำกัน คุณปฏิบัติ DSA Interview Prep ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน

คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน DSA Interview Prep หรือไม่

ไม่จำเป็นต้องมีประสบการณ์มาก่อน DSA Interview Prep บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน

บทเรียน “เซตย่อยและเพาเวอร์เซต” ใช้เวลานานแค่ไหน

บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย

ฉันเขียนและรันโค้ดในบทเรียน DSA Interview Prep นี้ได้ไหม

ได้ บทเรียน DSA Interview Prep ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ

บทเรียนทั้งหมดในหลักสูตรนี้

  1. แม่แบบการย้อนรอย: เลือก สำรวจ ยกเลิกการเลือก
  2. เซตย่อยและเพาเวอร์เซต
  3. การเรียงสับเปลี่ยนและการจัดหมู่
  4. N-ควีนและการเผยแพร่ข้อจำกัด
← กลับไปที่ DSA Interview Prep