前言
链表题目一向是面试算法考察的一个热点,作为一个必刷的专题,早做晚做都得做,不如早点将这个专题拿下~
1. 移除链表元素
刷链表的题目,那当然是从最经典,也是最基础的移除链表元素开始啦~
题目链接:203. 移除链表元素
题目描述
思路没什么好说的,就是遍历链表然后移除指定节点即可,来看代码:
代码
/** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func removeElements(head *ListNode, val int) *ListNode { phead := &ListNode{Next: head} // 创建哨兵位 cur := phead for cur.Next != nil { tmp := cur cur = cur.Next // 遍历 if cur.Val == val { // 移除节点的操作 tmp.Next = cur.Next cur = tmp } } return phead.Next }
有两个需要注意的地方:
- 我这里创建了一个哨兵位,这样可以让我们在移除元素的时候更方便,不需要对头插进行特判(我个人比较喜欢添加哨兵位)
- 移除节点的操作细节(推荐画图分析链表题目,再根据自己画的图写代码,思路会非常的清晰)
2. 设计链表
这道题目其实并不算是一道算法题,他训练的是我们的链表的基本功
题目链接:707. 设计链表
题目描述
这道题也没什么可说的,按照题目把需要实现的接口都实现了就行,训练基本功的时候总是非常痛苦的,不痛苦又怎么能够进步呢~
来看代码:
代码
type MyLinkedList struct { head *ListNode size int } func Constructor() MyLinkedList { return MyLinkedList{&ListNode{}, 0} } func (list *MyLinkedList) Get(index int) int { if index < 0 || index >= list.size { return -1 } cur := list.head for i := 0; i <= index; i++ { cur = cur.Next } return cur.Val } func (list *MyLinkedList) AddAtHead(val int) { list.AddAtIndex(0, val) } func (list *MyLinkedList) AddAtTail(val int) { list.AddAtIndex(list.size, val) } func (list *MyLinkedList) AddAtIndex(index int, val int) { if index > list.size { return } index = max(index, 0) cur := list.head for i:= 0; i < index; i++ { cur = cur.Next } newnode := &ListNode{val, cur.Next} cur.Next = newnode list.size++ } func (list *MyLinkedList) DeleteAtIndex(index int) { if index < 0 || index >= list.size { return } cur := list.head for i:= 0; i < index; i++ { cur = cur.Next } cur.Next = cur.Next.Next list.size-- } func max(a, b int) int { if a > b { return a } return b } /** * Your MyLinkedList object will be instantiated and called as such: * obj := Constructor(); * param_1 := obj.Get(index); * obj.AddAtHead(val); * obj.AddAtTail(val); * obj.AddAtIndex(index,val); * obj.DeleteAtIndex(index); */
这道题目没有什么非常需要注意的地方,插入和删除的操作也相对简单,不要忘记判断传过来的参数是否合法就行,唯一需要注意的地方是,我们可以不用实现头插和尾插的操作,只需要实现插入的操作,然后直接进行复用就行,复用的思想无论到哪里都不过时~
3. 反转链表
接下来这道题目更是重量级,也许你前面或者后面的链表题目都没做过,但至少也会见过这道题,永远的经典,反转链表。这里分享一个小故事:我在刚学链表的时候,我的数据结构老师跟我说,他当年找工作面试学生的时候就被考过这道题目,现在他教的一个学生出去面试了,面试官也出了这道题目,这何尝不是一种传承呢~
题目链接:206. 反转链表
题目描述
这道题目的解法其实不少,但是我最喜欢,也是我用的最多的一种方法是通过反转指针来完成链表的反转。来看代码:
代码
/** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func reverseList(head *ListNode) *ListNode { var prev *ListNode = nil var cur *ListNode = head for cur != nil { tmp := cur.Next cur.Next = prev prev = cur cur = tmp } return prev }
这道题主要就是需要一个 prev 的辅助指针来帮助我们反转链表的指针指向,在之后许多链表相关的题目我们常常会使用一些辅助的指针来帮助我们操作。
另外,在做这道题题目的时候是不能用 Golang 的语法糖来初始化 prev 指针的:prev := &ListNode{},因为 := 符号没有办法初始化出 nil 值,所以只好使用 var 来定义了。
4. 两两交换链表中的节点
我们继续来练习~
题目链接:24. 两两交换链表中的节点
题目描述
这道题目可以用递归来做代码会相对少一点,不过我习惯使用迭代来做链表的题目,所以我选择的还是通过迭代来对链表进行操作,我个人觉得迭代的方法也会比递归要更容易去理解。来看代码:
代码
/** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func swapPairs(head *ListNode) *ListNode { phead := &ListNode{Next: head} cur := phead for cur.Next != nil && cur.Next.Next != nil { node1 := cur.Next node2 := cur.Next.Next // 这个的主要作用有两个: // 1. 第一次的时候让哨兵位指向第一个节点 // 2. 之后每次的翻转后连接下一段节点 cur.Next = node2 // 交换位置 node1.Next = node2.Next node2.Next = node1 cur = node1 } return phead.Next }
链表题最核心的地方其实就是具体操作链表时的逻辑,这里我采用的是让 cur 一直处于一个哨兵位的状态,通过设置 node1 和 node2 来进行互换的操作,每次交换的连接操作就是这段代码的核心:cur.Next = node2。
也许我们做第一遍的时候想不出解决的方案,但是当我们刷第二遍,第三遍,刷更多更多的题目之后,再遇到类似的题目我们也不会怕了。
5. 删除链表的倒数第 N 个结点
我们再来刷一道经典链表题
题目链接:19. 删除链表的倒数第 N 个结点
题目描述
这道题直接做其实并不难,而他经典的地方就在于,怎么样能够是实现一次遍历完成题目的要求,也就是 LeetCode 这道题最后那一句进阶做法,这个就是这道题思路的精华。来看代码:
代码
/** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func removeNthFromEnd(head *ListNode, n int) *ListNode { phead := &ListNode{Next: head} slow, fast := phead, head for n > 0 { // 让 fast 指针先走 n 步 fast = fast.Next n-- } for fast != nil { // 同时遍历,这样 slow 指针就会离链表尾 n 个身位 slow = slow.Next fast = fast.Next } slow.Next = slow.Next.Next // 删除节点 return phead.Next }
这道题想要做到一次遍历完成,就得用到双指针的思想(这也是我们最开始先把双指针学了的原因之一)链表使用双指针的思想来解题其实并不罕见,这里就是一个简单的快慢指针的应用,我们看代码的注释可以很容易的把核心思路给看懂。
6. 链表相交
我们再来刷一道经典的链表题目:
题目链接:面试题 02.07. 链表相交
题目描述
LeetCode 这里把题目给的很长很长,其实我也没有吧题目全部看完,其实抓住核心点就行了,这道题目的意思就是让我找出两个链表相交的点,然后返回那个点就行,如果没哟相交的点就返回空。来看代码:
代码
/** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func getIntersectionNode(headA, headB *ListNode) *ListNode { if headA == nil || headB == nil { // 如果有一个链表是空,那肯定没相交 return nil } curA, curB := headA, headB for curA != nil || curB != nil { // 互相走一遍对方的路 if curA == nil { curA = headB } if curB == nil { curB = headA } if curA == curB { // 相交的位置 return curA } curA = curA.Next curB = curB.Next } return nil }
如果是直接做的话,有很多的方法都能找到他们相交的地方,所以题目有一个进阶的要求,让我们用 O(N) 的时间,O(1) 的空间复杂度解决这道题目,实际上我第一次做的时候也是想不出来这能怎么实现的,看了题解才恍然大悟,非常的巧妙
具体的思路是这样的,我们设置两个指针遍历两个链表,指针走完自己的链表之后(也就是走到 nil 之后)到另外一个链表重头开始遍历,如果两个链表存在相交,那两个指针就会相遇,如果没有相交,那他们就会同时走到 nil,这样返回 nil 即可。
7. 环形链表 II
接着就是最后一道,非常经典的环形链表
题目链接:142. 环形链表 II
题目描述
如果这道题目想按照题目要求的那样,使用 O(1) 的空间,用双指针来做的话,更像是一个数学题,来看代码:
代码
/** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func detectCycle(head *ListNode) *ListNode { slow, fast := head, head for { if fast == nil || fast.Next == nil { // 走到尾了,证明没环 return nil } slow = slow.Next fast = fast.Next.Next if slow == fast { // 相遇了,证明有环 break } } fast = head // 让 fast 回头重新遍历 for slow != fast { slow = slow.Next fast = fast.Next } return fast }
所以这道题的重点就在最后一步为什么要这么做,我这里把证明贴出来:
- 指针走过的步数为 a + nb 时可重返环入口(如果链表有环)
- 设在双指针在第一次相遇时 slow 的步数 s 为 s 步, 那么 fast 的步数f即为 2s 步
- 在环中相遇时, fast 比 slow 多走 nb 步,也即 fast 走了 s + nb 步
- 结合条件 2,3 :f = 2nb, s = nb
- 结合条件 1 与结论 1: slow 再走 a 步即可到达入口点
总结
链表的题目其实还有很多很多,这里就把一些经典的题型给总结到了一起,如果什么时候对链表生疏了,来刷几道,感觉一定能很快回来~
至于其他的一些变式题目,基础打牢,怕什么变式题呢~,题目永远是刷不完的,多做多总结,才能越做越顺手。