在校菜鸡学生,可能表述上存在错误,如发现了恳请批评斧正。
单链表定义——什么是单链表
先前我们学习了顺序表的实现(链接:另一篇学习笔记——顺序表)。我们注意到顺序表也存在一些缺点:
- 为保证表的连续性,在插删适合需要移动过多的元素,当元素很多时候会导致性能下降得很明显。
- 在内存需求变化很大时候,可能会导致内存浪费或者不够用。
这时,我们想:加入我们使用一定空间,将数据之间的逻辑关系记录下来,是否可以解决上述问题。我们使用空间记录结点与结点的逻辑关系时,就有了单链表。
定义:单链表是一种链式存取的数据结构,用一组地址任意的存储单元存放线性表中的数据元素。链表的基本元素是结点。每个结点的构成 = 元素+ 指针。元素就是存储数据的存储单元,指针就是连接每个结点的地址数据。
单链表的实现
结点的定义与实现
前面提到,一个结点有两个基本的属性:数据元素(data)
与指针(nextnode)
。数据元素
用于存储数据,而指针用于指向当前结点的下一个结点的地方。
//单链表结点的定义
template <class T> class Node {
protected:
T data; //数据
Node<T>* nextnode; //指针指向下一结点
public:
Node();//用于创建空的节点
Node(const T& obj);//创建只知道数据的节点
Node(const T& obj, Node<T>* address);//创建都知道的节点
Node(const Node<T>& node);//复制节点
virtual Node<T>* getNextNode();//返回下一节点地址
virtual void setNextNode(Node<T>* const address);//设置下一节点
virtual T showData();//返回数据
~Node();//销毁节点
};
单链表的定义与实现
当我们具有结点(Node)
的相关表述时,我们就可以对单链表进行表述了。与顺序表类似,对于单链表(LinkList)
我们需要实现基本的增add()
、插insert()
、删remove()
与查check()
。
template<class T> class LinkList {
protected:
Node<T>* head;//指向链表表头
int LENGTH;//链表的长度(不含头结点)
public:
LinkList();
public:
virtual void add(const T& obj); //往链表中添加数据
virtual void insert(const T& obj, int index); //插入一个数据
virtual T check(int index)const; //查看一个数据,需要提供下标
virtual bool remove(int index); //删除一个数据
virtual int length()const; //返回单链表的长度
public:
virtual ~LinkList();
};
单链表的一个属性——链表头head
在上面的单链表表述过程中,我们发现单链表具有一个链表表头head
。为什么需要设置这个头结点?为统一空表与非空表的操作而设置的
。
我们以add(const T& dt)
为例子,查看是如何统一操作的:
- 当不设立链表表头。我们添加一个数据时候。需要检查
head
是否指向nullptr
。若head
指向nullptr
,则直接添加数据;若没有指向nullptr
,则需要从head
开始,遍历链表直到查找到某一结点的下一结点指向nullptr
(说明这是链表尾),添加数据。 - 若设立链表表头。我们只需以
head
为起点,向后遍历,知道某一结点的下一结点指向nullptr
,说明当前结点时链表尾,添加数据。
从上方可以看到,设置链表表头head
可以简化方法表述,起到统一操作的作用。
创建一个空表
在这里我们创建一个空表,即完成LinkList
的构造函数。
template<class T>
LinkList<T>::LinkList() {
head = new Node<T>(); //创建一个链表头,即不含数据的Node对象
LENGTH = 0; //空表长度为0
}
向单链表添加数据
实现一个数据结构,首先需要向这个结构添加数据。对于void add(const T&)
方法,往下细分可以分为头插法与尾插法。
头插法顾名思义是向单链表添加数据元素时,在链表头之后第一个位置添加元素。多次添加后,链表中离头结点最远的元素(即链表尾的元素)是最先进入的数据元素,最近的是最后添加的数据元素。
尾插法顾名思义是向单链表添加数据元素时,在链表尾添加元素。多次添加后,链表中离头结点最近的元素(即链表头的下一个结点的存储的数据元素)是最先进入的数据元素。
在这里我们给出尾插法得到方法实现:
template<class T>
void LinkList<T>::add(const T& obj) {
Node<T>* temp = new Node<T>(obj); //创建新结点
if(temp == nullptr) { return; } //数据无法存入,新结点创建失败,返回
Node<T>* node = head; //游标(node)指向头结点(链表头)
while(node->getNextNode() != nullptr) { //寻找结点尾
node = node->getNextNode(); //node指向下一结点
}
node->setNextNode(temp); //将结点连上链表
LENGTH++; //长度+1
}
向单链表插入数据元素
当向单链表插入某个数据时,我们需要知道,数据值obj
与插入位置index
,使用for
循环查找到插入位置的前一位置是结点,完成插入。至于为什么寻找到插入位置的前一位置,因为
插入位置的前一结点(node
)储存着插入节点的后一结点(node_A
)的地址。插入时,需要将node
的下一结点地址赋值给新结点node_new
的属性nextnode
,再将node
的下一结点指向node_new
.
如下图:
template<class T>
void LinkList<T>::insert(const T& obj, int index) {
if (index <= 0 || index > SIZE) { //下标错误,返回不做处理
return;
}
Node<T>* temp = new Node<T>(obj); //创建新结点,含数据obj
if(temp == nullptr) { return; } //创建结点失败,返回
Node<T>* node = head; //游标指向链表表头head
int i = 0;
for (i = 0; i < index - 1; i++) { //寻找插入点的前驱
node = node->getNextNode();
}
temp->setNextNode(node->getNextNode()); //设置新结点的后继
node->setNextNode(temp); //设置插入点的前驱的后继
LENGTH++; //长度+1
}
从单链表删除元素
当某一个数据元素我们不再需要时候,我们需要删除掉这数据。注意,由于我们均使用new
创建对象,删除数据时需要释放(使用delete
语句)掉相应的空间,防止内存泄漏。
删除与插入相似,即是insert()
方法的逆过程。获取到删除点的前驱node_A
后,存储需要删除的地址node_del
,将node_del
的后继传给node_A
作为node_A
的后继,删除node_del
.
如下图:
template<class T>
bool LinkList<T>::remove(int index) {
if (index <= 0 || index > SIZE) {
return false;
}
int i = 0;
Node<T>* node = head;
for(i = 0; i < index - 1; i++) {
node = node->getNextNode();
}
Node<T>* temp = node->getNextNode();
node->setNextNode(node->getNextNode()->getNextNode());
delete temp;
LENGTH--;
return true;
}
上面的删除方法是基于下标实现的,当然如果单链表需要实现多个相同数据的批量删除可以对remove()
方法重载,提供如下接口
template<class T>
bool LinkList<T>::remove(const T& obj, bool (*comparator)(const T&, const T&));
/*
方法需要传入两个参数
obj和comparator
obj是删除样本,comparator为比较函数
当数据元素与样本在comparator返回为true时,说明是需要删除的元素。执行删除。
*/
查看链表中的一个元素
单链表查看即从链表头下一个结点开始,遍历查找。在这里提供基于下标查看的方法。
template<class T>
T LinkList<T>::check(int index)const {
if (index <= 0 || index > SIZE) {
return head->showData();
}
int i = 1; Node<T>* node = head->getNextNode();
for (i = 1; i < index; i++) {
node = node->getNextNode();
}
return node->showData();
}
析构函数
当程序运行结束时,需要回收单链表占用的空间,这时默认调用析构函数,完成对占用空间的释放。
template<class T>
LinkList<T>::~LinkList() {
Node<T>* temp = head; //释放者,指向头结点,头结点也需要释放
Node<T>* next = head->getNextNode(); //释放后是无法访问已释放的空间,需要提前记录下一结点位置
while (temp != nullptr) {
next = temp->getNextNode(); //获取下一个需要释放空间的地址
delete temp; //释放当前结点空间
temp = next; //将下一结点传递给释放者
}
}
单链表的优劣
- 从上面的代码可以看到,相较于顺序表而言,单链表插删操作更加简单,无需移动其他操作。
加入需要删除或插入单链表中的Node
结点的后一结点或插入一个结点,我们并不需要执行任何循环操作。时间复杂度为常数阶
O
(
1
)
O(1)
O(1)而当指定删除某一下标的元素或插入元素至某一下标时,需要执行一个循环,这个循环受到链表长度影响,最大即为链表的长度,时间复杂度为
O
(
n
)
O(n)
O(n)
2. 相较于顺序表,单链表的长度可以轻松应对空间需求不确定的情况。
3. 由于单链表中,需要额外的空间来存储逻辑结构nextnode
,所以相较于顺序表,单链表的插删便捷是通过牺牲空间换取的。
4. 通过C++的指针与地址的相关知识。由于程序运行过程中,形如单链表的空间开辟方法,每个数据结点的存储地址是不确定的。所以若需要查找某一数据元素,是不能够像顺序表一样直接提取的,即便知道下标也如此,所以单链表带来方便的同时也导致了查找性能的下降。
关于头插法与尾插法
对于这两种方法的选择,个人认为有以下分析:
- 头插法是当一个LinkList对象执行add方法时,将数据插入到链表头(假设当前链表设置有链表头head)之后。这样得到的链表数据顺序与插入的数据先后相反。
- 尾插法是当一个LinkList对象执行add方法时,将数据插入到链表尾。这样得到的链表数据顺序与插入的数据先后一致。
- 与栈(一种后进先出的数据结构,LIFO)与队(一种先进先出的数据结构,FIFO)的相关知识相结合。
- 头插法更适合栈得到相关操作。最后进的数据元素必定在头结点之后,我们执行head->getnextNode()的方法读取头结点的下一位结点即可获取到存储地址,实现栈的相关操作。
- 尾插法更适合栈得到相关操作。最先进的数据元素必定在头结点之后,我们执行head->getnextNode()的方法读取头结点的下一位结点即可获取到存储地址,实现队的相关操作。
- 综上所述,当我们使用链表实现栈时,添加元素的方法适合使用头插法;而实现队时,添加元素方法更适合使用尾插法。
- 推广至广义层面上,当一个链表需要频繁对最后进的(部分)元素频繁操作时,适合使用头插法;需要对先进的(部分)元素频繁操作时,适合尾插法。