结构与算法(8)-----线性表
我们都知道数组作为数据存储结构有一定的缺陷。在无序数组中,查找性能差,在有序数组中,插入效率低,并且无论是有序还是无序数组都存在删除效率低的问题,并且数组在创建后,其大小是固定了,设置的过大会造成内存的浪费,过小又不能满足数据量的存储,所以其扩展性也不行。
1、线性表概念
java数据结构一书对线性表的定义:
线性表是由n(n>=0)个相同类型的数据元素 a0,a1,…,an-1 组成的有限序列,在数学中记作(a0,a1,…,an-1)。
其中ai的数据类型可以是基本数据类型(int,float等)、字符或类。 n 代表线性表的元素个数,也称其为长度(Length)。
若 n=0,则为空表;
若 n>0,则 ai(0 < i < n-1)有且仅有一个前驱(Predecessor)元素ai-1 和 一个后继(Successor)元素ai+1,
a0(第一个元素)没有前驱元素,ai(最后一个元素)没有后继元素。
线性表根据元素在空间上的排列又分为顺序存储与链式存储;
1.1、线性表顺序存储结构:
顺序存储结构底层是利用数组来实现的,而数组可以存储具有相同数据类型的元素集合,如int,float或者自定义类型等,当我们创建一个数组时,计算机操作系统会为该数组分配一块连续的内存块,这也就意味着数组中的每个存储单元的地址都是连续的,因此只要知道了数组的起始内存地址就可以通过简单的乘法和加法计算出数组中第n-1个存储单元的内存地址,就如下图所示:
通过上图可以发现为了访问一个数组元素,该元素的内存地址需要计算其距离数组基地址(local(a0))的偏移量,即用一个乘法计算偏移量然后加上基地址,就可以获得数组中某个元素的内存地址。其中 c 代表的是元素数据类型的存储空间大小,而序号则为数组的下标索引。整个过程需要一次乘法和一次加法运算,因为这两个操作的执行时间是常数时间,所以我们可以认为数组访问操作能再常数时间内完成,即时间复杂度为O(1),这种存取任何一个元素的时间复杂度为O(1)的数据结构称之为随机存取结构。而顺序表的存储原理正如上图所示,因此顺序表的定义如下(引用):
线性表的顺序存储结构称之为顺序表(Sequential List),它使用一维数组依次存放从a0到an-1的数据元素 (a0,a1,…,an-1),将ai(0< i <> n-1)存放在数组的第i个元素,使得ai与其前驱ai-1及后继ai+1的存储位置相邻,因此数据元素在内存的物理存储次序反映了线性表数据元素之间的逻辑次序。
1.2、线性表顺序存储的实现与分析:
顺序表接口
// 顺序表顶级接口 public interface ISeqList<T> { boolean isEmpty(); //判断链表是否为空 int length(); //链表长度 T get(int index); // 获取元素 T set(int index, T data); //设置某个元素的值 boolean add(int index, T data); //根据index添加元素 boolean add(T data); //添加元素 T remove(int index); //根据index移除元素 boolean remove(T data); //根据data移除元素 boolean removeAll(T data); //根据data移除元素 void clear(); //清空链表 boolean contains(T data); //是否包含data元素 int indexOf(T data); //根据值查询下标 int lastIndexOf(T data); //根据data值查询最后一个出现在顺序表中的下标 String toString(); //输出格式 }
顺序表实现
public class SeqList<T> implements ISeqList<T> { private Object[] table; // 数组声明,用于存储元素 private int length; // 顺序表的大小 public SeqList(int capacity) { // 申请数组存储空间,元素初始化为null this.table = new Object[Math.abs(capacity)]; this.length = 0; } /** * 默认大小为64 */ public SeqList() { this(64); } /** * 传入一个数组初始化顺序表 * * @param array */ public SeqList(T[] array) { if (array == null) { throw new NullPointerException("array can\'t be empty!"); } // 创建对应容量的数组 this.table = new Object[array.length]; for (int i = 0; i < array.length; i++) { this.table[i] = array[i]; } this.length = array.length; } /** * 判断顺序表是否为空 * * @return */ @Override public boolean isEmpty() { return this.length == 0; } /** * 计算顺序表的大小 * * @return */ @Override public int length() { return this.length; } /** * 获取元素 * * @param index * @return */ @Override public T get(int index) { if (index >= 0 && index < this.length) return (T) this.table[index]; return null; } /** * 设置某个结点的的值 * * @param index * @param data * @return */ @Override public T set(int index, T data) { if (index >= 0 && index < this.length && data != null) { T old = (T) this.table[index]; this.table[index] = data; return old; } return null; } /** * 根据index插入元素 * * @param index * 插入位置的下标,0作为起始值 * @param data * 插入的数据 * @return */ @Override public boolean add(int index, T data) { if (data == null) { return false; } // 插入下标的容错判断,插入在最前面 if (index < 0) { index = 0; } // 插入下标的容错判断,插入在最后面 if (index > this.length) { index = this.length; } // 判断内部数组是否已满 if (this.length == table.length) { // 把原数组赋值给临时数组 Object[] temp = this.table; // 对原来的数组进行成倍拓容,并把原数组的元素复制到新数组 this.table = new Object[temp.length * 2]; // 先把原数组下标从0到index-1(即插入位置的前一个位置)复制到新数组 for (int i = 0; i < index; i++) { this.table[i] = temp[i]; } } // 从原数组的最后一个元素开始直到index位置,都往后一个位置 // 最终腾出来的位置就是新插入元素的位置了 for (int j = this.length - 1; j >= index; j--) { this.table[j + 1] = this.table[j]; } // 插入新值 this.table[index] = data; // 长度加一 this.length++; // 插入成功 return true; } /** * 在尾部插入元素 * * @param data * @return */ @Override public boolean add(T data) { return add(this.length, data); } /** * 根据index删除元素 * * @param index * 需要删除元素的下标 * @return */ @Override public T remove(int index) { if (this.length != 0 && index >= 0 && index < this.length) { // 记录删除元素的值并返回 T old = (T) this.table[index]; // 从被删除的元素位置开,其后的元素都依次往前移动 for (int j = index; j < this.length - 1; j++) { this.table[j] = this.table[j + 1]; } // 设置数组元素对象为空 this.table[this.length - 1] = null; // 顺序表长度减1 this.length--; return old; } return null; } /** * 根据data删除某个数据 * * @param data * @return */ @Override public boolean remove(T data) { if (this.length != 0 && data != null) { return this.remove(this.indexOf(data)) != null; } return false; } @Override public boolean removeAll(T data) { boolean done = false; if (this.length != 0 && data != null) { int i = 0; while (i < this.length) // 找出数据相同的选项 if (data.equals(this.table[i])) { this.remove(i);// 根据下标删除 done = true; } else { i++;// 继续查找 } } return done; } /** * 清空顺序表 */ @Override public void clear() { this.length = 0; } /** * 判断两个顺序表是否相等 * * @param obj * @return */ public boolean equals(Object obj) { // 如果内存地址相当,那么两个顺序肯定相等 if (this == obj) { return true; } // 判断是否属于同种类型对象 if (obj instanceof SeqList) { // 强制转换成顺序表 SeqList<T> list = (SeqList<T>) obj; for (int i = 0; i < this.length(); i++) // 比较每个值是否相当 if (!(this.get(i).equals(list.get(i)))) return false; return true; } return false; } /** * 根据数据查询下标 * * @param data * @return */ @Override public int indexOf(T data) { if (data != null) { for (int i = 0; i < this.length; i++) { // 相当则返回下标 if (this.table[i].equals(data)) { return i; } } } return -1; } /** * 根据data查询最后一个出现在顺序表中的下标 * * @param data * @return */ @Override public int lastIndexOf(T data) { if (data != null) { for (int i = this.length - 1; i >= 0; i--) { if (data.equals(this.table[i])) { return i; } } } return -1; } /** * 查询是否包含某个数据 * * @param data * @return */ @Override public boolean contains(T data) { return this.indexOf(data) >= 0; } @Override public String toString() { String str = "("; if (this.length != 0) { for (int i = 0; i < this.length - 1; i++) str += this.table[i].toString() + ", "; str += this.table[this.length - 1].toString(); } return str + ") "; } }
实现代码中声明了一个Object数组,初始化数组大小默认为64,存储的元素类型为泛型T,length则为顺序表的长度,部分方法实现比较简单,这里不过多分析,我们主要分析get(int index)、set(int index, T data)、add(int index, T data)、remove(int index)、removeAll(T data)、indexof(T data)等方法的实现
- get(int index) 实现分析
从顺序表中取值是一种相当简单高效的操作,这是由于顺序表内部采用了数组作为存储数据容器。因此只需根据传递的索引值,直接获取数组中对应下标的值即可,代码如下:
public T get(int index){ if (index>=0 && index<this.length){ return (T) this.table[index]; } return null; }
- set(int index, T data) 实现分析
在顺序表中替换值也是非常高效和简单的,只要根据传递的索引值index找到需要替换的元素,然后把对应元素值替换成传递的data值即可,代码如下:
public T set(int index, T data){ if (index>=0 && index<this.length&& data!=null){ T old = (T)this.table[index]; this.table[index] = data; return old; } return null; }
- add(int index, T data)实现分析
在顺序表中执行插入操作时,如果其内部数组的容量尚未达到最大值时,可以归结为两种情况,一种是在头部插入或者中间插入,这种情况下需要移动数组中的数据元素,效率较低,另一种是在尾部插入,无需移动数组中的元素,效率高。但是当顺序表内部数组的容量已达到最大值无法插入时,则需要申请另一个更大容量的数组并复制全部数组元素到新的数组,这样的时间和空间开销较大,于是效率也就不尽人意。因此在插入频繁的场景下,顺序表的插入操作并不是理想的选择。下面是顺序表在数组容量充足下头部或中间插入操作示意图(尾部插入比较简单就不演示了):
顺序表在数组容量不充足的情况下头部或中间插入操作示意图:
理解了以上几种顺序表的插入操作后,我们在看一下代码实现:
/** * 根据index插入元素 * @param index 插入位置的下标,0作为起始值 * @param data 插入的数据 * @return */ public boolean add(int index, T data){ if (data==null){ return false; } if (index<0){ //插入下标的容错判断,插入在最前面 index=0; } if (index>this.length) {//插入下标的容错判断,插入在最后面 index = this.length; } if (this.length==table.length) {//判断内部数组是否已满 Object[] temp = this.table; //把原数组赋值给临时数组 //对原来的数组进行成倍拓容,并把原数组的元素复制到新数组 this.table = new Object[temp.length*2]; //先把原数组下标从0到index-1(即插入位置的前一个位置)复制到新数组 for (int i=0; i<index; i++) { this.table[i] = temp[i]; } } //从原数组的最后一个元素开始直到index位置,都往后一个位置 // 最终腾出来的位置就是新插入元素的位置了 for (int j=this.length-1; j>=index; j--) { this.table[j + 1] = this.table[j]; } this.table[index] = data; //插入新值 this.length++; //长度加一 return true; //插入成功 }
- remove(int index) 实现分析
顺序表的删除和插入操作情况是类似的,如果是在中间或者头部删除顺序表中的元素,那么在删除位置之后的元素都必须依次往前移动,效率较低,如果是在顺序表的尾部直接删除的话,则无需移动元素,此情况下删除效率高。如下图所示在顺序表中删除元素ai时,ai之后的元素都依次往前移动:
删除操作的代码实现如下:
/** * 根据index删除元素 * @param index 需要删除元素的下标 * @return */ public T remove(int index){ if (this.length!=0 && index>=0 && index<this.length){ //记录删除元素的值并返回 T old = (T)this.table[index]; //从被删除的元素位置开,其后的元素都依次往前移动 for (int j=index; j<this.length-1; j++) { this.table[j] = this.table[j + 1]; } //设置数组元素对象为空 this.table[this.length-1]=null; //顺序表长度减1 this.length--; return old; } return null; }
- removeAll(T data) 实现分析
在顺序表中根据数据data找到需要删除的数据元素和前面分析的根据index删除顺序表中的数据元素是一样的道理,因此我们只要通过比较找到与data相等的数据元素并获取其下标,然后调用前面实现的remove(int index)方法来移除即可。代码实现如下:
@Override public boolean removeAll(T data) { boolean done=false; if (this.length!=0 && data!=null){ int i=0; while (i<this.length) //找出数据相同的选项 if (data.equals(this.table[i])){ this.remove(i); //根据下标删除 done = true; } else{ i++; //继续查找 } } return done; }
- indexOf(T data) 实现分析
要根据data在顺序表中查找第一个出现的数据元素的下标,只需要通过对比数据项是否相等,相等则返回下标,不相等则返回-1,indexOf和lastIndexOf方法实现如下:
/** * 根据数据查询下标 * @param data * @return */ @Override public int indexOf(T data){ if (data!=null) for (int i=0; i<this.length; i++) { //相当则返回下标 if (this.table[i].equals(data)) return i; } return -1; } /** * 根据data查询最后一个出现在顺序表中的下标 * @param data * @return */ @Override public int lastIndexOf(T data){ if (data!=null) for (int i=this.length-1; i>=0; i--) if (data.equals(this.table[i])) return i; return -1; }
1.3、线性表顺序存储的效率分析:
通过上述分析,我们对顺序表的实现已有了比较清晰的认识,接下来主要针对获取、插入、修改、删除等主要操作看一下顺序表的执行效率问题。前面说过,由于顺序表内部采用了数组作为存储容器,而数组又是随机存取结构的容器,也就是说在创建数组时操作系统给数组分配的是一块连续的内存空间,数组中每个存储单元的地址都是连续的,所以在知道数组基地址后可以通过一个简单的乘法和加法运算即可计算出其他存储单元的内存地址(实际上计算机内部也就是这么做的),这两个运算的执行时间是常数时间,因此可以认为数组的访问操作能在常数时间内完成,即顺序表的访问操作(获取和修改元素值)的时间复杂为O(1)。
对于在顺序表中插入或删除元素,从效率上则显得不太理想了,由于插入或者删除操作是基于位置的,需要移动数组中的其他元素,所以顺序表的插入或删除操作,所花费的时间主要是用于移动元素,如在顺序表头部插入或删除时,效率就显得相当糟糕了。若在最前插入或删除,则需要移动n(这里假设长度为n)个元素;若在最后插入或删除,则需要移动的元素为0。这里我们假设插入或删除值为第i(0<i<=n)个元素,其概率为pipi,则插入或删除一个元素的平均移动次数求和为:
p1∗(n−1)+p2∗(n−2)+...+pi∗(n−i)+...+pn−1∗1+pn∗0=∑i=1n(pi∗(n−i))
如果在各个位置插入元素的概率相同即 pi=1n+1pi=1n+1 (n+1个插入位置任意选择一个的概率)则有:
∑i=1n(pi∗(n−i))=1n+1∑i=1n(n−i)=1n+1∗n(n+1)2=n2=O(n)
也就是说,在等概率的情况下,插入或删除一个顺序表的元素平均需要移动顺序表元素总量的一半,其时间复杂度是O(n)。
当然如果在插入时,内部数组容量不足时,也会造成其他开销,如复制元素的时间开销和新建数组的空间开销。
因此总得来说顺序表有以下优缺点:
-
优点
-
使用数组作为内部容器简单且易用
-
在访问元素方面效率高
-
数组具有内存空间局部性的特点,由于本身定义为连续的内存块,所以任何元素与其相邻的元素在物理地址上也是相邻的。
-
-
缺点
-
内部数组大小是静态的,在使用前必须指定大小,如果遇到容量不足时,需动态拓展内部数组的大小,会造成额外的时间和空间开销
-
在内部创建数组时提供的是一块连续的空间块,当规模较大时可能会无法分配数组所需要的内存空间
- 顺序表的插入和删除是基于位置的操作,可能需要移动内部数组中的其他元素,这样会造成较大的时间开销,时间复杂度为O(n)
-
2、线性表的链式存储结构的实现(链表-- Linked List)
2.1、链表的链式存储结构设计原理概要
通过前面对线性顺序表的分析,我们知道当创建顺序表时必须分配一块连续的内存存储空间,而当顺序表内部数组的容量不足时,则必须创建一个新的数组,然后把原数组的的元素复制到新的数组中,这将浪费大量的时间。而在插入或删除元素时,可能需要移动数组中的元素,这也将消耗一定的时间。
鉴于线性表的顺序存储的种种优劣,线性表的链式存储结构,链表就出场了,链表在初始化时仅需要分配一个元素的存储空间,并且插入和删除新的元素也相当便捷,同时链表在内存分配上可以是不连续的内存,也不需要做任何内存复制和重新分配的操作,由此看来顺序表的缺点在链表中都变成了优势,实际上也是如此,当然链表也有缺点,主要是在访问单个元素的时间开销上,这个问题留着后面分析,我们先通过一张图来初步认识一下链表的存储结构,如下:
从图可以看出,线性链表的存储结构是用若干个地址分散的存储单元存放数据元素,逻辑上相邻的数据元素在物理位置上不一定相邻,因此每个存储单元中都会有一个地址指向域,这个地址指向域指明其后继元素的位置。
在链表中存储数据的单元称为结点(Node),从图中可以看出一个结点至少包含了数据域和地址域,其中数据域用于存储数据,而地址域用于存储前驱或后继元素的地址。前面我们说过链表的插入和删除都相当便捷,这是由于链表中的结点的存储空间是在插入或者删除过程中动态申请和释放的,不需要预先给单链表分配存储空间的,从而避免了顺序表因存储空间不足需要扩充空间和复制元素的过程,提高了运行效率和存储空间的利用率。
2.2、单链表的设计与实现
单链表是链表中结构最简单的。一个单链表的节点(Node)分为两个部分,第1个部分保存数据信息,另1部分存储地址信息。最后一个节点存储地址的部分指向空值。
单向链表只可向一个方向遍历,一般查找一个节点的时候需要从第一个节点开始每次访问下一个节点,一直访问到需要的位置。而插入一个节点,对于单向链表,我们只提供在链表头插入,只需要将当前插入的节点设置为头节点,next指向原头节点即可。删除一个节点,我们将该节点的上一个节点的next指向该节点的下一个节点。
在表头增加节点:
删除节点:
链表节点实体:
/** * 节点 */ public class Node<T> { public T data; //数据域 public Node<T> next; //地址域 public Node(T data){ this.data=data; } public Node(T data,Node<T> next){ this.data=data; this.next=next; } }
链表接口定义:
/** * 链表 * @param <T> */ public interface ILinkedList<T> { boolean isEmpty(); //判断链表是否为空 int length(); //链表长度 T get(int index); //获取元素 T set(int index, T data); //设置某个结点的的值 boolean add(int index, T data); //根据index添加结点 boolean add(T data); //添加结点 T remove(int index); //根据index移除结点 boolean removeAll(T data); //根据data移除结点 void clear(); //清空链表 boolean contains(T data); //是否包含data结点 String toString(); //输出格式 }
链表接口实现:
/** * 单项链表的实现,不含独立头结点,不含尾部指针 */ public class SingleILinkedList<T> implements ILinkedList<T> { protected Node<T> head; // 带数据头结点 public SingleILinkedList(Node<T> head) { this.head = head; } public SingleILinkedList() { } /** * 传入一个数组,转换成链表 * * @param array */ public SingleILinkedList(T[] array) { this.head = null; if (array != null && array.length > 0) { this.head = new Node<T>(array[0]); Node<T> rear = this.head; int i = 1; while (i < array.length) { rear.next = new Node<T>(array[i++]); rear = rear.next; } } } /** * 通过传入的链表构造新的链表 * * @param list */ public SingleILinkedList(SingleILinkedList<T> list) { this.head = null; if (list != null && list.head != null) { this.head = new Node<>(list.head.data); Node<T> p = list.head.next; Node<T> rear = this.head; while (p != null) { rear.next = new Node<T>(p.data); rear = rear.next; p = p.next; } } } /** * 判断链表是否为空 * * @return */ @Override public boolean isEmpty() { return this.head == null; } @Override public int length() { int length = 0; //标记长度的变量 Node<T> p = head; //变量p指向头结点 while (p != null) { length++; p = p.next; //后继结点赋值给p,继续访问 } return length; } /** * 根据index索引获取值 * * @param index * 下标值起始值为0 * @return */ @Override public T get(int index) { if (this.head != null && index >= 0) { int count = 0; Node<T> p = this.head; // 找到对应索引的结点 while (p != null && count < index) { p = p.next; count++; } if (p != null) { return p.data; } } return null; } /** * 根据索引替换对应结点的data * @param index 下标从0开始 * @param data * @return 返回旧值 */ @Override public T set(int index, T data) { if (this.head != null && index >= 0 && data != null) { Node<T> pre = this.head; int count = 0; while (pre != null && count < index) { pre = pre.next; count++; } if (pre != null) { T oldData = pre.data; pre.data = data;// 设置新值 return oldData; } } return null; } /** * 根据下标添加结点 1.头部插入 2.中间插入 3.末尾插入 * * @param index * 下标值从0开始 * @param data * @return */ @Override public boolean add(int index, T data) { if (data == null) { return false; } // 在头部插入 if (this.head == null || index <= 1) { this.head = new Node<T>(data, this.head); } else { // 在尾部或中间插入 int count = 0; Node<T> front = this.head; while (front.next != null && count < index - 1) { front = front.next; count++; } // 尾部添加和中间插入属于同种情况,毕竟当front为尾部结点时front.next=null front.next = new Node<T>(data, front.next); } return true; } /** * 默认尾部插入 * * @param data * @return */ @Override public boolean add(T data) { return add(Integer.MAX_VALUE, data); } /** * 根据索引删除结点 * * @param index * @return */ @Override public T remove(int index) { T old = null; if (this.head != null && index >= 0) { // 直接删除的是头结点 if (index == 0) { old = this.head.data; this.head = this.head.next; } else { Node<T> front = this.head; int count = 0; // 查找需要删除结点的前一个结点 while (front.next != null && count < index - 1) { front = front.next; count++; } if (front.next != null) { // 获取旧值 old = front.next.data; // 更改指针指向 front.next = front.next.next; } } } return old; } /** * 根据data移除所有数据相同的结点 * * @param data * @return */ @Override public boolean removeAll(T data) { boolean isRemove = false; if (this.head != null && data != null) { // 如果移除的是头结点 if (data.equals(this.head.data)) { this.head = this.head.next; isRemove = true; } else { Node<T> front = this.head; Node<T> pre = front.next; // 查找所有数据相同的结点并删除 while (pre != null) { if (data.equals(pre.data)) { // 更改指针指向 front.next = pre.next; pre = front.next; isRemove = true; } else { front = pre; pre = pre.next; } } } } else {// data=null || this.head=null isRemove = false; } return isRemove; } /** * 清空链表 */ @Override public void clear() { this.head = null; } /** * 判断是否包含某个值 * * @param data * @return */ @Override public boolean contains(T data) { if (this.head != null && data != null) { Node<T> pre = this.head; while (pre != null) { if (data.equals(pre.data)) { return true; } pre = pre.next; } } return false; } /** * 从末尾连接两个链表 * * @param list */ public void concat(SingleILinkedList<T> list) { if (this.head == null) this.head = list.head; else { Node<T> pre = this.head; while (pre.next != null) pre = pre.next; pre.next = list.head; } } @Override public String toString() { String str = "("; Node<T> pre = this.head; while (pre != null) { str += pre.data; pre = pre.next; if (pre != null) str += ", "; } return str + ")"; } public static void main(String[] args) { String[] letters = { "A", "B", "C", "D", "E", "F" }; SingleILinkedList<String> list = new SingleILinkedList<>(letters); System.out.println("list.get(3)->" + list.get(3)); // D System.out.println("list:" + list.toString()); // (A, B, C, D, E, F) System.out.println("list.add(4,Y)—>" + list.add(4, "Y")); // true System.out.println("list.add(Z)—>" + list.add("Z")); // true System.out.println("list:" + list.toString()); // (A, B, C, D, Y, E, F, // Z) System.out.println("list.contains(Z)->" + list.contains("Z")); // true System.out.println("list.set(4,P)-->" + list.set(4, "P")); // Y System.out.println("list:" + list.toString()); // (A, B, C, D, P, E, F, // Z) System.out.println("list.removeAll(Z)->" + list.removeAll("Z")); // true System.out.println("list.remove(4)-->" + list.remove(4)); // P System.out.println("list:" + list.toString()); // (A, B, C, D, E, F) } }
我们来具体解析一下实现类:创建一个单链表 SingleILinkedList 并实现ILinkedList接口,覆盖其所有方法,声明一个单链表的头结点head,代表链表的开始位置,如下:
public class SingleILinkedList<T> implements ILinkedList<T> {
protected Node<T> headNode; //声明一个头结点 public SingleILinkedList(Node<T> head) { this.headNode = head; } //其他代码先省略 ..... }
- boolean isEmpty()实现分析
需要判断链表是否为空的依据是头结点head是否为null,当head=null时链表即为空链表,因此我们只需判断头结点是否为空即可,isEmpty方法实现如下:
//判断链表是否为空 @Override public boolean isEmpty() { return this.head==null; }
- int length()实现分析
由于单链表的结点数就是其长度,因此我们只要遍历整个链表并获取结点的数量即可获取到链表的长度。遍历链表需要从头结点HeadNode开始,为了不改变头结点的存储单元,声明变量p指向当前头结点和局部变量length,然后p从头结点开始访问,沿着next地址链到达后继结点,逐个访问,直到最后一个结点,每经过一个结点length就加一,最后length的大小就是链表的大小。实现代码如下:
@Override public int length() { int length=0; //标记长度的变量 Node<T> p=head; //变量p指向头结点 while (p!=null){ length++; p=p.next; //后继结点赋值给p,继续访问 } return length; }
- T get(int index)实现分析
在单链表中获取某个元素的值是一种比较费时的操作,需要从头结点开始遍历直至传入值index指向的位置,其中需要注意的是index是从0开始计算,也就是说传递的index=3时,查找的是链表中第4个位置的值。其查询获取值的过程如下图所示:
通过上图和如下代码,我们就可以很容易理解链表中取值操作的整个过程了。
/** * 根据index索引获取值 * @param index 下标值起始值为0 * @return */ @Override public T get(int index) { if(this.head!=null&&index>=0){ int count=0; Node<T> p=this.head; //找到对应索引的结点 while (p!=null&&count<index){ p=p.next; count++; } if(p!=null){ return p.data; } } return null; }
- add(int index, T data)实现分析
单链表的插入操作分四种情况:
情况1、空表插入一个新结点,插语句如下:
head=new Node<T>(x,null);
情况2、在链表的表头插入一个新结点(即链表的开始处),此时表头head!=null,因此head后继指针next应该指向新插入结点p,而p的后继指针应该指向head原来的结点,代码如下:
//创建新结点 Node<T> p =new Node<T>(x,null); p.next=head; //p的后继指针指向head原来的结点 head=p; //更新head
以上代码可以合并为如下代码:
//创建新结点,其后继为head原来的结点,head的新指向为新结点 head=new Node<T>(x,head);
执行过程如下图:
情况3、在链表的中间插入一个新结点p,需要先找到给定插入位置的前一个结点,假设该结点为front,然后改变front的后继指向为新结点p,同时更新新结点p的后继指向为front原来的后继结点,即front.next,其执行过程如下图所示:
代码实现如下:
//新结点p Node<T> p =new Node<T>(x,null); //更新p的后继指向 p.next=front.next; //更新front的后继指向 front.next=p;
以上三句代码合并为一句简洁代码:
front.next=new Node<T>(x,front.next);
情况4、在链表的表尾插入一个新结点(链表的结尾)在尾部插入时,同样需要查找到插入结点P的前一个位置的结点front(假设为front),该结点front为尾部结点,更改尾部结点的next指针指向新结点P,新结点P的后继指针设置为null,执行过程如下:
其代码语句如下:
//front的next指针指向新结点,新结点的next指针设置为null front.next=new Node<T>(x,null);
到此我们也就可以发现单向链表中的中间插入和尾部插入其实可以合并为一种情况。最后这里给出该方法整体代码实现,从代码实现上来看中间插入和尾部插入确实也视为同种情况处理了。
/** * 根据下标添加结点 * 1.头部插入 * 2.中间插入 * 3.末尾插入 * @param index 下标值从0开始 * @param data * @return */ @Override public boolean add(int index, T data) { if (data==null){ return false; } //在头部插入 if (this.head==null||index<=1){ this.head = new Node<T>(data, this.head); }else { //在尾部或中间插入 int count=0; Node<T> front=this.head; //找到要插入结点位置的前一个结点 while (front.next!=null&&count<index-1){ front=front.next; count++; } //尾部添加和中间插入属于同种情况,毕竟当front为尾部结点时front.next=null front.next=new Node<T>(data,front.next); } return true; }
- T remove(int index) 删除结点实现分析
在单向链表中,根据传递index位置删除结点的操作分3种情况,并且删除后返回被删除结点的数据:
a.删除链表头部(第一个)结点,此时需要删除头部head指向的结点,并更新head的结点指向,执行图示如下:
代码实现如下:
//头部删除,更新head指向 head=head.next;
b.删除链表的中间结点,与添加是同样的道理,需要先找到要删除结点r(假设要删除的结点为r)位置的前一个结点front(假设为front),然后把front.next指向r.next即要删除结点的下一个结点,执行过程如下:
代码语句如下:
Node<T> r =front.next; //更新结点指针指向 front.next=r.next; r=null;
c.删除链表的最后一个结点,通过遍历操作找到最后一个结点r的前一个结点front,并把front.next设置为null,即可。执行过程如下:
代码如下:
front.next=null; r=null;
我们把中间删除和尾部删除合并为如下代码:
Node<T> r =front.next; //如果不是尾部结点,更新结点front指针指向 if(r=!null){ front.next=r.next; r=null; }
该方法整体代码实现如 /**
* 根据索引删除结点 * @param index * @return */ @Override public T remove(int index) { T old=null; if (this.head!=null&&index>=0){ //直接删除的是头结点 if(index==0){ old=this.head.data; this.head=this.head.next; }else { Node<T> front = this.head; int count = 0; //查找需要删除结点的前一个结点 while (front.next != null && count < index - 1) { front = front.next; count++; } //获取到要删除的结点 Node<T> r = front.next; if ( r!= null) { old =r.data; //获取旧值
front.next=r.next; //更改指针指向
r=null; //释放
} } } return old; }
当然还有如下更简洁的代码写法:
@Override public T remove(int index) { T old=null; if (this.head!=null&&index>=0){ if(index==0){ //直接删除的是头结点 old=this.head.data; this.head=this.head.next; }else { Node<T> front = this.head; int count = 0; //查找需要删除结点的前一个结点 while (front.next != null && count < index - 1) { front = front.next; count++; } if ( front.next!= null) { old =front.next.data; //获取旧值 front.next=front.next.next; //更改指针指向 } } } return old; }
- void clear() 实现分析
清空链表是一件非常简单的事,只需让head=null即可;代码如下:
/** * 清空链表 */ @Override public void clear() { this.head=null; }
到此单链表主要的添加、删除、获取值、设置替换值、获取长度等方法已分析完毕~
通过如上我们也发现:使用链表结构可以克服数组链表需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。
2.3、带特殊头结点的单链表
前面分析的单链表是不带特殊头结点的 (Node<T> head=new Node<T>(data,null);)
(数据,地址)
所谓的特殊头结点就是一个没有值的结点即(Node<T> head=new Node<T>(null,null);)
此时空链表的情况如下:
正常的单链表(头结点数据区有数据) 特殊的单链表(头结点数据区为null,没有数据)
那么多了头结点的单向链表有什么好处呢?通过对没有带头结点的单链表的分析,我们可以知道,在链表插入和删除时都需要区分操作位,比如插入操作就分头部插入和中间或尾部插入两种情况(中间或尾部插入视为一种情况对待即可),如果现在有不带数据的头结点,那么对于单链表的插入和删除不再区分操作的位置,也就是说头部、中间、尾部插入都可以视为一种情况处理了,这是因为此时头部插入和头部删除无需改变head的指向了,头部插入如下所示:
接着再看看在头部删除的情况:
带头结点遍历从head.next开始:
因此无论是插入还是删除,在有了不带数据的头结点后,在插入或者删除时都无需区分操作位了(不再区分是头部插入,中间插入还是尾部插入了),
到此我们来小结一下带头结点的单链表特点:
- a. 空单链表只有一个结点,head.next=null。
- b. 遍历的起点为p=head.next。
- c. 头部插入和头部删除无需改变head的指向。
同时为了使链表在尾部插入时达到更加高效,我们可在链表内增加一个尾部指向的结点rear,如果我们是在尾部添加结点,那么此时只要通过尾部结点rear进行直接操作即可,无需从表头遍历到表尾,带尾部结点的单链表如下所示:
从尾部直接插入的代码实现如下:
/** * 尾部插入 * @param data * @return */ @Override public boolean add(T data) { if (data==null){ throw new NullPointerException("data can\'t be empty!"); } this.rear.next = new Node<T>(data); //更新末尾指针的指向 this.rear = this.rear.next; return true; }
2.4、循环单链表
链表通常由一连串节点组成,每个节点包含任意的实例数据(data fields)和一或两个用来指向上一个/或下一个节点的位置的链接("links")
链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。
使用链表结构可以克服数组链表需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。
2、单向链表(Single-Linked List)
单链表是链表中结构最简单的。一个单链表的节点(Node)分为两个部分,第一个部分(data)保存或者显示关于节点的信息,另一个部分存储下一个节点的地址。最后一个节点存储地址的部分指向空值。
单向链表只可向一个方向遍历,一般查找一个节点的时候需要从第一个节点开始每次访问下一个节点,一直访问到需要的位置。而插入一个节点,对于单向链表,我们只提供在链表头插入,只需要将当前插入的节点设置为头节点,next指向原头节点即可。删除一个节点,我们将该节点的上一个节点的next指向该节点的下一个节点。
在表头增加节点:
删除节点:
①、单向链表的具体实现
/** * 根据索引删除结点 * @param index * @return */@Overridepublic T remove(int index) { T old=null; if (this.head!=null&&index>=0){ //直接删除的是头结点if(index==0){ old=this.head.data; this.head=this.head.next; }else { Node<T> front = this.head; int count = 0; //查找需要删除结点的前一个结点while (front.next != null && count < index - 1) { front = front.next; count++; } //获取到要删除的结点 Node<T> r = front.next; if ( r!= null) { //获取旧值 old =r.data; //更改指针指向 front.next=r.next; //释放 r=null; } } } return old; }