目录
1.链表理论
在线性表的链式描述中,线性表的元素在内存中的存储位置是随机的。每个元素都有一个明确的指针或链(指针和链是一个意思)指向线性表的下一个元素的位置,即地址。在基于数组的描述中,元素地址是由数学公式决定的。而在链式描述中,元素地址是随机分布的。在链式描述中,数据对象示例的每一个元素都用一个单元或节点描述,节点不必是数组成员,因此不是用公式来确定元素的位置。取而代之的是,每一个节点都明确包含另一个相关节点的位置信息,这个信息称为链或指针。
链表类型包括单链表、双链表和循环链表。对于双链表,其每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点,其数据结构如图2所示。
循环链表是在单链表的基础上进行首尾相连,如图3所示。
顺序表(数组描述)和链表的区别:
顺序表 | 链表 | |
---|---|---|
存储空间 | 预先分配,可能会导致空间闲置或溢出 | 动态分配,不会出现空间闲置或者溢出 |
存储密度 | 存储密度为1,逻辑关系等于存储关系,没有额外开销 | 存储密度小于1,要借助指针域来表示元素之间的逻辑关系 |
存取元素 | 随机存取,按位置访问元素的时间复杂度O(1) | 顺序存取,访问某位置的元素的时间复杂度为O(n) |
插入、删除 | 插入和删除都要移动大量的元素。平均移动元素约为表的一半。时间复杂度O(n) | 不需要移动元素,只需要改变指针位置,继而改变结点之间的链接关系。时间复杂度O(1) |
适用情况 | 1.表长变化不大,或者事先就能确定变化的范围 2.很少进行插入和删除,需要下标访问元素 | 1.长度变化较大 2.频繁的插入和删除 |
这不就是vector的使用特点吗 | 这不就是list的使用特点吗 |
2.链表常用操作(以单链表为例)
链表的定义:
struct Lnode
{
ElemType data;//结点的数据域
Lnode *next;//结点的指针域
};
typedef Lnode *LinkList;
链表的初始化:
bool InitList(LinkList &L)//插入题外话:LinkList &L等价于 Lnode *&L,Lnode *&L是一个指向指针的引用
{
L = new Lnode; //堆区开辟一个头结点,结点的数据类型为Lnode
L->next = nullptr; //空表,也就是说头结点的指针指向为空
return true;
}
头插法创建链表:
void CreatListHead(LinkList &L, const size_t n)
{
for (int i = 0; i < n; ++i)
{
Lnode *p = new Lnode;
cin >> p->data;
p->next = L->next;
L->next = p;
}
}
尾插法创建链表:
void CreatListTail(LinkList &L, const size_t n)
{
Lnode *r = L;
for (int i = 0; i < n; ++i)
{
Lnode *p = new Lnode;
cin >> p->data;
p->next = r->next;
r->next = p;
r = r->next;
}
}
销毁链表:
bool DestroyList(LinkList &L)
{
//判断链表是否为空
if(IsEmpty(L))
{
cerr << "empty List!" << endl;
return false;
}
while (L)//链表还未到达尾端
{
auto temp = L->next;//将头指针指向下一个结点
delete L;
L = temp;
}
return true;
}
统计链表长度:
size_t GetLength(const LinkList &L)
{
Lnode *p;
p = L->next;
size_t cnt = 0;
while (p)
{
++cnt;
p = p->next;
}
return cnt;
}
//算法的时间复杂度为O(n)
按值查找链表:
size_t LocateElem(LinkList &L, ElemType &e)
{
Lnode *p = L->next;
size_t cnt = 1;
while (p)
{
if (p->data == e)
{
return cnt;
}
++cnt;
p = p->next;
}
cerr << "not found" << endl;
return 0;
}
在链表中插入元素:
bool InsertList(LinkList &L, const int &i, const ElemType &e)
{
Lnode *p = L;
int j = 0;
while(p && j < i-1)
{
p = p->next;
++j;
}
//异常判断
if(!p || i<0)
{
cerr << "out of range" << endl;
return false;
}
LinkList insert = new Lnode;
insert->data = e;
insert->next = p->next;
p->next = insert;
return true;
}
//算法的时间复杂度为O(n)
删除链表中的某个节点:
bool EraseList(LinkList &L, const int &i)
{
Lnode *p = L;
int j = 0;
while (p->next && j < i - 1)
{
p = p->next;
++j;
}
if (!(p->next) || i < 0)
{
cerr << "out of range" << endl;
return false;
}
Lnode *q = p->next;
p->next = p->next->next;
delete q;
return true;
}
3.链表应用
3.1翻转链表
给你单链表的头节点
head
,请你反转链表,并返回反转后的链表。
最简单的方法是定义一个新的链表,然后将翻转后的链表储存到新链表中,不过这极大的浪费了内存。本道题使用了在数组题目中介绍的双指针法进行求解,首先定义一个cur指针,指向头结点,再定义一个pre指针,初始化为null。然后就要开始反转了,首先要把 cur->next 节点用tmp指针保存一下,也就是保存一下这个节点。为什么要保存一下这个节点呢,因为接下来要改变 cur->next 的指向了,将cur->next 指向pre ,此时已经反转了第一个节点了。接下来,就是循环走如下代码逻辑了,继续移动pre和cur指针。最后,cur 指针已经指向了null,循环结束,链表也反转完毕了。 此时我们return pre指针就可以了,pre指针就指向了新的头结点。
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* temp; // 保存cur的下一个节点
ListNode* cur = head;
ListNode* pre = NULL;
while(cur) {
temp = cur->next; // 保存一下 cur的下一个节点,因为接下来要改变cur->next
cur->next = pre; // 翻转操作
// 更新pre 和 cur指针
pre = cur;
cur = temp;
}
return pre;
}
};
3.2两两交换链表中的节点
给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。
建议使用虚拟头结点,这样会方便很多,要不然每次针对头结点(没有前一个指针指向头结点),还要单独处理,主要交换步骤如图5。
class Solution {
public:
ListNode* swapPairs(ListNode* head) {
ListNode* dummyHead = new ListNode(0); // 设置一个虚拟头结点
dummyHead->next = head; // 将虚拟头结点指向head,这样方面后面做删除操作
ListNode* cur = dummyHead;
while(cur->next != nullptr && cur->next->next != nullptr) {
ListNode* tmp = cur->next; // 记录临时节点
ListNode* tmp1 = cur->next->next->next; // 记录临时节点
cur->next = cur->next->next; // 步骤一
cur->next->next = tmp; // 步骤二
cur->next->next->next = tmp1; // 步骤三
cur = cur->next->next; // cur移动两位,准备下一轮交换
}
return dummyHead->next;
}
};
3.3删除链表的倒数第n个节点
给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
进阶:你能尝试使用一趟扫描实现吗
双指针的经典应用,如果要删除倒数第n个节点,让fast移动n步,然后让fast和slow同时移动,直到fast指向链表末尾。删掉slow所指向的节点就可以了。
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;
ListNode* slow = dummyHead;
ListNode* fast = dummyHead;
while(n-- && fast != NULL) {
fast = fast->next;
}
fast = fast->next; // fast再提前走一步,因为需要让slow指向删除节点的上一个节点
while (fast != NULL) {
fast = fast->next;
slow = slow->next;
}
slow->next = slow->next->next;
// ListNode *tmp = slow->next; C++释放内存的逻辑
// slow->next = tmp->next;
// delete nth;
return dummyHead->next;
}
};
3.4链表相交
给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。如果两个链表没有交点,返回 null 。
图示两个链表在节点 c1 开始相交
简单来说,就是求两个链表交点节点的指针。 这里注意,交点不是数值相等,而是指针相等。为了方便举例,假设节点元素数值相等,则节点指针相等。首先求出两个链表的长度,并求出两个链表长度的差值,然后让curA移动到和curB末尾对齐的位置,如图:
此时我们就可以比较curA和curB是否相同,如果不相同,同时向后移动curA和curB,如果遇到curA == curB,则找到交点。否则循环退出返回空指针。
//时间复杂度O(n + m)
//空间复杂度O(1)
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode* curA = headA;
ListNode* curB = headB;
int lenA = 0, lenB = 0;
while (curA != NULL) { // 求链表A的长度
lenA++;
curA = curA->next;
}
while (curB != NULL) { // 求链表B的长度
lenB++;
curB = curB->next;
}
curA = headA;
curB = headB;
// 让curA为最长链表的头,lenA为其长度
if (lenB > lenA) {
swap (lenA, lenB);
swap (curA, curB);
}
// 求长度差
int gap = lenA - lenB;
// 让curA和curB在同一起点上(末尾位置对齐)
while (gap--) {
curA = curA->next;
}
// 遍历curA 和 curB,遇到相同则直接返回
while (curA != NULL) {
if (curA == curB) {
return curA;
}
curA = curA->next;
curB = curB->next;
}
return NULL;
}
};
3.5环形链表
给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
不允许修改链表。
3.5.1判断链表是否有环
可以使用快慢指针法,分别定义 fast 和 slow 指针,从头结点出发,fast指针每次移动两个节点,slow指针每次移动一个节点,如果 fast 和 slow 指针在途中相遇 ,说明这个链表有环。这是因为fast是走两步,slow是走一步,其实相对于slow来说,fast是一个节点一个节点的靠近slow的,所以fast一定可以和slow重合。
3.5.2寻找环的入口
假设从头结点到环形入口节点 的节点数为x。 环形入口节点到 fast指针与slow指针相遇节点 节点数为y。 从相遇节点 再到环形入口节点节点数为 z。 如图所示:
那么相遇时: slow指针走过的节点数为:x+y,fast指针走过的节点数:x+y+n(y+z),n为fast指针在环内走了n圈才遇到slow指针,(y+z)为一圈内节点的个数A。因为fast指针是一步走两个节点,slow指针一步走一个节点, 所以fast指针走过的节点数 = slow指针走过的节点数 * 2:
注意这里n一定是大于等于1的,因为fast指针至少要多走一圈才能相遇slow指针。这个公式说明什么呢?先拿n为1的情况来举例,意味着fast指针在环形里转了一圈之后,就遇到了slow指针了。当 n为1的时候,公式就化解为x=z,这就意味着,从头结点出发一个指针,从相遇节点也出发一个指针,这两个指针每次只走一个节点, 那么当这两个指针相遇的时候就是 环形入口的节点。也就是在相遇节点处,定义一个指针index1,在头结点处定一个指针index2。让index1和index2同时移动,每次移动一个节点, 那么他们相遇的地方就是环形入口的节点。
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode* fast = head;
ListNode* slow = head;
while(fast != NULL && fast->next != NULL) {
slow = slow->next;
fast = fast->next->next;
// 快慢指针相遇,此时从head 和 相遇点,同时查找直至相遇
if (slow == fast) {
ListNode* index1 = fast;
ListNode* index2 = head;
while (index1 != index2) {
index1 = index1->next;
index2 = index2->next;
}
return index2; // 返回环的入口
}
}
return NULL;
}
};
4.总结
对于链表,除了需要掌握增删改查等基本操作外,还需要学习例如虚拟头节点、双指针法等相关方法技巧,文中部分图片来源自代码随想录,后续会继续更新哈希表,如果觉得有用的话点个免费的赞吧!