空間索引
找附近的點: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:它就是一個字串,字首相同代表在同一格。
和其他主題的關係
- 被這些用到
- 系統設計 · 案例:叫車服務
出現在這些架構裡
時間與空間複雜度(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 O | n = 1,000 | n = 4,000 | n = 16,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 四分樹:走訪的方塊 | O(log n) | 17.2 | 18.8 | 20.6 | ×1.2 (×1.4) |
| 四分樹:量距離的點 | O(1) | 17.6 | 17.6 | 18.5 | ×1.0 (×1.0) |
| 逐一掃描 | O(n) | 1,000 | 4,000 | 16,000 | ×16 (×16) |
點變成 16 倍,四分樹只多走了幾個方塊,量的點數幾乎不變;逐一掃描則跟著變成 16 倍。Geohash 沒有放進表裡:它的精度一次跳一個字元,格子數一次變 32 倍,所以每次查詢的工作量會隨 n 上下跳,不是一條平滑的曲線。
和其他做法比
| 平均找到 | 四分樹量了幾個點 | Geohash(2 字元)量了幾個點 | 逐一掃描 | |
|---|---|---|---|---|
| 半徑 0.05 km | 0.8 | 5.9 | 84.3 | 10,000 |
| 半徑 0.15 km | 6 | 16.5 | 86.2 | 10,000 |
| 半徑 0.3 km | 27.3 | 45.8 | 86.3 | 10,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)算。
- 很多點疊在同一個位置時,四分樹會一直切下去,所以要限制最大深度。