跳到主要內容

資料結構

資料怎麼排,決定了哪些操作便宜

主題 · 空間索引

空間索引

找附近的點:Geohash 把座標變成可以排序、可以比字首的字串,四分樹把擁擠的區域一再切細,都不必逐一計算每個點的距離。

索引
0.9 km
走進去看的方塊量過距離的點在半徑內剛加入的點

在地圖上點一下,找出 0.9 公里內的點。沒有索引就得量全部 48 個點的距離;看看索引實際量了幾個。

點
48
葉子方塊
37
量了幾個點的距離
–
找到
–
亮起來的是這一步執行的程式碼
type Point = { id: number; x: number; y: number };
const CAPACITY = 4, MAX_DEPTH = 10;
class QuadNode {
points: Point[] | null = [];
children: QuadNode[] | null = null;
constructor(public x: number, public y: number, public size: number, public depth: number) {}
child(p: Point): QuadNode {
const half = this.size / 2;
return this.children![(p.x >= this.x + half ? 1 : 0) + (p.y >= this.y + half ? 2 : 0)];
}
}
class QuadTree {
root = new QuadNode(0, 0, 1, 0);
visited = 0;
checked = 0;
insert(p: Point): void {
let node = this.root;
while (node.children) node = node.child(p);
node.points!.push(p);
if (node.points!.length > CAPACITY && node.depth < MAX_DEPTH) this.split(node);
}
private split(node: QuadNode): void {
const half = node.size / 2;
node.children = [0, 1, 2, 3].map((i) =>
new QuadNode(node.x + (i & 1 ? half : 0), node.y + (i & 2 ? half : 0), half, node.depth + 1));
const moving = node.points!;
node.points = null;
for (const q of moving) node.child(q).points!.push(q);
for (const c of node.children) {
if (c.points!.length > CAPACITY && c.depth < MAX_DEPTH) this.split(c);
}
}
query(cx: number, cy: number, r: number): number[] {
this.visited = 0;
this.checked = 0;
const found: number[] = [];
const walk = (node: QuadNode): void => {
const dx = Math.max(node.x - cx, 0, cx - (node.x + node.size));
const dy = Math.max(node.y - cy, 0, cy - (node.y + node.size));
if (dx * dx + dy * dy > r * r) return;
this.visited++;
if (node.children) { node.children.forEach(walk); return; }
for (const p of node.points!) {
this.checked++;
const ex = p.x - cx, ey = p.y - cy;
if (ex * ex + ey * ey <= r * r) found.push(p.id);
}
};
walk(this.root);
return found.sort((a, b) => a - b);
}
}
const BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
function geohash(x: number, y: number, chars: number): string {
let xlo = 0, xhi = 1, ylo = 0, yhi = 1, out = "", value = 0;
for (let bit = 0; bit < chars * 5; bit++) {
if (bit % 2 === 0) {
const mid = (xlo + xhi) / 2;
value = value * 2 + (x >= mid ? 1 : 0);
if (x >= mid) xlo = mid; else xhi = mid;
} else {
const mid = (ylo + yhi) / 2;
value = value * 2 + (y >= mid ? 1 : 0);
if (y >= mid) ylo = mid; else yhi = mid;
}
if (bit % 5 === 4) { out += BASE32[value]; value = 0; }
}
return out;
}
class GeohashIndex {
buckets = new Map<string, Point[]>();
cols: number;
rows: number;
checked = 0;
constructor(public chars: number) {
this.cols = 2 ** Math.ceil((chars * 5) / 2);
this.rows = 2 ** Math.floor((chars * 5) / 2);
}
insert(p: Point): void {
const cell = geohash(p.x, p.y, this.chars);
const bucket = this.buckets.get(cell) ?? [];
bucket.push(p);
this.buckets.set(cell, bucket);
}
/** Assumes r is no larger than a cell. */
query(cx: number, cy: number, r: number): number[] {
this.checked = 0;
const found: number[] = [];
const ix = Math.floor(cx * this.cols), iy = Math.floor(cy * this.rows);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = ix + dx, ny = iy + dy;
if (nx < 0 || ny < 0 || nx >= this.cols || ny >= this.rows) continue;
const cell = geohash((nx + 0.5) / this.cols, (ny + 0.5) / this.rows, this.chars);
for (const p of this.buckets.get(cell) ?? []) {
this.checked++;
const ex = p.x - cx, ey = p.y - cy;
if (ex * ex + ey * ey <= r * r) found.push(p.id);
}
}
}
return found.sort((a, b) => a - b);
}
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。

什麼時候用

  • 「找我附近的 X」:附近的司機、餐廳、朋友,或遊戲裡附近的物體(碰撞偵測)。
  • 點的分布很不平均(城市密、郊區疏)時用四分樹:擁擠的地方自動切得比較細。
  • 要放進現成的鍵值或排序結構(Redis、資料庫索引)時用 geohash:它就是一個字串,字首相同代表在同一格。

和其他主題的關係

延伸閱讀
字首樹B-tree

出現在這些架構裡

時間與空間複雜度(Big O)

操作平均最差
四分樹:插入
d 是樹的深度:點很集中時樹會很深,這裡最多切 10 層
O(log n)O(d)
四分樹:查附近
k 是找到的點數
O(log n + k)O(n)
Geohash:插入
算一個固定長度的字串,丟進桶子
O(1)O(1)
Geohash:查附近(9 格)
精度配合半徑時;點都擠在幾格裡就退化成全部掃
O(1 + k)O(n)
沒有索引:逐一掃描O(n)O(n)

空間:O(n),每個點存一次,加上樹的節點或格子的桶

Big O 實測:n 變大時步數怎麼長

數的是:每次查詢的平均值;半徑跟著 n 縮小,讓每次大約找到 7 個點(像「找附近的司機」)

Big On = 1,000n = 4,000n = 16,000成長倍數:實測(理論)
四分樹:走訪的方塊O(log n)17.218.820.6×1.2 (×1.4)
四分樹:量距離的點O(1)17.617.618.5×1.0 (×1.0)
逐一掃描O(n)1,0004,00016,000×16 (×16)

點變成 16 倍,四分樹只多走了幾個方塊,量的點數幾乎不變;逐一掃描則跟著變成 16 倍。Geohash 沒有放進表裡:它的精度一次跳一個字元,格子數一次變 32 倍,所以每次查詢的工作量會隨 n 上下跳,不是一條平滑的曲線。

和其他做法比

平均找到四分樹量了幾個點Geohash(2 字元)量了幾個點逐一掃描
半徑 0.05 km0.85.984.310,000
半徑 0.15 km616.586.210,000
半徑 0.3 km27.345.886.310,000

10,000 個均勻分布的點,每種半徑各查 40 次取平均。四分樹量的點數跟著答案多寡變;geohash 的精度固定在 2 個字元(32 × 32 格),不管半徑多小都要掃九格,所以小半徑時白量了很多點。實務上 geohash 會依半徑挑精度,但精度一次跳一個字元、格子數變 32 倍,很難剛好。

真實世界裡的它

  • Redis 的 GEOADD/GEOSEARCH 把座標轉成 52 位元的 geohash,存在 sorted set 裡,查附近就是查幾段分數範圍。
  • Elasticsearch、MongoDB 的地理查詢都靠類似的格子;Uber 的 H3 用六角形格子做同一件事,因為六角形的鄰居距離都一樣。
  • PostGIS 用 R-tree(四分樹的親戚)索引任意形狀;遊戲引擎用四分樹或八分樹找可能相撞的物體。

取捨與陷阱

  • 邊界問題:兩個點只隔著一條格線,geohash 的字首卻可能完全不同,所以一定要連同周圍八格一起查。
  • geohash 的精度一次跳一個字元(格子數變 32 倍):格子要至少和查詢半徑一樣大,九格才夠;太大又會白量很多點。
  • 經緯度不是平面:越靠近兩極,一度經度越短、格子越扁;真正的距離要用球面公式(haversine)算。
  • 很多點疊在同一個位置時,四分樹會一直切下去,所以要限制最大深度。

LeetCode 練習