Markdown 排版测试长文

/ 30 min read / updated 2026-10-11 / #测试 #Markdown

一篇专门用来检查排版的长文:标题、列表、表格、代码、公式、提示块、脚注,能想到的 Markdown 语法都在这里出现一次。

这篇文章没有要讲的观点,它存在的唯一目的是把博客支持的排版元素都摆出来,方便在改样式之后从头到尾看一遍,确认没有哪一处坏掉。为了让它读起来不像一张语法清单,正文借了一个真实的话题来写:怎样估算一段程序要跑多久。话题本身不重要,重要的是每一节都会用到一两种不同的语法。

如果你是第一次读到这里,可以先看从 Obsidian 到博客的发布流程,了解这些文章是怎么从笔记变成网页的;想直接跳到公式部分,可以去公式那一节。

一、段落与行内样式

最普通的段落

一个段落就是连续的几行文字。中文和 English 混排是最常见的情况,比如一句话里出现 O(n log n)、HTTP 状态码 404、版本号 v2.3.1,或者一个很长的不带空格的单词 Pneumonoultramicroscopicsilicovolcanoconiosis,这些都不应该把一行撑破,也不应该让行与行之间的距离忽大忽小。

段落里可以手动换行。这一行的末尾有两个空格,
所以这里另起了一行,但仍然属于同一个段落。用反斜杠也能做到同样的事,
这是第三行。两种写法渲染出来应该完全一样。

一个很长的段落

下面这一段故意写得很长,用来观察大段文字在页面上的灰度是否均匀,行与行之间会不会显得太挤。估算一段程序的运行时间,听起来像是需要精密仪器的工作,实际上大多数时候只需要纸和笔。一台普通的电脑每秒能执行的简单操作在十亿次上下,这个数字不必记得很准,记住量级就够了:一千万次操作大约是几十毫秒,一亿次是零点几秒,十亿次就要按秒算了。有了这把尺子,很多问题在写代码之前就有了答案。如果一个功能需要对十万条记录两两比较,那就是五十亿次比较,无论怎么调优都不可能在一秒内完成,应该换一种做法,而不是换一台更快的机器;反过来,如果只是把十万条记录排个序,大约是一百七十万次比较,根本不值得花时间去优化。真正难的地方不在算术,而在于数清楚「一次操作」到底是什么:读一个已经在缓存里的变量,和读一个要从磁盘取回来的数据块,相差的不是几倍,而是几十万倍。把这两种东西加在一起数,得到的数字没有任何意义。所以一份靠谱的估算,总是先把不同代价的操作分开列出来,各自数清楚次数,最后才相加。这个习惯一旦养成,你会发现自己很少再写出那种「本地跑得好好的,上线就超时」的代码。

强调的几种写法

估算运行时间的第一步,是分清必须精确的部分和只需要量级的部分。大多数时候我们关心的是后者:一个算法是线性的还是平方的,比它具体用了 3.2 毫秒还是 3.5 毫秒重要得多。同时加粗和倾斜的文字很少用到,但它应该能正常显示。

写错的结论可以用删除线划掉:哈希表的查找永远是常数时间,更准确的说法是「平均情况下是常数时间」。Obsidian 里还有一种高亮写法:这句话在笔记里是黄色底的,网页上它是否生效取决于渲染器。

行内代码用反引号包起来,例如 len(items)、for i in range(n),以及带反引号本身的写法 `code`。键盘按键可以直接写 HTML:按 Ctrl + C 中断程序。上标和下标也是 HTML:H2O,x2 + y2。

链接

链接有几种形式:

需要原样显示的符号要转义:*这不是斜体*,# 这不是标题,[这不是链接],1. 这不是列表。

二、标题层级

二级标题是文章的主要分节,上面已经出现过两个。下面依次是三级和四级标题,用来确认它们的字号、颜色和间距能分得开。

三级标题:时间复杂度

三级标题下面通常是一个具体的小主题。时间复杂度描述的是「输入规模翻倍时,耗时变成原来的多少倍」,它不告诉你具体的秒数。

四级标题:常数因子

四级标题已经很细了,一般只在长文里偶尔出现。常数因子指的是被大 O 记号省略掉的那个系数:两个都是线性的算法,一个可能比另一个慢十倍。

四级标题:低阶项

同样被省略的还有低阶项。当 n 足够大时,n² + 1000n 里的 1000n 可以忽略;但 n 只有几十的时候,它才是主角。

三级标题:空间复杂度

和时间相对的是空间。一个算法用掉多少内存,同样可以用输入规模的函数来描述。空间和时间常常可以互相交换:多记一些中间结果,就能少算几次;内存紧张的时候,也可以反过来用重复计算换空间。后面完整的例子里就有一次这样的交换。

值得留意的是,空间不只影响「放不放得下」。数据占的地方越大,能留在缓存里的比例就越低,每次访问的平均代价也跟着上升。所以一个理论上更省时间、但要多占几倍内存的做法,实测未必更快。

标题里的 行内代码 与 强调

标题里也可以出现行内代码和强调,它们应该继承标题的字号,而不是缩回正文的大小。这一节的标题同时用来测试目录里的显示:目录应该只显示文字,不带反引号和星号。

三、列表

无序列表

估算之前先问自己几个问题:

有序列表

一次完整的估算大致分四步:

  1. 找出最内层的循环。
  2. 数清楚它执行多少次,用输入规模表示出来。
  3. 估计每次执行的代价。这一项比较长,用来测试列表项内部折行之后的缩进是否对齐:一次内存访问、一次函数调用、一次系统调用,三者的代价相差好几个数量级,不能当成同一种「基本操作」。
  4. 两者相乘,再和实测结果比较。
    1. 如果差一个数量级以内,估算就算成功。
    2. 如果差得更多,说明第 2 步或第 3 步漏了东西。

任务列表

列表里嵌套其他元素

四、引用与提示块

普通引用

我们应该忘掉那些微小的效率提升,大约 97% 的时间里都是如此:过早优化是万恶之源。但也不应该放过那关键的 3%。

—— 高德纳,《使用 goto 语句的结构化编程》

引用可以嵌套:

第一层引用。

第二层引用,用来回应上面那句话。

第三层,很少用到。

回到第一层。

引用里也可以有列表和代码:

三条经验:

  1. 先测量,再优化。
  2. 只优化瓶颈。
  3. 优化之后再测量一次。
time ./program < input.txt

引用和正文之间应该有明显但不突兀的区别。一段引用如果很长,比如下面这段,它的左侧竖线应该一直延伸到最后一行,中间不会断开:

程序的性能问题很少均匀地分布在代码里。通常是很小的一部分代码占用了绝大部分时间,而程序员对这一小部分在哪里的直觉往往是错的。这就是为什么要先测量:不是因为测量比思考高明,而是因为它便宜,而且不会骗你。在没有数据的时候就动手修改,最常见的结果是把一段本来清楚的代码改得难懂,换来的提升却小到测不出来。

提示块

Obsidian 的 Callout 会被渲染成提示块,不同类型的区别只在左侧竖线的颜色。

备注

大 O 记号描述的是上界。说一个算法是 O(n²),并不等于它一定跑满平方时间。

提示

不确定复杂度的时候,把输入规模翻倍、测两次时间,比推导要快。

重要

复杂度相同的两个算法,实际速度可以相差几十倍。

注意

在笔记本电脑上测出来的时间不能直接搬到服务器上,反过来也一样。

危险

不要在生产环境的数据库上「试一试」一个没有估算过的查询。

引用

测量两次,下刀一次。

默认折叠的例子(点击展开)

这个提示块默认是收起来的。里面可以放任何内容:

  • 列表
  • 加粗
  • 行内代码
默认展开、可以折叠的问题

为什么冒泡排序在几乎有序的数组上会很快?

备注

没有写标题的提示块,标题会用类型的中文名代替。

五、表格

基本表格

复杂度 名称 n = 1000 时的操作数
O(1) 常数 1
O(log n) 对数 约 10
O(n) 线性 1 000
O(n log n) 线性对数 约 10 000
O(n²) 平方 1 000 000
O(2ⁿ) 指数 比宇宙中的原子还多

对齐方式

左对齐 居中 右对齐
插入排序 稳定 12.4 ms
归并排序 稳定 1.8 ms
快速排序 不稳定 1.1 ms
堆排序 不稳定 2.3 ms

单元格里的行内样式

写法 效果 说明
**文字** 文字 加粗
*文字* 文字 倾斜
~~文字~~ 文字 删除线
`代码` 代码 行内代码
[链接](网址) 链接 外部链接
[[双链|别名]] 别名 表格里的竖线要转义

很宽的表格

下面这张表的列很多,窄屏上应该能横向滚动,而不是把页面撑宽。

算法 最好情况 平均情况 最坏情况 额外空间 是否稳定 是否原地 适用场景
冒泡排序 O(n) O(n²) O(n²) O(1) 是 是 教学,或数据几乎有序
插入排序 O(n) O(n²) O(n²) O(1) 是 是 小数组,或作为其他排序的收尾
归并排序 O(n log n) O(n log n) O(n log n) O(n) 是 否 需要稳定排序,或排序链表
快速排序 O(n log n) O(n log n) O(n²) O(log n) 否 是 通用场景的默认选择

六、代码

行内代码与代码块

行内代码适合提到一个名字,比如 bisect_left;超过一行的内容应该放进代码块。没有标注语言的代码块不做高亮:

$ python bench.py
n=1000     0.8 ms
n=2000     3.1 ms
n=4000    12.6 ms

代码块里用的是另一款等宽字体,字号比正文略小。检查的时候留意几件事:缩进是否对齐,中文注释和英文代码混在一起时会不会错位,以及亮色和暗色两种主题下每一种颜色是否都看得清。下面几种语言各有侧重:Python 的例子里有文档字符串和类型标注,TypeScript 的例子里有接口和模板字符串,Go 的例子用制表符缩进。

Python

from bisect import bisect_left


def binary_search(items: list[int], target: int) -> int | None:
    """在有序列表里查找 target,返回下标;找不到返回 None。"""
    index = bisect_left(items, target)
    if index < len(items) and items[index] == target:
        return index
    return None


if __name__ == "__main__":
    data = sorted({3, 1, 4, 1, 5, 9, 2, 6})
    print(binary_search(data, 5))  # 4

TypeScript

interface Sample {
  size: number
  millis: number
}

// 输入规模翻倍时耗时变成几倍,取对数就是复杂度的指数
export function estimateExponent(a: Sample, b: Sample): number {
  return Math.log(b.millis / a.millis) / Math.log(b.size / a.size)
}

const exponent = estimateExponent({ size: 1000, millis: 0.8 }, { size: 2000, millis: 3.1 })
console.log(`指数约为 ${exponent.toFixed(2)}`)

Go

package main

import (
	"fmt"
	"time"
)

func measure(name string, fn func()) {
	start := time.Now()
	fn()
	fmt.Printf("%-12s %v\n", name, time.Since(start))
}

func main() {
	measure("sum", func() {
		total := 0
		for i := 0; i < 1_000_000; i++ {
			total += i
		}
	})
}

Shell、JSON、SQL 与 diff

# 重复运行 10 次,取最快的一次
for i in $(seq 1 10); do
  /usr/bin/time -f "%e" ./program < input.txt 2>> times.log
done
sort -n times.log | head -n 1
{
  "benchmark": "sort",
  "runs": 10,
  "results": [{ "n": 1000, "ms": 0.8 }, { "n": 2000, "ms": 3.1 }],
  "stable": true,
  "note": null
}
SELECT algorithm, AVG(millis) AS avg_ms
FROM benchmarks
WHERE n >= 1000
GROUP BY algorithm
ORDER BY avg_ms ASC
LIMIT 5;
 def total(values):
-    result = 0
-    for v in values:
-        result += v
-    return result
+    return sum(values)

很长的一行

代码块里的长行不应该折行,而是让代码块自己横向滚动:

const result = samples.filter((sample) => sample.size >= minimumSize).map((sample) => ({ ...sample, perItem: sample.millis / sample.size })).sort((a, b) => a.perItem - b.perItem).slice(0, 10)

代码块里的特殊内容

代码块里的 Markdown 语法、双链和注释都应该原样保留:

# 这不是标题

**这不是加粗**,[[这不是双链]],%%这不是注释%%,$这不是公式$。

> [!note] 这也不是提示块

七、公式

行内公式与块级公式

行内公式写在一对美元符号之间。二分查找最多比较 ⌈log⁡2(n+1)⌉\lceil \log_2 (n + 1) \rceil 次;归并排序的递推式是 T(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n),解出来是 O(nlog⁡n)O(n \log n)。行内公式应该和周围的文字在同一条基线上,不把行距撑开。

块级公式单独占一行,写在两对美元符号之间。大 O 记号的定义:

f(n)=O(g(n))  ⟺  ∃ c>0, n0>0:∀n≥n0, 0≤f(n)≤c⋅g(n)f(n) = O(g(n)) \iff \exists\, c > 0,\ n_0 > 0 : \forall n \ge n_0,\ 0 \le f(n) \le c \cdot g(n)

求和、分式与根号

插入排序在最坏情况下的比较次数是一个等差数列的和:

∑i=1n−1i=n(n−1)2=O(n2)\sum_{i=1}^{n-1} i = \frac{n(n-1)}{2} = O(n^2)

调和级数出现在快速排序的平均情况分析里:

Hn=∑k=1n1k≈ln⁡n+γ,γ≈0.5772H_n = \sum_{k=1}^{n} \frac{1}{k} \approx \ln n + \gamma, \qquad \gamma \approx 0.5772

斯特林公式解释了为什么基于比较的排序不可能快过 nlog⁡nn \log n:

n!≈2πn(ne)nn! \approx \sqrt{2 \pi n} \left( \frac{n}{e} \right)^n

矩阵与分段函数

斐波那契数可以用矩阵快速幂在 O(log⁡n)O(\log n) 时间内算出来:

(Fn+1Fn)=(1110)n(10)\begin{pmatrix} F_{n+1} \\ F_n \end{pmatrix} = \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{n} \begin{pmatrix} 1 \\ 0 \end{pmatrix}

主定理按三种情况给出递推式 T(n)=aT(n/b)+f(n)T(n) = aT(n/b) + f(n) 的解:

T(n)={Θ(nlog⁡ba)若 f(n)=O(nlog⁡ba−ε)Θ(nlog⁡balog⁡n)若 f(n)=Θ(nlog⁡ba)Θ(f(n))若 f(n)=Ω(nlog⁡ba+ε)T(n) = \begin{cases} \Theta(n^{\log_b a}) & \text{若 } f(n) = O(n^{\log_b a - \varepsilon}) \\ \Theta(n^{\log_b a} \log n) & \text{若 } f(n) = \Theta(n^{\log_b a}) \\ \Theta(f(n)) & \text{若 } f(n) = \Omega(n^{\log_b a + \varepsilon}) \end{cases}

多行对齐

把归并排序的递推式一层层展开:

T(n)=2T(n/2)+cn=4T(n/4)+2cn=8T(n/8)+3cn=⋯=nT(1)+cnlog⁡2n\begin{aligned} T(n) &= 2T(n/2) + cn \\ &= 4T(n/4) + 2cn \\ &= 8T(n/8) + 3cn \\ &= \cdots \\ &= nT(1) + cn \log_2 n \end{aligned}

八、脚注、分隔线与 HTML

脚注

正文里可以插入脚注1,脚注的内容集中显示在文章末尾。同一篇文章可以有多个脚注2,标识符可以是数字,也可以是一个单词。

分隔线

分隔线用来在同一节里隔开两段不相关的内容。这套设计里它不画线,只空出一行。


分隔线之后的段落。

折叠内容

点击展开一段用 HTML 写的折叠内容

折叠块里可以继续写 Markdown:加粗、代码,以及一个列表:

  • 第一项
  • 第二项

特殊字符

九、一个完整的小例子

前面各节都是零散的片段,这一节把它们合起来,写一段接近真实文章的内容,用来看各种元素交替出现时的节奏。

问题

有一个长度为 n 的整数数组,要找出其中是否存在两个数的和等于给定的目标值。最直接的做法是两层循环:

def has_pair_naive(nums: list[int], target: int) -> bool:
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            if nums[i] + nums[j] == target:
                return True
    return False

内层循环一共执行 n(n−1)2\frac{n(n-1)}{2} 次,所以它是 O(n2)O(n^2) 的。

改进

用一个集合记住见过的数,就只需要扫一遍:

def has_pair(nums: list[int], target: int) -> bool:
    seen: set[int] = set()
    for x in nums:
        if target - x in seen:
            return True
        seen.add(x)
    return False

为什么更快

集合的查找平均是常数时间,所以整体是 O(n)O(n)。代价是多用了 O(n)O(n) 的内存。

实测

n 朴素做法 用集合 倍数
1 000 21 ms 0.06 ms 350
10 000 2 080 ms 0.61 ms 3 400
100 000 约 3.5 分钟 6.3 ms 33 000

规模每扩大十倍,朴素做法慢一百倍,集合做法慢十倍,和估算一致。几点观察:

  1. n 很小的时候两者都够快,不值得优化。
  2. n 到一万时差距已经是三个数量级。
  3. n 到十万时,朴素做法已经不可用了。

结论

估算不需要很精确,它的作用是在动手之前告诉你方向对不对。回到开头的那句话:先分清哪些部分必须精确,哪些只需要量级。

十、几个常见的误区

这一节没有新的语法,只是几段连续的正文,中间夹着少量行内样式。它的作用是让文章的后半部分也有足够长的纯文字,方便看滚动到页面中段时目录的位置是否稳定。

把平均情况当成保证

哈希表的查找「平均」是常数时间,这句话里的「平均」两个字经常被读漏。它的意思是:在键分布得足够均匀的前提下,查找的期望代价不随规模增长。前提一旦不成立,比如大量的键恰好落进同一个桶,查找就会退化成线性的。这不是纸上谈兵,历史上出现过专门构造这类输入来拖垮服务器的攻击,所以现在主流语言的哈希表都会在启动时加入随机因子。

快速排序也是同样的情况。它的平均表现很好,但在已经排好序的输入上,朴素的实现会退化到平方时间。现实中「已经排好序」恰恰是很常见的输入,所以实用的实现都会随机选取基准,或者在检测到退化时换成别的算法。

忽略输入输出

很多慢的程序其实并不「算」得慢。一次内存访问大约是一百纳秒,一次固态硬盘的随机读取是它的上千倍,一次跨机房的网络请求又要再高出几个数量级。如果一个循环里藏着一次数据库查询,那么无论循环体里的计算写得多精巧,耗时都由查询的次数决定。

这类问题有一个朴素但有效的检查方法:把循环里所有会离开当前进程的调用圈出来,然后问自己,它们能不能合并成一次。把一千次单条查询改成一次批量查询,得到的提升通常比任何算法层面的优化都大。

在错误的规模上测量

用十条数据测试一个平方时间的算法,它会显得快如闪电。规模太小的时候,测到的主要是启动、分配内存这些固定开销,算法本身的差别被淹没了。反过来,如果实际使用时数据永远只有几十条,那么为它换上一个更复杂的算法也毫无意义,甚至可能更慢,因为复杂的算法往往有更大的常数因子。

所以测量之前要先弄清楚真实的规模,然后在那个规模以及它的两倍、十倍上各测一次。只测一个点,看不出增长的趋势;测三个点,就能大致判断它是线性的、线性对数的,还是平方的。

只看一次运行的结果

同一段程序连续跑两次,耗时相差百分之十是很平常的事。操作系统在后台做的事情、处理器的降频、缓存里碰巧留着什么,都会影响结果。因此比较两种做法的时候,应该各跑多次,看分布而不是看单个数字。习惯上取多次运行里最快的一次作为代表,因为干扰只会让程序变慢,不会让它变快。

如果两种做法的差距小于这种自然的波动,那么它们在实践中就是一样快的,此时应该选更容易读懂的那一个。

十一、检查清单

读到这里,可以对照下面的清单确认每一种元素都正常:

Footnotes

  1. 这是第一条脚注。脚注里也可以有 行内代码 和链接。 ↩

  2. 高德纳的原话出自 1974 年的论文,常被断章取义地引用成前半句。 ↩