在计算机科学中,链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是不一定按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。
链表的分类
从结构上进行区分,链表可以分为:单向链表(Singly Linked)、双向链表(Doubly Linked List)和循环链表(Circular List),在一些资料里还会有块状链表或者多重链表(Multiply Linked List),绝大对数情况下,我们只会用到下面三种链表。
单向链表(Singly Linked)
单向链表的存储结构比较简单,在存储上,它能够用一组任意的存储单元存储线性表的数据元素(这组存储单元可以是连续的,也可以是不连续的)。每个元素包含两个域,其中存储数据元素信息的域称为数据域;存储直接后继存储位置的域称为指针域。指针域中存储的信息称作指针或链。如下图,链接指向列表中下一个节点,而最后一个节点则指向一个空值。
双向链表(Doubly Linked List)
双向链表的存储结构相对于单向链表更加复杂。在双向链表中,每个节点中有两个指针域,一个指向直接后继,另一个指向直接前驱,当连接为最后一个连接时,指向空值或者空列表。如下图:
循环链表(Circular List)
循环链表是另一种形式的链式存储结构。其特点是表中最后一个节点的指针域指向头节点,整个链表形成一个环。由此,从表中任意节点出发均可找到表中其他节点,如下图:
类似的,双向链表也可以构建循环链表。循环链表有两个特点,第一,链表中没有 NULL 指针;第二,链表无须增加存储量。
其他
链表有多种形式,还可以采用其他维度进行分类,它可以是单链接的或双链接的,可以是已排序的或未排序的,可以是循环的或非循环的,在存储时,可以共享存储空间,也可以独立存储空间。
特点
- 存储单元可以是连续的,也可以是不连续的;
- 每个节点包含两部分,分别为数据元素域和指针域;
- 节点和节点之间通过指针域进行连接。
性能
假设我们所处理的链表都是未排序的双向链,如下图所示:
插入
给定一个已设置好关键字 key 的元素 x,将其“连接至”链表 L 的前端。
伪代码实现如下:
LIST_INSERT(L,x)
x.next = L.head
if L.head != NIL
L.head.prev = x
L.head = x
x.prev = NIL
可以看出,在一个含有 n 个元素的链表上执行 LIST-INSERT 的运行时间为 O(1),在确定插入位置后,插入操作无需移动数据,只需要修改指针。但如果要在给定关键字前(后)插入元素,则最坏情况下需要的时间为 ,因为需要先调用 LIST-SEARCH 找到该元素。
删除
将一个元素 x 从链表 L 中移除。该过程要求给定一个指向 x 的指针,然后通过修改一些指针,将 x “移除出”该链表。
伪代码实现如下:
LIST-DELETE(L,x)
if x.prev != NIL
x.prev.next = x.next
else L.head = x.next
if x.next != NIL
x.next.prev = x.prev
可以看出,LIST-DELETE 的时间复杂度为 O(1)。但如果要删除具有给定关键字的元素,则最坏情况下需要的时间为 ,因为需要先调用 LIST-SEARCH 找到该元素。
搜索
查找链表 L 中第一个关键字为 k 的元素,并返回该元素的指针,如果链表中没有关键字为 k 的对象,则返回 NIL。
伪代码实现如下:
LIST-SEARCH(L, k)
x = L.head
while x != NIL and x.key != k
x = x.next
return x
可以看出,搜索的时间复杂度为 ,因为可能需要搜索整个链表。
修改
修改操作可以理解为先调用 LIST-SEARCH 操作,找到该元素,再修改关键字,其时间复杂度与搜索相同。
哨兵(Sentinel)
上面我们讲删除的时候,需要判断表头和表尾处的边界条件,如果能够忽视这些检查,则 LIST-DELETE 的代码可以更简单,如下:
LIST-DELETE'(L,x)
x.prev.next = x.next
x.next.prev = x.prev
哨兵的存在就是为了解决这样的问题的。哨兵是一个哑对象,其作用是简化边界条件的处理。例如,假设在链表 L 中设置一个对象 L.nil,该对象代表 NIL,但也具有和其他对象相同的各个属性。对于链表代码中出现的每一处对 NIL 的引用,都代之以对哨兵 L.nil 的引用。这样以来,就会将一个常规的双向链表转变为一个有哨兵的双向循环链表(circular,doubly linked list with a sentinel),哨兵 L.nil 位于表头和表尾之间。属性 L.nil.next 指向表头,L.nil.prev 指向表尾。类似地,表尾的 next 属性和表头的 prev 属性同时指向 L.nil。因为 L.nil.next 指向表头,我们就可以去掉属性 L.head,并把对它的引用代替为对 L.nil.next 的引用。值得一提的是,一个空的链表只由一个哨兵组成,L.nil.next 和 L.nil.prev 同时指向 L.nil。如下图:
其中,
- 图 (a)为空链表;
- 图(b)为带哨兵的链表;
- 图(c)为执行 LIST-INSERT’(L,x) 后的链表,其中 x.key=25,新插入的对象成为表头。
- 图(d)为删除关键字为 1 的对象后的链表。新的表尾是关键字为 4 的对象。
引入哨兵后,搜索过程如下:
LIST-SEARCH'(L,k)
x = L.nil.next
while x != L.nil and x.key != k
x = x.next
return x
插入过程如下:
LIST-INSERT'(L,x)
x.next = L.nil.next
L.nil.next.prev = x
L.nil.next = x
x.prev = L.nil
可以看出,哨兵基本不能降低数据结构相关操作的渐近时间界,但是可以降低常数因子。在循环语句中,使用哨兵的好处在于可以使代码简洁,而非提高速度。
最后,需要注意的是,我们应该慎用哨兵。假如有许多个很短的链表,它们的哨兵所占用的额外的存储空间就会造成严重的存储浪费。当且仅当可以真正简化代码时才应该使用哨兵。
参考资料
- 《算法导论》第三版 殷建平 等译
- 《数据结构》(C 语言第二版)严蔚敏 等编著
- 维基百科