DSA Interview Prep · Lekcja

Scalanie kont i spójne składowe

Grupować konta współdzielące adres e-mail, traktując adresy e-mail jako wierzchołki DSU, a następnie zbierać wszystkie adresy z każdej składowej, aby odtworzyć scalone konta

Lekcja 4 z 413 kroki

Scalanie kont i spójne składowe to bezpłatna lekcja DSA Interview Prep na CoddyKit. To lekcja 4 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej DSA Interview Prep, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs DSA Interview Prep zawiera 4 lekcji w sumie.

Problem: Accounts Merge

Problem Accounts Merge (LeetCode 721) daje Państwu listę kont, z których każde jest listą ciągów znaków: pierwszy element to nazwa konta, a pozostałe to adresy e-mail. Dwa konta należą do tej samej osoby, jeśli mają co najmniej jeden wspólny adres e-mail. Należy scalić wszystkie konta należące do tej samej osoby i zwrócić posortowane listy adresów e-mail.

Jest to w istocie problem spójnych składowych, w którym adresy e-mail są węzłami, a wspólne konto je łączy. DSU jest idealnym narzędziem: należy połączyć wszystkie adresy e-mail należące do tego samego konta, a następnie zebrać adresy z każdej składowej.

# Example input
accounts = [
    ['John', 'john@mail.com', 'john1@mail.com'],
    ['John', 'john2@mail.com'],
    ['Mary', 'mary@mail.com'],
    ['John', 'john1@mail.com', 'john2@mail.com'],
]
# john@mail.com and john1@mail.com are in account[0]
# john1@mail.com and john2@mail.com are in account[3]
# => john@, john1@, john2@ are all the same person
# Expected output:
# ['John', 'john1@mail.com', 'john2@mail.com', 'john@mail.com']
# ['Mary', 'mary@mail.com']
print('Goal: merge accounts sharing any email into one account')

Mapowanie adresów e-mail na identyfikatory całkowite

DSU działa na indeksach całkowitych, ale naszymi węzłami są ciągi znaków zawierające adresy e-mail. Musimy zmapować każdy unikalny adres e-mail na identyfikator całkowity. Musimy także zapamiętać, do kogo należy dany adres. Służy do tego słownik email_to_id, który przypisuje kolejne identyfikatory, oraz email_to_name, który przechowuje nazwę konta powiązaną z danym adresem e-mail.

Każdy unikalny adres e-mail otrzymuje jeden identyfikator. Jeśli ten sam adres pojawia się na wielu kontach, jest mapowany na ten sam identyfikator — połączenie identyfikatorów adresów należących do jednego konta łączy je w jedną składową. Nazwę powiązaną z identyfikatorem adresu e-mail znajdującego się w korzeniu można wykorzystać jako nazwę scalonego konta.

accounts = [
    ['John', 'john@mail.com', 'john1@mail.com'],
    ['John', 'john2@mail.com'],
    ['Mary', 'mary@mail.com'],
    ['John', 'john1@mail.com', 'john2@mail.com'],
]

email_to_id = {}
email_to_name = {}
next_id = [0]

for account in accounts:
    name = account[0]
    for email in account[1:]:
        if email not in email_to_id:
            email_to_id[email] = next_id[0]
            next_id[0] += 1
        email_to_name[email] = name

print('Total unique emails:', len(email_to_id))
for email, eid in email_to_id.items():
    print(f'  {email} => id {eid} (owner: {email_to_name[email]})')

Łączenie adresów e-mail w obrębie każdego konta

Dla każdego konta wykonujemy union identyfikatorów wszystkich wymienionych adresów e-mail. Wybieramy pierwszy adres e-mail na koncie jako reprezentanta i wykonujemy operację union, łącząc z nim identyfikator każdego pozostałego adresu. W ten sposób wszystkie adresy e-mail z konta trafiają do jednej składowej.

Po przetworzeniu wszystkich kont adresy e-mail, które występowały razem — bezpośrednio lub przechodnio przez wspólne adresy w różnych kontach — mają ten sam korzeń DSU. To kluczowy etap, który propaguje spójność między wieloma kontami.

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))
    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]
    def union(self, x, y):
        self.parent[self.find(x)] = self.find(y)

# After building email_to_id (from previous step)
# email_to_id = {'john@mail.com':0, 'john1@mail.com':1,
#                'john2@mail.com':2, 'mary@mail.com':3}

dsu = DSU(5)  # 4 unique emails

# For account ['John', 'john@mail.com', 'john1@mail.com']:
dsu.union(0, 1)    # john@ and john1@ share account => same component

# For account ['John', 'john1@mail.com', 'john2@mail.com']:
dsu.union(1, 2)    # john1@ and john2@ share account => same component

# Now 0,1,2 all share a root; 3 (mary) is separate
print('find(0)==find(2)?', dsu.find(0) == dsu.find(2))  # True
print('find(0)==find(3)?', dsu.find(0) == dsu.find(3))  # False

Zbieranie adresów e-mail według składowych

Po zakończeniu wszystkich operacji union przechodzimy po każdym adresie e-mail, znajdujemy jego korzeń DSU i grupujemy adresy według tego korzenia za pomocą słownika list. Identyfikator korzenia staje się kluczem. Następnie dla każdej grupy pobieramy nazwę konta, sortujemy listę adresów e-mail i umieszczamy nazwę na jej początku.

Sortowanie adresów e-mail jest wymagane przez zadanie — w scalonym koncie muszą one występować w porządku leksykograficznym. Nazwę można pobrać z dowolnego adresu w grupie, ponieważ wszystkie adresy w jednej składowej należą do tej samej osoby.

from collections import defaultdict

# After DSU unions, group by root
def collect_components(email_to_id, email_to_name, dsu):
    root_to_emails = defaultdict(list)
    for email, eid in email_to_id.items():
        root = dsu.find(eid)
        root_to_emails[root].append(email)

    result = []
    for root, emails in root_to_emails.items():
        # Find the name from any email in this group
        name = email_to_name[emails[0]]
        result.append([name] + sorted(emails))
    return result

# Mock data for illustration
email_to_id = {'john@m.com':0,'john1@m.com':1,'john2@m.com':2,'mary@m.com':3}
email_to_name = {e:'John' for e in list(email_to_id)[:3]}
email_to_name['mary@m.com'] = 'Mary'

class DSU:
    def __init__(self,n): self.p=list(range(n))
    def find(self,x): self.p[x]=self.p[self.p[x]] if self.p[x]!=x else x; return self.p[x] if self.p[x]==x else self.find(self.p[x])
    def union(self,x,y): self.p[self.find(x)]=self.find(y)

dsu=DSU(4); dsu.union(0,1); dsu.union(1,2)
for row in collect_components(email_to_id, email_to_name, dsu):
    print(row)

Kompletne rozwiązanie problemu Accounts Merge

Oto kompletne rozwiązanie łączące wszystkie trzy kroki: zbudowanie mapowania adresów e-mail na identyfikatory, wykonanie union adresów należących do tego samego konta oraz zebranie grup adresów według korzenia DSU. Całkowita złożoność czasowa wynosi O(n × m × alpha(n × m)), gdzie n oznacza liczbę kont, a m — maksymalną liczbę adresów e-mail przypadających na konto; w praktyce jest to O(n × m).

Złożoność pamięciowa wynosi O(n × m) ze względu na mapy adresów e-mail oraz tablice DSU. Rozwiązanie poprawnie obsługuje scalanie przechodnie: jeśli konto A ma wspólny adres X z kontem B, a konto B ma wspólny adres Y z kontem C, wszystkie konta A, B i C zostają scalone w jedną grupę.

from collections import defaultdict

def accounts_merge(accounts):
    email_to_id = {}
    email_to_name = {}
    eid = 0

    for account in accounts:
        name = account[0]
        for email in account[1:]:
            if email not in email_to_id:
                email_to_id[email] = eid
                eid += 1
            email_to_name[email] = name

    parent = list(range(eid))

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    def union(x, y):
        parent[find(x)] = find(y)

    for account in accounts:
        first_id = email_to_id[account[1]]
        for email in account[2:]:
            union(first_id, email_to_id[email])

    root_to_emails = defaultdict(list)
    for email, i in email_to_id.items():
        root_to_emails[find(i)].append(email)

    return [[email_to_name[emails[0]]] + sorted(emails)
            for emails in root_to_emails.values()]

accounts = [['John','a@m.com','b@m.com'],['John','c@m.com'],
            ['Mary','d@m.com'],['John','b@m.com','c@m.com']]
for row in accounts_merge(accounts):
    print(row)

Alternatywa BFS/DFS dla Accounts Merge

Alternatywne podejście polega na zbudowaniu grafu odwzorowującego adresy e-mail na konta, w którym adresy e-mail są węzłami, a krawędzie łączą adresy występujące na tym samym koncie. Następnie BFS/DFS znajduje każdą spójną składową. Rozwiązanie jest poprawne, ale wymaga jawnego zbudowania grafu i uruchomienia BFS dla każdego nieodwiedzonego adresu e-mail — oznacza to więcej kodu i trudniejsze rozumowanie niż w przypadku DSU.

DSU jest prostsze, ponieważ struktura union-find w naturalny sposób reprezentuje przynależność do składowej bez potrzeby tworzenia jawnej listy sąsiedztwa. BFS jest tutaj lepszym wyborem tylko wtedy, gdy trzeba odtworzyć rzeczywistą ścieżkę lub łańcuch wspólnych adresów e-mail między dwoma kontami.

# BFS alternative (for comparison)
from collections import defaultdict, deque

def accounts_merge_bfs(accounts):
    email_to_accounts = defaultdict(set)
    for i, account in enumerate(accounts):
        for email in account[1:]:
            email_to_accounts[email].add(i)

    visited_accounts = set()
    result = []

    for i, account in enumerate(accounts):
        if i in visited_accounts:
            continue
        queue = deque([i])
        emails_in_group = set()
        while queue:
            acc_idx = queue.popleft()
            if acc_idx in visited_accounts:
                continue
            visited_accounts.add(acc_idx)
            for email in accounts[acc_idx][1:]:
                emails_in_group.add(email)
                for j in email_to_accounts[email]:
                    queue.append(j)
        result.append([account[0]] + sorted(emails_in_group))
    return result

accounts = [['John','a@m.com','b@m.com'],['John','b@m.com','c@m.com'],['Mary','d@m.com']]
for row in accounts_merge_bfs(accounts):
    print(row)

Uogólnienie: spójne składowe grafu

Wzorzec accounts-merge można uogólnić na dowolny problem spójnych składowych z etykietami: istnieje zbiór elementów, pewne elementy są zadeklarowane jako równoważne (połączone), a celem jest pogrupowanie wszystkich elementów równoważnych przechodnio. Przykłady obejmują problemy klastrowania, grupy znajomych w sieciach społecznościowych oraz wykrywanie zduplikowanych rekordów.

Ogólny algorytm zawsze wygląda tak: (1) przypisz każdemu elementowi identyfikator całkowity, (2) wykonaj union dla identyfikatorów zadeklarowanych jako równoważne, (3) pogrupuj elementy według korzenia DSU. DSU jest w istocie mechanizmem grupowania dla relacji równoważności.

# Generalised grouping template
def group_equivalents(items, equivalences):
    item_to_id = {item: i for i, item in enumerate(items)}
    n = len(items)
    parent = list(range(n))

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    def union(x, y):
        parent[find(x)] = find(y)

    for a, b in equivalences:
        if a in item_to_id and b in item_to_id:
            union(item_to_id[a], item_to_id[b])

    groups = {}
    for item in items:
        root = find(item_to_id[item])
        groups.setdefault(root, []).append(item)
    return list(groups.values())

# Example: merging duplicate customer records
customers = ['Alice-NY','Alice-LA','Bob','Alice-TX','Carol']
links = [('Alice-NY','Alice-LA'),('Alice-LA','Alice-TX')]
print(group_equivalents(customers, links))

Obsługa przypadków brzegowych

Ważne przypadki brzegowe w problemie accounts merge:

  • Konta z jednym adresem e-mail: konto zawierające tylko jeden adres e-mail tworzy własną składową, chyba że inne konto zawiera ten sam adres.
  • Ta sama nazwa, różne osoby: wystąpienie nazwy 'John' na dwóch kontach nie oznacza, że dotyczą one tej samej osoby — konta są scalane wyłącznie na podstawie wspólnych adresów e-mail. Nazwa jest przechowywana dla każdego adresu e-mail, a nie dla całej składowej.
  • Puste konta: konto bez adresów e-mail należy pominąć, aby uniknąć błędów indeksowania.

Zawsze należy sprawdzić, czy rozwiązanie nie scala kont tylko dlatego, że mają tę samą nazwę. Połączenia DSU wynikają wyłącznie ze wspólnych adresów e-mail.

# Edge case: two Johns with no shared email => separate output
accounts = [
    ['John', 'john_a@m.com'],
    ['John', 'john_b@m.com'],   # different email => different component
    ['Mary'],                    # no emails => skip
]

def accounts_merge_safe(accounts):
    email_to_id = {}; email_to_name = {}; eid = 0
    for account in accounts:
        name = account[0]
        for email in account[1:]:
            if email not in email_to_id:
                email_to_id[email] = eid; eid += 1
            email_to_name[email] = name

    parent = list(range(eid))
    def find(x):
        while parent[x]!=x: parent[x]=parent[parent[x]]; x=parent[x]
        return x
    def union(x,y): parent[find(x)]=find(y)

    for account in accounts:
        if len(account) < 2: continue          # skip no-email accounts
        first = email_to_id[account[1]]
        for email in account[2:]:
            union(first, email_to_id[email])

    from collections import defaultdict
    groups = defaultdict(list)
    for email, i in email_to_id.items():
        groups[find(i)].append(email)
    return [[email_to_name[e[0]]] + sorted(e) for e in groups.values()]

for row in accounts_merge_safe(accounts):
    print(row)

Liczba spójnych składowych grafu

Pokrewny problem (LeetCode 323) polega na znalezieniu liczby spójnych składowych w grafie nieskierowanym. Jest on prostszy niż accounts-merge: należy zainicjalizować DSU z n węzłami, przetworzyć wszystkie krawędzie za pomocą union, a następnie policzyć różne korzenie.

Najbardziej zwięzłym sposobem liczenia składowych jest utrzymywanie zmiennej count z wartością początkową n i zmniejszanie jej za każdym razem, gdy udane union scala dwie różne składowe. Alternatywnie na końcu można policzyć liczbę węzłów i, dla których zachodzi find(i) == i.

def count_components(n, edges):
    parent = list(range(n))

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    count = n
    for u, v in edges:
        pu, pv = find(u), find(v)
        if pu != pv:
            parent[pu] = pv
            count -= 1
    return count

print(count_components(5, [[0,1],[1,2],[3,4]]))  # 2: {0,1,2} and {3,4}
print(count_components(5, [[0,1],[1,2],[2,3],[3,4]]))  # 1: all connected
print(count_components(5, []))   # 5: no edges, all isolated

Najmniejsza i największa składowa

Gdy DSU śledzi rozmiary składowych, można odpowiadać na pytania takie jak „jaki jest rozmiar największej spójnej składowej?” lub „ile składowych ma dokładnie 3 węzły?” w czasie O(n), przeglądając tablicę rozmiarów dla węzłów będących korzeniami.

Takie zapytania pojawiają się w zadaniach takich jak „znalezienie największej połączonej wyspy” na siatce lub „identyfikacja najmniejszej partycji sieci”. Po zakończeniu wszystkich operacji union należy znaleźć węzły i, dla których zachodzi find(i) == i (czyli korzenie), i sprawdzić ich rozmiary.

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))
        self.size = [1] * n
    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]
    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py: return
        if self.size[px] < self.size[py]: px, py = py, px
        self.parent[py] = px
        self.size[px] += self.size[py]

def component_stats(n, edges):
    dsu = DSU(n)
    for u, v in edges:
        dsu.union(u, v)
    sizes = [dsu.size[i] for i in range(n) if dsu.find(i) == i]
    print('Component sizes:', sizes)
    print('Largest component:', max(sizes))
    print('Smallest component:', min(sizes))
    print('Number of components:', len(sizes))

component_stats(8, [(0,1),(1,2),(3,4),(5,6),(6,7)])

Wskazówki dotyczące zadań z DSU

Gdy napotkają Państwo problem dotyczący scalania grup, zapytań o spójność lub znalezienia dodatkowej krawędzi, należy od razu pomyśleć o DSU. Podczas rozmów rekrutacyjnych warto wspomnieć o obu optymalizacjach (kompresji ścieżek oraz łączeniu według rangi/rozmiaru), aby wykazać się znajomością tematu, nawet jeśli prostsza, naiwna wersja DSU wystarczyłaby przy danych ograniczeniach.

Typowe błędy, których należy unikać, to: nieuwzględnienie przypadku, w którym oba końce są już połączone (union nic wtedy nie robi), niepoprawne użycie indeksowania od 0 zamiast od 1 lub odwrotnie oraz niesortowanie wyniku dla accounts-merge (problem wymaga posortowanych list adresów e-mail). Przed rozpoczęciem kodowania należy zawsze doprecyzować ograniczenia danych wejściowych.

# Interview checklist for DSU problems
checklist = [
    '1. Identify: is this a grouping/connectivity/cycle problem?',
    '2. Map problem entities to integer node IDs if needed',
    '3. Implement DSU with path compression + union by rank/size',
    '4. Process all relationships (edges/pairs) with union()',
    '5. Answer queries using find() and size/count tracking',
    '6. Handle edge cases: already connected, single nodes, no edges',
    '7. Check output format: sorted? 1-indexed? Name included?',
    '8. State time complexity: O(n * alpha(n)) ~ O(n)',
]
for item in checklist:
    print(item)

Szybki test

Sprawdź swoją znajomość zagadnień Data Structures & Algorithms — Coding Interview Prep z tej lekcji.

Podsumowanie lekcji

W tej lekcji poznali Państwo następujące zagadnienia: accounts-merge to problem spójnych składowych, w którym adresy e-mail są węzłami, a konta łączą adresy e-mail, DSU rozwiązuje go przez przypisanie adresom e-mail identyfikatorów całkowitych, wykonanie union dla identyfikatorów w obrębie każdego konta oraz pogrupowanie elementów według korzenia, a także ten sam wzorzec grupowania DSU można stosować do dowolnego problemu klas równoważności lub klastrowania. W następnej części zmienimy temat na operacje bitowe, zaczynając od podstawowych operatorów AND, OR, XOR, NOT oraz operatorów przesunięcia.

Bezpłatny start

Ucz się Python dzięki korepetycjom AI — za darmo

Pisz i uruchamiaj kod w przeglądarce, otrzymuj natychmiastową pomoc od korepetytora AI dostępnego 24/7 i kontynuuj naukę w sieci lub w aplikacji.

Kursy
30
Lekcje
120

Często zadawane pytania

Czy lekcja „Scalanie kont i spójne składowe” jest bezpłatna?

Tak — pełny tekst „Scalanie kont i spójne składowe” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu DSA Interview Prep, przejdź na CoddyKit PRO. Kurs DSA Interview Prep zawiera 4 lekcji w sumie.

Co nauczysz się w „Scalanie kont i spójne składowe”?

Grupować konta współdzielące adres e-mail, traktując adresy e-mail jako wierzchołki DSU, a następnie zbierać wszystkie adresy z każdej składowej, aby odtworzyć scalone konta Ćwiczysz DSA Interview Prep z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.

Czy potrzebuję doświadczenia, aby zacząć DSA Interview Prep?

Nie wymagamy żadnego doświadczenia. DSA Interview Prep w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 4 z 4.

Ile czasu zajmuje lekcja „Scalanie kont i spójne składowe”?

Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.

Czy mogę pisać i uruchamiać kod w tej lekcji DSA Interview Prep?

Tak. Każda lekcja DSA Interview Prep zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.

Wszystkie lekcje w tym kursie

  1. DSU z kompresją ścieżek
  2. Łączenie według rangi i ograniczenie odwrotną funkcją Ackermanna
  3. Nadmiarowa krawędź i wykrywanie cykli
  4. Scalanie kont i spójne składowe
← Powrót do DSA Interview Prep