一、前言
链表作为一种线性的数据结构,时计算机领域中非常重要的一部分,很多高级复杂的数据结构都是建立在链表的结构基础之上的,可以说掌握了链表就能给自己的数据结构学习过程奠定一个非常坚实的基础。
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。链表有很多种不同的类型:单向链表,双向链表以及循环链表。
相比于线性表顺序结构,链表操作更加复杂。使用链表结构可以克服数组需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。
初学者最常接触的是单链表,也就是一个节点中,只包含数据域和一个指向下一个节点的指针的链表,双链表其实是一种对单链表只能单向访问的痛点改进后得到的数据结构。实际上,双向链表的应用范围要比单链表广泛的多。
二、关于链表的性能分析
链表在插入节点时不像顺序表那样需要依次移动相关的元素,链表在增添新的节点的时候往往只需要常数次的操作,但是由于链表的存储方式时非连续的,所以确定插入位置通常需要线性级的复杂度。所以在添加节点上,顺序表的时间复杂度为O(n^2),而链表的复杂度为O(n)。删除操作同理。
虽然在增删方面,链表的性能比顺序表更加优秀,但是在修改上,顺序表只需要O(1),链表因为需要从头开始逐个访问才能修改目标节点,复杂度为O(n)。
而在查找时,链表和顺序表的复杂度都为O(n)。
所以综合各方面来看,顺序表和链表各有其优势,要根据具体的使用场景来选择要使用的数据结构。
三、双链表模板的代码实现和讲解
template<typename T>
struct linknode
{
T element;
linknode* prev, * next;
linknode(const T& val) : element(val), prev(nullptr), next(nullptr) {}
};
这是链表节点的声明和定义,构造函数中将参数赋给element,然后给prev、next赋值为nullptr。
template<typename T>
class linklist
{
protected:
int __size;
linknode<T> head, end;
public:
linklist() :__size(0), head(0), end(0)
{
head.next = &end;
end.prev = &head;
}
~linklist()
{
linknode<T>* p = &head, * temp;
while (p->next != &end)
{
temp = p->next;
p->next = p->next->next;
delete temp;
}
}
bool push_back(const T& val)
{
linknode<T>* p = new linknode<T>(val);
if (!p)
{
return false;
}
p->prev = end.prev;
p->next = &end;
p->prev->next = p;
end.prev = p;
__size++;
return true;
}
bool push_begin(const T& val)
{
linknode<T>* p = new linknode<T>(val);
if (!p)
{
return false;
}
p->next = head.next;
p->prev = &head;
p->next->prev = p;
head.next = p;
__size++;
return true;
}
int size()
{
return __size;
}
bool insert(int index, const T& val)
{
if (index > __size) return false;
linknode<T>* p = new linknode<T>(val);
if (!p)
{
return false;
}
linknode<T>* lp = &head;
for (int i = 0; i < index; i++)
{
lp = lp->next;
}
p->next = lp->next;
p->prev = lp;
lp->next = p;
p->next->prev = p;
__size++;
return true;
}
linknode<T>* find(const T& val)
{
linknode<T>* p = head.next;
while (p != &end)
{
if (p->element == val) return p;
p = p->next;
}
return p;
}
bool erase(linknode<T>* node)
{
if (!node || node == &end) return false;
node->next->prev = node->prev;
node->prev->next = node->next;
delete node;
__size--;
return true;
}
void output()
{
std::cout << "null";
if (__size == 0)
{
std::cout << "\n";
return;
}
linknode<T>* p = head.next;
while (p != &end)
{
std::cout << " <-> " << p->element;
p = p->next;
}
std::cout << " <-> null\n";
}
};
这一段是双向链表的结构声明和定义,数据包含一个int类型的__size用于记录链表中当前存在多少个节点(表头和表尾不计入其中)。还包含两个linknode<T>类型的begin和end,分别作为链表的表头和表尾,可以在某些情况下简化相关操作的代码逻辑。
构造函数中只需要分别调用begin和end的构造函数,然后再让他们互指。__size赋值为0。析构函数则从begin开始依次向后删除节点,直至遇到end,时间复杂度为O(n)。
push_back()方法的作用是在链表的最后添加新的节点,需要一个T类型的参数,具体的操作流程是构造新的节点,然后将新节点插入到end之前。同理push_begin()方法则是将新节点插入在begin之后。两个方法均会在插入完成后返回true,否则返回false。时间复杂度均为O(1)。
insert()方法同样是插入新节点,只不过比前两个插入方法更为普适,需要int类型的index参数来确定插入的位置和T类型的参数val。不过因为需要从begin开始遍历找到需要插入的位置,所以时间复杂度为O(n),当index大于__size或插入失败时,返回false,否则返回true。
size()方法是提供给使用者获取链表中节点数量的接口,由于在定义链表结构时专门设置了一个__size作为存储变量,在调用size方法时只需要return __size即可,所以时间复杂度为O(1)。
find()方法需要一个T类型的参数val,从begin开始遍历链表,遇到element与val相等的节点后返回该节点的指针,否则返回end的地址,复杂度O(n)。
erase()方法能删除传入的指针参数指向的节点,如果节点不存在则返回false,否则返回true,时间复杂度为O(1)。
output()方法实际上并没有实际用途,只是面向使用者的输出函数,能将链表的结构输出,以便使用者根据此调试程序。