目录
【顺序表的缺点】
1、在顺序表中插入元素和删除元素时需要移动元素,时间复杂度为O(N)。
2、给顺序表进行扩容时,一般都是进行二倍扩容,若只插入一个元素,就会造成内存的浪费,拷贝数据时也会消耗时间。
由于顺序表具有这些缺陷,那么就会引起思考,有没有一种数据结构可以随用随取,即要添加一个元素,就给一个空间,不造成浪费,同时插入或删除元素时是否可以不移动元素。那就是接下来要介绍的一种新的数据结构——链表。
一、链表
1.1 链表的概念
链表是一种物理存储结构上非连续存储结构,数据元素的逻辑顺序是通过链表中的引用链接次序实现的。
1.2 链表的结构
链表的结构非常多样,但都是由节点组成,节点又分单向节点与双向节点,单向节点是由数值域 value 以及 next 域组成,而双向节点比单向节点多一个 prev 域,其中 value 用于存放该节点的值,next 用于存放下一个节点的地址,prev 用于存放上一个节点的地址。
单向与双向节点图:
链表图:
由上文链表图可以看出:链表是在物理上(内存)不一定连续,但在逻辑上是连续的一种结构。而顺序表由于本身是数组,故顺序表是在物理上(内存)一定连续,逻辑上也连续的一种结构。
链表的结构组合总共有 8 种,由三种不同情况组成:单向或双向,带头或者不带头,循环或者非循环。
上文链表图属于单向不带头非循环链表,而单向带头非循环链表(如下图)就是多一个头节点,其中头节点 head 的数值域是无有效数据的,永远标识该链表的头节点,一直不变,而不带头链表若要在最前面插入节点,头节点就会发生改变:
而循环链表就是最后一个节点的 next 域不为 null,放入第一个节点的地址,如单向不带头循环链表:
还有一种常见的链表,该链表是 Java 集合框架库中 LinkedList 的底层实现,双向不带头非循环链表:
1.3 链表的实现
我们来实现一个单向不带头非循环链表,由于链表是由一个个节点组成,所以我们将单节点定义为内部类 ListNode,value 域定义为 int 型,而 next 域中地址是节点的地址,故 next 域类型应该是 ListNode 节点类型。
static class ListNode {
public int value;
public ListNode next;
public ListNode(int value) {
this.value = value;
}
}
给链表定义一个引用 head ,用于标识第一个节点,默认是 null。
public ListNode head;//null
生成一个链表,定义四个节点,第一个节点为 node1,将 node2 值的地址放入 node1 的 next 域中,让 node2 成为 node1 的下一个节点,以此类推,构成一个拥有四个节点的链表。例如:
代码实现:
public void createList() {
ListNode node1 = new ListNode(12);
ListNode node2 = new ListNode(23);
ListNode node3 = new ListNode(34);
ListNode node4 = new ListNode(45);
node1.next = node2;//将node2值的地址放入node1的next域
node2.next = node3;
node3.next = node4;
this.head = node1;//head指向第一个节点
}
1、首先写一个 display() 方法来打印链表中所有节点,方便检查后续方法是否成功实现。
为防止 head 一直改变,导致节点丢失,我们定义一个 cur 节点表示当前节点,cur 从第一个节点开始,利用每个节点的 next 域中的地址往后遍历,直到 cur 节点为 null,遍历打印完成。如图:
代码实现:
public void display() {
ListNode cur = head;
//遍历完链表所有节点
while (cur != null) {
System.out.print(cur.value+" ");
cur = cur.next;//走向下一个节点
}
}
注:display() 方法并不是链表中的方法,只是为了方便检查而编写的。
2、contains(int key):求当前链表是否存在输入的数据。
定义 cur 节点从头遍历链表,判断每一个节点中 value 值是否与 key 相等,若找到相等的值,则返回 true ;若遍历完链表所有节点都没有找到相等的值,则返回 false。
public boolean contains(int key) {
ListNode cur = head;
//遍历完链表所有节点
while (cur != null) {
if (cur.value == key) {
return true;
}
cur = cur.next;//走向下一个节点
}
return false;
}
3、size():求当前链表有多少个节点。
定义一个 count 计数,cur 节点从头遍历链表,经过一个节点计一次数,直至遍历完链表所有节点,返回 count,即链表有 count 个节点。
public int size() {
int count = 0;
ListNode cur = head;
//遍历完链表所有节点
while (cur != null) {
count++;
cur = cur.next;//走向下一个节点
}
return count;
}
4、头插法 addFirst(int data):将数据插入链表头部。
将 data 值放入一个 node 节点的 value 域,将原来第一个节点的地址放入 node 节点的 next 域,令 head 指向 node,使 node 成为新的第一个节点,如下图。
代码实现:
public void addFirst(int data) {
//将data值放入node节点的value域
ListNode node = new ListNode(data);
//将原来头节点的地址放入node的next域
node.next = this.head;
//令head指向node
this.head = node;
}
5、尾插法 addLast(int data):将数据插入链表尾部。
先判断链表是否为空,若为空,则直接令 head 指向 node;若不为空,则定义 cur 节点从头遍历链表找到最后一个节点,将要插入的 node 节点地址放入尾节点的 next 域中,如下图。
代码实现:
public void addLast(int data) {
ListNode node = new ListNode(data);
//若链表为空
if(head == null) {
//head指向node
head = node;
} else { //若不为空
ListNode cur = head;
//遍历至链表最后一个节点
while (cur.next != null) {
cur = cur.next;
}
//将node地址放入尾节点的next域
cur.next = node;
}
}
6、addIndex(int index, int data):将数据插入指定位置。
先判断 index 是否合法,若不合法,则报异常;若合法,则定义 cur 遍历到 index-1 位置上,为防止节点丢失,得先绑定后方节点,故将原 index 位置的节点地址赋予 node 的 next 域,而后将 node 节点的位置赋予 index-1 位置节点的 next 域,即完成插入。如下图。
代码实现:
public void addIndex(int index, int data) throws IndexException{
//判断index是否合法
if(index < 0 || index > size()) {
throw new IndexException("index不合法的: "+index);
}
ListNode node = new ListNode(data);
//若index等于0,则用头插法
if(index == 0) {
addFirst(data);
return;
}
//若index=size(),则用尾插法
if(index == size()) {
addLast(data);
return;
}
//插入
ListNode cur = searchPrevIndex(index);
node.next = cur.next;
cur.next = node;
}
private ListNode searchPrevIndex(int index) {
ListNode cur = head;
int count = 0;
//遍历至index-1的位置
while (count != index-1) {
cur = cur.next;
count++;
}
return cur;
}
7、remove(int key):删除第一次出现的指定数据。
先判断链表是否为空,若为空,则无法删除,返回;若不为空,则判断是否第一个节点就是要删除的节点,若是,则 head 指向下一个节点,删除成功;若不是,则定义 cur 遍历至要删除节点的前驱,若遍历完全部节点,还未找到要删除节点,则返回;若找到,则定义一个 del 作为要删除节点,将要删除节点的 next 域的地址放入 cur 的 next 域,这样则成功删除。
public void remove(int key) {
//判断链表是否为空
if(head == null) {
return;
}
//若第一个节点就是要删除的节点,则head指向下一个节点
if(head.value == key) {
head = head.next;
return;
}
//找到要删除节点的前驱
ListNode cur = findPrevKey(key);
//没有要删除的数字
if(cur == null) {
return;
}
//删除
//cur.next = cur.next.next;
ListNode del = cur.next;
cur.next = del.next;
}
private ListNode findPrevKey(int key) {
ListNode cur = head;
//遍历全部节点
while (cur.next != null) {
//找到要删除节点的前驱
if(cur.next.value == key) {
return cur;
}else {
cur = cur.next;
}
}
return null;
}
8、removeAllKey(int key):删除所有指定数据。
先判断链表是否为空,若为空,则返回;若不为空,则定义 prev 指向第一个节点,cur指向第二个节点。开始遍历链表,若 cur 指向节点的值要删除的数据,则将 cur 节点下一节点的地址放入 prev 节点的 next 域中;若 cur 指向节点的值不是要删除的数据,则 cur 与 prev 都指向对应的下一节点。删除完除第一个节点以外的所有节点,再开始判断第一个节点中的值是否是需要删除的数据,若一开始就判断,遇到第一第二连续两个节点中都是要删除的数据,除非加入循环来判断,否则就会发生漏删的情况。
public void removeAllKey(int key) {
//判断链表是否为空
if(head == null) {
return;
}
ListNode prev = head;//前驱节点
ListNode cur = head.next;//当前节点
//遍历所有节点
while (cur != null) {
//删除
if(cur.value == key) {
prev.next = cur.next;
cur = cur.next;
}else {
prev = cur;
cur = cur.next;
}
}
//最后判断第一个节点是否是需要删除的值
if(head.value == key) {
head = head.next;
}
}
9、clear():清空链表。
令 head 指向空,则全部节点丢失,清空成功。
public void clear() {
head = null;
}
二、LinkedList
2.1 LinkedList 的实现
由于 LinkedList 的底层实现是双向不带头非循环链表,故我们来试着实现该链表。我们将单个双向节点定义为内部类 ListNode,value 域定义为 int 型,而 next 域中地址是后一个节点的地址,故 next 域类型应该是 ListNode 节点类型,prev 域中是前一个节点的地址,故同理。
static class ListNode {
public int value;
public ListNode next;
public ListNode prev;
public ListNode(int value) {
this.value = value;
}
}
//定义head,用于标识第一个节点,默认是null
public ListNode head;
//定义last,用于标识最后一个节点,默认是null
public ListNode last;
由于双向链表与单向链表区别只在于双向链表多了一个 prev 域,故其中一些方法是一致的,例如上文中的 display()、contains() 以及 size() 方法。其余方法也是同样的道理,主要就是要多进行一步 prev 域的操作。
1、头插法 addFirst(int data)
与单链表的头插法类似,首先将 data 值存入一个 node 节点的 value 域,然后判断链表是否为空,如果为空,则链表的 head 节点与 last 节点都为 node 节点;其次将 head 节点的地址存入 node 节点的 next 域中,再将 node 节点的地址存入 head 节点的 prev 域中,最后改变 head 指向。
代码实现:
public void addFirst(int data) {
ListNode node = new ListNode(data);
//如果链表为空
if (head == null) {
head = node;
last = node;
} else {
node.next = head;
head.prev = node;
head = node;
}
}
2、尾插法 addLast(int data)
与头插法类似,若 head 节点不为空,将 node 节点的地址放入 last 节点的 next 域,再将 last 节点的地址放入 node 节点的 prev 域,最后改变 last 指向,使其指向 node 节点。
public void addLast(int data) {
ListNode node = new ListNode(data);
if (head == null) {
head = node;
last = node;
} else {
last.next = node;
node.prev = last;
last = node;
}
}
3、 addIndex(int index, int data):将数据插入指定位置。
首先判断 index 是否合法,若 index 小于 0 或大于链表大小则抛异常。其次判断要插入位置是否为链表的头或者尾,再执行头插法或尾插法。若处于中间位置,则需要先定义一个 node 节点,将要插入数据放入 node 节点,再定义一个 cur 节点,令其遍历至要插入位置,最后进行四步存放地址操作:① 将 cur 节点的地址放入 node 节点的 next 域;② 将 node 节点地址放入 cur 节点的前一个节点 next 域;③ 将 cur 节点的前一个节点地址放入 node 节点的 prev 域;④ 将 node 节点的地址放入 cur 节点的 prev 域。
插入位置为中间位置时实现过程:
代码实现:
public void addIndex(int index, int data) {
if (index < 0 || index > size()) {
throw new IndexException("index不合法");
} else if (index == 0) {
//头插法
addFirst(data);
} else if (index == size()) {
//尾插法
addLast(data);
} else {
ListNode cur = findIndex(index);
ListNode node = new ListNode(data);
//位于中间位置时
node.next = cur;
cur.prev.next = node;
node.prev = cur.prev;
cur.prev = node;
}
}
//找到要插入的节点位置
public ListNode findIndex(int index) {
ListNode cur = head;
while (index != 0) {
cur = cur.next;
index--;
}
return cur;
}
4、remove(int key):删除第一次出现的 key。
假若要删除值为 34 的节点,首先定义一个当前节点,令其遍历链表,找到所要删除的节点,然后开始判断其属于哪种节点,具有三种情况:① 若要删除节点为头节点,则先将 head 指向下一个节点,然后判断链表中是否只有一个节点,若只有一个节点,则 head 指向 null,也需要将 last 指向 null;若不止一个节点,则将 head 指向下一个节点后,需将 head 节点的 prev 域置空;② 若要删除节点为中间节点,则将 cur 节点的后一个节点地址放入 cur 节点的前一个节点 next 域中,再将 cur 节点的前一个节点地址放入 cur 节点的下一个节点 prev 域即可;③ 若要删除节点为尾节点,则将 cur 节点的前一个节点 next 域置空,再将 last 指向上一个节点即可。
删除节点为中间节点时实现过程:
public void remove(int key) {
ListNode cur = head;
while (cur != null) {
//找到所要删除的节点
if (cur.value == key) {
//若删除的是头节点
if (cur == head) {
head = head.next;
//若链表中只有一个节点
if (head.next == null) {
last = null;
} else {
//不止一个节点
head.prev = null;
}
} else {
cur.prev.next = cur.next;
if (cur.next != null) {
//若删除的是中间节点
cur.next.prev = cur.prev;
} else {
//若删除的是尾节点
last = last.prev;
}
}
return;
}
cur = cur.next;
}
}
5、removeAllKey(int key):删除所有 key。
假如链表中具有多个值为 34 的节点,而此时想把值为 34 的节点全部删除,若使用 remove() 方法,显然只能删除第一个值为 34 的节点,此时我们可以使用 removeAllKey 方法。而两种方法的实现区别只在于 remove() 方法删除一个节点后就会返回,若我们将 return 删掉,不让其返回,即可实现全部删除。
public void removeAllKey(int key) {
ListNode cur = head;
while (cur != null) {
//找到所要删除的节点
if (cur.value == key) {
//若删除的是头节点
if (cur == head) {
head = head.next;
//若链表中只有一个节点
if (head.next == null) {
last = null;
} else {
//不止一个节点
head.prev = null;
}
} else {
cur.prev.next = cur.next;
if (cur.next != null) {
//若删除的是中间节点
cur.next.prev = cur.prev;
} else {
//若删除的是尾节点
last = last.prev;
}
}
}
cur = cur.next;
}
}
6、clear():清空链表。
令 head 和 last 指向 null,则全部节点丢失,清空成功。
public void clear() {
head = null;
last = null;
}
三、ArrayList 和 LinkedList 的区别
不同点 | ArrayList | LinkedList |
存储空间上 | 物理上一定连续 | 逻辑上连续,物理上不一定连续 |
随机访问 | 支持;O(1) | 不支持;O(N) |
头插 | 需要移动元素;O(N) | 只需修改引用指向;O(1) |
插入 | 空间不够时需扩容 | 容量无限制 |
更适用场景 | 查找、修改 | 插入、删除 |
总结
1、在插入节点的时候,为防止节点丢失,得先绑定插入位置的后方节点,再进行下一步操作。
2、在单向链表中,删除第一次出现的指定数据时,应一开始就判断 head 是否需要删除。
3、在单向链表中删除所有指定数据时,需先删完除 head 以外的所有节点,最后再判断 head 是否需要删除。
4、在双向列表中,remove() 方法与 removeAllKey() 方法区别只在于是否会 return。