0Pricing
DSA Interview Prep · レッスン

Accounts Mergeと連結成分

メールアドレスをDSUのノードとして扱い、メールアドレスを共有するアカウントをグループ化して、各成分のメールアドレスを集めて統合後のアカウントを復元します。

「Accounts Mergeと連結成分」はCoddyKit上の無料DSA Interview Prepレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはDSA Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 DSA Interview Prepコースには全4レッスンが含まれています。

問題: Accounts Merge

Accounts Merge 問題(LeetCode 721)では、アカウントのリストが与えられます。各アカウントは文字列のリストで、最初の要素がアカウント名、それ以降の要素がメールアドレスです。2 つのアカウントが少なくとも 1 つのメールアドレスを共有していれば、それらは同じ人物のものです。同じ人物に属するすべてのアカウントを統合し、メールアドレスのリストをソートして返してください。

これは本質的には連結成分の問題です。メールアドレスをノードとし、同じアカウントに含まれることがそれらを結ぶ辺になります。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 で各メールアドレスに対応するアカウント名を管理します。

一意なメールアドレスごとに 1 つの ID が割り当てられます。同じメールアドレスが複数のアカウントに現れる場合も、同じ ID に対応付けられます。これにより、1 つのアカウント内にあるメールアドレスの ID 同士を union すると、それらが 1 つの連結成分としてつながります。ルートにあるメールアドレスの 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 をそれと union します。これにより、アカウント内のすべてのメールアドレスが 1 つの連結成分になります。

すべてのアカウントを処理した後、同じアカウントに現れたメールアドレスは、複数のアカウント間で共有されるメールアドレスを介して直接または間接的につながり、すべて同じ 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 をキーにします。最後に各グループについてアカウント名を取得し、メールアドレスのリストをソートして、先頭に名前を追加します。

メールアドレスのソートは問題の要件です。統合されたアカウント内では、メールアドレスを辞書順に並べる必要があります。1 つの連結成分に属するメールアドレスはすべて同じ人物のものなので、グループ内のどのメールアドレスからでも名前を取得できます。

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)

Accounts Merge の完全な解法

ここでは、メールアドレスから ID への対応付けの作成、各アカウント内のメールアドレスの union、DSU のルートごとのメールアドレスのグループ化という 3 つの手順を組み合わせた完全な解法を示します。全体の時間計算量は O(n × m × alpha(n × m)) です。ここで n はアカウント数、m は 1 アカウントあたりのメールアドレス数の最大値です。実質的には O(n × m) です。

空間計算量は、メールアドレスのマップと DSU 配列のために O(n × m) です。この解法は推移的な統合にも正しく対応します。アカウント A がアカウント B とメールアドレス X を共有し、アカウント B がアカウント C とメールアドレス Y を共有している場合、A、B、C はすべて 1 つのグループに統合されます。

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)

Accounts Merge の BFS/DFS による代替手法

別のアプローチでは、メールアドレスをノードとし、同じアカウントに現れるメールアドレス同士をエッジで結んだ、メールアドレスからアカウントへのグラフを構築します。その後、BFS/DFSで各連結成分を見つけます。正しい方法ではありますが、グラフを明示的に構築し、未訪問の各メールアドレスからBFSを実行する必要があるため、DSUよりコード量が多く、考え方も難しくなります。

DSUのほうが簡潔なのは、union-find構造によって、明示的な隣接リストなしでコンポーネントへの所属を自然に表現できるためです。この問題でBFSが適しているのは、2つのアカウント間にある共有メールアドレスの実際の経路や連鎖を復元する必要がある場合だけです。

# 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のrootごとに項目をグループ化します。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))

エッジケースへの対応

accounts-mergeにおける重要なエッジケースは次のとおりです。

  • メールアドレスが1つだけのアカウント:メールアドレスが1つしかないアカウントは、別のアカウントがそのメールアドレスを共有していない限り、独自のコンポーネントを形成します。
  • 同じ名前で別人の場合:2つのアカウントに「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があります。これはaccounts-mergeより簡単です。n個のノードでDSUを初期化し、すべてのエッジをunionで処理してから、異なるrootの数を数えます。

コンポーネント数を数える最も簡潔な方法は、nから始まるcount変数を保持し、成功したunionによって異なる2つのコンポーネントが統合されるたびに、その値を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個のノードを持つコンポーネントはいくつあるか」といったクエリに、サイズ配列のrootノードだけを走査してO(n)で答えられます。

このようなクエリは、グリッド上で「最大の連結した島を見つける」問題や、「最小のネットワーク分割を特定する」問題などに登場します。すべてのunionが完了したら、find(i) == iとなるノードi(これらがrootです)を探し、そのサイズを調べます。

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でも制約上は通る場合であっても、2つの最適化(パス圧縮とランクまたはサイズによるunion)について説明すると、知識の深さを示せます。

避けるべきよくあるミスは、両端点がすでに接続されている場合(unionが何もしない場合)への対応を忘れること、0始まりと1始まりを取り違えること、そしてaccounts-mergeの出力をソートしないことです(この問題ではメールアドレスのリストをソートする必要があります)。コーディングの前に、必ず入力の制約を確認してください。

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

クイックチェック

このレッスンで扱った Data Structures & Algorithms — Coding Interview Prep の概念について、理解度を確認しましょう。

レッスンのまとめ

このレッスンでは、accounts-mergeは、メールアドレスをノード、アカウントをメールアドレス同士を結ぶリンクとする連結成分の問題であること、DSUは、メールアドレスを整数IDに対応付け、各アカウント内のIDをunionし、rootごとにグループ化することでこの問題を解決すること、そして同じDSUによるグループ化のテンプレートが、同値類やクラスタリングに関するあらゆる問題に適用できることを学びました。次はビット操作に移り、基本的なAND、OR、XOR、NOT、シフト演算子から始めます。

よくある質問

「Accounts Mergeと連結成分」レッスンは無料ですか?

はい。「Accounts Mergeと連結成分」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、DSA Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 DSA Interview Prepコースには全4レッスンが含まれています。

「Accounts Mergeと連結成分」で何を学びますか?

メールアドレスをDSUのノードとして扱い、メールアドレスを共有するアカウントをグループ化して、各成分のメールアドレスを集めて統合後のアカウントを復元します。 ブラウザで直接実行するハンズオンコードでDSA Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

DSA Interview Prepを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのDSA Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。

「Accounts Mergeと連結成分」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このDSA Interview Prepレッスンでコードを書いて実行できますか?

はい。すべてのDSA Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. 経路圧縮付きDSU
  2. ランクによるUnionと逆アッカーマン境界
  3. Redundant Connectionと閉路検出
  4. Accounts Mergeと連結成分
← DSA Interview Prepに戻る