欢迎光临
我们一直在努力

算法入门:基本数据结构


为什么我们需要数据结构?

学习数据结构之前,我们可以先问一个很朴素的问题:

为什么我们需要数据结构?

假设没有任何结构,数据会是什么样子?

它可能就是一大堆散落的字符、字符串、数字。比如你现在有一百个人的信息,包括姓名和年龄,如果没有任何组织方式,它们可能长这样:

张三,李四,10岁,11岁,16岁,38岁,王二……

这当然也是“数据”,但它几乎没法被好好使用。

你很难知道:

张三到底几岁?
10岁对应的是谁?
王二在不在里面?
怎样增加一个新的人?
怎样删除一个人?
怎样找到年龄最大的人?

数据本身虽然存在,但它们之间没有清晰的关系,没有规则,也没有秩序。这样的数据就像把一百个人的档案全部撕开混在一起,名字一堆,年龄一堆,看起来都有信息,但真正用的时候非常痛苦。

所以我们需要数据结构。

简单来说:

数据结构就是为了把数据组织起来,让数据形成某种有逻辑的结构,从而便于存储、获取、修改和处理。

比如对于人的姓名和年龄,我们可以把它组织成这样:

[
{姓名: 张三, 年龄: 10},
{姓名: 李四, 年龄: 11},
{姓名: 王二, 年龄: 16}
]

这样一来,姓名和年龄就被绑定起来了。数据不再是散落的碎片,而是有了关系、有了层次、有了可操作的形状。

这就是数据结构最基本的意义。


数据结构不是为了“存”,而是为了“用”

很多初学者刚接触数据结构时,会觉得数据结构就是“存数据的东西”。

这个理解不算错,但还不够。

因为我们真正关心的不是单纯地把数据放进去,而是:

能不能方便地放进去?
能不能方便地拿出来?
能不能快速找到?
能不能删除?
能不能修改?
能不能按照某种规则管理它?

也就是说,数据结构服务的是具体需求。

常见需求包括:

插入
删除
查找
修改
遍历
获取最大值
获取最小值
判断是否为空
获取数据规模

而插入本身还可以继续细分,比如:

头部插入
尾部插入
中间插入
按照顺序插入

查找也可以有不同需求:

按下标查找
按值查找
查找最大值
查找最小值
查找某个范围内的数据

所以数据结构不是凭空出现的。它们之所以不同,是因为我们面对的数据使用需求不同。

你要频繁按位置访问,可能会想到数组。

你要频繁在中间插入删除,可能会想到链表。

你要先进先出地处理任务,可能会想到队列。

你要后进先出地撤销操作,可能会想到栈。

你要快速根据名字找到年龄,可能会想到哈希表。

数据结构的背后,其实始终是一个问题:

我接下来要怎样使用这些数据?


ADT:先规定“能做什么”,再考虑“怎么做”

为了描述不同的数据结构,我们通常会先定义一套行为规则。

这就是 ADT,Abstract Data Type,中文叫抽象数据类型。

ADT 关心的是:

一个数据类型应该支持哪些操作,这些操作应该表现出什么行为。

它暂时不关心底层到底怎么实现。

比如我们说“队列”这个东西,它的核心规则可能是:

add(x):添加元素 x
remove():移除下一个应该被移除的元素

注意,这里最关键的是“下一个应该被移除的元素”。

不同规则下,“下一个”是不一样的。

如果规定最早加入的元素最先被移除,那就是 FIFO 队列。

如果规定最后加入的元素最先被移除,那就是 LIFO 栈。

所以 ADT 像是在说:

你应该提供什么功能?
这些功能应该遵守什么规则?

而具体实现则是在说:

我用数组实现?
我用链表实现?
我用语言内置容器实现?
我怎样让它更快、更省空间?

因此可以这样理解:

ADT 定义行为。
具体实现决定性能。

这是学习数据结构时非常重要的一层抽象。


本次先讲两个ADT,Queue和List,后续会陆续出其它ADT的讲解。

Queue:从 add 和 remove 开始理解

我们先从一个很简单的抽象开始:

Queue

广义地说,Queue 可以理解为一种能提供下面两个操作的东西:

add(x):添加元素 x
remove():移除一个元素

但问题是:

remove() 到底应该移除谁?

这个问题一问出来,队列和栈的区别就出现了。


FIFO Queue:先进先出

FIFO 是 First In, First Out,也就是:

先进先出

它就像现实生活中的排队。

假设大家排队买奶茶:

张三先来
李四后到
王二最后来

那么服务的时候,应该是:

张三先被服务
李四第二个
王二最后

先进入队伍的人,先离开队伍。

这就是 FIFO Queue。

你可以把它想象成一条两端连通的通道:

入口 -> [ 张三 | 李四 | 王二 ] -> 出口

元素从一端进入,只能从另一端离开。

所以最先放进去的元素,会最先被取出。

这就是先进先出。

它通常支持的操作可以写成:

add(x):把 x 加到队尾
remove():从队头移除元素

比如:

add(张三)
add(李四)
add 王二
remove() -> 张三
remove() -> 李四
remove() -> 王二

队列非常适合处理“按顺序来”的场景,比如:

排队系统
打印任务
消息队列
广度优先搜索 BFS
操作系统中的任务调度

只要你看到“先来的先处理”,就可以想到 FIFO Queue。


LIFO Stack:后进先出

LIFO 是 Last In, First Out,也就是:

后进先出

它就是我们常说的栈。

栈像什么呢?

它像一个只有一个口的封底圆筒。

你把糖片一片片从上面放进去:

先放 A
再放 B
最后放 C

圆筒里大概是这样:

顶部
C
B
A
底部

现在你要拿糖片,只能从顶部拿。

所以你最先拿到的是 C,而不是 A。

也就是说:

最后放进去的元素,最先被取出。

这就是后进先出。

栈常见操作是:

push(x):把 x 放到栈顶
pop():移除并返回栈顶元素
top() / peek():查看栈顶元素,但不移除

比如:

push(A)
push(B)
push(C)
pop() -> C
pop() -> B
pop() -> A

栈非常适合那些“最近发生的事情要最先处理”的场景,比如:

撤销操作
函数调用栈
浏览器后退
括号匹配
表达式求值
深度优先搜索 DFS

比如撤销操作就很像栈。

你先输入一句话,又加粗,又改颜色。现在你按撤销,当然应该先撤销“改颜色”,再撤销“加粗”,最后才撤销“输入文字”。

这就是典型的后进先出。


Deque:两头都能操作的队列

接下来是 Deque。

Deque 是 Double-ended Queue,也就是:

双端队列

它比普通 Queue 更灵活。

普通 FIFO Queue 通常是:

一端进,另一端出

而 Deque 是:

两端都可以进,两端都可以出

你可以把它想象成一根线上串珠子。

左边可以穿珠子,也可以取珠子。

右边也可以穿珠子,也可以取珠子。

它的典型操作是:

AddFirst(x):把 x 添加到队列前端
AddLast(x):把 x 添加到队列后端
RemoveFirst():移除并返回前端元素
RemoveLast():移除并返回后端元素

比如一开始 Deque 是空的:

[ ]

执行:

AddLast(A)

得到:

[ A ]

再执行:

AddLast(B)

得到:

[ A, B ]

再执行:

AddFirst(C)

得到:

[ C, A, B ]

这时候:

RemoveFirst() -> C
RemoveLast() -> B

Deque 的灵活性很强。它可以当普通队列用,也可以当栈用。

当作队列:

AddLast(x)
RemoveFirst()

当作栈:

AddLast(x)
RemoveLast()

所以 Deque 像是一个更通用的结构。

它常见于:

滑动窗口问题
双端搜索
需要两头插入删除的场景
实现栈或队列

一句话概括 Deque:

Deque 就是一条两端都开口的通道,元素可以从两头进,也可以从两头出。


List:按位置组织的一串元素

接下来是 List。

List 通常表示一种线性结构。

所谓线性结构,就是元素像排成一条线一样,一个接一个:

[ a0, a1, a2, a3, a4 ]

每个元素都有自己的位置,也就是下标:

0, 1, 2, 3, 4

List 的核心特点是:

元素之间有先后顺序,并且可以通过位置来访问和操作。

一个 List ADT 常见会规定这些操作:

Size():返回列表长度 n
Get(i):返回第 i 个位置上的元素
Set(i, x):把第 i 个位置上的元素改成 x
Add(i, x):在第 i 个位置插入 x
Remove(i):删除第 i 个位置上的元素

比如现在有一个 List:

[ A, B, C, D ]

它的长度是 4:

Size() -> 4

获取下标 2 的元素:

Get(2) -> C

修改下标 1 的元素:

Set(1, X)

得到:

[ A, X, C, D ]

在下标 2 的位置插入 Y:

Add(2, Y)

得到:

[ A, X, Y, C, D ]

删除下标 3 的元素:

Remove(3)

得到:

[ A, X, Y, D ]

List 的抽象非常自然,因为我们日常生活中经常使用这种“有顺序的一串东西”。

比如:

购物清单
播放列表
学生名单
排行榜
文章段落
任务列表

它们都有一个共同特点:

元素是一个接一个排列的。
元素之间有顺序。
我们经常需要按位置访问、插入、删除、修改。


List 的两种常见实现:数组和链表

List 是一种 ADT,它规定了应该支持哪些操作。

但是它不规定底层必须怎么实现。

所以 List 有很多实现方式,其中最常见的是:

数组实现
链表实现

尤其常见的是:

ArrayList
LinkedList

或者在 C++ 中,可以类比为:

vector
list

不过要注意,它们虽然都可以用来表示“一串有顺序的数据”,但底层结构完全不同,因此性能差别很大。


用数组实现 List

数组实现的 List,可以想象成一排连续的格子:

[ A ][ B ][ C ][ D ][ ][ ]

每个格子都有编号,所以按下标访问非常快。(初学者可能觉得比较神奇,实际上,数组之所以能够快速按下标访问,是因为数组变量通常保存的是第一个元素的起始地址,数组元素在内存中连续存储,且每个元素大小相同,因此可以根据起始地址和下标直接计算出目标元素的内存地址,而不需要逐个查找)

如果我要访问第 2 个元素:

Get(2)

计算机可以直接跳到第 2 个位置。

所以数组实现 List 的一个重要优点是:

按下标访问快

通常可以做到:

Get(i):O(1)
Set(i, x):O(1)

但是它的缺点也很明显。

如果你要在中间插入一个元素,比如在 B 和 C 之间插入 X:

[ A ][ B ][ C ][ D ]

插入后应该变成:

[ A ][ B ][ X ][ C ][ D ]

那么 C 和 D 都需要往后挪。

如果后面有很多元素,就要挪很多次。

所以数组实现的 List:

中间插入慢
中间删除慢
按下标访问快

它适合的场景是:

经常按位置访问
很少在中间插入删除
数据整体比较连续


用链表实现 List

链表则完全不同。

链表不是一排连续的格子,而是一颗颗珠子,每颗珠子里面除了存数据,还会存“下一颗珠子在哪里”。

如果是双向链表,每颗珠子还会知道:

前一颗在哪里
后一颗在哪里

它大概像这样:

A <-> B <-> C <-> D

这就是 doubly-linked list,双向链表。

链表的优点是插入删除很灵活。

比如你要在 B 和 C 之间插入 X,只需要改变几个连接关系:

A <-> B <-> X <-> C <-> D

不需要像数组那样把后面一大堆元素整体搬家。

所以链表在某些插入删除场景下很有优势。

但是链表也有缺点。

如果你想访问第 100 个元素,链表不能像数组那样直接跳过去。

它只能从头开始,一个一个往后找:

第 0 个
第 1 个
第 2 个
……
第 100 个

所以链表:

按下标访问慢
已知位置时插入删除快

它适合的场景是:

经常插入删除
不太需要频繁随机访问
数据规模变化频繁


同一个 ADT,可以有不同实现

这就是数据结构里非常重要的一个思想:

同一个 ADT 可以有不同实现,而不同实现的性能可能差别非常大。

比如 List 这个 ADT 规定:

Size()
Get(i)
Set(i, x)
Add(i, x)
Remove(i)

但是它可以用数组实现,也可以用链表实现。

数组实现时:

Get(i) 很快
Set(i, x) 很快
中间 Add / Remove 可能较慢

链表实现时:

随机 Get(i) 较慢
但如果已经定位到节点,插入删除很方便

它们对外都可以叫 List,但内部完全不是一回事。

这就像你都可以从北京去上海:

坐高铁
坐飞机
开车
骑车

目标一样,方式不同,成本和速度也完全不同。

ADT 规定的是“你能去上海”。

具体实现决定的是“你怎么去、多久到、花多少钱”。


语言中的容器,本质上就是封装好的数据结构

在真实编程中,我们很多时候并不会从零开始手写数据结构。

因为大多数语言已经帮我们封装好了。

比如 C++ 中常见的容器有:

vector
list
deque
queue
stack
map
set
unordered_map
unordered_set
priority_queue

它们背后其实就是各种数据结构和 ADT 的具体实现。

比如:

vector:动态数组
list:双向链表
deque:双端队列
queue:队列适配器
stack:栈适配器
unordered_map:哈希表
map:通常是平衡搜索树

所以现实编程中,我们经常不是先问:

我要不要自己实现一个数据结构?

而是先问:

我的数据需要什么行为?
我最常做什么操作?
我更关心查找、插入、删除,还是有序性?

然后再选择合适的容器。

比如:

如果你需要一串元素,经常按下标访问:

vector

如果你需要先进先出:

queue

如果你需要后进先出:

stack

如果你需要两头都能插入删除:

deque

如果你需要根据 key 快速查找 value:

unordered_map

如果你需要数据自动保持有序:

map
set

这就是数据结构在现实编程中的使用方式。


学数据结构,学的到底是什么?

很多人学数据结构时,会陷入一种误区:

我要背会所有操作。
我要记住所有代码。
我要把每种结构都手写一遍。

当然,能实现它们很重要。

但更核心的是:

理解每种数据结构的组织规则,以及这种规则带来的能力和代价。

比如你学 Queue,不只是记住 add 和 remove,而是要理解:

它是一种先进先出的秩序。
它适合处理按到达顺序排队的问题。

你学 Stack,不只是记住 push 和 pop,而是要理解:

它是一种后进先出的秩序。
它适合处理最近发生的事情。

你学 Deque,不只是记住 AddFirst 和 RemoveLast,而是要理解:

它把两端都开放了,所以更灵活。

你学 List,不只是记住 Get、Set、Add、Remove,而是要理解:

它是一串有顺序的元素。
不同实现会影响访问、插入、删除的效率。

所以学习数据结构,其实是在学习三件事:

第一,数据可以怎样被组织。
第二,这种组织方式支持哪些操作。
第三,这些操作的时间和空间成本是多少。


最后总结

数据结构存在的原因很简单:

数据如果没有结构,就只是散乱的信息;有了结构,才变成可以被高效使用的资源。

ADT 则帮助我们先从抽象层面定义一种结构应该具备的行为。

比如:

Queue 关注 add 和 remove,并根据 remove 的规则分出 FIFO 和 LIFO。
Deque 允许两端插入和删除。
List 关注按位置组织元素,并支持 Get、Set、Add、Remove 等操作。

而具体实现则决定这些操作到底快不快、占多少空间、适合什么场景。

同一个 ADT 可以有多种实现。

同一种需求也可能有多种数据结构可以选择。

所以真正学会数据结构,不是死记硬背某个容器的名字,而是能在看到问题时想清楚:

我的数据是什么?
我最常做什么操作?
我需要什么规则?
我能接受什么性能代价?

当你能这样思考时,数据结构就不再是一堆抽象名词,而会变成你组织问题、驾驭复杂性的工具。

赞(0)
未经允许不得转载:171主机测评 » 算法入门:基本数据结构
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址