单链表基本操作、链表翻转详细教程
什么是链表?
数据的存储结构
现如今,我们处于一个被手机、电脑信息环绕的时代,这些海量信息本质上都是数据。「在计算机科学中,数据是指所有能输入计算机并被计算机程序处理的符号的介质的总称,是用于输入电子计算机进行处理,具有一定意义的数字、字母、符号和模拟量等的通称。」简单理解,我们在计算机、手机中所看到的一切,都是由数据构成的。
那么这些数据是如何被储存的呢? 这涉及到计算机的 存储器,存储器按使用类型可以分为随机存取存储器(RAM)和只读存储器(ROM)。
- 随机存取存储器(RAM) 俗称内存。又叫做主存,因为可以和CPU直接交换信息,切断电源后,其中的数据不会保留。
- 只读存储器(ROM) 俗称硬盘(其中一种)、外存。与主存对应又叫做辅存,即使切断电源,数据也不会丢失。
举个例子,我们买华为手机时总会有不同的配置挑选,比如说「8G+512G」、「6G+256G」等等。这里的6G、8G就是内存大小,256G内存就是硬盘大小。内存和CPU直接交换信息,运行的速度比硬盘要快得多,更大的内存理论上能让设备更好地应对复杂工作任务。
不管是哪种存储器,我们都可以把其中的空间划分给一个个数据。在多数的计算机系统中,一个字节是一个8位长的数据单位,大多数的计算机用一个字节表示一个字符、数字或其他字符。你可以想象存储器中的每个字节都占据一个小格子,存储器中的空间则像一个围棋棋盘。数据的存储结构则是把这些数据安置在这个“棋盘”上的基本规则。
数组和链表是最常见的两种基础数据结构。
- 数组:数组是一种线性存储结构。它用一组连续的内存空间,来储存一组具有相同类型的数据。这里连续的内存空间,我们就可以想象为在“棋盘”上同行的一串连续数据,数据排成一条线一样,所以称之为线性表。
- 链表:链表是一种链式存储结构,是在物理存储单元上非连续、非顺序的。这就相当于数据是分散分布在“棋盘”上的,而每个数据都存储在节点之中,节点与节点通过指针连接,像一条锁链,所以称之为链表。
数组与链表的应用场景、优劣及衍生我会在另一篇文章中说明。
几种常见的链表
我们已经知道链表由一系列节点组成,每个节点包括两个部分:数据和指针。通过不同的指针连接顺序可以实现不同的数据逻辑顺序,即不同的链表形式,如:单链表、循环链表、双向链表、双向循环链表等。
单链表
单链表即单方向的链表。指针单方向的指向下一个数据元素。其中有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。
循环链表
循环链表是一种特殊的单链表。它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。从我画的循环链表图中,你应该可以看出来,它像一个环一样首尾相连,所以叫作“循环”链表。循环链表特别适合处理数据为环形结构的数据。
双向链表
双向链表是一种可以指针双向指向,可以双向遍历的链表。和单链表一样,它也有一个头结点,用于进入链表。但是不同的是,每个节点除了保存了数据和下一节点的地址,也保存了上一个节点的地址。这样做的好处就是能够在O(1)的时间复杂度下完成上一节点的查找,不需要像单链表一样需要去遍历链表来查找,在插入、删除特定节点的前序节点时能更加高效。但是缺点就是每个节点都额外又存储了一个地址,增大了内存占用。
双向循环链表
双向循环链表则是双向链表和循环链表的组合,能够双向处理环形结构的数据。
链表的代码实现
了解清楚概念后,我们可以尝试用JAVA来写一个单链表,包括单链表的节点、插入方法、删除方法、查询方法、翻转链表方法及打印方法。
节点(创建节点类)
我们刚刚讲到,链表的最基础元素就是节点,每个节点至少包括两个属性,一个是存储数据的data变量,另一个是存储下一节点位置的后继指针next。如果是双向链表,还有一个前驱指针pre变量存储上一节点的地址。那么我们据此就可以在JAVA中创建一个节点类,包含这些属性,再加入一些基础的获取属性、修改属性的方法。
我们用代码实现如下:
public class Node {
//属性初始化
int data; //数据
Node next; //后续节点
//构造方法:仅用数据初始化一个新节点,next指针指向空
public Node(int data) {
this.data = data;
}
//构造方法:用数据和后继指针初始化一个节点(注意:需要已知下一节点的对象)
public Node(int data, Node next) {
this.data = data;
this.next = next;
}
//设置当前节点对象的下一个节点(输入的节点为下一个节点的对象)
public void setNext(Node next) {
this.next = next;
}
//获取当前节点对象的下一个节点对象
public Node getNext() {
return this.next;
}
//设置当前节点对象的数据
public void setData(int data) {
this.data = data;
}
//获取当前节点对象的数据
public int getData() {
return this.data;
}
}
单链表(创建链表类)
我们写好基本节点之后,就可以再写一个单链表类,其中需要先初始化一个空的头结点,作为链表的开始。再在其中加入一些方法让这些节点相互连接,并实现插入、删除、翻转等功能。
//属性
//创建一个空的头结点
private Node head = null;
插入方法
插入方法可以分为顺序插入和倒叙插入两种方法。
顺序插入,又叫有头插入,因为有固定的头节点元素。在一个单链表中,插入的第一个元素会被设置为头结点,后续的第二个节点会被放在头结点之后,第三个节点放在第二个之后,以此类推。但第一个插入的元素永远为头结点。(如下图所示,为了方便理解,我把第一、二、三个插入的节点的数据设置为了1、2、3.)
倒叙插入,又叫无头插入,因为头结点不固定,链表为反向插入,所以每一个新插入的节点都为头结点。
理解了概念,我们尝试使用代码把这两个方法实现出来。
顺序插入(有头插入)
思路:
- 创建一个新节点;
- 考虑特殊情况:判断「当前链表头结点head是否为空」,若为空,直接设新节点作为头结点;
- 若「头结点head不为空」,在head处创建一个指针节点q,当q的下一个节点不为空时持续向后遍历,直到q的下一个节点为空;
- 将q的下一个节点设置为该新节点。
/**
* 顺序插入(链表尾部插入)
*/
//未创建新节点时,输入新节点数据即可尾部插入的方法
//注意:这个方法在内部创建对象,如果后续不需要再对该对象进行操作,则可使用
public void insertToTail(int value) {
Node newNode = new Node(value,null);
insertToTail(newNode);
}
//节点已被创建时,尾部插入该节点的方法
public void insertToTail(Node newNode) {
if (head == null) {
head = newNode;
}else {
Node q = head;
while(q.next!= null) {
q = q.next;
}
q.next = newNode;
}
}
注意:这里易出现一个错误,就是遍历时直接用指针q != null 进行遍历, 然后遍历结束后 q = newNode 直接把q赋为新节点。这样看似逻辑上没有错误,但实际上遍历结束后,指针q为空,相当于已经失去了节点的属性,所以指到最后一个节点指针丢了。在写链表代码时,一定要注意指针丢失和内存泄漏的问题。
倒叙插入(无头插入)
思路:
- 创建一个新节点;
- 考虑特殊情况:「头结点为空」,则新节点直接放在头结点处;
- 若「head不为空」,将新节点的下一个节点指向头结点,并把头结点head赋为该新节点,使head一直在链表头部。
/**
* 逆序插入
* 无头节点,表头处插入(头结点从后向前移动)
* 这种操作将与输入的顺序相反,逆序
*/
//未创建新节点时,输入新节点数据即可插入的方法
public void insertToHead(int value) {
Node newNode = new Node(value,null);
insertToHead(newNode);
}
//节点已被创建时,头部插入该节点的方法
public void insertToHead(Node newNode) {
if (head == null) {
head = newNode;
}else {
newNode.next = head;
head = newNode;
}
}
中间插入
除了在链表头尾插入以外,我们还需要在链表中间插入的方法。这可以分为在特定的节点前插入一个新节点,和在特定的节点后插入新节点。
特定节点后插入
思路:
- 要在特定的节点p后插入新节点,首先考虑特殊情况:这个「插入位置p为空」,即p不存在,则直接退出方法;
- 如果「插入位置p存在」,则直接重新规划节点间的指向关系。为方便理解,请看图示。
这里需要特别注意,在step 1 处一定要先将newNode指向p的下一节点,即newNode.next = p.next,再将p的下一节点指向newNode。否则,如果先将p的下一节点指向newNode, 则原链表p的下一个节点位置就会丢失。
错误代码如下:
p.next = newNode;
newNode.next = p.next;
正确代码:
/**
* 中间插入方法
* 在已知节点P的后方插入(不需要创建指针)
* @param p
* @param value
*/
//未创建新节点时,输入新节点数据即可插入的方法
public void insertAfter(Node p, int value) {
Node newNode = new Node(value,null);
insertAfter(p,newNode);
}
//创建已新节点时,直接插入的方法
public void insertAfter(Node p, Node newNode) {
if (p == null) return;
newNode.next = p.next;
p.next = newNode;
}
特定节点前插入
特定节点前和特定节点后插入又有些不一样,这主要是由于单链表是单向连接的,p节点的上一节点位置不能直接获得,需要重新遍历链表来获得。
思路:
- 考虑特殊情况:如果「节点p为空」,退出方法;
- 考虑特殊情况:如果「头结点为想插入的节点位置p」,则直接调用之前写过的倒叙插入方法来插入这个新节点;
- 若为正常情况,创建在head处创建一个指针节点q,当「q不为空」且「q.next不是p节点」时,持续遍历到链表的下一个位置,直到q的下一个节点为p。这里也需要特别注意,如果遍历完成后依然未找到节点p,即q遍历到空,则退出方法;
- 这时,p的前序节点已知,即为q。重构节点的关系完成插入。
/**
* 中间插入方法
* 在已知节点p的前面插入(需要指针遍历找到p的前一个节点)
* @param p
* @param value
*/
//未创建新节点时,输入新节点数据即可插入的方法
public void insertBefore(Node p, int value) {
Node newNode = new Node(value,null);
insertBefore(p,newNode);
}
//创建已新节点时,直接插入的方法
public void insertBefore(Node p, Node newNode) {
if (p == null) return;
if (head == p) {
insertToHead(newNode);
return;
}
Node q = head;
while (q != null && q.next != p) {
q = q.next;
}
if(q == null) {
return;
}
newNode.next = p;
q.next = newNode;
}
删除方法
删除方法的实现与插入时重构节点关系类似,只要将删除节点p的上一个节点指针指向p的下一个节点即可。插入方法可以写出两种,一种是删除指定节点,另一种是删除包含特定数据的节点。我们接下来一个一个来看。
删除特定节点对象
思路:
- 考虑特殊情况:「要删除的节点p为空」 或「头结点为空」,直接退出方法;
- 考虑特殊情况:「链表中只有头结点」,则让头结点等于它后面的空节点;
- 正常情况:创建一个指针节点q,当「q不为空」且「q的下一节点不为p」时,节点q持续遍历链表,直到q的下一节点为p。这里也要考虑一种特殊情况,遍历完成后依然没找到节点p,则退出方法;
- 遍历完成后找到了节点p,则q.next = q.next.next,让数组绕开节点p,即完成了删除。
/**
* 特定节点删除
* @param p
*/
public void deleteByNode(Node p) {
if (p == null || head == null) return;
if (p == head) {
head = head.next;
return;
}
Node q = head;
while (q != null && q.next != p) {
q = q.next;
}
//遍历完成后依然没找到节点p
if (q == null) {
return;
}
//遍历完成后找到了节点p
q.next = q.next.next;
}
删除特定数据的节点
思路:
- 考虑特殊情况:「头结点为空」,则退出方法;
- 接着需要用到双指针遍历,在头结点处创建一个指针节点p,用于查找特定的值,再创建一个空的指针节点q。当「p不为空」且「p的数据不为特定值」时,p节点持续遍历数组查找特定值,q节点跟随遍历,保存住p节点的前序节点,以用于删除操作;
- 考虑特殊情况:遍历完成后,如果「p没有找到想要删除的值」,即p = null,则退出方法;
- 考虑特殊情况:如果「头结点即为要删除的值」,则q节点保持为空,直接删除头结点;
- 正常情况:使用q.next = q.next.next进行删除操作。
/**
* 特定值删除
* @param value
*/
public void deleteByValue(int value) {
//头结点为空时直接返回
if (head == null) return;
//双节点遍历,一个指针节点用于判断是否查找到值,另一个节点保留住指针的前一个节点
Node p = head;
Node q = null;
while (p != null && p.data != value) {
q = p;
p = p.next;
}
//如果p没有找到想要删除的值
if (p == null) return;
if (q == null) {
head = head.next; //头结点即为要删除的节点,删除头结点
}else {
q.next = q.next.next; //无特殊情况,查找到数据后删除该节点
}
}
查询方法
有时,我们在操作链表时,需要获取到特定值对应的节点,或者是获取到特定序号下标的节点,这里我们再添加两个方法。在我们上面写的插入方法中,都包含了一个直接输入数据,在方法内部创建节点的方法,这样创建出来的新节点我们没有手动给他命名,所以可以用查询方法来返回特定节点对象。
按值查找
思路:
- 在head处创建一个指针节点p;
- 当「节点p不为空」且「节点p的数据不为要查询的数据」时,持续向后遍历链表,直到找到值,就返回这个p指针当前的对象。
//按值查询
public Node findByValue(int value) {
Node p = head;
while(p != null && p.data != value) {
p = p.next;
}
return p;
}
按下标查找
思路:
- 在head处创建一个指针节点p,在创建一个int变量pos来记录位置下标;
- 当「p不为空」且「pos不为目标下标」时,指针p持续向后遍历,且每次循环pos+1。查找到特定下标后返回p。
//按下标查找
public Node findByIndex(int index) {
Node p = head;
int pos = 0;
while(p != null & pos != index) {
p = p.next;
++pos;
}
return p;
}
翻转链表
翻转链表顾名思义,将链表的顺序翻转过来。可以分为有头链表翻转和无头链表翻转。
有头链表翻转
/**
* 链表翻转
*/
//有头节点的链表翻转
public Node inverseLinked_head(Node p) {
//创建一个新的头结点
Node NewHead = new Node(9999,null);
//将新的头结点指向链表的头
NewHead.next = p;
//创建一个当前正操作的节点,为链表头的下一个节点
Node Current = p.next;
//将链表头指向空
p.next = null;
//创建一个Next节点,用于储存当前操作节点Current的下一个节点
Node Next = null;
//当当前操作节点不为空,对Current节点操作
while(Current != null) {
//保存住链表中Current节点的下一节点
Next = Current.next;
//将Current节点指向新的头结点所指处,此时Current成为新的链表头
Current.next = NewHead.next;
//将新的头结点指向当前链表头节点
NewHead.next = Current;
//将Current改为下一操作节点
Current = Next;
}
head = NewHead.next;
//返回表头
return NewHead;
}
无头链表翻转
//无头节点的链表翻转(输入p为链表的第一个数据,非head)
public Node inverseLinkedList(Node p) {
//创建一个空的前序节点Pre
Node Pre = null;
//创建一个当前要处理的节点Current,在链表的head处(head为链表最后插入的数据)
Node Current = head;
//创建Next节点,用于保存当前处理节点的下一个节点
Node Next = null;
//当当前处理节点不为p时,处理当前节点的前后关系
while(Current != p) {
//将当前节点的下一个位置储存到Next
Next = Current.next;
//把当前节点指向pre
Current.next = Pre;
//把pre节点移动到当前节点位置
Pre = Current;
//把当前节点移动到即将处理的下个节点位置
Current = Next;
}
//若当前处理节点为p,即链表最后一个节点,则直接把p节点指向pre,翻转结束
Current.next = Pre;
head = Current;
//返回当前节点,即新的链表头节点
return Current;
}
打印链表
思路:
- 创建一个指针节点p;
- 「p节点不为空」时,打印当前节点。
//打印所有链表数据
public void printAll() {
Node p = head;
while (p != null) {
System.out.print(p.data + " ");
p = p.next;
}
System.out.println();
}
测试代码
测试一下以上所有方法都能顺利工作。
public class LinkedListDemo {
public static void main (String args[]) {
SinglyLinkedList list = new SinglyLinkedList(); //创建一个链表对象
Node node1 = new Node(1); //创建一个新节点node1,赋值为1
Node node2 = new Node(2); //创建一个新节点node2,赋值为2
Node node3 = new Node(3); //创建一个新节点node3,赋值为3
Node node4 = new Node(4); //创建一个新节点node4,赋值为4
list.insertToHead(node2); //头部插入方法,在链表头插入node2
list.insertBefore(node2,node1); //在节点node2前插入node1
list.insertToTail(node3); //在链表尾部插入方法,插入node3
list.insertAfter(node3, node4); //在节点node3后插入node4
System.out.println(list.findByValue(1)); //打印出数据值为1的对象
System.out.println(list.findByIndex(0)); //打印序号为0的对象
list.printAll(); //打印整个链表
// list.deleteByNode(node4); //删除节点node4
// list.deleteByValue(3); //删除数据为3对应的节点
// list.printAll(); //打印整个链表
list.inverseLinked_head(node1);
list.printAll();
list.inverseLinkedList(node1);
list.printAll();
SinglyLinkedList list2 = new SinglyLinkedList();
int [] dataSet = {0,1,2,3,4,5,6,7,8,9};
for (int i=0; i<dataSet.length; i++) {
list2.insertToHead(dataSet[i]);
}
list2.printAll();
//测试有头链表翻转
list2.inverseLinked_head(list2.findByIndex(0));
list2.printAll();
//测试无头链表翻转
list2.inverseLinkedList(list2.findByIndex(9));
list2.printAll();
}
}
总结
写链表代码的时候有以下几个注意要点:
-
理解指针或引用的含义
含义:将某个变量(对象)赋值给指针(引用),实际上就是就是将这个变量(对象)的地址赋值给指针(引用)。 -
警惕指针丢失与内存泄漏
正如上面插入方法所提到的,先要保存住后续节点,将新节点指向后续节点后,再将前驱节点指向新节点。在链表的代码实现中,可能一个不小心就会发生指向错误,这个需要特别去注意。 -
留意边界条件的处理
可以看到,我们写每个方法时都考虑到特殊情况。经常用来检查链表是否正确的边界4个边界条件:
一、如果链表为空时,代码是否能正常工作?
二、如果链表只包含一个节点时,代码是否能正常工作?
三、如果链表只包含两个节点时,代码是否能正常工作?
四、代码逻辑在处理头尾节点时是否能正常工作? -
举例画图,辅助思考
核心思想:释放脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。 -
多写多练,没有捷径
常见的链表操作:单链表反转、链表中环的检测、两个有序链表合并、删除链表倒数第n个节点、求链表的中间节点等。都需要多加练习!