1. 环形链表
首先以此题作为链表带环问题的引入,首先给出此题的思路和代码
思路:
- 循环条件:fast和fast->next不能为NULL
- 注意:要先走一步再判断,因为fast和slow最初都指向head
bool hasCycle(struct ListNode *head) { struct ListNode * fast = head; struct ListNode * slow = head; while(fast && fast->next) { fast = fast->next->next; slow = slow->next; if(slow == fast) { return true; } } return false; }
链表带环问题
在解决带环问题时,我们使用了经典的快慢指针。但你有没有想过:
- 为什么fast走两步,slow走一步就能确定它有没有环呢?(这里仅讨论fast,理论上slow是一样的)
- 如果fast走3步,走n步,能否确定环一定存在呢?
- 怎样才能找到环的入口呢?(进入环的第一个节点)
下面给出证明:
fast2步,slow1步
由上一题我们知道:如果链表带环,那么快慢指针一定会在环中相遇。这是判断链表带环与否的标志。下面假设链表有环。
在slow进环之前,fast一定已经进环。一旦slow进环(指向入口第一个节点),fast和slow的关系就像追及问题,假设此时它们之间的距离为N。 这时候fast以每次比slow多一步的优势顺时针向slow进发。
fast3步,slow1步
顺着上面的思路:如果快指针每次走3步呢?快慢指针一定会在环内相遇吗?现在,fast以每次比slow多两步的优势顺时针向slow进发。
假设slow在入口时,它们之间的距离为奇数N每循环一次,它们的距离就会从N,N-2,… 1,-1(因为N是奇数)
这里的-1是什么意思呢?
图中影响理解的关键点有:
- 它们的起始距离N是奇数
- fast比slow每次多走2步
- 环的长度为C
- 只要fast和slow不相等,这个循环就不会停止
看最后一个环中,如果环的长度是偶数,那么下轮循环开始时它们的距离C-1就是奇数,是不是会重蹈上面的覆辙?如此循环下去,slow和fast将永不相遇。
小结:在fast比slow每次多走2步的情况下,只要快慢指针之间的距离是奇数,那么随着循环的迭代,距离一定会再次减到-1,开启下一轮循环,永不相遇。其他情况也是类似的。
所以slow每次走1步,fast每次走3步,一定能确定链表带环吗?
不一定,得看起始时它们的距离和链表长度。
环的入口
假设起点到入口的距离为L,入口到相遇点的距离为X(顺时针),环的长度为C
由以上的图示及快慢指针距离的倍数可以得出以下式子
单看式子比较抽象,把它放到环中看:
重新理解这个式子:左边直接锁定了入口(因为设L是起点和入口的距离);右边可以理解为让指针从相遇点退回N圈,再退回X步。
即:一个指针从起点走,一个指针从相遇点走,如果有环,那么它们一定会在入口点相遇。
注:这里的往回退是想象地理解,实际上指针只能顺时针往下走。
2. 环形链表 II
思路:一个指针从起点走,一个指针从相遇点走,如果有环,那么它们一定会在入口点相遇。
先找到相遇点,然后让指针从相遇点和起点开始走,直到它们相等,即为入口。
- 循环终止条件:fast和slow相遇
struct ListNode *detectCycle(struct ListNode *head) { struct ListNode * fast = head; struct ListNode * slow = head; struct ListNode * cur = head; while(fast && fast->next) { fast = fast->next->next; slow = slow->next; //一个指针从起点走,一个指针从相遇点走, //那么它们一定会在入口点相遇 if(fast == slow) { struct ListNode * meet = slow;//让meet从相遇点开始 while(meet != cur)//让两个指针同时走 { meet = meet->next; cur = cur->next; } if(meet == cur) { return meet; } } } return NULL; }
3. 复制带随机指针的链表
思路:
这道题表达得比较抽象,理解就ok了。
假如要复制一个普通的单向链表,只需复制数据,然后将所有节点按顺序链接即可。
而题目的意思是这个链表中除了有next这个指针变量,还有一个random指针变量,它的指向是指向任意节点的,如示例1。
那么复制它就不能像复制普通链表那样,单纯复制数据,然后链接。
思路1:
- 遍历,先认为这是一个普通链表,只处理next和val,将它们链接起来,剩下每个节点的random;
- 然后再遍历,处理random。但random指向的节点是随机的,而且val的值可能有多个节点,所以在处理每个random都要重新遍历一遍链表。
时间复杂度O(N*N),效率不太高,虽然思路比较简单,但是实现起来复杂。
思路2:
- 在每个原链表的每两个节点之间都插入一个新节点,这样处理random就好办了:想处理当前的新节点的random,只需找到它前一个节点(即当前位置的原节点)的random。新节点的random指向的地方就在原节点的random指向的下一个节点(要指向的新节点)
- 把原链表连接回去
- 把新链表连接上
- 注意更新cur(cur是当前节点)
- 链接新链表的循环终止条件:cur遍历原链表为空时
- 注意判断head是否为空
- 先链,再处理random
- 最后再还原
/** * Definition for a Node. * struct Node { * int val; * struct Node *next; * struct Node *random; * }; */ struct Node* copyRandomList(struct Node* head) { if(head == NULL) return head; struct Node* cur = head; while(cur) { struct Node* next = cur->next; struct Node* copy = (struct Node*)malloc(sizeof(struct Node)); copy->val = cur->val;//拷贝val值 cur->next = copy;//让旧指向新 copy->next = next;//让新指向旧 cur = next;//旧链表迭代 } cur = head;//让cur重新回到起点 //处理新random while(cur) { struct Node* copy = cur->next;//表示新节点在旧节点后面 if(cur->random == NULL)//random可能没有指向 { copy->random = NULL; } else { copy->random = cur->random->next;//将老random给新 } cur = copy->next;//走两步才是下一个旧节点 } //链接新链表,还原旧链表 //其实就是删除节点,有三种情况:头尾中 //顺便把删除的连接起来 cur = head;//让cur重新回到起点 struct Node* copyHead, *copyTail; copyHead = copyTail = (struct Node*)malloc(sizeof(struct Node)); while(cur) { struct Node* copy = cur->next;//新 struct Node* next = copy->next;//旧 //新链表尾插 copyTail->next = copy; copyTail = copyTail->next; //还原旧链表 cur->next = next; cur = next; } struct Node* guard = copyHead; copyHead = copyHead->next; free(guard); return copyHead; }
最后链接的部分:给新链表创建了一个头结点,最后要将返回的地址更新为头结点的下一个地址
4. 对链表进行插入排序
插入排序的核心思想(升序):将数据分为两部分,前一部分看作是有序的,后半部分一个一个地跟前半部分的第一个比较,如果比它小,则放到它前面;否则跟它的下一位比较,如果要插入的数据比到最后还是比它大,则插入到前一部分的最后。
思路:
- 注意判断空链表和只有一个节点的情况
struct ListNode* insertionSortList(struct ListNode* head){ if(head == NULL || head->next == NULL)//特殊情况 { return head; } struct ListNode* sortHead = head; struct ListNode* cur = head->next; sortHead->next = NULL; //上面两句将链表分为两段 while(cur) { struct ListNode* next = cur->next;//迭代条件 struct ListNode* p = NULL;//这个p只有在插入时用来保存插入位置前一个节点的地址 struct ListNode* c = sortHead;//更新排序后的链表的地址 while(c) { if(cur->val < c->val)//符合插入条件,先跳过 { break; } else//c往后移动 { p = c; c = c->next; } } if(p == NULL)//p是空,说明c一直是第一个,头插 { cur->next = c; sortHead = cur; } else//p在中间 { p->next = cur; cur->next = c; } cur = next;//迭代 } return sortHead; }
5. 删除有序链表中重复的元素-I
思路:
这是一个删除节点的问题,也是经典的快慢指针思想问题。即:如果指针所指节点val相同,快指针继续走,直到它们不相等,然后只保留一个,剩下的跳过。如此反复,直到快指针遍历完链表所有节点。
- 循环终止条件:next遍历完链表所有节点
- 注意判断链表是否为空和只有一个节点
struct ListNode* deleteDuplicates(struct ListNode* head ) { struct ListNode* cur = head; while(cur && cur->next)//cur判断链表为空的情况 { if(cur->val == cur->next->val)//相等让next往下走 { cur->next = cur->next->next; } else//否则让cur往前走 { cur = cur->next; } } return head; }
6. 删除有序链表中重复的元素-II
思路:
- 循环终止条件:next遍历完所有节点
- 注意判断链表为空和只有一个节点的情况
- 连续有首、中、尾、全这些情况。
struct ListNode* deleteDuplicates(struct ListNode* head ) { if(head == NULL || head->next == NULL) return head; struct ListNode* cur = head; struct ListNode* prev = NULL; struct ListNode* next = cur->next; while(next) { if(next->val == cur->val) { while(next && next->val == cur->val)//跳过相同的 { next = next->next;//让next继续走 } while(next != cur)//让cur到next的位置 { struct ListNode* del = cur; cur = cur->next; free(del);//删除跳过的节点 } if(prev == NULL)//prev空,是头部连续的标志 { head = cur;//更新链表地址 } else//不为空,说明prev在中间 { prev->next = cur;//连接跳过之后的节点 } if(next)//迭代 { next = next->next; } } else//迭代 { prev = cur; cur = next; next = next->next; } } return head; }
心得
对链表的操作,无非就是增、删、改
增:头插、尾插、中间插
删:头删、尾删、中间删
- 对于单向链表,增或删都需要知道当前位置的上一个节点和下一个节点的信息。所以一般涉及删除或增加节点,都需要使用三指针分别维护当前位置的前一个节点,当前节点,下一个节点。通常分别用prev、cur、next命名
- 关于cur的迭代:当循环条件为cur不为空时,next不要写在while循环内,因为当cur为空时,next是
野指针
- 画图是最最重要的,通过画图才能由清晰的思路。
- 通过画图得到的思路,不要立刻动手写代码,因为常常存在特殊情况。例如:链表为空或者只有一个,题目条件推出来的特殊情况,实在想不出其他情况了再动手写。然后看没有通过的用例来找到其他情况,调试代码。
- 有了较完整的思路还不够,将想法转换成代码的能力需要训练,实际上有不少地方是有差不多的“模板”的,例如:迭代next,要在循环的首尾处;多个指针同时迭代,从左往右修改等等。
- 涉及到对第一个节点的修改(单向无头不循环链表),常常可以跳过添加一个哨兵位节点简化操作,在结束时再去掉它即可
- 调试的重要性:越界编译器不一定会报错.因为系统对越界的检查时设岗抽查,并不是在所有位置都检查是否越界,这与编译器和系统有关.所以我们要养成“防御式编程”的习惯
日志
4/18/2022 心得->关于第一个节点 4/19/2022 心得->cur的迭代、调试的重要性 man9o