一、链表定义
链表是由一系列连接在一起的结点构成,其中的每个结点都是一个数据结构。
链表的结点通常是动态分配、使用和删除的,允许链表在程序运行时增大或缩小。如果需要将新信息添加到链表中,则程序只需分配另一个结点并将其插入到系列中。如果需要从链表中删除特定的信息块,则程序将删除包含该信息的结点。
二、链表的结构
2.1 单个节点的组成
链表的每个节点,除数据之外还包含一个后继指针指向链表中的下一个节点。
2.2 链表图解结构
非空链表的第一个结点称为链表的头。要访问链表中的结点,需要有一个指向链表头的指针。从链表头开始,可以按照存储在每个结点中的后继指针访问链表中的其余结点。最后一个结点中的后继指针被设置为 nullptr 以指示链表的结束。
因为指向链表头的指针用于定位链表的头部,所以也可以认为它代表了链表头。同样的指针也可以用来定位整个链表,从头开始,后面跟着后续指针,所以也可以很自然地把它看作是代表了整个链表。
下图给出了一个由 3 个结点组成的链表,其中显示了指向头部的指针,链表的 3 个结点以及表示链表末尾的 nullptr 指针。
2.3 头指针、头节点和首元节点详解
头节点:可有可无
有时,在链表的第一个节点之前会额外增设一个节点,该节点的数据域一般不存放数据(有些情况下也可以存放链表的长度等信息),此节点被称为头节点。
若链表中存在头节点,且头节点的指针域为空(NULL),表明链表是空表。
头节点对于链表来说,不是必须的,换句话说,一个完整的链表中可以不设有头节点。那么,可能有人会问:既然头节点无关紧要,那它有什么作用?在处理某些问题时,给链表添加头节点会使问题变得简单。
首元节点
链表中第一个元素所在的节点,它是头节点后边的第一个节点。
其实,首元节点和链表中存放数据的其他节点没什么不同,只是因为该节点位于链表的头部,所以被称为首元节点。
头指针
链表的头指针永远指向链表中第一个节点的位置,换句话说,如果链表有头节点,头指针指向头节点;否则,头指针指向首元节点。
一个链表可以头节点,但不能没有头指针。
头节点和头指针的区别是:
- 头指针是一个指针,头指针指向链表的头节点或者首元节点;
- 头节点是一个实际存在的节点,它包含有数据域和指针域。
头节点和头指针的区别在程序中的直接体现是:头指针只声明而没有分配存储空间,头节点需要声明并分配一个节点的实际物理内存。
三、链表的种类
在实际中,链表有许多形式,根据单向、双向;带头、不带头;循环、非循环的排列组合,就有八种形式的链表。
下面举几个常见的链表:
- 不带头单链表
- 不带头双向链表
- 带头单链表
- 循环单链表
- 带头双向循环链表
虽然有很多的链表结构,但其实,我们最常用的就是两种结构:不带头单链表、带头双向循环链表
- 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
- 带头双向循环链表:结构最复杂,一股用在单独存储数据。 实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
四、链表和数组的区别
两者的区别:
- 数组静态分配内存,链表动态分配内存。
- 数组在内存中是连续的,链表是不连续的。
- 数组利用下标定位,查找的时间复杂度是O(1),链表通过遍历定位元素,查找的时间复杂度是O(N)。
- 数组插入和删除需要移动其他元素,时间复杂度是O(N),链表的插入或删除不需要移动其他元素,时间复杂度是O(1)。
数组的优点
- 随机访问性比较强,可以通过下标进行快速定位。
- 查找速度快
数组的缺点
3. 插入和删除的效率低,需要移动其他元素。
4. 会造成内存的浪费,因为内存是连续的,所以在申请数组的时候就必须规定七内存的大小,如果不合适,就会造成内存的浪费。
5. 内存空间要求高,创建一个数组,必须要有足够的连续内存空间。
6. 数组的大小是固定的,在创建数组的时候就已经规定好,不能动态拓展。
链表的优点
7. 插入和删除的效率高,只需要改变指针的指向就可以进行插入和删除。
8. 内存利用率高,不会浪费内存,可以使用内存中细小的不连续的空间,只有在需要的时候才去创建空间。大小不固定,拓展很灵活。
链表的缺点
查找的效率低,因为链表是从第一个节点向后遍历查找。
五、链表的C++实现
5.1 链表实现的前提——节点的定义及初始化
struct ListNode
{
double value;
ListNode *next;
//构造函数
ListNode(double valuel, ListNode *nextl = nullptr)
{
value = value1;
next = next1;
}
};
通过该声明,即可使用以下两种不同的方式创建一个结点:
- 通过仅指定其 value 部分,而后继指针则默认为 nullptr。
- 通过指定 value 部分和一个指向链表下一个结点的指针。
5.2 链表的创建
例如,使用numberList作为链表头,使用 numberFile 作为输入文件对象,则以下代码将读取存储在某个文本文件中的数字,并将它们排列在链表中:
ListNode *numberList = nullptr;
double number;
while (numberFile >> number)
{
//创建一个结点以保存该数字
numberList = new ListNode(number, numberList);
}
5.3 遍历链表
从链表头开始,涉及整个链表,并在每个结点上执行一些处理操作的过程被称为遍历链表。
例如,如果需要打印某个链表中每个结点的内容,则必须遍历该链表。假设某个链表的链表头指针是 numberList,要遍历该链表,则需要使用另一个指针 ptr 指向链表的开头:
ListNode *ptr = numberList;
然后就可以通过使用表达式 *ptr 或者使用结构指针操作符 -> 来处理由 ptr 指向的结点。例如,如果需要打印在结点上的值,则可以编写以下代码:
cout << ptr->value;
一旦在该结点的处理完成,即可将指针移动到下一个结点(如果有的话),其语句如下:
ptr = ptr->next;
以上语句使用指向结点后继的指针来替换了指向该结点的指针,实现了结点之间的移动。因此,要打印整个链表,可以使用如下代码:
ListNode *ptr = numberList;
while (ptr != nullptr)
{
cout << ptr->value << " "; //处理结点(显示结点内容)
ptr = ptr->next; //移动到下一个结点
}
下面的程序演示了上面所介绍的各种技巧,即读取文件中的数字,将数字排列在链表中,然后通过遍历链表将数字显示在屏幕上。
// This program illustrates the building
// and traversal of a linked list.
#include <iostream>
#include <fstream>
using namespace std;
struct ListNode
{
double value;
ListNode *next;
// Constructor
ListNode(double value1, ListNode *next1 = nullptr)
{
value = value1; next = next1;
}
};
int main()
{
double number; // Used to read the file
ListNode *numberList = nullptr; // List of numbers
// Open the file
ifstream numberFile("numberFile•dat");
if (!numberFile)
{
cout << "Error in opening the file of numbers.";
exit (1);
}
//Read the file into a linked list
cout << "The contents of the file are: " << endl;
while (numberFile >> number)
{
cout << number << " ";
// Create a node to hold this number
numberList = new ListNode(number, numberList);
}
// Traverse the list while printing
cout << endl << "The contents of the list are: " << endl;
ListNode *ptr = numberList;
while (ptr != nullptr)
{
cout << ptr->value << " "; // Process node
ptr = ptr->next; // Move to next node
}
return 0;
}