链表基础概念
链表类型
1.单链表:
每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null(空指针的意思),单链表中的指针域只能指向节点的下一个节点。
2.双链表
双链表:每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点。
双链表:既可以向前查询也可以向后查询。
3.环形链表
环形链表:首尾相连
链表存储方式
数组是在内存中是连续分布的,但是链表在内存中可不是连续分布的。
链表是通过指针域的指针链接在内存中各个节点。
所以链表中的节点在内存中不是连续分布的 ,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。
链表定义
c++默认生成构造函数,但是存在问题,就是你使用默认构造初始化节点,不能直接给变量赋值:
ListNode* head = new ListNode();
head->val = 5;
所以:我们首要自己定义链表
// 单链表
struct ListNode {
int val; // 节点上存储的元素
ListNode *next; // 指向下一个节点的指针
ListNode(int x) : val(x), next(NULL) {} // 节点的构造函数
};
定义链表后,就可以直接对链表初始化节点,可以直接赋值:
ListNode* head = new ListNode(5);
链表删除和添加
删除:就是讲该链表上个节点指向下个节点头节点,就把该节点掠过
添加:往里插入节点
链表和数组性能分析
数组在定义的时候,长度就是固定的,如果想改动数组的长度,就需要重新定义一个新的数组。
链表的长度可以是不固定的,并且可以动态增删, 适合数据量不固定,频繁增删,较少查询的场景。
双指针技巧解决链表问题
合并两个有序链表
这是最基本的链表技巧,力扣第 21 题「合并两个有序链表」
思路代码可视化图:
思路一:递归
目前你就明白一个事,只要调用这个 mergeTwoLists 函数你就可以讲两个链表合并,如何合并我们不管,默认这函数就只做这一件事,就对了。
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
if(list1 == nullptr) return list2;
if(list2 == nullptr) return list1;
if(list1->val < list2->val)
{
// 所以此处,我们用list1->next 来接收合并后的链表接在后面
list1->next = mergeTwoLists(list1->next,list2);
return list1;
}
list2->next = mergeTwoLists(list1,list2->next);
return list2;
}
};
思路二:常规思路while循环遍历
就是对两个连边逐层遍历,比较他们的值
lass Solution {
public:
if(list1 == nullptr) return list2;
if(list2 == nullptr) return list1;
// 虚拟头节点
ListNode* cur= new ListNode(0);
ListNode *p = cur;
while(list1 != nullptr && list2 != nullptr)
{
if(list1->val >= list2->val )
{
p->next = list2;
list2 = list2->next;
}
else
{
p->next = list1;
list1 = list1->next;
}
p = p->next;
}
p->next = list1 != nullptr ? list1 : list2;
ListNode *result = cur->next;
return result;
}
};
代码中还用到一个链表的算法题中是很常见的「虚拟头结点」技巧,也就是 cur节点。
什么时候需要创造虚拟右节点呢?:
如果需要创造新链表,则创建虚拟头节点。比如:将一个链表拆分为两条链表。
单链表分解
直接看下力扣第 86 题「分隔链表」:
思路:
将大于等于x值的,我们放入一个链表,小于x的放入宁一个链表,这样再将两个链表结合起来
class Solution {
public:
ListNode* partition(ListNode* head, int x) {
ListNode * dumppy1 = new ListNode(0);
ListNode * dumppy2 = new ListNode(0);
ListNode *p1 = dumppy1;
ListNode *p2 = dumppy2;
ListNode *p = head;
while(p != nullptr)
{
if(p->val >= x)
{
p2->next = p;
p2 = p2->next;
}
else
{
p1->next = p;
p1 = p1->next;
}
//
ListNode *temp = p->next;
p->next =nullptr;
p = temp;
// head = head->next;
//p = p->next;
}
p2->next = nullptr;
p1->next = dumppy2->next;
return dumppy1->next;
}
};
该该代码中,最重要的一点就是将节点提取出来,进行排序:
ListNode *temp = p->next;
p->next =nullptr;
p = temp;
不断开,最终结果将会包含一个环形链表,这样断开属于是将链表提取出来了。
思考迷惑:如果我直接用head这个代表节点就不会有什么其他问题
合并k个有序链表(该题涉及后续的(优先级队列问题)完全不会) 后续在回头查看
力扣第 23 题「合并K个升序链表」:
单链表的倒数第k个节点
寻找某个节点,最简单方法:
1.正序遍历,for循环。
2.寻找倒数k个,正序遍历也可以,比如 n - k + 1 也属于for循环(该方法,你得首先遍历链表知道链表的n,这样会大大增加复杂度)。
3.只需要遍历一次就可以到达倒数第几个***(双指针法)***
a.首先,p1先移动k次
b.然后再p2,p1移动 n-k 步,这样刚刚p2只想倒数k节点
代码逻辑模板:
// 返回链表的倒数第 k 个节点
ListNode* findFromEnd(ListNode* head, int k) {
ListNode* p1 = head;
// p1 先走 k 步
for (int i = 0; i < k; i++) {
p1 = p1 -> next;
}
ListNode* p2 = head;
// p1 和 p2 同时走 n - k 步
while (p1 != nullptr) {
p2 = p2 -> next;
p1 = p1 -> next;
}
// p2 现在指向第 n - k + 1 个节点,即倒数第 k 个节点
return p2;
}
比如力扣题库力扣第 19 题「删除链表的倒数第 N 个结点」:
这道题就完美运用了上文的双指针法:
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
if (head == nullptr || n <= 0) {
return nullptr; // 无效的输入,返回空指针
}
ListNode* fast = head;
ListNode* slow = head;
// 将 fast 移至第 n+1 个节点
while (n-- && fast != nullptr) {
fast = fast->next;
}
if (fast == nullptr) {
// 处理 n 等于链表长度的情况
return head->next;
}
// 移动 fast 到链表末尾,同时 slow 保持在倒数第 n+1 个节点
while (fast->next != nullptr) {
fast = fast->next;
slow = slow->next;
}
// 删除倒数第 n 个节点
slow->next = slow->next->next;
return head;
}
};
其中,得注意这个状况,就事倒数 n 刚好是头节点问题
if (fast == nullptr) {
// 处理 n 等于链表长度的情况
return head->next;
}
单链表的中点
思路:
1.最常规的方法,就是for遍历,先求出链表长度 n ,然后再 for 遍历找到 n/2 的位置。
2.本章节主要讲解的是双指针的问题,直接使用双指针链表,快慢指针。
快慢指针:fast = fast+2 , slow = slow+1 这样永远fast是slow两倍,当 fast 指向末尾节点 null 时,此时 slow 则为中间指针。
比如力扣题库第 876 题「链表的中间结点」:
我先给出最常规求解方法,代码如下:
class Solution {
public:
ListNode* middleNode(ListNode* head) {
int n = 0;
ListNode *cur = head;
while(cur!=nullptr)
{
n++;
cur = cur->next;
}
int k = 0;
cur = head;
while(k<n/2)
{
k++;
cur = cur->next;
}
return cur;
}
};
下文代码是通过双指针写法:
该部分注意的一点就是,奇偶状况,fast到底是运行到末尾,末尾上一个。比如下图,fast指向末尾了,他都不存在 fast = fast->next->next;,所以此处的判断的稍微注意!
class Solution {
public:
ListNode* middleNode(ListNode* head) {
//双指针写法
if(head == nullptr)
{
return head;
}
//快慢指针定义
ListNode* fast = head;
ListNode* slow = head;
while(fast!=nullptr && fast->next != nullptr)
{
fast = fast->next->next;
slow = slow->next;
}
return slow;
}
};
判断链表是否包含环
思路:
方法1 :
和上文找寻中节点一样。
每当慢指针 slow 前进一步,快指针 fast 就前进两步。
如果 fast 最终遇到空指针,说明链表中没有环;如果 fast 最终和 slow 相遇,那肯定是 fast 超过了 slow 一圈,说明链表中含有环。
比如:再该途中,只要存在环,fast 和 slow 指针迟早会相遇,只是相遇节点不一定是环的起点
判断是否有环模板代码:
bool hasCycle(ListNode* head) {
// 初始化快慢指针,指向头结点
ListNode* slow = head;
ListNode* fast = head;
// 快指针到尾部时停止
while (fast && fast->next) {
// 慢指针走一步,快指针走两步
slow = slow->next;
fast = fast->next->next;
// 快慢指针相遇,说明含有环
if (slow == fast) {
return true;
}
}
// 不包含环
return false;
}
那我们应该怎么找到起点?在上文我们可以找到相遇点:
该图我们可以看到 fast 始终是比 slow 快 k 步,当相遇时 slow 和 fast 都指向相遇点这个节点
所以,找到相遇点,我们再 让 slow 指向 head 头节点,head 和 fast 再一起一步一步往后移,则下次他两相遇则再环起点
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode *fast = head;
ListNode *slow = head;
if(head == NULL)
{
return NULL;
}
while(fast->next != NULL && fast->next->next != NULL)
{
slow = slow->next;
fast = fast->next->next;
if(slow == fast)
{
// 此处就是查找环起点
while(fast != head)
{
fast = fast->next;
head = head->next;
}
return fast;
}
}
return NULL;
}
};
方法二:
该方法,只要遍历一遍,不需要双指针法,如果有环,肯定 for 遍历的时候会首先再环起点相遇:
class Solution{
public:
ListNode *detectCycle(ListNode *head) {
unordered_set<ListNode *> visited;
while(head != NULL)
{
//查找是否已经插入该节点,查询到了,则直接返回
if(visited.count(head))
{
return head;
}
//没查询到,则插入该节点
visited.insert(head);
head = head->next;
}
return NULL;
};
两链表是否相交
链表相交,我们明白一个点,如果相交了,等于他们后续节点都会一摸一样,理解到这个地方,这件事就简单了。
我们只需要像下图这样,从末尾开始对齐,因为判断相交嘛,就代表后续节点数肯定相同,然后再分别 curA 和 curB 一个一个往后遍历,看时候节点相等,遇到相等节点,则代表相交了
还有个思路如下:(但是我不推荐,这样容易把自己绕进去)
我们可以让 p1 遍历完链表 A 之后开始遍历链表 B,让 p2 遍历完链表 B 之后开始遍历链表 A,这样相当于「逻辑上」两条链表接在了一起。
如果这样进行拼接,就可以让 p1 和 p2 同时进入公共部分,也就是同时到达相交节点 c1:
倘若至始至终没有交点,则属于 c1 当作空指针吗,最后返回null
// 求链表的交点
ListNode* getIntersectionNode(ListNode* headA, ListNode* headB) {
// p1 指向 A 链表头结点,p2 指向 B 链表头结点
ListNode *p1 = headA, *p2 = headB;
while (p1 != p2) {
// p1 走一步,如果走到 A 链表末尾,转到 B 链表
if (p1 == nullptr) p1 = headB;
else p1 = p1->next;
// p2 走一步,如果走到 B 链表末尾,转到 A 链表
if (p2 == nullptr) p2 = headA;
else p2 = p2->next;
}
return p1; // 返回交点
}
递归解决链表
单个链表整体反转
链表递归模板
再这个代码中,我们就想清楚一件事,当我调用了 reverseList 函数他就会执行反转操作,怎么执行,我不理会
class Solution {
public:
ListNode* reverseList(ListNode* head) {
//递归做法
// 此处 head == nullptr 的原因,是防止 head 就是为空节点
if(head == nullptr || head->next == nullptr)
{
return head;
}
ListNode* last = reverseList(head->next);
head->next->next = head;
head->next = nullptr;
return last;
}
};
下面是递归流程图:
1.初始链表
2.开始进行递归操作
ListNode* last = reverseList(head->next);
执行完成,链表则成为下图:
last 属于一步一步指向末尾
3.head->next->next = head;
4.最后一步将原本 head-> = nullptr; 保证链表最后指向为空
反转链表前N个节点
反转前N个节点,意思如下图:
代码模板:
ListNode* successor = nullptr;
// 反转以 head 为起点的 n 个节点,返回新的头结点
ListNode* reverseN(ListNode* head, int n) {
if (n == 1) {
successor = head->next;
return head;
}
ListNode* last = reverseN(head->next, n - 1);
head->next->next = head;
head->next = successor;
return last;
}
1.我们此处判定是 n == 1 时结束,因为 n == 1 时,此处就是他自己这个节点。
2.上文整体反转时,我们将,head->next = nullptr; , 但是因为此处我们只反转了前 n 个,所以此处我们需将后续节点加在后面,所以我们上文代码设立了 successor 记录不反转的节点,这行代码的作用就是如此: successor = head->next;
反转链表的其中一部分
比如力扣的92题「反转链表 II」:
该题的思路就是,将中间反转部分进行转化:
我们把中间部分挑剔出来:
这样后面部分就化解成为了上文的反转前 n 个节点链表问题
class Solution {
public:
ListNode * success = nullptr;
ListNode* reverseBetween(ListNode* head, int left, int right) {
if(left == 1)
{
return trverse(head,right);
}
head->next = reverseBetween(head->next,left-1,right-1);
return head;
}
// 该函数主要是用于链表反转
ListNode* trverse(ListNode*head,int n)
{
if(n == 1)
{
success = head->next;
return head;
}
ListNode *last = trverse(head->next,n-1);
head->next->next = head;
head->next = success;
return last;
}
};
方法二:迭代法
思路和前文一样,依旧属于是将中间部分挑出来进行前N个反转迭代
class Solution {
public:
ListNode* reverseBetween(ListNode* head, int left, int right) {
ListNode *result = new ListNode();
result->next = head;
ListNode* before = result;
ListNode* pre = head;
ListNode* cur = head->next;
//此处属于是将前几个不反转的给挑剔出来
for(int i =1; i<left;i++)
{
//确定前面不反转的最后一个节点
before = pre ;
pre = cur;
cur = cur->next;
}
//下面函数则是属于是前 n 个链表反转
while(cur != nullptr && left<right)
{
left++;
ListNode *temp = cur->next;
cur->next = pre;
pre = cur;
cur = temp;
}
before->next->next = cur;
before->next = pre ;
return result->next;
}
};
如何k个一组反转链表(迭代法反转)
单链表反转问题
此处主要是讲迭代方法
如下图框架,一点一点往后遍历
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode * cur = head;
ListNode * pre = nullptr;
while(cur != nullptr)
{
ListNode *temp = cur->next;
cur->next = pre ;
pre = cur;
cur = temp;
}
return pre;
}
};
当然,上文我们学习了递归,那也可以用递归的方式进行解决,回顾递归知识点:
下文代码,我们在递归时反复运用,加深记忆了。
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if(head == nullptr || head->next == nullptr)
{
return head;
}
ListNode * last = reverseList(head->next);
head->next->next = head;
head->next = nullptr;
return last;
}
};
反转链表a到b个节点
此处和上文全部反正没什么区别,上文无非就时 b == nullptr 属于是末尾节点,所以只需要将上文代码稍加修改即可,属于 cur != b ;
// 反转区间 [a, b) 的元素,注意是左闭右开
ListNode* reverse(ListNode* a, ListNode* b) {
ListNode *pre, *cur, *nxt;
pre = nullptr; cur = a; nxt = a;
// while 终止的条件改一下就行了
while (cur != b) {
nxt = cur->next;
cur->next = pre;
pre = cur;
cur = nxt;
}
// 返回反转后的头结点
return pre;
}
l流程图如下:
该模板代码,我们只是得到了 a 到 b 之间的链表反转,并没有进行完整拼接,比如当 b = 4 时,我们 return pre 得到的结果如下,只有前半部分,上文代码并没有整体结合起来。
反转k个一组链表
思路:一步一步分解为上文 节点 a 到节点 b 的反转
流程图:
class Solution {
public:
ListNode* reverseKGroup(ListNode* head, int k) {
if(head == nullptr)
{
return head;
}
ListNode *b = head;
for(int i = 0 ;i<k;i++)
{
// 下面这个if用处在于判断是否超过k 个,不超过之直接输出,不用反转;
if(b == nullptr)
{
return head;
}
b = b->next;
}
ListNode *result = reverse(head,b);
head->next = reverseKGroup(b,k);
return result;
}
ListNode * reverse(ListNode *a, ListNode *b)
{
ListNode *pre = nullptr;
ListNode * cur = a;
while(cur != b)
{
ListNode *temp = cur->next;
cur->next = pre ;
pre = cur;
cur = temp;
}
return pre;
}
};
该部分属于反转临界条件:
// 下面这个if用处在于判断是否超过k 个,不超过之直接输出,不用反转;
if(b == nullptr)
{
return head;
}
回文问题链表
什么时回文?
回文就是对称,正着读和倒着读都是一样的
寻找回文串
核心思想: 从中心往两段进行扩展
// 在 s 中寻找以 s[left] 和 s[right] 为中心的最长回文串
string palindrome(string s, int left, int right) {
// 防止索引越界
while (left >= 0 && right < s.length()
&& s[left] == s[right]) {
// 双指针,向两边展开
left--;
right++;
}
// 返回以 s[left] 和 s[right] 为中心的最长回文串
return s.substr(left + 1, right - left - 1);
}
while 循环就是属于从中间往两边找最大回文子串。
如果是奇数,则只需要传入一个中心点(传入两个既同时指向一个中心点为起始点),偶数,则需要入上文传入两个。
判断回文串
思路和上文恰好相反
双指针从两段向中间逼近:
只要属于左右存在不相等,则说明它不是回文
bool isPalindrome(string s) {
//一左一右两个指针相向而行
int left = 0, right = s.length() - 1;
while (left < right) {
if (s[left] != s[right]) { //判断左右指针对应字符是否相等
return false;
}
left++;
right--;
}
return true;
}
判断回文单链表
力扣第 234 题「回文链表」:
思路一:以数组形式进行判断
回文嘛,前后相等即可,我们可以把链表值存入 vector 容器,进行前后对比:
class Solution {
public:
bool isPalindrome(ListNode* head) {
vector<int> vals;
while (head != nullptr) {
vals.emplace_back(head->val);
head = head->next;
}
for(int i=0,j = vals.size()-1;i<j;i++,j--)
{
if(vals[i] != vals[j])
{
return false;
}
}
return true;
}
};
也可以直接双容器,不过和上文是一个性质,反而增加冗余度:
class Solution {
public:
bool isPalindrome(ListNode* head) {
vector<int> vals;
while (head != nullptr) {
vals.emplace_back(head->val);
head = head->next;
}
vector<int>que;
que = vals;
reverse(que.begin(),que.end());
vector<int>res;
res = que;
for(int i = 0;i<vals.size();i++)
{
if(res[i] != vals[i])
{
return false;
}
}
return true;
}
};
思路二:双指针链表法:
1.快慢指针,left = left +1 right = right + 2
2.当 right 指向末尾或者 null 时(分奇偶),left 指向为后半部分节点
3.后半部分节点反转(此处为奇数,left +1 )
4.将反转后的链表和原始链表进行对比,相等,则代表时回文
下文代码属于回顾前文知识点结合,链表反转和双指针:
class Solution {
public:
bool isPalindrome(ListNode* head) {
if(head == nullptr)
{
return true;
}
ListNode *fast = head;
ListNode *slow = head;
while(fast!=nullptr &&fast->next!=nullptr)
{
fast = fast->next->next;
slow = slow->next;
}
//此处判断奇偶问题,奇数则需要 + 1
if(fast != nullptr)
{
slow = slow->next;
}
slow = reverseList(slow);
fast = head;
// 此处则是反转后代码和原始代码的对比
while(slow != nullptr)
{
if(slow->val != fast->val)
{
return false;
}
slow = slow->next;
fast = fast->next;
}
return true;
}
public:
// 链表反转代码
ListNode* reverseList(ListNode* head) {
ListNode *cur = head;
ListNode *pre = nullptr;
while(cur != nullptr)
{
ListNode *temp = cur->next;
cur->next =pre;
pre = cur;
cur = temp;
}
return pre;
}
};
但是上文代码怕坏了原始链,弥补的话:只需要将前文节点 left->next = reverseList(slow)
链表反转部分:可以参考二叉树
二叉树遍历:
void traverse(TreeNode* root) {
// 前序遍历代码
traverse(root->left);
// 中序遍历代码
traverse(root->right);
// 后序遍历代码
}
同样,链表也兼具递归结构:
如果时正序,则只需要将代码放在前序遍历位置
如果是倒叙,则只需要将代码放在后续遍历位置
void traverse(ListNode* head) {
// 前序遍历代码
//print(head->val);
traverse(head->next);
// 后序遍历代码
//print(head->val);
}
代码如下:
class Solution {
public:
bool isPalindrome(ListNode* head) {
ListNode* left = head; // 将 left 移到 isPalindrome 函数中
return traverse(head, left);
}
bool traverse(ListNode* right, ListNode*& left) { // 注意传递 left 的引用
if (right == nullptr) return true;
bool res = traverse(right->next, left);
// 后序遍历代码
res = res && (right->val == left->val);
left = left->next;
return res;
}
};
其中这行代码:
res = res && (right->val == left->val);
这行代码的作用是将之前的结果 res 与当前节点值的比较结果相与,确保之前的结果为true,并且当前节点的值也相等,才能保持整体的回文性质。