계정 병합과 연결 요소
이메일을 DSU 노드로 취급해 이메일을 공유하는 계정을 그룹화한 다음, 각 요소의 모든 이메일을 모아 병합된 계정을 복원합니다.
계정 병합과 연결 요소은(는) CoddyKit의 무료 DSA Interview Prep 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 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를 1씩 증가시키며 할당하고, email_to_name을 사용해 각 이메일과 연결된 계정 이름을 추적합니다.
각 고유 이메일에는 하나의 ID가 부여됩니다. 같은 이메일이 여러 계정에 나타나면 동일한 ID로 매핑됩니다. 한 계정에 속한 이메일의 ID들을 union하면 하나의 컴포넌트로 연결됩니다. 루트 이메일 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를 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가 키가 됩니다. 마지막으로 각 그룹에서 계정 이름을 가져오고 이메일 목록을 정렬한 뒤 이름을 앞에 추가합니다.
이메일 정렬은 문제에서 요구하는 사항입니다. 병합된 계정 안의 이메일은 사전순으로 정렬되어야 합니다. 한 컴포넌트의 모든 이메일은 같은 사람에게 속하므로 그룹 내 어떤 이메일에서든 이름을 가져올 수 있습니다.
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)입니다.
공간 복잡도는 이메일 매핑과 DSU 배열에 필요한 O(n × m)입니다. 이 풀이는 전이적 병합을 올바르게 처리합니다. 계정 A가 계정 B와 이메일 X를 공유하고, 계정 B가 계정 C와 이메일 Y를 공유하면 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가 더 깔끔한 이유는 명시적인 인접 목록 없이도 union-find 구조가 자연스럽게 요소의 소속을 나타내기 때문입니다. 여기서 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를 union하며, (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))경계 사례 처리
계정 병합에서 중요한 경계 사례는 다음과 같습니다.
- 이메일이 하나뿐인 계정: 이메일이 하나뿐인 계정은 다른 계정이 해당 이메일을 공유하지 않는 한 자체적인 요소를 이룹니다.
- 이름은 같지만 서로 다른 사람: 두 계정에 'John'이 나타난다고 해서 같은 사람이라는 뜻은 아닙니다. 공유된 이메일이 있을 때만 계정이 병합됩니다. 이름은 요소별이 아니라 이메일별로 저장됩니다.
- 비어 있는 계정: 이메일이 없는 계정은 인덱스 오류를 피하기 위해 건너뛰어야 합니다.
이름이 같다는 이유만으로 병합해서는 안 되는 계정을 해법이 제대로 처리하는지 항상 확인하십시오. 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은 무방향 그래프에서 연결 요소의 개수를 묻습니다. 이 문제는 계정 병합보다 간단합니다. n개의 노드로 DSU를 초기화하고, 모든 간선을 union으로 처리한 다음, 서로 다른 루트의 개수를 세면 됩니다.
요소의 개수를 세는 가장 간결한 방법은 n에서 시작하는 count 변수를 유지하고, 성공적인 union으로 서로 다른 두 요소가 병합될 때마다 이 값을 1씩 줄이는 것입니다. 또는 마지막에 find(i) == 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를 사용하면 '가장 큰 연결 요소의 크기는 얼마인가?' 또는 '노드가 정확히 3개인 요소는 몇 개인가?'와 같은 질의에 루트 노드의 크기 배열을 O(n)에 훑어 답할 수 있습니다.
이러한 질의는 격자에서 '가장 큰 연결 섬 찾기' 또는 '가장 작은 네트워크 분할 식별하기'와 같은 문제에 등장합니다. 모든 union을 완료한 뒤 find(i) == 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를 떠올리십시오. 면접에서는 두 가지 최적화인 경로 압축과 순위/크기에 따른 union을 모두 언급하십시오. 주어진 제약 조건에서는 더 단순한 순진한 DSU로도 통과할 수 있더라도, 이를 통해 깊이 있는 지식을 보여줄 수 있습니다.
피해야 할 일반적인 실수는 다음과 같습니다. 두 끝점이 이미 연결된 경우를 처리하지 않는 것(union은 아무 작업도 하지 않습니다), 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를 union한 다음, 루트별로 그룹화하여 이 문제를 해결합니다. 또한 동치 클래스나 군집화 문제라면 동일한 DSU 그룹화 틀을 적용할 수 있습니다. 다음에는 비트 조작으로 전환하여 기본적인 AND, OR, XOR, NOT 및 시프트 연산자를 살펴봅니다.
AI 튜터와 함께 Python을(를) 배우세요 — 무료
브라우저에서 실제 코드를 작성하고 실행하며, 24/7 AI 튜터로부터 즉각적인 도움을 받고, 웹이나 앱에서 중단한 부분부터 계속 학습하세요.
- 코스
- 30
- 레슨
- 120
자주 묻는 질문
“계정 병합과 연결 요소” 강의는 무료인가요?
네 — “계정 병합과 연결 요소” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 DSA Interview Prep 강의 전체를 잠금 해제할 수 있습니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
“계정 병합과 연결 요소”에서 뭘 배우나요?
이메일을 DSU 노드로 취급해 이메일을 공유하는 계정을 그룹화한 다음, 각 요소의 모든 이메일을 모아 병합된 계정을 복원합니다. 브라우저에서 직접 실행하는 실습 코드로 DSA Interview Prep을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
DSA Interview Prep을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 DSA Interview Prep은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.
“계정 병합과 연결 요소” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 DSA Interview Prep 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 DSA Interview Prep 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- 경로 압축을 적용한 DSU
- 랭크에 의한 합치기와 역 아커만 상한
- 중복 간선과 사이클 탐지
- 계정 병합과 연결 요소