☰
动态数组、哈希表、队列与栈:四种基础数据结构的原理与工程实践
2026/10/20 3:51:53 网站建设 项目流程

从面试聊起吧。最近帮团队招人,面了好几位候选人,简历上都写着“熟悉常用数据结构与算法”。我一般会先问一个自认为很简单的问题:一个动态数组,往尾部追加元素,时间复杂度是多少?有人答 O(1),有人答 O(n),也有人愣住说“平均是O(1)吧”。他们都没错,但当我追问“为什么扩容一次要付出 O(n) 的代价,却还能说均摊 O(1)”的时候,能讲清楚的人就少了很多。哈希表、队列、栈也类似,人人都会用,但“为什么这么设计”“什么场景选哪个”“底层到底怎么跑的”这三连问,往往才是区分水平的关键。

这篇我就把自己这些年用集合类数据结构的经验整理出来,围绕动态数组、哈希表、队列、栈这四种最基础的“容器”,讲清楚它们的设计逻辑、核心操作、典型应用,以及我实际踩过的坑。不堆理论,尽量用“人能听懂的话”把底层讲透。适合刚入门想打好基础的同学,也适合工作两三年想回头补内功的开发者。

1. 四个基础集合,为什么必须吃透

1.1 集合类数据结构到底在解决什么问题

你写代码,本质上就是在处理数据:一组用户列表、一批订单、一堆日志、一串消息。数据很少是孤立存在的,绝大多数情况下是一群一群出现的。这时候你就需要一个“容器”把它们装起来,同时支持往里放、往外取、按条件找、按顺序遍历。这类专门管理“一组元素”的数据结构,就是集合类数据结构。

动态数组、哈希表、队列、栈,就是四个最基础的容器。它们解决的问题各不相同:动态数组解决“有序存储、按下标随机访问”的问题;哈希表解决“快速查找、快速去重”的问题;队列解决“先进先出、缓冲削峰”的问题;栈解决“后进先出、回溯嵌套”的问题。

但很多人的问题恰恰在于:只把它们当成“API调用”,不知道每种结构背后的抽象模型。数组就是下标访问,哈希表就是 key-value,队列就是排队,栈就是摞盘子——这种理解太浅了。真正干活的时候,你需要知道:动态数组为什么扩容,扩容会影响什么;哈希表什么情况下会退化;队列满了怎么办;栈除了函数调用还能干什么。

1.2 从零构建动态数组——扩容背后的工程哲学

先说说动态数组。很多语言里叫 ArrayList、List,或者干脆就是默认的数组类型,动态扩容对使用方透明。但“透明”不等于“没有成本”。动态数组的本质仍然是连续内存上的一段空间,只不过比原生数组多了一个“自动扩容”的机制。

底层逻辑不复杂:初始分配一块固定大小的连续内存,比如容量是 8;往里塞元素,塞到 8 个满了,再追加第 9 个时,就重新申请一块更大的内存,把旧数据全部拷贝过去,然后释放旧内存。这块新内存多大?常见策略是扩成原来的 1.5 倍或 2 倍,具体取决于语言实现和内存分配策略。

有人会问,为什么是 1.5 倍或者 2 倍,而不是“每次多分配一个”或者“直接翻十倍”?每次只多分配一个,会导致频繁扩容,每加一个元素都要复制整个数组,追加操作的复杂度退化成 O(n);直接翻十倍,又会让内存浪费很严重。1.5 到 2 倍之间是一个折中:让扩容次数尽量少,同时把空间浪费控制在可接受范围。

那“均摊 O(1)”是怎么回事?我用等比数列求和来算一下:假设初始容量是 1,每次翻倍,那么容量变化是 1、2、4、8、16……如果最终数组里有 n 个元素,扩容的总代价大约是 1+2+4+...+n/2+n,等比数列求和结果是 2n 左右。也就是说,插入 n 个元素的过程中,所有扩容拷贝工作加起来也就 O(n),平摊到每次插入头上就是 O(1)。这就是均摊分析的魅力:单次操作可能很慢,但整体操作序列是高效的。

注意:动态数组在中间插入或删除元素,仍然需要搬移后续元素,这是 O(n) 操作。很多人忽略这一点,以为动态数组什么操作都快,结果在循环里往头部 insert,把 O(n²) 的复杂度写出来,线上数据一大就出事。

2. 哈希表:让查找变成 O(1) 的魔法到底藏在哪里

2.1 哈希函数与散列冲突的基本原理

哈希表是我认为最值得深挖的一个基础结构,因为它不是“理所当然”就能 O(1) 的,背后有数学和工程的双重支撑。核心思想是:把 key 通过一个哈希函数计算出一个整数,再把这个整数映射到数组的某个下标,这样就能直接通过下标访问,不需要逐个比较。

比如有 16 个桶,一个字符串 key 算出来的哈希值是 105102,那么 105102 mod 16 = 14,这个 key 就放在下标 14 的位置。下次查的时候,重新算一遍哈希,直接去下标 14 找,操作次数是常数。

但哈希函数不是完美的。两个不同的 key 可能算出同样的下标,这就是“哈希冲突”(也叫散列碰撞)。冲突如果处理不好,哈希表会从“平均 O(1)”退化成“最坏 O(n)”。最常见的处理方式有两种:开放寻址法和链地址法。

开放寻址法,即冲突了就往下一个空位放,比如线性探测:下标 14 被占了,就试 15、16、17……直到找到空位。链地址法则是每个桶不再存单个元素,而是存一个链表或红黑树,冲突的元素挂在同一条链上。

2.2 哈希表的工程落地——从开放寻址到链地址法

实操中,链地址法更常见,因为实现简单、删除方便、对负载因子不那么敏感。很多主流语言的哈希表实现就是“数组+链表”的变体:初始一个数组,每个位置是一个桶;元素进来后先算下标,挂到对应桶里;当桶里元素太多时,从链表升级成红黑树,避免极端情况下链路过长。

这里有个关键参数叫“负载因子”,等于“已有元素数量 / 桶的总数”。负载因子越高,冲突概率越大,查找效率下降。工程上一般把负载因子控制在 0.75 左右。为什么是 0.75?这是用泊松分布推出来的一个经验值,可以让冲突概率维持在一个很低水平,同时空间利用率又不至于太低。当负载因子超过阈值,哈希表就会触发扩容:重新申请更大的桶数组,把所有元素重新计算哈希并放置到新桶里。这个操作和动态数组扩容一样是 O(n) 的,所以如果数据量突然暴涨,扩容瞬间会有一次明显的耗时。

哈希函数的选择也很讲究。字符串哈希、整数哈希、复合对象哈希,各有各的套路。核心原则是:分布要均匀,计算要快。如果哈希函数设计得差,比如所有 key 都映射到同一个桶,那哈希表直接退化成链表,查询从 O(1) 变成 O(n)。

2.3 真实场景:缓存、去重、索引

哈希表在工程里几乎无处不在。内存缓存是典型场景:传入一个查询条件,算出一个 key,去哈希表里找,找到了直接返回结果,没找到再去数据库查,查完塞回哈希表。这能挡住大量重复的数据库查询。另一个常见场景是去重:日志系统里要过滤重复事件,往哈希表里记一下事件 ID,新事件来了先查一下,存在就丢弃,不存在就插入。再一个是索引:很多数据库的等值查询索引,底层叶子节点是 B+ 树,但内存里的映射层经常用哈希结构做快速定位。

我也见过不少同学自己写工具类,本来一个哈希表就能搞定的去重,非要用两层循环遍历列表,数据量一到千级就明显卡顿。选对数据结构,比“优化代码逻辑”见效快得多。

3. 队列与栈:两种“排队哲学”和它们的禁忌

3.1 栈:后进先出的场景威力

栈这个结构,规则只有一句话:后进先出。所有的操作都发生在栈顶,入栈就是往栈顶放,出栈就是取栈顶元素。它太简单了,简单到很多初学者轻视它。

但实际上,栈是理解“递归”“嵌套”“回溯”这类问题的钥匙。函数调用本身就是用栈实现的:每一次函数调用都会把返回地址、局部变量压入调用栈,函数返回时再弹出。递归调用过深导致栈溢出,就是因为调用栈被压满了。浏览器的后退按钮也是栈:每访问一个新页面就入栈,点后退就是出栈。编辑器里的 Undo 也是栈:每一次操作记录入栈,撤销时从栈顶弹出。

括号匹配的经典问题也能用栈优雅解决:遇到左括号就入栈,遇到右括号就弹出栈顶检查类型是否匹配,遍历完以后栈为空说明括号是配对的。这类题眼熟吧?但面试考它并不是为了刁难人,而是在考察你能否识别出“嵌套结构”与“栈”之间的关系。

3.2 队列:先进先出的工程价值

队列的规则正好反过来:先进先出。先到的先处理,后来的排队等。这个模型在工程里太重要了。消息队列、任务队列、网络请求缓冲、打印任务排队,全是队列的典型应用。

为什么需要队列?因为“生产者”和“消费者”往往速度不一致。用户疯狂下订单,订单处理系统能力有限,如果直接把请求怼给处理器,处理器会过载甚至崩溃。中间加一个队列,生产者把订单放进去,消费者按自己的节奏慢慢处理,这就是削峰填谷,也是消息队列最基础的价值。

队列的实现有几个层级。最简单的可以基于动态数组或链表写;生产环境里常用“循环队列”:让数组的首尾相连,避免频繁搬移数据,让入队出队都保持在 O(1)。比如一个容量为 8 的循环队列,队尾指针到 7 之后如果队头已经出队了,队尾可以回到 0 继续写,而不是把整个数组往前搬。

队列还有一个变体叫优先队列。它不严格按先进先出,而是按“优先级”出队。实现优先队列最常用的数据结构是堆,但逻辑上依然是一个队列模型:进去的元素不保证顺序,出来时优先级最高的先出。这在任务调度、Dijkstra 最短路径算法里都有应用。

3.3 双端队列与单调栈的变体

常被忽略的两个变体:双端队列和单调栈。双端队列(Deque)允许在头尾两端都进行插入和删除,灵活性比普通队列高很多。滑动窗口最大值这类问题,最经典的解法就是维护一个双端队列,队头是当前窗口最大值,新元素进来时从队尾弹出比它小的元素,窗口滑动时从队头移除过期元素。

单调栈则是单调递增或单调递减的栈结构。它的典型应用是“找下一个更大元素”:给定一个数组,对每个元素求右边第一个比它大的元素。暴力做法是 O(n²),用单调栈只要 O(n)。原理简单说就是维护一个栈,让栈内元素保持单调;新元素来的时候,不断弹出栈顶比它小的元素,弹出去的元素就找到了它的“下一个更大元素”。这种结构第一次接触会觉得绕,但写熟之后再看很多算法题,会觉得非常爽。

4. 实操:用代码把四种结构跑起来

4.1 动态数组的实现与扩容调试

为了说明白底层,我用类 Python 的语法写一个极简动态数组。先不依赖语言内置的 List 特性,用固定数组来模拟:

class SimpleArray: def __init__(self): self.capacity = 4 self.size = 0 self.data = [None] * self.capacity def push(self, value): if self.size == self.capacity: self._resize(self.capacity * 2) self.data[self.size] = value self.size += 1 def _resize(self, new_capacity): new_data = [None] * new_capacity for i in range(self.size): new_data[i] = self.data[i] self.data = new_data self.capacity = new_capacity def pop(self): if self.size == 0: raise IndexError("pop from empty array") self.size -= 1 return self.data[self.size] def get(self, index): if index < 0 or index >= self.size: raise IndexError("index out of range") return self.data[index]

你自己跑一遍就能观察到:到第 4 次 push 时容量满了,触发扩容到 8;到第 8 次 push 时再扩容到 16。如果你在扩容函数里打个日志,每次触发扩容都会打印一次,这时候你会直观感受到为什么“尾部追加均摊 O(1)”——扩容偶尔会痛一次,但大部分时候 push 就是一次赋值加一个指针移动。

调试动态数组最常见的坑是:缩容。很多人实现了扩容,但忘了缩容。如果一个大数组被撑到 1 百万容量,然后大部分元素被删掉,只剩下 10 个,容量还留在 1 百万,纯属浪费内存。但缩容也不能太激进,否则在临界点来回插入删除会导致频繁扩容缩容,性能抖动。工程上的做法是:当 size 降到容量的 1/4 时才缩容到一半,留出缓冲区间。

4.2 哈希表实现中的坑

哈希表实现里,我遇到过的坑比动态数组多得多。第一个坑是哈希函数的性能。如果你的 key 是一个很长的字符串,每一次 get、put 都要重新遍历整个字符串计算哈希,这个成本可能超过你的想象。解决办法是“缓存哈希值”:字符串对象内部保存一个字段记录已算出的哈希,算过一次之后再也不重算。

第二个坑是哈希冲突的“最坏情况”。正常情况下哈希分布挺均匀,但如果 key 是用户可控的,比如 HTTP 参数名,攻击者就可能构造出一批哈希值相同的 key,导致所有数据挂到同一个桶里,哈希表直接退化成链表。这种攻击在业界叫哈希碰撞拒绝服务攻击。防御手段一般是:随机化哈希种子、限制单个桶大小、超阈值自动升级成红黑树。这件事说明一个道理:数据结构不是“设计出来就完事”,还得考虑边界和安全性。

第三个坑是扩容时的 rehash。扩容不只是“把数组变大”,而是要把所有元素按新桶数重新计算下标。如果扩容过程中有新线程同时读写,就可能出现元素丢失或者短暂查不到。生产级哈希表会做渐进式 rehash:扩容期间,新元素按新规则插入,旧元素按批次慢慢搬移,读取时先查新表再查旧表。这是一个非常经典的工程优化,面试聊到“哈希表并发问题”时很能体现水平。

下面是一个简化版的哈希表示例,用链地址法处理冲突:

class SimpleHashMap: def __init__(self, capacity=8): self.capacity = capacity self.size = 0 self.buckets = [[] for _ in range(capacity)] def _hash(self, key): return hash(key) % self.capacity def put(self, key, value): idx = self._hash(key) bucket = self.buckets[idx] for i, (k, v) in enumerate(bucket): if k == key: bucket[i] = (key, value) return bucket.append((key, value)) self.size += 1 if self.size > self.capacity * 0.75: self._resize(self.capacity * 2) def get(self, key): idx = self._hash(key) bucket = self.buckets[idx] for k, v in bucket: if k == key: return v raise KeyError(key) def _resize(self, new_capacity): old_buckets = self.buckets self.capacity = new_capacity self.buckets = [[] for _ in range(new_capacity)] self.size = 0 for bucket in old_buckets: for k, v in bucket: self.put(k, v)

这个代码能工作,但不适合上生产环境。为什么?因为hash(key)对同一个 key 每次都重算,字符串特别长时性能堪忧;同时_resize是全部搬移的,数据量大时有明显停顿。你理解了这两点,再去看语言自带的 HashMap 实现,就会明白为什么它们要加缓存、渐进式搬移了。

4.3 栈与队列的相互实现

栈和队列虽然规则不同,但在工程上有个近乎“魔术”的事实:栈可以用两个队列实现,队列也可以用两个栈实现。这个结论乍一看很反直觉,但原理并不复杂。

用两个栈实现队列的思路是:入队时把元素压入栈 A;出队时,如果栈 B 为空,就把栈 A 的所有元素依次弹出并压入栈 B,然后从栈 B 弹出栈顶。这个过程本质上是做了一次“倒序”,让最先进栈 A 的元素最后变成栈 B 的栈顶,实现先进先出。均摊复杂度是 O(1),因为每个元素最多被倒腾两次。

反过来,用两个队列实现栈的核心思路是:入栈时把元素放进队 A,但要保证队尾元素是“最后入栈的”,出栈时直接把队尾拿出来。做法是把队 A 中除了最后一个元素之外的全部元素搬到队 B,然后队 A 剩下的那个元素就是栈顶,出栈后交换队 A 和队 B 的角色。

这一类“用 A 结构实现 B 结构”的题目,本质上是在考察你对两种结构操作特征的理解。很多人背答案,背完就忘。我建议你亲手实现一遍,跑几个用例,你会真正理解“操作顺序”四个字的分量。

5. 常见问题与排查技巧实录

5.1 扩容导致的性能抖动怎么办

我之前维护过一个数据接口服务,平时延迟稳定在 10 毫秒以内,但每过一段时间就会出现一次 200 毫秒以上的尖刺。一开始以为是垃圾回收问题,后来加了监控才发现,尖刺总是出现在一个动态数组扩容的瞬间。

原因不复杂:这个数组保存了一个业务对象的缓存列表,数据量缓慢增长,容量到了阈值就要扩容,每次扩容要复制整个数组,几万美元个对象,一次拷贝就是上百毫秒。排查思路是先定位最耗时的函数,再分析是不是扩容导致。解决方案有几条路子:预估容量,初始化时直接给一个足够大的容量,避免中途扩容;或者换成链式结构,用链表代替动态数组,牺牲随机访问,换掉扩容的批量拷贝;再或者做分批搬移,学哈希表渐进式 rehash 的思路。

这个案例启发了我一个很重要的工程经验:任何“均摊 O(1)”的数据结构,都必须对“单次最坏延迟”有心理预期。你要评估自己的场景到底在乎平均延迟还是最大延迟。实时系统在乎最大延迟,那么动态数组的翻倍扩容就是不可接受的。离线批处理系统在乎吞吐量,扩容的偶尔抖动就无所谓。

5.2 哈希碰撞引发的“事故”

有段时间我负责一个接口,会根据一批 ID 批量查询数据,内部逻辑是把 ID 作为 key 存进哈希表,再把结果按 ID 顺序返回。测试环境一切正常,上线后某天突然发现,有几个 ID 的查询特别慢。

排查发现,这批 ID 的哈希值对桶数的取模结果高度集中,造成大量元素挤在同一个桶里,链表遍历非常长。根本原因是数据分布本来就不均匀,加上桶数设计得不够合理。解决办法是两方面的:一方面改进哈希函数,让这些 ID 尽量分散;另一方面把负载因子阈值调低,提前扩容,降低冲突概率。哈希表最怕的就是“分布不均匀”,你要学会看监控里每个桶的长度分布,而不是只看平均长度。

5.3 栈溢出与队列积压的排查思路

栈溢出这个问题,多见于递归调用过深或者无限递归。排查时最直接的手段是看调用栈:如果发现某两三个函数反复互相调用,那就是写了一个没有终止条件的递归;如果递归深度确实很大但逻辑正确,那就得考虑把递归改成循环,或者用显式栈模拟递归。我在实际项目里就把一段深度可能达到几万层的递归改成了栈加循环,效果立竿见影。

队列积压则是另一类问题。消息队列里堆积了大量消息,消费者处理不过来。排查先看生产速率和消费速率,再看有没有慢消费者阻塞,比如消费逻辑里做了耗时的数据库操作,把整个消费线程卡住了。还有一种隐蔽情况:消费失败的消息被反复重新投递,形成死信循环,不断挤占队列。应对思路一般是:增加消费者实例数量、优化消费逻辑、设置最大重试次数并转入死信队列。

这些问题的共性是什么?都要你先对数据结构的行为特征有准确预期。数组慢一下,你要知道是扩容;哈希慢一下,你要想到冲突;队列堆积,你要分清是生产太快还是消费太慢。

5.4 一个通用排查工具:复杂度意识

说了这么多,最想强调的还是复杂度意识。很多线上问题不是 bug,而是“数据结构选型不当”导致的问题。一个简单的判断标准:如果数据量从一万涨到一百万,你的接口耗时是涨一百倍还是只涨一点?如果是涨一百倍,且算法是 O(n²) 的,那不管怎么优化代码细节都救不回来,必须换结构。

举个简单例子:一段文本去重。用动态数组存已经出现过的单词,每来一个新单词就遍历数组查找,数组长度为 n 时新单词的插入操作是 O(n),总复杂度 O(n²)。一万个单词还好,十万个就出问题了。换成哈希表,插入和查找都是 O(1),总复杂度变成 O(n),数据量翻十倍耗时基本只翻十倍甚至更低。数据结构选对了,很多事情事半功倍。

6. 几条关于学习路径的实用建议

最后分享几个我自己的体会,不针对新手也不针对老手,就看你现在想解决什么问题。

第一,不要死记“XX 数据结构适合 XX 场景”的对照表。动态数组不是只能按下标访问,哈希表不是只能做键值缓存,队列不是只能做消息队列。你要理解的是每种结构的核心操作是 O(1) 还是 O(n),内存上是连续还是离散,顺序上是有序还是无序。这三个维度想清楚,选型自然就对了。

第二,刷题没必要追求数量,但把“数组、链表、哈希表、栈、队列”相关的基础题吃透,非常有必要。这么做的好处是:你会逐渐形成一种直觉,看到问题就知道该用哪种结构打底。比如看到“嵌套”想栈,看到“时序处理”想队列,看到“快速查找”想哈希,看到“随机访问”想动态数组。这种直觉不是背出来的,是练出来的。

第三,生产环境里不要自己造轮子,除非你真的理解轮子的全部细节。语言自带的 HashMap、队列实现,经过百万级场景验证,比自己写的可靠得多。但学习阶段一定要自己造一遍,因为只有亲手写一次,你才会知道哈希函数怎么设计、负载因子怎么选、扩容要处理哪些边界条件。自己造轮子的目的是“理解”,而不是“替换”。

第四,多关注数据结构的“最坏情况”和“边界条件”。动态数组满容量了怎么办?哈希表冲突严重了怎么办?队列满元素了怎么办?栈为空时出栈怎么办?这些边界处理往往才是工程里最重要的部分。在线系统出问题,基本都出在边界上,而不是主流程上。

我自己的一个习惯是:每接触一个新业务场景,先停下来问自己一句,这里的数据是“堆积”“排队”“配对”还是“索引”?堆积优先想动态数组,排队优先想队列,配对回溯想栈,索引和去重想哈希表。这套朴素分类法,帮我少写了很多冤枉代码。希望这些内容对你也有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询