Code Tricks
平时读源码和博客时遇到的一些小而妙的代码技巧合集
这是一个用来记录各种小技巧的合集。
平时读源码、翻博客、刷推特的时候,经常会碰到一些很小但很妙的写法。它们大多不值得单独写一篇长文,但又确实值得记下来,免得下次遇到又得从头想一遍。于是我把它们收集在这里,每个技巧作为一篇 sub blog。
每篇都尽量简单:之前的写法差在哪,这个写法好在哪,配一小段能看懂的代码就够了。
代码统一用 TypeScript,重点在思路,语言只是载体。
平时读源码和博客时遇到的一些小而妙的代码技巧合集
这是一个用来记录各种小技巧的合集。
平时读源码、翻博客、刷推特的时候,经常会碰到一些很小但很妙的写法。它们大多不值得单独写一篇长文,但又确实值得记下来,免得下次遇到又得从头想一遍。于是我把它们收集在这里,每个技巧作为一篇 sub blog。
每篇都尽量简单:之前的写法差在哪,这个写法好在哪,配一小段能看懂的代码就够了。
代码统一用 TypeScript,重点在思路,语言只是载体。
把二叉堆 sift-down 里选孩子的不可预测分支换成一次加法
二叉堆的核心操作就一个:sift-down(下沉)——把一个节点顺着树往下挪到它该待的位置。堆排序、top-k、k 路归并全靠它,是个实打实的热循环。
分享一个小技巧:sift-down 里“选哪个孩子往下走”会引入一个分支预测器很难猜准的分支,用一点小算术就能把它干掉。(代码里的 less(a, b) 就是比较函数,a 该排在 b 前面就返回 true。)
sift-down 每往下一层,都要先在两个孩子里选出更小的那个(min-heap),再拿它和父节点比。朴素写法长这样:
function siftDown<T>(heap: T[], pos: number, less: (a: T, b: T) => boolean): void { const n = heap.length; while (true) { let child = 2 * pos + 1; // 左孩子 if (child >= n) return; // 没有孩子了
// 在左右孩子里选更小的:这一步基本是 50/50,预测器猜不准 if (child + 1 < n && less(heap[child + 1], heap[child])) { child += 1; }
if (!less(heap[child], heap[pos])) return; // 已满足堆性质 [heap[pos], heap[child]] = [heap[child], heap[pos]]; pos = child; }}问题就在高亮那几行:“左右哪个孩子更小”这个比较结果几乎是随机的,分支预测器命中率接近一半。而 sift-down 是排序 / 归并里的热循环,每一次 mispredict 都要清空流水线,白白搭上十几个周期。
改进的写法靠两点把这个分支干掉:
child + 1 < n,这样循环体里两个孩子一定都在,不用再判断“有没有右孩子”。child += less(右, 左) ? 1 : 0。右孩子更小就 +1 走到右边,否则 +0 留在左边。一次加法代替一次跳转。function siftDown<T>(heap: T[], pos: number, less: (a: T, b: T) => boolean): void { const n = heap.length; let child = 2 * pos + 1;
// 不变量:右孩子一定存在,循环体里不再判断“有没有右孩子” while (child + 1 < n) { // 无分支选更小的孩子:右 < 左 → +1 指向右孩子,否则 +0 留在左孩子 child += less(heap[child + 1], heap[child]) ? 1 : 0;
if (!less(heap[child], heap[pos])) return; // 已满足堆性质 [heap[pos], heap[child]] = [heap[child], heap[pos]]; pos = child; child = 2 * pos + 1; }
// 收尾:堆大小为偶数时,最后一个父节点只有左孩子,循环覆盖不到,单独补一次 if (child + 1 === n && less(heap[child], heap[pos])) { [heap[pos], heap[child]] = [heap[child], heap[pos]]; }}less,谁用谁传——要 min 要 max、按哪个字段排都行,编译器也容易把它内联进去。