AVL树原理与实现:平衡因子、旋转操作与插入删除调整
1. 二叉搜索树为什么会退化AVL树要解决的核心问题先从一个很常见的场景说起。我在给某项目做内存词典模块时一开始图省事直接用了二叉搜索树BST来组织键值对。数据集是预先排好序的订单号插入的时候节点一路向右挂整棵树慢慢变成了一条链表。查询某个订单号最坏要从头遍历几千个节点耗时从微秒级别涨到毫秒级别。当时我还在想是不是数据量太大了后来一查树的结构左子树空荡荡右子树一条线到底这才意识到问题出在树的形态上——BST的性能完全依赖插入顺序一旦数据近似有序平衡性就会崩掉。AVL树之所以被设计出来就是为了解决这个“形态失控”的问题。它的核心思想不复杂在每次插入或删除之后检查树的左右子树高度差如果某个节点两侧的高度差超过1就通过旋转把树重新“掰”回平衡。这样整棵树的高度始终维持在 O(log n) 级别查询、插入、删除的时间复杂度也就稳定在 O(log n)不会因为数据分布的特殊性而退化。很多教材会直接抛出一堆旋转图然后让读者背四种情况。但这里我想换个顺序先搞明白它为什么需要旋转旋转到底在“修正”什么再去看四种旋转怎么记最省力。搞懂动机之后代码只是表达方式。那“高度差”这个东西怎么量化AVL树引入了一个概念叫平衡因子Balance Factor定义为某个节点的左子树高度减去右子树高度取值只能是 -1、0、1。一旦某个节点算出来的平衡因子是 2 或者 -2就说明这棵树在这个位置已经失衡了需要通过旋转来恢复。为了维护这个信息每个节点除了保存左右孩子指针和数据之外还要额外存一个高度值或直接存平衡因子。存高度值更常见因为删除节点时需要重新计算子树高度光靠平衡因子不好回溯。这里有一个新手容易忽略的细节平衡因子不是“局部属性”而是“全局属性”的局部体现。一棵子树失衡往往是由底层某个新插入的叶子引起的但这个失衡信息要沿着路径一层层往上传递。所以插入操作不只是把新节点挂上就完事还要从新节点出发一路回溯更新祖先节点的高度检查有没有谁的超了。整个过程像多米诺骨牌你永远不知道刚才那一下会推到哪一层。2. 平衡因子与旋转四种旋转模式的记忆锚点旋转是整个AVL树的灵魂。四个名字——LL、RR、LR、RL——看着简单但很多人学完就忘原因在于他们把这四个名字当成独立的规则去背没有理解旋转发生的路径。先看最基础的右旋和左旋。右旋处理的是“左子树过重”的情况也就是某个节点的左孩子高度明显高于右孩子。右旋的操作是把这个节点降级为它左孩子的右孩子同时把左孩子的右子树过继给该节点作为新的左子树。整个过程像用左孩子作为支点把失衡节点往右下方压。左旋则正好镜像处理“右子树过重”用右孩子作为支点把失衡节点往左下方压。记这四个模式的锚点我的方法是看失衡节点到失衡根因节点的路径方向。如果路径是“左→左”就是LL用一次右旋解决如果路径是“右→右”就是RR用一次左旋解决。麻烦的是路径拐了弯先左后右LR或者先右后左RL这时候单旋一次解决不了需要先做一次反向旋转把路径捋直再做一次正向旋转。为什么LR和RL不能直接单旋这里用一个生活类比就很好理解。想象一个跷跷板重心如果偏在支点正上方你只需要推一边就能恢复平衡但如果重心已经歪到斜上方去了你直接推一边只会让整个板子绕着一个错误的支点转得更歪。必须先调整支点位置把重心拉回正上方再推。LR情况的根因节点在失衡节点的左子树的右子树上直接右旋会把“病灶”转到更别扭的位置所以第一步先对左孩子做左旋把它变成LL形态第二步再对失衡节点做右旋一次到位。这里列一个速查表格把路径、旋转方式和记忆点放一起失衡类型路径方向第一步操作第二步操作记忆锚点LL左子树的左子树无需第一步对失衡节点右旋一条直线向左偏右旋纠正RR右子树的右子树无需第一步对失衡节点左旋一条直线向右偏左旋纠正LR左子树的右子树对左孩子左旋对失衡节点右旋先内旋捋直再外旋纠正RL右子树的左子树对右孩子右旋对失衡节点左旋先内旋捋直再外旋纠正光记住这个表还不够真正容易出错的地方在双旋时的**“谁先转、谁后转”**。口诀可以这样记双旋的方向永远是先处理靠近“病灶”的那一端再处理失衡节点本身。LR的病灶在左子树的右侧所以先转左孩子左旋再转失衡节点右旋。RL的病灶在右子树的左侧所以先转右孩子右旋再转失衡节点左旋。3. 插入调整全流程从一步操作看平衡因子的连锁变化插入操作可以拆成三步按BST规则找到位置并挂上新节点、从新节点开始向上更新高度、找到第一个失衡节点并旋转修正。第三步最考验人因为不是所有插入都会触发旋转而且失衡节点不一定就是新节点的直接父节点。我举个例子。假设现有树根节点是50左孩子30右孩子7070的左孩子6570的右孩子8080的左孩子75。现在插入76。按照BST规则76应该挂在75的右孩子位置。挂上去之后75的平衡因子变成-1左空右有没问题。80的平衡因子变成-1没问题。70的平衡因子变成-2——这时候失衡了。你会发现失衡的是70而不是新节点的父节点75更不是根节点50。所以要沿着祖先链往上逐个检查找到第一个平衡因子绝对值大于1的节点以它作为旋转的支点。找到70之后该怎么转这时候要看从70到新节点76的路径方向。70 → 右孩子80 → 左孩子75 → 右孩子76。路径是先右后左属于RL类型。按规则第一步对80做右旋把80变成75的右孩子同时把75的右子树如果有过继为80的左子树第二步对70做左旋把75提上来当新的子树根。旋转完成之后以75为根的子树高度又回到了插入之前的值所以链上更高层的祖先的平衡因子其实不需要再更新因为该子树对上层贡献的高度没变。这一点非常重要也是很多实现里“为什么更新高度只用跑到第一个失衡点就停”的理论依据。插入后更新高度的方式也值得说清楚。每个节点的高度等于左右子树高度的较大值加1。很多初学实现会把高度写在递归函数里插入完成后从递归栈逐层返回自然就把高度更新好了。这种做法比显式回溯要简洁也避免了在迭代版本里忘记更新祖先节点高度的老问题。还有一个细节容易被忽略新插入的节点高度初始为1或者0看你定义空树高度为0还是-1但同一套代码里必须统一。我习惯定义空树高度为0单节点高度为1这样平衡因子的值是-1、0、1三种。如果定义空树高度为-1单节点高度为0逻辑也成立但临界判断会多一些。只要保证取高度时对空指针做了保护两种定义都能跑通。4. 删除操作真正难在哪向上回溯与旋转选择的坑插入的调整已经够绕了但删除更麻烦。删除的麻烦不在于找要删的节点而在于删完之后可能不止一个节点失衡。插入时只要在第一个失衡点做完旋转整棵子树高度就恢复了上层不需要再管删除时做完一次旋转可能把失衡转移给更高层的祖先需要一路回溯到根节点沿途检查并修正每一个失衡点。删除的完整流程是按BST规则找到目标节点、用前驱或后继替换的方式删掉它推荐后继替换即右子树的最小节点、从被删节点的实际位置开始向上回溯、每到一个节点就更新高度并检查平衡因子、遇到失衡就按旋转规则修正、然后继续往上走直到根。这里的“继续往上走”和插入的“转一次就停”完全不同得用循环或递归把整条路径走完。删除时的旋转选择也容易踩坑。比如某个节点平衡因子是2说明左子树比右子树高这时候要右旋。但在右旋之前还要看一眼左孩子的平衡因子如果左孩子的平衡因子是-1说明失衡根因在左子树的右子树里此时需要先对左孩子做左旋再做右旋也就是LR双旋只有当左孩子的平衡因子是1或0时才能直接右旋。同样的道理平衡因子是-2时要检查右孩子的平衡因子如果是1得先右旋再左旋做成RL双旋否则直接左旋。这里应该重点记住一句话单旋还是双旋取决于失衡节点“偏高那边”的孩子节点的平衡因子符号而不是失衡节点自己的平衡因子。这一步的判断错了旋转转了等于白转因为病根没除掉。删除时还有一个很隐蔽的坑用后继替换删节点时后继节点可能不是叶子它还带着右孩子。在把后继节点值复制到目标节点之后实际上真正删掉的是后继节点本身所以要从后继节点的原位置开始向上调整。很多实现里这一段是写递归删除的取一个节点的右子树最小值时左子树是不需要递归的但右子树得递归下去返回时要逐层更新高度和检查平衡。这部分逻辑只要漏掉一次高度更新后续查平衡因子就会算错表现出来就是程序跑着跑着树的高度越来越高性能直线下降。举一个具体的删除场景。一棵树根节点是40左孩子2020的右孩子3030的左孩子2530的右孩子35。现在删除40。按后继替换右子树的最小值是50假设40的右孩子是5050没有左孩子把50的值覆盖到40的位置然后删除原50节点。删掉50之后50原来所在位置的父节点也就是根现在是40但值已变50的右子树高度变0左子树高度还是220→30→35平衡因子变成2需要右旋。右旋前检查左孩子20的平衡因子20的平衡因子是-1因为20的左孩子为空、右孩子30为高所以要双旋先对20左旋把30提上来再对50右旋。整个过程完成后树高度恢复为2层。如果这里不看20的平衡因子直接对50右旋就会得到一棵仍然是失衡的树后续插入删除的操作复杂度全部退化。5. 完整代码、调试心得与工程中的取舍前面讲的都是原理这里给一份能直接跑的最小实现。我用C语言写结构清晰方便对比教材里的伪代码。先看节点的定义和辅助函数typedef struct avl_node { int key; int height; struct avl_node *left; struct avl_node *right; } avl_node; static int get_height(avl_node *n) { return n ? n-height : 0; } static int get_balance(avl_node *n) { return n ? get_height(n-left) - get_height(n-right) : 0; } static avl_node *new_node(int key) { avl_node *n (avl_node *)malloc(sizeof(avl_node)); n-key key; n-height 1; n-left NULL; n-right NULL; return n; }然后是两种基础旋转。右旋的代码很短但每个指针都要想清楚static avl_node *rotate_right(avl_node *y) { avl_node *x y-left; avl_node *t2 x-right; x-right y; y-left t2; y-height 1 max(get_height(y-left), get_height(y-right)); x-height 1 max(get_height(x-left), get_height(x-right)); return x; } static avl_node *rotate_left(avl_node *x) { avl_node *y x-right; avl_node *t2 y-left; y-left x; x-right t2; x-height 1 max(get_height(x-left), get_height(x-right)); y-height 1 max(get_height(y-left), get_height(y-right)); return y; }这段代码里最需要注意的就是t2的过继。右旋时x的右子树要过继成y的左子树因为旋转之后x的左子树没有任何变化但x的右孩子变成了y原来x和y之间的那棵中间子树t2如果丢掉了树就断了。很多初学者的实现写到这里会漏掉y-left t2这一行结果一旋转少了一整棵子树。插入函数用递归写返回新的子树根static avl_node *insert_node(avl_node *node, int key) { if (!node) return new_node(key); if (key node-key) node-left insert_node(node-left, key); else if (key node-key) node-right insert_node(node-right, key); else return node; node-height 1 max(get_height(node-left), get_height(node-right)); int balance get_balance(node); if (balance 1 key node-left-key) return rotate_right(node); if (balance -1 key node-right-key) return rotate_left(node); if (balance 1 key node-left-key) { node-left rotate_left(node-left); return rotate_right(node); } if (balance -1 key node-right-key) { node-right rotate_right(node-right); return rotate_left(node); } return node; }删除函数比插入复杂一些。这里取后继的方式是找右子树的最小值因为递归实现里这个路径最清晰static avl_node *min_value_node(avl_node *node) { avl_node *current node; while (current-left) current current-left; return current; } static avl_node *delete_node(avl_node *root, int key) { if (!root) return root; if (key root-key) root-left delete_node(root-left, key); else if (key root-key) root-right delete_node(root-right, key); else { if (!root-left || !root-right) { avl_node *temp root-left ? root-left : root-right; if (!temp) { temp root; root NULL; } else { *root *temp; } free(temp); } else { avl_node *temp min_value_node(root-right); root-key temp-key; root-right delete_node(root-right, temp-key); } } if (!root) return root; root-height 1 max(get_height(root-left), get_height(root-right)); int balance get_balance(root); if (balance 1 get_balance(root-left) 0) return rotate_right(root); if (balance 1 get_balance(root-left) 0) { root-left rotate_left(root-left); return rotate_right(root); } if (balance -1 get_balance(root-right) 0) return rotate_left(root); if (balance -1 get_balance(root-right) 0) { root-right rotate_right(root-right); return rotate_left(root); } return root; }注意删除里旋转的判断条件和插入不同插入时用key node-left-key判断路径方向是因为新节点已经插入完成可以直接比较键值删除时用get_balance(root-left)判断失衡形态是因为我们不确定删除后左孩子的内部结构到底偏到哪边必须靠平衡因子的符号来决定单旋还是双旋。这一段一定要分清楚很多踩坑经历都出自这里。调试AVL树的经历让我总结出几个很实用的经验。第一一定要写一个可视化打印函数按中序遍历输出每个节点的key和平衡因子。光靠断点看局部指针根本没有全局视野打印出整棵树之后哪个节点失衡一目了然。我的打印函数大概长这样递归遍历每个节点输出(key, balance factor)括号嵌套展示树形结构。第二随机插入大量数据后专门验证一下树的高度是否接近 log2(n) 的上界。AVL树的严格高度上界大约是 1.44 * log2(n2) - 1.328。我没法在这里推导完整数学证明但可以给一个其实用结论节点数是100万时AVL树高度不会超过约43层。如果实测插了100万节点树高到了50多甚至100多说明调整逻辑出问题了。这个上界公式在很多经典数据结构教材里都有严格推导实际操作时直接用这个数值当标尺检查即可。第三删除操作跑完一轮之后再跑一遍中序遍历确认每个节点的平衡因子都落在 -1、0、1 范围内。这个检查很粗暴但极其有效。我把这个检查写成了一个验证函数在项目里的测试用例会反复调用如果某次插入删除破坏了这个性质立刻就能定位到是哪一步操作出的问题。关于AVL树和红黑树的工程取舍这里也说一点实际体会。AVL树对平衡要求更严格查询性能更好尤其适合读多写少、数据规模较大的场景。红黑树放宽了平衡约束插入删除的旋转次数更少写操作更便宜所以很多编程语言的标准库和操作系统内核里的调度器都用了红黑树。在我做的那个内存词典项目里选择AVL树是因为数据以查询为主并且键值的插入是批量预加载的树建好之后几乎不再改动AVL树在这类场景下的平均查询表现非常稳定。如果你的场景是写多读少或者数据量不大但对实现简洁度要求高红黑树会比AVL树更合适。最后分享一个小技巧。如果你在实现AVL树时反复因为平衡因子算错而调试半天试试在节点结构体里直接存平衡因子而不是高度插入和删除时每次旋转后重新计算该节点的平衡因子再用父节点的高度变化去同步更新祖先。这个做法的代码会稍微长一些但调试时每个节点的平衡因子都是直接可见的出错时容易定位。不过我不建议在递归实现里改用这种方式递归天然适合自底向上返回高度硬改成显式回溯反而容易把代码写复杂。根据我的经验高度存储是更通用、更符合大多数人思维习惯的选择平衡因子存储只适合在特定迭代实现里用。AVL树是数据结构进阶路上绕不开的一关它难在“平衡”两个字不是一次性操作而是每次写操作后的持续维护。把插入和删除的调整逻辑分开梳理清楚把单旋和双旋的应用条件刻进脑子里再配合大量随机数据的验证这棵树很快就能被驯服。

相关新闻

从Tornado模板注入到PHP反序列化绕过:Web安全中的信息泄露与利用链剖析

从Tornado模板注入到PHP反序列化绕过:Web安全中的信息泄露与利用链剖析

1. 从两道经典题看Web安全里的"信息泄露"与"反序列化绕过" 做Web安全练习的人,迟早会撞上两类题:一类是"看起来什么都没给,其实线索全在明面上",另一类是"代码逻辑看着天衣无缝,但…

2026/10/16 11:14:24 阅读更多 →
2026下半年一人公司AI工具清单:爆款复刻、无限画布与工作流实战

2026下半年一人公司AI工具清单:爆款复刻、无限画布与工作流实战

1. 从“一人公司”说起:为什么2026下半年的AI工具清单值得认真对待“一人公司”这个词,这两年从硅谷创业圈一路火到国内的内容社区,但真正把它跑通的人并不多。我身边有不少独立开发者、自由设计师、自媒体创作者,嘴上说着“一个人…

2026/10/16 11:14:24 阅读更多 →
NPU/TPU部署实战:从算子融合到INT8量化的推理优化指南

NPU/TPU部署实战:从算子融合到INT8量化的推理优化指南

这几年我主要做深度学习模型的服务化部署和端侧推理优化,前后接手过不少号称“智算”的硬件平台,也踩过无数次编译器报错的坑。刚开始接触NPU/TPU硬件加速时,我天真地以为“把模型丢上去,编译器会自动搞定一切”,实际情…

2026/10/16 11:14:24 阅读更多 →

最新新闻

流量异常波动分析:从监控告警到根因定位的实战案例

流量异常波动分析:从监控告警到根因定位的实战案例

做了几年流量数据,我最怕看到的不是流量掉了,而是凌晨三点的曲线图上突然冒出一个不该有的尖峰,或者大促前流量莫名其妙断崖式归零。这种“异常波动”是所有数据分析师、运维和运营共同的噩梦。它可能意味着一次成功的活动,也可能…

2026/10/16 17:16:21 阅读更多 →
ArcGIS地图数据规范化:属性表、坐标系与符号化实战指南

ArcGIS地图数据规范化:属性表、坐标系与符号化实战指南

简介:一份面向ArcGIS使用者与地图二次开发者的多尺度地图数据包,内含城市级、洲级和世界级三个图层体系,可满足城市规划、交通管理、区域分析及全球研究等不同场景的底图需求,也是mxd文档操作和图层管理的理想练习数据。包体共24个…

2026/10/16 17:16:21 阅读更多 →
Windows Defender彻底关闭指南:UAC、篡改防护与注册表全流程

Windows Defender彻底关闭指南:UAC、篡改防护与注册表全流程

简介:针对 Win11/Win10 各版本系统安全设置需求,整理了彻底关闭 Windows Defender 及其后台通知的完整操作指南。重点解决按网上零散教程操作仍无法关闭、Win10 家庭版受限等典型难题,内容涵盖 UAC 通知调整、防火墙与通知逐个关闭、基于声誉…

2026/10/16 17:16:21 阅读更多 →
C语言循环控制结构:从语法到实战避坑指南

C语言循环控制结构:从语法到实战避坑指南

写代码这行当干久了,你会慢慢发现,真正决定程序“智商”的往往不是那些复杂的算法,而是最基础的三件事:顺序、选择、循环。前两件事好理解,一行一行执行,遇到岔路判断一下方向。但一旦遇上“重复做某件事”…

2026/10/16 17:16:21 阅读更多 →
HashMap扩容机制深度解析:从位运算到工程实践

HashMap扩容机制深度解析:从位运算到工程实践

HashMap的扩容机制,是Java面试里出现频率几乎最高的题,也是线上定位Map相关性能瓶颈时绕不开的底层逻辑。大多数人都能背出"当元素数量超过阈值就扩容到两倍",但问到"为什么是2倍而不是1.5倍"、"扩容时节点到底怎么…

2026/10/16 17:16:21 阅读更多 →
卷积网络中Padding全解析:Valid与Same的数学原理、工程选型与调试实战

卷积网络中Padding全解析:Valid与Same的数学原理、工程选型与调试实战

聊到 CNN 卷积网络,很多人会把注意力放在卷积核大小、步长、激活函数这些参数上,真正把 padding 的两种方式彻底吃透的人并不多。我第一次意识到 padding 是个大问题,是在搭建一个图像分割网络时:模型结构看起来完全正常&#xff…

2026/10/16 17:15:20 阅读更多 →

日新闻

个人 AI Agent 平台接入 TaoToken:统一 Key 与 API 通道的配置大纲

个人 AI Agent 平台接入 TaoToken:统一 Key 与 API 通道的配置大纲

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 0:00:05 阅读更多 →
信号与系统频域分析作业解题指南

信号与系统频域分析作业解题指南

1. 作业背景与核心要求解析信号与系统作为电子信息类专业的核心基础课,其作业设计往往直接关联课程的核心知识框架。2026年春季第六次作业的布置,通常对应着课程进展到频域分析的关键阶段。根据常规教学进度推测,本次作业可能涉及以下核心内容…

2026/10/16 0:00:05 阅读更多 →
Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

1. 从一个"四不像"的练手项目说起如果你已经看完了Django的官方教程前几章,跟着敲完了投票应用,大概会有一种"我好像会了,又好像什么都不会"的割裂感。教程里每个知识点都是拆开的——这一章讲模型,下一章讲视…

2026/10/16 0:00:05 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/15 19:41:51 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/16 15:04:24 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 3:49:43 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 14:23:36 阅读更多 →