Valmistautuminen ohjelmointihaastatteluihin · Oppitunti

Osajoukot ja potenssijoukko

Tuottakaa joukon kaikki osajoukot backtrackingilla ja bittimaskeilla sekä käsitelkää duplikaatit lajittelemalla ja ohittamalla toistuvat alkiot.

Oppitunti 2/413 vaihetta

Osajoukot ja potenssijoukko on ilmainen Valmistautuminen ohjelmointihaastatteluihin-oppitunti CoddyKitissä. Tämä on oppitunti 2/4. Voit lukea koko oppitunnin alta ilmaiseksi ja harjoitella sen jälkeen käytännössä selaimessa sisäänrakennetulla koodieditorilla ja ympäri vuorokauden käytettävissä olevan tekoälytuutorin avulla. Oppitunti kuuluu Valmistautuminen ohjelmointihaastatteluihin-oppimispolkuun, ja edistymisesi synkronoituu verkon ja CoddyKit-sovelluksen välillä. Valmistautuminen ohjelmointihaastatteluihin-kurssilla on yhteensä 4 oppituntia.

Osajoukot ja potenssijoukko

Joukon S potenssijoukko on kaikkien S:n mahdollisten osajoukkojen kokoelma, johon kuuluvat sekä tyhjä joukko että S itse. Joukolle, jossa on n alkiota, on täsmälleen 2ⁿ osajoukkoa. Joukon [1, 2, 3] kahdeksan osajoukkoa ovat: [], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]. Tämä on perustavanlaatuinen kombinatorinen ongelma, joka esiintyy haastattelutehtävissä, joissa etsitään kaikkia mahdollisia yhdistelmiä, osituksia tai valintoja.

# 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

Osajoukkojen muodostaminen backtrackingilla

Käyttäkää Choose–Explore–Unchoose-mallipohjaa. Keskeinen suunnittelupäätös on lisätä nykyinen osittainen polku tuloksiin välittömästi jokaisessa rekursiivisessa kutsussa (ennen uusien alkioiden valitsemista). Näin jokainen tila — tyhjä, osittainen ja täydellinen — tallennetaan kelvollisena osajoukkona. Siirtäkää start-indeksiä niin, että tarkastelette vain viimeksi valitun alkion oikealla puolella olevia alkioita. Näin vältetään duplikaatit ja säilytetään järjestys.

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]]

Bittimaskiin perustuva lähestymistapa

Vaihtoehto backtrackingille on bittimaskaus: jokaista osajoukkoa vastaa n-bittinen luku, jossa bitin i arvo 1 tarkoittaa, että alkio i sisältyy osajoukkoon. Käykää luvut 0:sta 2ⁿ - 1:een ja poimikaa kustakin luvusta bitit osajoukon muodostamista varten. Menetelmä on iteratiivinen, usein käytännössä nopeampi ja erittäin helppo ohjelmoida. Se ei kuitenkaan yleisty yhtä luontevasti rajoitteita sisältäviin ongelmiin, kuten summan ylärajaan.

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

Osajoukkojen iteratiivinen muodostaminen

Iteratiivinen menetelmä rakentaa potenssijoukon alkio kerrallaan. Aloittakaa arvosta [[] ] (tyhjästä joukosta). Jokaisen uuden alkion kohdalla monistakaa kaikki olemassa olevat osajoukot ja liittäkää uusi alkio kuhunkin kopioon. Kun n alkiota on käsitelty, tuloksissa ovat kaikki 2ⁿ osajoukkoa. Tämä vastaa bittimaskausta, mutta on helpompi lukea, jos bittikohtaiset operaatiot eivät ole entuudestaan tuttuja.

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]]

Osajoukot II: duplikaattien käsittely

Kun syöte sisältää duplikaatteja, naiivi menetelmä muodostaa samoja osajoukkoja useita kertoja. Joukolle [1, 2, 2] kakkosen molemmat esiintymät muodostaisivat itsenäisesti osajoukon [1, 2]. Korjatkaa tämä lajittelemalla taulukko ensin ja ohittamalla ehdokas nykyisellä tasolla, jos se on sama kuin edellinen ehdokas samalla tasolla. Silmukassa tämä tarkoittaa: 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

Miksi kaksoiskappaleiden ohitus toimii

Ehto i > start and nums[i] == nums[i-1] ohittaa kaksoiskappaleen vain samalla rekursiotasolla (samalla start-arvolla). Se ei estä saman arvon valitsemista eri syvyyksillä. Esimerkiksi tapauksessa [1, 2, 2]: tasolla 0 sisällytämme ensimmäisen kakkosen (indeksi 1), ja seuraavalla tasolla (start=2) sisällytämme toisen kakkosen muodostaaksemme joukon [2, 2]. Jos kuitenkin yrittäisimme sisällyttää toisen kakkosen uudelleen tasolla 0, ehto tunnistaisi sen ja ohittaisi sen.

# 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

Kiinteän koon osajoukot (k-yhdistelmät)

Kun muodostetaan vain täsmälleen k:n alkion osajoukkoja (LeetCode 77: Combinations), haku voidaan lopettaa aikaisin: jos jäljellä olevat alkiot eivät riitä polun kasvattamiseen k:n kokoiseksi, haara karsitaan. Karsintaehto on i > n - (k - len(path)): jos jäljellä ei ole tarpeeksi alkioita, haku lopetetaan ajoissa. Tämä pienentää hakutilaa huomattavasti verrattuna kaikkien osajoukkojen muodostamiseen ja niiden suodattamiseen.

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

Tehojoukon sovellukset

Tehojoukkokaava esiintyy monissa haastattelutehtävien muunnelmissa: (1) Jakaminen kahteen yhtä suureen osajoukkoon — tarkistetaan, summautuuko jonkin osajoukon summa arvoon total/2. (2) Kahden osajoukon suurin XOR — kokeillaan kaikkia osajoukkopareja. (3) k alkion valitsemisen vähimmäiskustannus — luetellaan k-alkion osajoukot. Vaikka suora luettelointi on eksponentiaalista, moniin näistä ongelmista voidaan soveltaa dynaamisen ohjelmoinnin ratkaisuja, kun rakenne tunnistetaan. Tehojoukkonäkökulma auttaa hahmottamaan tila-avaruuden, vaikka ratkaisu myöhemmin optimoitaisiin.

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

Osajoukkosumman tarkistus

Subset Sum -ongelmassa kysytään, summautuuko jokin taulukon osajoukko tavoitearvoon. Sen voi ratkaista backtrackingilla (eksponentiaalinen aikavaativuus) tai dynaamisella ohjelmoinnilla (polynominen aikavaativuus). Backtracking-versio on suoraviivainen, mutta muuttuu suurilla syötteillä epäkäytännölliseksi. Haastatteluissa suositeltava ratkaisu on DP-versio (totuusarvotaulukko dp[target+1]). Kun ymmärrätte molemmat, pystytte perustelemaan kompromissin: backtracking tuottaa kaikki ratkaisut, kun taas DP ratkaisee päätösongelman tehokkaasti.

# 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

Osajoukkojen luetteloinnin aikavaativuus

Kaikkien osajoukkojen muodostamisen aikavaativuus on väistämättä O(n × 2ⁿ) — osajoukkoja on 2ⁿ, ja niiden keskimääräinen koko on n/2. Tätä ei voida parantaa, jos kaikki osajoukot pyydetään tuottamaan. Jos tehtävässä pyydetään vain yhtä tietyn ehdon täyttävää osajoukkoa (kuten suurimman summan osajoukkoa), tulisi suosia DP:tä tai ahnetta algoritmia. Keskeinen haastatteluoivallus on aina kysyä, tarvitseeko kaikki osajoukot luetella vai pitääkö vain selvittää, täyttääkö jokin osajoukko ehdon — vastaus ratkaisee, onko eksponentiaalinen vai polynominen aikavaativuus hyväksyttävä.

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')

Kaikkien kolmen lähestymistavan vertailu

Kaikkien osajoukkojen muodostamiseen: Backtracking on yleiskäyttöisin — se mukautuu helposti kaksoiskappaleisiin ja rajoitteisiin. Bittimaskaus on tiivis ja nopea, mutta rajoittuu arvoon n ≤ 30 kokonaislukujen koon vuoksi. Iteratiivinen lähestymistapa on intuitiivinen ja välttää rekursion aiheuttaman yleiskustannuksen. Kaikki kolme tuottavat tuloksen, jonka aikavaativuus on O(n × 2ⁿ). Haastattelussa backtracking osoittaa rekursiivisen päätösprosessin ymmärtämistä, ja sama ajatus yleistyy vaikeampiin ongelmiin. Mainitkaa lähestymistavoista keskusteltaessa kaikki kolme.

# 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

Pikatarkistus

Testatkaa tässä oppitunnissa käsiteltyjen Data Structures & Algorithms — Coding Interview Prep -käsitteiden ymmärtämistä.

Oppitunnin yhteenveto

Tässä oppitunnissa opitte, että backtracking muodostaa kaikki osajoukot lisäämällä jokaisen osittaisen polun tuloksiin ennen etenemistä, kaksoiskappaleet käsitellään järjestämällä arvot ja ohittamalla toistuvat arvot samalla rekursiosyvyydellä ehdolla i > start and nums[i] == nums[i-1] ja bittimaskaus tarjoaa tiiviin iteratiivisen vaihtoehdon, jossa jokainen osajoukko vastaa yksikäsitteistä bittimaskia. Seuraavaksi käsittelemme permutaatioita ja kombinaatioita — toisiinsa liittyviä luettelointiongelmia, joissa on erilaiset rajoitteet.

Aloita maksutta

Opi Valmistautuminen ohjelmointihaastatteluihin tekoälytuutorin avulla — ilmaiseksi

Kirjoita ja suorita oikeaa koodia selaimessa, saa välitöntä apua tekoälytuutorilta ympäri vuorokauden ja jatka siitä, mihin jäit, verkossa tai sovelluksessa.

Kurssit
90
Oppitunnit
360

Usein kysytyt kysymykset

Onko oppitunti ”Osajoukot ja potenssijoukko” ilmainen?

Kyllä – oppitunnin ”Osajoukot ja potenssijoukko” koko tekstin voi lukea täällä verkossa ilmaiseksi. Jos haluat harjoitella interaktiivisesti sisäänrakennetulla koodieditorilla ja ympäri vuorokauden käytettävissä olevan tekoälytuutorin avulla sekä avata koko Valmistautuminen ohjelmointihaastatteluihin-kurssin, päivitä CoddyKit PROhon. Valmistautuminen ohjelmointihaastatteluihin-kurssilla on yhteensä 4 oppituntia.

Mitä opin oppitunnilla ”Osajoukot ja potenssijoukko”?

Tuottakaa joukon kaikki osajoukot backtrackingilla ja bittimaskeilla sekä käsitelkää duplikaatit lajittelemalla ja ohittamalla toistuvat alkiot. Harjoittelet Valmistautuminen ohjelmointihaastatteluihin-aihetta koodilla, jonka suoritat suoraan selaimessa. Ympäri vuorokauden käytettävissä oleva tekoälytuutori vastaa kysymyksiisi oppitunnin aikana.

Tarvitsenko kokemusta aloittaakseni Valmistautuminen ohjelmointihaastatteluihin-opiskelun?

Aiempi kokemus ei ole tarpeen. CoddyKitin Valmistautuminen ohjelmointihaastatteluihin-oppimispolku sopii vasta-alkajista edistyneisiin, joten voit aloittaa tästä tai alusta ja edetä omaan tahtiisi. Tämä on oppitunti 2/4.

Kuinka kauan ”Osajoukot ja potenssijoukko”-oppitunnin suorittaminen kestää?

Useimmat CoddyKitin oppitunnit kestävät noin 5–10 minuuttia. Jokainen oppitunti on lyhyt ja interaktiivinen, joten edistyt tasaisesti ja voit jatkaa siitä, mihin jäit – sekä verkossa että sovelluksessa.

Voinko kirjoittaa ja suorittaa koodia tällä Valmistautuminen ohjelmointihaastatteluihin-oppitunnilla?

Kyllä. Jokainen Valmistautuminen ohjelmointihaastatteluihin-oppitunti sisältää sisäänrakennetun koodieditorin, joten voit kirjoittaa ja suorittaa oikeaa koodia suoraan selaimessa ja saada välitöntä palautetta tekoälyltä – paikallista asennusta ei tarvita.

Kaikki tämän kurssin oppitunnit

  1. Backtracking-malli: valitse, tutki, peru valinta
  2. Osajoukot ja potenssijoukko
  3. Permutaatiot ja kombinaatiot
  4. N-kuningat ja rajoitteiden eteneminen
← Takaisin: Valmistautuminen ohjelmointihaastatteluihin