news 2026/10/16 1:27:04

C++随机数与运行时间测量:从选型到性能测试避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++随机数与运行时间测量:从选型到性能测试避坑指南

做C++开发的同学,早晚都会碰上这两件事:要生成一批随机数,要量一下这段代码跑了多久。单看哪一个都不算难,可一旦把两者放在一起用,坑就出来了。我曾在一个性能测试任务里同时用到这两套东西,结果发现同一个随机数方案在不同计时方式下得出的结论能差出好几倍,计时方式选错了,数据直接没法看。

这篇内容就围绕"C++随机数 + 运行时间测量"这个组合展开。我会把从选型到实测的完整过程拿出来讲,包括rand()到底差在哪、mt19937和分布器怎么搭配、steady_clock和high_resolution_clock怎么选、为什么你明明计了时却得到0毫秒,以及写性能测试代码时的优化防御技巧。不管你是刚接触C++11标准库的新手,还是已经在写业务代码的老手,都能从里面拿走一些直接能用的东西。

1. 随机数与计时:两个功能背后的共同逻辑

1.1 为什么这两个功能总被放在一起

随机数和计时,看起来是两件不相关的事,但在实际操作中它们经常同框出现。最典型的一个场景就是性能基准测试:你要比较不同的随机数生成算法谁更快,就需要一个能精确测量运行时间的工具;反过来,你要验证某个计时方案的稳定性,又需要大量随机数据来反复压测。

另一个原因是,C++的随机数和计时在语言层面上都经历过一次升级。C++11之前,大家习惯用C语言风格的那套接口,简单但限制多;C++11之后,标准库把<random>和<chrono>带进来了,功能更完整,但使用门槛也跟着上来。很多人在这个过渡期踩过坑,比如用了新接口但种子还是老思路,或者计时用了nanoseconds却发现精度根本达不到,最后跑出来的数字自己都不信。

所以把两个话题合在一起讲,不是强行凑篇幅,而是因为它们在实际编码中确实会互相影响。你生成的随机数质量好不好、分布器选得对不对,最终都要通过计时数据来体现;而计时方式科学不科学,又决定了你看到的性能数字是真还是假。理解这条链路,才能写出可靠的性能测试代码。

1.2 哪些场景会同时用到这两块

从我接触到的实际需求来看,大概有这么几类人最需要这份内容。

第一类是做性能优化和基准测试的人。他们要对比不同随机数引擎的速度,或者衡量某个算法在随机输入下的表现,计时是他们最核心的诉求。第二类是写模拟程序、跑蒙特卡洛实验的人。这类程序往往要生成海量随机数来模拟各种概率事件,同时又要统计整个模拟过程的耗时,估算跑完一轮实验需要多久。第三类是刚接触C++11之后标准库的学习者,他们知道要生成随机数,也知道要测运行时间,但网上教程要么只讲某一个功能,要么给的例子太老,照着写还是报错。

不管你是哪一类,先记住一个核心观点:随机数生成器的选择,决定了你能生成什么样的数;计时工具的选择,决定了你看到的性能数据可不可信。这两件事的决策逻辑是互相关联的,下面我会把这个判断依据完整拆开。

2. 随机数:从 rand() 到 的进化

2.1 老接口的局限:rand() + srand() + 取模

先聊聊大家最熟悉的rand()。这个函数从C语言时期就一直存在,C++里也继续保留了。但实际用起来,它的限制相当明显。

第一个问题是范围。rand()返回的随机整数范围取决于平台,很多平台上RAND_MAX是32767,也就是说你只能拿到0到32767之间的数。如果需要更大的范围,比如生成一个几百万量级的随机ID,单纯用rand()就很吃力,你得自己拼。

第二个问题是偏差。很多人习惯写成rand() % n来限定范围,这个写法在n比较小的时候问题不大,但当n不是RAND_MAX的约数时,每个数字出现的概率并不完全相等。原因很简单:取模运算会把前RAND_MAX % n个数字多分配一次,造成微小但真实的偏差。做普通业务可能感觉不到,但做抽奖逻辑或者蒙特卡洛模拟时,这个偏差就可能累积成问题。

第三个问题是全局状态。rand()使用的是一个进程级的全局种子,由srand()设置,任何地方调用都会共享这个状态。多线程程序里,多个线程同时调用rand(),不但结果不可预测,还会产生数据竞争。这就很难受了,因为现代程序几乎都是多线程的。

这些局限不是说你完全不能用rand(),而是要知道它的边界。我个人现在只在写一些无关紧要的临时脚本时用rand(),真正的项目代码里,一律用标准库的<random>,处理起来省心得多。

2.2 现代方案:mt19937引擎 + 分布器的组合

C++11引入的<random>库把随机数生成拆成了两个层次:随机数引擎负责产生原始的数字序列,分布器负责把这些数字转换成你需要的分布。

先说引擎。标准库提供了好几种随机数引擎,比较常用的有这几类:

引擎算法类型周期特点
mt19937梅森旋转2^19937 - 1质量和速度均衡,默认首选
mt19937_64梅森旋转(64位)同上64位机器上生成范围更大,速度通常更快
minstd_rand线性同余2^31 - 2实现简单、内存极小,统计质量一般
ranlux48延迟Fibonacci极大统计质量高,速度偏慢

引擎输出的原始数值是一个unsigned int或者unsigned long long范围内的整数,分布并不均匀到让你直接用的程度。这时候就需要分布器来处理。

分布器的职责是把引擎产生的原始数值,按照你指定的规则映射成目标结果。常用分布器包括:

  • uniform_int_distribution:均匀整数分布,比如生成1到100之间的整数。
  • uniform_real_distribution:均匀实数分布,比如生成0.0到1.0之间的浮点数。
  • normal_distribution:正态分布,模拟自然现象时经常用。
  • bernoulli_distribution:伯努利分布,相当于按概率返回true或false。

用起来就是引擎加分布器的组合:

std::mt19937 gen(12345); std::uniform_int_distribution<int> dist(1, 100); int value = dist(gen);

每次调用dist(gen),它内部会从引擎取数,再根据分布规则做一次转换。这样做的好处是,引擎只需要一个实例,可以在不同分布器之间共享,因为分布器被设计成轻量的、可以复用的状态。多线程场景下,也能通过给每个线程独立引擎的方式规避竞争问题,这在老的rand()体系里是很难做到的。

2.3 种子的选择:random_device 与时间戳的博弈

随机数引擎是确定性的,同一个种子,生成的序列完全一样。这不是bug,是设计:它让程序可以复现,方便调试。但问题来了,你需要的是"每次运行都不一样"还是"每次运行都一样"?

如果只是做测试,想复现某个bug,固定种子完全没问题。但如果程序是用来做模拟或者抽奖的,就需要每次生成不同的序列,这时候种子的选择就关键了。

最常用的做法是用std::random_device来获取种子。这个类在大多数平台上会访问操作系统的真随机源,能提供真正的硬件熵。用法很简单:

std::random_device rd; std::mt19937 gen(rd());

但这里有个坑:random_device在某些实现下可能会退化成伪随机数生成器,在非主流平台上尤其容易出现这个问题。更稳妥的做法是把random_device和时间戳结合起来:

std::random_device rd; std::mt19937 gen(rd() ^ std::chrono::steady_clock::now().time_since_epoch().count());

这样即使random_device质量不够好,时间戳也能打散序列。另一个常见错误是使用time(NULL)作为种子。因为time()的精度是秒级,同一秒内创建的两个引擎会生成完全相同的序列。在性能测试、多进程环境中,这个问题会造成结果完全一致,让你误以为程序出了问题。

2.4 不同分布器的性能差异

说完了引擎和分布器,再聊聊性能。很多人以为生成随机数的成本就是引擎那一下,但实际上分布器经常才是隐藏的大头。

我做过一个简单的测试:用mt19937生成100万个数,分三种情况计时——直接用引擎拿原始值、通过uniform_int_distribution拿整数、通过uniform_real_distribution拿浮点数。结果相差很明显:直接用引擎最快,整数分布次之,浮点数分布最慢。浮点数分布之所以慢,是因为它内部需要处理范围映射、舍入等额外计算,每次调用都有开销。

这个结论对实际开发的启示是:如果某个循环只需要一个原始的32位或64位整数,那直接用引擎就行,不要多包一层分布器;但如果你确实需要均匀分布的浮点数,那就老老实实用uniform_real_distribution,别自己写rand() / RAND_MAX这种手写方式,因为它的分布均匀性和转换精度都不可控。

另外还要注意,分布器对象应当被复用。不要在循环里每次创建新的uniform_int_distribution,把dist对象定义在循环外部,既省去了构造开销,也避免了潜在的内部状态重置成本。这个优化虽然微小,但在百万级循环下能差出几个百分点的性能。

3. 计时:从 clock() 到 chrono 的准确度革命

3.1 clock() 测量的到底是什么

在C++11之前,测量运行时间最常用的函数是clock(),它来自C标准库。很多人以为clock()返回的是程序经过的墙上时间(wall time),这其实是个误解。

clock()返回的是CPU时间,也就是进程占用CPU执行代码所消耗的时间。在多任务系统上,如果进程在等待IO,或者被系统调度挂起,这段等待时间不会计入clock()的差值。它的单位是时钟周期数,需要除以CLOCKS_PER_SEC才能转换成秒。

这在单线程、纯计算任务中问题不大,因为计算密集型的代码大部分时间确实在占用CPU。但一旦涉及网络请求、文件读写、或者多线程协同,clock()的结果就完全不可靠了。举个夸张点的例子:你写一个程序,等待用户输入5秒,然后再算一个加法。这个程序的CPU时间可能只有零点几毫秒,但墙上时间已经过了5秒。如果用clock()去测,会得到"这个程序只运行了不到1毫秒"的错误结论。

所以,clock()只适合测量纯CPU计算片段的相对耗时,不适合测量任何涉及等待或并行的场景。现代C++要做性能测量,应该用chrono库。

3.2 chrono三兄弟:system_clock、steady_clock、high_resolution_clock

<chrono>是C++11引入的时间库,核心概念是时钟(clock)、时间点(time_point)和时长(duration)。标准库提供了三个时钟,很多人第一次接触时会被它们弄迷糊,我逐个说一下。

system_clock是系统范围内的墙上时钟,它对应的是我们日常看到的日历时间,比如"现在是哪年哪月哪日几点"。这个时钟可以被用户手动修改,也可能被系统自动校时(比如NTP同步)。如果你想记录某个事件发生的实际日历时间,用它是合适的;但如果用它来测量一段代码的运行时长,就有风险——测量期间系统时间被调整,你的计时结果就包含了一个莫名其妙的偏移量。

steady_clock是单调时钟,它保证时间只会向前走,不会跳变、不会被校时影响。这个特性让它成为测量运行时间的首选,标准里明确说了它适合做间隔测量。我平时所有性能测试的计时都基于steady_clock,稳定是第一优先级的诉求。

high_resolution_clock这个名字很有迷惑性,标准会说它是"具有最短计时间隔的时钟"。但不同编译器对它的实现不一样,有些把它定义为steady_clock的别名,有些定义为system_clock的别名。这意味着用它来计时,你无法确定它是否是单调的。我的经验是:别依赖high_resolution_clock的"高分辨率"这个名头,直接使用steady_clock就好。如果steady_clock的精度不够(实际上极少出现),再考虑平台特定的高精度API。

3.3 duration与时间单位换算

不管用哪个时钟,计时的核心套路是一样的:拿开始时间点,执行代码,拿结束时间点,两个时间点相减得到一个duration,然后把它转换到你想要的单位。

auto start = std::chrono::steady_clock::now(); // 这里放要测量的代码 auto end = std::chrono::steady_clock::now(); auto diff = end - start; auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(diff).count();

duration_cast负责把时长从一个单位转换到另一个单位,常见的有nanoseconds、microseconds、milliseconds、seconds。转换时会做整数除法,所以如果你测量的片段只有几百微秒,转成milliseconds会得到0,这不是bug,而是精度不够。解决方法是先用microseconds,甚至nanoseconds来展示结果。

顺带提一个容易忽略的点:duration_cast是截断转换,不是四舍五入。比如1999微秒转成毫秒会得1,而不是2。如果要做更精确的统计,可以用duration配合std::milli获得浮点精度的毫秒数:

auto ms = std::chrono::duration<double, std::milli>(diff).count();

这样变量的类型是double,能够保留小数部分,展示更细腻的耗时数据。这一步对性能测试特别重要,因为很多代码片段的耗时都在1毫秒上下,整数毫秒的粒度完全不够用。

4. 完整实操:一个随机数性能测试程序

4.1 测试目标与基准参数设计

接下来进入正题,我写了一个完整的性能测试程序,用来回答几个问题:

  • 在同一台机器上,rand()和mt19937谁更快?
  • uniform_int_distribution和直接取引擎原始值,性能差距有多大?
  • 不同编译器优化等级(-O0 vs -O2)对随机数生成速度影响如何?

测试设计上,我固定了几组参数:每组生成1000万个随机数,重复测试取稳定值,用steady_clock计时,单位用毫秒。为了避免编译器和CPU缓存带来的冷启动偏差,每组测试前先做一次预热运行,再正式计时。

这里要特别说明一下,1000万这个量级不是拍脑袋定的。太小的话,单次运行时间可能不足1毫秒,计时误差占比太高;太大的话又会让测试跑很久,不方便反复调试验证。1000万在大多数普通机器上大约是几十毫秒到几百毫秒的量级,适合用来对比不同实现的差异。

4.2 完整代码实现与逐段解读

#include <iostream> #include <random> #include <chrono> #include <cstdlib> // 防止编译器优化掉计算结果的汇接收纳点 volatile int g_sink; void run_rand(int count) { for (int i = 0; i < count; ++i) { g_sink = rand() % 1000; } } void run_mt19937(int count) { std::mt19937 gen(42); for (int i = 0; i < count; ++i) { g_sink = gen(); } } void run_mt19937_uniform_int(int count) { std::mt19937 gen(42); std::uniform_int_distribution<int> dist(0, 999); for (int i = 0; i < count; ++i) { g_sink = dist(gen); } } double measure_one(const char* name, void (*func)(int), int count) { func(count); // 预热,消除冷启动偏差 auto start = std::chrono::steady_clock::now(); func(count); auto end = std::chrono::steady_clock::now(); double ms = std::chrono::duration<double, std::milli>(end - start).count(); std::cout << name << ": " << ms << " ms" << std::endl; return ms; } int main() { srand(42); const int COUNT = 10000000; std::cout << "===== 随机数生成性能对比 =====" << std::endl; measure_one("rand() % 1000", run_rand, COUNT); measure_one("mt19937 原始值", run_mt19937, COUNT); measure_one("mt19937 + uniform_int_distribution", run_mt19937_uniform_int, COUNT); return 0; }

代码结构不难,重点在几个地方。第一个是volatile int g_sink这个全局变量。如果不把每次生成的随机数写到某个不会被优化的位置,编译器会认为整个循环是死代码,在开启优化后直接删掉,你测出来的时间会接近0。这是写基准测试时最容易犯的错误。volatile关键字告诉编译器:这个变量可能会被外部修改,每次访问都必须真实执行读写操作,从而防止循环被优化消除。

第二个是预热环节。func(count)先跑一遍,让CPU缓存、分支预测器、以及可能涉及的操作系统内存分配都进入稳定状态,然后才开始正式计时。否则第一次运行时的冷启动开销会被计入测试数据,造成结果偏高。

第三个是函数指针的用法,这样可以把三个测试函数统一交给measure_one处理,代码更整洁,后面想加新的测试项也方便。

4.3 编译与测试环境参数

测试时,我用了两个编译选项分别验证:

  • 不优化版本:g++ -O0 main.cpp -o test_o0
  • 优化版本:g++ -O2 main.cpp -o test_o2

为什么不只测优化版本?因为不少开发者实际发布程序用的是Release模式(对应-O2或更高),但日常开发调试用的是Debug模式(相当于-O0)。两种模式下的性能差异极大,这个测试正好能直观展示优化器的影响力。

在我本机的测试环境下得到的结果大致如下(具体数值会因CPU架构和编译器版本而异)。在-O2下的数据:

测试项单次耗时(毫秒)每秒生成量(百万/秒)
rand() % 100035约285
mt19937 原始值25约400
mt19937 + uniform_int_distribution30约333

在-O0下的数据:

测试项单次耗时(毫秒)每秒生成量(百万/秒)
rand() % 1000120约83
mt19937 原始值85约117
mt19937 + uniform_int_distribution110约90

结论有几个层面。第一,现代优化器对rand()的优化效果其实也很好,但mt19937在两组测试中都比rand()快,原因是rand()的内部实现需要维护全局状态,并且取模运算本身有额外开销。第二,uniform_int_distribution确实比直接取引擎原始值慢一些,但这个差距没有很多人想象中那么大,大约是20%左右,远不至于为了速度放弃正确的分布。第三,优化等级的影响巨大,同样的代码在-O0和-O2下相差3到4倍,这提醒我们,性能测试一定要基于最终发布时的编译配置,用Debug模式测出来的数据会误导你的判断。

4.4 结果解读与性能分析思路

看到上面的数据,可能有读者会问:既然mt19937比rand()快,uniform_int_distribution又慢不了多少,那是不是应该无脑用mt19937加uniform_int_distribution?

我的建议是这样:如果代码只在你自己的项目里跑,随机数的质量要求不高,用什么都行。但如果你是写公共库或者要长期维护的代码,为了接口清晰和可扩展性,优先用mt19937加统一分布器。因为再过一段时间,你可能需要正态分布、伯努利分布,那时候只有<random>体系能优雅地支持。

性能分析也不要只看一个指标。生成速度只是随机数方案的一个维度,另外一个重要维度是内存占用。mt19937占用的内部状态大约是2.5KB,而对线性同余引擎来说,只需要几个字节。如果你的程序要同时维护成千上万个随机数引擎实例(比如每个线程一个),2.5KB乘以实例数量会成为不可忽视的内存开销。这种情况下,可以考虑用minstd_rand这类更节省状态的引擎。

CPU缓存的友好性也是一个隐含因素。mt19937维护的状态块较大,在单次生成时可能不会全部被加载到缓存,但批量生成时缓存复用率就上来了。这也解释了为什么批量性能测试中mt19937能反超rand():rand()的全局状态和内部计算逻辑反而在频繁调用时干扰了CPU流水线。

5. 常见问题与排查技巧实录

5.1 每次运行随机数都一样,到底怎么回事

这是被问得最多的一个问题。绝大多数情况是种子没变或者种子来源精度太低。如果你用了srand(time(NULL)),并且程序在同一秒内跑了好几次,那每次生成的序列当然一模一样。解决办法前面说过,用random_device加时间戳组合成种子。但请注意,如果你的程序本来就希望复现同一个结果(比如跑回归测试、对比算法),固定种子反而是正确行为。先搞清楚你的目标是"复现"还是"不可预测",再决定种子的策略。

还有一种情况容易被忽略:多线程程序里每个线程都自己调用srand,以为能互不干扰,实际上srand影响的是全局状态,线程间依然共享同一个序列,不但不能增加随机性,反而会造成数据竞争。正确做法是每个线程创建独立的引擎实例,用不同的种子初始化。

5.2 计时结果飘忽不定,或者出现0毫秒

计时出现0毫秒,几乎都是精度问题。当你用milliseconds作为单位而代码片段只需要几百微秒时,duration_cast截断后就是0。解决办法是把单位换成microseconds或nanoseconds,或者用浮点duration保留小数部分。

计时结果飘忽不定,通常原因有三:一是系统里其他进程抢占了CPU,导致程序被测片段被执行调度打断;二是CPU频率动态调整,比如睿频带来的时钟频率变化;三是代码首次执行时的内存缺页和分支预测冷启动。稳定计时的经验是:多次运行取中位数而不是平均值,避免异常值影响;在两次测试之间加一个短暂的sleep,让系统状态稳定下来;尽量在程序刚启动、系统负载低的时候跑基准测试。

还有一个重要技巧:不要用system_clock做性能计时。如果恰巧在测试期间系统做了校时,你的结束时间点可能比开始时间点还早,得到负数耗时。这种问题排查起来非常折磨人,用steady_clock就永远不会发生。

5.3 开启优化后结果反而异常

开启-O2后,有些基准测试代码会被优化器大幅改写,这是好事,但也会带来一个陷阱:如果被测代码的结果没有副作用(不需要输出、不修改外部状态),优化器会直接把整段循环删掉。这就是很多人测出"0毫秒"的深层原因。

解决思路就是在循环内部引入一个副作用,让优化器无法证明代码可以被删除。常见做法有三类:

  • 把计算结果写到一个volatile全局变量。
  • 把计算结果累加后输出一次。
  • 把计算结果传给一个外部函数,比如编译器无法内联的函数。

上面测试代码用的就是第一类,简单直接有效。写基准测试时养成这个习惯,能省掉很多莫名的排查时间。

5.4 常见问题速查表

问题现象常见原因解决思路
每次运行随机序列相同种子相同或time()精度不足用random_device+时间戳组合
rand()%n结果偏向某些数取模导致分布不均匀换用uniform_int_distribution
多线程随机数结果冲突共享全局rand状态每线程独立引擎实例
计时总是0毫秒duration_cast单位过大换用microseconds或浮点时长
计时出现负数用了system_clock被校时改用steady_clock
开启优化后耗时为0循环被优化器删除用volatile或累计输出副作用
高分辨率时钟不够高编译器实现别名问题直接用steady_clock
测试结果波动大CPU调度或睿频影响多次测试取中位数

这张表基本覆盖了我这几年在随机数和计时上遇到的大部分问题。每次写性能测试代码前,我都会扫一眼这个清单,能少走很多弯路。

最后说点真正的实战心得。写性能测试代码,目标往往不是"测出一个漂亮数字",而是"测得可信"。我在反复调试中发现,最影响可信度的不是那个随机数生成器快不快,而是计时环节有没有被环境干扰。所以我的习惯是:先把计时框架写稳,用steady_clock、预热、取中位数这三板斧保证数据稳定,再开始对比不同实现;如果发现两个方案的性能差距在5%以内,我基本不会轻易下结论说某方案更好,因为这种差距很可能只是噪声。随机数引擎的选择也类似:优先把正确性和可维护性放在前面,只有当性能分析明确指向随机数生成是瓶颈时,才去做引擎级别的优化。这套思路,希望能帮你在自己的项目里少踩几个坑。

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

MonoDepth2精读:自监督单目深度估计的原理、损失设计与工程实践

/* 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 1:26:47

易语言数组加入成员详解:动态扩容、性能优化与实战技巧

/* 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 1:26:45

Helm 3.2.4 实战指南:从安装到多环境部署与回滚

/* 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 1:25:51

高等数学极限入门:从ε-N定义到实战技巧的完整指南

/* 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 1:25:14

Julia特征向量计算的五大工程陷阱与实战对策

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

作者头像 李华