在内存中的存储方式
数组和链表是计算机中最基本的两种数据结构,他们在内存中的存储方式不同,这直接导致了数组和链表的读取,插入,删除的时间复杂度是不一样的。
数组
数组的存储方式是很简单的,假设我们有如下样子的格子,我们可以在任意一个位置开始放置我们的数据,例如,我们从内存地址#4开始,每个格子依次放置godfish
中的一个字母,在内存中godfish
的存储就如下:
#0 | #1 | #2 | #3 | #4 | #5 | #6 | #7 | #8 | #9 |
---|---|---|---|---|---|---|---|---|---|
g | o | d | f | i | s | ||||
#10 | #11 | #12 | #13 | #14 | #15 | #16 | #17 | #18 | #19 |
h |
可以看到,数组在内存中的存储是连续的,这意味着你如果知道存储开始的内存地址,你就通过一次操作
获取到任意相对位置的元素。例如,我已经知道了g
的内存地址为#4,我想知道g后面的第三位是啥,我只需要直接访问内存地址#7,我就可以获得该元素。
如此看来,这种数据结构是简单而又快速的,但是,如果考虑另外一个问题:如果我插入的列表后面的内存空间被占用了,数组应该如何继续插入数据呢?例如,我们在上述例子中继续插入Good
,但是,此时我们发现,内存中#10后面的内存有些已经被系统写入了其它数据,如下:
#0 | #1 | #2 | #3 | #4 | #5 | #6 | #7 | #8 | #9 |
---|---|---|---|---|---|---|---|---|---|
g | o | d | f | i | s | ||||
#10 | #11 | #12 | #13 | #14 | #15 | #16 | #17 | #18 | #19 |
h | 此 | 路 | 是 | 我 | 开 |
此时,要先记住:数组是连续的,也就是说,我们不能在#16~#19处写入Good,这样的数据结构就不会是数组了。解决方案是:在另外的空闲的内存空间里寻找一个足以放下插入后数组的内存空间,然后将原来的数据转移过去。以下是一种可能的存储:
#0 | #1 | #2 | #3 | #4 | #5 | #6 | #7 | #8 | #9 |
---|---|---|---|---|---|---|---|---|---|
#10 | #11 | #12 | #13 | #14 | #15 | #16 | #17 | #18 | #19 |
此 | 路 | 是 | 我 | 开 | g | o | d | f | |
#20 | #21 | #22 | #23 | #24 | #25 | #26 | #27 | #28 | #29 |
i | s | h | G | o | o | d | 此 | 树 |
这时我们已经在godfish
后面插入Good
了,但是这时,你如果需要继续插入数据,你可能发现现在的数组后面已经没有连续的空闲空间了,这时你又要进行一次迁移才能继续插入数据,这种操作的开销无疑是巨大的。
当然,你也可以预先额外请求多余的空闲空间。比如,你现在只有10个数据,你可以先申请100个单位的空间,这样,只要你后续插入的数据不超过90个,你就不用继续转移你的数据。不过,这样也会带来一个问题:额外请求的空间有可能根本用不上,这就会浪费多余的内存了。
链表
为了解决上述的问题,我们介绍另一种数据结构:链表。同样是在内存中存储godfish
,链表的存储状况可能如下:
#0 | #1 | #2 | #3 | #4 | #5 | #6 | #7 | #8 | #9 |
---|---|---|---|---|---|---|---|---|---|
g:#5 | o:#6 | d:#7 | f:#8 | i:#9 | s:#10 | ||||
#10 | #11 | #12 | #13 | #14 | #15 | #16 | #17 | #18 | #19 |
h:null | 此 | 路 | 是 | 我 | 开 |
其中,元素后面跟的是下一个元素的地址。比如现在我们知道g
的内存地址是#4,从#4中可以获得下一个元素的内存地址是#5,如此类推,我们可以获取再下一个,再再下一个,再再再下一个元素的数据和地址,这样就可以遍历整个列表的内容了。
链表是不要求内存连续的,因为链表中每个元素都存储下一个元素的内存地址,这意味着即使后续的内存空间已经被占用,也不影响继续插入数据。如上表中,最后一个元素的内存地址是#10,而此时#11已经被占用了,我们继续插入数据Good
,插入后的链表内存状况可能如下:
#0 | #1 | #2 | #3 | #4 | #5 | #6 | #7 | #8 | #9 |
---|---|---|---|---|---|---|---|---|---|
G:#1 | o:#2 | o:#3 | d:null | g:#5 | o:#6 | d:#7 | f:#8 | i:#9 | s:#10 |
#10 | #11 | #12 | #13 | #14 | #15 | #16 | #17 | #18 | #19 |
h:#0 | 此 | 路 | 是 | 我 | 开 |
由此可以看出,在插入方面,链表是比数组明显好用很多的,但是链表存在一个很严重的问题:假设你要访问该链表的第n个元素,你需要先访问第1个元素,从中获取第2个元素的地址,再访问第2个元素获取第3个元素的地址。。。你需要经过n次操作才能获取这个元素!这意味着链表的跳跃查询的效率是很低的。
时间复杂度
数组和链表的读取、插入和删除的操作时间复杂度如下:
数组 | 链表 | |
---|---|---|
读取 | (O(1)) | (O(n)) |
插入 | (O(n)) | (O(1)) |
删除 | (O(n)) | (O(1)) |
先说读取,我们已经在上文介绍了两者的读取差别和原因,这里就不再赘述。
在插入方面,考虑最恶劣的情况:在列表的最开头插入数据。对于数组,要进行这一操作,需要先将数组原有的元素依次往后挪一位,再在最开头的位置插入该元素,显而易见这需要n次操作。而对于链表,只需要在任意空闲内存内存入新元素,然后将链表的开始地址指向该地址,并将该元素的下一个地址指向原来的头元素地址就行。
删除可以看作是插入的逆过程,考虑最恶劣的情况:删除列表最开头的数据。对于数据,要先删除该位置的元素,再将后面的元素依次往前挪一位,需要n次操作。而对于链表,只需将链表的开始地址指向第二个元素的地址就行。
趣味小知识
Python的list实现
众所周知,python中的list是相当好用的,既不用预先定义列表的大小,又可以快速访问,这乍一看还以为是数组和链表的结合体。其实不是的,python的list是通过数组实现的,但是为了克服数组在追加数据时可能需要频繁转移数据的问题,python的申请了多余的内存空间,而且这个空间的大小是指数增长的。具体情况可以参考这篇文章:『Python』内存分析_list和arra。我们通过上述文章中的代码段稍作了解:
import sys
# 【你的程序】计算size列表,它的每个小标都保存增长到此下标时size列表的大小
size = []
for i in range(10000):
size.append(sys.getsizeof(size))
import pylab as pl
pl.plot(size, lw=2, c='b')
pl.show()