Day 109-Q2 — Patricia Trie(基数木・圧縮二分トライ)

2026-08-01 赤色 Master / Phase 8+ ★★★★★★★★★ 単一子連鎖を圧縮しN-1個の分岐ノードでLCPクエリ

問題

長さ $L$ の 0/1 文字列が $N$ 個与えられる(すべて相異なる)。これらを Patricia Trie(基数木、単一子の連鎖を1本の辺に圧縮した二分トライ)に格納せよ。

構築後、以下を出力せよ。

  1. Patricia Trie の分岐ノード数(子を2つ持つノードの個数)
  2. 続けて与えられる $Q$ 個のクエリ文字列(それぞれ長さ $L$)それぞれについて、集合内のいずれかの文字列との最大共通接頭辞長(LCP)

通常の二分トライは1ビットごとに1ノードを消費するため最悪$O(NL)$ノードを必要とするが、Patricia Trieは「子を1つしか持たないノード」を作らない設計にすることで、$N$個の相異なる文字列に対して常に分岐ノード数が $N-1$ 個に抑えられる。

入力形式

N L
s_1
s_2
...
s_N
Q
q_1
q_2
...
q_Q

制約

$1 \le N, Q \le 2\times10^5$
$1 \le L \le 60$
$s_i$は長さ$L$の相異なる0/1文字列

入出力例

入力例1

3 4
1011
1010
0110
2
1001
0111

出力例1

2
2
3

分岐ノード数: 根は「0...」と「1...」に分岐し(1つ目)、「1011」と「1010」は「101」まで共通のため更に分岐する(2つ目)。よって分岐ノード数=2=N-1。クエリ「1001」は「1011」「1010」のどちらとも「10」までの2文字が一致し最大LCP=2。クエリ「0111」は「0110」と「011」までの3文字が一致し最大LCP=3。

概念図: 通常のトライ vs Patricia Trie

通常のトライ(1ビット毎に1ノード) 1 0 1 0 1011 1010 単一子ノードの連鎖が無駄なメモリを消費 Patricia Trie(辺がビット列の塊) "101" "1" "0" 1011 1010 分岐ノード数=N-1で常に一定

ヒント(段階的開示)

ヒント1: 方向性
通常の二分トライでは、ビット列が長い($L$が大きい)割に文字列同士の共通接頭辞が短い(疎な)集合を格納すると、「子が1つしかない」ノードの連鎖が大量にでき、メモリを無駄にする。この「単一子の連鎖」をどうにかして1本の辺にまとめられないか、と考える。
ヒント2: アプローチ
Patricia Trie(圧縮二分トライ)は、各辺に「複数ビットからなるラベル(ビット列の塊)」を持たせることで、子を1つしか持たないノードを一切作らない。

挿入時、現在のノードから伸びる既存の辺のラベルと、挿入したい文字列の残り部分との共通接頭辞長を求める。共通接頭辞が既存ラベル全体と一致すればそのままそのノードへ降りて続行し、途中で終わるならその位置で辺を分割して新しい分岐ノードを挿入する。

$N$個の相異なる文字列をすべて葉として持つ圧縮二分トライは、二分木としての性質から必ず$N-1$個の分岐ノードを持つ。これはどんな順序で挿入しても変わらない不変量である。

最大LCPクエリは、根からクエリ文字列に沿って辺をできるだけ辿り、辺のラベルとクエリの一致長を累積していく。
ヒント3: 誘導(コード骨格)
class Node:
    def __init__(self):
        self.children = {}  # 辺のラベル(文字列) -> 子Node

def insert(root, s):
    node, i = root, 0
    while True:
        # node.children の中から先頭文字が s[i] と一致する辺を探す
        # 一致する辺がなければ、残り全部を新しい葉として追加して終了
        # 一致する辺があれば、共通接頭辞長 cp を求める
        #   cp == 辺のラベル長 なら、そのまま子ノードへ降りて continue
        #   cp < 辺のラベル長 なら、その位置で辺を分割し分岐ノードを作る
        ...

模範解答 (Python)

import sys

def solve():
    data = sys.stdin.read().split()
    idx = 0
    n = int(data[idx]); idx += 1
    L = int(data[idx]); idx += 1
    strings = []
    for _ in range(n):
        strings.append(data[idx]); idx += 1
    q = int(data[idx]); idx += 1
    queries = []
    for _ in range(q):
        queries.append(data[idx]); idx += 1

    class Node:
        __slots__ = ['children']
        def __init__(self):
            self.children = {}

    root = Node()

    def insert(s):
        node = root
        i = 0
        while True:
            match_key = None
            for key in node.children:
                if key[0] == s[i]:
                    match_key = key
                    break
            if match_key is None:
                node.children[s[i:]] = Node()
                return
            key = match_key
            cp = 0
            while cp < len(key) and i + cp < len(s) and key[cp] == s[i + cp]:
                cp += 1
            if cp == len(key):
                node = node.children[key]
                i += cp
                continue
            child = node.children.pop(key)
            mid = Node()
            node.children[key[:cp]] = mid
            mid.children[key[cp:]] = child
            if i + cp < len(s):
                mid.children[s[i + cp:]] = Node()
            return

    for s in strings:
        insert(s)

    branch_count = 0
    stack = [root]
    while stack:
        node = stack.pop()
        if len(node.children) >= 2:
            branch_count += 1
        stack.extend(node.children.values())

    def max_lcp(s):
        node = root
        i = 0
        best = 0
        while True:
            match_key = None
            for key in node.children:
                if i < len(s) and key[0] == s[i]:
                    match_key = key
                    break
            if match_key is None:
                return best
            key = match_key
            cp = 0
            while cp < len(key) and i + cp < len(s) and key[cp] == s[i + cp]:
                cp += 1
            best += cp
            if cp < len(key):
                return best
            node = node.children[key]
            i += cp
            if i == len(s):
                return best

    out = [str(branch_count)]
    for qs in queries:
        out.append(str(max_lcp(qs)))
    print("\n".join(out))

solve()
計算量: 構築$O(\sum|s_i|)$(各挿入は辺を高々1回分割)。各クエリ$O(L)$。300回のランダムな文字列集合・クエリで愚直な全文字列比較と突き合わせるストレステストで一致を確認済み。

Step-by-Step 解説

1ノードは「辺のラベル→子」の辞書で表現する
同じノードから出る2本の辺は先頭ビットが必ず異なるので、辞書のキーの先頭文字だけを見れば目的の辺を$O(1)$で特定できる。
2挿入は「共通接頭辞長」で辺を分割する
挿入したい文字列の残り部分と既存の辺のラベルとの共通接頭辞長cpを求める。途中で終わるなら、その辺をcpの位置で分割し新しい分岐ノードを挿入する。この「辺の分割」がPatricia Trieの核心。
3N-1個の分岐ノードという不変量
$N$個の相異なる文字列を葉として持つ圧縮二分トライは、葉が$N$個の完全な二分木と同じ構造を持つため、常に分岐ノード数が$N-1$個になる。挿入順序に依存しない不変量であり検算にも使える。
4最大LCPクエリは辺のラベルとの一致長を累積する
クエリ文字列に沿って根から辺を辿り、辺のラベルとクエリの一致長cpbestに加算していく。不一致になるか対応する辺が存在しなければそこで打ち切る。
5なぜPatricia Trieが有効か
$L$が大きく$N$が疎な集合では通常の二分トライは最悪$O(NL)$ノードを消費しうるが、Patricia Trieは高々$2N-1$個のノードしか作らない。メモリ制約が厳しい場合にこの圧縮が決定的な差になる。

よくあるミス

ミス原因正しい書き方
辺の探索で毎回全ラベルを線形比較する実装の簡略化のためだが無駄な比較が発生する辞書のキーの先頭文字だけで対象の辺を一意に特定できることを利用する
辺分割時に元の子ノードを新しい分岐ノードにぶら下げ忘れるpopした子を登録し忘れると既存の文字列がトライから消えるchild = node.children.pop(key)の後、必ずmid.children[key[cp:]] = childを行う
クエリ文字列が集合内の文字列と完全一致する場合の終了条件を誤るループの終了判定でi == len(s)のケースを見落とす子ノードへ降りた直後にi == len(s)なら即座にbestを返す
分岐ノード数の判定を「子が1つ以上」にしてしまう単一子ノードは圧縮トライには存在しないはずが実装ミスでできる場合があるlen(node.children) >= 2で判定し、$N-1$との不一致で実装バグに気づけるようにする

次のステップ

  • 発展: 各葉にIDを持たせ、クエリ文字列と最大LCPを達成する文字列そのものも復元できるようにする
  • 発展: 挿入だけでなく削除もサポートし、削除後に分岐ノードが単一子になったら辺をマージし直す
  • 発展: 整数列のXOR最大値クエリ(01-Trie応用)にPatricia Trie圧縮を組み合わせ、省メモリ実装を検討する

自己評価

自分の回答

気づき・メモ