本章导读

哈希映射回答"这个键在哪",AVL 树回答"这个键旁边还有什么":中序迭代天然有序、范围查询("ID 在 20 到 30 之间的所有会话")一次走树。XRT 给了它两副面孔——侵入式 xavl(节点内嵌业务对象,插删零分配,与第 17 章链表同宗)与容器式 xavltree(树拥有对象,取出即移交)。两副面孔的选择逻辑与"值数组 vs 指针数组"(第 14 章)一脉相承:对象归属决定容器形态。本章用两个完整示例对照讲解,并把"多索引"(一棵按 ID、一棵按名称)这个侵入式的杀手级应用讲透。

20.1引入

一个会话管理器需要两种视图:运维侧要"按会话 ID 精确找到某条"(哈希就能做),审计侧要"按 ID 顺序导出全部"或"导出某段 ID 区间"——哈希做不到,排序数组插入成本线性,链表根本无序。AVL 树的答案是平衡二叉搜索树:插入删除查找全是 O(log n),中序遍历即有序序列,范围查询沿树走一趟。

第二个决策紧随其后:树和对象谁拥有谁?会话对象本体已经存在于池里(生命周期归池管),树只需要"把它们按 ID 组织起来"——侵入式 xavl,节点是对象的一个字段,零分配。而独立场景("这批配置项就该由树管生死")用容器式 xavltree,插入即移交、取出即收回。这个决策与第 14 章如出一辙,卷三的选择题到本章补全最后一块。

20.2概念

两副面孔一张表

维度侵入式 xavl容器式 xavltree
对象归属调用方(栈/池/数组放哪都行)树拥有
插入删除零分配(只改链接)插入分配节点
多索引一个对象加多个节点字段挂多棵树一棵树一个归属
比较函数从节点反查宿主(offsetof)直接比元素
典型场景池化对象的多视图索引独立配置/数据的有序 owning

中序即有序

1插入比较函数定位就位,失衡自动旋转——平衡是树的内部事务
2查找O(log n) 按比较函数下行
3中序迭代左-根-右的访问顺序 = 键升序序列
4范围查询下界起步、上界停止,只走区间子树

AVL 的"自动平衡"对使用者是黑盒:你只管提供三态比较函数,旋转、高度、再平衡由树自己维护。比较函数的签名值得看一眼——(查找键, 树节点, 用户数据):查找键与节点是不同类型(一个是键、一个是含键的节点),侵入式形态下比较函数先用 offsetof 反查宿主再取键比较——这个"反查"惯用法与第 17 章的 XRT_CONTAINER_OF 同源。

侵入式:零分配索引

xavl 句柄本身零内容(Init 只是初始化句柄),插入只重排既有对象的链接——全程无堆分配。业务对象放哪由你定:栈数组(示例一)、对象池(第 22 章)、甚至 slot_map——树不关心,只管链接。一个对象加两个节点字段就能同时挂两棵树:"按 ID 一棵、按名称一棵"是数据库式多索引的 C 形态。代价写在比较函数里:每次比较都要反查宿主,这层间接是侵入式的固有成本。

容器式:拥有与移交

xavltree 管对象生死:插入即移交(树内分配节点包裹对象),迭代交出对象指针,取出(Take 族)把对象还给你、节点归还树。它适合"数据天然就该由这棵树管"的场景——配置项、静态字典。不需要多索引、不需要外部池时,容器式少写一半样板。

与哈希映射的分工

AVL 与第 18 章的映射不是竞争关系,是分工关系。单键精确查找两者都 O(1)/O(log n) 量级,哈希的常数更小;但一旦问题变成"这个键旁边还有什么"——有序遍历、范围查询、找前驱后继、最小最大——哈希结构上就做不到,AVL 全部 O(log n) 起步。实践中的资源管理器常常两者并存:xmap 做热路径定位,xavl 做审计与导出的有序视图,同一批对象、两套索引(侵入式形态下一个对象挂两个索引正好)。第 23 章的选型表会把这条分工写成规则:只查 → 映射;查+序 → AVL;两者都要 → 并存,不要让一个容器硬扛两种问题。

重复键语义

插入时若树里已有同键节点,Insert 返回那个既有节点(非 NULL)——重复不报错、不覆盖,把"怎么办"交回调用方:替换值、拒绝、还是改名。返回值语义要记准:NULL 才是插入成功(与很多库相反),非 NULL 是"撞键了,这是旧节点"。

20.3示例

完整程序:侵入式索引与有序迭代

来自仓库范例 examples/containers/avl/main.c:

examples/containers/avl/main.c源码 ↗






















 */

#include <stdio.h>
#include <stddef.h>

#include <xrt.h>



/* 外部会话对象直接嵌入树节点,不产生额外分配。 */
typedef struct examplesession {
	int ID;
	const char* Name;
	xavlnode Index;          /* 侵入式链接:挂在 ID 索引树上 */
} examplesession;



/* 从侵入式链接恢复完整会话(节点地址 - 字段偏移)。 */
static examplesession* exampleSession(xavlnode* pNode)
{
	return (examplesession*)((bytes)pNode - offsetof(examplesession, Index));
}







 */
static int exampleCompare(const void* pKey, const xavlnode* pNode, ptr pUserData)
{
	const examplesession* pSession = (const examplesession*)(
		(cbytes)pNode - offsetof(examplesession, Index)
	);
	int iID = *(const int*)pKey;

	(void)pUserData;
	return (iID > pSession->ID) - (iID < pSession->ID);
}



int main(void)
{
	xavl tSessions;          /* 树句柄:本身零分配 */
	xavliter tIterator;
	examplesession pSessions[] = {
		{ 30, "gamma", { 0 } },
		{ 10, "alpha", { 0 } },
		{ 20, "beta", { 0 } }
	};

	if ( !xrtAVLInit(&tSessions) ) {
		return 1;
	}

	




	 */
	for ( size_t i = 0; i < 3; i++ ) {
		xrtAVLNodeInit(&pSessions[i].Index);
		if (
			xrtAVLInsert(
				&tSessions,
				&pSessions[i].Index,
				&pSessions[i].ID,
				exampleCompare,
				NULL,
				NULL
			) == NULL
		) {
			return 2;
		}
	}

	


	 */
	xrtAVLIterBegin(&tSessions, &tIterator);
	while ( true ) {
		xavlnode* pNode = xrtAVLIterNext(&tIterator);
		examplesession* pSession;

		if ( pNode == NULL ) {
			break;
		}
		pSession = exampleSession(pNode);
		printf("id=%d name=%s\n", pSession->ID, pSession->Name);
	}
	return 0;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single impl.c examples/containers/avl/main.c -lws2_32 -liphlpapiid=10 name=alphaid=20 name=betaid=30 name=gamma

刚才发生了什么。 ① 会话数组在栈上(30、10、20 乱序),Index 字段内嵌 xavlnode——挂树前 xrtAVLNodeInit 清零链接,这一步不能省。② 比较函数 (iID > pSession->ID) - (iID < pSession->ID):先反查宿主再取键,三态返回,减法式写法防溢出(第 14 章的老朋友)。③ xrtAVLInsert 返回 NULL 才算成功——循环里 == NULL 的分支就是成功分支。④ 全程零堆分配:树只是把三个栈上对象链接成了有序结构。⑤ 中序迭代输出恒为 10、20、30——插入顺序 30,10,20 与输出无关,"索引"的价值就是插入顺序与有序视图解耦。⑥ offsetof(examplesession, Index) 反查:节点地址减字段偏移得宿主地址——侵入式的配套惯用法。

完整程序:容器式与范围查询

来自 examples/containers/avl_tree/main.c,演示拥有式树与区间迭代:

examples/containers/avl_tree/main.c源码 ↗
























 */

#include <stdio.h>

#include <xrt.h>



/* 拥有型配置对象:整条结构会被复制进树内存储。 */
typedef struct exampleconfig {
	int ID;
	int Timeout;
} exampleconfig;



/* 按配置编号比较查找键与对象(容器式:直接拿对象指针)。 */
static int exampleCompare(const void* pKey, const void* pItem, ptr pUserData)
{
	int iID = *(const int*)pKey;
	int iItemID = ((const exampleconfig*)pItem)->ID;

	(void)pUserData;
	return (iID > iItemID) - (iID < iItemID);
}



int main(void)
{
	xavltree tConfigs;
	xavltreeiter tIterator;
	exampleconfig pInput[] = {
		{ 30, 3000 },
		{ 10, 1000 },
		{ 20, 2000 }
	};
	int iSearch = 20;

	/* 初始化即绑定元素大小与比较函数——之后树全权管理存储。 */
	if ( !xrtAVLTreeInit(&tConfigs, sizeof(exampleconfig), exampleCompare, NULL) ) {
		return 1;
	}

	


	 */
	for ( size_t i = 0; i < 3; i++ ) {
		if ( xrtAVLTreeAdd(&tConfigs, &pInput[i].ID, &pInput[i], NULL) == NULL ) {
			xrtAVLTreeUnit(&tConfigs);
			return 2;
		}
	}

	/* 点查 ID=20:拿到树内元素的直接指针(可读写)。 */
	{
		exampleconfig* pConfig = (exampleconfig*)xrtAVLTreeFind(&tConfigs, &iSearch);

		if ( pConfig != NULL ) {
			printf("id=%d timeout=%d\n", pConfig->ID, pConfig->Timeout);
		}
	}

	



	 */
	if ( xrtAVLTreeIterFrom(&tConfigs, &iSearch, &tIterator) ) {
		exampleconfig* pConfig;

		while ( (pConfig = (exampleconfig*)xrtAVLTreeIterNext(&tIterator)) != NULL ) {
			printf("range id=%d timeout=%d\n", pConfig->ID, pConfig->Timeout);
		}
	}

	/* 整树释放:所有元素存储一并归还。 */
	xrtAVLTreeUnit(&tConfigs);
	return 0;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single impl.c examples/containers/avl_tree/main.c -lws2_32 -liphlpapiid=20 timeout=2000range id=20 timeout=2000range id=30 timeout=3000

刚才发生了什么。 ① 插入即移交:配置对象进树后由树持有,迭代交出对象指针。② 先精确命中 id=20——查找与命中输出的形态。③ 范围迭代从下界 20 走到上界 40 前,输出 20、30——"区间导出"就是范围迭代的直接应用,整棵树扫都不需要。④ 树管生死:Unit/Destroy 时对象一并释放——容器式的拥有语义让它省心也让你少一层控制。

从示例到工程:索引的三层结构

真实的资源管理器通常是"三层索引结构":最底层是对象存储(池或 slot_map,管生死与稳定地址);中间层是定位索引(xmap 或 xintmap,键到句柄);顶层是有序视图(侵入式 xavl,审计导出与范围查询)。三层各司其职——存储层保证地址稳定,定位层扛热路径,视图层扛顺序——侵入式 AVL 能存在正是因为对象地址由存储层保证不变。这个三层结构在第 67 章网络连接表会以完整形态出现,届时你可以回来对照:本章的两个示例分别训练了中间层与顶层的最小用法。

20.4契约

  • 重复键:Insert 返回非 NULL = 撞键,返回值是既有节点;NULL = 成功。成功分支写 == NULL。
  • 侵入式零分配:NodeInit 先于 Insert;对象生命周期必须覆盖挂树期;摘树后才可销毁对象(与第 17 章链表同纪律)。
  • 容器式拥有:插入即移交;Unit/Destroy 释放对象本体。
  • 比较函数:三态返回;侵入式先反查宿主;防溢出用减法式两步比较。
  • 迭代:中序 = 升序;范围迭代沿区间子树,不整树扫描。

给比较函数做体检

AVL 的全部行为正确性都押在比较函数上,它值得被当作独立单元来测。体检三问:三态吗——相等时必须返回 0,返回 -1 或 1 的比较函数会让"查不到刚插入的键";防溢出吗——整数比较用减法式两步写法;一致吗——同一对键不管从哪个方向比较,结果必须互补(a<b 则 b>a),不一致的比较函数会让树的结构性平衡彻底失效。把比较函数写完先喂一组手工用例(小于/等于/大于/极值),再挂树——这个顺序能省掉后来"树行为诡异"的整段排查时间。

常见错误

坑 1:把 Insert 返回值当布尔用

症状:插入"总是失败"或"总是成功"——取决于你怎么写 if;撞键时对象静默丢失或旧数据被误解。

原因:与其他库相反,xrtAVLInsert 的 NULL 是成功、非 NULL 是撞键返回的旧节点——按"非空为真"的惯性写判断,逻辑整个反转。

错误写法
if ( xrtAVLInsert(&tSessions, &pSessions[i].Index, &pSessions[i].ID,
		exampleCompare, NULL, NULL) ) {
	/* 以为进这里=成功;实际上进这里=撞键了 */
}
正确写法
if ( xrtAVLInsert(&tSessions, &pSessions[i].Index, &pSessions[i].ID,
		exampleCompare, NULL, NULL) == NULL ) {
	/* NULL 才是插入成功 */
}
坑 2:忘记 NodeInit 或对象未摘树就销毁

症状:前者插入即崩(链接是垃圾值);后者与链表同族——树指向已释放内存,崩溃位置远离现场。

原因:侵入式的链接字段不初始化就是未定义状态;挂树期的对象销毁把树的指针一起带走。

错误写法
examplesession* pDead = find_expired(&tSessions);
xrtFree(pDead);                        /* Index 还挂在树上——树已悬空 */
正确写法
examplesession* pDead = find_expired(&tSessions);
xrtAVLRemove(&tSessions, &pDead->Index);   /* 先摘树 */
xrtFree(pDead);                             /* 后销毁 */

编程练习

基础:乱序插入验证

复现主示例:乱序插入 5 个会话,断言中序输出恒为升序;再按另一种插入顺序重跑,输出不变——两次运行之间树内部形状可能完全不同,但中序输出一致,这正是"平衡是树的内部事务"的可观察证据。

进阶:双索引(侵入式的杀手级应用)

给会话对象加第二个节点字段,建"按 ID"与"按名称字典序"两棵树;同一批对象两棵树各自中序输出。提示:两个比较函数 + 两轮 Insert;对象只存在一份。

挑战:区间导出器(范围迭代 vs 暴力过滤)

用容器式树装载 1000 个随机 ID 的配置,实现 export(minId, maxId) 输出区间内全部条目;与"全量迭代 + 手工过滤"对比遍历的节点数。验收标准:构造 [0,1000] 内随机区间 10 组,输出与暴力法逐行一致;记录并汇报两种方法的迭代次数差异。

小结

知识点速查
两形态侵入式 xavl 零分配多索引 / 容器式 xavltree 拥有对象
返回值Insert NULL=成功;非 NULL=既有同键节点
反查offsetof(宿主, 节点字段);与 XRT_CONTAINER_OF 同源
有序性中序迭代 = 键升序;范围迭代只走区间子树
挂树纪律NodeInit 先行;摘树先于销毁对象;对象生命周期覆盖挂树期(与第 17 章链表同纪律)
三层结构存储(池/slot_map)+ 定位(map)+ 有序视图(侵入式 AVL)——真实资源管理器标配
平衡自动旋转,使用者只提供三态比较函数;挂树前先给比较函数喂手工用例
与映射分工只查用映射;查+序用 AVL;双需求双索引并存
复杂度查/插/删 O(log n);中序与范围只走必要子树;与映射并存各扛一职

📖 本章涉及的 API 参考手册:avl(53 个条目)

点击查看完整签名、参数约束、返回值与错误说明