From f30615ae3049c3c3f912f6ff65cd659ea5aff1fa Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 4 Nov 2026 18:00:53 -0800 Subject: [PATCH] Build the K-D tree with quickselect instead of sorting at every level KDTree sorted a copy of the cells at every level and split it into sublists. It now selects only the median inside one array (Wirth's quickselect) and recurses on index ranges. Hex cell centres share x per column, so the partition closes in from both ends; a one-sided partition is O(n^2) on equal keys. The caller's list is not reordered. HexCellRegistry.setPoint no longer replaces a cell that is already registered, so the cell it returns is the one the tree holds. Only marking the tree dirty when the set of cells changes was already in place here. Same change as MapConductor/js-sdk-core#20. --- .../core/geocell/HexCellRegistry.kt | 12 ++- .../com/mapconductor/core/geocell/KDTree.kt | 73 +++++++++++--- .../core/geocell/KDTreeBuildTest.kt | 95 +++++++++++++++++++ 3 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 src/test/java/com/mapconductor/core/geocell/KDTreeBuildTest.kt diff --git a/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt b/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt index 0abe2b5..b8673e5 100644 --- a/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt +++ b/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt @@ -63,11 +63,12 @@ class HexCellRegistry( // 位置が変わらない更新は珍しくない — アイコンやフラグの差し替え( // `MarkerManager.updateEntity` は毎回 setPoint を呼ぶ)や、 // [com.mapconductor.core.marker.MarkerViewportSwitch] のタイル⇔ネイティブ切り替えが該当する。 - if (previousCellId == cellId && allCells.containsKey(cellId)) { + val sameCell = if (previousCellId == cellId) allCells[cellId] else null + if (sameCell != null) { entryIDsByCell.compute(cellId) { _, existingSet -> (existingSet ?: mutableSetOf()).apply { add(entityId) } } - return cell + return sameCell } var cellsChanged = false @@ -80,8 +81,9 @@ class HexCellRegistry( } } - // Add to new cell - if (allCells.put(cellId, cell) == null) cellsChanged = true + // Add to new cell. 既にあるセルは置き換えない(木が持っているものと揃える) + val existing = allCells.putIfAbsent(cellId, cell) + if (existing == null) cellsChanged = true allEntries[entityId] = cellId // Add entity to cell's entry list @@ -90,7 +92,7 @@ class HexCellRegistry( } if (cellsChanged) markDirty() - return cell + return existing ?: cell } /** diff --git a/src/main/java/com/mapconductor/core/geocell/KDTree.kt b/src/main/java/com/mapconductor/core/geocell/KDTree.kt index 360ec85..af106a2 100644 --- a/src/main/java/com/mapconductor/core/geocell/KDTree.kt +++ b/src/main/java/com/mapconductor/core/geocell/KDTree.kt @@ -12,7 +12,14 @@ import kotlin.math.sqrt class KDTree( points: List, ) { - private val root = if (points.isNotEmpty()) build(points, 0) else null + // 呼び出し元のリストは並べ替えない + private val root = + if (points.isNotEmpty()) { + val items = points.toTypedArray() + build(items, 0, items.size, 0) + } else { + null + } private class Node( val cell: HexCell, @@ -23,24 +30,27 @@ class KDTree( /** * Build the K-D tree recursively + * + * `items` の from から to の手前までの中で中央値だけを選び(quickselect)、左右を同じ配列のまま + * 再帰する。階層ごとにソートしてリストを作ると、数万件で数百ミリ秒かかり、 + * マーカーを足し引きした直後の空間検索がその間止まる。 */ private fun build( - items: List, + items: Array, + from: Int, + to: Int, depth: Int, ): Node? { - if (items.isEmpty()) return null + if (from >= to) return null val axis = depth % 2 - val sorted = - items.sortedBy { - if (axis == 0) it.centerXY.x else it.centerXY.y - } - val mid = sorted.size / 2 + val mid = from + (to - from) / 2 + selectNth(items, from, to - 1, mid, axis) return Node( - cell = sorted[mid], - left = build(sorted.subList(0, mid), depth + 1), - right = build(sorted.subList(mid + 1, sorted.size), depth + 1), + cell = items[mid], + left = build(items, from, mid, depth + 1), + right = build(items, mid + 1, to, depth + 1), axis = axis, ) } @@ -326,3 +336,44 @@ data class KDTreeStats( val maxDepth: Int, val isEmpty: Boolean, ) + +private fun axisValue( + cell: HexCell, + axis: Int, +): Double = if (axis == 0) cell.centerXY.x else cell.centerXY.y + +/** + * `items` の left から right まで(両端を含む)を、k 番目の要素が軸の値で k 番目になるよう並べ替える + * (左は以下、右は以上)。Wirth の quickselect。 + * + * 六角セルの中心は列ごとに同じ x を持つので、等しい値が大量に並ぶ。 + * 左右から寄せる分割にしてあるのはそのため(片側に寄せる分割だと O(n²) になる)。 + */ +private fun selectNth( + items: Array, + left: Int, + right: Int, + k: Int, + axis: Int, +) { + var lo = left + var hi = right + while (lo < hi) { + val pivot = axisValue(items[k], axis) + var i = lo + var j = hi + do { + while (axisValue(items[i], axis) < pivot) i++ + while (pivot < axisValue(items[j], axis)) j-- + if (i <= j) { + val tmp = items[i] + items[i] = items[j] + items[j] = tmp + i++ + j-- + } + } while (i <= j) + if (j < k) lo = i + if (k < i) hi = j + } +} diff --git a/src/test/java/com/mapconductor/core/geocell/KDTreeBuildTest.kt b/src/test/java/com/mapconductor/core/geocell/KDTreeBuildTest.kt new file mode 100644 index 0000000..c52eb8d --- /dev/null +++ b/src/test/java/com/mapconductor/core/geocell/KDTreeBuildTest.kt @@ -0,0 +1,95 @@ +package com.mapconductor.core.geocell + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.projection.ProjectedPoint +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * [KDTree] の構築(quickselect)が、総当たりと同じ答えを返すこと。 + * + * 階層ごとにソートしてリストを作っていた構築を、1 本の配列の中で中央値だけを選ぶ形に + * 変えた。木の取り違えは何も落とさず、マーカーが静かに見つからなくなるだけなので、 + * 総当たりの結果と比べる。六角セルの中心は列ごとに同じ x を持つので、同じ座標が + * 大量に並ぶ格子でも確かめる(分割の取り違えはそこで出る)。 + */ +class KDTreeBuildTest { + private fun cellAt( + id: String, + x: Double, + y: Double, + ) = HexCell( + coord = HexCoord(0, 0), + centerLatLng = GeoPoint(0.0, 0.0), + centerXY = ProjectedPoint(x, y), + id = id, + ) + + private fun random(seed: Long): () -> Double { + var state = seed + return { + state = state * 6364136223846793005L + 1442695040888963407L + (state ushr 11).toDouble() / (1L shl 53).toDouble() + } + } + + private fun gridCells(): List = + (0 until 40).flatMap { col -> + (0 until 40).map { row -> + cellAt("c${col}_$row", col * 3.0, row * 2.0 + (col % 2)) + } + } + + private fun scatteredCells(count: Int): List { + val next = random(123456789L) + return (0 until count).map { cellAt("s$it", next() * 1000.0, next() * 1000.0) } + } + + private fun distSq( + a: ProjectedPoint, + b: ProjectedPoint, + ): Double = (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + + private fun assertMatchesBruteForce(cells: List) { + val tree = KDTree(cells) + assertEquals(cells.size, tree.getStats().nodeCount) + + val next = random(42L) + repeat(200) { + val query = ProjectedPoint(next() * 130.0 - 5.0, next() * 90.0 - 5.0) + + val nearest = tree.nearest(query)!! + val best = cells.minOf { distSq(it.centerXY, query) } + assertEquals(best, distSq(nearest.centerXY, query), 0.0) + + val radius = next() * 10.0 + val within = tree.withinRadiusWithDistance(query, radius).map { it.cell.id }.sorted() + val expected = + cells + .filter { distSq(it.centerXY, query) <= radius * radius } + .map { it.id } + .sorted() + assertEquals(expected, within) + + val k = 1 + (next() * 8).toInt() + val knn = tree.nearestKWithDistance(query, k).map { it.distanceMeters * it.distanceMeters } + val expectedKnn = cells.map { distSq(it.centerXY, query) }.sorted().take(k) + assertEquals(expectedKnn.size, knn.size) + expectedKnn.zip(knn).forEach { (e, a) -> assertEquals(e, a, 1e-6) } + } + } + + @Test + fun `同じ座標が並ぶ格子で総当たりと一致する`() = assertMatchesBruteForce(gridCells()) + + @Test + fun `ばらばらの点で総当たりと一致する`() = assertMatchesBruteForce(scatteredCells(3_000)) + + @Test + fun `渡したリストを並べ替えない`() { + val cells = scatteredCells(500) + val before = cells.map { it.id } + KDTree(cells) + assertEquals(before, cells.map { it.id }) + } +}