写在前面:读本节,您可能得先读懂01:数组和动态数组,由于本节有超过200行的代码,所以您可能也得有一点读代码的能力和耐心。我会尽量保证代码风格良好。
链表
链表是另一个最基本的线性数据结构。
链表由若干单元组成,每一个单元都由实际存放的数据和下一个单元的地址组成。
可能不太好理解,以一个非常浅显的例子打个比方:
很多游戏都有一种解谜任务,开始的时候NPC告诉玩家密码的第一位是‘3',然后说:想要知道密码的下一位,请去XXX地点。成功去XXX地点之后,得到一张字条,写着“密码的第二位是'4',想要知道下一位,请去XXX地点”...
这就是一种逻辑上的链表。C/C++中的链表需要支持顺序访问和读写。
那么我们来简单实现一个链表:
构造一个链表需要两步:1、定义并创建节点 2、将节点连起来
首先定义节点:定义节点用到了C/C++中的结构体类型。
template
struct node {
elementType data;
node *next;
node(elementType _data, node* _next) {
data = _data;
next = _next;
}
};
我们可以仅仅使用这一个结构体完成所有的链表操作。但是根据C++的封装思想,我们给它写一个类简单地封装起来:(事实上一般的OJ题目都是直接在结构体上使用指针操作的)
以下是类的架构:(本实现中,表头的数据(data)部分不存放任何有效信息)
template
class chainList {
protected:
node *head = nullptr;
int length = 0;
bool validIndex(int index) { return index >= 0 && index < length; }
public:
chainList();
chainList(const chainList ©List);
chainList(const vector ©Vector);
~chainList();
void makeEmpty();
int size() const { return length; }
bool empty() const { return length == 0; }
chainList& operator=(const chainList &A);
elementType& operator[](const unsigned int index);
void pop(const unsigned int index);
void push(elementType val, const unsigned int index);
void merge(chainList const &A);
};
简单实现一下:
构造函数:缺省构造函数、复制构造函数、由动态数组建立链表的构造函数和析构函数
template
chainList::chainList() {
if (this->length != 0)
this->makeEmpty();
if (this->head == nullptr) this->head = new node;
this->length = 0;
}
template
chainList::chainList(const chainList ©List) {
if (this->length != 0)
this->makeEmpty();
if (this->head == nullptr) this->head = new node;
this->length = 0;
*this = copyList;
}
template
chainList::chainList(const vector ©Vector) {
if (this->length != 0)
this->makeEmpty();
if (this->head == nullptr) this->head = new node;
this->length = 0;
if (copyVector.empty()) return;
node *str = head;
for (int i = 0; i < copyVector.size(); i++) {
node *tempPtN = new node(copyVector[i]);
str->next = tempPtN;
str = str->next;
}
this->length = copyVector.size();
}
template
chainList::~chainList() {
this->makeEmpty();
delete head;
}
置空方法和赋值重载
template
void chainList::makeEmpty() {
node *str = head -> next;
while (str != nullptr) {
node *tempPtN = str->next;
delete str;
str = tempPtN;
}
head->next = nullptr;
}
template
chainList& chainList::operator=(const chainList &A) {
if (!this->empty()) this->makeEmpty();
if (A.empty()) return *this;
node *strThis = head, *strThat = A.head;
while (strThat != nullptr) {
strThis->data = strThat->data;
if (strThat->next != nullptr) {
node *tempPtN = new node;
strThis->next = tempPtN;
}
strThis = strThis->next, strThat = strThat->next;
}
this->length = A.length;
return *this;
}
几个基本操作:
template
elementType& chainList::operator[](const unsigned int index) {
if (!this->validIndex(index)) throw("Invalid Index");
node *str = this->head;
for (int i = 0; i < index + 1; i++){
str = str->next;
}
return str->data;
}
template
void chainList::pop(const unsigned int index) {
if (!this->validIndex(index)) throw("Invalid Index");
node *str = this->head;
for (int i = 0; i < index; i++){
str = str->next;
}
node *tempPtN = str->next;
str->next = tempPtN->next;
delete tempPtN;
this->length--;
}
template
void chainList::push(elementType val, const unsigned int index) {
this->length++;
if (!this->validIndex(index)) throw("Invalid Index");
node *str = this->head;
for (int i = 0; i < index; i++){
str = str->next;
}
node *tempPtN = new node(val, str->next);
str->next = tempPtN;
}
template
void chainList::merge(chainList const &A) {
chainList tempChain(A);
node *str = this->head;
while (str->next != nullptr) {
str = str->next;
}
str->next = tempChain.head->next;
this->length += tempChain.length;
tempChain.head->next = nullptr;
};
这个链表的实现对于“有圈链表”是非常敏感的,只要链表存在圈,这个实现中的大部分方法就会陷入死循环。好在我们可以通过对方法的仔细调整来避免产生圈,只要用户以常规方式调用public成员,我们的链表的“无圈性”就得以保证。这也是面向对象编程的数据封装原则的优势所在。
关于一个链表是否有圈这一问题我会在以后的文章中讨论。
实际上使用如上的封装类操作链表的效率是低于用指针或者迭代器操作链表的。比如:使用封装类的重载运算符[]来遍历一个长度为n的链表所需的渐近时间复杂度是O(n^2),而使用指针的next成员遍历一个长度为n的链表的渐近时间复杂度仅是O(n)。
何时使用表头以及为何使用表头?
在上述实现中,我们引入了一个不存放任何实际数据的表头节点。在某些情况下,我们也可以将该节点虚拟化为一个指向首个元素的指针。但是,就单链表而言,使用表头节点可以使链表的首个元素“去特殊化”,也就是对于首个元素的插入和删除操作的代码可以与其他元素完全一致,但如果使用虚拟的头指针,则可能需要对于首个元素的插入和删除操作作特殊处理。
但是,在某些特殊的链表(如循环链表)中,我们一般不使用头节点,而使用虚拟的头指针。
链表的直接应用
链表对于离散且稀疏的数据处理效率很高,比如基数排序、模拟多项式、并查集等。
简单讨论一下基于链表的并查集:
并查集
首先介绍一个离散数学中“等价关系”的概念:设R是非空集合A上的关系,如果R满足:
xRy <=> yRx(对称)/ xRx(自反)/ xRy and yRz => xRz
则称R是一个等价关系。常见的等价关系有:相等、双向连通等。
一群两两等价的元素可以形成一个集合,这个集合就叫等价类。我们所谓并查集就是用来描述这种等价类的。
例1:
现在有城市ABCDEFGH,其中A-B A-E B-C C-D C-E C-F有双向公路连通,G-H有双向公路连通,请各位简单画一下他们的连通图。
可以看到这个图分为了两个区域,第一块是ABCDEF,第二块是GH,在第一块的人没有办法到达第二块。这两块区域分别各自形成一个基于公路连接关系的等价类。
问:随机输入两个城市,请您判断它们之间是否存在着一条交通线
这个问题存在许多的解法,包括但不限于Dijkstra路径算法、深度优先搜索等,也可以使用并查集。我们先暂时挂起这个问题,先把并查集的实现写了:
并查集有很多种实现方式:比如树实现、散列实现、数组实现、矩阵实现、链表实现、图实现等。我们使用一个数组和“以数组下标做next的节点”的形式上的链表节点来实现这一数据结构。
注意UFS这一结构体的next变量是一个整形数而不是指针!!意识到这一点将是你理解这一并查集实现的关键一环
struct UFSNode {
unsigned int classNum;
unsigned int size;
unsigned int next;
};
class UFSChain {
private:
vector arr;
public:
UFSChain(int initSize = 0);
void unite(int classA, int classB);
int operator[](unsigned int index) { return arr[index].classNum; }
};
UFSChain::UFSChain(int initSize) : arr(initSize) {
for (int i = 0; i < arr.size(); i++) {
arr[i].classNum = i;
arr[i].size = 1;
arr[i].next = -1;
}
}
void UFSChain::unite(int classA, int classB) {
/***********************************
* arr[classA].classNum指classA的头节点的标号
* 那么(arr[arr[classA].classNum]就是classA的头节点
* 理解这一点非常重要
***********************************/
if (arr[arr[classA].classNum].size > arr[arr[classB].classNum].size) {
swap(classA, classB); //选择对较小集合进行操作以获得更快的运行速度
}
int str = arr[classA].classNum;
arr[arr[classB].classNum].size += arr[arr[classA].classNum].size; //只有首节点的size成员会被调用,因此只需要维护首节点的size
while (arr[str].next != -1) {
arr[str].classNum = classB;
str = arr[str].next;
}
arr[str].classNum = arr[classB].classNum;
arr[str].next = arr[classB].next;
arr[arr[classB].classNum].next = classA;
}
需要注意的是,我们在之后讨论并查集的树实现的合并方法时也会有一个比较两个并查集大小的代码块,但是它的作用与上述链表实现中的大小比较完全不同。
静态链表
这个并查集实现提示了另一种链表实现方式的可行性,即通过“下一个元素的数组下标”作为next成员进行链接。
实际上,因为有一部分高级语言不支持指针(比如BASIC),这种链表的实现也经常被使用。这种链表的实现方式被称为链表的游标实现或者静态链表(国内研究生入学考试试题中通用的名词就是静态链表)。
练习
1、简单考虑为什么对于有圈链表来说,上述实现会失效;考虑如何高效判断一个链表有圈
2、用并查集做例1