学数据结构那会儿,老师敲着黑板说:数组随机访问是O(1),链表插入删除是O(1)。当时我信了,直到自己在Sandy Bridge的i7上跑了一组实验,整个人都不好了。遍历一个1亿个int的数组,for循环总共花了80毫秒;而遍历同样数量、用new逐个分配出来的单向链表,竟然跑了1.2秒——整整15倍差距。不要怪编译器,也不全是动态分配的开销,真正的祸根是缓存。CPU缓存一次按64字节拿数据,数组相邻元素正好共享同一个缓存行,每次预取都能用上;链表节点散落在堆里,你访问一个节点就要把那条64字节的线装进来,结果真正用到的只有4个字节。教科书把内存当成纸面上一格一格均匀排布,可现实是有层次、有脾气、有带宽的。
按位置插入的场景更喜剧。链表号称O(1)是站在“已经拿到前驱指针”的假设上,可实际业务里你要先找到那个位置,找的过程本身就是O(n),而且因为节点不连续,这个n被缓存miss系数放大了好几倍。反过来数组插入要移数据,听起来是O(n),但memmove是底层能跑到每秒几十GB的拷贝指令,很短的数据量搬起来眨眼就完事。我在自己的跳表引擎里对比过,16个元素的有序结构里用数组做一次插入调用一次memcpy,和链表的若干个new + 指针操作相比,快得不是一点半点。这说明复杂度分析只是给渐近趋势画一条线,线的斜率在常数因子面前经常是笑话。
所以我认为数据结构的本质不是它的逻辑形态,而是它触达内存的方式。哈希表为什么在很多场景下秒杀平衡树?因为哈希表底层是一块连续的数组,算个哈希函数后直接跳到对应缓存行,连递归都省了。平衡树每次比较都在“追”节点,每追一步都可能触发一次cache miss,这个代价比比较本身昂贵一两个数量级。但链表并没有完全出局,无锁并发队列、死锁检测、撤销栈这些极端场景里,你反而需要它那种天然离散、可以原子操作的节点语义。甚至环形缓冲区看起来像队列,骨子里却是连续的数组。所有线性存储的真相都是:物理连续永远是王者,逻辑连续只是不得已。
最后说一个最近的真实工程。我要把1.2GB的日志文件按key解析进一张大表,先偷懒用了std::unordered_map,结果内存碎片让我目瞪口呆,RSS直接飙到2.5GB,构建耗时41秒。换成自研的开放寻址连续数组哈希表,容量开到2倍负载因子,内存降到1.4GB,构建时间26秒,快了接近40%。这里没有任何奇技淫巧,纯粹是改变数据的存放方式。我越来越觉得,数据结构课真正应该教的不是那几十种形状和时间复杂度,而是教人理解缓存行有多大、内存分配器怎么工作、数据会在哪里生存多久。下次再有人问你数组和链表哪个快,你应该反问一句:你的缓存行、你的分配器、你的顺序访问到底站在哪一边。