【算法通关村 Day2】反转链表
链表反转青铜挑战
手写链表反转
方式一 头插法
- 借助虚拟头节点:
dummyHead
是一个值为 0 的节点,它的next
一开始指向null
。我们不关心dummyHead
的值,只关注它的next
指针,它将最终指向反转后的链表头部。 - 保存当前节点的下一个节点
- 将当前节点插入到虚拟头节点后面
- 更新虚拟头节点的 next 为当前节点
- 继续遍历链表
public class ReverseList {
class ListNode {
int val;
ListNode next;
ListNode(int val){
this.val = val;
}
}
public ListNode reverseList(ListNode head){
ListNode dummyHead = new ListNode(0);
ListNode current = head;
while (current != null) {
ListNode nextNode = current.next;
current.next = dummyHead.next;
dummyHead.next = current;
current = nextNode;
}
return dummyHead.next;
}
}
方式二 迭代法(使用 3 个指针)
next = curr.next
:保存当前节点的next
,这样我们在修改curr.next
时,不会丢失对下一个节点的引用。curr.next = prev
:将当前节点的next
指向前一个节点prev
,这样就完成了当前节点的反转。prev = curr
:将prev
更新为当前节点,为下一次反转做准备。curr = next
:将curr
移动到下一个节点,继续进行反转。- prev 最终是新的头节点
public class ReverseList {
class ListNode {
int val;
ListNode next;
ListNode(int val){
this.val = val;
}
}
public ListNode reverseList(ListNode head){
ListNode prev = null;
ListNode curr = head;
ListNode next = null;
while (curr != null) {
next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
}
迭代法通过三个指针来逐步反转链表,时间复杂度为 O(n),空间复杂度为 O(1),只用了常数的额外空间。
方式三 递归法
-
递归终止条件:如果链表为空(
head == null
)或者只有一个节点(head.next == null
),递归就结束,直接返回当前节点head,
作为新链表的头。 -
递归过程:
- 在
head.next != null
时,递归调用reverseList(head.next)
来反转当前节点后面的链表部分,直到递归到链表的最后一个节点。 - 递归返回时,会把当前节点的
next
指针指向前一个节点,逐步完成链表的反转。
- 在
-
递归的回溯阶段:
- 当递归返回时,
head
的next
指针需要指向前一个节点(head.next.next = head
),然后将head.next
设置为null
,断开原来链表的连接,防止链表形成环。 - 这种逐步“反转”指针的过程,实际是从链表尾部逐渐完成反转,最终形成完全反转的链表。
- 当递归返回时,
-
返回新链表头:递归的返回值
newHead
始终指向反转后的链表头部,最终返回newHead
作为反转后的链表头。
public class ReverseList {
class ListNode {
int val;
ListNode next;
ListNode(int val){
this.val = val;
}
}
public ListNode reverseList(ListNode head){
if(head == null || head.next == null){
return head;
}
ListNode newHead = reverseList(head.next);
head.next.next = head;
head.next = null;
return newHead;
}
}
时间复杂度是 O(n),空间复杂度是 O(n)。每一次递归都需要占用栈空间,递归深度为链表长度,最坏情况下空间复杂度为 O(n)。
链表反转白银挑战
指定区间反转
已知链表头节点和整数left和right,left<right<链表长度,要求反转链表从位置left到位置right的链表节点,返回反转后的链表。leetcode
- 虚拟头节点:我们使用虚拟头节点(
dummy
)来简化头节点的处理,避免特殊情况处理,比如链表从头开始反转。 - 定位
prev
节点:我们将prev
移动到left-1
的位置。 - 反转部分链表:使用
for
循环反转从left
到right
的部分。在每次循环中:next
存储当前节点的下一个节点;- 将当前节点的
next
指向next.next
,使当前节点与后面的节点脱离; - 将
next
插入到prev
后面,完成反转。
public class ReverseBetween {
class ListNode {
int val;
ListNode next;
ListNode(int val){
this.val = val;
}
}
public static ListNode reverseBetween(ListNode head, int left, int right){
ListNode dummyHead = new ListNode(0);
dummyHead.next = head;
ListNode prev = dummyHead;
for(int i = 0; i < left-1; i++){
prev = prev.next;
}
ListNode curr = prev.next;
ListNode post = null;
for(int i = 0; i < right - left; i++){
post = curr.next;
curr.next = post.next;
post.next = prev.next;
prev.next = post;
}
return dummyHead.next;
}
}
- 这个算法的时间复杂度是
O(n)
,其中n
是链表的长度,因为我们只需要遍历链表一次。
两两交换链表节点
已知一个链表,要求两两交换链表中的节点,返回交换后的头节点(智能交换节点,不能只交换节点值)。详见leetcode
-
dummyHead 节点:为了简化边界处理(特别是当链表长度为 0 或 1 时),我们创建了一个虚拟节点 dummyHead,它的
next
指针指向链表的头节点。dummyHead节点本身不会参与交换,但它帮助我们处理边界情况。 -
current 指针:current 是指向当前交换对的前一个节点,它初始化为 dummyHead。每次交换后,current 更新为交换后的第一个节点。
-
交换操作:
- 每次迭代我们交换
head
和head.next
这两个节点。 - current
.next
指向交换后的第二个节点(即head.next
)。 first.next
指向交换后第二个节点的下一个节点(即head.next.next
)。second.next
指向第一个节点(即head
)。
- 每次迭代我们交换
-
更新指针:
- 在交换完当前一对节点后,更新 current 指向
first
(即交换后的第一个节点)。 - 然后将
head
更新为first.next
,继续处理下一对节点。
- 在交换完当前一对节点后,更新 current 指向
-
返回结果:最终返回 dummyHead
.next
,这是交换后的新头节点。
public class SwapPairs {
class ListNode {
int val;
ListNode next;
ListNode(int val){
this.val = val;
}
}
public static ListNode swapPairs(ListNode head) {
ListNode dummyHead = new ListNode(-1);
ListNode current = dummyHead;
dummyHead.next = head;
while (head != null && head.next != null) {
ListNode first = head;
ListNode second = head.next;
current.next = second;
first.next = second.next;
second.next = first;
current = first;
head = first.next;
}
return dummyHead.next;
}
}
时间复杂度是 O(n),其中 n
是链表的长度。我们只需要遍历链表一次。
空间复杂度是 O(1),我们只使用了常数级别的额外空间。
单链表加一
已知一个单链表,用这个单链表来表示一个整数,从表头到表尾表示从高位到低位,要求将这个数加1后返回。
运算过程中会出现:无进位,有进位但不在首位,有进位且在首位 这三种情况。
首先反转链表,然后像加法运算一样从链表的头部开始进行加 1 操作,最后再将链表反转回去。这样做的优点是避免了使用栈,同时实现相对简单。
步骤:
- 反转链表:先反转链表,这样可以从个位开始进行加法操作。
- 加法操作:从链表的头部开始加 1,处理进位。
- 反转链表回去:最后,反转链表以恢复原来的顺序。
public class PlusOne {
class ListNode {
int val;
ListNode next;
ListNode(int val) {
this.val = val;
}
}
public ListNode plusOne(ListNode head) {
if (head == null) return new ListNode(1);
// 1. 反转链表
ListNode reversedHead = reverseList(head);
// 2. 从反转后的链表开始加 1
ListNode current = reversedHead;
int carry = 1; // 初始加1
while (current != null && carry > 0) {
int sum = current.val + carry;
current.val = sum % 10; // 更新当前节点值
carry = sum / 10; // 计算进位
current = current.next;
}
// 如果最后仍然有进位
if (carry > 0) {
ListNode newNode = new ListNode(carry);
current.next = newNode;
}
// 3. 反转链表回去
return reverseList(reversedHead);
}
// 反转链表的辅助函数
private ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode current = head;
while (current != null) {
ListNode nextTemp = current.next;
current.next = prev;
prev = current;
current = nextTemp;
}
return prev;
}
}
- 反转链表的时间复杂度是 O(n),其中 n 是链表的长度。
- 遍历链表进行加法操作的时间复杂度是 O(n)。
- 最终反转链表的时间复杂度是 O(n)。
除了使用反转链表也可以使用栈。因为栈是后进先出(LIFO)结构,所以链表的最后一个节点会被最先取出来,这样我们就能从个位开始进行加法运算。
add
初始为 1,因为我们需要给链表表示的数字加 1。flag
用于表示进位(1
代表有进位,0
代表没有进位)。sum
是当前节点的值、加 1 的值以及进位的值之和。
处理进位:
- 如果
sum
大于等于 10,说明需要进位。我们把当前节点的值更新为sum - 10
,并设置flag = 1
表示有进位。 - 如果
sum
小于 10,则直接更新当前节点的值,并把flag
设置为 0 表示没有进位。 - 一旦完成加法,
add
会被设置为 0,因为我们只需要给链表的最后一位加 1。 - 如果最后一个节点加法后仍然有进位(即
flag == 1
),说明大整数本身发生了进位(比如999 + 1
变成了1000
)。此时,我们需要在链表的头部添加一个新的节点1
。
import java.util.Stack;
public class PlusOne {
class ListNode {
int val;
ListNode next;
ListNode(int val){
this.val = val;
}
}
public static ListNode plusOne(ListNode head){
Stack<ListNode> stack = new Stack<>();
ListNode current = head;
while (current != null) {
stack.push(current);
current = current.next;
}
int add = 1;
int flag = 0;
boolean carry = true;
while (!stack.isEmpty()) {
ListNode node = stack.pop();
int sum = node.val + add + flag;
if (sum >= 10) {
node.val = sum - 10;
flag = 1;
} else {
node.val = sum;
flag = 0;
}
add = 0;
}
if (flag == 1) {
ListNode newHead = new ListNode(1);
newHead.next = head;
head = newHead;
}
return head;
}
}
链表加法
两个非空链表存储两个整数,数字最高位位于链表开始位置,每一个节点存储一个数字,将这两个链表的数字相加,返回一个新的链表。(假设除了数字0以外,这两个数字不会以0开头)。
反转链表:由于链表的存储顺序是从高位到低位,直接从链表头开始加法比较麻烦。我们可以首先将两个链表反转,这样可以从低位开始逐位加法。
- 我们初始化了
carry = 0
,表示初始没有进位。 - 然后进入
while
循环,直到两个链表都遍历完并且没有进位为止。 - 每次循环时,我们从两个链表(如果有节点的话)中取出当前位的值,并加上进位
carry
。 - 然后,我们计算当前位的值(
digit = sum % 10
)和进位(carry = sum / 10
)。 - 将计算得到的
digit
存入一个新的链表。 - 加法完成后,
resultHead
中存储的链表是反转的,所以最后我们需要再将其反转一次,得到最终的结果链表。
public class AddTwoNumbers {
class ListNode {
int val;
ListNode next;
ListNode(int val){
this.val = val;
}
}
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode reversedL1 = reverseList(l1);
ListNode reversedL2 = reverseList(l2);
ListNode resultHead = null;
ListNode current = null;
int carry = 0;
while (reversedL1 != null || reversedL2 != null || carry != 0) {
int sum = carry;
if (reversedL1 != null) {
sum += reversedL1.val;
reversedL1 = reversedL1.next;
}
if (reversedL2 != null) {
sum += reversedL2.val;
reversedL2 = reversedL2.next;
}
carry = sum / 10;
int digit = sum % 10;
ListNode newNode = new ListNode(digit);
if (resultHead == null) {
resultHead = newNode;
current = resultHead;
} else {
current.next = newNode;
current = current.next;
}
}
return reverseList(resultHead);
}
private ListNode reverseList(ListNode head){
ListNode prev = null;
ListNode current = head;
while (current != null) {
ListNode nextTemp = current.next;
current.next = prev;
prev = current;
current = nextTemp;
}
return prev;
}
}
时间复杂度:
- 反转两个链表的时间复杂度是 O(n) 和 O(m),其中
n
和m
分别是两个链表的长度。 - 遍历两个链表并进行加法操作的时间复杂度是 O(max(n, m))。
- 反转结果链表的时间复杂度是 O(max(n, m))。
因此,总的时间复杂度是 O(max(n, m))。
空间复杂度:
由于反转链表和创建新链表的操作,我们需要 O(max(n, m)) 的空间来存储结果链表。
链表反转黄金挑战
K个一组反转链表
给你链表的头节点 head
,每 k
个节点一组进行翻转,请你返回修改后的链表。
k
是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k
的整数倍,那么请将最后剩余的节点保持原有顺序。
你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
- 虚拟头节点:创建一个虚拟头节点
dummy
,让它指向原链表的头节点。这样可以简化操作,尤其是在翻转链表时不需要考虑头节点的特殊情况。 - 长度计算:遍历链表一次,计算链表的长度
length
。然后,在每次翻转时,判断剩余的节点是否有足够的数量(至少为k
个)来进行翻转。 - 翻转过程:
- 每次翻转前,
curr
指向当前需要翻转的一组的第一个节点,next
指向下一个节点。 - 通过修改指针实现翻转,每次将
next
节点插入到当前组的最前面。 - 翻转一组后,更新
prev
为当前组的尾节点,准备翻转下一组。
- 每次翻转前,
- 更新链表结构:翻转完成后,返回
dummy.next
,即新的链表头。
class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
public class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
// 如果链表为空或k为1,不需要翻转
if (head == null || k == 1) {
return head;
}
// 虚拟头节点,简化处理
ListNode dummy = new ListNode(0);
dummy.next = head;
// 用prev指针指向翻转的前一个节点
ListNode prev = dummy;
ListNode curr = head;
ListNode next = null;
// 计算链表的长度
int length = 0;
while (curr != null) {
length++;
curr = curr.next;
}
// 每k个节点一组进行翻转
while (length >= k) {
curr = prev.next; // 当前组的第一个节点
next = curr.next; // 当前节点的下一个节点
// 进行翻转操作
for (int i = 1; i < k; i++) {
curr.next = next.next; // 将当前节点的next指向next.next,断开原有连接
next.next = prev.next; // 将next的next指向prev.next,这样可以将next节点放到最前面
prev.next = next; // prev的next指向next,使链表连接起来
next = curr.next; // 更新next节点
}
prev = curr; // prev指向当前组翻转后的尾节点
length -= k; // 更新剩余的节点数
}
return dummy.next;
}
}
- 时间复杂度:O(n),其中
n
是链表的长度。每个节点最多被遍历两次(一次计算长度,一次进行翻转)。 - 空间复杂度:O(1),我们只使用了常数级的额外空间。