链表
一、理解指针或引用的含义
- 含义:将某个变量(对象)赋值给指针(引用),实际上就是就是将这个变量(对象)的地址赋值给指针(引用)。
- 示例:
p—>next = q; 表示p节点的后继指针存储了q节点的内存地址。
p—>next = p—>next—>next; 表示p节点的后继指针存储了p节点的下下个节点的内存地址。
二、警惕指针丢失和内存泄漏(单链表)
- 插入节点
在节点a和节点b之间插入节点x,b是a的下一节点,,p指针指向节点a,则造成指针丢失和内存泄漏的代码:p—>next = x;x—>next = p—>next; 显然这会导致x节点的后继指针指向自身。
正确的写法是2句代码交换顺序,即:x—>next = p—>next; p—>next = x; - 删除节点
在节点a和节点b之间删除节点b,b是a的下一节点,p指针指向节点a:p—>next = p—>next—>next;
三、利用“哨兵”简化实现难度
-
什么是“哨兵”?
链表中的“哨兵”节点是解决边界问题的,不参与业务逻辑。如果我们引入“哨兵”节点,则不管链表是否为空,head指针都会指向这个“哨兵”节点。我们把这种有“哨兵”节点的链表称为带头链表,相反,没有“哨兵”节点的链表就称为不带头链表。 -
未引入“哨兵”的情况
如果在p节点后插入一个节点,只需2行代码即可搞定:new_node—>next = p—>next; p—>next = new_node;
但,若向空链表中插入一个节点,则代码如下:
if(head == null){ head = new_node; }
如果要删除节点p的后继节点,只需1行代码即可搞定:
p—>next = p—>next—>next;
但,若是删除链表的最有一个节点(链表中只剩下这个节点),则代码如下:if(head—>next == null){ head = null; }
从上面的情况可以看出,针对链表的插入、删除操作,需要对插入第一个节点和删除最后一个节点的情况进行特殊处理。这样代码就会显得很繁琐,所以引入“哨兵”节点来解决这个问题。
-
引入“哨兵”的情况
“哨兵”节点不存储数据,无论链表是否为空,head指针都会指向它,作为链表的头结点始终存在。这样,插入第一个节点和插入其他节点,删除最后一个节点和删除其他节点都可以统一为相同的代码实现逻辑了。 -
“哨兵”还有哪些应用场景?
这个知识有限,暂时想不出来呀!但总结起来,哨兵最大的作用就是简化边界条件的处理。
四、重点留意边界条件处理
经常用来检查链表是否正确的边界4个边界条件:
- 如果链表为空时,代码是否能正常工作?
- 如果链表只包含一个节点时,代码是否能正常工作?
- 如果链表只包含两个节点时,代码是否能正常工作?
- 代码逻辑在处理头尾节点时是否能正常工作?
五、举例画图,辅助思考
核心思想:释放脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。
习题
单链表反转(LC:206)
//首先手写一个链表结构体,多写多练,做到随时手写
/*
struct ListNode{
int val;
ListNode* next;
ListNode():val(0),next(nullptr){}
ListNode(int x):val(x),next(nullptr){}
ListNode(int x,ListNode *next):val(x),next(next){}
};
*/
//简单迭代,调用两个指针锻逻辑关系
class Solution{
public:
ListNode* reverseList(ListNode *head){
ListNode *pre = head,*cur = nullptr;
while(pre != nullptr){
ListNode *temp = pre->next;
pre->next = cur;
cur = pre;
pre = temp;
}
return cur;
}
};
链表中环的检测(LC:141)
//照样首先手写链表结构体,多练
/*
struct ListNode{
int val;
ListNode *next;
ListNode(int x):val(x),next(next){}
};
*/
//第一种自己写的,边界判断有点混乱,存在很多不必要的判断,浪费时间。但好在可以AC
class Solution{
public:
bool hasCycle(ListNode *head){
if(head==nullptr || head->next==nullptr) return false;//判断单个元素,空链等必无环的情况,可以优化
//if(!head || !head->next)跟上面的判断是一样的
ListNode *pre=head,*last = head;//核心是快慢双指针,慢指针一次走一步,快指针一次走两步。只要存在环,二者必相见
while(pre != nullptr){
pre = pre->next;
if(!pre) return false;//有一种情况是想让快指针一次走满两步 pre = pre->next->next。
//但存在走过的情况,若链表无环,走到了nullptr又要往后走,但nullptr的后面是没有元素的,报错。
//所以分开,快指针每走一步都进行一次判断。
pre = pre->next;
last = last->next;
if(pre == last) return true;
}
return false;
}
};
//第二种同样是快慢指针,但是快指针的推进考虑优化了很多,放在循环条件里,简洁易懂
class Solution{
public:
bool hasCycle(ListNode *head){
ListNode *fast = head,*slow = head;
//这个判断条件我只能说666妙不可言。只有满足快指针不是空指针,且快指针的下一步也非空的时候才进入循环。
//保证了快指针fast的下两跳一定可取。不会出现边界问题
while(fast && fast->next){
fast = fast->next->next;
slow = slow->next;
if(fast == slow) return true;
}
return false;
}
};
两个有序链表的合并(LC:21)
//照例 先写一个链表节点结构体
/*
struct ListNode{
int val;
ListNode *next;
ListNode():val(0),next(nullptr){}
ListNode(int x):val(x),next(nullptr){}
ListNode(int x,ListNode *next):val(x),next(next){}
}
*/
//迭代方法,创建新的头节点,然后比较两条链表,将节点数值更小的那个放入新的链表下一位。
//直到某个节点被全部处理完。把剩下没处理完的另一条链表直接拼上
class Solution{
ListNode* mergeTwoLists(ListNode *l1,ListNode *l2){
ListNode *nhead = new ListNode(-1);//创建新的头节点
ListNode *pre = nhead;// 创建新链表中的一个动点,维护新节点的末尾节点的
//当l1 l2两个链表都非空时,进入循环,开始比较寻找两个链表中头部val更小的节点直接拼接到新链表的尾端pre->next
while(l1 && l2){
if(l1->val <= l2->val){
pre->next = l1;
l1 = l1->next;
}
else{
pre->next = l2;
l2 = l2->next;
}
//新链表尾部拼接新节点后始终将pre指向尾部,用以维护新链表的最后节点。
pre = pre->next;
}
//判断最终哪个节点时非空的?非空的剩余链表同样满足升序排列,故直接拼上就可以了
pre->next = l1?l1:l2;
//记得满足要求的链表是从头节点后面一位开始的
return nhead->next;
}
};
删除链表倒数第n个节点(LC:19)
//还是先写链表节点结构体
/*
struct ListNode{
int val;
ListNode *next;
ListNode():val(0),next(nullptr){}
ListNode(int x):val(x),next(nullptr){}
ListNode(intx,ListNode *next):val(x),next(next){}
};
*/
class Solution{
ListNode* removeNthFromEnd(ListNode *head,int n){
//快慢双指针,中间错位n步,这样保证当快指针到达末尾时,慢指针的下一步指向希望删除的节点。
ListNode *pre = head,*cur = head;
while(n>0){
pre = pre->next;
n--;
}
//考虑特殊情况,当快指针先行n步后,已然到达末尾节点的下一跳空指针。此时希望删除的就是原链表中的头节点。
//返回head->next即可
if(!pre) return head->next;
//试想,若此快指针到达了nullptr,那么相隔n步的cur指向的正是待删除节点。
//但事实上单链表只能操作下一跳节点,因此获知待删除节点的上一跳节点比它本身更有意义。
//若此时pre仍未走到尾节点,则将pre与cur同步推进直到pre走到尾节点,那么cur此时所处位置就是待删除节点的上一步。
//删除此时cur指向节点的下一步节点即可。
while(pre->next){
pre = pre->next;
cur = cur->next;
}
cur->next = cur->next->next;
return head;
}
};
求链表的中间节点(LC:876)
//照例先手写链表结构体
/*
struct ListNode{
int cal;
ListNode *next;
ListNode():val(0),next(nullptr){}
ListNode(int x):val(x),next(nullptr){}
ListNode(int x,ListNode *next):val(x),next(next){}
};
*/
class Solution{
ListNode *middleNode(ListNode * head){
ListNode *cur = head,*pre = head;
while(pre && pre->next){
pre = pre->next->next;
cur = cur->next;
}
return cur;
}
}
- 以上题解都是迭代方式,递归方式等以后精通了再来写,现在一知半解,写了也白写。。
心得(别人的)
- 函数中需要移动链表时,最好新建一个指针来移动,以免更改原始指针位置。
- 单链表有带头节点和不带头结点的链表之分,一般做题默认头结点是有值的。
- 链表的内存时不连续的,一个节点占一块内存,每块内存中有一块位置(next)存放下一节点的地址(这是单链表为例)。
- 链表找倒数第k个节点思想:创建两个指针,第一个先走k-1步然后两个在一同走。第一个走到最后时则第二个指针指向倒数第k位
- 反向链表思想:从前往后将每个节点的指针反向,即.next内的地址换成前一个节点的,但为了防止后面链表的丢失,在每次换之前需要先创建个指针指向下一个节点。
- 两个有序链表合并思想:这里用到递归思想。先判断是否有一个链表是空链表,是则返回两一个链表,免得指针指向不知名区域引发程序崩溃。然后每次比较两个链表的头结点,小的值做新链表的头结点,此节点的next指针指向本函数(递归开始,参数是较小值所在链表.next和另一个链表)。