0Pricing
DSA Interview Prep · Урок

Объединение аккаунтов и компоненты связности

Сгруппируйте аккаунты с общим адресом электронной почты, рассматривая адреса как узлы DSU, а затем соберите все адреса каждой компоненты для восстановления объединённых аккаунтов

«Объединение аккаунтов и компоненты связности» — бесплатный урок DSA Interview Prep на CoddyKit. Это урок 4 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения DSA Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс DSA Interview Prep содержит 4 уроков всего.

Задача: объединение учетных записей

В задаче «Объединение учетных записей» (LeetCode 721) дан список учетных записей, каждая из которых представляет собой список строк: первый элемент — имя учетной записи, а остальные — адреса электронной почты. Две учетные записи принадлежат одному человеку, если у них есть хотя бы один общий адрес. Объедините все учетные записи одного человека и верните отсортированные списки адресов.

По сути, это задача о связных компонентах, где адреса электронной почты являются вершинами, а общая учетная запись связывает их. DSU — идеальный инструмент: выполните union для всех адресов внутри одной учетной записи, а затем соберите адреса по компонентам.

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

Сопоставление адресов электронной почты с целочисленными ID

DSU работает с целочисленными индексами, но наши вершины представлены строками с адресами электронной почты. Поэтому нужно сопоставить каждый уникальный адрес электронной почты с целочисленным ID. Также необходимо запомнить, какое имя связано с каждым адресом. Используйте словарь email_to_id для назначения последовательных ID и email_to_name для хранения имени учетной записи, связанного с каждым адресом.

Каждый уникальный адрес электронной почты получает один ID. Если один и тот же адрес встречается в нескольких учетных записях, ему соответствует один и тот же ID, а выполнение union для ID адресов внутри одной учетной записи соединяет их в одну компоненту. Имя, связанное с ID корневого адреса, становится именем объединённой учетной записи.

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

Объединение адресов внутри каждой учетной записи

Для каждой учетной записи мы выполняем union для ID всех перечисленных в ней адресов. Первый адрес в учетной записи выбирается представителем, а ID каждого другого адреса объединяется с ним с помощью union. Так все адреса учетной записи оказываются в одной компоненте.

После обработки всех учетных записей адреса, встречавшиеся вместе напрямую или транзитивно через общие адреса в разных учетных записях, имеют один и тот же корень DSU. Это ключевой шаг, распространяющий связность между несколькими учетными записями.

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

Сбор адресов по компонентам

После завершения всех операций union переберите каждый адрес электронной почты, найдите его корень DSU и сгруппируйте адреса по этому корню с помощью словаря списков. ID корня становится ключом. Наконец, для каждой группы получите имя учетной записи, выполните sort для списка адресов и добавьте имя в начало.

Сортировка адресов требуется условием задачи: внутри объединённой учетной записи адреса должны располагаться в лексикографическом порядке. Имя можно получить из любого адреса в группе, поскольку все адреса одной компоненты принадлежат одному человеку.

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)

Полное решение задачи об объединении учетных записей

Ниже приведено полное решение, объединяющее все три шага: построение сопоставления адресов с ID, выполнение union для адресов внутри каждой учетной записи и сбор сгруппированных адресов по корню DSU. Общая временная сложность составляет O(n × m × alpha(n × m)), где n — количество учетных записей, а m — максимальное количество адресов в одной учетной записи; фактически это O(n × m).

Пространственная сложность составляет O(n × m) для словарей адресов и массивов DSU. Это решение корректно выполняет транзитивное объединение: если учетная запись A имеет общий адрес X с учетной записью B, а учетная запись B имеет общий адрес Y с учетной записью C, то A, B и C объединяются в одну группу.

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)

Альтернатива BFS/DFS для объединения аккаунтов

Альтернативный подход строит граф соответствий между адресами электронной почты и аккаунтами, в котором адреса являются узлами, а рёбра соединяют адреса, встречающиеся в одном аккаунте. Затем BFS/DFS находит каждую связную компоненту. Хотя этот подход корректен, он требует явно построить граф и запустить BFS из каждого ещё не посещённого адреса — это больше кода, и рассуждать о таком решении сложнее, чем о DSU.

DSU удобнее, потому что структура объединения и поиска естественным образом представляет принадлежность к компонентам, не требуя явного списка смежности. Единственный случай, когда здесь предпочтительнее BFS, — если необходимо восстановить настоящий путь или цепочку общих адресов электронной почты между двумя аккаунтами.

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

Обобщение: связные компоненты графа

Шаблон объединения аккаунтов обобщается на любую задачу о связных компонентах с метками: у вас есть набор элементов, некоторые элементы объявлены эквивалентными (связанными), и нужно объединить все транзитивно эквивалентные элементы в группы. Примеры включают задачи кластеризации, группы друзей в социальных сетях и обнаружение дубликатов записей.

Общий алгоритм всегда один и тот же: (1) назначить каждому элементу целочисленный ID, (2) объединить ID объявленных эквивалентными элементов, (3) сгруппировать элементы по их корню DSU. По сути, DSU — это механизм группировки для отношений эквивалентности.

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

Обработка граничных случаев

Важные граничные случаи при объединении аккаунтов:

  • Аккаунты с одним адресом электронной почты: аккаунт только с одним адресом образует собственную компоненту, если только другой аккаунт не использует тот же адрес.
  • Одинаковое имя, разные люди: имя «Джон» в двух аккаунтах не означает, что это один и тот же человек — аккаунты объединяются только при наличии общих адресов электронной почты. Имя хранится для каждого адреса, а не для компоненты.
  • Пустые аккаунты: аккаунт без адресов электронной почты следует пропустить, чтобы избежать ошибок индексации.

Всегда проверяйте, что ваше решение не объединяет аккаунты только потому, что у них одинаковое имя. Связи DSU создаются исключительно на основе общих адресов электронной почты.

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

Количество связных компонент в графе

В связанной задаче (LeetCode 323) требуется найти количество связных компонент в неориентированном графе. Она проще объединения аккаунтов: инициализируйте DSU с n узлами, обработайте все рёбра с помощью union, а затем подсчитайте различные корни.

Самый краткий способ подсчитать компоненты — поддерживать переменную count, начиная со значения n, и уменьшать её каждый раз, когда успешный union объединяет две разные компоненты. Другой вариант — в конце подсчитать количество узлов i, для которых 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

Наименьшая и наибольшая компоненты

Если DSU отслеживает размеры, с его помощью можно за O(n) отвечать на запросы вроде «каков размер наибольшей связной компоненты?» или «сколько компонент содержат ровно 3 узла?», просматривая массив размеров в узлах-корнях.

Такие запросы встречаются в задачах вроде «найти наибольший связный остров» на сетке или «определить наименьший фрагмент сети». После завершения всех объединений просмотрите узлы i, для которых find(i) == i (это корни), и изучите их размеры.

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

Советы для собеседования по задачам на DSU

Если вы встречаете задачу с объединением групп, запросами связности или поиском лишнего ребра, сразу думайте о DSU. На собеседовании упомяните обе оптимизации (сжатие путей и объединение по рангу/размеру), чтобы продемонстрировать глубину знаний, даже если более простой наивный DSU прошёл бы при заданных ограничениях.

Распространённые ошибки, которых следует избегать: не обработать случай, когда обе конечные вершины уже связаны (объединение не выполняет никаких действий), неправильно использовать нумерацию с 0 вместо нумерации с 1 или наоборот, а также не отсортировать результат для объединения аккаунтов (задача требует отсортированных списков адресов электронной почты). Всегда уточняйте ограничения на входные данные перед написанием кода.

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

Быстрая проверка

Проверьте своё понимание концепций курса «Структуры данных и алгоритмы — подготовка к собеседованию по программированию», изученных в этом уроке.

Итоги урока

В этом уроке вы узнали, что объединение аккаунтов — это задача о связных компонентах, где адреса электронной почты являются узлами, а аккаунты связывают адреса, DSU решает её, сопоставляя адресам целочисленные ID, объединяя ID внутри каждого аккаунта и группируя их по корню, а тот же шаблон группировки DSU применим к любой задаче о классах эквивалентности или кластеризации. Далее мы перейдём к работе с битами, начав с фундаментальных операторов AND, OR, XOR, NOT и операторов сдвига.

Часто задаваемые вопросы

Урок «Объединение аккаунтов и компоненты связности» бесплатный?

Да — полный текст урока «Объединение аккаунтов и компоненты связности» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс DSA Interview Prep, подпишись на CoddyKit PRO. Курс DSA Interview Prep содержит 4 уроков всего.

Чему я научусь в уроке «Объединение аккаунтов и компоненты связности»?

Сгруппируйте аккаунты с общим адресом электронной почты, рассматривая адреса как узлы DSU, а затем соберите все адреса каждой компоненты для восстановления объединённых аккаунтов Ты практикуешь DSA Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать DSA Interview Prep?

Предыдущий опыт не требуется. DSA Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 4 из 4.

Сколько времени занимает урок «Объединение аккаунтов и компоненты связности»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке DSA Interview Prep?

Да. Каждый урок DSA Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. DSU со сжатием путей
  2. Объединение по рангу и оценка обратной функции Аккермана
  3. Избыточное ребро и обнаружение циклов
  4. Объединение аккаунтов и компоненты связности
← Назад к DSA Interview Prep