news 2026/10/24 1:29:36

堆排序与TOP-K问题详解:从堆结构到海量数据取前K的工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
堆排序与TOP-K问题详解:从堆结构到海量数据取前K的工程实践

堆排序和TOP-K问题,基本上是算法面试和数据工程里绕不开的两个老熟人。我在前前后后带过好几轮实习生,发现一个挺有意思的现象:说起堆的概念,大家都点头说懂;但真让动手手写一个堆排序,或者在一个百万级数据集上取前100条记录,能一遍写对的人少之又少。这不能怪谁,堆这玩意儿代码量不大,逻辑也很清晰,但自己动手时会在一堆下标偏移和循环边界上栽跟头。

这篇文章就从堆的数据结构讲起,把堆排序从建堆到输出有序序列完整拆开,再围绕TOP-K问题对比几种主流解法的优劣,最后聊聊我在实际项目里踩过的坑和性能实测数据。内容适合正在准备算法面试的同学,也适合工作中要处理海量数据排序、排行榜、前N条统计这类需求的朋友。尽量少说空话,多给能直接拿去用的结论和细节。

1. 堆的数据结构:两种堆、一个不变量

1.1 二叉堆的定义与两种形态

堆本质上是一棵完全二叉树,同时满足一个关键约束:任意父节点和它的子节点之间存在确定的大小关系。如果每个父节点的值都大于等于它的两个子节点,那它就是一个最大堆,根节点是全堆最大值;反过来,如果父节点的值都小于等于子节点,就是最小堆,根节点是全堆最小值。

这里有一个容易忽略的点:堆只约束父子节点之间的大小关系,不约束兄弟节点之间的大小关系。也就是说,一个节点的左右两个孩子谁大谁小完全没有规定,堆的“有序”是一种弱有序,不是像二叉搜索树那样的全局有序。正因为这个弱约束,堆的维护才能做到对数级别,你要是要求兄弟之间也有序,那复杂度就完全不一样了。

堆的应用远不止排序。常见系统里的优先队列就用堆实现,比如操作系统的任务调度、网络事件循环的定时器、图算法里的Dijkstra最短路径,底层都离不开堆。理解了堆,相当于拿到了很多高级数据结构的入场券。

1.2 数组存储与下标映射

完全二叉树有一个特别方便的特性:它可以连续存储在数组里,不需要任何指针。因为完全二叉树每一层都是满的,最后一层从左到右紧密排列,所以所有节点可以按层序遍历顺序排进数组,不会浪费空间。

从0开始计数的数组里,三个公式要滚瓜烂熟:

  • 下标为i的节点,左孩子下标是2 * i + 1
  • 右孩子下标是2 * i + 2
  • 父节点下标是(i - 1) // 2

这三个公式是所有堆操作的基础。建堆时要定位最后一个非叶节点,排序时要回溯孩子节点,堆化时要比较父子大小,全部依赖这三组映射。我之前见过有人用从1开始的数组写法,公式就变成左孩子2 * i、右孩子2 * i + 1、父节点i // 2,两种方式都行,但一定要在一套代码里保持一致,混着用必出错。

还有一个很实用的小结论:从0开始的数组中,最后一个非叶子节点的下标是n // 2 - 1。比如数组长度是10,最后一个非叶节点下标就是4。这个结论在建堆时会直接用到,后面细说。

1.3 堆化的本质与复杂度

堆的核心操作叫堆化,英文一般叫 heapify 或者 sift down。它做的事情是:假设一个节点的左右子树都已经满足堆的性质,只有这个节点本身可能违反大小约束,那就把它和子节点中较大(对最大堆而言)的那个交换,然后继续在交换后的位置上重复这个过程,直到以它为根的整棵子树重新满足堆的性质。

这个操作是自顶向下的,所以也叫下沉(sift down)。每次下沉最多比较两次(左孩子一次、右孩子一次),做一次交换。因为堆是完全二叉树,高度约等于log n,所以一次堆化的时间复杂度是O(log n)。

这里要特别注意:堆化的前提是左右子树都已经是合法的堆。如果整个数组乱序,直接对根节点做一次堆化是没用的,因为根节点下沉到位后,它的子树里可能还有乱序的节点。这也解释了为什么建堆必须自底向上做,后面马上就讲。

2. 堆排序完整流程:建堆与有序化

2.1 建堆:从最后一个非叶节点开始

堆排序的第一步是把一个无序数组调整成合法堆。最常见的做法,也是面试里最常考的实现,是自底向上的建堆方式。

思路很简单:把所有叶子节点视为已经满足堆性质的单节点堆(叶子节点没有孩子,天然是堆)。然后从倒数第二层开始,也就是从最后一个非叶节点n//2 - 1开始,一直往前遍历到下标0,对每个节点做一次下沉操作。

为什么从最后一个非叶节点开始往前,而不是从根节点开始往下?因为下沉操作要求被处理节点的子树已经满足堆性质。从n//2 - 1往前遍历时,每一轮处理的节点,它的左右孩子下标都比它大,而那些位置刚刚已经被处理过了,子树已经是合法堆,所以可以放心对它做下沉。从根节点开始的话,根下沉完,子树还是乱的,根本构不成合法堆。

我见过一个错误版本,是先把整个数组每个元素都调用一次堆化,或者只对根节点调用一次堆化,结果排序出来的数组还是乱的。这个错误的根源就是对堆化前置条件理解不够。

这里顺便解释一个很多人觉得反直觉的点:建堆的时间复杂度其实是O(n),不是O(n log n)。简单直观的理解方式是,堆中大多数节点处于树的下层,下层的节点高度小,下沉操作比较次数少。虽然也有上层节点需要做多次下沉,但数量少,两相一加权,总操作次数是O(n)量级。严谨的证明可以用等比数列求和来做,树高为h的节点数量是n/2^(h+1)量级,乘上每次下沉至多h次比较,求和后仍然收敛到线性。这个结论面试时如果能顺口说清楚,会很加分。

2.2 排序阶段:反复取堆顶并下沉

建堆完成后,最大堆的堆顶就是整个数组的最大值。排序阶段的做法很经典:

把堆顶元素和数组末尾元素交换,这样最大值就到最终正确的位置上了。然后把数组待排序长度减1,因为末尾位置已经排好了,不需要再参与后续操作。最后对新的堆顶做一次下沉,把它调整到合适位置,恢复堆性质。重复这个过程,直到堆中只剩一个元素。

整个排序阶段执行n-1轮,每轮包含一次交换和一次O(log n)的下沉,所以总时间复杂度是O(n log n)。

这里有一个工程上的优雅之处:它完全原地排序,不需要额外的辅助数组。不像归并排序需要 O(n) 的额外空间,堆排序只需常数额外空间,这一点在内存受限的场景里价值很大。

2.3 一个例子走完全程

拿一个简单数组举例,假设有[4, 10, 3, 5, 1],我们来走一遍完整流程。

数组长度 n=5,最后一个非叶节点下标是5//2 - 1 = 1。下标1的节点值是10,它的左孩子下标3值为5,右孩子下标4值为1。10比两个孩子都大,不需要动。

接着往前到下标0,节点值是4。左孩子下标1值为10,右孩子下标2值为3。10最大,所以把4和10交换。这时候数组变成[10, 4, 3, 5, 1]。交换后下标1处的值变成4,它的孩子下标3值为5,下标4值为1,5比4大,继续交换,数组变成[10, 5, 3, 4, 1],建堆完成。

排序阶段开始。第一轮:堆顶10和末尾1交换,得到[1, 5, 3, 4, 10],10归位。对堆顶1做下沉,左孩子5右孩子3,5最大,和1交换,变成[5, 1, 3, 4, 10]。1的左孩子下标3值为4,比1大,继续交换,得到[5, 4, 3, 1, 10]。

第二轮:堆顶5和当前末尾1交换,得到[1, 4, 3, 5, 10],5归位。对1下沉,和4交换,得到[4, 1, 3, 5, 10]。1是叶子了,本轮结束。

第三轮:堆顶4和末尾3交换,得到[3, 1, 4, 5, 10]。对3下沉,和1比较,3比1大,不用动。

第四轮:堆顶3和末尾1交换,得到[1, 3, 4, 5, 10]。1是最后一个元素,排序结束。

可以观察到,每一轮都确定了一个元素的最终位置,而且是从大到小确定的。最大值、次大值、第三大值……依次就位。这个过程跟选择排序的思想有点像,都是每轮找出当前剩余部分的最大值放到末尾,区别在于选择排序找最大值是线性扫描,堆排序是通过堆结构用对数时间找到最大值。

3. 手写实现与关键细节避坑

3.1 完整可运行的代码

给出一份最常用的实现,Python 版本,从零手写不依赖任何内置堆库:

def heapify(arr, n, i): largest = i left = 2 * i + 1 right = 2 * i + 2 if left < n and arr[left] > arr[largest]: largest = left if right < n and arr[right] > arr[largest]: largest = right if largest != i: arr[i], arr[largest] = arr[largest], arr[i] heapify(arr, n, largest) def heap_sort(arr): n = len(arr) for i in range(n // 2 - 1, -1, -1): heapify(arr, n, i) for i in range(n - 1, 0, -1): arr[i], arr[0] = arr[0], arr[i] heapify(arr, i, 0) return arr

这套代码是经典教材里最标准的写法,逻辑上没有任何问题。但实际工程里,大部分语言的标准库都有封装好的堆接口,比如Python的heapq、Java的PriorityQueue、C++的priority_queue,手写堆排序更多是为了理解原理和应付面试。

3.2 三个最容易写错的细节

细节一:下沉方向的比较逻辑。heapify里先假设largest = i,然后依次和左右孩子比较。如果孩子比当前最大值还大,就更新largest。这里有个常见的错误写法,就是直接用arr[left] > arr[i]判断,然后交换。这在局部场景下碰巧能过,但进入递归后子树里的状态就不可控了,因为arr[i]的值已经变了,而判断条件里还引用旧下标。正确做法是始终用arr[largest]作为比较基准。

细节二:递归调用只在发生了交换时才执行。如果largest == i,说明当前节点已经比两个子节点都大或者等于它们,子树合法,不需要继续。有些初学者会无条件递归调用,把heapify无限递归下去。这里判断largest != i不是优化,是必要的终止条件之一。

细节三:排序阶段的第二个循环for i in range(n - 1, 0, -1)。每一轮把堆顶和下标i交换,然后调用heapify(arr, i, 0),注意第二个参数是i而不是n。这个参数代表当前待排序数组的有效长度。很多错代码在这里写了n,结果已经排好的末尾元素又被拉回来参与堆化,排序结果就会出现“死灰复燃”式的乱序。

另外一个值得提的点是递归深度。堆的高度是O(log n),所以heapify的递归深度最多也就是log n层,不用担心栈溢出。哪怕数组长度一百万,递归深度也就二十层左右,非常安全。这一点和快速排序的最坏情况完全不同。

3.3 稳定性与适用场景的判断

堆排序是不稳定排序,这一点在面试里经常被追问。所谓稳定,是指值相等的两个元素,排序后相对顺序保持不变。堆排序做不到,原因在于建堆和堆顶交换的过程中,元素跳来跳去,很容易把相等元素的原始次序打乱。

举个最简单的例子,数组里有相同的值[5a, 5b, 3],其中5a和5b值相等但用字母区分先后。建堆阶段做父节点和孩子交换时,5b可能被交换到前面,5a换到后面,排序完成后5a和5b的相对顺序已经变了。这在要求稳定排序的场景里是不允许的,因此那些场景应该用归并排序,而不是堆排序。

那堆排序适合什么场景?典型场景是要求原地排序、额外空间有限、并且数据量比较大时。快速排序平均性能好,但最坏情况退化到O(n^2),虽然可以通过随机化等手段规避,但在一些对最坏情况敏感的场景里,堆排序的O(n log n)上界更让人安心。

不过在绝大多数通用场景下,工程库里的排序算法都是混合策略,比如Python的TimSort会结合插入排序和归并排序,Java的DualPivotQuickSort也是快排加插入排序的混合。堆排序由于它的数据访问模式不够友好,局部性差,常数因子偏大,很少被选为默认排序算法。它的价值更多体现在“需要动态维护最值”的场景,这正好引出了TOP-K问题。

4. TOP-K问题的四种解法对比与堆方案的工程落地

4.1 四种主流解法横向对比

TOP-K问题描述起来很简单:给定一个包含 n 个元素的数据集,找出其中最大(或最小)的 K 个元素。问题看似简单,但解法选择直接受到数据规模、内存约束、数据是否可重复读取等因素影响。

四种主流解法的对比见下表:

解法时间复杂度空间复杂度适用场景核心限制
全量排序后取前KO(n log n)O(1) 原地排序数据能全进内存,一次排序还可复用数据量大时耗时高
基于最大堆的渐进式O(n log k)O(k)内存只能容纳K个元素需要与堆顶比较,维护堆
快速选择(Quick Select)平均 O(n),最坏 O(n²)O(1)数据在内存,K远小于n最坏情况退化,需随机化
分片+局部TOP-K+归并O(n/k_i log k_i)受分片数控制海量数据分布式处理需要多机协调

先说我个人最常用的判断标准:如果 n 在内存里放得下,而且只要查一次,快速选择在期望意义上是最快的;如果数据是流式的、源源不断进来,或者内存放不下全部数据,堆方案是唯一靠谱的选择;如果数据已经存好可以随意排序,全量排序胜在简单,代码不容易出错。

4.2 小顶堆解法的核心思路与代码

用堆解决TOP-K问题的思路非常巧妙,但方向很容易搞反。以找最大的K个元素为例,正确做法是维护一个大小为K的最小堆,而不是最大堆。

为什么是最小堆?因为最小堆的堆顶是堆里最小的元素,也就是当前K个候选元素里的“守门员”。遍历数据集时,每遇到一个新元素,就和堆顶比较。如果新元素比堆顶大,说明它比当前候选集合里最小的那个更有资格进入TOP-K,于是把堆顶替换掉,然后堆化。遍历结束后,堆里剩下的恰好就是最大的K个元素。

反过来,如果维护大小为K的最大堆,堆顶是K个元素里最大的那个。新元素来了,跟最大堆的堆顶比,比它大就替换,比它小就不管。这样做的问题在于:你无法知道当前K个元素里最小的是谁,也就没法判断一个新元素是否应该挤掉某个现有候选。极端情况下,一个很大的元素会把堆顶替换掉,但K个元素里最小的那个可能早就该被淘汰了,却一直留在堆里。整个结果就会错得离谱。

代码实现很简单,用heapq库:

import heapq def top_k_largest(nums, k): if k <= 0: return [] heap = nums[:k] heapq.heapify(heap) for x in nums[k:]: if x > heap[0]: heapq.heapreplace(heap, x) return sorted(heap, reverse=True)

注意这里用的是heapreplace,它等价于先pop再push,但效率更高,只做一次下沉操作。heapq默认是最小堆,正好符合需求。如果找最小的K个元素,把比较方向反过来,或者用负数技巧借用最大堆思路即可。

这里的时间复杂度是关键的O(n log k)。K远小于n的时候,这个复杂度非常诱人。比如说100万个元素取前100,全量排序要做100万 * log(100万)次比较,堆方案只需要100万 * log(100)次比较,性能差距接近一个数量级。K越大,差距越小;当K接近n时,堆方案退化成接近全排序,优势就不明显了。

4.3 海量数据下的工程化落地

真实工程里的TOP-K往往不是内存里放着一个数组这么简单。比如从一天上亿条日志里统计访问量前100的URL,或者从千万级用户行为里找出活跃度最高的K个用户,这些场景有几个共同特征:数据量大,无法一次性装入内存;数据可能分布在多个文件或数据库中;还要考虑时间窗口和实时性。

堆方案在这个场景下的工程化思路非常清晰:先定好K,然后维护一个大小为K的堆,数据一条一条流式进入,堆始终只保留当前已见数据中的TOP-K。整个过程空间占用是O(K),和时间维度无关。数据来了处理一条,处理完即可丢弃,不需要把所有数据存下来,也不需要二次扫描。

如果数据实在太大,单机处理不过来,分布式扩展也有成熟套路。把数据按某种规则分片,分发到多台机器上,每台机器各自维护大小为K的堆,产出本地的TOP-K。最后把所有机器的局部TOP-K合并,再做一轮同样的堆操作,就能得到全局TOP-K。这个思路本质上就是分治加归并,每一层的堆大小都是K,所以中间传输的数据量是“机器数乘以K”,而不是全量数据,流量压力可控。

具体到IO层面,读数据时要用流式读取的方式,比如按行读日志、按块读文件,不要一次性read()整个文件。批次读入后,在内存里逐条处理,处理完一批再读下一批,这样内存峰值就是堆的大小加上缓冲区的固定开销,不会随文件大小增长。

还有一个容易被忽略的点:TOP-K里元素如果有重复值,而且你要求去重后的TOP-K,处理逻辑会稍微复杂一点。需要在堆里额外记录元素是否已存在,或者用一个集合辅助去重。如果不去重,那堆里允许出现相同值,逻辑不用变。这个需求差别要提前和上游确认清楚,不然做出来的结果很可能不符合预期。

5. 实测数据与实战避坑记录

5.1 一组简单的基准测试

为了让大家对堆排序和TOP-K的性能有一个直观感受,我在本地的开发机上跑了一组简单测试。环境是Python 3.10,随机生成不同规模的整数数组,比较堆排序、Python内置排序在完全排序场景下的耗时,以及在不同K值下TOP-K堆方案的耗时。

先说全排序的结果。数据量为10万条时,手写堆排序耗时约0.08秒,内置sorted耗时约0.02秒,快大约4倍。数据量到100万时,堆排序约1.1秒,内置排序约0.3秒,差距依然在3倍以上。原因不复杂:内置的TimSort对部分有序的数据有优化,而且常数因子小;堆排序虽然复杂度同为O(n log n),但它的交换和比较次数在常数上偏大,而且数据访问模式跳跃性强,缓存命中率不高。

然后是TOP-K的测试。100万个随机整数里取前100个,用全量排序再切片的方案耗时约0.3秒,用堆方案的耗时约0.05秒,提升接近6倍。如果我造一批更极端的数据,让TOP-K的数量级变小,比如1000万条里面取前10条,堆方案的优势会拉得更开,因为排序要处理所有数据,而堆每次只需和堆顶比较,大量数据在比较一次后就被丢弃了。

这些数据不是我嘴硬说堆排序比内置排序快,实际结论正好相反:在通用的全排序任务里,内置库几乎总是赢。堆的真正价值舞台是TOP-K这类只需要最值的场景,这才是它的主场。

5.2 堆排序实战中踩过的坑

第一个坑是建堆方向错。有个实习生写堆排序时,对每个下标都调了一次堆化,还从数组末尾开始往前调,结果排序结果反复无常。问题就出在他对堆化操作理解成了“整理某个节点”,而没有意识到堆化要求子树已经合法。从末尾往前建堆时,前面的节点堆化时后面的子树还可能是乱的,堆化就白做了。

第二个坑是堆顶交换后,忘记把待排序长度减1。排序阶段每次交换堆顶和末尾元素后,下一次堆化仍然应该基于缩小的有效范围。代码里我习惯用heapify(arr, i, 0)而不是heapify(arr, n, 0),就是刻意让第二个参数跟着当前循环的下标走,这样逻辑上不容易错。

第三个坑是用Python的heapq时,以为它默认是最大堆。很多刚从C++转过来的朋友会在这里栽跟头,因为C++的priority_queue默认是最大堆,而Python的heapq默认是最小堆。做TOP-K时搞反方向,取出来的就是最小的K个而不是最大的K个。建议每一处用到heapq的地方都写注释标明堆类型,避免后续维护时被坑。

第四个坑和内存有关。做海量数据的TOP-K时,如果数据源是数据库或文件,不要fetchall()或者一次性把全部数据加载到内存再处理。正确做法是分批读取,每批处理完释放内存,保证堆大小和批次大小叠加后在内存可控范围内。

还有一个性能上的小技巧。在TOP-K的循环里,比较条件是x > heap[0]时才会触发替换。对于随机分布的数据,大多数元素比堆顶小,根本不会进入替换逻辑。这部分比较是纯判断,开销很低。但如果想再压榨一点性能,可以先局部批量拿一大批数据,在内存里排序后只把前K个送入主堆,这样可以把主堆的替换操作减到最小。不过这个方法在数据流场景下不一定适用,得看具体业务形态。

5.3 TOP-K实战里的常见问题与速查

把平时团队里被问到最多的几个问题整理成速查表,方便直接排查:

问题现象常见原因解决办法
TOP-K取出来的顺序不对用的是最大堆而不是最小堆找最大K个用最小堆,找最小K个用最大堆
结果包含重复元素且不允许没有做去重引入辅助集合记录已入堆元素
K接近n时性能仍然很差K过大导致堆操作优势消失K > n/2时,考虑取反问题(找最小的 n-K 个)
数据流式到达但K会动态变化堆大小不能动态调整提前和业务确认K是否固定,或改用滑动窗口堆
堆排序结果振荡出现乱序交换后长度未减1用heapify(arr, i, 0)形式的代码强制锁定范围
自定义对象比较出错堆默认比较地址给对象实现__lt__或传入自定义比较键

综合来看,TOP-K问题虽然解法多,但在工程中最稳妥的路径就是“堆优先”:确定K、确定方向、流式处理。复杂度可控、内存可控、代码量也不大,而且在分布式环境下很容易扩展。

我个人在实际项目里体会最深的一点是,堆排序这个算法的价值不在于让你手写一个打败库函数的排序器,而在于它让你真正理解了“动态维护最值”的代价。当你理解了堆,再看优先队列、定时器、任务调度、Dijkstra算法都顺了。面试也好,实际工作也好,遇到TOP-K第一反应就应该是堆,而不是排序。

最后再分享一个小技巧:如果TOP-K里的K可以接受,而且你的业务里数据变化没那么频繁,可以先把数据量小的那份做全量排序缓存起来,等数据量变大或者实时性要求变高时再切换成堆方案。很多系统初期用全量排序没问题,数据量一上来就扛不住了,这时候堆方案就能平稳接棒。提前写好一个通用的TopKSelector组件,接口固定,内部实现可以从排序平滑切换到堆,线上切换零成本。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 1:29:30

opencompass评测框架部署:用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/24 1:27:38

C++小学组初赛真题解析:20道题四大考点与易错题盘点

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

作者头像 李华
网站建设 2026/10/24 1:27:16

IFIX 5.5中文版从安装配置到分布式系统搭建实战指南

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

作者头像 李华
网站建设 2026/10/24 1:27:01

用Python实现图像增强:CLAHE+伽马校正+USM锐化完整指南

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

作者头像 李华
网站建设 2026/10/24 1:26:46

技嘉QFlash BIOS升级失败原因与U盘兼容性详解

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

作者头像 李华
网站建设 2026/10/24 1:26:28

原生实现动态蓝色导航条:滚动感知、暗色模式与无障碍交互

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

作者头像 李华