KdTree理解與實現(Java)
KdTree理解與實現(Java)
丟擲問題
如果讓你設計一個外賣系統,你的資料庫中有所有外賣商家所在的經緯度,那麼如何能有效地根據使用者的位置篩選出所有附近的商家?
最直接的方法是根據城市或者城市的每個區(如嶗山區,市南區…)來對商家進行分類,然後根據使用者所在的區返回同一區域下的所有商家。這個方法可以解決大部分問題,但是如果使用者位於兩個區的分界線周圍怎麼辦?
KdTree簡介
KdTree 是以二叉搜尋樹(Binary Search Tree)為原型的用於空間檢索的資料結構,能夠在隨機分佈的空間內以 O(log2N) 的時間複雜度實現對平面內點的搜尋以及 O(log2N) + R 的複雜度查詢平面內任意矩形內的所有點(R為矩形內點的個數)。 KdTree的應用十分廣泛,包括且不限於範圍搜尋,最鄰近點搜尋,物理引擎中的碰撞檢測以及地理節點(如外賣商家)資料庫等。
原理簡介
KdTree的實現方法與BST十分相似,以最常用的二維平面的KdTree為例,其每個節點儲存一個二維的座標點,並將平面空間以該點所在的橫線/豎線遞迴地分割成兩個子空間。
以width = 1.0, height = 1.0的單位平面為例,依次插入下列點
Note:
- 點對平面的分割方式是橫向/縱向按照層次交替出現(根節點是哪個方向都可以)。
- 插入節點的方法類似於BST,即從根節點開始,(設要插入的節點為Pinsert,當前遍歷的節點為Pcurrent)如果Pinsert在Pcurrent的左邊或者下邊,那麼就訪問Pcurrent的left child, 反之訪問right child直到成為葉子節點。
- 本KdTree不支援刪除操作。
程式碼實現
在介紹KdTree實現之前先定義兩個輔助類Point(用來表示點)和Rect(用來表示矩形)
Point.java
用來表示一個座標點,在本部落格的語境下只需要兩個方法:計算與另一點的距離(以平方和的形式)
// @file Point.java
// @author 王成昊
// @date 2018.10.14
public class Point {
public final double x;
public final double y;
// Point類是 immutable datatype
public Point(double x, double y) {
this.x = x;
this.y = y;
}
// 為了減少計算量,一般使用平方和來表示距離
public double distanceSquareTo(Point that) {
double dx = that.x - this.x;
double dy = that.y - this.y;
return dx * dx + dy * dy;
}
@Override
public boolean equals(Object that) {
if (this == that) return true;
if (that == null) return false;
if (that.getClass() != this.getClass()) return false;
Point point = (Point) that;
return (x == point.x) && (y == point.y);
}
}
Rect.java
用來表示一個矩形,在本例中使用四個座標值來表示一個矩形。需要的方法是 判斷矩形是否包含一個點 和 計算矩形和某點的距離(平方和的形式)
// @file Rect.java
// @author 王成昊
// @date 2018.10.14
public class Rect {
// 分別表示左下頂點和右上頂點
public final double minX;
public final double minY;
public final double maxX;
public final double maxY;
// Rect類是 immutable datatype
public Rect(double x0, double y0, double x1, double y1) {
minX = x0;
minY = y0;
maxX = x1;
maxY = y1;
}
// 判斷該點是否位於該矩形之內
public boolean contains(Point point) {
return (point.x >= minX) && (point.x <= maxX)
&& (point.y >= minY) && (point.y <= maxY);
}
// 計算矩形到某一點的最近距離(以平方和的形式)
public double distanceSquareToPoint(Point point) {
double dx = 0.0;
double dy = 0.0;
if (point.x < minX) dx = minX - point.x;
else if (point.x > maxX) dx = point.x - maxX;
if (point.y < minY) dy = minY - point.y;
else if (point.y > maxY) dy = point.y - maxY;
return dx * dx + dy * dy;
}
}
KdTree.java
本例中KdTree將實現4個功能:
- 插入
- 判斷是否包含某點
- 查詢任意矩形內的所有點
- 查詢距離某一點最近的點
// @file KdTree.java
// @author 王成昊
// @date 2018.10.14
import java.util.LinkedList;
public class KdTree {
// 節點類,其中 rect 成員表示該節點所分割的平面,
// 即它的左右孩子所表示的空間之和,該成員用於判斷
// 最鄰近點
private class Node {
Point point;
Rect rect;
Node left;
Node right;
Node (Point p, Rect r) {
point = p;
rect = r;
left = null;
right = null;
}
}
// 根節點
private Node root;
// 建構函式
public KdTree() {
root = null;
}
// 插入, 用同名私有方法遞迴實現, 預設根節點是縱向分割
public void insert(Point point) {
root = insert(point, root, false, 0.0, 0.0, 1.0, 1.0);
}
private Node insert(Point point, Node node, boolean isVertical,
double x0, double y0, double x1, double y1) {
if (node == null) {
return new Node(point, new Rect(x0, y0, x1, y1));
}
// 改變分割方向
isVertical = !isVertical;
// 判斷要插入的點在當前點的左/下還是右/上
double value0 = isVertical ? point.x : point.y;
double value1 = isVertical ? node.point.x : node.point.y;
if (value0 < value1) {
node.left = insert(point, node.left, isVertical,
x0, y0, isVertical ? node.point.x : x1, isVertical ? y1 : node.point.y);
} else {
node.right = insert(point, node.right, isVertical,
isVertical ? node.point.x : x0, isVertical ? y0 : node.point.y, x1, y1);
}
return node;
}
// 判斷是否包含該點, 用同名私有方法遞迴實現
public boolean contains(Point point) {
return contains(point, root, false);
}
private boolean contains(Point point, Node node, boolean isVertical) {
if (node == null) return false;
if (node.point.equals(point)) return true;
// 改變分割方向
isVertical = !isVertical;
// 判斷要查詢的點在當前點的左/下還是右/上
double value1 = isVertical ? point.x : point.y;
double value2 = isVertical ? node.point.x : node.point.y;
if (value1 < value2) {
return contains(point, node.left, isVertical);
} else {
return contains(point, node.right, isVertical);
}
}
// 返回矩形範圍內的所有點, 用同名私有方法遞迴實現
public Iterable<Point> range(Rect rect) {
LinkedList<Point> result = new LinkedList<Point>();
range(rect, root, false, result);
return result;
}
private void range(Rect rect, Node node, boolean isVertical, LinkedList<Point> bag) {
if (node == null) return;
// 改變分割方向
isVertical = !isVertical;
Point point = node.point;
if (rect.contains(point)) bag.add(point);
// 判斷當前點所分割的兩個空間是否與矩形相交
double value = isVertical ? point.x : point.y;
double min = isVertical ? rect.minX : rect.minY;
double max = isVertical ? rect.maxX : rect.maxY;
if (min < value) {
range(rect, node.left, isVertical, bag);
}
if (max >= value) {
range(rect, node.right, isVertical, bag);
}
}
// 返回距離該點最近的點, 用同名私有方法遞迴實現
public Point nearest(Point target) {
return nearest(target, root, null, false);
}
private Point nearest(Point target, Node node, Point currentBest, boolean isVertical) {
if (node == null) return currentBest;
isVertical = !isVertical;
double value1 = isVertical ? target.x : target.y;
double value2 = isVertical ? node.point.x : node.point.y;
// 繼續搜尋目標點所在的半區
Node next = value1 < value2 ? node.left : node.right;
Node other = value1 < value2 ? node.right : node.left;
Point nextBest = nearest(target, next, node.point, isVertical);
double currentDistance = 0;
double nextDistance = nextBest.distanceSquareTo(target);
if (currentBest == null) {
currentBest = nextBest;
currentDistance = nextDistance;
} else {
currentDistance = currentBest.distanceSquareTo(target);
if (nextDistance < currentDistance) {
currentBest = nextBest;
currentDistance = nextDistance;
}
}
// 判斷另一半區是否可能包含更近的點
if ((other != null) && (other.rect.distanceSquareToPoint(target) < currentDistance)) {
currentBest = nearest(target, other, currentBest, isVertical);
}
return currentBest;
}
public static void main(String[] args) {
// unit test
}
}
Note:
比較難理解的是nearest()方法,該方法為深度優先搜尋,邏輯是:
1.從根節點開始向下搜尋,遞迴搜尋優勢半區 (定義 目標點 為
public Point nearest(Point target)
中的target, 目標點所在的半區為優勢半區,另一半區為劣勢半區 ) ,並將當前點作為currentBest 引數傳遞給下層,直到葉子節點。
2.此時開始回溯,返回 nextBest ,獲得 {該節點優勢半區中的所有點,以及parent點} 中距離目標點最近的點 ,其最優距離為currentDistance。 此時考慮是否需要搜尋劣勢半區。
3.如果劣勢半區所在的矩形與目標點的距離小於currentDistance,則搜尋劣勢半區。換句話說,如果矩形到目標點的距離小於currentDistance,說明劣勢半區中有存在更近的點的可能。
複雜度比較
KdTree在最壞情況下的複雜度與暴力求解(用集合遍歷所有元素)一樣都是O(n), 但在隨機分佈的情況下可以達到O(log2N)。
以下為兩個資料結構在隨機分佈的空間中的演算法複雜度 (其中R表示矩形範圍內點的個數)
資料結構 | insert() | contains() | range() | nearest() |
---|---|---|---|---|
Set | 1 | N/2 | N | N |
KdTree | log2N | log2N | log2N + R | log2N |
結語
之前在學資料庫的時候大作業是做一個類似餓了麼的外賣網站,其中的一個難點是如何根據使用者所在的位置檢索出所有附近的商家。當時想了半天也想不出來怎麼能有效的進行區間搜尋,用的是暴力方法(因為是demo所以資料量很小),現在學到了KdTree之後真的是大徹大悟啊