ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据结构入门:从增删改查效率到真实场景选型指南

数据结构入门:从增删改查效率到真实场景选型指南

1. 数据结构到底在解决什么问题

1.1 先从一个真实场景说起

我刚开始写代码那几年,一直觉得数据结构这门课很抽象。链表、二叉树、哈希表,名字听起来一个比一个唬人,但当时我的真实感受是:我又不考研,学这些到底能干嘛?直到有一天,我写了一个用户信息查询功能,数据量从一万涨到一百万的时候,程序响应从“瞬间”变成了“肉眼可见的卡顿”。旁边有经验的同事看了一眼,只说了句“你把这里的数组换成一个哈希表试试”,改完以后速度立刻恢复了。那一刻我才反应过来,原来数据结构不是一门需要背定义的课程,它本质上解决的是一个非常朴素的问题:在数据量大起来之后,如何让增删改查依旧快得让人舒服。

数据结构系列的第一篇,我想先把最基础、也最容易被忽略的问题讲清楚:数据结构是什么、它在解决什么、以及为什么初学者总是学了一堆结构,遇到实际问题还是不知道怎么选。这个系列不会堆定义,而是用我踩过的坑和实际项目里的思考,把每个结构掰开揉碎。

1.2 数据是“乱”的,结构是“理”出来的

很多人误以为程序里的数据天生就是整齐、易于访问的。但实际上,内存只是一片连续编号的存储空间,它本身没有任何“组织性”。你的用户列表、订单记录、日志数据,在内存里不过是一堆字节。谁先被放进某个地址、谁后又紧挨着谁,基本都是写代码的人决定的。

打个比方:你有一屋子书,书的数量少的时候,随便堆在桌上也能找到。当书多到上千本的时候,如果还是随手乱放,找一本书就等于灾难。图书馆里为什么要有分类号、书架编号、检索目录?因为数据量一大,无序的存储会让“查找”变成一件低效到无法接受的事情。数据结构就是那套“分类号 + 书架 + 目录”,它的作用不是改变数据本身,而是给数据设计一套组织规则,让后续的存取变得可控、可预测。

程序里所有对数据的操作,最终都能归纳成四类基本动作:插入、删除、查找、修改。你写一个通讯录功能,本质是插入联系人、删除联系人、查找联系人、修改联系人。你写一个订单系统,也一样。数据结构之间的差距,就在于它对这四个动作的“响应速度”完全不同。有些结构极其擅长查找但讨厌插入,有些结构天生适合两头操作但对中间元素无动于衷,有的结构牺牲空间换取时间,有的则反过来。

所以你问我数据结构在解决什么问题,我的答案是三个字:效率差。它用一套明确的组织规则,回答了同一个操作在同样的数据量下,凭什么别人的程序跑 1 毫秒,你的程序要跑 1 秒钟。理解了这一点,再看后面的每一种结构,你就不容易陷入“定义背得滚瓜烂熟,但不知道何时用”的尴尬。

1.3 访问方式决定了结构的价值

不同场景下的数据处理,对操作的“偏重”是完全不一样的。有人只需要按顺序把数据读一遍,有人需要随时跳转到任意位置,有人只关心最新进来的那条,有人则要求按某种规则快速定位。

我举个最简单的例子。数组在内存里是一段连续空间,所以它天生支持“随机访问”——你想拿第 5 个元素,直接按下标算个地址就取出来了,复杂度是 O(1)。但链表不一样,它的节点分散在内存各处,靠指针串在一起。你想知道第 5 个元素是谁,只能从头节点开始一个一个往后“跳”,复杂度是 O(n)。看起来链表很吃亏对吧?但链表的优点是插入和删除只需要改前后两个指针,而数组要做插入和删除,必须把后面所有元素逐个搬移。

这两种结构没有绝对的优劣,只有“谁更适合当前场景”。如果你的核心需求是高频随机读取,数组是显然的选择;如果你的核心需求是高频插入删除,链表反而更合适。数据结构入门最关键的思维转变,就是从“背出这个结构”变成“看穿这个结构的代价”。后面我们会反复使用这个视角。

2. 入门必须先分清的三组概念

2.1 逻辑结构与存储结构,是两件不同的事

新手最容易混淆的第一对概念就是“逻辑结构”和“存储结构”。我见过很多人把“栈”理解成一种内存里的特定摆法,一听到“栈”就以为必须用某个固定结构实现,结果一写代码就卡住。

其实逻辑结构指的是“数据之间的抽象关系”,它只关心你是怎么看待这些数据的:它们是一字排开的,还是上下嵌套的,还是像树一样分叉的,还是像社交网络一样互相连接的。至于底层的存储方式,数组也好、链表也好、内存池也好,都只是实现逻辑结构的手段,属于存储结构的范畴。

举个例子,栈的逻辑结构就是“后进先出”:你最后放进去的东西,最先被拿出来。但它底层的物理存储可以是连续的一段数组,也可以是一串分散的链表节点。用数组实现的栈,入栈出栈都发生数组尾部;用链表实现的栈,入栈出栈发生在链表头部。两者的对外表现完全相同——“后进先出”这个规则没有变。区别只在于,数组版可能更省内存,链表版可能在频繁扩容时更稳定。

所以判断一个人数据结构入门没有,我习惯看他能不能说清楚这句话:“逻辑结构决定行为,存储结构决定性能。”如果你能围绕这个原则理解每一种结构,后面很多疑惑比如为什么哈希表会扩容、为什么二叉树要分平衡实现,都会迎刃而解。

2.2 抽象数据类型与具体实现的边界

第二组概念是抽象与实现。什么叫抽象数据类型?你可以把它理解成一份“功能说明书”,里面描述了这种数据类型支持哪些操作、每个操作有什么效果。但说明书不关心内部是怎么实现的。

以列表为例。很多编程语言都提供了列表这种类型,但你今天用一个数组实现它,明天换成链表实现它,调用方完全感觉不到变化。因为调用方依赖的是接口:往第几个位置插入、读取某个位置的元素、获取长度。至于底层是连续内存还是分散节点,调用方不关心,也不应该关心。

这种“面向抽象编程”的思路,我建议所有新手一开始就养成。写代码时,让业务逻辑依赖接口,而不是依赖具体实现。因为一旦你将来发现性能瓶颈需要换实现方式时,抽象层能帮你把改动范围控制在最小。我有次在公司里重构一个缓存模块,就是把具体的数据结构实现替换成另一种,但所有调用方代码一行没改,靠的就是平时坚持面向接口编程的习惯。

2.3 复杂度:别用感觉衡量效率,用数学

聊数据结构,绕不开时间复杂度和空间复杂度。但我不建议你在入门阶段就去抠形式化定义,更推荐先建立一个直观理解:大 O 记号描述的是,当数据量 n 变得很大时,你的操作次数大概按照什么速度增长。

我做一个非常朴素的类比。O(1) 相当于你去楼下便利店拿一瓶水,不管你平日里囤了多少瓶,时间基本恒定。O(n) 相当于你在一条长队里从头走到尾找人,人数翻倍,时间就翻倍。O(n²) 相当于你参加一个聚会,每个人都要和其他所有人分别握手,人数翻倍,握手次数翻四倍。O(log n) 则像查字典:你每次翻一半范围,数据量再大,也多不了几次翻页。

操作数组链表
按下标 / 按节点访问O(1)O(n)
头部插入 / 删除O(n)O(1)
尾部插入 / 删除(已知尾指针)O(1)O(1)
中间插入 / 删除(已知目标位置)O(n)O(1)
查找某个值O(n)O(n)

这张表是整个数据结构课程里最值得反复回看的内容之一。很多面试题,你说到底就是在问这张表的变体。能脱口而出这张表,才算真正开始“会选”数据结构,而不是只会“认识”数据结构。

3. 把常用数据结构当“容器”逐一认识

3.1 数组:最简单的顺序控制

数组是所有数据结构里最直白的一种:一段连续内存,一组同类型的元素,按下标访问。它唯一的隐藏优势是地址计算——给定起始地址和下标,一步就能算出目标元素的地址,这就是为什么它的随机访问是 O(1)。

数组的短板同样明显。第一,长度固定,动态扩容需要整体搬到更大的空间。第二,中间插入和删除需要大量搬移元素。这意味着你每在数组头部插一条数据,后面所有元素都得集体往后退一位。

实际开发中,数组适合什么?适合那些逻辑简单、需要高频随机读取、并且数据量相对可控的场景。比如静态配置表、定长缓冲区、固定数量的传感器读数。如果你不确定数据规模会不会暴涨,并且不确定要不要频繁在中间改数据,那要慎重。

3.2 链表:把“碎片”组织成链条

链表是对数组短板的一种修正。它的每个节点既存数据,又存指向下一个节点的指针。节点之间不需要物理上连在一起,逻辑上却能形成一条完整的链条。

链表最大的优点是插入和删除只涉及相邻节点的指针操作,不需要搬移其他元素。只要你手里已经有目标位置的指针,插入和删除都是 O(1)。但它放弃了随机访问,你要找某个位置的节点,只能从头部一路遍历,代价是 O(n)。

初学者容易踩的坑,是觉得链表“什么都好”。我用过几次之后才发现,链表在真实项目里有不少隐形代价:每个节点需要额外存指针,内存开销更大;因为节点分散,CPU 缓存命中率不如连续数组;而且链表的很多收益都建立在“你已经拿到了某个节点的指针”这个前提上,而这个前提在不少场景里并不成立。所以链表更像是一种“特定场景下的好工具”,而不是“万能的替代品”。

3.3 栈与队列:两种“规则受限”的线性表

栈和队列本质上都是线性表,但给访问规则加了限制。栈只允许在一端操作,规则叫“后进先出”。队列也只允许在一端插入、另一端删除,规则叫“先进先出”。

这两个结构最大的特点,就是规则极其简单。但也正因为简单,它们在真实系统里无处不在。栈在代码里的典型用途包括:函数调用的调用栈、浏览器的前进后退、编辑器的撤销重做、表达式的求值。队列的典型用途包括:打印机任务排队、消息队列里的待处理请求、广度优先搜索里待访问节点的管理。

我入门时有一个很长时间的误区,总觉得栈和队列太简单了,不算正经的数据结构。后来工作里写一个消息中间件的接入层,发现合理使用队列能把“突发尖峰流量”转化为“平滑的按序处理”,这才意识到,简单的结构只要用对地方,价值未必输给复杂的结构。它给你的确定性,有时候比性能更重要。

3.4 哈希表:用空间换时间的经典

哈希表(也叫散列表)是所有容器结构里,对“查找”这个动作优化到极致的一种方案。它的原理是:通过一个哈希函数,把元素的“键”映射成“数组下标”,然后直接到那个位置存取数据。理想情况下,插入、删除、查找都是 O(1)。

这里最值得理解的概念是“哈希冲突”。不同的键经哈希函数计算后可能映射到同一个下标,这就是冲突。解决冲突的常见办法之一是链地址法:同一个下标下面挂一个链表或一棵树,冲突元素都挂在这个位置。极端情况下如果哈希函数设计糟糕,冲突太多,哈希表会退化成一个长链表,查找复杂度直接变成 O(n)。

真实项目里选哈希表的场景非常多:字典、缓存、去重、索引、配置查找。但你要记住它“以空间换时间”的本质——哈希表内部往往会预留大量空槽位来减少冲突,这意味着它占用的内存,通常比单纯存数据要多得多。数据量小的时候,用哈希表可能是浪费;数据量一大,它的速度优势才真正体现出来。

3.5 树与图:从线性走向分层和网络

如果数据之间不是“一个个排开”的关系,而是有层级、有分支的呢?这时候要用树。树结构里有一个根节点,往下不断分叉,每个节点可以有多个子节点。最常用的是二叉树,以及它的优化版本——平衡二叉树、堆等。树的作用是表达“层级关系”和“有序关系”。比如组织架构、文件目录、带优先级的任务调度,都能用树来建模。

堆是树的一种特殊形态。它保证每个父节点大于(或小于)所有子节点,所以根节点永远是全局最大(或最小)值。这个特性让堆非常擅长处理“每次只关心最值”的场景,比如优先级队列、定时任务、Top-K 问题。你不需要把所有元素排成严格递增的序列,只需要在需要的时候能立刻拿到最值,堆就足够了。

图则更自由,节点之间可以有任意多条边,表达的是“多对多”的网络关系。社交网络里的好友关系、地图里的路线规划、依赖关系里的任务调度,都需要图结构。图的遍历算法(深度优先和广度优先)和最短路径算法,是很多高级面试题的基础。

树和图刚接触时容易让人觉得复杂。我建议入门阶段不要追求一次掌握所有变体,先吃透“树的遍历”和“图的两种遍历方式”就够了。遍历是一切树图操作的地基,后续什么平衡旋转、什么最短路径、什么红黑树,都建立在“你能熟练遍历”这个前提上。

4. 面对实际问题,怎么选数据结构

4.1 先画出你的“操作画像”

很多新人问我,数据结构这么多,实际项目里怎么选?我的习惯是,先不急着套某个具体结构,而是先回答三个问题:

第一,数据量有多大。几十条和几百万条,是两种完全不同的思维。几十条数据用什么都差不多,这时候选最简单、最好读的;几百万条才开始认真谈复杂度。

第二,最频繁的操作是哪一种。是读取多,还是写入多?是随机跳转,还是顺序遍历?是只在两端操作,还是经常在中间动?回答完这个问题,就已经能划掉一半候选结构。

第三,对空间是否敏感。哈希表很快,但占内存;数组紧凑,但扩展麻烦;链表灵活,但每个节点都带着指针的额外开销。如果空间非常紧张,有些结构就算快,也不一定适合。

这三步走完,你通常只剩一两个备选项。然后再去考虑实现复杂度、代码可维护性、团队成员熟悉程度这些软性指标,最终敲定方案。

4.2 一个完整案例:实现“最近浏览记录”

我拿一个非常常见的产品功能来演示选型思路:记录用户最近浏览的若干商品或文章,保留最近 10 条,新的进来、最旧的淘汰。

你可以先尝试用数组实现。每次新数据进来,把它插入数组头部,然后把尾部那个挤掉。听起来没问题,但数组头部插入需要把所有元素后移一位。如果这个功能每秒被触发很多次,这个搬移成本就会累积起来,造成不必要的性能损耗。另一种做法是直接在尾部追加,但那样“最近浏览”的顺序就不对了,最后显示时还得整体翻转或重新排序,依旧不优雅。

用链表实现呢?新数据插入头部只需改一个头指针,挤掉尾节点只需让倒数第二个节点指向空。这样每次操作的复杂度都是 O(1),不需要搬移任何中间元素。但链表有个问题:有时候你需要快速判断“这个商品用户最近是否已经浏览过”,如果靠遍历链表找,最坏情况得扫完 10 个节点。浏览记录里总量很小,这个开销尚可接受。但如果你把这个“最近记录”的容量放大到几百万条,遍历就变得难以接受了。

所以真实工程里的做法,往往是组合多个结构。比如用一个哈希表记录“商品 ID → 节点指针”,再用一个双向链表维护访问顺序。哈希表负责快速判断某条记录是否存在,双向链表负责高效调整顺序和淘汰最旧记录。这其实就是 LRU 缓存淘汰算法的经典实现思路。数据结构选型最精彩的地方就在这里——它往往不是“二选一”,而是把不同结构组合起来,让每个结构都干它最擅长的事。

4.3 一个可参考的选型速查表

平时做技术方案,我基本按下面这张表来快速定方向,你可以把它当成一个起点:

场景优先考虑的结构说明
数据量固定,高频按下标读取数组连续内存,随机访问最快
数据量多变,频繁在中间插入删除链表改指针即可,不用搬移
只需要最近的数据,旧的无关紧要栈天然后进先出
先到先处理,按顺序消费队列天然先进先出
按键精确查找,时间优先,空间不敏感哈希表均摊 O(1) 查找
需要有序遍历、范围查找平衡树有序且可范围查询
每次只关心最大值或最小值堆最值取用 O(1)
节点之间关系复杂、多对多图用图和遍历算法建模
需要快速判断元素是否出现过哈希表去重的天然方案

这张表不是万能的,但可以帮你建立第一直觉:先判断核心操作,再匹配结构,最后再根据实际场景做微调。知识是别人的,判断力只能通过一次次真实需求练出来。

5. 初学者最省力的学习路线与避坑建议

5.1 概念和代码,必须两条腿走路

数据结构最容易踩的坑,是只看概念、只背定义、只记复杂度。我见过不少同学能把“红黑树的旋转过程”复述得很流畅,但一让他动手写个代码,连链表反转都要想半天。反过来也有一种坑,是上来就埋头刷题,完全不顾背后的结构原理,刷了一百道题,换一个场景还是不知道怎么选。

我推荐的学习节奏是:每一个结构,按“三步走”来过一遍。第一步,用自己的话讲清楚这个结构解决了什么问题、最核心的约束是什么。第二步,用最简单的语言实现一遍这个结构的基本操作,比如数组实现栈、链表实现队列、哈希表实现字典。第三步,把它丢进一个真实的小功能里去用,比如用栈写括号匹配、用队列写一个任务调度器、用哈希表写一个去重工具。

这三个步骤走完,你对这个结构的理解才算闭环。概念给方向,代码给手感,应用给直觉。三个环节缺任何一个,学完都会觉得“好像懂了,但又说不上哪里没懂”。

5.2 建议优先练手的几个代码任务

如果把数据结构比作一门手艺,练手就是必须的“基本功”。我整理了几道性价比很高的入门练习,每一道都能同时训练原理理解和编码能力。

第一,手写链表反转。这个任务能检验你对指针、引用和遍历逻辑的掌控能力。别看它简单,写错的人不在少数,尤其是边界情况:空链表、只有一个节点、两个节点。

第二,用数组实现一个栈,再用链表实现一个栈。两个版本各写一遍,你自然能体会“逻辑结构相同、存储结构不同”带来的区别。感受两种实现在内存占用和操作方式上的差异。

第三,用栈实现括号匹配。一个常见的笔试题,却是训练“把规则转化为数据结构操作”的极好案例。左括号入栈,右括号出栈,最后看栈是否为空。

第四,实现一个简易 LRU 缓存。这道题把哈希表和双向链表放在一起用,能逼你把两种结构的能力边界彻底理解清楚。也是很多公司面试特别喜欢考查的点。

第五,手写二叉树的前序、中序、后序遍历,再尝试用栈而不是递归去实现一次。这一下就能看出你是真懂栈,还是只是会背递归模板。

这些任务做完,你再看很多复杂话题,比如红黑树、B+ 树、跳表,就不会觉得它们是凭空冒出来的东西,而只是在已有结构上叠加约束和优化罢了。

5.3 三个让我印象深刻的常见误区

第一个误区是过早追求所有结构的完美掌握。有人一上来就想彻底弄懂红黑树、B 树、skiplist。结果每样都看了一点,每样都不精,遇到题目还是不会。我的建议是把基础结构吃深,高级结构等项目里真正用到再补。数据结构的学习曲线应该是“先宽后深”,先让常见的几种结构形成肌肉记忆,再针对特定场景深入研究特定结构。

第二个误区是用刷题数量代替结构理解。刷题当然有用,但如果每次做不对就去看题解,然后背下来,下一道换皮题又不会了,那这些题的积累就是无效的。我更推荐刷题之后做一次总结:这道题考的是哪个结构的能力?为什么换一个结构就不行?这次总结的价值,往往比做三道新题还要大。

第三个误区是完全不关注真实系统里的数据规模。数据结构讲复杂度时,n 都是抽象的。可真实项目里,n 可能是几千、几百万、甚至上亿,n 的实际大小直接影响选型结论。我见过一个项目里存储的条目只有几十条,却用了缓存和哈希表,不但没提速,反而浪费了内存和时间。数据结构的选择一定要配合你对业务规模的认识,脱离了真实规模谈复杂度,等于纸上谈兵。

我在这个系列里会一直保持一个原则:所有结构都回到“它解决了什么问题”“它付出了什么代价”“什么场景该用它”这三个角度来讨论。因为数据结构这门课,本质上是训练你在约束条件下做取舍的思维方式。算法是“怎么做得快”,数据结构更像是“用什么样的组织方式,才能让快变成可能”。把这一点想通,整扇门就算真正踏进去了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进