实验 16:Z 序曲线(Z-order / Morton code)

为什么做这个实验

数据库里最好用、最成熟的索引是 B+ 树,可它只能对**一个**排序键做范围查找。
"找出某个矩形框内的所有点"是个二维问题,一个 B+ 树区间表达不了。

绕过去的办法是找一个映射

f: (x, y) → z(一个整数)

让**空间上邻近的点,在一维顺序里也尽量邻近**。这样二维的矩形查询就能近似变成"若干个 z 值区间",
直接复用现成的 B+ 树。这类映射叫**空间填充曲线**(space-filling curve,SFC);
Z 序(Z-order,也叫 Morton 码)是其中构造最简单的一种——只需要把两个坐标的二进制位**交织**起来。

本实验要弄清楚四件事:

1. 位交织怎么算、怎么反算(它是严格的一一映射,可逆);
2. 为什么"坐标高位相同 ⇒ 排序后仍邻近",也就是 Z 序局部性的来源;
3. 用普通一维 B+ 树索引二维数据的**两阶段流程**:先取候选、再精确过滤;
4. 在仓库自带的真实经纬度数据上,把 Z 序与顺序扫描、与 Hilbert 曲线的**候选率**量出来,
并说清楚这个对比的方法学前提。

一句话预告结论:Z 序不是"最好"的曲线,但它是**最便宜**的曲线——位交织一次就算完,
工业界大量系统(Geohash、S2 的部分环节)仍在用它。

运行前准备

• Python 3.9 及以上。

• 必选依赖:`numpy`(整数版 Morton 码与真实数据读取)、`matplotlib`(画图)。

• 可选依赖:`ipycanvas`(本 notebook 最后一个 cell 用它画交互式画布)。

装不上也没关系,改用下面给出的 matplotlib 版本即可。

• 缺包时安装:

```bash
python -m pip install numpy matplotlib
```

(本机已配置环境可用:`python -m pip install numpy matplotlib`)

• 数据文件:`notebooks/latitude_longitude.bin`(仓库自带,197 229 个经纬度点)。

读取数据时要 `os.chdir` 到仓库根目录,或用绝对路径。

• 打开方式:在仓库根目录执行 `jupyter notebook notebooks/Z-Order-Curve.ipynb`。

**关于字符串实现**:下面的教学版用字符串表示 z 码,一眼能看出每一位从哪来。
真实系统用整数(位运算),并且要一次算几百万个点——末尾给出了 numpy 向量化版本。

空间填充曲线家族与位交织规则

常见的空间填充曲线:

• **Z 序**(Z-order / Morton order):本实验的主角,构造最简单,只需位交织;

• **Gray 码变体**:把 Z 序的码字再套一次格雷码变换,缓解边界跳跃;

• **Hilbert 曲线**:递归的"U"形拼接,通常被认为局部性更好(**但有争议,见末尾**);

• **Peano 曲线 / Sierpinski 曲线**:其他递归构造。

位序约定(本 notebook 采用)

设递归层级为 L,x、y 各用 L 个二进制位表示,z 码共 2L 位。
记 x 的各位为 a_{L−1} … a₁ a₀、y 的各位为 b_{L−1} … b₁ b₀(下标 0 是最低位),则

z = ( b₂ a₂ b₁ a₁ b₀ a₀ )₂ (以 L = 3 为例)

即:**y 的一位、x 的一位**交替,从高位排到低位;y 的位落在 z 的奇数位,x 的位落在偶数位。

举例(L = 3,x = 2,y = 1):

x = 2 = 010 y = 1 = 001
y₂ x₂ y₁ x₁ y₀ x₀
z = 0 0 0 1 1 0 = "000110"

网格规模

| 递归层级 L | 网格 | 单元数 | z 码位宽 |
| --- | --- | --- | --- |
| 1 | 2 × 2 | 4 | 2 |
| 2 | 4 × 4 | 16 | 4 |
| 3 | 8 × 8 | 64 | 6 |
| L | 2^L × 2^L | 2^(2L) | 2L |

量化:浮点坐标必须先变成整数

真实经纬度是浮点数,位交织只认整数,所以要先**量化**到整数网格上。本实验用线性映射:

x = round( (lon + 180) / 360 × (2^L − 1) )
y = round( (lat + 90) / 180 × (2^L − 1) )

量化层级越高越精确,但码越长;量化本身也是一次精度损失——这是所有空间索引共同的取舍。

第 1 步:位交织(坐标 → z 码)

`scalar_to_z_code(recursion_level, x, y)` 做三件事:

1. 把 x、y 转成二进制字符串,并用 `zfill(recursion_level)` 左侧补零到 L 位;
2. 断言两者长度都等于 L(防止坐标超出网格范围);
3. 用 `"".join(map(add, y_binary, x_binary))` 把 y 与 x **逐位拼接**——
`map(add, y, x)` 把两个字符串的对应字符两两相加,于是第 i 次得到 y[i] + x[i],
拼起来正好是 `y_{L−1} x_{L−1} … y₀ x₀`。这就是位交织的全部实现。

预期看到:`scalar_to_z_code(3, 2, 1)` 输出 `'000110'`,与上一节的手算一致。

注意这是**字符串**实现,慢且占内存,只用于教学。真实系统用整数位运算(末尾给出)。

from operator import add


def scalar_to_z_code(recursion_level: int, x: int, y: int) -> str:
    """
    Converts scalar coordinates into a z-code.

    Notice that we use a string-representation of z-codes for educational purposes
    a real implementation would represent z-codes in a bit-vector or map them to signed integers
    :param recursion_level: the recursion_level of the z-code
    :param x: x-coordinate
    :param y: y-coordinate
    :return: z-code as a string
    """
    if recursion_level == 0:
        # return empty string for recursion_level 0
        return ""

    # get the binary representation of x and y as a string and prefix them with zeros:
    x_binary = bin(x)[2:].zfill(recursion_level)
    y_binary = bin(y)[2:].zfill(recursion_level)

    assert len(x_binary) == len(y_binary) == recursion_level

    # zip the binary representations of x and y and interleave them#
    # this is the actual "trick" of z-codes: the bits of the n=2 binary numbers are interleaved
    ret = "".join(map(add, y_binary, x_binary))

    assert len(ret) == 2 * recursion_level

    return ret


scalar_to_z_code(3, 2, 1)

第 2 步:逆变换(z 码 → 坐标)

`z_code_to_scalar(z_code)` 把交织好的码拆回去:

• 码长除以 2 就是递归层级 L;

• 从**最高位**开始,每 2 位一组:`z_code[i]` 属于 y,`z_code[i+1]` 属于 x;

• 每组的权重是 2^exponent,exponent 从 L−1 递减到 0。

所以它能还原,是因为位交织是**严格的一一映射**:z 码里从低位起的第 0、2、4… 位就是 x 的各位,
第 1、3、5… 位就是 y 的各位,按原权重累加回去即可,信息一点没丢。

最后一个 cell 是**往返验证**:`z_code_to_scalar(scalar_to_z_code(2, 3, 1))` 应返回原坐标。
如果这里对不上,99% 是位序写反了(把 x 放到了高位)——这是本实验最常见的错误。

def z_code_to_scalar(z_code: str) -> tuple[int, int, int]:
    """
    inverse of scalar_to_z_code function

    converts z-code to scalar-coordinates:
    :param z_code: string-representation of a z-code
    :return: recursion_level, scalar x, scalar y
    """
    assert len(z_code) % 2 == 0

    dimensions: int = 2

    recursion_level: int = len(z_code) // dimensions
    x: int = 0
    y: int = 0
    exponent: int = int(recursion_level) - 1

    i: int
    for i in range(0, len(z_code), 2):
        # reconstruct x and y by reversing the interleaving of the z-code
        # basically builds sum of 2^i exponents
        y += int(z_code[i]) * 2**exponent
        x += int(z_code[i + 1]) * 2**exponent
        exponent -= 1

    return recursion_level, x, y


z_code_to_scalar("000110")
z_code_to_scalar(scalar_to_z_code(2, 3, 1))

第 3 步:把整张表的 z 码打印出来

辅助函数 `color_code` 只是给码字分段上 ANSI 颜色(终端转义序列),
让相邻两位的来源在屏幕上更容易分辨;它不影响码值本身。

• `print_z_codes(L)`:按 y 从 0 到 2^L−1、x 从 0 到 2^L−1 的顺序打印整个网格的 z 码;

• `compute_z_codes(L)`:返回 `(z_code, x, y)` 三元组列表,供后面画图用。

观察要点(用 L = 3 试):

• 沿同一行看,变化的是 z 码的**低位**;

• 跨行时变化的是**高位**;

• 整体读下来,码值从小到大的路径正是那个"Z"字形。

这也直观说明了局部性的来源:**码的高位来自坐标的高位**。
两个点的高位相同 ⇒ 它们落在同一个粗粒度的象限里 ⇒ 它们的 z 码也共享高位
⇒ 按数值排序后必然连成一段连续区间,在一维顺序里仍然邻近。

反过来,一旦跨象限,高位一变,一维顺序上就会出现一次**大跳跃**——
Z 曲线在象限边界上的那些长跳线,就是这个效应的可视化,也是 Z 序的主要缺点。

startEsc: str = "\033[1;"
middleEsc: str = "m"
endEsc: str = "\033[1;m"


def color_code(string: str, stepSize: int = 2):
    """color-codes a string in steps of <stepSize> with different colors

    :param string: the string to color-code
    :param stepSize: the step-size for coloring
    """
    color: int = 31
    i: int
    for i in range(0, len(string), stepSize):
        prefix: str = string[i : i + stepSize]
        print(startEsc + str(color) + middleEsc + prefix + endEsc, end="")
        color += 1


color_code("hello world")
def print_z_codes(recursion_level: int):
    """print all z-codes of a given recursion_level

    :param recursion_level: the recursion level
    """
    y: int
    for y in range(2**recursion_level):
        x: int
        for x in range(2**recursion_level):
            z_code: str = scalar_to_z_code(recursion_level, x, y)
            color_code(z_code)
            print(" ", end="")
        print()


print_z_codes(3)
def compute_z_codes(recursion_level: int):
    """computes all z-codes for the given recursion_level

    @param recursion_level: the recursion level
    @return: a list of tuples containing the z-code, scalar x, and scalar y coordinates
    """
    results: list[tuple[str, int, int]] = []
    y: int
    for y in range(2**recursion_level):
        x: int
        for x in range(2**recursion_level):
            z_code: str = scalar_to_z_code(recursion_level, x, y)
            results.append((z_code, x, y))

    return results

第 4 步:把 Z 曲线画出来

`visualize_z_codes(L)` 用 `ipycanvas` 画交互式画布:它先算出整个网格的 z 码,
**按码值排序**,再依次连线,于是笔尖走出的轨迹就是 Z 曲线。

预期看到(L 从 1 到 5 依次画):每一级递归都在四个象限里重复一个"Z"字,
层级越高,"Z"字越小越密,整体是一条自相似的折线。

**如果 `ipycanvas` 装不上**,用下面这个 matplotlib 版本替代(只依赖 numpy + matplotlib):

```python
import numpy as np
import matplotlib.pyplot as plt

def morton(x, y, levels=16):
"""向量化的 Morton 码:y 位放奇数位,x 位放偶数位,与教学版位序一致。"""
x = np.asarray(x, dtype=np.int64)
y = np.asarray(y, dtype=np.int64)
z = np.zeros_like(x)
for i in range(levels):
z |= ((y >> i) & 1) << (2 * i + 1)
z |= ((x >> i) & 1) << (2 * i)
return z

def draw_z_curve(L=3):
n = 1 << L
xs, ys = np.meshgrid(np.arange(n), np.arange(n))
xs, ys = xs.ravel(), ys.ravel()
order = np.argsort(morton(xs.astype(np.int64), ys.astype(np.int64), levels=L),
kind="stable")
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
fig, ax = plt.subplots(figsize=(6, 6))
ax.plot(xs[order], ys[order], "-o", color="#1F4E79", ms=8, lw=2)
ax.set_aspect("equal")
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_title(f"Z 曲线(递归层级 {L},{n}×{n} 网格)")
ax.grid(ls=":", alpha=0.4)
plt.tight_layout()
plt.show()

draw_z_curve(3)
```

把 L 改成 1、2、4 各画一次,看递归结构怎么长出来。

from ipycanvas import Canvas, hold_canvas
from IPython.display import display


def visualize_z_codes(recursion_level: int):
    """visualize the data space in z-code order, i.e. draw the z-curve

    :param recursion_level: the recursion level
    """
    results: list[tuple[str, int, int]] = compute_z_codes(recursion_level)
    results.sort()
    x_width: int = 300
    y_width: int = x_width
    canvas = Canvas(size=(x_width, y_width), sync_image_data=True)
    canvas.line_width = 1
    max_x: int = 2**recursion_level
    max_y: int = max_x
    mult_x: float = x_width / max_x
    mult_y: float = y_width / max_y
    start: tuple[str, int, int] = results.pop(0)
    start_x: int = start[1]
    start_y: int = start[2]

    with hold_canvas(canvas):
        while len(results) > 0:
            next: tuple[str, int, int] = results.pop(0)
            next_x: int = next[1]
            next_y: int = next[2]
            canvas.begin_path()
            canvas.move_to((start_x + 0.5) * mult_x, (start_y + 0.5) * mult_y)
            canvas.line_to((next_x + 0.5) * mult_x, (next_y + 0.5) * mult_y)
            canvas.stroke()
            start_x, start_y = next_x, next_y

    return canvas


# visualize the z-codes for recursion levels 1 to 5
for _recursion_level in range(1, 6):
    display(visualize_z_codes(_recursion_level))

在真实数据上量一量:197 229 个经纬度点

仓库自带 `notebooks/latitude_longitude.bin`:

• 197 229 个点;

• **float32 小端**存储,纬度、经度**交替**存放:`lat = raw[0::2]`、`lon = raw[1::2]`;

• 纬度范围 47.28° – 55.07°,经度范围 −2.01° – 50.51°(欧洲中北部一带)。

用 z 码做范围查询:两阶段流程

① 候选阶段(filter):把查询矩形翻译成一段(或几段)z 区间,用一维索引(B+ 树 / 有序数组 + 二分)取出区间内所有点
② 精化阶段(refine):对候选点逐个用真实坐标判断是否在矩形内

第 ① 步取到的是矩形的**超集**:一维区间无法精确表达二维矩形,
所以第 ② 步**不可省略**。工业实现会用 `BIGMIN` / `LITMAX` 把大区间切成若干真正落在矩形内的小段,
跳过大段空洞,从而少取候选。

衡量指标:**候选率 = 候选点数 / 真实命中点数**。越接近 1 越好;
顺序扫描的候选率 = 总点数 / 命中点数。

实测结果(同一 2^16 网格,89 个有效查询窗口,取中位数)

| 方案 | 候选数 / 真实命中数 |
| --- | --- |
| 顺序扫描 | 12.64× |
| Z 序索引 | 3.19× |
| Hilbert 序索引 | **1.77×** |

(口径:随机生成小窗口矩形查询;**只统计真实命中数 ≥ 5 的查询**——命中太少的查询不足以体现差异,
本次有效窗口 89 个;Z 序与 Hilbert 序建立在**同一个** 2^16 网格上。数字以本案例库
《00-实测数字口径表》为准,本机重跑会随随机种子略有差异。)

复现要点:量化到 2^16 网格 → 算码 → 按码排序 → 用 `np.searchsorted` 取区间
(右端用 `side="right"`)→ 与真实命中数求比值 → 取中位数。

一个必须讲的方法学教训:比较两条曲线要站在同一把尺子上

本案例库早期版本做过一次对比:Z 码用 2^16 网格、Hilbert 码用 2^10 网格。
结果 Hilbert 看起来领先很多——但那个"优势"是假的。

原因:网格越粗,同一个查询框在码空间里覆盖的区间越短,候选集天然就更少。
也就是说,测出来的差距有相当一部分来自**量化层级不同**,而不是曲线本身的性质。
那个数字已经作废,**不要再引用**。

现在把两者统一到 2^16 网格后,Hilbert 相对 Z 序的领先幅度从约 2.5 倍回落到约 1.8 倍(3.19 → 1.77)。
这才是可复现、可辩护的对比。

结论值得单独记住:**任何对比实验,只要两方的"其他条件"不一致,读出来的差距就不可信。**
比较两条空间填充曲线之前,先确认它们站在同一把尺子上——同网格、同查询集、同过滤阈值、同统计口径。

顺便说一句:别把"Hilbert 一定比 Z 序好"当定论

这个说法**有争议**,取决于用什么度量。Dai & Su(DMTCS 2003)在"平均跨簇距离"与
"平均总跨簇距离"两项统计上得到的结论是 **Z 序家族优于 Hilbert 家族**,与很多教材的说法相反。

本实验在"小窗口范围查询的候选率"这个口径下 Hilbert 更好(1.77× vs 3.19×),
但**换一个度量就可能翻转**。工程上的正确做法是:用自己的真实数据和真实查询分布跑一遍,
按候选率(或干脆按端到端查询耗时)来选,而不是照搬结论。

Z 序在真实系统里的落地形态

| 系统 / 结构 | 做法 | 备注 |
| --- | --- | --- |
| **Geohash** | 基于 Z 序,把经纬度交替二分后编成 base32 字符串 | 前缀相同表示区域相邻,常用作前缀匹配的粗过滤 |
| **Google S2** | 用**六条 Hilbert 曲线**首尾相连覆盖球面(立方体投影的六个面) | 层级 level 0–30,单元 ID 是 64 位整数 |
| **Uber H3** | 六边形层次网格,分辨率 0–15 | 总单元数 c(r) = 2 + 120·7^r;**每层恰有 12 个五边形**(六边形无法完美铺满球面,这是拓扑上不可避免的) |
| **R 树** | 用最小外接矩形(MBR)组织空间对象,不是线性化路线 | 出处:Guttman,《R-trees: A Dynamic Index Structure for Spatial Searching》,SIGMOD 1984 |
| **PostGIS** | GiST / SP-GiST 索引 | 见下方 `ST_DWithin` 的提醒 |
| **GeoParquet** | 列式空间数据文件格式,带 bbox 统计便于裁剪 | 配合 DuckDB / Sedona 做列存空间分析 |
| **Apache Sedona** | 在 Spark 之上提供空间分区、空间索引与空间连接算子 | 海量空间数据的分布式处理路径 |

三者取舍:Geohash / S2 的四边形单元便于位运算与前缀查询;H3 的六边形单元到邻居的距离更均匀
(每个单元有 6 个等距邻居),适合邻域聚合,代价是那 12 个五边形。

**PostGIS 的一个高频坑**:`ST_DWithin` 是**索引感知**(index-aware)的距离阈值谓词,
能用 GiST 索引先做包围盒粗过滤;而 `ST_Distance(...) < d` 只是一个普通表达式,
优化器不知道它等价于"距离阈值",只能逐行算距离再比较,**不走索引**,大表上退化成全表扫描。
(依据:PostGIS 官方文档 Chapter 5。)

```sql
-- 推荐:能走 GiST 索引
SELECT * FROM restaurants
WHERE ST_DWithin(geom, ST_MakePoint(:lon, :lat)::geography, 500);

-- 不推荐:全表扫描
SELECT * FROM restaurants
WHERE ST_Distance(geom, ST_MakePoint(:lon, :lat)::geography) < 500;
```

思考题与详解

**1. 手算 `scalar_to_z_code(3, 5, 2)`:写出 x、y 的二进制与交织过程,再用代码验证。**

<details><summary>答案要点</summary>

x = 5 = 101 y = 2 = 010
位序 y₂ x₂ y₁ x₁ y₀ x₀
0 1 1 0 0 1
z 码 = "011001"

即 a₂a₁a₀ = 101(x = 5),b₂b₁b₀ = 010(y = 2),按 y 位在前、x 位在后交织得 `0 1 1 0 0 1`。

验证:`print(scalar_to_z_code(3, 5, 2))` 输出 `'011001'`。

</details>

**2. 求 `z_code_to_scalar("011001")` 的结果,并说明逆变换为什么能还原出 x 与 y。**

<details><summary>答案要点</summary>

码长 6 ⇒ L = 3。按 2 位一组拆:`01 | 10 | 01`,权重从 2² 递减到 2⁰:

第 1 组 "01"(权重 4):y += 0×4 = 0,x += 1×4 = 4
第 2 组 "10"(权重 2):y += 1×2 = 2,x += 0×2 = 0
第 3 组 "01"(权重 1):y += 0×1 = 0,x += 1×1 = 1
结果:x = 5,y = 2 → 返回 (3, 5, 2)

能还原的原因是位交织是**一一映射**:从 z 码最低位起,第 0、2、4… 位恰好是 x 的各位,
第 1、3、5… 位是 y 的各位,把它们按原来的权重 2^i 累加回去即可,信息没有丢失。

(若你的结果对不上,先检查位序:本 notebook 约定 **y 在奇数位、x 在偶数位**。)

</details>

**3. 递归层级 L = 16 时,网格边长是多少?单元总数是多少?z 码需要几个二进制位、能否放进一个 64 位整数?**

<details><summary>答案要点</summary>

• 网格边长 = 2^16 = **65 536**

• 单元总数 = 2^16 × 2^16 = 2^32 = **4 294 967 296**(约 43 亿)

• z 码位宽 = 2L = **32 位**,**能**放进 64 位整数(也能放进无符号 32 位整数)

延伸:若 L 取到 32,z 码就是 64 位,此时必须注意有符号整数溢出,要用 `uint64`;
Python 的 `int` 虽然不会溢出,但与 C/NumPy 交互时类型要显式指定。

</details>

**4. 为什么范围查询必须"先取候选、再精确过滤"?举一个具体反例:某点的 z 值落在 `[z(x₀,y₀), z(x₁,y₁)]` 内,但点本身不在查询矩形里。**

<details><summary>答案要点</summary>

取 L = 2(4×4 网格),查询矩形 x ∈ [1, 2]、y ∈ [0, 1]。

z(1, 0):x = 01, y = 00 → y₁ x₁ y₀ x₀ = 0 0 0 1 = 1
z(2, 1):x = 10, y = 01 → y₁ x₁ y₀ x₀ = 0 1 1 0 = 6
候选区间 = [1, 6]

区间里的 z = 2 对应二进制 `0010`:`y₁ x₁ y₀ x₀ = 0 0 1 0` ⇒ y = `01` = 1,x = `00` = 0,
即点 **(0, 1)**。它的 x = 0 不在 [1, 2] 内——**落在 z 区间里却不在矩形里**。

原因:**一维区间无法精确表达二维矩形**,z 区间只是矩形的一个超集。所以精化阶段
(逐个用真实坐标判断)不可省略。工业实现用 `BIGMIN` 算法把大区间切成若干"真正落在矩形内"的小段,
跳过大段空洞,从而减少这类无用候选。

</details>

**5. 关于 Hilbert 与 Z 序的优劣,文献结论一致吗?Dai & Su(DMTCS 2003)得到的是什么结论?工程上应该怎么处理?**

<details><summary>答案要点</summary>

**不一致。** Dai & Su(DMTCS 2003)在"平均跨簇距离"与"平均总跨簇距离"两项统计上,
得到的结论是 **Z 序家族优于 Hilbert 家族**;而很多教材与工程经验又说 Hilbert 局部性更好。

分歧来自**度量口径**不同:用哪种距离、按哪种查询分布来统计,结论会变。
本实验在"小窗口范围查询的候选率"这个口径下 Hilbert 更好(1.77× vs 3.19×),
但**换一个度量就可能翻转**。

工程做法:**不要照搬结论,用自己的真实数据 + 真实查询分布实测一遍**,
按候选率(或干脆按端到端查询耗时)来选曲线与层级。

</details>

**6. 有人把 Z 码用 2^16 网格、Hilbert 码用 2^10 网格做对比,得出 Hilbert 大幅领先的结论。这个比较公平吗?请设计一个公平的方案。**

<details><summary>答案要点</summary>

**不公平。** 网格越粗,同一个查询框在码空间里覆盖的区间越短,候选集天然更少。
所以那个"领先"里混进了量化层级的差异,不是曲线本身的性质——
本案例库早期那个数字已经作废,不要引用。统一到同一 2^16 网格后,
Hilbert 的领先幅度从约 2.5 倍回落到约 1.8 倍(3.19 → 1.77)。

公平方案:

1. **同一量化层级**:两者都用 L = 16(或都用 L = 10),量化公式完全一致;
2. **同一查询集**:同一批随机小窗口,同样的"命中数 ≥ 5 才计入"过滤;
3. **同一统计口径**:同样的候选数计算方式(`searchsorted` 的 `side` 一致)、同样的中位数而非均值;
4. 若 Hilbert 的逐点循环太慢,可**抽样**再做,但两侧必须抽**同一批点**。

一句话:**比较两条曲线之前,先确认它们站在同一把尺子上。**

</details>

**7. Z 序在真实系统里有哪些落地形态?请说明 Geohash、Google S2、Uber H3 各自的编码方式,以及 H3 每层有多少个五边形。**

<details><summary>答案要点</summary>

• **Geohash**:基于 **Z 序**,把经纬度交替二分后编成 base32 字符串;

前缀相同表示区域相邻,常用作前缀匹配的粗过滤。

• **Google S2**:把球面(立方体投影后的六个面)用 **六条 Hilbert 曲线首尾相连**覆盖,

层级 level 0–30,单元 ID 是 64 位整数。

• **Uber H3**:**六边形**层次网格,分辨率 0–15,总单元数 c(r) = 2 + 120·7^r;

由于六边形无法完美铺满球面,**每层恰有 12 个五边形**(拓扑上不可避免)。

取舍:Geohash / S2 的四边形单元便于位运算与前缀查询;H3 的六边形单元到邻居的距离更均匀
(每个单元 6 个等距邻居),适合邻域聚合,代价是那 12 个五边形。

另外,不走线性化路线的还有 **R 树**(Guttman,SIGMOD 1984,用最小外接矩形组织空间对象),
以及 PostGIS 的 GiST / SP-GiST 索引、GeoParquet 列式格式、Apache Sedona(Spark 上的空间计算)。

</details>

**8. 在 PostGIS 里想查"距离某点 500 米以内的餐厅",为什么应该写 `ST_DWithin` 而不是 `ST_Distance < 500`?**

<details><summary>答案要点</summary>

`ST_DWithin(geom1, geom2, d)` 是**索引感知**(index-aware)的谓词:
PostGIS 会先用 GiST 索引的包围盒做粗过滤,再算精确距离,因此**能走索引**。

而 `ST_Distance(geom1, geom2) < d` 只是一个普通表达式:优化器不知道它等价于"距离阈值",
只能对每一行都算一次距离再比较,**不使用索引**,在大表上退化成全表扫描。

```sql
-- 推荐:能走 GiST 索引
SELECT * FROM restaurants
WHERE ST_DWithin(geom, ST_MakePoint(:lon, :lat)::geography, 500);

-- 不推荐:全表扫描
SELECT * FROM restaurants
WHERE ST_Distance(geom, ST_MakePoint(:lon, :lat)::geography) < 500;
```

(依据:PostGIS 官方文档 Chapter 5。)

这个坑与 Z 序实验是同一个道理:**能不能用上索引,取决于谓词的形式,而不是语义。**
想让优化器用索引,就要写成它能识别的谓词形式。

</details>