Day 150-Q1 — kd木(k-dimensional tree・2次元最近傍探索)

2026-09-11 赤色 Master / Phase 8+ ★★★★★★★★★ 軸交互分割で空間を二分し、分割超平面までの距離だけで部分木を枝刈りする最近傍探索

問題

平面上の $N$ 個の点(1-indexed、点 $i$ は座標 $(x_i, y_i)$)が与えられる。以下の構築規則に従って2次元kd木を構築し、$Q$ 個のクエリ点それぞれについて、木を辿って求めた最近傍点(ユークリッド距離の2乗が最小の点。複数該当する場合は入力インデックスが最小のもの)のインデックスを出力せよ。

構築規則(深さ $d$、$d=0$ が根)

  1. 深さ $d$ が偶数なら分割軸は $x$ 座標、奇数なら $y$ 座標。
  2. 現在のノード集合を分割軸の値で安定ソート(値が等しい場合は入力インデックス昇順)。
  3. ソート後の下側中央(0-indexedで(サイズ-1)//2番目)をノードとする。
  4. 前方を左部分木、後方を右部分木として深さ $d+1$ で再帰構築。

最近傍探索

  1. 現在ノードとの距離2乗を計算し最良を更新。
  2. 分割軸 $a$・分割値 $s$ に対し、$q_a < s$ なら左が近い側、そうでなければ(等しい場合含む)右が近い側。
  3. 近い側を先に再帰探索。
  4. $(q_a-s)^2 \le$ 現在の最良距離2乗 なら遠い側も探索。そうでなければ枝刈り。

入力形式

N
x_1 y_1
:
x_N y_N
Q
qx_1 qy_1
:
qx_Q qy_Q

制約

$1 \le N \le 1000$
$1 \le Q \le 1000$
座標は $0$ 以上 $10^4$ 以下の整数
点の座標に重複がある場合がある

入出力例

入力例1

6
2 3
5 4
9 6
4 7
8 1
7 2
3
9 2
4 4
1 1

出力例1

5
2
1

概念図:構築されるkd木の構造

構築規則で得られる木(丸=ノード、ラベルは idx / 座標 / 分割軸) R idx2 (5,4) x軸 idx1 (2,3) y軸 idx6 (7,2) y軸 idx4 (4,7) x軸 idx5 (8,1) x軸 idx3 (9,6) x軸 idx1の左部分木は空(Rのみ)。クエリ(9,2)は root→右(idx6)→右(idx3)を近い側として辿るが 分割差(2-2)^2=0が最良距離2乗4以下なので左(idx5)も探索し、そこで最近傍idx5(距離2乗2)に更新される。 最後にroot左部分木への分割差(9-5)^2=16は最良距離2乗4を上回るため探索されない(枝刈り)。

ヒント(段階的開示)

ヒント1(方向性)

kd木は「1次元の二分探索木」を2次元に拡張したもの。深さごとに軸を交互に切り替えながら空間を半分ずつに分割していく。最近傍探索では、まず「近そうな側」を先に見て良い候補を確保し、その後「反対側にもっと近い点があり得るか」を分割超平面までの距離だけで判定する。

ヒント2(アプローチ)

分割超平面までの距離が現在の最良距離より大きいなら、反対側のどの点も分割超平面より遠い位置にしかないため、最良距離を更新できない。分割軸の座標差の2乗と現在の最良距離2乗を比較するだけで部分木全体を探索対象から除外できるのが枝刈りの正体。

ヒント3(誘導)
def build(points, depth=0):
    if not points:
        return None
    axis = depth % 2
    points.sort(key=lambda p: (p[1][axis], p[0]))
    mid = (len(points) - 1) // 2
    node = points[mid]
    left = build(points[:mid], depth + 1)
    right = build(points[mid+1:], depth + 1)
    return {"idx": node[0], "pt": node[1], "axis": axis, "left": left, "right": right}

def dist2(a, b):
    return (a[0]-b[0])**2 + (a[1]-b[1])**2

模範解答 (Python)

import sys


def build(points, depth=0):
    if not points:
        return None
    axis = depth % 2
    points = sorted(points, key=lambda p: (p[1][axis], p[0]))
    mid = (len(points) - 1) // 2
    node = points[mid]
    left = build(points[:mid], depth + 1)
    right = build(points[mid + 1:], depth + 1)
    return {"idx": node[0], "pt": node[1], "axis": axis, "left": left, "right": right}


def dist2(a, b):
    return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2


def nearest(tree, q):
    best = [None]

    def search(t):
        if t is None:
            return
        d = dist2(t["pt"], q)
        key = (d, t["idx"])
        if best[0] is None or key < best[0]:
            best[0] = key
        axis = t["axis"]
        diff = q[axis] - t["pt"][axis]
        near, far = (t["left"], t["right"]) if diff < 0 else (t["right"], t["left"])
        search(near)
        if diff * diff <= best[0][0]:
            search(far)

    search(tree)
    return best[0][1]


def main():
    data = sys.stdin.read().split()
    ptr = 0
    n = int(data[ptr]); ptr += 1
    points = []
    for i in range(1, n + 1):
        x = int(data[ptr]); y = int(data[ptr + 1]); ptr += 2
        points.append((i, (x, y)))
    tree = build(points)
    q = int(data[ptr]); ptr += 1
    out = []
    for _ in range(q):
        qx = int(data[ptr]); qy = int(data[ptr + 1]); ptr += 2
        out.append(str(nearest(tree, (qx, qy))))
    print("\n".join(out))


main()

Step-by-Step 解説

1構築規則を機械的に適用する
深さ0では$x$座標でソートし下側中央を根にする。6点中(6-1)//2=2番目、つまり点2 (5,4) が根。
2木を明示的に確認する
root=idx2(x軸)、左=idx1(y軸、右子のみidx4)、右=idx6(y軸、左子idx5・右子idx3)という構造になる。
3最近傍探索を1クエリ追う
クエリ(9,2)は root→右(idx6, 距離2乗4)→右(idx3, 更新なし)→分割差0≤4なので左(idx5, 距離2乗2で更新)→root左部分木は分割差16>4で枝刈り。答えはidx5。
4枝刈りなしとの比較
枝刈り条件を外すと全ノードを毎回訪問し、$N$が大きいと線形探索と同じ計算量に劣化する。

よくあるミス

ミス原因正しい書き方
同座標点での構築結果が入力順に依存ソートキーに軸値だけ使うソートキーに必ずインデックスを含める
分割差の判定を<のみにする境界ケースの見落とし等号を含む<=で判定する
近い側・遠い側の判定でdiff==0の扱いが曖昧構築規則との対応漏れdiff<0なら左、それ以外(0含む)は右と明確化
深さではなく部分木の高さで軸を切り替える再帰引数の誤り軸は根からの深さで決まる
ブルートフォースと照合せず提出検証不足小さい$N$では必ずブルートフォース最近傍と一致するはず

次のステップ

  • 発展: $k$-NNを求めるには「現在の最良候補」をヒープで$k$個保持し、枝刈り条件をヒープ内最大距離との比較に変える。
  • 次回予告: Cycle Canceling法(最小費用流の負閉路除去法)

自己評価

自分の回答

気づき・メモ