舒适的环境很难培养出坚强的品格,被安排好的人生, 也很难做出伟大的事。
前言
线性结构是最简单,也是最常用的数据结构之一。线性结构的特点是:在数据元素的有限集中,除第一个元素无直接前驱,最后一个元素无直接后继以外,每个数据元素有且仅有一个直接前驱元素和一个直接后继元素。本中主要介绍了线性表的基本概念、定义线性表的抽象数据类型;在给出线性表的顺序存储结构和链式存储结构的基础上,分别给出线性表抽象数据类型的实现。
1. 线性表及抽象数据类型
1.1 线性表的定义
线性表:零个或多个具有相同数据类型的数据元素的有限序列
线性表的特点:
(1)元素个数有限 (2)逻辑上元素有先后次序
(3)数据类型相同 (4)仅讨论元素间的逻辑关系
注:线性表是逻辑结构,顺序表和链表是存储结构。
1.2 线性表的抽象数据类型
下面我们给出线性表的抽象数据类型定义。
ADT 线性表(List)
数据对象
D = {ai | ai∈D0, i=0, 1, 2 … n-1,D0为某一数据对象}
数据关系:
R = {<ai, ai+1> | ai, ai+1∈D,i=0, 1, 2 … n-2}
基本操作
getSzie() 返回线性表的大小,即数据元素的个数。
isEmpty() 如果线性表为空返回 true,否则返回 false。
indexOf(e) 返回数据元素 e 在线性表中的序号。如果 e 不存在则返回-1。
insert(i , e) 将数据元素 e 插入到线性表中 i 号位置。若 i 越界,报错。
remove(i) 删除线性表中序号为 i 的元素,并返回之。若 i 越界,报错。
replace(i, e) 替换线性表中序号为 i 的数据元素为 e,返回原数据元素。若 i 越界,报错。
getElement(i) 返回线性表中序号为 i 的数据元素。若 i 越界,报错。
end ADT
在上述抽象数据类型的定义中,定义了几种基本操作,然而对于线性表的操作并不仅限于上述的操作,根据实际情况的需要还可以定义更多更复杂的操作。例如,将两个线性表合并为一个更大的线性表;把一个线性表分成两个线性表;对现有线性表进行复制等。
2. 顺序存储结构
线性表的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。 可以使用一维数组来实现顺序存储结构,数组大小有两种方式指定,一是静态分配,二是动态扩展。
线性表从 1 开始,而数组从 0 开始, 于是线性表的第 i 个元素是要存储在数组下标为 i-1 的位置,即数据元素的序号和存放它的数组下标之间存在对应关系,如下图所示。
假设线性表的每个数据元素需占用 K 个存储单元,则线性表中第 i 个数据元素的存储地址 LOC(ai) 与第 i+1 个数据元素的存储地址 LOC(ai+1) 之间的关系为(LOC 表示获得存储地址的函数):
LOC(ai+1) = LOC(ai) + K
所以对于第 i 个数据元素的存储地址可以由 a1 推算得出:
LOC(ai) = LOC(a1) + (i-1)*K
其中 LOC(a1) 为第一个元素 a1 的存储地址,通常称为线性表的起始地址。
线性表顺序存储结构 Java 实现:
public class ArrayList<T> {
private final int MAXSIZE = 8; // 数组的默认大小
private int size; // 线性表中数据元素的个数
private T[] elements; // 数据元素数组
// 构造方法
public ArrayList() {
this.size = 0;
this.elements = (T[]) new Object[MAXSIZE];
}
// 返回线性表的大小,即数据元素的个数
public int getSize() {
return this.size;
}
// 如果线性表为空返回 true,否则返回 false
public boolean isEmpty() {
return this.size == 0;
}
public void checkIndex(int index) {
if (index < 1 || index > size + 1) {
throw new IndexOutOfBoundsException("位置" + index + "越界");
}
}
// 将数据元素e插入到线性表中第i个位置
public void insert(int i, T e) {
checkIndex(i);
if (size >= elements.length) {
expandSpace();
}
for (int j = size; j > i - 1; j--) {
elements[j] = elements[j - 1];
}
elements[i - 1] = e;
size++;
}
// 数组扩容
private void expandSpace() {
T[] a = (T[]) new Object[elements.length * 2];
for (int i = 0; i < elements.length; i++) {
a[i] = elements[i];
}
elements = a;
}
// 删除线性表的第i个元素,并返回之
public Object remove(int i) {
checkIndex(i);
Object obj = elements[i - 1];
for (int j = i - 1; j < size - 1; j++) {
elements[j] = elements[j + 1];
}
elements[--size] = null;
return obj;
}
// 将线性表的第i个元素替换为e,返回原数据元素
public void replace(int i, T e) {
checkIndex(i);
elements[i - 1] = e;
}
// 获取线性表第i个元素
public T getElement(int i) {
checkIndex(i);
return elements[i - 1];
}
// 返回数据元素e在线性表中的位置
public int indexOf(T e) {
for (int i = 0; i < size; i++) {
if (elements[i].equals(e)) {
return i + 1;
}
}
return -1;
}
}
顺序存储结构优缺点
-
优点
- 随机访问特性,查找 O(1) 时间,存储密度高;逻辑上相邻的元素,物理上也相邻; 缺点
- 插入删除需移动大量元素
3. 链式存储结构
实现线性表的另一种方法是链式存储,即用指针将存储线性表中数据元素的那些单元依次串联在一起。这种方法避免了在数组中用连续的单元存储元素的缺点,因而在执行插入或删除运算时,不再需要移动元素来腾出空间或填补空缺。然而我们为此付出的代价是,需要在每个单元中设置指针来表示表中元素之间的逻辑关系,因而增加了额外的存储空间的开销。
链表是一系列的存储数据元素的单元通过指针串接起来形成的,因此每个单元至少有两个域,一个域用于数据元素的存储,另一个域是指向其他单元的指针。这里具有一个数据域和多个指针域的存储单元通常称为结点(node)。
3.1 单链表
如下图所示,单链表由 n 个节点链接而成,且每个结点只包含一个指针域
链表中第一个结点的存储位置叫做头指针。
单链表分为带头结点和不带头结点两种,不管有没有头结点,头指针都指向链表的第一个节点(有头结点指向头结点)。
头结点:数值域可不设任何信息,头结点的指针域指向链表的第一个元素。
带头节点的好处有:
(1)链表第一位置节点上的操作和其它位置上的操作一致
(2)无论链表是否为空,头指针都指向头结点(非空),空表和非空表处理一样
Java 中是使用对象的引用来替代指针的。
单链表的插入与删除
插入节点
待插入节点为 s,一般采用后插法,即先找到插入位置节点的前驱节点,然后插入,时间复杂度O(n)。
还有一种方法是,直接插入到指定位置的后面(前插法),然后交换两个节点的值。
删除节点
待删除节点为q,也是先找到前驱节点,修改指针域即可,时间复杂度O(n)。
删除节点也能直接删除其后继节点,然后将后继节点的内容赋给自己即可,时间复杂度为O(1)。
单链表 Java 实现
public class SingleLinkedList<T> {
private Node<T> head; // 头结点:指向第一个结点的位置
private Node<T> tail; // 尾结点
private int size; // 链表中元素个数
public SingleLinkedList() {
this.head = new Node<T>();
this.tail = new Node<T>();
head.next = tail.next = null;
}
// 获取元素个数
public int size() {
return this.size;
}
// 检查是否越界
private void checkIndex(int index) {
if (index < 0 || index > size)
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}
// 获取指定位置的结点
private Node<T> entry(int index) {
checkIndex(index);
Node<T> node = head.next;
for (int i = 0; i < index; i++) {
node = node.next;
}
return node;
}
// 在链表末尾添加元素
public boolean add(T o) {
return add(size, o);
}
// 在指定位置插入元素(前插法)
public boolean add(int index, T o) {
checkIndex(index);
Node<T> node = new Node<T>(o, null);
Node<T> prev; // 获取待插入位置的前一个结点
if (index == 0) {
prev = head;
} else {
prev = entry(index - 1);
}
node.next = prev.next;
prev.next = node;
if (index == size) {
this.tail = node;
}
size++;
return true;
}
// 获取第i个元素
public T getElem(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}
return entry(index).data;
}
// 删除指定位置结点,并返回该结点元素
public T remove(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}
Node<T> prev = entry(index - 1);
Node<T> target = prev.next;
prev.next = target.next;
target.next = null;
T t = target.data;
target.data = null;
if (index == size - 1) {
this.tail = prev;
}
size--;
return t;
}
// 结点定义
private static class Node<T> {
T data;
Node<T> next;
Node(T t, Node<T> next) {
this.data = t;
this.next = next;
}
Node() {
}
}
}
经验总结:
- 若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构。若需要频繁插入和删除时,宜采用单链表结构。
- 当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构,这样可以不需要考虑存储空间的大小问题。
3.2 静态链表
C 语言具有指针的能力,Java、C# 等虽不使用指针,但因启用了对象引用机制,从某种角度也间接实现了指针的某些作用。但对于一些语言,如 Basic、Fortran 等早期的编程高级语言,由于没有指针,该如何实现链表呢?
答案就是用数组代替指针,来描述单链表。首先让数组的元素都是由两个数据域组成,data 和 cur。也就是说,数组的每个下标都对应一个 data 和一个 cur。数据域 data,用来存放数据元素;而 cur(游标) 相当于单链表中的 next 指针,这里和链表不同是,它存的不再是指向下一个结点的内存地址。而是下一个节点在数组中的下标。这种用数组描述的链表叫做静态链表,也叫游标实现法。
由上图我们需要注意以下几点:
- 我们对数组的第一个元素和最后一个元素做特殊处理,不存放数据。
- 把未使用的数组元素称为备用链表。
- 数组的第一个元素(下标为0)的 cur 域存放备用链表第一个结点的下标。
- 数组的最后一个元素的 cur 域存放第一个有数据的结点的下标,相当于链表中头结点的存在。链表为空时,其值为0。
引出的问题:数组的长度定义的问题,无法预支。所以,为了防止溢出,我们一般将静态表开的大一点。
3.3 循环链表
将单链表的尾结点的指针由空指针改为指向头结点,就使整个单链表形成一个环。这种头尾相接的单链表称为单循环链表,简称循环链表。
3.4 双向链表
双向链表是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。 所以在双向链表的每个结点中都有两个指针域,一个指向直接后继,另一个指向直接前驱。