这篇文章是看了“左程云”老师在b站上的讲解之后写的, 自己感觉已经能理解了, 所以就将整个过程写下来了。
这个是“左程云”老师个人空间的b站的链接, 数据结构与算法讲的很好很好, 希望大家可以多多支持左程云老师, 真心推荐.
左程云的个人空间-左程云个人主页-哔哩哔哩视频 (bilibili.com)
1. 链表类问题的注意点
一般来说:链表类问题在笔试和面试中遇见, 都是和 LeetCode
一样的“填函数”风格的, 不是 ACM
风格
链表类问题可以使用容器做, 但是这样的空间复杂度会高一点, 但是我们这节课的重点是对于空间的优化, 尽量使用少的空间, 但是用容器做这样会大大降低难度, 所以看情况, 要是笔试和面试没有要求你不能使用容器就直接用, 要是给了你严格的空间要求, 就不能使用容器解决了.
所以在接下来的实现中, 并不给出用容器做的代码实例, 只是给出逻辑实现, 不用容器, 只是使用几个变量的方式, 既有逻辑实现, 又有代码实例.
2. 题目一:两个链表相交的第一个节点
2.1 逻辑实现
我们先判断一个两个链表如何才能相交:我们只要遍历完两个链表之后, 查看两个链表的最终节点的地址是不是相同就行了, 若是相同, 说明两个链表肯定是相交了的, 若是不相同, 那就说明这两个链表肯定是没有相交.
两个链表相交就是下面这样的情况,
2.1.1 用容器实现
另一种用容器实现的方法:可以设置一个哈希表, 然后将其中一条链表的所有节点都放到哈希表中, 然后将另一条链表中的所有节点遍历一遍, 每次遍历到一个节点的时候就和哈希表中的所有节点对比一下, 看看在不在其中, 到了相交的第一个节点, 肯定就在哈希表中, 这样肯定就能判断这个是第一个节点了.
2.1.2 不用容器实现
如何实现寻找它们相交的第一个节点
- 我们先进行统计两个链表的长度, 比如是一个长一个短 (也有可能相同长度, 但是无所谓),
- 我们先让长的链表的长度“减去”短的链表的长度, 这样有一个值:
diff
, - 先让长的链表先走
diff
步 (先遍历diff
次), - 然后两个链表同时进行遍历, 只要最后
h1, h2
两个链表遍历到的节点的地址相同了, 那这个就是两个链表相交的第一个节点.
2.2 代码实例
所有的解释我都直接写到代码中了, 这个题目很简单, 所以就不多说了.
public static ListNode getIntersectionNode(ListNode h1, ListNode h2) {
if (h1 == null || h2 == null) {
return null; // 若是两个传递进来的两个链表中有任意一个是null, 直接返回null.
}
ListNode a = h1, b = h2;
int diff = 0; // diff的意义是:统计两个链表的长度的差值.
while (a.next != null) {
a = a.next;
diff++; // 先让“a”链表先遍历, 最后得出“a”链表的长度,
}
while (b.next != null) {
b = b.next;
diff--; // 然后让“b”链表后遍历, 最后得出“a链表长度 - b链表长度”.
}
if (a != b) {
return null; // 如果最后“a链表的最后一个节点地址 != b链表的最后一个节点地址”
} // 就直接返回 null, 因为这样就说明两个链表肯定没有相交.
if (diff >= 0) {
a = h1; // 这段代码实现的意义是将“a链表设置为长链表, b链表设置为短链表.
b = h2;
} else { // 要是diff > 0, 说明“a链表长度 > b链表长度”.
a = h2; // 要是diff < 0, 说明“a链表长度 < b链表长度”.
b = h1;
}
diff = Math.abs(diff); // diff的正负的意义就是选择一个长链表, 现在已经完成了, 所以直接取绝对值
while (diff-- != 0) {
a = a.next; // 因为我们上面设置好了“a链表是长链表, 所以这里我们先让a遍历diff个节点”.
}
while (a != b) {
a = a.next; // 最后, 让两个链表同时遍历, 每次都进行判断两个节点的地址是不是相同.
b = b.next;
}
return a; // 最后返回一个节点, 因为此时已经是到了同一个链表的第一个节点了, 所以返回“a 或者是 b都行”.
}
3. 题目二:按组翻转链表
3.1 题目描述
3.2 逻辑实现
3.2.1 用容器实现
可以将所有链表节点都放到一个数组中, 然后将所有的数字都在数组中进行交换, 这样就能非常简单地实现了, 但是这样需要的空间复杂度是:O(n)
. 我们这里需要的是 O(1)
的时间复杂度.
3.2.2 不用容器实现
我们如今有这样一个链表:a -> b -> c -> d -> e -> f -> g -> h -> null
. 并且设置 k = 3
, 将这个链表按 k
个翻转.
- 首先我们需要对第一组进行单独调整, 因为我们需要将
c
这个节点作为头结点返回, 所以第一组是特殊的, 我们可以利用我们前面讲过的翻转链表的实现, 唯一有一个地方不用的是:不能将a
指向null
, 需要将a
指向d
, 效果是:a -> b -> c -> d
修改为:c -> b -> a - d
. - 然后继续对
d -> e -> f
这一组操作, 还是执行上述翻转链表的操作, 然后将d -> g
, - 然后此时我们需要将
a -> f
因为此时第二组也已经被翻转过了, 所以此时f
成了第二组的头结点, - 最后按照上述的方式进行实现.
总结:这种做法实现了 0(1)
的空间复杂度实现, 但是还是有点绕的, 需要仔细思考才行.
3.2.3 代码实例
整个步骤我都在注释中写好了,
注意:这个的操作是有点复杂的, 步骤也是比较长, 边界问题也有, 但是不难理解, 所以最好是自己用电脑上的画图软件自己跟着代码走一遍, 这样会有助于理解.
// 不要提交这个类
public static class ListNode {
public int val;
public ListNode next;
}
// 提交如下的方法
public static ListNode reverseKGroup(ListNode head, int k) {
ListNode start = head; // 设置start为head, 后续需要这个.
ListNode end = teamEnd(start, k); // end节点是这一组的最后一个节点.
if (end == null) {
return head; // 若是最后end == null, 说明这一组没有 k 个节点, 所以直接返回head就行了.
}
// 第一组很特殊因为牵扯到换头的问题
head = end; // 此时说明end != null, 所以将head指向end, 因为需要翻转这组链表了.
reverse(start, end); // 翻转这组链表.
// 翻转之后start变成了上一组的结尾节点
ListNode lastTeamEnd = start;
while (lastTeamEnd.next != null) {// 这段循环的终止条件是上一组的结尾节点的下一个节点是null.
start = lastTeamEnd.next; // 此时下一组的开始节点是上一组的结尾节点的下一个节点
end = teamEnd(start, k); // end是这一组的下一个节点.
if (end == null) {
return head; // 还是按照上述的方式, 此时说明这一组链表没有k个节点, 直接返回
}
reverse(start, end); // 翻转这个链表.
lastTeamEnd.next = end; // 这一组的最后一个节点的指向的下一个节点修改为end
lastTeamEnd = start; // 然后将这一组的最后一个节点修改为start.
}
return head; // 最后返回head.
}
// 当前组的开始节点是s,往下数k个找到当前组的结束节点返回
public static ListNode teamEnd(ListNode s, int k) {
while (--k != 0 && s != null) {
s = s.next; // 这个就不多解释了, 直接看就能明白
}
return s;
}
// s -> a -> b -> c -> e -> 下一组的开始节点
// 上面的链表通过如下的reverse方法调整成 : e -> c -> b -> a -> s -> 下一组的开始节点
public static void reverse(ListNode start, ListNode end) {
end = end.next;
ListNode pre = null, cur = start, next = null;
while (cur != end) {
next = cur.next;
cur.next = pre;
pre = cur; // 这个就是之前讲过的翻转链表的问题, 只是增加了指向下一个组的开始节点.
cur = next;
}
start.next = end;
}
4. 题目三:复制带随机指针的链表
4.1 逻辑实现
4.1.1 用容器实现的方式
可以先设置一个哈希表, 然后遍历一遍原本的链表, 将其保存在 key
, 但是不设置指向任何节点的指针, 然后设置对应的 1'
节点, 将其放到 value
, 同样也不指向任何节点, 都放入之后, 从头结点 1
开始, 设置对应的 next指针和 random指针
, 然后 1'
也能通过查找哈希表的方式找到所有对应的节点位置, 这样就能实现题目的要求了.
4.1.2 不用容器实现的方式
- 因为我们需要完全复制原来的一个链表, 所以我们将每一个节点写作:
1', 2', 3'
, 我们先不管random指针指向
, - 将原来的:
1 -> 2 - > 3 -> null
修改为:1 -> 1' -> 2 -> 2' -> 3 -> 3' -> null
, - 然后遍历修改之后的链表, 根据
原来链表节点的:random指针
设置新加入的链表节点的指针:random'
- 最后将这个链表连接好之后, 再将原来的链表和我们自己新加入的链表分离就行了, 而且对
random
指针没有影响.
4.2 代码实例
public static Node copyRandomList(Node head) {
if (head == null) {
return null;
}
Node cur = head;
Node next = null;
// 1 -> 2 -> 3 -> ...
// 变成 : 1 -> 1' -> 2 -> 2' -> 3 -> 3' -> ... while (cur != null) {
next = cur.next;
cur.next = new Node(cur.val);
cur.next.next = next;
cur = next;
}
cur = head;
Node copy = null;
// 利用上面新老节点的结构关系,设置每一个新节点的random指针
while (cur != null) { // 新的random指针肯定是原来的random指针指向的下一个
next = cur.next.next; // 所以直接设置就行了.
copy = cur.next;
copy.random = cur.random != null ? cur.random.next : null;
cur = next;
}
Node ans = head.next;
cur = head;
// 新老链表分离 : 老链表重新连在一起,新链表重新连在一起
while (cur != null) {
next = cur.next.next;
copy = cur.next;
cur.next = next;
copy.next = next != null ? next.next : null;
cur = next;
}
// 返回新链表的头节点
return ans;
}
5. 题目四:判断链表是否是回文结构
5.1 逻辑实现
5.1.1 用容器的实现
用栈实现, 先遍历所有节点, 将所有节点都放到栈中, 然后弹出和遍历同时执行, 并且要作对比, 只要有一个不一样的就直接返回 false
.
5.1.2 不用容器的实现
- 设置一对“快慢指针”, 作用是寻找整个链表的中点,
- 若是一个奇数个数的链表, 那慢指针就会到达中间位置.
- 若是一个偶数个数的链表, 那慢指针就会到达中间两个节点的位置的左边的一个节点.
- 之后从慢指针的下一个链表节点开始, 将后续的所有链表节点翻转.
- 然后设置“左右两个指针”, 分别到最左边和最右边的位置, 从两边往中心靠近, 分别比较对应的值, 只要有一个不一样的就说明不是回文结构.
- 若是一个奇数个数的链表, 那慢指针就会到达中间位置, 最中间的位置不用比较.
- 若是一个偶数个数的链表, 那慢指针就会到达中间两个节点的位置的左边的一个节点. 直接比较中间的两个位置的大小就行了.
5.2 代码实例
该有的注意点都在代码中说明了, 直接看就行了.
public static class ListNode {
public int val;
public ListNode next;
public ListNode(int val) {
this.val = val;
}
}
public static boolean isPalindrome(ListNode head) {
if (head == null || head.next == null) {
return true;
}
ListNode slow = head;
ListNode fast = head; // 将两个快慢指针同时指向head.
while (fast.next != null && fast.next.next != null) {
slow = slow.next; // 这段代码的唯一意义就是将“slow”指针放到链表的中间位置.
fast = fast.next.next; // fast指针后续真的不重要, 后续需要关注的是“slow”指针
}
ListNode right = reverse(slow.next, slow); // 将“slow”指针后面的链表都进行翻转.将“右指针”指向最右边的位置.
ListNode left = head; // 将“左指针”指向head头结点位置
while (left != null && right != null) { // 左右两边, 若是有任何一个位置的为null就停止
if (left.val != right.val) {
return false; // 只要有任何一对数字不相同就直接返回false.
}
left = left.next; // 指向左指针的下一个
right = right.next; // 指向右指针的下一个.
}
return true; // 若是都相同, 最后就返回true.
}
// 我单独实现了一个翻转链表的函数, 在这个题目中, 这个connectNode 对应的是 slow 节点
// 若是不加入一个connectNode节点, 会将这个链表断开, 无论是奇数个数还是偶数个数的链表
// 比如:不加的情况:1 -> 2 -> 3 -> 4 会变成:1 -> 2 -> null 3 <- 4 这样2, 3节点之间会断开,
// 加入了这个connect之后:1 -> 2 -> 3 -> 4 会变成:1 -> 2 <- 3 <- 4, 2 -> null, 这样就不会断开了.
public static ListNode reverse(ListNode head, ListNode connectNode) {
ListNode pre = connectNode;
ListNode next = null;
while (head != null) {
next = head.next;
head.next = pre;
pre = head;
head = next;
}
return pre;
}
5.3 重排链表考研常见 (附加题)
左老师没有单独讲解这个题目:但是我找到了 LeetCode
的链接如下:
https://leetcode.cn/problems/reorder-list/
.
将下面的代码直接提交就可以了, 能通过.
该有的注释我都在代码中写好了.
将这个链表 a1 -> a2 -> a3 -> a4 -> a5 -> a6 -> a7 -> a8 -> null
变成
a1 -> a8 -> a2 -> a7 -> a3 -> a6 -> a4 -> a5 -> null
这个题目和上面这个双指针找中点的方式是一样的.
我就不写什么流程了, 链表就是调整节点, 直接对着代码一步一步画一下就行了.
public static ListNode reorderList(ListNode head) {
if (head == null) {
return null;
}
ListNode slow = head;
ListNode fast = head;
while (fast.next != null && fast.next.next != null) {
slow = slow.next;
fast = fast.next.next;
}
ListNode right = reverse(slow.next, slow);
slow.next = null;
ListNode left = head;
ListNode next = null;
while (left != null && right != null) {
next = left.next;
left.next = right;
right = right.next;
left.next.next = next;
left = next;
}
return head;
}
// 我单独实现了一个翻转链表的函数, 在这个题目中, 这个connectNode 对应的是 slow 节点
// 若是不加入一个connectNode节点, 会将这个链表断开, 无论是奇数个数还是偶数个数的链表
// 比如:不加的情况:1 -> 2 -> 3 -> 4 会变成:1 -> 2 -> null 3 <- 4 这样2, 3节点之间会断开,
// 加入了这个connect之后:1 -> 2 -> 3 -> 4 会变成:1 -> 2 <- 3 <- 4, 2 -> null, 这样就不会断开了.
public static ListNode reverse(ListNode head, ListNode connectNode) {
ListNode pre = connectNode;
ListNode next = null;
while (head != null) {
next = head.next;
head.next = pre;
pre = head;
head = next;
}
return pre;
}
6. 题目五:链表的第一个入环节点
这个就是一个有环链表
6.1 逻辑实现
6.1.1 用容器实现
直接利用一个哈希表, 然后遍历所有节点, 若是哈希表中没有这个节点就将这个节点放入到哈希表, 要是有说明这肯定是第一个入环节点.
6.1.2 不用容器实现
注意:这个方法直接记住就行了, 不用就纠结什么证明, 若是有兴趣自己去看一下证明就行了.
- 设置一对“快慢指针”, 然后还是设置成快指针移动两步的同时, 慢指针移动一步.
- 若是一个有环链表, 那么快慢指针一定会相遇.
- 若是一个无环链表, 那么快指针肯定会到
null
. 直接返回null
就行.
- 快慢指针相遇之后, 让快指针立即回到头节点, 慢指针停留在原地.
- 然后快指针移动一步, 慢指针同时也移动一步.
- 最后快慢指针一定会在入环节点位置相遇.
6.2 代码实例
这个实现起来非常简单, 没有什么需要注意的地方, 还是直接记住就行, 不用深究证明方式.
public static ListNode detectCycle(ListNode head) {
if (head == null || head.next == null || head.next.next == null) {
return null;
}
ListNode slow = head.next;
ListNode fast = head.next.next;
while (slow != fast) {
if (fast.next == null || fast.next.next == null) {
return null;
}
slow = slow.next;
fast = fast.next.next;
}
fast = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
7. 题目六:链表排序
7.1 题目描述
在链表上进行排序, 而且要求时间复杂度是:O(n * log(n))
, 空间复杂度:O(1)
, 具有稳定性
7.2 逻辑实现
7.2.1 用容器实现
直接用数组就能实现了, 没有什么可以说的.
7.2.2 不用容器实现
这个的思路非常好想, 可以参考数组排序的归并排序的思路, 正常用数组排序的话, 需要一个 help
辅助数组, 但是使用链表是不需要的, 链表本来就是一个跳转结构, 我们只需要找到那个小的节点, 然后一个一个串起来就行了.
还有一个需要注意的是, 我们不用设置 help
数组的同时也不能进行递归调用, 因为递归调用本身也是占用空间的, 空间复杂度是:O(log(n))
所以我们需要使用的方法是:利用原来讲过的使用步长调整的方式来实现归并排序.
- 最开始设置步长
step == 1
, 然后将两个数字进行merge
过程, 当然mrege
过程也是需要用链表进行实现的. - 然后将
step <<= 1(就是乘以二)
, 然后继续将对应的4
个数字进行merge
过程, - 一直重复上述过程.
- 最后直到所有数字都排好序了停止.
7.3 代码实例
这个代码写起来很难, 直接用左老师的代码了, 也比较清楚.
// 提交如下的方法
// 时间复杂度O(n*logn),额外空间复杂度O(1),有稳定性
// 注意为了额外空间复杂度O(1),所以不能使用递归
// 因为mergeSort递归需要O(log n)的额外空间
public static ListNode sortList(ListNode head) {
int n = 0;
ListNode cur = head;
while (cur != null) {
n++;
cur = cur.next;
}
// l1...r1 每组的左部分
// l2...r2 每组的右部分
// next 下一组的开头
// lastTeamEnd 上一组的结尾
ListNode l1, r1, l2, r2, next, lastTeamEnd;
for (int step = 1; step < n; step <<= 1) {
// 第一组很特殊,因为要决定整个链表的头,所以单独处理
l1 = head;
r1 = findEnd(l1, step);
l2 = r1.next;
r2 = findEnd(l2, step);
next = r2.next;
r1.next = null;
r2.next = null;
merge(l1, r1, l2, r2);
head = start;
lastTeamEnd = end;
while (next != null) {
l1 = next;
r1 = findEnd(l1, step);
l2 = r1.next;
if (l2 == null) {
lastTeamEnd.next = l1;
break;
}
r2 = findEnd(l2, step);
next = r2.next;
r1.next = null;
r2.next = null;
merge(l1, r1, l2, r2);
lastTeamEnd.next = start;
lastTeamEnd = end;
}
}
return head;
}
// 包括s在内,往下数k个节点返回
// 如果不够,返回最后一个数到的非空节点
public static ListNode findEnd(ListNode s, int k) {
while (s.next != null && --k != 0) {
s = s.next;
}
return s;
}
public static ListNode start;
public static ListNode end;
// l1...r1 -> null : 有序的左部分
// l2...r2 -> null : 有序的右部分
// 整体merge在一起,保证有序
// 并且把全局变量start设置为整体的头,全局变量end设置为整体的尾
public static void merge(ListNode l1, ListNode r1, ListNode l2, ListNode r2) {
ListNode pre;
if (l1.val <= l2.val) {
start = l1;
pre = l1;
l1 = l1.next;
} else {
start = l2;
pre = l2;
l2 = l2.next;
}
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
pre.next = l1;
pre = l1;
l1 = l1.next;
} else {
pre.next = l2;
pre = l2;
l2 = l2.next;
}
}
if (l1 != null) {
pre.next = l1;
end = r1;
} else {
pre.next = l2;
end = r2;
}
}
8. 总结
上述的这些题目使用容器做都是比较容易的, 但是链表训练的是我们的 coding 能力, 而且若是使用空间复杂度为:O(1)
, 的方法其实并不简单, 所以在我们训练的时候, 尽量不使用容器做, 这样能很好地锻炼我们的 coding 能力, 想要做到随意所欲的修改代码, 实现要求, 只有自己一步一步的来调整, 训练, 这个哪一个老师都教不了.