<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Neil的自留地</title><link>https://neilmin.com/zh/</link><description>Recent content on Neil的自留地</description><image><title>Neil的自留地</title><url>https://neilmin.com/images/papermod-cover.png</url><link>https://neilmin.com/images/papermod-cover.png</link></image><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Sat, 13 Jun 2026 07:00:00 -0700</lastBuildDate><atom:link href="https://neilmin.com/zh/index.xml" rel="self" type="application/rss+xml"/><item><title>RocksDB 是怎么工作的：一份 LSM-Tree 的极简笔记</title><link>https://neilmin.com/zh/posts/how-rocksdb-works/</link><pubDate>Sat, 13 Jun 2026 07:00:00 -0700</pubDate><guid>https://neilmin.com/zh/posts/how-rocksdb-works/</guid><description>准备面试时我把 RocksDB 的工作原理好好学了一遍，这是我整理的一份笔记：RocksDB 是什么、数据怎么写进去、怎么读出来、后台的 compaction 在忙什么，以及绕不开的「三种放大」权衡。不求多专业，只想把 LSM-Tree 的核心思路讲清楚，分享给同样想搞懂它的人。</description><content:encoded><![CDATA[<p>准备面试的时候，我花了点时间，把 RocksDB 的工作原理从头到尾学了一遍——它的存储引擎到底是怎么设计的，数据是怎么写进去、又怎么读出来的。RocksDB（以及它背后的 LSM-Tree）是那种很多人听过、但真要讲清楚就容易卡壳的东西，我自己以前也是。等真的搞懂了，就把里面的核心思路整理成这份笔记，分享给同样想弄明白它的人。</p>
<p>我不敢说讲得有多专业、多全面，但希望读完，你（还有未来的我）能对「RocksDB 大概是怎么转起来的」有一个清楚的整体印象。下面这些英文词我尽量保留原样（LSM-Tree、MemTable、SST、WAL、compaction……），因为面试和文档里大家就是这么说的，硬翻成中文反而别扭。</p>
<h2 id="rocksdb-是什么">RocksDB 是什么</h2>
<p>一句话：<strong>一个可嵌入的、持久化的键值（key-value）存储引擎</strong>。</p>
<ul>
<li><strong>可嵌入（embedded）</strong>：它不是一个像 MySQL 那样单独跑的服务器，而是一个库，直接编进你的程序里，省掉了进程间通信的开销。</li>
<li><strong>持久化</strong>：数据落在磁盘上，崩了也不丢。</li>
<li>2012 年从 Google 的 <strong>LevelDB</strong> fork 出来，用 C++ 写的，专门为 <strong>SSD</strong> 和<strong>写多</strong>的场景做了优化。Meta、Microsoft、Netflix、Uber 都在用。</li>
<li>它<strong>不是分布式的</strong>——副本、分片这些得你自己在上层做。</li>
</ul>
<p>它对外提供的操作很朴素：<code>put(key, value)</code> 写、<code>get(key)</code> 读、<code>delete(key)</code> 删、<code>merge(key, value)</code> 合并、还有 <code>iterator.seek()</code> 做范围扫描。</p>
<h2 id="核心思想lsm-tree">核心思想：LSM-Tree</h2>
<p>RocksDB 的一切都建立在 <strong>LSM-Tree（Log-Structured Merge-Tree，日志结构合并树）</strong> 上。</p>
<p>它要解决的核心矛盾是：<strong>磁盘最怕随机写，最喜欢顺序写</strong>。LSM 树的思路就是——先把写攒在内存里排好序，再一次性顺序地刷到磁盘。换句话说，它<strong>把大量随机写「攒」成了顺序写</strong>，这就是它写得快的根本原因。</p>
<p>结构上，数据分成很多层：最上面一层在内存里，下面一层层在磁盘上，编号 L0、L1、L2……越往下的数据越老、容量越大（通常下一层是上一层的约 10 倍）。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>内存   ┌──────────────────────────────┐
</span></span><span style="display:flex;"><span>       │  MemTable（可写，内部有序）    │  ← 新数据先进这里
</span></span><span style="display:flex;"><span>       └──────────────────────────────┘
</span></span><span style="display:flex;"><span>- - - - - - - - - - - - - - - - - - - - - -  flush（刷盘）
</span></span><span style="display:flex;"><span>磁盘   L0   [SST] [SST] [SST]      ← 最新；文件之间 key 范围可能重叠
</span></span><span style="display:flex;"><span>       L1   [SST][SST][SST][SST]   ← 每层内部 key 不重叠，且更大
</span></span><span style="display:flex;"><span>       L2   [SST][SST] ......      ← 越往下越老、越大（约 ×10）
</span></span><span style="display:flex;"><span>       ...
</span></span></code></pre></div><p>这套结构 1996 年就提出来了，专门优化写密集的负载。除了 RocksDB，Bigtable、HBase、Cassandra、MongoDB 的 WiredTiger 引擎用的也都是 LSM 树。</p>
<h2 id="写数据是怎么进去的">写：数据是怎么进去的</h2>
<p>一次写入，会<strong>同时</strong>落到两个地方：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>put(key, value)
</span></span><span style="display:flex;"><span>      │
</span></span><span style="display:flex;"><span>      ├──► WAL    （顺序追加到磁盘，防崩溃）
</span></span><span style="display:flex;"><span>      │
</span></span><span style="display:flex;"><span>      └──► MemTable（在内存里排好序）
</span></span><span style="display:flex;"><span>                  │  写满约 64MB
</span></span><span style="display:flex;"><span>                  ▼
</span></span><span style="display:flex;"><span>            转为只读，后台线程刷成一个 SST 文件 → 落到 L0
</span></span></code></pre></div><p><strong>MemTable</strong>：内存里的写缓冲，所有增删改都先到这。它内部是<strong>按 key 排好序</strong>的（默认用<strong>跳表 skip list</strong> 实现），这样后面刷盘和范围查询才高效。一个细节：删除不是真的把数据抹掉，而是写一条<strong>墓碑</strong>（tombstone）记录，表示「这个 key 删了」——真正的清理留给后面的 compaction。</p>
<p><strong>WAL（Write-Ahead Log，预写日志）</strong>：MemTable 在内存里，断电就没了。所以每次写也会<strong>顺序追加</strong>一条记录到磁盘上的 WAL 文件，里面有 key、value、操作类型和校验和（checksum）。崩溃重启后，靠重放 WAL 把 MemTable 恢复出来。注意 WAL 是<strong>按写入顺序追加的，不排序</strong>——它图的就是个快。</p>
<p><strong>Flush（刷盘）</strong>：MemTable 写满后会变成只读，换一个新的继续接客；后台线程把这个只读的 MemTable 刷成一个 <strong>SST 文件</strong>，落在 L0。刷完，对应的 WAL 就可以丢了。因为 MemTable 本来就是有序的，这一刷就是一次<strong>顺序写</strong>——LSM 树的精髓就在这儿。</p>
<h2 id="sst-文件长什么样">SST 文件长什么样</h2>
<p><strong>SST（Static Sorted Table，静态有序表）</strong> 是磁盘上真正存数据的文件，一旦写好就不再修改。它里面是一堆<strong>排好序的 key-value</strong>，并且为了查询方便做了精心的分块设计（block，默认 4KB，可以用 Snappy、LZ4、ZSTD 等压缩）。</p>
<p>一个 SST 大致分几部分：</p>
<ul>
<li><strong>数据块（data）</strong>：有序的 key-value。因为相邻 key 很像，可以只存差异（delta encoding）省空间。</li>
<li><strong>索引（index）</strong>：记录每个数据块「最后一个 key → 在文件里的位置」，这样查的时候能<strong>二分</strong>直接定位到某个块，而不用扫整个文件。</li>
<li><strong>布隆过滤器（Bloom filter，可选）</strong>：一个概率型结构，能极快地回答「这个 key <strong>一定不在</strong>这个文件里」。它可能误报「在」，但绝不漏报「不在」——所以特别适合在读的时候先挡掉一大批根本不用查的文件。</li>
</ul>
<h2 id="读数据是怎么找出来的">读：数据是怎么找出来的</h2>
<p>读一个 key，要<strong>从新到老</strong>一层层找，因为新写的值在上层、旧值在下层，第一个找到的就是最新的：</p>
<ol>
<li>先查正在写的 MemTable；</li>
<li>再查还没刷完的只读 MemTable；</li>
<li>再查 L0 的每个 SST 文件（L0 文件之间 key 范围会重叠，所以得挨个看，从新到旧）；</li>
<li>L1 及以下，每层内部 key 不重叠，所以<strong>每层只需定位并查一个文件</strong>。</li>
</ol>
<p>而在<strong>单个 SST 文件</strong>里找，又是三步走：先用 <strong>Bloom filter</strong> 问一句「key 在不在」，不在就直接跳过这个文件；在的话，用 <strong>index 二分</strong>定位到对应的数据块；最后把那个块读出来，在块内找到 key。</p>
<p>所以读的代价，关键看要翻多少层、多少文件——这也引出了下一节要解决的问题。</p>
<h2 id="compaction后台一直在打扫">Compaction：后台一直在「打扫」</h2>
<p>前面说了，删除只是写墓碑、更新也只是写新值盖在旧值上面。时间一长，磁盘上就会堆满<strong>过期的旧版本和墓碑</strong>：既白占空间，又让读的时候要翻越更多文件。</p>
<p><strong>Compaction（压缩合并）</strong> 就是后台干这个清理活的：把某一层的若干 SST 文件，和下一层有重叠的文件合在一起，<strong>丢掉那些被覆盖的旧值和被删的 key</strong>，再写成新的、干净的 SST 放到下一层。因为每个文件本来都有序，合并用的是<strong>多路归并（k-way merge）</strong>，就是归并排序里「合并」那一步的放大版。整个过程在后台线程跑，不挡前台的读写。</p>
<p>RocksDB 默认用 <strong>leveled compaction（分层合并）</strong>：</p>
<ul>
<li><strong>L0</strong> 比较特殊，文件之间 key 范围<strong>允许重叠</strong>（因为它们是 MemTable 直接刷下来的）；当 L0 文件数攒到阈值（默认 4 个）就触发合并。</li>
<li><strong>L1 及以下</strong>，每一层内部所有文件的 key 范围<strong>互不重叠</strong>、整体有序；当某层的总大小超过设定值，就把超出的部分往下一层合并，有时会一路连锁往下压好几层。</li>
</ul>
<h2 id="一切都是权衡三种放大">一切都是权衡：三种「放大」</h2>
<p>理解 RocksDB（其实是所有 LSM 引擎）调优的关键，是三个<strong>放大</strong>（amplification）指标：</p>
<ul>
<li><strong>空间放大（space amplification）</strong>：实际占的磁盘空间 ÷ 数据本身的大小。旧版本和墓碑越多，空间放大越大。</li>
<li><strong>读放大（read amplification）</strong>：读一条数据，底层实际要做几次 I/O。要翻的层和文件越多，读放大越大。</li>
<li><strong>写放大（write amplification）</strong>：写一条数据，底层实际写了几次。同一条数据会在 compaction 里被反复重写到更下面的层，所以写放大会很大。</li>
</ul>
<p>这三者按下葫芦浮起瓢：<strong>compaction 越勤，空间和读放大越小，但写放大越大</strong>；反之亦然。怎么平衡全看你的负载，参数极多且互相影响——连 RocksDB 官方都坦言很难讲清每个参数的确切效果，建议<strong>多做 benchmark，盯着这三个放大指标调</strong>。</p>
<blockquote>
<p><strong>顺带一提：merge 操作</strong></p>
<p>除了 put 和 delete，RocksDB 还有个 <code>merge</code>。当你要对一个值做大量「增量更新」（比如不停往一个计数器或列表上追加）时，传统做法是 read-modify-write：读出来、改、再写回去，很费劲。<code>merge</code> 让你只写「增量」本身，把怎么合并交给一个你自定义的 merge 函数，到读取或 compaction 时再真正算出最终值。<strong>好处</strong>是省写放大、还线程安全；<strong>代价</strong>是读变贵了——没合并之前，每次读都得把这些增量重新算一遍。</p>
</blockquote>
<h2 id="记住这几点就够了">记住这几点就够了</h2>
<p>如果只留一张「脑图」，是这样的：</p>
<ul>
<li><strong>RocksDB</strong> = 可嵌入的持久化 KV 存储，源自 LevelDB，核心是 <strong>LSM-Tree</strong>；</li>
<li><strong>写</strong>：先进内存的 <strong>MemTable</strong>（有序）+ 顺序写 <strong>WAL</strong>（防崩溃）→ 攒满后刷成 <strong>SST</strong> 文件落到 L0 → 后台 <strong>compaction</strong> 慢慢往下整理；</li>
<li><strong>读</strong>：从新到老一层层找，靠 <strong>Bloom filter</strong> + <strong>index</strong> 跳过和定位，少读冤枉文件；</li>
<li><strong>本质</strong>：用「写放大」换「把随机写变成顺序写」的高吞吐——<strong>空间、读、写三种放大之间，永远是权衡，没有免费的午餐</strong>。</li>
</ul>
<p>把这几句记住，RocksDB 的大框架就立起来了。更细的东西——skip list、delta encoding、各种 compaction 策略、参数到底怎么调——等真正用到的时候再往里钻也不迟。</p>
<blockquote>
<p>这份笔记里不少理解，来自 Artem Krylysov 的 <a href="https://artem.krylysov.com/blog/2023/04/19/how-rocksdb-works/">How RocksDB Works</a>——原文讲得非常细致，想往深里走的话很推荐读一读。</p>
</blockquote>
]]></content:encoded></item><item><title>排序算法面试复盘：一份从冒泡到 Timsort 的 Python 参考</title><link>https://neilmin.com/zh/posts/sorting-algorithms-interview-reference/</link><pubDate>Sat, 13 Jun 2026 00:00:00 -0700</pubDate><guid>https://neilmin.com/zh/posts/sorting-algorithms-interview-reference/</guid><description>为准备 coding 面试重新复习排序算法时整理的一份参考：11 种排序的 Python 实现、时间与空间复杂度、稳定性和适用场景，外加快排的多种 partition 写法和容易被忽略的非比较排序。可以照着自检——哪些我会，哪些忘了。</description><content:encoded><![CDATA[<p>最近在准备 coding 面试，把排序算法又从头捋了一遍。捋的过程里我有点被自己吓到：很多东西五年前我是真的记得的，比如快排的 partition 到底怎么写、为什么会退化，现在却要愣一下才能想起来。等翻到非比较排序那一块——计数排序、基数排序、桶排序——我发现那已经基本是一片空白了。</p>
<p>于是干脆把这一遍复习写下来，一方面给后来准备面试的人当个 reference，另一方面也是给未来的自己留个存档：等下次再要面试，我可以回到这里，照着扫一遍，很快就知道「这个我还会、这个我忘了，重点看一下」。</p>
<p>这篇文章的用法很简单：</p>
<ul>
<li>先看下面那张<strong>速查表</strong>，扫一眼就能自检哪些算法你已经忘了；</li>
<li>想细看哪个，用右侧的目录（TOC）直接跳过去；</li>
<li>每个算法都按同一个模板写：<strong>一句话思路 → Python 实现 → 复杂度 → 稳定性和是否原地 → 面试要点</strong>，方便对照。</li>
</ul>
<p>代码全部用 Python 写，因为它最接近伪代码、最容易看清逻辑。</p>
<h2 id="一页速查表">一页速查表</h2>
<p>先把结论摆出来。下面这张表覆盖了本文的全部 11 种排序，面试里被问到复杂度、稳定性的时候，脑子里应该能立刻浮现出这张表。</p>
<table>
  <thead>
      <tr>
          <th>算法</th>
          <th>最好</th>
          <th>平均</th>
          <th>最坏</th>
          <th>空间</th>
          <th style="text-align: center">稳定</th>
          <th style="text-align: center">原地</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>冒泡 Bubble</td>
          <td>O(n)</td>
          <td>O(n²)</td>
          <td>O(n²)</td>
          <td>O(1)</td>
          <td style="text-align: center">✅</td>
          <td style="text-align: center">✅</td>
      </tr>
      <tr>
          <td>选择 Selection</td>
          <td>O(n²)</td>
          <td>O(n²)</td>
          <td>O(n²)</td>
          <td>O(1)</td>
          <td style="text-align: center">❌</td>
          <td style="text-align: center">✅</td>
      </tr>
      <tr>
          <td>插入 Insertion</td>
          <td>O(n)</td>
          <td>O(n²)</td>
          <td>O(n²)</td>
          <td>O(1)</td>
          <td style="text-align: center">✅</td>
          <td style="text-align: center">✅</td>
      </tr>
      <tr>
          <td>希尔 Shell</td>
          <td>O(n log n)</td>
          <td>≈O(n^1.3)</td>
          <td>O(n²)</td>
          <td>O(1)</td>
          <td style="text-align: center">❌</td>
          <td style="text-align: center">✅</td>
      </tr>
      <tr>
          <td>归并 Merge</td>
          <td>O(n log n)</td>
          <td>O(n log n)</td>
          <td>O(n log n)</td>
          <td>O(n)</td>
          <td style="text-align: center">✅</td>
          <td style="text-align: center">❌</td>
      </tr>
      <tr>
          <td>快排 Quick</td>
          <td>O(n log n)</td>
          <td>O(n log n)</td>
          <td>O(n²)</td>
          <td>O(log n)</td>
          <td style="text-align: center">❌</td>
          <td style="text-align: center">✅</td>
      </tr>
      <tr>
          <td>堆排 Heap</td>
          <td>O(n log n)</td>
          <td>O(n log n)</td>
          <td>O(n log n)</td>
          <td>O(1)</td>
          <td style="text-align: center">❌</td>
          <td style="text-align: center">✅</td>
      </tr>
      <tr>
          <td>计数 Counting</td>
          <td>O(n+k)</td>
          <td>O(n+k)</td>
          <td>O(n+k)</td>
          <td>O(n+k)</td>
          <td style="text-align: center">✅</td>
          <td style="text-align: center">❌</td>
      </tr>
      <tr>
          <td>基数 Radix</td>
          <td>O(d·(n+k))</td>
          <td>O(d·(n+k))</td>
          <td>O(d·(n+k))</td>
          <td>O(n+k)</td>
          <td style="text-align: center">✅</td>
          <td style="text-align: center">❌</td>
      </tr>
      <tr>
          <td>桶 Bucket</td>
          <td>O(n+k)</td>
          <td>O(n+k)</td>
          <td>O(n²)</td>
          <td>O(n+k)</td>
          <td style="text-align: center">✅*</td>
          <td style="text-align: center">❌</td>
      </tr>
      <tr>
          <td>Timsort</td>
          <td>O(n)</td>
          <td>O(n log n)</td>
          <td>O(n log n)</td>
          <td>O(n)</td>
          <td style="text-align: center">✅</td>
          <td style="text-align: center">❌</td>
      </tr>
  </tbody>
</table>
<p>几点说明，免得这张表骗到你：</p>
<ul>
<li><strong>希尔排序</strong>的复杂度取决于「增量序列」，最好情况随用的序列不同而变，所以这里的数字只是常见量级。</li>
<li><strong>快排</strong>标的空间是平均情况下的递归栈深度 O(log n)，最坏会退化到 O(n)；它是原地分区，但递归本身要占栈。</li>
<li><strong>桶排序</strong>的稳定性打了星号：桶内用稳定排序（比如插入排序）它才稳定。</li>
<li><strong>k</strong> 是数据的取值范围，<strong>d</strong> 是数字的位数——非比较排序的复杂度都和数据本身的特征绑在一起，这点后面会细说。</li>
</ul>
<h2 id="写在前面几个绕不开的概念">写在前面：几个绕不开的概念</h2>
<p>在逐个看算法之前，有四个概念几乎每道排序面试题都会用到。把它们先讲清楚，后面就不用反复解释了。</p>
<h3 id="比较排序-vs-非比较排序">比较排序 vs 非比较排序</h3>
<p><strong>比较排序</strong>只通过「两个元素谁大谁小」这一种操作来决定顺序——冒泡、插入、归并、快排、堆排都是。它们的共同点是：理论下界就是 O(n log n)，谁也快不过（原因见下面）。</p>
<p><strong>非比较排序</strong>不靠比较，而是利用元素本身的值去「算」出它该放的位置——计数、基数、桶都是。正因为绕开了比较，它们能做到线性时间 O(n)，但代价是对数据有额外要求（比如必须是范围有限的整数）。</p>
<h3 id="稳定性stability">稳定性（stability）</h3>
<p>如果两个元素的排序键相等，排完序后它们的<strong>相对先后顺序</strong>不变，这个排序就是<strong>稳定的</strong>。</p>
<p>举个具体例子。有一批订单，已经按下单时间排好了，现在要再按金额排序：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>排序前（已按时间）： (100元, 9:00)  (50元, 9:01)  (100元, 9:02)
</span></span><span style="display:flex;"><span>稳定排序后（按金额）：(50元, 9:01)  (100元, 9:00)  (100元, 9:02)   ← 两个 100 元仍保持时间先后
</span></span><span style="display:flex;"><span>不稳定排序后：       (50元, 9:01)  (100元, 9:02)  (100元, 9:00)   ← 两个 100 元的顺序被打乱
</span></span></code></pre></div><p>为什么面试爱问？因为<strong>多关键字排序</strong>依赖它：先按次要键排，再用一个稳定排序按主要键排，次要键的顺序就被保留下来了。记住哪些稳定（冒泡、插入、归并、计数、基数、Timsort）、哪些不稳定（选择、希尔、快排、堆排）几乎是必考点。</p>
<h3 id="原地排序in-place">原地排序（in-place）</h3>
<p>只用 O(1) 或 O(log n) 的额外空间就能完成排序，叫<strong>原地</strong>。归并排序要额外开一个 O(n) 的数组，所以不是原地；快排、堆排只在原数组上倒腾，是原地的。当面试官追问「内存很紧张怎么办」，问的往往就是这个。</p>
<h3 id="复杂度为什么分最好平均最坏">复杂度为什么分最好/平均/最坏</h3>
<p>同一个算法，面对不同输入表现可能天差地别。最典型的是快排：输入随机时是 O(n log n)，但输入已经有序、又恰好每次都挑到最差的 pivot 时，会退化成 O(n²)。面试里报复杂度，最好顺带说清是哪种情况——这恰恰是体现你理解深度的地方。</p>
<blockquote>
<p><strong>为什么比较排序快不过 O(n log n)？</strong>
任何比较排序都可以画成一棵「决策树」：每个内部节点是一次比较，每个叶子是一种可能的最终排列。n 个元素共有 n! 种排列，所以树至少要有 n! 个叶子。一棵高度为 h 的二叉树最多有 2ʰ 个叶子，于是 2ʰ ≥ n!，即 h ≥ log₂(n!)。由斯特林公式，log₂(n!) ≈ n log n。树的高度就是最坏情况下的比较次数，所以下界是 Ω(n log n)。这也解释了为什么想更快，就只能绕开「比较」这件事——也就是非比较排序。</p>
</blockquote>
<p>好，概念铺垫完了，开始逐个看。</p>
<h2 id="比较类排序">比较类排序</h2>
<h3 id="冒泡排序-bubble-sort">冒泡排序 Bubble Sort</h3>
<p><strong>一句话思路</strong>：相邻两个元素两两比较，逆序就交换，每一轮都把当前最大的「冒泡」到末尾。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">bubble_sort</span>(arr):
</span></span><span style="display:flex;"><span>    n <span style="color:#f92672">=</span> len(arr)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(n <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>):
</span></span><span style="display:flex;"><span>        swapped <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># 每一轮把未排序区间里最大的元素冒泡到右端</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> j <span style="color:#f92672">in</span> range(n <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span> <span style="color:#f92672">-</span> i):
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> arr[j] <span style="color:#f92672">&gt;</span> arr[j <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>]:
</span></span><span style="display:flex;"><span>                arr[j], arr[j <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>] <span style="color:#f92672">=</span> arr[j <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>], arr[j]
</span></span><span style="display:flex;"><span>                swapped <span style="color:#f92672">=</span> <span style="color:#66d9ef">True</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> swapped:          <span style="color:#75715e"># 一整轮都没交换，说明已经有序，提前收工</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">break</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> arr
</span></span></code></pre></div><ul>
<li><strong>复杂度</strong>：最坏、平均都是 O(n²)；加了 <code>swapped</code> 提前退出后，对已经有序的输入是 O(n)。空间 O(1)。</li>
<li><strong>稳定性 / 原地</strong>：稳定（只在严格大于时才交换），原地。</li>
<li><strong>面试要点</strong>：实战里基本不用，但它是「稳定 + 提前退出能到 O(n)」的经典例子。注意那个 <code>swapped</code> 优化，常被拿来考。</li>
<li><strong>LeetCode 例题</strong>：<a href="https://leetcode.cn/problems/sort-an-array/">912. 排序数组</a> —— LeetCode 没有专门考冒泡的题，这道通用排序题可以拿来练手实现（纯 O(n²) 在大数据下会超时，仅作练习）。</li>
</ul>
<h3 id="选择排序-selection-sort">选择排序 Selection Sort</h3>
<p><strong>一句话思路</strong>：每一轮从未排序区间里挑出最小的，放到已排序区间的末尾。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">selection_sort</span>(arr):
</span></span><span style="display:flex;"><span>    n <span style="color:#f92672">=</span> len(arr)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(n <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>):
</span></span><span style="display:flex;"><span>        min_idx <span style="color:#f92672">=</span> i
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># 在未排序区间 [i+1, n) 里找最小值的下标</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> j <span style="color:#f92672">in</span> range(i <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>, n):
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> arr[j] <span style="color:#f92672">&lt;</span> arr[min_idx]:
</span></span><span style="display:flex;"><span>                min_idx <span style="color:#f92672">=</span> j
</span></span><span style="display:flex;"><span>        arr[i], arr[min_idx] <span style="color:#f92672">=</span> arr[min_idx], arr[i]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> arr
</span></span></code></pre></div><ul>
<li><strong>复杂度</strong>：无论输入长什么样都是 O(n²)——它不会因为数据有序而变快。空间 O(1)。</li>
<li><strong>稳定性 / 原地</strong>：<strong>不稳定</strong>，原地。比如 <code>[5a, 5b, 2]</code>，第一轮把 <code>2</code> 和 <code>5a</code> 交换，两个 5 的相对顺序就反了。</li>
<li><strong>面试要点</strong>：唯一的亮点是<strong>交换次数最少</strong>（最多 n−1 次），在「写操作很贵」的场景下有意义。另外它是「最好情况也救不回来」的反例，常和插入排序对比着考。</li>
<li><strong>LeetCode 例题</strong>：<a href="https://leetcode.cn/problems/sort-an-array/">912. 排序数组</a> —— 同样用这道通用题练手实现，体会它「交换少、但比较一点不少」的特点。</li>
</ul>
<h3 id="插入排序-insertion-sort">插入排序 Insertion Sort</h3>
<p><strong>一句话思路</strong>：像理扑克牌——从左到右，把每张新牌插到左边已经排好的牌中正确的位置。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">insertion_sort</span>(arr):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">1</span>, len(arr)):
</span></span><span style="display:flex;"><span>        key <span style="color:#f92672">=</span> arr[i]
</span></span><span style="display:flex;"><span>        j <span style="color:#f92672">=</span> i <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># 把比 key 大的元素整体右移一位，给 key 腾出位置</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">while</span> j <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">0</span> <span style="color:#f92672">and</span> arr[j] <span style="color:#f92672">&gt;</span> key:
</span></span><span style="display:flex;"><span>            arr[j <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>] <span style="color:#f92672">=</span> arr[j]
</span></span><span style="display:flex;"><span>            j <span style="color:#f92672">-=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>        arr[j <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>] <span style="color:#f92672">=</span> key
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> arr
</span></span></code></pre></div><ul>
<li><strong>复杂度</strong>：最坏、平均 O(n²)；对<strong>几乎有序</strong>的输入接近 O(n)。空间 O(1)。</li>
<li><strong>稳定性 / 原地</strong>：稳定（<code>while</code> 条件用 <code>&gt;</code> 不是 <code>&gt;=</code>），原地。</li>
<li><strong>面试要点</strong>：别小看它。<strong>数据量小或几乎有序时，插入排序比快排还快</strong>，所以它正是 Timsort、Introsort 这些工业级排序在「小块」上回退使用的算法。基础三件套里，它是最有实用价值的一个。</li>
<li><strong>LeetCode 例题</strong>：<a href="https://leetcode.cn/problems/insertion-sort-list/">147. 对链表进行插入排序</a> —— 专门为插入排序准备的题：在链表上原地插入。</li>
</ul>
<h3 id="希尔排序-shell-sort">希尔排序 Shell Sort</h3>
<p><strong>一句话思路</strong>：插入排序的升级版。先按一个较大的「间隔」分组做插入排序，再逐步缩小间隔，最后一轮间隔为 1（就是普通插入排序），但此时数组已经「大致有序」，所以很快。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">shell_sort</span>(arr):
</span></span><span style="display:flex;"><span>    n <span style="color:#f92672">=</span> len(arr)
</span></span><span style="display:flex;"><span>    gap <span style="color:#f92672">=</span> n <span style="color:#f92672">//</span> <span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">while</span> gap <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">0</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># 对每个间隔为 gap 的子序列做插入排序</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(gap, n):
</span></span><span style="display:flex;"><span>            key <span style="color:#f92672">=</span> arr[i]
</span></span><span style="display:flex;"><span>            j <span style="color:#f92672">=</span> i <span style="color:#f92672">-</span> gap
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">while</span> j <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">0</span> <span style="color:#f92672">and</span> arr[j] <span style="color:#f92672">&gt;</span> key:
</span></span><span style="display:flex;"><span>                arr[j <span style="color:#f92672">+</span> gap] <span style="color:#f92672">=</span> arr[j]
</span></span><span style="display:flex;"><span>                j <span style="color:#f92672">-=</span> gap
</span></span><span style="display:flex;"><span>            arr[j <span style="color:#f92672">+</span> gap] <span style="color:#f92672">=</span> key
</span></span><span style="display:flex;"><span>        gap <span style="color:#f92672">//=</span> <span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> arr
</span></span></code></pre></div><ul>
<li><strong>复杂度</strong>：取决于增量序列。上面这种 <code>n//2</code> 折半序列最坏是 O(n²)；换更好的序列（如 Knuth 的 <code>3k+1</code>、Sedgewick 序列）能到 O(n^1.5) 甚至更好。空间 O(1)。</li>
<li><strong>稳定性 / 原地</strong>：<strong>不稳定</strong>（跨间隔交换会打乱相等元素的相对位置），原地。</li>
<li><strong>面试要点</strong>：它是「为什么让数据先变得大致有序，能让插入排序提速」这个思想的代表。面试不常直接考，但作为承上启下的一环值得知道——它把简单的 O(n²) 排序往 O(n log n) 的方向推了一把。</li>
<li><strong>LeetCode 例题</strong>：<a href="https://leetcode.cn/problems/sort-an-array/">912. 排序数组</a> —— 拿它练手实现希尔排序，可以试试不同增量序列对耗时的影响。</li>
</ul>
<h3 id="归并排序-merge-sort">归并排序 Merge Sort</h3>
<p><strong>一句话思路</strong>：分治。把数组对半切到不能再切，再把两个<strong>已排序</strong>的小数组合并成一个大的有序数组。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">merge_sort</span>(arr):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> len(arr) <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">1</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> arr
</span></span><span style="display:flex;"><span>    mid <span style="color:#f92672">=</span> len(arr) <span style="color:#f92672">//</span> <span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>    left <span style="color:#f92672">=</span> merge_sort(arr[:mid])
</span></span><span style="display:flex;"><span>    right <span style="color:#f92672">=</span> merge_sort(arr[mid:])
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> merge(left, right)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">merge</span>(left, right):
</span></span><span style="display:flex;"><span>    result <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    i <span style="color:#f92672">=</span> j <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 双指针，每次取两边较小的那个；用 &lt;= 保证稳定</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">while</span> i <span style="color:#f92672">&lt;</span> len(left) <span style="color:#f92672">and</span> j <span style="color:#f92672">&lt;</span> len(right):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> left[i] <span style="color:#f92672">&lt;=</span> right[j]:
</span></span><span style="display:flex;"><span>            result<span style="color:#f92672">.</span>append(left[i])
</span></span><span style="display:flex;"><span>            i <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>            result<span style="color:#f92672">.</span>append(right[j])
</span></span><span style="display:flex;"><span>            j <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    result<span style="color:#f92672">.</span>extend(left[i:])   <span style="color:#75715e"># 剩下的直接接上</span>
</span></span><span style="display:flex;"><span>    result<span style="color:#f92672">.</span>extend(right[j:])
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> result
</span></span></code></pre></div><ul>
<li><strong>复杂度</strong>：最好、平均、最坏<strong>全是 O(n log n)</strong>——非常稳定，不会因为输入退化。空间 O(n)（合并时要额外数组）。</li>
<li><strong>稳定性 / 原地</strong>：稳定，<strong>不是原地</strong>。</li>
<li><strong>面试要点</strong>：复杂度稳如磐石、天然稳定，是「需要稳定 + 最坏也要 O(n log n)」时的首选。</li>
<li><strong>LeetCode 例题</strong>：<a href="https://leetcode.cn/problems/sort-list/">148. 排序链表</a> —— 排序链表的最优解就是归并；想练数组版可用 <a href="https://leetcode.cn/problems/sort-an-array/">912. 排序数组</a>。</li>
</ul>
<p>下面是两个高频延伸：</p>
<blockquote>
<p><strong>概念补充：链表排序和外部排序</strong></p>
<p><strong>链表排序</strong>：归并排序对链表特别友好——合并链表只要改指针，不需要额外数组，能做到 O(1) 额外空间（不算递归栈）。这也是为什么「O(n log n) 排序一个链表」的标准答案是归并而不是快排。</p>
<p><strong>外部排序（external sort）</strong>：当数据大到内存装不下（经典面试题：「1G 内存怎么排 10G 文件？」），答案就是<strong>外部归并排序</strong>——把大文件切成内存能装下的小块，逐块读进来排好序写回磁盘，再用「多路归并」把这些有序小文件合并成最终结果。归并的精髓「合并多个有序序列」在这里被用到了极致。</p>
</blockquote>
<h3 id="快速排序-quick-sort">快速排序 Quick Sort</h3>
<p>这是我自己复习时最该重点捡起来的一节，partition 的细节五年没碰就模糊了。慢慢来。</p>
<p><strong>一句话思路</strong>：分治。选一个基准值（pivot），通过一次<strong>分区</strong>（partition）把数组拆成「比 pivot 小的」和「比 pivot 大的」两部分，pivot 归位后，再对两边递归。</p>
<h4 id="1-lomuto-分区最好背的版本">1. Lomuto 分区（最好背的版本）</h4>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">quick_sort</span>(arr, low<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>, high<span style="color:#f92672">=</span><span style="color:#66d9ef">None</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> high <span style="color:#f92672">is</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>        high <span style="color:#f92672">=</span> len(arr) <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> low <span style="color:#f92672">&lt;</span> high:
</span></span><span style="display:flex;"><span>        p <span style="color:#f92672">=</span> partition(arr, low, high)
</span></span><span style="display:flex;"><span>        quick_sort(arr, low, p <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>)    <span style="color:#75715e"># 递归左半</span>
</span></span><span style="display:flex;"><span>        quick_sort(arr, p <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>, high)   <span style="color:#75715e"># 递归右半</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> arr
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">partition</span>(arr, low, high):
</span></span><span style="display:flex;"><span>    pivot <span style="color:#f92672">=</span> arr[high]            <span style="color:#75715e"># Lomuto：固定取最右元素作 pivot</span>
</span></span><span style="display:flex;"><span>    i <span style="color:#f92672">=</span> low <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>                  <span style="color:#75715e"># i 是「小于 pivot 区」的右边界</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> j <span style="color:#f92672">in</span> range(low, high):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> arr[j] <span style="color:#f92672">&lt;</span> pivot:
</span></span><span style="display:flex;"><span>            i <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>            arr[i], arr[j] <span style="color:#f92672">=</span> arr[j], arr[i]
</span></span><span style="display:flex;"><span>    arr[i <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>], arr[high] <span style="color:#f92672">=</span> arr[high], arr[i <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>]   <span style="color:#75715e"># pivot 归位</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> i <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>
</span></span></code></pre></div><p>Lomuto 的好处是只用一个指针 <code>i</code> 推进，逻辑直观、最好记。面试里手写快排，默认写这个版本就行。</p>
<h4 id="2-为什么会退化以及怎么救">2. 为什么会退化，以及怎么救</h4>
<p>固定取最右元素当 pivot 有个致命问题：<strong>输入已经有序（或逆序）时，每次分区都把数组切成 0 和 n−1 两块</strong>，递归深度变成 n，复杂度退化到 O(n²)，还可能爆栈。</p>
<p>救法是<strong>别让 pivot 的选择被输入「预测」</strong>——随机选一个，或者取「头、中、尾」三个数的中位数（median-of-three）：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> random
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">partition</span>(arr, low, high):
</span></span><span style="display:flex;"><span>    rand <span style="color:#f92672">=</span> random<span style="color:#f92672">.</span>randint(low, high)
</span></span><span style="display:flex;"><span>    arr[rand], arr[high] <span style="color:#f92672">=</span> arr[high], arr[rand]   <span style="color:#75715e"># 随机选 pivot，再换到最右，复用上面的逻辑</span>
</span></span><span style="display:flex;"><span>    pivot <span style="color:#f92672">=</span> arr[high]
</span></span><span style="display:flex;"><span>    i <span style="color:#f92672">=</span> low <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> j <span style="color:#f92672">in</span> range(low, high):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> arr[j] <span style="color:#f92672">&lt;</span> pivot:
</span></span><span style="display:flex;"><span>            i <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>            arr[i], arr[j] <span style="color:#f92672">=</span> arr[j], arr[i]
</span></span><span style="display:flex;"><span>    arr[i <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>], arr[high] <span style="color:#f92672">=</span> arr[high], arr[i <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> i <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>
</span></span></code></pre></div><p>只加两行，就把「有序输入退化」这个最常见的坑堵上了。面试官追问「快排最坏情况怎么办」，这就是标准答案。</p>
<h4 id="3-三路快排对付大量重复元素">3. 三路快排：对付大量重复元素</h4>
<p>如果数组里有<strong>大量重复值</strong>（比如全是 0 和 1），普通快排还是会做很多无谓的递归。三路快排（基于「荷兰国旗问题」）把数组分成 <code>&lt; pivot</code>、<code>== pivot</code>、<code>&gt; pivot</code> 三段，等于 pivot 的那一整段直接跳过：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">quick_sort_3way</span>(arr, low<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>, high<span style="color:#f92672">=</span><span style="color:#66d9ef">None</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> high <span style="color:#f92672">is</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>        high <span style="color:#f92672">=</span> len(arr) <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> low <span style="color:#f92672">&gt;=</span> high:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> arr
</span></span><span style="display:flex;"><span>    pivot <span style="color:#f92672">=</span> arr[low]
</span></span><span style="display:flex;"><span>    lt, i, gt <span style="color:#f92672">=</span> low, low, high   <span style="color:#75715e"># [low,lt)&lt;pivot  [lt,i)==pivot  (gt,high]&gt;pivot</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">while</span> i <span style="color:#f92672">&lt;=</span> gt:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> arr[i] <span style="color:#f92672">&lt;</span> pivot:
</span></span><span style="display:flex;"><span>            arr[lt], arr[i] <span style="color:#f92672">=</span> arr[i], arr[lt]
</span></span><span style="display:flex;"><span>            lt <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>            i <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">elif</span> arr[i] <span style="color:#f92672">&gt;</span> pivot:
</span></span><span style="display:flex;"><span>            arr[gt], arr[i] <span style="color:#f92672">=</span> arr[i], arr[gt]
</span></span><span style="display:flex;"><span>            gt <span style="color:#f92672">-=</span> <span style="color:#ae81ff">1</span>               <span style="color:#75715e"># 换过来的元素还没检查，i 不动</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>            i <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    quick_sort_3way(arr, low, lt <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    quick_sort_3way(arr, gt <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>, high)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> arr
</span></span></code></pre></div><ul>
<li><strong>复杂度</strong>：平均 O(n log n)，最坏 O(n²)（用随机 pivot 后几乎遇不到）。空间 O(log n)，是递归栈的开销。</li>
<li><strong>稳定性 / 原地</strong>：<strong>不稳定</strong>（分区里的远距离交换会打乱相等元素），<strong>原地</strong>。</li>
<li><strong>面试要点</strong>：手写默认 Lomuto；被追问最坏情况就讲随机/三数取中；被追问大量重复就讲三路快排。</li>
<li><strong>LeetCode 例题</strong>：<a href="https://leetcode.cn/problems/sort-an-array/">912. 排序数组</a> —— 提交时记得用随机 pivot，否则遇到有序或大量重复的数据会超时、甚至递归栈溢出。</li>
</ul>
<p>再加一个高频延伸——</p>
<blockquote>
<p><strong>概念补充：Quickselect（快速选择）</strong></p>
<p>「求第 k 大/小的元素」是面试常客。如果只是要第 k 个，没必要全排序：用快排的 partition，每次分区后看 pivot 落在哪，<strong>只递归 k 所在的那一边</strong>。平均 O(n)，比先排序再取（O(n log n)）更快。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">quickselect</span>(arr, k):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;返回第 k 小的元素，k 从 1 开始计数&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    low, high, target <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>, len(arr) <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>, k <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">while</span> low <span style="color:#f92672">&lt;=</span> high:
</span></span><span style="display:flex;"><span>        p <span style="color:#f92672">=</span> partition(arr, low, high)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> p <span style="color:#f92672">==</span> target:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> arr[p]
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">elif</span> p <span style="color:#f92672">&lt;</span> target:
</span></span><span style="display:flex;"><span>            low <span style="color:#f92672">=</span> p <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>        <span style="color:#75715e"># 目标在右边</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>            high <span style="color:#f92672">=</span> p <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>       <span style="color:#75715e"># 目标在左边</span>
</span></span></code></pre></div><p><strong>练手</strong>：<a href="https://leetcode.cn/problems/kth-largest-element-in-an-array/">215. 数组中的第 K 个最大元素</a> —— 用快速选择平均 O(n) 解，正好和堆的解法对照。</p>
</blockquote>
<h3 id="堆排序-heap-sort">堆排序 Heap Sort</h3>
<p><strong>一句话思路</strong>：先把数组建成一个<strong>最大堆</strong>（每个父节点都 ≥ 子节点），堆顶就是最大值；把堆顶换到末尾，堆缩小 1，再调整堆顶下沉，重复直到排完。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">heap_sort</span>(arr):
</span></span><span style="display:flex;"><span>    n <span style="color:#f92672">=</span> len(arr)
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 1. 建堆：从最后一个非叶子节点开始，逐个向下调整</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(n <span style="color:#f92672">//</span> <span style="color:#ae81ff">2</span> <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>):
</span></span><span style="display:flex;"><span>        sift_down(arr, i, n)
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 2. 反复把堆顶（最大值）换到末尾，再修复剩下的堆</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> end <span style="color:#f92672">in</span> range(n <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">0</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>):
</span></span><span style="display:flex;"><span>        arr[<span style="color:#ae81ff">0</span>], arr[end] <span style="color:#f92672">=</span> arr[end], arr[<span style="color:#ae81ff">0</span>]
</span></span><span style="display:flex;"><span>        sift_down(arr, <span style="color:#ae81ff">0</span>, end)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> arr
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">sift_down</span>(arr, root, size):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">while</span> <span style="color:#66d9ef">True</span>:
</span></span><span style="display:flex;"><span>        largest <span style="color:#f92672">=</span> root
</span></span><span style="display:flex;"><span>        left, right <span style="color:#f92672">=</span> <span style="color:#ae81ff">2</span> <span style="color:#f92672">*</span> root <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span> <span style="color:#f92672">*</span> root <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> left <span style="color:#f92672">&lt;</span> size <span style="color:#f92672">and</span> arr[left] <span style="color:#f92672">&gt;</span> arr[largest]:
</span></span><span style="display:flex;"><span>            largest <span style="color:#f92672">=</span> left
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> right <span style="color:#f92672">&lt;</span> size <span style="color:#f92672">and</span> arr[right] <span style="color:#f92672">&gt;</span> arr[largest]:
</span></span><span style="display:flex;"><span>            largest <span style="color:#f92672">=</span> right
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> largest <span style="color:#f92672">==</span> root:      <span style="color:#75715e"># 父节点已经最大，下沉结束</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">break</span>
</span></span><span style="display:flex;"><span>        arr[root], arr[largest] <span style="color:#f92672">=</span> arr[largest], arr[root]
</span></span><span style="display:flex;"><span>        root <span style="color:#f92672">=</span> largest
</span></span></code></pre></div><ul>
<li><strong>复杂度</strong>：最好、平均、最坏<strong>全是 O(n log n)</strong>。建堆是 O(n)（不是 O(n log n)，这是个常考的反直觉点），之后 n 次下沉每次 O(log n)。空间 O(1)。</li>
<li><strong>稳定性 / 原地</strong>：<strong>不稳定</strong>，<strong>原地</strong>。</li>
<li><strong>面试要点</strong>：它是<strong>唯一</strong>既保证最坏 O(n log n)、又只用 O(1) 空间的排序——「内存极紧又不能退化」时选它。注意它和<strong>优先队列 / 堆</strong>（heap）是同一套机制：<code>heapq</code>、Top-K 问题、Dijkstra 里的堆，全是这个 <code>sift_down</code> 的变体。用最小堆维护一个大小为 k 的堆求 Top-K，是堆这块的连环考点。</li>
<li><strong>LeetCode 例题</strong>：<a href="https://leetcode.cn/problems/kth-largest-element-in-an-array/">215. 数组中的第 K 个最大元素</a> —— 堆的经典题（维护一个大小为 k 的最小堆）；它也能用快速选择解，正好对照两种思路。</li>
</ul>
<h2 id="非比较类排序">非比较类排序</h2>
<p>前面所有算法都靠「比较」，所以卡在 O(n log n) 这条线上。接下来三个绕开了比较，用<strong>元素的值本身</strong>当索引去定位，从而做到线性时间——代价是对数据有要求。这一块也是我复习时空白最大的地方，写得细一点。</p>
<h3 id="计数排序-counting-sort">计数排序 Counting Sort</h3>
<p><strong>一句话思路</strong>：统计每个值出现了几次，再用「前缀和」算出每个值在结果里该放的位置，直接放进去。适合<strong>取值范围 k 不大的整数</strong>。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">counting_sort</span>(arr):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> arr:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> arr
</span></span><span style="display:flex;"><span>    lo, hi <span style="color:#f92672">=</span> min(arr), max(arr)
</span></span><span style="display:flex;"><span>    k <span style="color:#f92672">=</span> hi <span style="color:#f92672">-</span> lo <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    count <span style="color:#f92672">=</span> [<span style="color:#ae81ff">0</span>] <span style="color:#f92672">*</span> k
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> x <span style="color:#f92672">in</span> arr:                <span style="color:#75715e"># 1. 计数</span>
</span></span><span style="display:flex;"><span>        count[x <span style="color:#f92672">-</span> lo] <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">1</span>, k):        <span style="color:#75715e"># 2. 前缀和：count[i] 变成「值 ≤ i 的元素个数」</span>
</span></span><span style="display:flex;"><span>        count[i] <span style="color:#f92672">+=</span> count[i <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>]
</span></span><span style="display:flex;"><span>    result <span style="color:#f92672">=</span> [<span style="color:#ae81ff">0</span>] <span style="color:#f92672">*</span> len(arr)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> x <span style="color:#f92672">in</span> reversed(arr):      <span style="color:#75715e"># 3. 从后往前填，保证稳定</span>
</span></span><span style="display:flex;"><span>        count[x <span style="color:#f92672">-</span> lo] <span style="color:#f92672">-=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>        result[count[x <span style="color:#f92672">-</span> lo]] <span style="color:#f92672">=</span> x
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> result
</span></span></code></pre></div><ul>
<li><strong>复杂度</strong>：O(n + k)，n 是元素个数，k 是取值范围。空间 O(n + k)。</li>
<li><strong>稳定性 / 原地</strong>：稳定（关键就在第 3 步<strong>从后往前</strong>遍历），不是原地。</li>
<li><strong>面试要点</strong>：当 k 远小于 n（比如给十万个 0–100 的分数排序）时，它吊打任何 O(n log n) 排序。但 k 一旦很大（比如要排任意 32 位整数），空间就爆了——这正是它的适用边界，也是基数排序要解决的问题。</li>
<li><strong>LeetCode 例题</strong>：<a href="https://leetcode.cn/problems/sort-colors/">75. 颜色分类</a> —— 只有 0、1、2 三种值，计数排序（或三路快排）一趟搞定。</li>
</ul>
<h3 id="基数排序-radix-sort">基数排序 Radix Sort</h3>
<p><strong>一句话思路</strong>：按位排序。从最低位（个位）开始，对每一位用一次<strong>稳定的</strong>计数排序，一直排到最高位。因为每一轮都稳定，排完最高位时整体就有序了。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">radix_sort</span>(arr):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> arr:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> arr
</span></span><span style="display:flex;"><span>    max_val <span style="color:#f92672">=</span> max(arr)
</span></span><span style="display:flex;"><span>    exp <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>                           <span style="color:#75715e"># 当前处理的位：1=个位, 10=十位, 100=百位…</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">while</span> max_val <span style="color:#f92672">//</span> exp <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">0</span>:
</span></span><span style="display:flex;"><span>        arr <span style="color:#f92672">=</span> counting_sort_by_digit(arr, exp)
</span></span><span style="display:flex;"><span>        exp <span style="color:#f92672">*=</span> <span style="color:#ae81ff">10</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> arr
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">counting_sort_by_digit</span>(arr, exp):
</span></span><span style="display:flex;"><span>    count <span style="color:#f92672">=</span> [<span style="color:#ae81ff">0</span>] <span style="color:#f92672">*</span> <span style="color:#ae81ff">10</span>                  <span style="color:#75715e"># 十进制，每位只有 0–9</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> x <span style="color:#f92672">in</span> arr:
</span></span><span style="display:flex;"><span>        count[(x <span style="color:#f92672">//</span> exp) <span style="color:#f92672">%</span> <span style="color:#ae81ff">10</span>] <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">10</span>):
</span></span><span style="display:flex;"><span>        count[i] <span style="color:#f92672">+=</span> count[i <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>]
</span></span><span style="display:flex;"><span>    result <span style="color:#f92672">=</span> [<span style="color:#ae81ff">0</span>] <span style="color:#f92672">*</span> len(arr)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> x <span style="color:#f92672">in</span> reversed(arr):           <span style="color:#75715e"># 从后往前，保持这一位排序的稳定性（基数排序正确性的关键）</span>
</span></span><span style="display:flex;"><span>        digit <span style="color:#f92672">=</span> (x <span style="color:#f92672">//</span> exp) <span style="color:#f92672">%</span> <span style="color:#ae81ff">10</span>
</span></span><span style="display:flex;"><span>        count[digit] <span style="color:#f92672">-=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>        result[count[digit]] <span style="color:#f92672">=</span> x
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> result
</span></span></code></pre></div><ul>
<li><strong>复杂度</strong>：O(d·(n + k))，d 是最大数字的位数，k 是基数（这里十进制 k=10）。空间 O(n + k)。</li>
<li><strong>稳定性 / 原地</strong>：稳定，不是原地。</li>
<li><strong>面试要点</strong>：它解决了计数排序「取值范围一大空间就爆」的问题——把一个大整数拆成几位小数字来排。上面的版本只处理非负整数；要支持负数，可以先整体平移成非负，或对正负分别处理。面试常考的点：<strong>为什么必须从低位排到高位？为什么每一位的排序必须稳定？</strong>（因为高位排序时要靠稳定性保留低位已经排好的顺序。）</li>
<li><strong>LeetCode 例题</strong>：<a href="https://leetcode.cn/problems/maximum-gap/">164. 最大间距</a> —— 要求线性时间和空间，标准解法正是基数排序或桶排序。</li>
</ul>
<h3 id="桶排序-bucket-sort">桶排序 Bucket Sort</h3>
<p><strong>一句话思路</strong>：把数据按值域均匀分到若干个「桶」里，每个桶内部各自排序，最后按桶的顺序拼起来。适合<strong>均匀分布</strong>的数据。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">bucket_sort</span>(arr):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> arr:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> arr
</span></span><span style="display:flex;"><span>    n <span style="color:#f92672">=</span> len(arr)
</span></span><span style="display:flex;"><span>    buckets <span style="color:#f92672">=</span> [[] <span style="color:#66d9ef">for</span> _ <span style="color:#f92672">in</span> range(n)]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> x <span style="color:#f92672">in</span> arr:                     <span style="color:#75715e"># 假设元素均匀分布在 [0, 1)</span>
</span></span><span style="display:flex;"><span>        buckets[int(n <span style="color:#f92672">*</span> x)]<span style="color:#f92672">.</span>append(x)
</span></span><span style="display:flex;"><span>    result <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> bucket <span style="color:#f92672">in</span> buckets:
</span></span><span style="display:flex;"><span>        insertion_sort(bucket)        <span style="color:#75715e"># 桶内用稳定排序，整体才稳定</span>
</span></span><span style="display:flex;"><span>        result<span style="color:#f92672">.</span>extend(bucket)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> result
</span></span></code></pre></div><ul>
<li><strong>复杂度</strong>：数据均匀分布时平均 O(n + k)；最坏情况（所有元素挤进同一个桶）退化到 O(n²)。空间 O(n + k)。</li>
<li><strong>稳定性 / 原地</strong>：取决于桶内排序——用插入排序就稳定；不是原地。</li>
<li><strong>面试要点</strong>：它的性能完全押在「数据分布是否均匀」上，这是它和计数/基数最大的区别。计数和基数对数据形态不敏感，桶排序敏感。经典适用场景：把一批均匀分布在 [0, 1) 的浮点数排序。</li>
<li><strong>LeetCode 例题</strong>：<a href="https://leetcode.cn/problems/top-k-frequent-elements/">347. 前 K 个高频元素</a> —— 按出现频次分桶，是这道题最漂亮的解法。</li>
</ul>
<h2 id="现实世界里用的是什么timsort">现实世界里用的是什么：Timsort</h2>
<p>前面这些都是「教科书算法」。但你每天 <code>sorted()</code> 一下，Python 背后跑的其实是 <strong>Timsort</strong>——一个为真实世界数据精心调校过的混合算法。值得单独说，因为面试里能讲出这个，往往是加分项。</p>
<p><strong>核心思想</strong>：真实数据很少是完全随机的，往往<strong>局部已经有序</strong>。Timsort 抓住这一点：</p>
<ol>
<li>先扫描数组，找出已经天然有序的连续片段，叫 <strong>run</strong>；</li>
<li>太短的 run 用<strong>插入排序</strong>补齐到一个最小长度（<code>minrun</code>，通常 32–64）——前面说过，小数组上插入排序最快；</li>
<li>再用<strong>归并排序</strong>的方式，按一套规则把这些 run 两两合并，合并时还有「galloping（飞奔）」模式来加速。</li>
</ol>
<p>所以 Timsort = <strong>归并排序的骨架 + 插入排序的小块优化 + 对已有序数据的特判</strong>。</p>
<ul>
<li><strong>复杂度</strong>：最坏 O(n log n)，但对几乎有序的数据能到 O(n)。空间 O(n)。</li>
<li><strong>稳定性</strong>：稳定。这也是为什么 Python 的 <code>sorted()</code> 和 <code>list.sort()</code> 保证稳定。</li>
<li><strong>冷知识</strong>：Java 给对象排序（<code>Arrays.sort(Object[])</code>）用的也是 Timsort 的变体；而 C++ 的 <code>std::sort</code> 用的是另一个混合算法 <strong>Introsort</strong>（快排打底，递归太深时切换堆排避免退化，小块用插入排序）。「快排 + 堆排 + 插入排序」三合一，思路和 Timsort 异曲同工：<strong>没有银弹，工业级排序都是混合的</strong>。</li>
<li><strong>LeetCode 例题</strong>：<a href="https://leetcode.cn/problems/merge-intervals/">56. 合并区间</a> —— 先排序再扫描合并；在 Python 里那句 <code>sorted()</code> 跑的就是 Timsort，正好体会「真实数据局部有序」带来的加速。</li>
</ul>
<h2 id="面试里到底怎么选--怎么答">面试里到底怎么选 / 怎么答</h2>
<p>把上面的东西浓缩成一张「该用哪个」的决策清单：</p>
<ul>
<li><strong>没有特殊要求，就要快</strong> → 快排（随机 pivot）。这是大多数场景的默认选择。</li>
<li><strong>要稳定，且最坏也得 O(n log n)</strong> → 归并排序。</li>
<li><strong>内存极紧（要 O(1) 空间）又不能退化</strong> → 堆排序。</li>
<li><strong>数据量很小（几十个）或几乎已经有序</strong> → 插入排序。</li>
<li><strong>排链表</strong> → 归并排序。</li>
<li><strong>整数且取值范围不大</strong> → 计数排序。</li>
<li><strong>整数但范围很大（如定长整数 / 字符串）</strong> → 基数排序。</li>
<li><strong>数据均匀分布在一个区间</strong> → 桶排序。</li>
<li><strong>只要第 k 大 / 中位数，不用全排序</strong> → Quickselect。</li>
<li><strong>数据大到内存装不下</strong> → 外部归并排序。</li>
</ul>
<p>几个常见的连环追问，提前想好答案：</p>
<ul>
<li><strong>「哪些排序是稳定的？」</strong> → 冒泡、插入、归并、计数、基数、桶（桶内稳定时）、Timsort。</li>
<li><strong>「快排最坏情况和怎么避免？」</strong> → 有序输入 + 坏 pivot 退化成 O(n²)；用随机 pivot 或三数取中。</li>
<li><strong>「能不能比 O(n log n) 更快？」</strong> → 比较排序不能（决策树下界）；但如果数据是范围有限的整数，可以用非比较排序到 O(n)。</li>
<li><strong>「O(n log n) 又稳定又原地的排序存在吗？」</strong> → 一般实现里不存在；归并稳定但不原地，堆排原地但不稳定，快排原地但不稳定。这是个很好的「理解权衡」的考点。</li>
</ul>
<h2 id="几个常见易错点">几个常见易错点</h2>
<p>复习时我自己踩过或者差点踩的坑，列在这里：</p>
<ul>
<li><strong>选择排序对有序输入也不会变快</strong>——它没有「提前退出」，永远 O(n²)。别和冒泡/插入搞混。</li>
<li><strong>建堆是 O(n) 不是 O(n log n)</strong>。直觉上是 n 个元素各 O(log n)，但仔细算（大部分节点离底很近）是 O(n)。这是个高频反直觉考点。</li>
<li><strong>计数 / 基数排序「从后往前」填结果</strong>——这一步是稳定性的来源，反过来写就不稳定了，而基数排序一旦不稳定就直接错了。</li>
<li><strong>快排的空间不是 O(1)</strong>——虽然原地分区，但递归栈平均 O(log n)、最坏 O(n)。</li>
<li><strong>桶排序的最坏是 O(n²)</strong>——别只记平均 O(n)，分布不均时会退化。</li>
<li><strong>稳定 ≠ 原地</strong>，这是两个独立的维度，面试里经常被一起问，别混为一谈。</li>
</ul>
<h2 id="小结">小结</h2>
<p>排序这块知识，框架其实很清楚：</p>
<ul>
<li><strong>比较排序</strong>卡在 O(n log n)，里面快排最快但会退化、归并稳定但费空间、堆排原地但不稳定——<strong>没有全能选手，全是权衡</strong>；</li>
<li><strong>非比较排序</strong>靠数据特征换来线性时间，但对数据有要求；</li>
<li><strong>工业级排序（Timsort、Introsort）都是混合的</strong>，把几种算法的长处缝在一起。</li>
</ul>
<p>如果你和我一样是隔了几年重新捡起来，建议照着最上面那张速查表过一遍：能默写出实现、说清复杂度和稳定性的就跳过，卡壳的就回到对应章节细看。等下次再准备面试，回到这里再扫一遍就好。</p>
<p>祝面试顺利。</p>
]]></content:encoded></item><item><title>看到 MBTI 和 SBTI 爆火之后，我做了一个程序员人格测试网站</title><link>https://neilmin.com/zh/posts/building-a-programmer-personality-test/</link><pubDate>Tue, 28 Apr 2026 11:45:00 -0700</pubDate><guid>https://neilmin.com/zh/posts/building-a-programmer-personality-test/</guid><description>从几个爆火的人格测试项目里得到灵感，我做了一个面向程序员语境的人格测试站：外表很认真，里面全是程序员黑话。</description><content:encoded><![CDATA[<p>前段时间我连续看到了几个很火的人格测试项目。</p>
<p>一个是大家已经很熟悉的 MBTI 风格网站，比如 <a href="https://www.16personalities.com/ch/">16personalities</a>，整体做得特别完整，看起来很像某种严肃的人格测评产品。另一个是像 <a href="https://sbti.unun.dev/">SBTI</a> 这种更抽象、更互联网、更适合在社交媒体上传来传去的测试。除此之外，我还看到了一个程序员版的脑洞来源，比如 B 站上有人在玩“程序员 MBTI”的梗，也看了像 <a href="https://github.com/liyupi/cbti-test">cbti-test</a> 这种把测试做成纯前端小产品的项目。</p>
<p>看着看着，我脑子里就冒出来一个很朴素的念头：</p>
<p><strong>要不我也做一个程序员人格测试？</strong></p>
<p>而且不是那种随便拼几个梗、做个几分钟就看腻了的页面，而是想认真一点做：让它外表看起来像一个很严肃的心理测评网站，但题目和结果全都在影射程序员日常那些很具体、很离谱、但又确实存在的工作习惯和精神状态。</p>
<h2 id="我真正感兴趣的是那种外表认真内容不正经的反差">我真正感兴趣的，是那种“外表认真，内容不正经”的反差</h2>
<p>如果只是做一个“程序员版 MBTI”，其实很容易做成那种你看完哈哈一笑、截图发给朋友、然后第二天就忘了的东西。</p>
<p>我不太想只停在那个层面。</p>
<p>我更感兴趣的是另一种感觉：</p>
<ul>
<li>页面看起来要像真的</li>
<li>交互看起来要像真的</li>
<li>结果页也要像真的</li>
<li>但你认真做完以后，会发现它测的全是程序员特有的精神状态</li>
</ul>
<p>比如：</p>
<ul>
<li>你写代码到底更依赖 AI，还是更依赖自己手敲？</li>
<li>你是不是那种看到一个小需求也想先讲一轮架构的人？</li>
<li>你排查 Bug 到底是翻日志看源码，还是先打一排 <code>console.log</code> 再说？</li>
<li>你做程序员到底是因为热爱技术，还是说白了就是为了 TC、为了别被 PIP、为了准点下班？</li>
</ul>
<p>这些问题一旦认真想一想，其实都很有“人格测试”的味道。</p>
<h2 id="真正花时间的部分不是写代码而是把方向讲清楚">真正花时间的部分，不是写代码，而是把方向讲清楚</h2>
<p>这个项目一开始其实非常模糊。</p>
<p>最早只是一个感觉：<br>
我想做一个程序员人格测试，而且最好不是泛泛的那种程序员，而是更贴近北美华人程序员、湾区程序员、Tech 公司打工人那种语境。</p>
<p>但只要真开始做，你就会很快发现，里面有一堆问题不是一句“做个测试”能糊过去的。</p>
<p>比如：</p>
<ul>
<li>到底是先做中文，还是一开始就做中英双语？</li>
<li>到底要不要继续借传统 MBTI 的 E/I、S/N、T/F、J/P？</li>
<li>结果页应该更像正式报告，还是更像方便传播的海报？</li>
<li>前端要不要上 Router？</li>
<li>部署到底挂主站子路径，还是干脆单独开子域名？</li>
<li>英文版要不要强行直译中文，还是让它自己说人话？</li>
</ul>
<p>这种问题表面看起来都不大，但它们会直接决定你最后做出来的是一个“能跑的小玩具”，还是一个至少看起来像完整产品的东西。</p>
<p>这次我一个很深的感受就是：</p>
<p><strong>真正花时间的不是实现，而是很多方向性的取舍，需要一轮一轮来回掰清楚。</strong></p>
<p>有些地方甚至不是在讨论代码，而是在讨论一句文案该不该更狠一点、结果标题该不该更像网梗、题目到底要更像心理测评还是更像互联网问卷。</p>
<p>这些事听上去很虚，但最后都会非常具体地影响整个产品的味道。</p>
<h2 id="最后我定下来的是一套叫-ship-的四维框架">最后我定下来的，是一套叫 SHIP 的四维框架</h2>
<p>后来我没有继续沿用传统 MBTI 的四个维度，而是重新造了一套更贴近程序员日常的四维框架，名字叫 <code>SHIP</code>。</p>
<p>一方面这个缩写本身挺顺，另一方面也很符合程序员语境：你开了半天会、写了半天代码、画了半天 Design Doc，最后不还是为了把东西 ship 出去。</p>
<p>这四个维度分别是：</p>
<h3 id="1-source你代码的来源到底是什么">1. Source：你代码的来源到底是什么？</h3>
<ul>
<li><code>C = Copilot</code></li>
<li><code>T = Typecraft</code></li>
</ul>
<p>也就是你更像哪一种人：</p>
<ul>
<li>一看到脏活累活就先让 Gemini、Claude、Copilot 给你吐个骨架</li>
<li>还是哪怕 AI 已经写出来了，你也想自己手敲一遍才放心</li>
</ul>
<h3 id="2-hierarchy你对架构这件事到底有多上头">2. Hierarchy：你对架构这件事到底有多上头？</h3>
<ul>
<li><code>O = Overdesign</code></li>
<li><code>A = ASAP</code></li>
</ul>
<p>有些程序员看到一个普通 CRUD 需求，脑子里第一反应是：</p>
<ul>
<li>Decoupling</li>
<li>Scalability</li>
<li>HA</li>
<li>Backward compatibility</li>
<li>Platform 级通用服务</li>
</ul>
<p>另一些人想到的则只有一句：</p>
<p><strong>今晚能不能先 Push to Prod。</strong></p>
<h3 id="3-investigation你排查问题的方式到底偏理性还是偏玄学">3. Investigation：你排查问题的方式，到底偏理性还是偏玄学？</h3>
<ul>
<li><code>L = Logic</code></li>
<li><code>P = Pray</code></li>
</ul>
<p>有的人一出问题先翻日志、看 trace、顺着源码往里挖。<br>
还有的人第一反应是：</p>
<ul>
<li>先加几个 print</li>
<li>先重启试试</li>
<li>先写个兜底脚本</li>
<li>只要服务先恢复了，根因以后再说</li>
</ul>
<h3 id="4-purpose你做程序员这件事到底是在追求什么">4. Purpose：你做程序员这件事，到底是在追求什么？</h3>
<ul>
<li><code>G = Geek</code></li>
<li><code>W = Worker</code></li>
</ul>
<p>有些人真的会在周末自愿折腾 side project、看新技术、研究新框架。<br>
另一些人则更容易被这些事情驱动：</p>
<ul>
<li>perf review</li>
<li>promo</li>
<li>PIP</li>
<li>H1B / PERM</li>
<li>layoff 焦虑</li>
<li>准点下班</li>
</ul>
<p>我后来越来越觉得，这几个维度虽然听上去像在整活，但落到具体题目里，其实都很“像真的”。</p>
<h2 id="算法我故意做得很简单因为我不想装神弄鬼">算法我故意做得很简单，因为我不想装神弄鬼</h2>
<p>这次在计算逻辑上，我其实有一个很明确的偏好：</p>
<p><strong>不要为了显得“科学”而故意做复杂。</strong></p>
<p>有些人格测试会用各种看起来很高深的向量匹配、距离计算，乍一看很厉害，但放到这个项目里我反而觉得没必要。</p>
<p>因为这套产品本身就只有 4 个维度、每个维度 2 极，所以最自然的方式其实就是：</p>
<ul>
<li>每道题只打一个维度</li>
<li>7 点量表映射成 <code>+3</code> 到 <code>-3</code></li>
<li>每个维度各自累计分数</li>
<li>分数正负决定最终字母</li>
<li>四个字母拼起来就是结果码，比如 <code>CAPW</code></li>
</ul>
<p>这个算法的好处也很直接：</p>
<ul>
<li>足够透明</li>
<li>足够容易解释</li>
<li>很适合结果页上的百分比条</li>
<li>后面做中英双语时，逻辑层完全不用变</li>
</ul>
<p>我后来把 README 也专门补得很详细，就是因为我觉得这种项目最怕的是：表面上看起来只是个整活测试，实际上里面如果堆太多没必要的“神秘逻辑”，后面连自己都不想维护。</p>
<h2 id="技术架构最后也被我收得很克制">技术架构最后也被我收得很克制</h2>
<p>虽然这个网站看起来像个完整产品，但最后我的技术路线其实非常克制。</p>
<p>我没有把它塞进主博客 repo 里，而是单独开了一个独立仓库，再单独挂到了子域名上：</p>
<ul>
<li>代码仓库独立</li>
<li>前端用 Vite + React</li>
<li>纯静态部署</li>
<li>GitHub Pages 免费托管</li>
<li>子域名是 <code>mbti.neilmin.com</code></li>
</ul>
<p>然后我刻意没有做几件很容易“看起来更高级”，但其实对这个项目没必要的事：</p>
<ul>
<li>不上 React Router</li>
<li>不上完整 i18n 框架</li>
<li>不做后端</li>
<li>不做数据库</li>
</ul>
<h3 id="为什么不上-router">为什么不上 Router？</h3>
<p>因为这个产品本质上就是一个单页问卷：</p>
<ul>
<li>首页</li>
<li>答题页</li>
<li>结果页</li>
</ul>
<p>如果为了“看起来更像 SPA”去上完整路由，GitHub Pages 上反而会带来一堆没必要的深链接和 404 问题。</p>
<p>所以最后页面切换全靠 React state，真正保留在 URL 里的只有一个东西：</p>
<ul>
<li><code>?result=CODE</code></li>
</ul>
<p>这样别人如果分享一个结果链接，你还是可以直接打开结果页；但整个项目的部署复杂度会低很多。</p>
<h3 id="为什么不上-i18n-框架">为什么不上 i18n 框架？</h3>
<p>后来我又给这个网站补了英语版。</p>
<p>但我也没有上完整的 i18n 系统。原因很简单：这个项目的文案非常重，而且英文版我根本不想做成中文直译。</p>
<p>所以最后的做法是：</p>
<ul>
<li>一个轻量 <code>locale</code> 状态</li>
<li>一套共用的评分逻辑和结果 code</li>
<li>两套独立的内容数据：
<ul>
<li>中文题库</li>
<li>英文题库</li>
<li>中文人格文案</li>
<li>英文人格文案</li>
</ul>
</li>
</ul>
<p>这样英文版就可以写得像自然英文，而不是带着中文句式的翻译腔。</p>
<h2 id="最后最上头的部分反而是人物图和分享海报">最后最上头的部分，反而是人物图和分享海报</h2>
<p>如果只是把题目和结果页做出来，这个项目其实已经能跑了。</p>
<p>但这种测试一旦想传播出去，真正会被保存和转发的，往往不是网页本身，而是一张图。</p>
<p>于是后面我又开始折腾两件特别容易上头的事。</p>
<h3 id="1-给-16-个人格做人物立绘">1. 给 16 个人格做人物立绘</h3>
<p>我参考的是那种偏 <code>16personalities</code> 的低多边形、干净、几何切面风格，但内容还是我们自己这套程序员人格。</p>
<p>最开始我还在想，是不是要一张一张单独画。后来发现这样很容易风格漂，而且太慢。最后就走了一条很符合这个时代的路：</p>
<ul>
<li>先让 AI 一次性生成一整张角色表</li>
<li>再用脚本自动切图</li>
<li>最后把它们接进首页、结果页和分享海报</li>
</ul>
<p>这种做法特别像现代软件工程版的“批处理”：创意交给模型，机械劳动交给脚本。</p>
<h3 id="2-单独做一张竖屏分享海报">2. 单独做一张竖屏分享海报</h3>
<p>结果页本身是给“读”的，海报则是给“发”的。</p>
<p>所以最后我没有让用户直接截结果页，而是专门做了一张手机优先的竖屏海报。里面会带上：</p>
<ul>
<li>人格 code</li>
<li>标题</li>
<li>quote</li>
<li>4 个维度摘要</li>
<li>长描述</li>
<li>生活与社交侧写</li>
<li>二维码</li>
<li>网址</li>
</ul>
<p>而且二维码并不会带着分享者的结果跳转，而是直接回到测试首页。因为这张图真正的任务，不是让别人复现你的结果，而是让别人也去做一遍。</p>
<p>我觉得这个区别还挺重要的：</p>
<p><strong>分享结果是内容，二维码入口是拉新。</strong></p>
<h2 id="最后">最后</h2>
<p>回头看，这个项目对我来说，更像是一件“我当时觉得挺有意思，所以就真的把它做出来了”的事情。</p>
<p>一开始其实没有想那么多宏大的东西，也不是奔着做什么特别完整的产品实验去的。就是我刚好连续看到了几个类似的人格测试项目，觉得这种“外表很认真，里面全是圈内梗”的反差特别有趣。再加上程序员这个群体本来就有很多非常具体、非常好笑、但又确实存在的工作习惯和精神状态，于是我就忍不住想：那不如自己也做一个。</p>
<p>最后做出来的这个版本，我自己觉得已经挺好玩了：</p>
<ul>
<li>能认真地测</li>
<li>能比较顺地看结果</li>
<li>也能生成一张适合分享的图</li>
</ul>
<p>但它肯定也还远远没到“已经完全定型”的地步。后面不管是题目文案、人格设定、英文版细节，还是视觉和交互，我觉得都还有继续优化的空间。</p>
<p>如果你也好奇自己到底更像哪一种程序员，可以去玩一下：</p>
<p><a href="https://mbti.neilmin.com">https://mbti.neilmin.com</a></p>
<p>如果你测完觉得“这也太像我了”，那说明这次整活至少整对了一半。</p>
]]></content:encoded></item><item><title>我喜欢的一些个人网站</title><link>https://neilmin.com/zh/posts/favorite-personal-websites/</link><pubDate>Tue, 14 Apr 2026 03:30:00 -0700</pubDate><guid>https://neilmin.com/zh/posts/favorite-personal-websites/</guid><description>整理了一些我很喜欢的个人网站：它们有的设计克制，有的交互大胆，但都很有自己的表达方式。</description><content:encoded><![CDATA[<p>最近我看了不少个人网站，越看越觉得这类站点很有意思。</p>
<p>它们不一定都追求同一种风格，有些非常克制，有些会在交互上放得很开，但共同点是都很有“这个人自己的味道”。我把其中一些自己很喜欢的网站记在这里，也当作以后做自己网站时的一份灵感收藏。</p>
<a class="favorite-site-card" href="https://gkoberger.com" target="_blank" rel="noopener noreferrer">
  <figure class="favorite-site-card__media">
    <img class="favorite-site-card__image" src="https://neilmin.com/images/favorite-personal-websites/gkoberger-homepage.png" alt="gkoberger.com 首页截图" loading="lazy">
  </figure>
  <div class="favorite-site-card__body">
    <h3 class="favorite-site-card__title">gkoberger.com</h3>
    <div class="favorite-site-card__description">这个网站最有意思的地方，就是作者先给自己做了一个坐在桌子前面的虚拟形象。然后你去点画面里的每一个东西，不管是人、桌上的物件，还是后面的画板，都会把你带到不同的子页面。第一次看到的时候我就觉得，原来个人网站还能这么玩。</div>
  </div>
</a>

<a class="favorite-site-card" href="https://www.alanagoyal.com/finder" target="_blank" rel="noopener noreferrer">
  <figure class="favorite-site-card__media">
    <img class="favorite-site-card__image" src="https://neilmin.com/images/favorite-personal-websites/alanagoyal-finder-homepage.png" alt="alanagoyal.com/finder 页面截图" loading="lazy">
  </figure>
  <div class="favorite-site-card__body">
    <h3 class="favorite-site-card__title">alanagoyal.com/finder</h3>
    <div class="favorite-site-card__description">这个网站很厉害的一点，是它几乎把整个界面都做得像苹果系统一样，而且不是那种只做个样子，很多地方都是真的可以交互的。整个体验非常轻巧，也非常有趣，会让人忍不住到处点一遍。</div>
  </div>
</a>

<a class="favorite-site-card" href="https://www.sharyap.com" target="_blank" rel="noopener noreferrer">
  <figure class="favorite-site-card__media">
    <img class="favorite-site-card__image" src="https://neilmin.com/images/favorite-personal-websites/sharyap-homepage.png" alt="sharyap.com 首页截图" loading="lazy">
  </figure>
  <div class="favorite-site-card__body">
    <h3 class="favorite-site-card__title">sharyap.com</h3>
    <div class="favorite-site-card__description">这个网站跟刚才那个有点像，也是你每打开一个新的页面，就像在电脑上新开了一个窗口一样。更可爱的是，网站里的很多图、标志，还有各种小元素，都是作者自己画的，整个感觉就特别统一，也特别可爱。</div>
  </div>
</a>

<a class="favorite-site-card" href="https://merodev.net" target="_blank" rel="noopener noreferrer">
  <figure class="favorite-site-card__media">
    <img class="favorite-site-card__image" src="https://neilmin.com/images/favorite-personal-websites/merodev-homepage.png" alt="merodev.net 首页截图" loading="lazy">
  </figure>
  <div class="favorite-site-card__body">
    <h3 class="favorite-site-card__title">merodev.net</h3>
    <div class="favorite-site-card__description">这是一个非常炫酷、非常有科技感的 3D 网站。你一打开就能感受到那个渲染出来的质感，光影、场景、整体氛围都很抓人，属于那种会让人下意识多停几秒看一看的页面。</div>
  </div>
</a>

<a class="favorite-site-card" href="https://bruno-simon.com" target="_blank" rel="noopener noreferrer">
  <figure class="favorite-site-card__media">
    <img class="favorite-site-card__image" src="https://neilmin.com/images/favorite-personal-websites/bruno-simon-homepage.png" alt="bruno-simon.com 首页截图" loading="lazy">
  </figure>
  <div class="favorite-site-card__body">
    <h3 class="favorite-site-card__title">bruno-simon.com</h3>
    <div class="favorite-site-card__description">这个网站非常有名，我很久以前就看过。现在再看，它又进化了。它最离谱、也最让人忘不掉的地方在于，作者居然把自己的个人网站做成了一个 3D 开车小游戏。你一边开车，一边看他的作品和介绍。我到现在都还是会想，他到底是怎么把这么多东西全都塞进一个网站里的。而且他还有自己的 YouTube 频道，专门讲这些东西是怎么做出来的。</div>
  </div>
</a>

<a class="favorite-site-card" href="https://logartis.info" target="_blank" rel="noopener noreferrer">
  <figure class="favorite-site-card__media">
    <img class="favorite-site-card__image" src="https://neilmin.com/images/favorite-personal-websites/logartis-homepage.png" alt="logartis.info 首页截图" loading="lazy">
  </figure>
  <div class="favorite-site-card__body">
    <h3 class="favorite-site-card__title">logartis.info</h3>
    <div class="favorite-site-card__description">这个网站给我的感觉很像在看电影。你像是在树林里穿来穿去，整个场景的氛围特别完整。尤其是它对天气和环境变化的处理，真的做得很好，所以你在里面待着的时候会很有沉浸感。</div>
  </div>
</a>

<a class="favorite-site-card" href="https://wodniack.dev" target="_blank" rel="noopener noreferrer">
  <figure class="favorite-site-card__media">
    <img class="favorite-site-card__image" src="https://neilmin.com/images/favorite-personal-websites/wodniack-homepage.png" alt="wodniack.dev 首页截图" loading="lazy">
  </figure>
  <div class="favorite-site-card__body">
    <h3 class="favorite-site-card__title">wodniack.dev</h3>
    <div class="favorite-site-card__description">这个网站也是个人风格非常鲜明的那一类。它里面的很多互动，还有滚动时触发出来的效果，都做得很独特，也很有艺术感。你能感觉到作者不是在套一个模板，而是真的在用网站表达自己的审美。</div>
  </div>
</a>

<a class="favorite-site-card" href="https://getcoleman.com" target="_blank" rel="noopener noreferrer">
  <figure class="favorite-site-card__media">
    <img class="favorite-site-card__image" src="https://neilmin.com/images/favorite-personal-websites/getcoleman-homepage.png" alt="getcoleman.com 首页截图" loading="lazy">
  </figure>
  <div class="favorite-site-card__body">
    <h3 class="favorite-site-card__title">getcoleman.com</h3>
    <div class="favorite-site-card__description">这个网站最让我眼前一亮的地方，是它介绍自己的方式特别有创意。作者用进度条去展示自己的人生时间线，还有不同阶段的变化。你一看就会觉得，原来自我介绍这件事还可以这样做，真的很有意思。</div>
  </div>
</a>

<a class="favorite-site-card" href="https://animejs.com" target="_blank" rel="noopener noreferrer">
  <figure class="favorite-site-card__media">
    <img class="favorite-site-card__image" src="https://neilmin.com/images/favorite-personal-websites/animejs-homepage.png" alt="animejs.com 首页截图" loading="lazy">
  </figure>
  <div class="favorite-site-card__body">
    <h3 class="favorite-site-card__title">animejs.com</h3>
    <div class="favorite-site-card__description">这个不是个人网站，而是一个产品展示网站。但它的动画真的做得非常流畅，不同页面之间滚动切换时的过渡、节奏控制都很顺。整个体验非常完整，是那种你一看就会觉得“这做得真好”的网站。</div>
  </div>
</a>

<a class="favorite-site-card" href="https://www.joshwcomeau.com" target="_blank" rel="noopener noreferrer">
  <figure class="favorite-site-card__media">
    <img class="favorite-site-card__image" src="https://neilmin.com/images/favorite-personal-websites/joshwcomeau-homepage.png" alt="joshwcomeau.com 首页截图" loading="lazy">
  </figure>
  <div class="favorite-site-card__body">
    <h3 class="favorite-site-card__title">joshwcomeau.com</h3>
    <div class="favorite-site-card__description">这个网站整体做得特别舒服。无论是字体、配色，还是首页上的互动小游戏，都会让人觉得这个人的审美真的很好，而且是那种越看越觉得舒服的好。再加上他还写了很多技术文章，所以它不只是好看，也很值得学习。</div>
  </div>
</a>

<a class="favorite-site-card" href="https://portfolio.ohevan.com" target="_blank" rel="noopener noreferrer">
  <figure class="favorite-site-card__media">
    <img class="favorite-site-card__image" src="https://neilmin.com/images/favorite-personal-websites/ohevan-portfolio-homepage.png" alt="portfolio.ohevan.com 首页截图" loading="lazy">
  </figure>
  <div class="favorite-site-card__body">
    <h3 class="favorite-site-card__title">portfolio.ohevan.com</h3>
    <div class="favorite-site-card__description">这个网站更像是在展示一个人的作品集、去过的地方，还有他拍过的照片，而且那些照片拍得真的很像大片一样。我自己也非常喜欢这种方向，等以后我有更多值得分享的照片时，也很想认真想一想，要不要做一个类似的页面。</div>
  </div>
</a>

<p>这些网站最让我喜欢的地方，大概就两种：要么互动做得特别好，要么就是本身非常漂亮。但更重要的是，它们真的让我看到，一个网站到底能被玩出多大的花样。很多你原本想象不到的事情，其实真的都可以在一个网站上做出来。对我来说，这份清单就是一次彻底的大开眼界，所以才忍不住想把它分享给大家。</p>
]]></content:encoded></item><item><title>为了一盘醋包顿饺子：我是怎么用 Vibe Coding 手搓出这个博客网站的</title><link>https://neilmin.com/zh/posts/building-my-hugo-blog-with-github-pages/</link><pubDate>Mon, 06 Apr 2026 13:30:00 -0700</pubDate><guid>https://neilmin.com/zh/posts/building-my-hugo-blog-with-github-pages/</guid><description>记录我是怎么用 Hugo、GitHub Pages 和 PaperMod 搭起一个双语博客，并接入 Vercount、Giscus、自定义域名，以及一路排查 404 部署问题的。</description><content:encoded><![CDATA[<p>这件事说白了，其实就是“为了买一盘醋，包了一顿饺子”。</p>
<p>之前我碰到了一个特别冷门、也比较难解决的 Bug。Stack Overflow 和 Google 上都翻不出几条真正有用的东西。最后还是硬靠自己一点一点死磕，才终于把问题解决。</p>
<p>问题搞定以后，我第一反应就是要把这个曲折的踩坑经理记录下来。</p>
<p>因为这种坑如果不记下来，过几个月自己都未必还记得当时是怎么想通的；既然都已经受过这份罪了，不把它整理出来，简直有点对不起自己消耗的脑细胞。</p>
<p>但真正的问题很快就来了：</p>
<p>写完了，发哪儿呢？</p>
<h2 id="真正的起点其实不是我想建个网站">真正的起点，其实不是“我想建个网站”</h2>
<p>我一开始根本没有“我要做个人网站”这种规划。</p>
<p>我的真实想法非常朴素，就是单纯地想找个地方，把那篇 Bug 复盘发出去。</p>
<p>为了这件事，我做了一圈 Research。</p>
<p>国内的平台像博客园、知乎、CSDN，也不是不能发，但总感觉自由度有限，而且听说有些平台不做人，你发上去的内容就不属于你了：一篇本来应该干干净净的技术文章，最后经常会被平台味道包起来，总之不是我想象里那种利利索索的极客博客。</p>
<p>国外的平台像 Medium 或 Blogger，体验确实会轻一点，但本质上还是在别人的地盘上租房子住。你可以装修，但墙不是你的，门牌号也不是你的，哪天平台规则一变，很多东西就不完全由你说了算。</p>
<p>我也不为了最终变现；作为工程师，最后还是很自然地走到了那条老路上：</p>
<p>既然想要 100% 的掌控权，那就自己搭。</p>
<p>而一旦这个念头出来，后面的选择其实就很顺了。静态托管、免费、天然适配 Git 工作流、还能直接跟自己平时的开发习惯接上，基本没有什么比 <a href="https://pages.github.com/">GitHub Pages</a> 更像“数字自留地”了。</p>
<h2 id="既然要自己搭那就顺手搭个像样的">既然要自己搭，那就顺手搭个像样的</h2>
<p>在框架选型上，我一开始也看了几家常见选手。</p>
<ul>
<li><a href="https://hexo.io/">Hexo</a> 背后是 Node.js</li>
<li><a href="https://jekyllrb.com/">Jekyll</a> 是 GitHub Pages 老牌选手，背后是 Ruby</li>
<li><a href="https://gohugo.io/">Hugo</a> 则是 Go 生态里出了名的快</li>
</ul>
<p>经过一番比较，我最终选了 Hugo。</p>
<p>原因非常简单：我看到一个别人的网站，用的就是Hugo，整体是我喜欢的样子，其他technical上的不同，其实在你网站体量做得很小的时候，区别不是非常大。</p>
<p>主题我最后选的是 <a href="https://github.com/adityatelange/hugo-PaperMod">PaperMod</a>。</p>
<p>我挺喜欢它那种克制感：默认样式很干净，没有一上来就把页面做得花里胡哨，文章内容也够靠前。对我这种主要还是想写技术文章、顺便放一点个人页面的人来说，它是个非常好的起点。</p>
<p>如果你也想搭一套一样的，最基础的 Setup 其实就这么几步：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># 1. 安装 Hugo（Mac 环境）</span>
</span></span><span style="display:flex;"><span>brew install hugo
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 2. 创建网站目录</span>
</span></span><span style="display:flex;"><span>hugo new site my-blog
</span></span><span style="display:flex;"><span>cd my-blog
</span></span><span style="display:flex;"><span>git init
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 3. 用 submodule 引入 PaperMod 主题</span>
</span></span><span style="display:flex;"><span>git submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod
</span></span></code></pre></div><p>这里有个很值得专门提醒一下的小坑：<strong>PaperMod 一定要作为 submodule 引进来。</strong></p>
<p>千万别手快直接 <code>git clone</code> 到 <code>themes/</code> 里面，然后以为事情结束了。那样你本地看起来是正常的，但后面一旦 push 到 GitHub，很容易因为嵌套仓库的关系把主题目录当成“空壳”处理掉。最后你人还挺淡定地看着 Actions 跑成功了，网站却开始 404，或者直接缺一半样式。别问我为什么对这个坑印象这么深。</p>
<h2 id="这次印象最深的部分其实是-vibe-coding-真挺好用">这次印象最深的部分，其实是 Vibe Coding 真挺好用</h2>
<p>如果把时间拨回几年前，这种项目虽然不算难，但绝对是那种很容易把整个周末都吃掉的东西。</p>
<p>你得先配主题、改 Hugo 配置、想双语怎么做、想部署怎么做、把 GitHub Actions 跑起来、处理自定义域名、调页面样式、处理评论区、处理统计，再顺手解决几个“明明看起来很小但就是很烦”的前端细节。</p>
<p>现在不一样了。</p>
<p>现在有了 <strong>Vibe Coding</strong>。</p>
<p>我自己这次比较深的感受是：AI 真正帮到你的地方，不是“它替你想好了你要做什么”，而是当你的方向已经很明确的时候，它能把大量本来很机械、很碎、很烦的工作快速铺开。</p>
<p>比如这个站，很多东西其实都不是一次写对的，而是我一边想要什么，一边让 AI 帮我试：</p>
<ul>
<li>双语路由怎么配</li>
<li>中文内容和英文内容怎么配对</li>
<li>语言切换怎么优先跳到当前页面的翻译，而不是傻傻跳首页</li>
<li>首页的背景光晕应该动得多快、多明显</li>
<li>白色主题下颜色不够明显该怎么补</li>
<li>阅读量统计用什么方案，坏了之后怎么换</li>
<li>评论区怎么接、怎么让中英文各自显示对应语言</li>
</ul>
<p>很多地方都是我们来来回回调了好几轮，甚至十几轮。</p>
<p>比如双语这件事，最后我定下来的约定其实非常简单：</p>
<ul>
<li>英文站放在根路径 <code>/</code></li>
<li>中文站放在 <code>/zh/</code></li>
<li>英文内容文件默认不带后缀</li>
<li>中文翻译文件统一用 <code>.zh.md</code></li>
</ul>
<p>也就是像这样：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>content/posts/my-post.md
</span></span><span style="display:flex;"><span>content/posts/my-post.zh.md
</span></span></code></pre></div><p>虽然vibe coding很省事，但做出来东西也不是一蹴而就的，这个网站不少地方并不是“AI 一次性写了个完美答案”，而更像是我在旁边一直盯着方向，然后不断说：</p>
<p>这个不对，再往左一点。</p>
<p>这个太快了，慢一点。</p>
<p>这个颜色不好看，换一组。</p>
<p>这个交互像磁铁，不像我想要的那种“轻轻推开”的感觉。</p>
<p>这也是我现在越来越喜欢这种工作方式的原因。你不需要自己把每一行实现都亲手敲出来，但你仍然在做设计、在做判断、在定方向。AI 更像一个执行力非常强、而且不嫌你烦的搭档。</p>
<h2 id="双语部署域名一个都没忍住">双语、部署、域名，一个都没忍住</h2>
<p>本来最开始我只是想“有个地方发文章就行”。</p>
<p>但既然都搭到这一步了，脑子里那句危险的话还是冒出来了：</p>
<p>都来了，不如顺手再做完整一点。</p>
<p>然后事情就开始失控了。</p>
<h3 id="1-双语支持">1. 双语支持</h3>
<p>因为我本来就有中英文切换表达的需求，所以这个站从一开始就不是单语设计。</p>
<p>现在整个站的结构是：</p>
<ul>
<li>英文站在根路径 <code>/</code></li>
<li>中文站在 <code>/zh/</code></li>
<li>文章、搜索页、简历页都是双语思路去配的</li>
</ul>
<p>技术上看起来只是几行 Hugo 配置，但真正麻烦的其实是那些“边边角角”：菜单要不要双语、搜索页要不要分语言、语言切换是切主页还是切当前页面、评论区语言跟不跟着页面走。这些东西单独看都不大，凑在一起就会决定整个站看起来到底是“真双语”，还是“勉强有两个语言入口”。</p>
<h3 id="2-github-actions-自动部署">2. GitHub Actions 自动部署</h3>
<p>部署这块，我也没想走什么复杂路线，直接交给 GitHub Actions。</p>
<p>现在 <code>.github/workflows/gh-pages.yml</code> 里已经配好了 Pages 部署。日常工作流基本就是：</p>
<ol>
<li>本地改内容或样式</li>
<li><code>git add</code></li>
<li><code>git commit</code></li>
<li><code>git push</code></li>
</ol>
<p>剩下的交给 CI/CD 自己跑。</p>
<p>这种体验有个很危险的副作用：你会越来越想改东西。因为每次改动的发布成本都低得离谱，低到有时候你只是突然觉得一句文案不顺眼，也会忍不住顺手修一下。</p>
<p>而且 Hugo 这套静态站的部署链路确实很舒服：构建就是 <code>hugo --gc --minify</code>，产物全在 <code>public/</code>，GitHub Pages 负责托管，Actions 负责发布，整个过程没有什么多余的“运维戏份”。对主要想写内容、顺便享受一点折腾过程的人来说，这种复杂度非常合适。</p>
<h3 id="3-买域名">3. 买域名</h3>
<p>本来用 <code>neilmin.github.io</code> 其实已经够用了。</p>
<p>但后来我手痒去域名商那里搜了一下，发现买一个完全属于自己的 <code>.com</code> 域名，一年大概也就十来刀。这个价格实在太容易让人心理防线失守了。花不到一顿饭的钱，给自己在互联网上买一块带名字的地，怎么看都挺划算。</p>
<p>于是就直接拿下了 <code>neilmin.com</code>。</p>
<p>说真的，自定义域名这件事对功能没有任何本质提升，但对心情提升极大。你在浏览器里看到那个地址的时候，会很自然地产生一种“这地方真是我自己的”的感觉。</p>
<h2 id="基础跑通之后最容易上头的就是这些小外挂">基础跑通之后，最容易上头的就是这些小外挂</h2>
<p>网站这种东西，一旦主链路打通，后面最上头的部分往往不是“能不能用”，而是“能不能再顺手加一点小细节”。</p>
<p>然后我就没忍住又折腾了几样。</p>
<h3 id="丝滑的动态光晕背景">丝滑的动态光晕背景</h3>
<p>如果你现在是用电脑在看这个网站，首页和搜索页的背景里应该能看到几个会动的发光色块。</p>
<p>这个东西不是图片，也不是视频，纯靠 CSS 和一点 JavaScript 撑起来的。我一开始只是想做个 mesh gradient 一样的氛围背景，后来越调越上头，又把鼠标交互也加了进去。</p>
<p>最搞笑的是，这个背景的“手感”其实来回调了很多轮。最早那版动得太机械，后来又一度像磁铁排斥一样乱窜，最后才慢慢调成现在这种更黏一点、更柔和一点的感觉。中间甚至还专门把交互模型换成了更像“泡泡被轻轻推开”的那种写法，用 <code>lerp</code> 去做缓动，再配上更柔和的衰减曲线。你用鼠标去推的时候，它现在应该更像在轻轻拨开几团有点粘滞感的光，而不是在戳三个会弹飞的球。</p>
<h3 id="阅读量统计从不蒜子到-vercount">阅读量统计：从不蒜子到 Vercount</h3>
<p>一开始很多人都会用“不蒜子”做访问统计，我最开始也试了。</p>
<p>但这玩意儿现在的稳定性确实有点看心情，时不时就掉链子。后来索性换成了 <a href="https://www.vercount.one/">Vercount</a>。</p>
<p>它最让我满意的一点是：<strong>对不蒜子的 DOM 结构几乎是 drop-in replacement。</strong></p>
<p>也就是说，原来那套 <code>busuanzi_*</code> 的 HTML ID 你几乎可以不动，很多时候直接把引入的 JS 换掉就能继续跑。对于这种已经接进页面、但你又不想把模板全部推翻重写的场景来说，真的很省事。</p>
<h3 id="giscus-评论系统">Giscus 评论系统</h3>
<p>评论区这块，我最后接的是 <a href="https://giscus.app/">Giscus</a>。</p>
<p>我挺喜欢它的思路：不用自己再额外养一套数据库，底层直接把评论存到 GitHub Discussions 里。对这种本来就以 GitHub 为中心的小站来说，这种方案非常顺手。</p>
<p>而且它和这个站整体也很搭：</p>
<ul>
<li>不需要单独维护后台</li>
<li>支持暗色模式</li>
<li>可以根据页面语言切换评论组件的界面语言</li>
</ul>
<p>所以现在你在中文文章页看到的是中文评论区，在英文文章页看到的是英文评论区，这种细节统一起来以后，整个体验就会顺很多。</p>
<p>而且说实话，这种“小而完整”的体验，特别容易让人上头。你本来只是想把评论区接上，最后顺手又会想：那它的语言要不要自动跟着页面走？暗黑模式切换时要不要也一起适配？这种事就是典型的，一旦开始较真，就会一路较真下去。</p>
<h2 id="回头看这真的是一顿标准的饺子工程">回头看，这真的是一顿标准的“饺子工程”</h2>
<p>最开始我只是想发一篇技术文章。</p>
<p>结果最后变成了：</p>
<ul>
<li>搭 Hugo</li>
<li>接 PaperMod</li>
<li>配双语</li>
<li>配搜索</li>
<li>配 GitHub Pages</li>
<li>买域名</li>
<li>接统计</li>
<li>接评论区</li>
<li>调背景动画</li>
<li>调交互手感</li>
</ul>
<p>本来只想买一盘醋，结果最后把整顿饺子都包出来了。</p>
<p>但说实话，我还挺喜欢这种过程的。</p>
<p>一方面它确实解决了一个很现实的问题：我终于有了一个完全属于自己的地方，可以安心发那些我自己真的想写的东西。另一方面，它也让我重新感受到了一种很熟悉的快乐，就是工程师那种“既然都做到这儿了，不如顺手再打磨一下”的快乐。</p>
<h2 id="最后">最后</h2>
<p>那篇关于冷门 Bug 的技术文章，现在已经真的发出来了。</p>
<p>而这个网站本身，也算是那篇文章顺手带出来的副产品。</p>
<p>如果你恰好刷到了这篇复盘，欢迎你滑到最下面，用 GitHub 账号登录评论区，给我留一句话。你可以跟我打个招呼，也可以顺手帮我测测这套系统到底稳不稳。</p>
<p>毕竟这个站虽然现在已经挺像样了，但它的出身，说到底还是非常朴素：</p>
<p>我只是想找个地方，好好把一篇值得记下来的文章发出去。</p>
]]></content:encoded></item><item><title>明明还有 20TB 空间，PostgreSQL 为什么还在报 “No space left on device”？</title><link>https://neilmin.com/zh/posts/linux-disk-bug-triage/</link><pubDate>Sun, 05 Apr 2026 12:28:16 -0700</pubDate><guid>https://neilmin.com/zh/posts/linux-disk-bug-triage/</guid><description>一次看似磁盘容量不足的 PostgreSQL 备份失败，最后定位到的根因其实是 EXT4 目录索引在海量微型 Large Object 文件场景下撞上了上限。</description><content:encoded><![CDATA[<p>之前我们碰到过一个特别有意思的 customer issue。客户那边一用 <code>pg_dump</code> 生成 backup file，任务跑着跑着就会直接报错：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>pg_dump: error: could not open output file: No space left on device
</span></span></code></pre></div><p>刚看到这个报错的时候，我心里其实是很放松的。因为这类问题看起来太像标准题了，无非就是磁盘不够大，扩容一下就行。</p>
<h2 id="诡异的开端永远填不满的黑洞">诡异的开端：永远填不满的“黑洞”</h2>
<h3 id="最直接的尝试先加磁盘">最直接的尝试：先加磁盘</h3>
<p>客户的数据库本身就有几个 TB。为了图省事，我们第一步根本没多想，直接把目标备份磁盘扩到 10 TB。一般这种场景下，先看一下磁盘空间：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>df -h
</span></span></code></pre></div><p>我当时真的是抱着“这下总该好了吧”的心态去重新跑 <code>pg_dump</code> 的。结果居然又失败了。</p>
<p>这时候我们还没完全警觉，只是觉得，也许 10 TB 还是不够？于是又继续加，一路加到了 20 多 TB，然后再跑一次。</p>
<p>结果还是同一个 error message，还是失败。</p>
<p>这下就不对劲了。因为这已经不是“磁盘不够大”能解释的问题了。<code>df -h</code> 明明显示还有很多 Free Space，但系统就是执意告诉你：<code>No space left on device</code>。</p>
<p>到这里我基本就确定了，这个报错一定没有字面上看起来那么简单。</p>
<h2 id="顺藤摸瓜潜藏的-2-亿个-large-object">顺藤摸瓜：潜藏的 2 亿个 Large Object</h2>
<p>既然不是硬件资源枯竭，那就只能开始翻数据库本身了。我们先检查数据内容，一开始其实也没发现什么特别奇怪的地方：没有极度膨胀的 TOAST data，也没有什么异常的 highly compressed data。</p>
<p>后来才注意到一个很不寻常的点：这个客户的数据库里有海量的 Large Object。</p>
<h3 id="什么是-postgresql-的-large-object">什么是 PostgreSQL 的 Large Object？</h3>
<p>在 PostgreSQL 中，Large Object，简称 LO，是一种专门用来存储大型数据，比如图片、音频、文档的机制。它提供了一套类似文件读写的 API，例如 <code>open</code>、<code>read</code>、<code>write</code>、<code>seek</code>。</p>
<p>当你使用 <code>pg_dump</code> 并指定 Directory Format，也就是 <code>-Fd</code> 时，<code>pg_dump</code> 会为表结构和数据生成文件，同时还会为数据库中的每一个 Large Object 生成一个独立的 dump file。文件名通常会使用该 LO 的 OID，例如：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>blob_12345.dat
</span></span></code></pre></div><p>正常人的用法一般是存一些很大的 object，但是数量不会太夸张。这个客户刚好反过来：他们存了超过 2 亿个非常小的 Large Object。</p>
<p>问题也就出在这里。<code>pg_dump</code> 显然没有针对这种“数量极大、体积极小”的 LO 使用方式做特殊优化，它还是沿用原本的逻辑，老老实实地给每一个 LO 生成一个独立文件。</p>
<h2 id="本地复现突破-1400-万的结界">本地复现：突破 1400 万的结界</h2>
<p>怀疑是这 2 亿个文件引发的问题之后，我们就在本地尽量模拟客户环境开始测试。</p>
<p>结果很快就复现出来了，而且复现得非常稳定。我们盯着备份进程跑，慢慢发现一个特别关键的线索：每次报错的时候，输出目录下的文件数量都差不多停在 1400 万个左右。</p>
<p>我当时的第一直觉其实就是：是不是 inode 被用完了？</p>
<p>因为在 Linux 里，每个文件都要占用一个 inode。如果文件又多又小，就很容易出现“磁盘容量还没用完，但 inode 先耗尽”的情况。于是我们第一时间查了：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>df -i
</span></span></code></pre></div><p>结果一看，不对。inode 还剩很多。</p>
<p>接下来我们就开始一路排查别的可能性：是不是 container 的限制？是不是进程限制，比如 <code>ulimit -a</code>？是不是某种 system-level resource 被悄悄打满了？</p>
<p>但查来查去，全部都正常。</p>
<p>唯一让我越来越在意的，就是那个 1400 万文件的阈值。每次都像撞到一堵看不见的墙一样，到了这里就死。</p>
<h2 id="真相大白ext4-的-htree-与哈希冲突">真相大白：EXT4 的 HTree 与哈希冲突</h2>
<p>带着这个“1400 万文件结界”，我查了很多资料，也去问了更懂 Linux 文件系统的人，最后终于把 root cause 挖出来了：问题出在 EXT4 文件系统的目录索引机制上。</p>
<h3 id="深入底层ext4-的-htree-限制与-hash-collision">深入底层：EXT4 的 HTree 限制与 Hash Collision</h3>
<p>在 Linux 系统中，目录本质上也是一个文件，里面记录了它所包含的所有子文件名称以及对应的 inode 指针。为了在一个包含数百万文件的目录中快速查找到特定文件，EXT4 默认使用基于哈希的树状结构，也就是 HTree，来给目录建立索引。</p>
<p>然而，传统的 EXT4 HTree 深度是有限的，通常只有 2 层。当我们往同一个文件夹里无脑塞入上千万个文件时，由于文件名生成的 hash 值空间有限，就会开始发生严重的 hash collision。当某个 hash bucket 被填满，而 HTree 又已经达到深度限制、无法继续分裂时，文件系统就会拒绝写入新文件，并向操作系统返回 <code>ENOSPC</code>，也就是 <code>No space left on device</code>。</p>
<p>这就完美解释了为什么我们每次都在 1400 万个文件时崩溃：因为 <code>pg_dump</code> 每次都是按顺序生成文件，文件名又是由 LO ID 决定的，所以命名模式完全一致。也就是说，每次生成到某一类特定名字的时候，它都会走进同一条冲突路径里，然后非常稳定地撞墙。</p>
<p>说实话，查到这里的时候，那种感觉还挺爽的。前面一直像在黑屋子里乱摸，到了这一刻，终于“梆”地一下，所有现象全都能对上了。</p>
<h2 id="破局之道如何解决这个问题">破局之道：如何解决这个问题？</h2>
<p>一旦定位了 root cause，解决起来就有明确方向了。我们可以从三个层面入手。</p>
<h3 id="1-数据库备份层面分离-dump">1. 数据库备份层面：分离 dump</h3>
<p>既然 Directory Format 会把所有 LO 都塞进一个文件夹里，那我们就可以把表数据和 Large Object 分开处理：</p>
<ul>
<li>普通数据依然使用 Directory Format，也就是 <code>-Fd</code>，但排除 LO。</li>
<li>Large Object 单独导出成一个大文件，例如使用 plain SQL 或 custom format，这样就避免生成数以亿计的小文件。</li>
</ul>
<p>也就是说，可以用 <code>--no-blobs</code> 排除大对象，然后单独导出大对象。</p>
<p>当时我们也是选择了这种解决方案.</p>
<h3 id="2-os-文件系统层面开启-large_dir">2. OS 文件系统层面：开启 <code>large_dir</code></h3>
<p>其实现在的 EXT4 已经意识到了这个极端场景，并提供了一个叫 <code>large_dir</code> 的 feature。开启之后，它支持 3 层深度的 HTree，并且允许目录大小突破 2 GB，哈希冲突的概率会大大降低，基本可以消除单目录 1400 万文件这个瓶颈。</p>
<p>可以通过下面的命令为磁盘开启这个能力：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># 开启前先 umount 磁盘</span>
</span></span><span style="display:flex;"><span>umount /dev/sdX
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 开启 large_dir 特性</span>
</span></span><span style="display:flex;"><span>tune2fs -O large_dir /dev/sdX
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 检查文件系统并重新挂载</span>
</span></span><span style="display:flex;"><span>e2fsck -f /dev/sdX
</span></span><span style="display:flex;"><span>mount /dev/sdX /backup_dir
</span></span></code></pre></div><h3 id="3-架构设计层面目录分片">3. 架构设计层面：目录分片</h3>
<p>如果在实际开发中，某些业务确实需要将数千万的实体文件存放在磁盘上，那么把它们全部塞进同一个文件夹本身就是一个非常危险的设计。即使开启了 <code>large_dir</code>，像 <code>ls</code> 这样的命令也可能会非常卡。</p>
<p>业界更标准的做法是：根据文件名的 hash 值或 ID 进行目录分片。</p>
<p>举个例子，如果某个 Large Object 最终的文件名叫：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>1234567.dat
</span></span></code></pre></div><p>我们可以这样切分路径：</p>
<ul>
<li>前两位 <code>12</code> 作为第一层目录</li>
<li>三四位 <code>34</code> 作为第二层目录</li>
</ul>
<p>最终路径就变成：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/backup_dir/12/34/1234567.dat
</span></span></code></pre></div><p>这样做之后，千万级文件会被均匀打散到成千上万个子目录中，每个目录下的文件数都能保持在一个很低的水平，从根源上规避文件系统的性能瓶颈。</p>
<h2 id="最后">最后</h2>
<p>回头看这次排查，我还是会觉得它特别像那种很典型的 debug 过程：一开始你觉得这题太简单了，甚至有点不屑一顾，结果越查越发现事情根本不是你想的那样。</p>
<p>最开始我们真的只是想“给盘加大一点不就好了”。结果从 10 TB 加到 20 多 TB，问题还原地不动地摆在那里。然后你开始怀疑 inode，怀疑 container，怀疑 process limit，怀疑各种 system-level resource。每个方向都查了一遍，每个方向都不像。</p>
<p>最后真正的问题，居然藏在 EXT4 目录索引和哈希冲突这种平时根本不会去想的地方。那一刻其实还挺有意思的，因为前面所有看起来很碎、很乱、很莫名其妙的现象，突然一下子就都连起来了。</p>
<p>所以我后来越来越觉得，排查这种问题最有意思的部分，不只是“把 bug 修掉了”，而是你会被逼着重新理解一些原来觉得理所当然的东西。像这次这个报错就是个很好的例子：<code>No space left on device</code>，听起来特别直接，但它真正表达的，其实可能完全不是你第一眼理解的那个意思。</p>
<p>如果以后你也遇到这种“明明还有空间，却怎么都写不进去”的场景，希望你别急着被 error message 带跑。很多时候，真正的问题，往往都藏得比报错本身深一点。</p>
]]></content:encoded></item></channel></rss>