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 }) + } +}