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。
链接
链接有几种形式:
- 行内链接:大 O 记号的维基百科条目
- 带标题的链接:Python 官方文档
- 自动链接:https://example.com/a/very/long/path/that/should/wrap/instead/of/overflowing/the/content/column
- 裸网址:https://example.com
- 站内双链:上一篇文章
- 页内锚点:五、表格
需要原样显示的符号要转义:*这不是斜体*,# 这不是标题,[这不是链接],1. 这不是列表。
二、标题层级
二级标题是文章的主要分节,上面已经出现过两个。下面依次是三级和四级标题,用来确认它们的字号、颜色和间距能分得开。
三级标题:时间复杂度
三级标题下面通常是一个具体的小主题。时间复杂度描述的是「输入规模翻倍时,耗时变成原来的多少倍」,它不告诉你具体的秒数。
四级标题:常数因子
四级标题已经很细了,一般只在长文里偶尔出现。常数因子指的是被大 O 记号省略掉的那个系数:两个都是线性的算法,一个可能比另一个慢十倍。
四级标题:低阶项
同样被省略的还有低阶项。当 n 足够大时,n² + 1000n 里的 1000n 可以忽略;但 n 只有几十的时候,它才是主角。
三级标题:空间复杂度
和时间相对的是空间。一个算法用掉多少内存,同样可以用输入规模的函数来描述。空间和时间常常可以互相交换:多记一些中间结果,就能少算几次;内存紧张的时候,也可以反过来用重复计算换空间。后面完整的例子里就有一次这样的交换。
值得留意的是,空间不只影响「放不放得下」。数据占的地方越大,能留在缓存里的比例就越低,每次访问的平均代价也跟着上升。所以一个理论上更省时间、但要多占几倍内存的做法,实测未必更快。
标题里的 行内代码 与 强调
标题里也可以出现行内代码和强调,它们应该继承标题的字号,而不是缩回正文的大小。这一节的标题同时用来测试目录里的显示:目录应该只显示文字,不带反引号和星号。
三、列表
无序列表
估算之前先问自己几个问题:
- 输入的规模大概是多少?
- 最坏情况会不会真的出现?
- 瓶颈在计算、内存还是输入输出?
- 如果是输入输出,算法再快也没用。
- 如果是内存,要看数据能不能放进缓存。
- 一级缓存通常只有几十 KB。
- 超出之后每次访问的代价会高一个数量级。
- 这段代码会被调用多少次?
有序列表
一次完整的估算大致分四步:
- 找出最内层的循环。
- 数清楚它执行多少次,用输入规模表示出来。
- 估计每次执行的代价。这一项比较长,用来测试列表项内部折行之后的缩进是否对齐:一次内存访问、一次函数调用、一次系统调用,三者的代价相差好几个数量级,不能当成同一种「基本操作」。
- 两者相乘,再和实测结果比较。
- 如果差一个数量级以内,估算就算成功。
- 如果差得更多,说明第 2 步或第 3 步漏了东西。
任务列表
- 写出朴素的实现
- 用小数据验证结果正确
- 用大数据测量耗时
- 和估算值比较,解释差距
列表里嵌套其他元素
-
列表项里可以放一段代码:
total = sum(x * x for x in values) -
也可以放一段引用:
过早优化是万恶之源。
-
还可以有多个段落。这是第一段。
这是同一个列表项里的第二段,它应该和第一段左对齐。
四、引用与提示块
普通引用
我们应该忘掉那些微小的效率提升,大约 97% 的时间里都是如此:过早优化是万恶之源。但也不应该放过那关键的 3%。
—— 高德纳,《使用 goto 语句的结构化编程》
引用可以嵌套:
第一层引用。
第二层引用,用来回应上面那句话。
第三层,很少用到。
回到第一层。
引用里也可以有列表和代码:
三条经验:
- 先测量,再优化。
- 只优化瓶颈。
- 优化之后再测量一次。
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] 这也不是提示块
七、公式
行内公式与块级公式
行内公式写在一对美元符号之间。二分查找最多比较 次;归并排序的递推式是 ,解出来是 。行内公式应该和周围的文字在同一条基线上,不把行距撑开。
块级公式单独占一行,写在两对美元符号之间。大 O 记号的定义:
求和、分式与根号
插入排序在最坏情况下的比较次数是一个等差数列的和:
调和级数出现在快速排序的平均情况分析里:
斯特林公式解释了为什么基于比较的排序不可能快过 :
矩阵与分段函数
斐波那契数可以用矩阵快速幂在 时间内算出来:
主定理按三种情况给出递推式 的解:
多行对齐
把归并排序的递推式一层层展开:
八、脚注、分隔线与 HTML
脚注
正文里可以插入脚注1,脚注的内容集中显示在文章末尾。同一篇文章可以有多个脚注2,标识符可以是数字,也可以是一个单词。
分隔线
分隔线用来在同一节里隔开两段不相关的内容。这套设计里它不画线,只空出一行。
分隔线之后的段落。
折叠内容
点击展开一段用 HTML 写的折叠内容
折叠块里可以继续写 Markdown:加粗、代码,以及一个列表:
- 第一项
- 第二项
特殊字符
- 标点与符号:—— …… 「」『』 · ← → ↑ ↓ ≤ ≥ ≠ ≈ × ÷ ±
- 实体:© & < > (最后一个是不换行空格)
- 表情:🚀 ✅ ⚠️ 🐱
- 全角与半角:ABC ABC,123 123
九、一个完整的小例子
前面各节都是零散的片段,这一节把它们合起来,写一段接近真实文章的内容,用来看各种元素交替出现时的节奏。
问题
有一个长度为 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
内层循环一共执行 次,所以它是 的。
改进
用一个集合记住见过的数,就只需要扫一遍:
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
为什么更快
集合的查找平均是常数时间,所以整体是 。代价是多用了 的内存。
实测
| 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 |
规模每扩大十倍,朴素做法慢一百倍,集合做法慢十倍,和估算一致。几点观察:
- n 很小的时候两者都够快,不值得优化。
- n 到一万时差距已经是三个数量级。
- n 到十万时,朴素做法已经不可用了。
结论
估算不需要很精确,它的作用是在动手之前告诉你方向对不对。回到开头的那句话:先分清哪些部分必须精确,哪些只需要量级。
十、几个常见的误区
这一节没有新的语法,只是几段连续的正文,中间夹着少量行内样式。它的作用是让文章的后半部分也有足够长的纯文字,方便看滚动到页面中段时目录的位置是否稳定。
把平均情况当成保证
哈希表的查找「平均」是常数时间,这句话里的「平均」两个字经常被读漏。它的意思是:在键分布得足够均匀的前提下,查找的期望代价不随规模增长。前提一旦不成立,比如大量的键恰好落进同一个桶,查找就会退化成线性的。这不是纸上谈兵,历史上出现过专门构造这类输入来拖垮服务器的攻击,所以现在主流语言的哈希表都会在启动时加入随机因子。
快速排序也是同样的情况。它的平均表现很好,但在已经排好序的输入上,朴素的实现会退化到平方时间。现实中「已经排好序」恰恰是很常见的输入,所以实用的实现都会随机选取基准,或者在检测到退化时换成别的算法。
忽略输入输出
很多慢的程序其实并不「算」得慢。一次内存访问大约是一百纳秒,一次固态硬盘的随机读取是它的上千倍,一次跨机房的网络请求又要再高出几个数量级。如果一个循环里藏着一次数据库查询,那么无论循环体里的计算写得多精巧,耗时都由查询的次数决定。
这类问题有一个朴素但有效的检查方法:把循环里所有会离开当前进程的调用圈出来,然后问自己,它们能不能合并成一次。把一千次单条查询改成一次批量查询,得到的提升通常比任何算法层面的优化都大。
在错误的规模上测量
用十条数据测试一个平方时间的算法,它会显得快如闪电。规模太小的时候,测到的主要是启动、分配内存这些固定开销,算法本身的差别被淹没了。反过来,如果实际使用时数据永远只有几十条,那么为它换上一个更复杂的算法也毫无意义,甚至可能更慢,因为复杂的算法往往有更大的常数因子。
所以测量之前要先弄清楚真实的规模,然后在那个规模以及它的两倍、十倍上各测一次。只测一个点,看不出增长的趋势;测三个点,就能大致判断它是线性的、线性对数的,还是平方的。
只看一次运行的结果
同一段程序连续跑两次,耗时相差百分之十是很平常的事。操作系统在后台做的事情、处理器的降频、缓存里碰巧留着什么,都会影响结果。因此比较两种做法的时候,应该各跑多次,看分布而不是看单个数字。习惯上取多次运行里最快的一次作为代表,因为干扰只会让程序变慢,不会让它变快。
如果两种做法的差距小于这种自然的波动,那么它们在实践中就是一样快的,此时应该选更容易读懂的那一个。
十一、检查清单
读到这里,可以对照下面的清单确认每一种元素都正常:
- 各级标题的颜色和大小能区分,标题后的
#锚点可以点击 - 目录列出了所有二、三级标题,点击能跳到对应位置
- 行内样式:加粗、倾斜、删除线、行内代码、链接
- 列表:无序、有序、任务列表、多层嵌套
- 引用的嵌套层级清楚,提示块的竖线颜色各不相同
- 表格的对齐方式生效,宽表格可以横向滚动
- 代码块有语法高亮,长行可以横向滚动
- 行内公式和块级公式都渲染成了数学符号
- 脚注可以点击跳转,并能跳回原处
- 开头那段
%%注释没有出现在页面上 - 亮色和暗色两种主题下都检查一遍