每日算法学习记录
206、反转链表
思路分析
要反转整个链表,只需要将每一个节点的next
域指向自己的前置节点就可以了。因此,我们需要两个指针:
current
:当前正在操作的节点
previous
:当前节点的前置节点
但是同时,我们还要将current
的后置节点提前捏在手里,即用一个tmp
变量存储后置节点的地址值,否则随着current.next
的修改,后面的所有节点都丢了!
代码展现
由于反转之后,不需要再返回原来的链表头,于是我们可以将给的head
变量作为previous
指针使用,这样的话在修改完最后的节点时,返回head
,也将恰好是优雅地返回新头节点的正确方式。
public ListNode reverseList(ListNode head) {
ListNode p = head; //current指针
head = null; //previous指针
while (p != null) {
ListNode tmp = p.next; //提前存储后置节点
p.next = head; //修改next域为前置节点
head = p;
p = tmp;
}
return head;
}
19、删除链表的倒数第N个节点
思路分析
看到这道题的题目描述中包含的“倒数”、“只扫描一次”等字眼,很多人自然而然地就想到使用递归的算法,因为递归是一次压栈的过程,在触底之后再返回,符合“倒数”。也就是定义一个递归方法remove
,功能是返回输入节点的倒数位置。
那么这样的话,每个节点的倒数位置都是后置节点的倒数位置加1。进入下一次递归的操作就是:
int count= 1 + remove(p.next, n);
在返回倒数位置count
之前需要判断一下是不是等于n+1
,因为要删除倒数第n
个点,就需要找到它的前置节点倒数第n+1
个节点,进行删除操作。
代码展现
public ListNode removeNthFromEnd(ListNode head, int n) {
head = new ListNode(-1, head);//虚拟头节点
remove(head, n);//进入递归方法
return head.next;//返回真正头节点
}
private int remove(ListNode p, int n) {//返回的是当前节点是倒数第几个节点
if (p.next == null) {//如果p的后置节点为空,说明到尾节点了
return 1;//尾节点计数为倒数第1个节点
}
int count = 1 + remove(p.next, n);//进入递归
//每个节点开始的链表都是当前节点+后置节点开始的链表
//所以当前节点的倒数位置=1+后置节点的倒数位置
if (count == n + 1) {//找到需要删除的节点的前置节点了
p.next = p.next.next;//删除倒数第n个节点
}
return count;//返回当前节点的倒数位置
}
160、链表相交
思路分析
需要特别指出的是,本题的链表相交,指的并不是相交处节点存储的数据val
相等,而是节点地址相同,也就是A中某节点和B中某节点是同一个节点对象。
本题有一个最符合直觉的思路,那就是如果两个链表相交,那么从某个节点开始都可以看作同一个链表,当遍历A的指针pointerA
和遍历B的指针pointerB
同时遍历到这个共同子链表的头节点时,就是需要返回的答案节点。
如果两个链表长度相同,则同时从头向后遍历,碰到pointerA==pointerB
的情况,就返回;
如果两个链表长度不等,那么可以强行“让它长度相等”,也就时抛弃掉较长的那个链表的前若干个节点,使得开始遍历时两个链表长度相同。另外一种角度来讲,也就是将尾巴对齐。
要做到这个操作,只需要预先计算好两个链表的长度lenA
和lenB
,然后让pointerA
先行遍历lenA-lenB
个节点即可(假设链表A较长)。
代码展现
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
int lenA = 0, lenB = 0;
for (ListNode p = headA; p != null; p = p.next) {
lenA++;
}
for (ListNode p = headB; p != null; p = p.next) {
lenB++;
}
int i = lenA - lenB;
if (i>0) {
while (i-- > 0) {
headA = headA.next;
}
} else {
while (i++ < 0) {
headB = headB.next;
}
}
while (true) {
if (headA == headB) {
return headA;
}
headA = headA.next;
headB = headB.next;
}
}
由于要两个链表都要计算长度,因此这将是整个算法中最耗时的环节
复杂度O(Max(lenA, lenB)) = O(n)
142、环形链表II
思路分析
哈希表法
看到这道题,其实首先就会想到,将遍历过的节点记录在HashSet
中,当每遍历到一个节点时,只需要对比一下它是否已存在于HashSet
内即可。
但这么做有一个弊端,因为链表理论上可以是无限长,当然不能超过虚拟机的内存大小,但这也远超Java数组的最大长度2^31-1
。
我们知道HashSet
的底层是HashMap
实现的,而HashMap
的底层就是数组+链表(或红黑树)实现的。那么当我们碰到一条“能绕地球一圈”的链表时,HashMap
的占用空间不仅会巨大,而且搜索效率也将变得极低。况且每遍历一个节点就要搜索一次。
这道题或许能用HashSet
实现,但一定不是最优解。
双指针法
在处理链表相关的问题时,双指针法是降低时空复杂度的首选。这道题也能使用双指针法,但相对来说会复杂一些,需要一点数学证明过程。
定义一个快指针fast
,一个慢指针slow
。fast每次移动2个节点,slow每次移动1个节点。这样一来,如果链表没有环,fast很快就会触底;如果链表有环,那么fast和slow最终会相遇,而且是在环内相遇。
数学证明
如果设:
从头节点到相交点为x
个节点
相交点到快慢指针相遇点为y
个节点
相遇点经环绕一圈到相遇点为z
个节点
则slow
移动了x+y
个节点,fast
移动了x+y+n(y+z)
个节点(n>=1)
由于fast
每移动2
个节点,slow
移动1
个节点
于是有方程
2
×
(
x
+
y
)
=
x
+
y
+
n
×
(
y
+
z
)
2\times (x+y)=x+y+n\times(y+z)
2×(x+y)=x+y+n×(y+z)
化简得到
x
=
n
(
y
+
z
)
−
y
x=n(y+z)-y
x=n(y+z)−y
而我们发现,如果从快慢指针的相遇点开始,如果要移动到相交点,则要移动z+m(y+z)
,其中m>=0
,而n>=1
,则可以表示为m=n-1
,代入可得
z
+
(
n
−
1
)
(
y
+
z
)
=
n
(
y
+
z
)
−
y
=
x
z+(n-1)(y+z)=n(y+z)-y=x
z+(n−1)(y+z)=n(y+z)−y=x
上面的式子说明只要重新设定一个指针i
从链表头开始,一个指针j
从快慢指针相遇点开始,同时等距移动,那么i和j的相遇点一定是链表环的相交点。
因此,这道题的解法就很明确了
- 设定
fast
指针从头节点开始每次移动2
个节点,设定slow
指针从头节点开始每次移动1
个节点,直到相遇。 - 将
fast
指针拨至链表头,和slow
指针一起每次移动1个节点。 - 返回
fast
指针和slow
指针的相遇点。
代码展现
//双指针做法
public ListNode detectCycle(ListNode head) {
ListNode slow = head, fast = head;
do {
if (fast == null || fast.next == null) {
return null;
}
fast = fast.next.next;
slow = slow.next;
} while (slow != fast);
slow = head;
while (fast != slow) {
fast = fast.next;
slow = slow.next;
}
return fast;
}
//哈希表做法
public ListNode detectCycle(ListNode head) {
if (head == null) return null;
Set<ListNode> set = new HashSet<>();
ListNode _head = new ListNode();
_head.next = head;
head = _head;
while (head.next != null) {
if (set.contains(head.next)) {
return head.next;
}
set.add(head.next);
head = head.next;
}
return null;
}
总结和思考
以上就是今天的算法学习记录,我们通过思路分析、数学证明等手段,完成了四个链表题目:206.反转链表、19.删除链表的倒数第N个节点、160.链表相交、142.环形链表II。其中206题是Day3的内容,但Day3的博客错把今天的题目《两两交换链表内的节点》给写了,就只能调换一下顺序了。
链表的题目说起来就是,要注意头节点和尾节点的特殊情况,这一点我们选择了建立一个虚拟头节点来大幅简化特殊处理头节点的复杂情形。
在修改节点连接顺序的时候,要注意先将会因覆盖而丢失的后置节点临时存储起来,只要缕清思路、勤画图,链表的题目是简单的!