要点概论:
1. 引言
2. 数组数据结构
3. 数组的操作
4 . 链表结构
1. 引言
在编程语言中,最常用来实现集合的两种数据结构是数组和链表结构。
这两种类型的结构采用不同的方法在计算机内存中犓和访问数据。
这些方法反过来导致了操作该集合的算法中的不同的时间/空间取舍。
2 . 数组数据结构
数组(array)表示的是可以在给定的索引位置访问或替代的项的一个序列。
实际上, python 列表的底层数据结构就是数组。
在 python 和很多其他的编程语言中,集合中的实现结构主要是数组,因此,下面将用数据的方式来探讨问题。
2.1 随机访问和连续内存
计算机通过为数组分配一端连续的内存单元,从而支持对数据的随机访问,如下图所示:
为了简化,假设每个数据都占用一个单个的内存单元。
python 数组中的索引操作有两个步骤:
1)获取数组内存块的基本地址。
2)给这个地址加上索引,返回最终的结果。
2.2 静态内存和动态内存
在 FORTRAN 和 Pascal 这样层级的语言中,数组的长度和大小都是在编译时确定的,因此,程序员需要用一个常量来指定这个大小。
显然,程序员在很多情况下必须请求足够的内存才能够满足后期数据项的数目的变化,这种需求会导致程序对很多应用来说都浪费了内存。
想 Java 和 C++ 这样的现代语言,允许程序员创建动态数组来弥补这一问题。
Java 或 C++ 程序员可以在实例化的时候指定一个动态数组的长度。python 的 Array 类的行为方式也是类似的。
这种根据应用程序的数据需求调整数组的长度可以采取如下的 3 种方式:
1)在程序开始的时候创建一个具有合理默认大小的数组。
2)当这个数据不能保存更多的数据的时候,创建一个新的,更大的数组,并且从旧的数组转移数据项。
3)当数组似乎存在浪费的内存的时候(有一些数据已经被应用程序删除了),以一种类似的方式来减小数组的长度。
PS:对 Python 列表来说,这些调整都是自动进行的。
2.3 数组的物理大小和逻辑大小
物理大小:指数组单元的总数,或者说是在创建数组的时候,用来指定其容量的数字。
逻辑大小:是数组当前可供应用程序使用的项的数目。
下图展示了具有相同的物理大小但逻辑大小不同的 3 个数组,当前被数据占用的单元格用阴影表示。
在头两个数组种,你可能访问到了包含了垃圾(或者说是对应用程序当前没有意义的数据)的单元格,
因此,在大多数应用程序中,程序员必须负责记录数组的逻辑大小和物理大小。
逻辑大小和物理大小会传达三个信息:
1)如果逻辑大小为0,数组为空,也就是说,该数组不包含数据项。
2)否则,在任何给定的时间,数组中最后一项的索引都是其逻辑大小减去1.
3)如果逻辑大小等于物理大小,说明数组已经被数据填满了。
3. 数组的操作
3.1 增加数组的大小
当要插入新的项的时候,并且此时数据的逻辑大小等于其物理大小,那么就该增加数据的大小了。
当数据需要更多的内存的时候, Python 的 list 类型通过调用 insert 和 append 方法来执行这种操作:
1)创建一个新的,更大的数组
2)将数据从旧的数组复制到新的数组中
3)将旧的数据变量重新设置为新的数组对象
if logicalSize == len(a): temp = Array(len(a) + 1) # 创建新的数组 for i in range(logicalSize): # 复制旧的元素 temp[i] = a [i] # 旧元素转移到新的数组中 a = temp # 将旧的数组变量重新设置为新的数组对象
旧数组的内存留给了垃圾回收程序。
我们还可以采用自然增加的过程,将数组的长度增加一个单元以容纳新的项。
然后,考虑到这一决策在性能上的含义。当调整数组的大小的时候,复制操作的次数是线性的。
也就是说,给一个数组添加 n 项的整体时间性能是 1 + 2 + 3 + ...... + n 或 n(n+1)/2,即 O(n2)。
3.2 减小数组的大小
当数组的逻辑大小缩小的时候,就会有单元浪费。
当未使用的单元达到或超过某一个阈值,就应该减少数组的物理大小了。
在 Python 中,使用 pop 方法导致内存的浪费超越某一个阈值的时候,在 Python 的 list 类型就会发生下列操作:
1)创建一个新的,更小的数组。
2)将旧数组的数据复制到新的数组中。
3)将旧数组变量重新设置为新的数组对象。
3.3 在数组中插入一项和删除一项
向数组中插入一项,和替代数组中的一项不不相同的。替代的情况下,对给定的索引位置进行一次直接赋值就够了(逻辑大小不会改变)。
在插入的情况下,程序执行下列步骤:
1)如果必要的话,先检查可用的空间
2)从数组的逻辑末尾开始,知道目标索引位置,将每一项都向后移动一个单元(这时会在目标索引位置为新的项打开一个“洞”)。
3)将新的项赋值给目标索引位置
4)将逻辑大小增加 1 。
PS:项移动的顺序很重要,如果从目标索引位置开始,并且从那里向下复制的话,你将会丢失两项。
因此,你必须从数组的逻辑末尾开始,并且朝着目标索引的位置操作,将每一项都复制到其后续的单元之中。
在插入过程中,移动项的时间性能在平均情况下是线性的,因此,插入操作是线性的。
在数组中删除一项是插入一项的反过程,步骤如下:
1)从紧跟目标索引位置之后位置开始,到数组的逻辑末尾,将每一项都想前移动一位(这时会填上在目标索引位置删除一项之后所留下的“洞”)。
2)将逻辑大小减小 1 。
3)检查浪费的空间,如果必要的话,将数组的物理大小减小 1 。
PS:和插入一样,移动项的顺序很关键。对于删除,我么从紧跟着目标位置之后的项开始,并且朝着数组的逻辑重点移动,将每一项都复制到其前的项中。
时间性能同样为线性。
3.4 复杂度权衡:时间,空间和数组
下表记录了每次数组操作的运行时间,其中还包括两个额外的操作(从一个数组的逻辑末尾插入项和删除项)。
操作 | 运行时间 |
从第 i 个位置访问 | O(1),最好情况和最差情况 |
在第 i 个位置替换 | O(1),最好情况和最差情况 |
从逻辑末尾插入 | O(1),平均情况 |
从逻辑末尾删除 | O(1),平均情况 |
在第 i 个位置插入 | O(n),平均情况 |
从第 i 个位置删除 | O(n),平均情况 |
增加容量 | O(n),最好情况和最差情况 |
减小容量 | O(n),最好情况和最差情况 |
如上表所示:数组提供了对已经存在的项的快速访问,并且提供了在逻辑末尾位置的快速插入和删除。
在任意位置的插入和删除可能会慢上一个量级。调整大小所需时间也是线性阶的,但是,将大小加倍或者将其减半则能够把所需时间最小化。
使用数组的唯一真正的内存代价是:一些未占用的单元格将会被浪费。
获取数组的内存用量的一个有用的概念是装载因子(load factor)。数组的装载因子等于数组中存储的项的数目除以数组的容量。
即数组填满时装载因子为1,数组为空时装载因子为0。当一个数组共有 10 个单元,其中 3 个单元被占用的时候,装载因子为 0.3 。
当数组中的装载因子下降到某一个阈值时,就应该调整数组的大小以使得浪费的单元格数目保持最小。
4. 链表结构
4.1 单链表结构和双链表结构
链表相对于数组的优点对比:
1)物理存储单元上非连续,而且采用动态内存分配,能够有效的分配和利用内存资源;
2)节点删除和插入简单,不需要内存空间的重组。
链表相对于数组的缺点对比:
1)不能进行索引访问,只能从头结点开始顺序查找;
2)数据结构较为复杂,需要大量的指针操作,容易出错。
4.2 非连续性内存和节点
在数组中,数组项必须存储在连续的内存中。这意味着,数组项中项的逻辑顺序是和内存中的物理单元序列紧密耦合的。
相反,链表结构将结构中的项的逻辑顺序和内存中的顺序解耦了。这种内存表示方案,叫做非连续性的内存。
链表结构中的基本单位是节点,包含如下字段:
1)一个数据项。
2)到结构中的下一个节点的一个链接。
3)到结构中的前一个节点的一个链接(只在双链表结构中包含)。
PS:Python 通过对对象的引用建立起了节点和链表结构。在 Python 中,任何变量都可以引用任何内容,包括 None 值,它意味着一个空的链接。
由此, Python 通过定义包含两个字段的一个对象,从而定义了一个单链表节点,
这两个字段是:数据项的一个引用和到另一个节点的一个引用。Python 为每一个新的节点对象提供了动态分配的非连续性内存,
并且当对象不再被引用的时候,会自动把内存返回给系统(垃圾收集)。
4.3 定义于使用单链表节点类
以下是一个简单的单链表节点类代码:
class Node(object): def __init__(self,data,next=None): self.data = data self.next = next # 一个节点对象的实例变量通常不会有方法调用,并且在创建节点的时候, # 构造方法允许用户设置节点的链接
下面代码展示不同的实例化方式:
class Node(object): def __init__(self,data,next=None): self.data = data self.next = next # node1 没有指向节点对象 node1 = None # node2 指向一个对象,其下一个指针为 None node2 = Node('A',None) # node2 和 node3 指向所链接到的对象 node3 = Node('B',node2)
使用循环创建一个链表结构,并且访问其中的每一个节点:
# 单链表节点类定义 class Node(object): def __init__(self,data,next=None): self.data = data self.next = next # 使用循环来创建一个链表结构,并且访问其中的每一个节点 head = None for count in range(1,6): head = Node(count,head) while head != None: print(head.data) head = head.next # 程序运行结果 # 5 # 4 # 3 # 2 # 1
关于上面一段代码,需要注意以下几点:
1)指针 head 生成了链表结构。这个指针以这样一种方式操作,最近插入的项总是位于结构的开始处。
2)因此,当显示数据的时候,它们按照和插入时相反的顺序出现。
3)此外,当显示数据的时候, head 指针重新设置为下一个节点,知道 head 指针变为 None 。因此,在这个过程的最后,节点实际上从链表结构中删除了。
对于程序来说,节点不再可用,并且会在下一次垃圾回收的时候回收。