เซตย่อยและเพาเวอร์เซต
สร้างเซตย่อยทั้งหมดของเซตด้วยการย้อนกลับและการใช้บิตมาสก์ โดยจัดเรียงข้อมูลและข้ามสมาชิกที่ซ้ำกัน
เซตย่อยและเพาเวอร์เซต เป็นบทเรียน Coding Interview Prep ฟรีบน CoddyKit นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน Coding Interview Prep และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส Coding 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) และปลดล็อคส่วนที่เหลือของคอร์ส Coding Interview Prep ให้อัปเกรดเป็น CoddyKit PRO คอร์ส Coding Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน
คุณจะเรียนรู้อะไรในบทเรียน “เซตย่อยและเพาเวอร์เซต”
สร้างเซตย่อยทั้งหมดของเซตด้วยการย้อนกลับและการใช้บิตมาสก์ โดยจัดเรียงข้อมูลและข้ามสมาชิกที่ซ้ำกัน คุณปฏิบัติ Coding Interview Prep ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน
คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน Coding Interview Prep หรือไม่
ไม่จำเป็นต้องมีประสบการณ์มาก่อน Coding Interview Prep บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน
บทเรียน “เซตย่อยและเพาเวอร์เซต” ใช้เวลานานแค่ไหน
บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย
ฉันเขียนและรันโค้ดในบทเรียน Coding Interview Prep นี้ได้ไหม
ได้ บทเรียน Coding Interview Prep ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ
บทเรียนทั้งหมดในหลักสูตรนี้
- แม่แบบการย้อนรอย: เลือก สำรวจ ยกเลิกการเลือก
- เซตย่อยและเพาเวอร์เซต
- การเรียงสับเปลี่ยนและการจัดหมู่
- N-ควีนและการเผยแพร่ข้อจำกัด