基本数据结构(2)——算法导论(12)
1. 引言
这一篇博文主要介绍链表(linked list),指针和对象的实现,以及有根树的表示。
2. 链表(linked list)
我们在上一篇中提过,栈与队列在存储(物理)结构上都可以用数组和链表来实现。数组和链表都是线性存储结构,其中的各元素逻辑上都是按顺序排列的。它们的不同点在于:数组的线性顺序由数组的下标决定;而链表的顺序是由各元素里的指针决定的。链表为动态集合提供了一种简单而灵活的表示方法。
如下图所示,双向链表(doubly linked list)L的每个元素都是一个对象,每个对象有一个关键字(key)和两个指针:next和prev。对象中还可以包含其他辅助数据(或称为卫星数据)。设x为链表中的某个元素,x.next指向它在链表中的后继元素(也可能没有,此时x.next = NIL,x为链表的最后一个元素,即x的头(head));x.prev则指向它的前驱元素(也可能没有,此时x.prev = NIL,x为链表的第一个元素,即x的尾(tail))。属性L.head指向链表的头,如果为NIL,则链表为空。
链表还有多种形式:已排序的(sorted):链表的线性顺序与链表元素中关键字的线性顺序一致;单链接的(singly linked):去掉双链表每个元素的prev指针);循环链表(circular list):链表头元素的prev指针指向链表尾元素,尾元素的next指针指向头元素。
我们可以采用简单的线性搜索方法来搜索链表L中第一个关键字(key)为k的元素,并返回指向该元素的指针。如果没有找到,则返回NIL。下面是伪代码:
显然,对于一个长度为n的链表,在最坏情况下,搜索的时间为θ(n)。
给定一个已设置好关键字key的元素x,过程INSERT将x插入到链表的最前端。下面是伪代码:
显然,插入的时间与链表的长度无关,为常量θ(1)。
过程DELETE将一个元素x从L中移除。若该过程给出的是一个指向元素x的指针,则可通过修改指针将元素x删除;如果该过程给出的是一个关键字key,则需要先搜索出该元素,然后将其移除。下面是伪代码:
现最坏的情况下,删除操作的时间是θ(n),因为要先搜索出x。
下面给出一种双链表的Java实现:
public class DoublyLinkedList<T> { private Node<T> first; private int size; /** * 将元素插入到链表的最前端 */ public void insert(T t) { Node<T> newNode = new Node<>(null, t, first); if (first != null) { first.prev = newNode; } first = newNode; size++; } /** * 搜索第一次出现待搜索元素的节点 * * @param t * 待搜索元素 * @return 保存该元素的节点(若没找到,返回null) */ public Node<T> search(T t) { Node<T> node = first; while (node != null && node.key != t) { node = node.next; } return node; } /** * 删除链表中第一次出现的待搜索元素的节点 * * @param t */ public void delete(T t) { Node<T> node = search(t); if (node == null) { return; } if (node.prev == null) { // node是first if (node.next != null) { node.next.prev = null; } first = node.next; return; } if (node.prev != null) { node.prev.next = node.next; if (node.next != null) { node.next.prev = node.prev; } } size--; } /** * 根据index获取元素 * * @param index * @return */ public T get(int index) { if (index < 0 || index > size - 1) { throw new IndexOutOfBoundsException(index + ""); } Node<T> node = first; int i = 0; while (node != null && i != index) { node = node.next; i++; } return node == null ? null : node.key; } @Override public String toString() { Node<T> node = first; String result = ""; while (node != null) { String key = node.key == null ? "" : node.key.toString(); result += key + ","; node = node.next; } if (result.endsWith(",")) { result = result.substring(0, result.length() - 1); } return "[" + result + "]"; } public static class Node<T> { T key; Node<T> prev; Node<T> next; public Node(Node<T> prev, T key, Node<T> next) { super(); this.prev = prev; this.key = key; this.next = next; } } public static void main(String[] args) { DoublyLinkedList<Integer> list = new DoublyLinkedList<>(); // 插入 list.insert(1); list.insert(2); list.insert(3); list.insert(4); System.out.println(list); // 搜索 Node<Integer> node = list.search(3); System.out.println(node == null ? "null" : node.key); // 删除 list.delete(1); System.out.println(list); //获取 System.out.println(list.get(2)); System.out.println(list.get(1)); } }
3. 指针和对象的实现
当某种语言不支持指针和对象数据类型时,上面的实现方式是不可行的。这时我们可考虑用数组和其下标来实现对象和指针。
我们考虑对对象的每一个属性都用一个数组来存放,这样就可以表示一组具有相同属性的的对象。我们可以用如下图所示的方式来表示上面代码中出现的Node对象。其中数组key,数组prev,数组next分别存放Node的key,prev,next属性。
从上图我们可以看出,第一个节点在数组下标为7的位置,之后的节点在数组中的下标依次是:5,2,3。
像这样用数组存储的方式与一般使用数组的方式不同的是,被存储的元素在物理上不是连续的。(暂时还没想到这么做能带来什么好处。但学习算法更重要的是对思维的扩充。)。
计算机内存的字往往是从整数0到M-1进行编址的,其中M是一个足够大的整数。在许多程序设计语言中,一个对象在计算机内存中占据一组连续的储存单位,指针仅仅是该对象所在的第一个存储单位的地址(就像C中的结构体)。要访问对象内其他储存单元可以在指针上加上一个偏移量。(正如在学习C++时,老师说的,数据类型的本质是固定内存大小的别名)。
同样,我们可以采用上面的这种策略来实现对象。如下图所示,属性key,next,prev的偏移量分别是0,1,2。
当我们向一个双向链表表示的动态数组中插入一个元素时,就必须分配一个指向该链表中尚未利用的对象的指针。因此,有必要对链表中尚未利用的对象空间进行管理,使其能够被分配。在某些系统中,有垃圾回收器(garbage collector,GC)负责确定,回收哪些对象是未使用的。然而许多应用没有GC或者该应用本身很简单,我们完全可以自己负责将未使用的对象的存储空间返回给存储管理器。我们以多数组表示的双向链表为例,探讨同构对象(即有相同属性的对象)的分配与释放的问题。
我们假设多数组表示法中的各数组的长度为m,且在某一时刻,该动态集合中含有n≤m个元素。这n个对象表示现存于该动态集合中的元素,而余下的m-n个对象是自由的(free),这些自由对象表示的是将要插入该动态集合的元素。
我们把只有对象保存在一个单链表中,称为自由表(free list)。自由表只使用next数组,该数组只存放链表中的next指针。自由表的头保存在全局变量free中。当有链表L表示的动态集合非空时,自由表可能会和链表L交错,如下图。
自由表类似一个栈:下一个被分配的对象就是最后被释放的那个。我们可以利用栈的push和pop操作来实现分配和释放过程。伪代码如下:
4. 有根树的表示
上一节介绍的表示链表的方法可以推广到任意同构的数据结构上。在本节中,我们专门讨论用链式数据结构表示有根树的问题。我们从最简单的二叉树开始讨论,然后给出针对节点的孩子树任意的有根树的表示方法。
我们用对象来表示树的节点。与链表类似,假设每个节点都含有一个关键字key,其余我们感兴趣的属性包括指向其他节点的指针,它们随树的种类不同会有所变化。
如下图所示,它展示了在二叉树T中如何利用属性p,left,right存放指向父节点,左孩子,右孩子的指针。属性T.root指向整棵树T的根节点。
二叉树的表示方法可以推广到每个节点的孩子数至多为常数k的任意类型的树:只需要将left和right属性用child1,child2,…,childk代替。但是当孩子的节点树无限制时,这种方法就失效了,因为不知道预先分配多少个属性(在多数组表示发中就是多少个数组)。此外,即使孩子数k限制在一个大的常数以内,但当多数节点只有少量孩子时,这样做会浪费大量储存空间。
这时我们可以用一种叫做左孩子右兄弟表示法(left-child,right-sibling representation),如下图所示。对任意n个节点的有根树,它只需要O(n)的存储空间。与前面类似,每个节点都包含一个父节点指针p,且T.root指向树T的根节点。然而,每个节点中不是包含指向每个孩子的指针,而是只有两个指针:x.left-child指向节点x最左边的孩子节点。x.right-sibling指向节点x右侧相邻的兄弟节点。
事实上,我们还可以用许多其他的方法来表示有根树,例如在前面介绍的堆排序与优先队列——算法导论(7)中,我们用堆来表示一颗完全的二叉树,这里就不一一介绍了。至于哪种方法最优,需要具体情况具体分析。