🍕 链表
链表分为单链表和双链表:
- 单链表: 单向连接
- 双链表: 双向连接
小练习 - 反转链表
【题目】: 分别实现反转单链表和反转双链表的函数。 【要求】: 如果链表长度为 N, 要求时间复杂度为 O(N), 额外空间复杂度为 O(1)。
【⚠️重点】: 当你断掉某个链条时, 你要清楚是否需要使用到断开之前所指向的节点! 如果需要, 那么一定要先将该节点存储起来!
【我的代码】:
def reversed_linked_list(head, is_double=False):
# 判断边界条件
if head is None or head.next is None:
return head
# 初始值
pr = None
p = head
pn = p.next
while True:
p.next = pr # 反转
if is_double:
p.prev = pn # 反转双链表只多了这一步
# 结束判断
if pn is None:
break
# 更新
pr = p
p = pn
pn = p.next
return p
小练习 - 打印两个有序链表的公共部分
【题目】: 给定两个有序链表的头指针 head1 和 head2, 打印两个链表的公共部分。 【要求】: 如果两个链表的长度之和为 N, 时间复杂度要求为 O(N), 额外空间复杂度要求为 O(1)。
【算法思路】: 两个指针, 谁小谁移动。 相同时打印并一起移动。 直到某一指针越界。
【我的代码】:
def linked_list_common(head1, head2):
common = []
while head1 is not None and head2 is not None:
if head1.data < head2.data:
head1 = head1.next
elif head1.data > head2.data:
head2 = head2.next
else:
common.append(head1.data)
head1 = head1.next
head2 = head2.next
return common
面试时链表解题方法论
笔试和面试要求不一样。
- 笔试: 能过就行, 没人看你代码。 所以不用太在乎空间复杂度, 一切为了时间复杂度。
- 面试: 时间复杂度仍然是第一位, 但是空间复杂度一定要省。 因为此时看你的不是机器, 是面试官。
【技巧】:
- 额外数据结构记录(哈希表等)
- 快慢指针。 比如一个指针走一步, 一个指针走两步。
练习 - 判断单链表回文
【做法1(笔试)】: 先遍历一遍链表, 并把值依次放入栈中。 再遍历一遍链表, 此时每次遍历都将栈的内容弹出, 然后比较是否相等。 直到不相等或者栈为空。
【做法2(笔试)】: 做法 1 中遍历了两遍链表, 而且栈占用的空间大小为 N。 但实际上, 当我们遍历到链表中间之后, 就可以将栈中的元素 pop 出来和链表进行比较了。 问题在于, 如何确定中间位置? 方法就是 快慢指针。 一个指针走一步, 一个指针走两步。 当快指针到头时, 就说明到中间附近了。
【做法3(面试)】:不借助栈实现:
- 找到中间位置
- 从中间, 将后面的链表节点反转。
- 从链表两端往中间一一比较, 右链表在比较的过程中再次将链表反转
【注意⚠️】: 思路都很简单, 需要注意的只有长度奇偶数情况下, 中点的选取。 根据题意的不同, 选取的方式也不同。
老师代码
// 方法1
public static boolean isPalindrome1(Node head) {
Stack<Node> stack = new Stack<Node>();
Node cur = head;
// 将链表所有节点入栈
while (cur != null) {
stack.push(cur);
cur = cur.next;
}
// 再次遍历链表, 并从栈中元素一一比较
while (head != null) {
if (head.value != stack.pop().value) {
return false;
}
head = head.next;
}
return true;
}
// 方法2
public static boolean isPalindrome2(Node head) {
if (head == null || head.next == null) {
return true;
}
Node right = head.next;
Node cur = head;
// 找到中点位置
while (cur.next != null && nul.next.next != null) {
right = right.next;
cur = cur.next.next;
}
// 从中点位置再开始将元素入栈
Stack<Node> stack = new Stack<Node>();
while (right != null) {
stack.push(right);
right = right.next;
}
// 再次遍历, 一一比较
while (!stack.isEmpty()) {
if (head.value != stack.pop().value) {
return false;
}
head = head.next;
}
}
// 方法3
public static boolean isPalindrome3(Node head) {
if (head == null || head.next == null) {
return true;
}
Node n1 = head;
Node n2 = head;
// 快慢指针找到中间
while (n2.next != null && n2.next.next != null) {
n1 = n1.next;
n2 = n2.next.next;
}
// 反转后半链表
n2 = n1.next;
n1.next = null;
Node n3 = null;
while (n2 != null) {
n3 = n3.next;
n2.next = n1;
n2 = n3;
}
// 从链表两端向中间一一比较
n3 = n1;
n2 = head;
boolean res = true;
while (n1 != null && n2 != null) {
if (n1.value != n2.value) {
res = false;
break;
}
n1 = n1.next;
n2 = n2.next;
}
// 完成后再次把后半链表反转
n1 = n3.next;
n3.next = null;
while (n1 != null) {
n2 = n1.next;
n1.next = n3;
n3 = n1;
n1 = n2;
}
}
我的代码
def is_palindrome_linked_list2(head):
if head is None:
return False
if head.next is None:
return True
# 利用快慢指针, 走到中点就开始出栈比较
slow = head
quick = head.next
mid_r = None # 偶:偏右; 奇:中点右边
half_stack = [] # 只会填充左侧。 偶:填一半; 奇:不包含中点
while True:
half_stack.append(slow.data)
if quick.next is None: # 偶
mid_r = slow.next
break
if quick.next.next is None: # 奇
mid_r = slow.next.next
break
slow = slow.next
quick = quick.next.next
while mid_r is not None:
if (mid_r.data != half_stack.pop()):
return False
mid_r = mid_r.next
return True
def is_palindrome_linked_list3(head):
if head is None:
return False
if head.next is None:
return True
# 不借助栈, 过了中点后将链表反转
# 然后从链表两侧往回一一比较, 往回走时再次反转链表
slow = head
quick = head.next
mid_r = None
while True:
if quick.next is None:
mid_r = slow.next
break
if quick.next.next is None:
mid_r = slow.next.next
break
slow = slow.next
quick = quick.next.next
# 从 mid_r 节点开始进行反转
pr, p, pn = None, mid_r, mid_r.next
while True:
p.next = pr
if pn is None:
break
pr = p
p = pn
pn = p.next
# 此时的 p 是右链表头节点, mid_r 是尾节点
# 现在再次反转右链表, 在反转的过程中与左链表一一比较
left = head
pr, p, pn = None, p, p.next
is_palindrome = True
while True:
if left.data != p.data:
is_palindrome = False
p.next = pr
if pn is None:
break
pr = p
p = pn
pn = p.next
left = left.next
return is_palindrome
练习 - 对单链表的值进行划分
【题目】: 给定一个单链表的头节点 head, 节点的值类型是整型, 再给定一个整数 pivot。 实现一个调整链表的函数, 将链表调整为左部分都是值小于 pivot 的节点, 中间部分都是值等于 pivot 的节点, 右部分都是值大于 pivot 的节点。 【进阶】: 要求实现稳定性, 同时时间复杂度为 O(N), 额外空间复杂度为 O(1)。
【做法 1(笔试)】: 把单链表转换为数组, 然后做快排的 partition。 做完后再把数组转换为单链表
【做法 2(面试)】: 定义三个区域的空链表。 遍历链表, 将每次遍历到的节点放到正确的区域内。 遍历完成后将三个区域的链表连接起来就可以了。
由于链表中节点的移动代价是 O(1), 所以仅需有限的几个变量就实现链表 partition。
【代码思路】:
- 定义 6 个变量, SH 表示 "< 区" 的头节点, ST 表示 "<" 区域的尾节点。 同理还有 "= 区" 的两个节点 EH 和 ET, "> 区" 的两个节点 BH 和 BT。 他们的初始值都是空。
- 遍历链表
- 当节点 < 划分值时, 将节点放到 "< 区" 的链表上。具体做法是: 第一个小于划分值的节点, 将它同时赋值给 SH 和 ST, 然后 SH 就不用变了。 后续再遇到符合条件的节点时, 将它作为 ST 的下一节点, 同时 ST 向下移动, 以此类推。
- 当节点 == 划分值或节点 > 划分值 时, 都是同样的做法。
- 结束时, 将三条链表连接起来。 具体做法:
- 在 ST 不为空时: 若 EH 不为空, 则 ST 连接 EH, 否则连接 BH。 不需要再判断 BH 是否为空。
- 在 ET 不为空时, ET 只会连接 BH。
- 在 BT 不为空时, BT 必须连接"空"。
- 连接好各自的尾部后, 只需要放回三条链表中的一个头节点即可, 三个头节点的优先级是 SH > EH > BH。
老师代码
// 方法1
public static Node listPartition1(Node head, int pivot) {
if (head == null) {
return head;
}
Node cur = head;
// 获取链表长度
int i = 0;
while (cur != null) {
i++;
cur = cur.next;
}
// 将链表转换为数组
Node[] nodeArr = new Node[i];
i = 0;
cur = head;
for (i = 0; i != nodeArr.length; i++) {
nodeArr[i] = cur;
cur = cur.next;
}
// 在数组上执行 partition
arrPartition(nodeArr, pivot);
// 将数组重新连接成链表
for (i = 1; i != nodeArr.length; i++) {
nodeArr[i - 1].next = nodeArr[i];
}
nodeArr[i - 1].next = null;
return nodeArr[0];
}
public static void arrPartition(Node[] nodeArr, int pivot) {
int small = -1;
int big = nodeArr.length;
int index = 0;
while (index != big) {
if (nodeArr[index].value < pivot) {
swap(nodeArr, ++small; index++);
} else if (nodeArr[index].value == pivot) {
index ++;
} else {
swap(nodeArr, --big, index);
}
}
}
// 方法2
public static Node listPartition2(Node head, int pivot) {
// 创建三条链表, 分别存放三类值
Node sH = null; // small head
Node sT = null; // small tail
Node eH = null;
Node eT = null;
Node bH = null;
Node bT = null;
Node next = null; // save next node
// 将节点分别放入三类链表上
while (head != null) {
next = head.next;
head.next = null;
if (head.value < pivot) {
if (sH == null) {
sH = head;
sT = head;
} else {
sT.next = head;
sT = head;
}
} else if (head.value == pivot) {
if (eH == null) {
eH = head;
eT = head;
} else {
eT.next = head;
eT = head;
}
} else {
if (bH == null) {
bH = head;
bT = head;
} else {
bT.next = head;
bT = head;
}
}
head = next;
}
if (sT != null) {
// 若有 s, s 直接连 eH
sT.next = eH;
// 此时要是 eH 没有, 下一个 if 会捕获到, 并把 s 重新连到 b
// 要是 e 有, 那么 e 就需要连到 b
// 也就是, 不管 e 有没有, 都需要有人连接到 b, 要么是 s 连, 要么是 e 连
eT = eT == null ? sT : eT;
// 此时的 eT 可能是 s, 也可能是 e
}
// 如果上一个 if 进去了, 那么这里的 if 也一定会进去
// 如果上一个 if 没进去, 那么这里的 if 可能会进去
if (eT != null) {
eT.next = bH;
}
// 连接顺序是 s-e-b, 要是没有 sH, 那就返回 eH, 要是 eH 也没有, 那就返回 bH
// 所以前面两个 if 只需要确保三个区域能连接起来就可以。
return sH != null ? sH : (eH != null ? eH : bH);
}
我的代码
def linked_list_partition2(head, pivot):
# 稳定
if head is None or head.next is None:
return head
# 创建三条链表, 分别存放大于等于小于
lH, lT = None, None
eH, eT = None, None
mH, mT = None, None
p = head
while p is not None:
if p.data < pivot:
if lH is None:
lH = lT = p
else:
lT.next = p
lT = p
elif p.data == pivot:
if eH is None:
eH = eT = p
else:
eT.next = p
eT = p
else:
if mH is None:
mH = mT = p
else:
mT.next = p
mT = p
p = p.next
# 将三条链表连在一起。 只需要对 tail.next 进行操作
if lT is not None: # lT 要么连 eH, 要么连 mH。 (自动包含了 None 的情况)
lT.next = eH if eH is not None else mH
if eT is not None: # eT 只会连 mH (自动包含了 None 的情况)
eT.next = mH
if mT is not None: # mT 只会连 None
mT.next = None
# 只需返回三个头中的一个, 优先级是 lH > eH > mH
return lH if lH is not None else (eH if eH is not None else mH)
练习 - 复制含有随机指针节点的链表
【题目】: 一种特殊的单链表节点类描述如下
class Node {
int value;
Node next;
Node rand;
Node(int val){
value = val;
}
}
rand 指针是单链表节点结构中新增的指针, rand 可能指向链表中的任意一个节点, 也可能指向 null。 给定一个由 Node 节点类型组成的无环单链表的头节点 head, 请实现一个函数完成这个链表的复制, 并返回复制的新链表的头节点
╭ ---------- rand -------╮
↓ ↑
╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮
│ │ --> │ │ --> │ │ --> ....
╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯
【要求】: 时间复杂度 O(N), 额外空间复杂度 O(1)
【解释题目】: 这个链表和普通链表的区别在于, 他多了 rand 这个属性。 当我们复制链表时, 要求把每个节点上的 rand 的指向也成功复制。
【做法 1(笔记)】: 创建一个 Map, 它的 key 是旧链表节点, value 是新节点(即旧节点的克隆体)。 先遍历一遍链表, 完成所有节点值的拷贝, 此时的拷贝并不包含 next 和 rand。 再遍历旧链表, 通过旧链表获取 next 节点和 rand 节点, 将旧链表的节点作为 key, 可以获取就节点所对应的新节点的地址, 然后将新节点的地址赋值给 新节点的 next 和 rand。
【做法 2(面试)】: 如果不借助 Map 保存克隆出来的新节点, 那么就需要思考: 克隆出来的新节点要放在哪里能不占用额外空间, 并且还能根据旧节点找到对应的克隆节点? 答案就是将克隆节点插入到旧节点之后! 具体做法如下:
- 第一次遍历链表, 创建克隆节点并将其插在对应旧节点之后
- 第二次遍历链表, 为克隆节点找到他们的 rand 节点所对应的克隆节点。 (克隆 rand 节点 在 rand 节点的 next 中)
- 第三次遍历链表, 将克隆节点从中抽出出来
- 注意: 第二次遍历链表和第三次遍历链表不能合并在一次遍历中。 因为 rand 节点是随机的, 如果找到一个克隆节点的 rand 节点后就将该克隆节点抽离出来, 那么后面的节点就可能找不到它的克隆 rand 节点了。
╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮
│old1 │ --> │old2 │ --> │old3 │ --> │old4 │ --> │old5 │ --> ....
╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯
将新克隆的节点放到旧链表上
╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮ ╭ ─ ╮
│old1 │ --> │ new1│ --> │old2 │ --> │ new2│ --> │old3 │ --> │ new3│ --> │old4 │ --> │ new4│ --> │old5 │ --> │ new5│ ---> ....
╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯ ╰ ─ ╯
老师代码:
// 做法1
public static Node copyListWithRand1(Node head) {
HashMap<Node, Node> map = new HashMap<Node, Node>();
// 第一次遍历: 拷贝所有旧节点
Node cur = head;
while (cur != null) {
map.put(cur, new Node(cur.value));
cur = cur.next;
}
// 第二次遍历: 为新节点的 next 和 rand 赋值新节点的引用
cur = head;
while (cur != null) {
// 将旧节点的 next 节点 所对应的新节点复制给新节点的 next
map.get(cur).next = map.get(cur.next);
mpa.get(cur).rand = map.get(cur.rand);
cur = cur.next;
}
return map.get(head);
}
// 做法2
public static Node copyListWithRand2(Node head) {
if (head == null) {
return null;
}
// 1. 将新创建的克隆节点放到旧链表上。
Node cur = head;
Node next = null;
while (cur != null) {
next = cur.next;
cur.next = new Node(cur.value);
cur.next.next = next;
cur = next;
}
// 2. 为新创建的克隆节点的 rand 赋值
cur = head;
Node curCopy = null;
while (cur != null) {
next = cur.next.next;
curCopy = cur.next;
curCopy.rand = cur.rand != null? cur.rand.next : null;
cur = next;
}
// 3. 将克隆链表抽离出来
Node res = head.next;
cur = head;
while (cur != null) {
next = cur.next.next;
curCopy = cur.next;
cur.next = next;
curCopy.next = next != null ? next.next : null;
cur = next;
}
return res;
}
我的代码
def copy_random_node_linked_list2(head):
if head is None:
return None
# 1. 创建克隆节点并将它们插入到源节点后
p = head
while p is not None:
p_copy = Node(p.data)
p_copy.next = p.next
p.next = p_copy
p = p_copy.next
# 2. 找到克隆节点的 rand 所对应的克隆 rand 节点
p = head
while p is not None:
p_copy = p.next
pn = p.next.next
if p.rand is not None:
p_copy.rand = p.rand.next
p = pn
# 3. 将克隆节点抽离出来
p = head
copy_head = p.next
while p is not None:
p_copy = p.next
pn = p.next.next
p_copy.next = pn.next if pn is not None else None
p.next = pn
p = pn
return copy_head
练习 - 两个单链表相交
【题目】: 给定两个可能有环也可能无环的单链表, 头节点 head1 和 head2 。 请实现一个函数, 如果两个链表相交, 请返回相交的第一个节点。 如果不相交, 返回空 【要求】: 如果两个链表长度之和为 N, 时间复杂度请达到 O(N), 额外空间复杂度请达到 O(1)。
先判断一个链表是否有环
判断链表是否有环
对于链表结构, 他只会有一个 next 节点。 所以:
- 如果无环, 则一定会达到空
- 如果有环, 则一定会遇到重复的节点
【方法1】: 利用 Set 哈希表。 遍历链表, 每次遍历过程中, 先判断是否已存在该节点, 然后将该节点加到 Set 中。如果已存在, 则有环, 如果遍历到空, 则无环。
【方法2(只给步骤)】: 无环时, 快指针直接到空。 有环时, 则:
- 快指针慢指针都从头节点出发, 快指针一次走两步, 慢指针一次走一步。
- 快指针和慢指针一定会相遇。 (并且一定在两环内相遇, 即有限时间内相遇)
- 相遇后, 快指针回到头节点, 慢指针在原位置, 然后两者同时移动, 并且都是一次一步。
- 两个指针再次相遇时, 所在的节点就是第一个相交节点(入环节点)
【老师代码】:
public static Node getLoopNode(Node head) {
if (head == null || head.next == null || head.next.next == null) {
return null;
}
Node n1 = head.next; // 慢指针
Node n2 = head.next.next; // 快指针
while (n1 != n2) {
if (n2.next == null || n2.next.next == null) {
return null;
}
n2 = n2.next.next;
n1 = n1.next;
}
n2 = head;
while (n1 != n2) {
n1 = n1.next;
n2 = n2.next;
}
return n1;
}
判断链表相交
【注意】: 两条链表相交后, 一定会"一起走", 不可能分叉。 因为单链表节点只有一个 next 节点。
两条链表三种情况:
- 两个链表都是无环链表。 无环链表相交, 就相当于两条线相交, 但由于链表只有一个 next 节点, 所以相交后一定是
Y
情况, 而不会出现X
情况。 - 一条有环, 一条无环。 这种情况不可能相交。 假设无环链表和有环链表相交, 由于两个链表相交后一定会在一条线上, 所以无环链表就一定会有环, 这与假设不符, 故假设不成立。
- 两条链表都有环。 两条成环链表相交, 他们的环一定是同一个。 这个环, 要么是在相交后形成的, 要么是在相交成形成的。 如下所示
〇 〇
\ /
〇 〇 环的方向不重要,谁交谁都一样
\ / 此时环点就是交点
〇 〇 〇
| \ /
〇 〇 〇 〇
| \ / \ /
〇 〇 〇
/ \ | |
〇 〇 〇 〇
\ / \ /
〇 〇
【两条不成环链表相交时的交点求取】:
- 判断尾节点是否相同, 如果不相同则一定不相交。
- 计算两条链表的长度差 d
- 让长的链表从头节点先走 d 个节点
- 然后让两个链表同时走下去, 他们迟早会相遇。
【两条成环链表相交时的交点求取】:
- 判断两条链表的成环点是否相同, 如果相同说明他们是相交后才成环的。 即交点一点是在成环前, 故直接使用【两条不成环链表相交时的交点求取】方法求取即可
- 成环点不同, 则两条链表可能相交也可能不相交。
- 如果相交, 则说明有一条链表先成环, 然后另一条链表直接插入到环圈上的一个节点, 换句话说, 插入的这个节点就是这条链表的成环点, 同时也是他们的交点。
- 如果不相交, 则一定无法再环圈上找到另一条链表的成环点。
- 故, 只需要在一条链表的环圈上绕一圈, 看看是否有另一条链表的成环点, 如果有, 该环点就是交点。 如果饶了一圈还没找到, 说明不相交。
【老师代码】:
public static Node getIntersectNode (Node head1, Node head2) {
if (head1 == null || head2 == null) {
return null;
}
Node loop1 = getLoopNode(head1);
Node loop2 = getLoopNode(head2);
if (loop1 == null && loop2 == null) {
return noLoop(head1, head2);
}
if (loop1 != null && loop2 != null) {
return bothLoop(head1, loop1, head2, loop2);
}
return null;
}
public static Node noLoop(Node head1, Node head2) {
if (head1 == null || head2 == null) {
return null;
}
Node cur1 = head1;
Node cur2 = head2;
int n = 0;
while (cur1.next != null) {
n++;
cur1 = cur1.next;
}
while (cur2.next != null) {
n--;
cur2 = cur2.next;
}
if (cur1 != cur2) { // 尾节点不同, 一定不相交
return null;
}
cur1 = n > 0 ? head1 : head2 ; // 谁长, 谁的头变成 cur1
cur2 = cur1 == head1? : head2 : head1; // 谁短, 谁的头变成 cur2
n = Math.abs(n); // 两条链表的差值
// 先让长链表走完差值的长度
while (n != 0) {
n--;
cur1 = cur1.next;
}
// 然后两个链表一起走, 相遇时就是第一个交叉的节点
while (cur1 != cur2) {
cur1 = cur1.next;
cur2 = cur2.next;
}
return cur1;
}
public static Node bothLoop(Node head1, Node loop1, Node head2, Node loop2) {
Node cur1 = null;
Node cur2 = null;
if (loop1 == loop2) {
// 跟判断 noLoop 类似, 但区别在于 noLoop 判断的是尾巴是 None, 而这里判断的是 loop1, loop2
cur1 = head1;
cur2 = head2;
int n = 0;
while (cur1 != loop1) {
n++;
cur1 = cur1.next;
}
while (cur2 != loop2) {
n--;
cur2 = cur2.next;
}
cur1 = n > 0 ? head1 : head2;
cur2 = cur1 == head1 ? head2 : head1;
n = Math.abs(n);
while (n != 0) {
n--;
cur1 = cur1.next;
}
while (cur1 != cur2) {
cur1 = cur1.next;
cur2 = cur2.next;
}
return cur1;
} else {
cur1 = loop1.next;
while (cur1 != loop1) {
if (cur1 == loop2) {
return loop1; // 返回 loop1 或 loop2 都行, 这两个都叫做第一个相交的节点
}
cur1 = cur1.next;
}
// cur1 转完一圈后都没遇到 loop2, 说明两个环没有相交
return null;
}
}