注:本篇内容参考了《Java常用算法手册》、《大话数据结构》和《算法导论》书籍。
本人水平有限,文中如有错误或其它不妥之处,欢迎大家指正!
目录
1. 线性表概念
线性表是简单也是最常用的数据结构。线性表是由零个或多个数据元素组成的有限序列。从逻辑上看,它是由n(n >= 0)个数据元素,……,组成的有限序列,这些元素如同一条线一样连接在一起。数据元素的个数为n,也称为表的长度,当n = 0时称为空表。
首先它是一个序列。就是说元素之间是有顺序的,若元素存在多个,则第一个元素无前驱,最后一个元素无后继。其它元素有且只有一个前驱和后继。
然后,线性表强调是有限的,即元素个数是有限的。事实上,在计算机中处理的对象都是有限的,那种无限的数列,只存在于数学的概念中。
若将线性表记为(,……,,,,……,),领先于,领先于,称是的直接前驱元素,是的直接后继元素。当i = 1,2,……,n-1时,有且仅有一个直接后继。
所以线性表元素的个数 n (n 0)定义为线性表的长度,当n = 0时,称为空表。
在非空表中的每个数据元素都有一个确定的位置,如是第一个数据元素,是最后一个数据元素,是第i个数据元素。称 i 为数据元素在线性表中的位序。
在较为复杂的线性表中,一个数据元素可以由若干个数据项组成。如下图:
2 线性表的存储结构
线性表有两种存储结构:顺序存储结构和链式存储结构。
2.1 线性表的顺序存储结构
2.1.1 定义
线性表的顺序存储结构,是指用一段地址连续的存储单元依次存储线性表的数据元素。示意图如下:
在存储时,是在存储空间内找一块连续的存储空间,然后占用存储空间,把相同数据类型的数据元素依次放入占用的存储空间里。在具体实现时,可以使用一维数组来实现。第一个元素的下标为0。
按照顺序存储方式存储的线性表,也称叫顺序表(Sequential List),下面会有Java的代码实现。
2.1.2 数组长度与线性表的度度
以数组形式来存储线性表,此数组的存储空间的长度,称为数组的长度,它在存储分配后一般是不变的。虽然有些高级语言中有可以动态分配的一维数组,但这样会带来性能上的损耗。
线性表的数据元素的个数,称为线性表的长度。它会随着线性表插入和删除操作的进行而变化。它与数据的长度是两个不同的概念,需要注意区分。在任意时刻,线性表的长度应该小于或等于数组的长度。存储时一般会存在空闲的存储空间,用数组存储顺序表意味着要分配固定长度的数组空间,由于线性表中可以进行插入和删除操作,因此分配的数组空间要大于或等于当前线性表的长度。
2.1.3 地址计算方法
内存中的地址,就像电影院里的座位一样,都是有编号的。存储器的每个存储单元都有自己的编号,这个编号称为地址。每个数据元素,不管是什么类型,它都需要占用一定的存储单元空间。假设当前线性表需要占用 c 个存储单元,那表中的第 i+1 个数据元素的存储位置和第 i 个存储位置满足下列的关系(其中,LOC表示获得存储位置的函数):
LOC() = LOC() + c
所以对于第 i 个数据元素的储存位置,可以由推算得出:
LOC() = LOC() + (i + 1) * c
可以借助下图来进行理解。
通过上面的公式,可以随时算出线性表任意位置的地址,不管是第一个还是最后一个。对每个线性表位置的存入或取出数据,对计算机来说都是需要相同的时间,也就是一个常数。因此时间复杂度为O(1)。通常把具有这一特点的存储结构称为随机存储结构。
2.1.4 操作
顺序存储结构的线性表,主要操作集中在对表的插入和删除,还有获取某个元素的位置等操作。
1. 返回结点位置
返回某个元素在表的位置,在程序实现时就是返回该元素在数组中的下标数字。
2. 插入结点操作
插入操作时,就是一个元素插入到表中的某个位置,那么表中后面的元素都需要向后面移动,其后的结点编号依次加1,表的长度增加1。好比很多人排序在买票,有一个人插队了,他插队成功,后面的人都会往后移动一个位置。这样性能其实是不好的。插入结点的难点在于随后的每个数据都要进行移动,计算量比较大。
最好的情况就是插入的位置是在表的最后,那样就不会存在其它元素的移动,直接插入就好了。这种情况下时间复杂度为O(1)。最坏的情况是插入到了第一个元素的位置,那原来表中所有元素都需要往后移动一个位置。这种情况下时间复杂度为O(n)。一般情况下是插入在表的其它位置,不是第一个也不是最后一个,平均来说是(n-1)/2,这种情况下时间复杂度还是O(n)。
3. 追加结点操作
追加结点可以看作是插入结点操作的特殊形式,相当于在顺序表的末尾新增加一个结点。因为其特殊性,实现比插入结点要简单,不需要是遍历循环。
4. 删除结点操作
删除操作时,删除表的某个元素,此元素后面的都需要向前移动一个位置,表的长度减1,使得其后的所有结点的编号依次减1。好比排序买票时,突然队伍中有一个人因为什么原因走了,那这个人后面的人都会向前移动一个位置。删除操作的时间复杂度跟插入操作的一样。
从上面可以看出,线性表的顺序存储结构,在存入和取出数据时,不管是在哪个位置,时间复杂度都是O(1),在插入和删除操作时,时间复杂度为O(n)。说明它适合元素个数变化不频繁的场景,数据存入和读取多,而插入和删除少的情况。
5. 查找结点操作
对一个顺序表,序号就是数据元素在数组中的位置。也就是数组的下标标号。按照序号查找结点是顺序表查找结点最常用的方法。此操作比较简单,根据所给的序号返回元素,但也需要做一些必须的验证判断。
若是按关键字查找结点,就会麻烦一些,需要遍历。这里的关键字是数据元素结构中的任意一项。
2.1.5 Java实现
线性表的顺序存储结构,下面是Java语言的具体实现。代码如下。
public class SequenceList<T> {
/**
* 默认的最大容量
*/
private static final int MAX_SIZE = 64;
/**
* 大小
*/
private int size;
/**
* 数组,存放元素
*/
private T[] array;
public SequenceList(Class<T> clazz) {
this(MAX_SIZE, clazz);
}
public SequenceList (int size, Class<T> clazz) {
array = (T[])Array.newInstance(clazz, size);
size = 0;
}
/**
* 返回大小
* @return
*/
public int size() {
return size;
}
/**
* 是否满
* @return
*/
public boolean isFull () {
if (size > MAX_SIZE) {
throw new RuntimeException("sequence list is full");
}
return false;
}
/**
* 插入
* @param n
* @param t
* @return
*/
public boolean insert (int n, T t) {
if (isFull()) {
return false;
}
if (n < 0 || n > size()) {
throw new RuntimeException("insert error, this index is error");
}
size ++;
for (int i = n; i < size-1; i ++) {
array[i+1] = array[i];
}
array[n] = t;
return true;
}
/**
* 追加到结尾
* @param t
* @return
*/
public boolean add (T t) {
if (isFull()) {
return false;
}
array[size] = t;
size ++;
return true;
}
/**
* 删除
* @param n
* @return
*/
public boolean del (int n) {
if (n < 0 || n > size()) {
throw new RuntimeException("del error, this index is error");
}
for (int i = n; i < size-1; i ++) {
array[i] = array[i+1];
}
size --;
return true;
}
/**
* 根据下标查找
* @param n
* @return
*/
public T search(int n) {
if (n < 0 || n >= size()) {
throw new RuntimeException("search error, this index is error");
}
return array[n];
}
}
下面是测试代码。
public static void main(String[] args) {
SequenceList<String> list = new SequenceList(4, String.class);
list.add("1");
list.add("2");
list.add("4");
list.insert(2, "3");
list.del(1);
int size = list.size;
for (int i = 0; i < size; i ++) {
System.out.printf("%s\n", list.search(i));
}
}
2.1.5 优缺点
优点:不需要为线性表中元素间的逻辑关系而增加额外的存储空间,即只是数据元素本身占用了空间,不像队列还需要有地址信息的存储空间;可以快速的存取表中的任一位置的元素。
缺点:插入和删除操作需要移动大量元素;当长度变化较大时,难以确定存储空间的容量;会造成存储空间的“碎片”。
2.2 线性表的链式存储结构
上面描述了线性表的顺序存储结构,它最大的缺点就是插入和删除操作时需要移动大量元素,性能不强。有没有办法解决这个问题呢?就是元素之间的存储不考虑需要连续相邻,哪里有位置就存在哪里,只要让每个元素知道它的下一个元素的位置在哪里就可以了,就是每个元素除了存储数据元素外,还会存储它的后继元素的储存地址信息。
链表是用一组任意的存储单元来存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。如下图。
2.2.1 定义
为了表示每个数据元素与其后继数据元素之间的逻辑关系,对数据元素来说,除了存储其本身的信息之外,还需要存储一个指示其直接后继的信息(即直接后继的存储位置)。把存储元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称做指针或链。这两部分信息组成数据元素的存储映像,称为结点(Node)。
- 数据域:该结点的数据元素(即实际数据);
- 指针域:存放下一个结点的地址信息(指针)。
n 个结点链结成一个链表,即为线性表(,……,,,,……,)的链式存储结构。因为此链表的每个结点只包含一个指针域,所以叫做单链表。单链表正是通过每个结点的指针域将线性表的数据元素按其逻辑次序链接在一起。如下图。
链表中的第一个结点的存储位置,叫做头指针(或头引用),它指向链表结构的第一个结点,整个链表的存取必须从头指针开始。之后的每一个结点,其实就是上一个结点的后继指针指向的位置。最后一个结点,没有直接后继,所以它的结点指针是空的,通常用NULL或符号“^”表示。 如下图。
有时,为了方便对链表进行操作,会在单链表的第一个结点前附设一个 结点,称为头结点,这时头结点的数据域可以不存储任何信息,也可以存储如线性表的长度等附加信息。头结点的指针域存储指向第一个结点的指针。如下图。
第一个结点的地址部分又指向第二个结点...,直到最后一个结点。最后一个结点不再指向其他结点,称为“表尾”,一般在表尾的地址部分存放 一个空地址“null”,链表到此结束。
从上图可以看出,整个存储过程很像一个长链条,因此形象的称为链表结构,或链式结构。由于链表采用了引用来指示下个数据的地址,因此在链表结构中,逻辑上相邻的结点在内存中并不一定相邻。逻辑相邻关系通过地址部分的引用变量来实现。
2.2.2 头指针(或头引用)与头结点的异同
头指针(或头引用)
- 是指链表指向第一个结点的指针,,若链表有头结点,则是指向头结点的指针;
- 头指针具有标识作用,所以常用头指针冠以链表的名称;
- 无论链表是否为空,头指针均不为空。头指针是链表的必要元素。
头结点
- 头结点是为了操作的统一和方便而设立的,放在第一个元素结点之前,其数据域一般无意义(也可存放链表长度);
- 有了头结点,对在第一个元素结点前插入结点和删除第一个结点,其操作与其它结点的操作就统一了;
- 头结点不一定是链表的必须要素。
2.2.3 操作
链表结构带来的最大好处就是结点之间不要求连续存放,因此在插入数据时,不需要分配一块连续的存储空间。用户可以用new函数动态分配结点的存储空间。
1. 插入结点操作
在插入结点时,就是在链表的中间部分新增一个结点。具体操作如下。
- 首先分配内存空间,存放新增的结点;
- 找到要插入的逻辑位置,也就是要新增的结点位于哪两个结点之间;
- 修改插入结点的地址信息,使其指向新增结点,使新增结点指向原插入位置所指向的结点。
2. 追加结点操作
在追加结点时,就是向链表末尾增加一个节点,表尾结点的地址信息是空地址,此时需要将其设置为新增节点的地址(即表尾结点指得新增结点),然后将新增结点的地址部分设置为空地址,这样新增结点成为表尾,可以看作是插入结点操作的特殊形式。具体操作如下。
- 首先分配内存空间,存放新增的结点;
- 找到表尾结点(逐个查找链表直到找到最后一个链表;也可以写个获取表尾结点的方法,每次更改表尾结点时设置好表尾结点的信息);
- 将表尾结点的地址信息设置为新增结点的地址信息;
- 将新增结点的地址部分设置为空地址,这样新增结点成为表尾。
3. 插入头结点操作
在插入头结点时,就是在链表的首部添加结点,可以看作是插入结点操作的特殊形式。具体操作如下。
- 首先分配内存空间,存放新增的结点;
- 使新增结点指向头指针的结点;
- 使头指针指向新增结点。
4. 删除结点操作
当删除某个结点时给该节点赋值"null",释放其占用的空间。具体操作如下。
- 查找所要删除的结点;
- 使前一结点指向当前结点的下一个结点;
- 删除结点。
5. 查找结点操作
对于链表的访问只能从表头开始逐个查找,即通过头引用找到第一个结点,再从第一个结点找到第二个结点,...这样逐个比较一直到找到需要的结点为止,而不能像顺序表那样进行随机访问。
2.2.4 优缺点
链表结构也有缺点,那就是浪费存储空间。对于每个结点数据,都要额外保存一个引用变量,但在某此场合,链表结构所带来的好处还是大于其缺点的。
但链表结构的插入和删除操作性能要比顺序存储结构高,所以在插入和删除操作频繁的场合,链表结构应该比顺序表更适合。
2.2.5 链表存储结构的分类
链式存储是最常用的存储方式之一,它不仅可以用来表示线性表,也可以用来表示各种非线性的数据结构。链表结构可细分为以下几类:
- 单链表:和上面链式结构一样,每个结点中只包含一个引用;
- 双向链表:若每个结点包含两个引用,一个指向下一个结点,另一个指向上一个结点,这就是双向链表;
- 单循环链表:在单链表中,将终端结点的引用域null改为指指向表头结点或开始结点即可构成单循环链表;
- 多重链的循环链表:如果将表中的结点链在多个环上,将构成多重链的循环链表。
2.2.6 链表的Java实现
在Java语言的类库中,提供了LinkedList、LinkedSet这样的可以进行链表操作的基础类。下面手写的Java实现。
public class MyLinkedList<T> {
/**
* 链表的大小
*/
transient int size;
/**
* 链表的头结点
*/
transient Node<T> first;
/**
* 链表的最后一个结点
*/
transient Node<T> last;
public MyLinkedList() { }
/**
* 获取链表的大小
*
* @return
*/
public int size() {
return size;
}
/**
* 检查index的值
* @param index
*/
private void checkIndex(int index) {
if (index < 0 || index > size) {
throw new RuntimeException("index error");
}
}
/**
* 根据位置搜索元素,二分查找
*
* @param index
* @return
*/
private Node<T> node(int index) {
checkIndex(index);
/**
* 将头结点开始遍历
*/
Node<T> h = first;
for (int i = 0; i < index; i++) {
h = h.next;
}
return h;
}
/**
* 根据结点的元素内容,得到其位置
*
* @param t
* @return
*/
private int indexOf(T t) {
int index = 0;
if (t == null) {
for (Node<T> x = first; x != null; x = x.next) {
if (x.data == null) {
return index;
}
index++;
}
} else {
for (Node<T> x = first; x != null; x = x.next) {
if (t.equals(x.data)) {
return index;
}
index++;
}
}
return -1;
}
/**
* 新增元素到为头结点
* @param t
*/
private void linkFirst(T t) {
Node<T> f = first;
Node<T> n = f.next;
/**
* 如果头结点为空,则说明链表是空链表,直接设置新增结点为头结点;否则新增结点的下一个结点就是原来的头结点
*/
if (null == f) {
first = new Node<T>(t, null);
} else {
first = new Node<T>(t, n);
first.next = f;
}
size ++;
}
/**
* 将元素添加到结点之前
*
* @param t 将被链接到指定位置的元素内容
* @param prev 将被替换的结点的前一个结点,它的下一个结点就是原来位置的结点
*/
private void linkBefore(T t, Node<T> prev) {
/**
* 将被替换的结点
*/
final Node<T> cur = prev.next;
/**
* 新增结点,它的下一个结点是原来位置的结点
*/
final Node<T> newNode = new Node<T>(t, cur);
/**
* 修改上一个结点的下一个指点的指向
*/
prev.next = newNode;
size++;
}
/**
* 将元素链表到链表尾部,需要修改最后结点的内容,同时修改原来个数第二个结点的下一个结点指向
*
* @param e
*/
private void linkLast(T e) {
/**
* 此时的最后结点
*/
final Node<T> l = last;
/**
* 新增的结点,在新增完成后将是最后的结点,它的下一个结点是空
*/
final Node<T> newNode = new Node<>(e, null);
/**
* 修改链表的最后结点内容,最后结点为新增的结点
*/
last = newNode;
/**
* 若在新增前的最后结点为空,则将新增结点设置到头结点;否则新增前最后结点的下一个节点为所添加的结点
*/
if (l == null) {
first = newNode;
} else {
l.next = newNode;
}
size++;
}
/**
* 取消链结,将取消链接的内容设置为空,重新设置取消链接结点前一个结点的下一个结点指向
*
* @param x
* @param prev 要取消链接的上一个结点
* @return
*/
private T unlink(Node<T> x, Node<T> prev) {
/**
* 要取消链接的元素
*/
final T element = x.data;
/**
* 要取消链接的下一个结点
*/
final Node<T> next = x.next;
/**
* 如果前一个结点为空,则说明当前结点为头结点,则头结点为当前结点的下一个结点
*/
if (null == prev) {
first = next;
}
/**
* 如果下一个结点为空,则说明取消链接的结点为最后结点,将最后结点设置其前一个结点;否则将前一个结点的下一个结点指向当前结点的下一个结点
*/
if (null == next) {
if (null != prev) {
prev.next = null;
}
last = prev;
} else {
if (null != prev) {
prev.next = next;
}
}
x.data = null;
size--;
return element;
}
/**
* 添加元素
*
* @param t
*/
public void add (T t) {
linkLast(t);
}
/**
* 添加元素到指定的位置
*
* @param index 最小值为0
* @param t
*/
public void add(int index, T t) {
checkIndex(index);
if (index == size) {
linkLast(t);
} else {
/**
* 如果index为0,说明新增结点将是头结点
*/
if (index == 0) {
linkFirst(t);
} else {
linkBefore(t, node(index - 1));
}
}
}
/**
* 获取指定位置的元素
*
* @param index
* @return
*/
public T get(int index) {
return node(index).data;
}
/**
* 删除元素
*
* @param t
* @return
*/
public boolean remove(T t) {
if (t == null) {
for (Node<T> x = first; x != null; x = x.next) {
if (x.data == null) {
int index = indexOf(t);
Node<T> prev = null;
if (index > 0) {
prev = node(indexOf(t)-1);
}
unlink(x, prev);
return true;
}
}
} else {
for (Node<T> x = first; x != null; x = x.next) {
if (t.equals(x.data)) {
int index = indexOf(t);
Node<T> prev = null;
if (index > 0) {
prev = node(indexOf(t)-1);
}
unlink(x, prev);
return true;
}
}
}
return false;
}
/**
* 根据位置删除元素
*
* @param index
* @return
*/
public T remove(int index) {
checkIndex(index);
Node<T> prev = null;
if (index > 0) {
prev = node(index-1);
}
return unlink(node(index), prev);
}
}
下面是测试代码。
public static void main(String[] args) {
MyLinkedList<String> list = new MyLinkedList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("e");
list.add("f");
list.add("g");
list.add("h");
list.add(3, "d");
int size = list.size();
System.out.println("size:" + size);
String str = list.get(2);
System.out.println("get :" + str);
list.remove(2);
size = list.size();
for(int i = 0; i < size; i ++) {
System.out.println(list.get(i));
}
}
2.3 两种存储结构优缺点
注意:这里的链式存储结构以单链表为准。
存储分配方式 | 时间性能 | 空间性能 | |
顺序存储 | 用一段连续的存储单元依次存储线性表的数据元素 | 查找:O(1) 插入和删除:O(n),需要移动元素 | 需要预分配存储空间,分大了浪费,分小了易发生溢出,有时比较难以分配足够的连续存储空间,往往导致内存分配失败而无法存储 |
链式存储 | 用一组任意的存储单元存放线性表的元素 | 查找:O(n),需要遍历逐个查找 插入和删除:O(1),因为结点存储不是连续的,不需要移动元素,不需要像顺序表那样移动后面的数据 | 不需要预分配,是动态分配空间,只要有空间就可以分配,元素个数也不受限制。不像顺序表那样只存储数据,还需要存储引用信息,所以较为占用空间。 |
2.4 应用
线性表也可以应用在计算机中的数据结构,基本上按照内在存储的方式,可分为静态数据结构(static data structure)和动态数据结构(dynamic data structure)两种。
2.4.1 静态数据结构
静态数据结构或称为“密集表”(dense list),它是一种将有序列表的数据使用连续分配空间(contiguous allocation)来存储的。例如数组类型就是一种典型的静态数据结构,优点是设计时相当简单,读取与修改表中任一元素的时间都固定。缺点是删除或插入数据时,需要移动大量的数据。另外静态数据结构的内存分配是在编译时,就必须分配给相关的变量,因此数组在建立之初,必须事先声明最大可能的固定存储空间,容易造成内存的浪费。
2.4.2 动态数据结构
动态数据结构又称为“链接列表”(linked list,简称链表),它是一种线性表的数据使用不连续存储空间来存储。例如指针类型就是一种典型的动态数据结构。优点是数据的插入或删除都相当方便,不需要移动大量数据。另外动态数据结构的内存分配在执行时才发生,所以不需要事先声明,能够充分节省内存。缺点是设计数据结构时较为麻烦,另外在查找数据时,也无法像静态数据一样随机读取,必须顺序找到该数据为止。
2.5 适用场景
通过上面的对比可以看出,若线性表需要频繁查找,很少进行插入和删除操作时;或事先知道线性表的长度时,采用顺序存储结构比较合适。若需要频繁的插入和删除,或线性表中元素的个数变化较大或不确定个数时,采用单链表比较合适。
比如游戏开发中,对于用户注册的个人信息,除了注册时插入数据外,绝大多数情况下都是读取,所以应该考虑采用顺序存储结构。而游戏中的玩家的武器或装备列表,玩家在游戏过程中,可能会随时增加或删除,此时采用链式存储更合适。这里的举例都是比较简单的,实际上有些时候比这个要复杂的多。
3. 静态链表
3.1 静态链表
C语言具有指针能力,使得它可以非常容易的操作内存中的地址和数据,这比其它高级语言更加灵活方便。后面的面向对象语言,如Java、C#等,虽然不使用指针,但因为启用对象引用机制,从某种角度也间接实现了指针的某些作用,但对一些语言,如Basic、Fortran等早期的编程高级语言,由于没有指针,链表结构按照前面的描述,它就没法实现了。此时该怎么办呢?
可能有人提出用数组来代替指针,来描述单链表。首先让数组的元素都是由两个数据域组成,data和cur。即数组的每个下标都对应一个data和一个cur。数据域data用来存储数据元素,也就是通常要处理的数据,而游标cur相当于单链表中的next指针,存放该元素的后继在数组中的下标。
我们把这种用数组描述的链表叫做静态链表,这种描述方法还有起名叫做游标实现法。
为了方便插入数据,通常会把数组设置的大一些,以便有一些空闲空间,以便在插入时不至于溢出。
静态链表中要解决的是如何用静态模拟动态链表结构的存储空间的分配,需要时申请,无用时释放。在静态链表中操作是数组,不存在像动态链表的结点申请和释放问题,所以需要实现申请和释放这两个方法,才好做插入和删除操作。
3.2 静态链表的优缺点
总的来说,静态链表其实是为了给没有指针的高级语言设计的一种实现单链表能力的方法。尽管不一定用得上,但这样的思考方式还是很巧妙的,最好能够理解其思想,可以借鉴一二。
3.2.1 优点
在插入和删除操作时,只需要修改游标,不需要移动元素,从而改进了顺序存储结构中的插入和删除操作需要移动大量元素的缺点。
3.2.2 缺点
没有解决连续存储分配带来的表长度难以确定的问题;失去了顺序存储结构随机存取的特性。
4. 循环链表(circular linked list)
对于单链表,由于每个结点只存储了向后的指针,到了尾标志就停止了向后链的操作。这样以来,中间的某一个结点就无法找到它的前驱结点了。
循环链表就是将单链表中终端结点的指针端由空指针改为指向头结点,使得整个单链表形成一个环,最终是头尾相接的单链表,简称循环链表(circular linked list)。
从逻辑上来说,循环链表和单链表的主要差异在于循环的判断条件上。单链表是判断结点的后继结点是否为空,循环链表则是结点的后继结点不等于头结点,则循环结束。
在单链表中,有了头结点,访问第一个结点的时间复杂度是O(1)。但要访问后面的结点,时间复杂度是O(n),需要把链表都扫描一遍。有没有可能用O(1)的时间复杂度由链表指针访问到最后一个结点呢?当然可以!
改造下这个循环链表,不用头指针,而是用指向终端结点的尾指针来表示循环链表。
从上图可以看出,终端结点用尾指针rear指示,查找端线结点是O(1),而开始结点,其实就是rear—> next—> next,其时间复杂度也是O(1)。
5. 双向链表(double linked list)
在单链表中,有了下一个结点的指针,这使得查找下一个结点的时间复杂度为O(1)。但若要查找上一个结点的话,最坏的时间复杂度就是O(n)了,因为需要遍历查找。
5.1 双向链表
所以为了克服单链表的这一缺点,设计出双向链表。双向链表就是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。这样每个结点包含两个引用,一个指向下一个(后继)结点,另一个指向上一个(前驱)结点。
5.2 哨兵(sentinel)
哨兵是一个哑对象,其作用是简化边界条件的处理。例如,假设在链表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的引用。下图中的(a)显示,一个空的链表只有一个哨兵构成,L.nil.next和L.nil.prev同时指向L.nil。
其中,图(b)中表头关键字为9,表尾关键字为1。图(c)执行链表插入后的链表,插入的结点的关键字为25,新插入的对象成为表头。图(d)是删除关键字为1的对象后的链表,新的表尾是关键字为4的对象。
哨兵基本不能降低数据结构相关操作的渐近时间界,但可以降低常数因子。在循环语句中使用哨兵的好处往往在于可以使代码简洁,而非提高速度。举例来说,使用哨兵使链表的代码变得简洁了,但在插入和删除过程上仅节约了O(1)的时间。然而,在另一些情况下,哨兵的使用使循环语句的代码更紧凑,从而降低了运行时间中 n 或 等项的系数。
我们应当慎用哨兵。假如有许多个很短的链表,它们的哨兵所占用的额外的存储空间会造成严重的存储浪费。这里仅当可以真正简化代码时才使用哨兵。
如果可以忽视表头和表尾的边界条件,则删除可以更简单些。
5.3 双向链表的Java实现
在Java中,有双链表的实现,比如LinkedList。下面的实现代码大部分来源的java.util.LinkedList类。代码如下:
public class MyDoubleLinked<T> {
/**
* 链表的长度
*/
transient int size = 0;
/**
* 链表的头结点
*/
transient Node<T> head;
/**
* 链表的最后一个结点
*/
transient Node<T> last;
public MyDoubleLinked(){ }
/**
* 链表的长度
*
* @return
*/
public int size() {
return size;
}
/**
* 检查index的值
* @param index
*/
private void checkIndex(int index) {
if (index < 0 || index > size) {
throw new RuntimeException("index error");
}
}
/**
* 根据位置搜索元素,二分查找
*
* @param index
* @return
*/
private Node<T> node(int index) {
checkIndex(index);
if (index < (size >> 1)) {
Node<T> h = head;
for (int i = 0; i < index; i++) {
h = h.next;
}
return h;
} else {
Node<T> l = last;
for (int i = size - 1; i > index; i--){
l = l.prev;
}
return l;
}
}
/**
* 将元素添加到结点之前
*
* @param e
* @param t
*/
private void linkBefore(T e, Node<T> t) {
/**
* 前一个结点
*/
final Node<T> prev = t.prev;
final Node<T> newNode = new Node<T>(prev, e, t);
t.prev = newNode;
/**
* 若前一结点为空,则新增的元素为前一个结点,否则为下一个结点
*/
if (prev == null) {
head = newNode;
} else {
prev.next = newNode;
}
size++;
}
/**
* 将元素链表到链表尾部
*
* @param e
*/
private void linkLast(T e) {
/**
* 此时的最后结点
*/
final Node<T> l = last;
final Node<T> newNode = new Node<>(l, e, null);
/**
* 添加后的最后结点
*/
last = newNode;
/**
* 若最后结点为空,则将结点设置到头结点;否则添加结点前的最后结点的下一个节点为所添加的结点
*/
if (l == null) {
head = newNode;
} else {
l.next = newNode;
}
size++;
}
/**
* 添加结点,链表的长度增加1
*
* @param t
*/
public void add(T t) {
linkLast(t);
}
/**
* 添加元素
*
* @param index
* @param t
*/
public void add(int index, T t) {
checkIndex(index);
if (index == size) {
linkLast(t);
} else {
linkBefore(t, node(index));
}
}
/**
* 根据位置获取结点的元素内容
*
* @param index
* @return
*/
public T get(int index) {
return node(index).data;
}
/**
* 根据元素内容删除元素
*
* @param t
* @return
*/
public boolean remove(T t) {
if (t == null) {
for (Node<T> x = head; x != null; x = x.next) {
if (x.data == null) {
unlink(x);
return true;
}
}
} else {
for (Node<T> x = head; x != null; x = x.next) {
if (t.equals(x.data)) {
unlink(x);
return true;
}
}
}
return false;
}
/**
* 根据位置删除元素
*
* @param index
* @return
*/
public T remove(int index) {
checkIndex(index);
return unlink(node(index));
}
/**
* 取消链结,将取消链接结点的元素内容、下一个结点和上一个结点全部设置为空,并重新设置取消链接结点的前后结点
*
* @param x 当前结点,也是要取消链接的结点
* @return
*/
private T unlink(Node<T> x) {
/**
* 要取消链接的结点的数据
*/
final T element = x.data;
/**
* 要取消链接的结点的下一个结点
*/
final Node<T> next = x.next;
/**
* 要取消链接的结点的上一个结点
*/
final Node<T> prev = x.prev;
/**
* 若上一个结点为空,则说明要取消链接的结点为头结点,则重新设置头结点为要取消链接的下一个结点;
* 若上一个结点不为空,则设置取消链接结点的上一个结点的下一个结点为要取消链接的下一个结点,同时设置要取消链接结点的上一个结点为空
*/
if (prev == null) {
head = next;
} else {
prev.next = next;
x.prev = null;
}
/**
* 若要取消链接结点的下一个结点为空,则说明要取消链接的结点是最后一个结点,则重新设置最后一个结点为要取消链接的上一个结点;
* 否则设置取消链接结点的下一个结点的上一个结点指向为取消链接结点的上一个结点指向,同时将取消链接结点的下一个结点设置为空
*/
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.data = null;
size--;
return element;
}
}
测试代码如下。
public static void main(String[] args) {
MyDoubleLinked<String> list = new MyDoubleLinked<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
int size = list.size();
size = size >> 1;
size = size << 1;
System.out.println(size);
String str = list.get(2);
System.out.println("index 2:" + str);
str = list.remove(2);
System.out.println("remove 2:" + str);
list.add(3, "e");
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
size = list.size();
for(int i = 0; i < size; i ++) {
System.out.println(list.get(i));
}
}