25. Reverse Nodes in k-Group
題目 / Problem
中文: 給定一個鏈結串列的頭節點 head,請每 k 個節點為一組進行反轉,並回傳修改後的串列。k 是正整數且不大於串列長度。如果最後剩下的節點數量不足 k 個,這些節點就保持原樣、不反轉。你不能修改節點的值,只能改變節點之間的連接關係(也就是移動指標)。
English: Given the head of a linked list, reverse the nodes k at a time and return the modified list. k is a positive integer not larger than the list length. If the leftover nodes at the end are fewer than k, leave them as they are. You may not change the values inside nodes — you may only re-link the nodes themselves (rearrange pointers).
Constraints / 限制:
- 節點數量 n 滿足 1 <= k <= n <= 5000
- 節點值 0 <= Node.val <= 1000
- Follow-up:能否只用 O(1) 額外空間?
Example / 範例:
Input: head = [1,2,3,4,5], k = 2
Output: [2,1,4,3,5]
前兩個 [1,2] 反轉成 [2,1],接著 [3,4] 反轉成 [4,3],最後只剩一個 5 不足 2 個,保持原樣。
The first pair [1,2] becomes [2,1], the next pair [3,4] becomes [4,3], and the lone 5 stays put because it can't form a full group of 2.
名詞解釋 / Glossary
- 鏈結串列 / Linked list:一種資料結構,由一連串「節點」組成。每個節點存一個值 (
val) 和一個指向下一個節點的指標 (next)。最後一個節點的next是空 (NULL)。你只能從頭開始,一個接一個往後走,不能像陣列那樣用索引直接跳到中間。 - 節點 / Node:串列中的一個元素。這裡定義為
struct ListNode { int val; struct ListNode *next; }。 - 指標 / Pointer:一個存放「記憶體位址」的變數。
p->next表示「p 所指節點的 next 欄位」。改指標就是改連接方向,而不是複製資料。 - 虛擬頭節點 / Dummy node:一個我們自己造的、放在真正 head 前面的假節點。好處是「反轉第一組」和「反轉後面的組」可以用同一套程式碼處理,不用為「頭有可能改變」寫特例。
- 原地反轉 / In-place reversal:不另外開新串列,只透過改動現有節點的
next指標來反轉。額外空間只需要幾個指標變數,因此是O(1)空間。 - 反轉一段串列 / Reversing a segment:把
a -> b -> c改成a <- b <- c,靠三個指標prev / cur / next一步步把每個節點的箭頭往回指。 - 遞迴 / Recursion:函式自己呼叫自己。這題也可以用遞迴:反轉前 k 個,再把「反轉後那段的尾巴」接到「對剩餘串列遞迴的結果」。本文主解採用迭代(
O(1)空間),因為它符合 follow-up。
思路
中文:最直覺的暴力想法是把所有值倒進一個陣列,每 k 個一組反轉,再重建串列。但題目明確說「不能改值、只能改節點」,而且 follow-up 要求 O(1) 空間,用陣列需要 O(n) 空間,不理想。所以我們要在串列上原地操作指標。核心子問題是「反轉一段長度為 k 的串列」。反轉單向串列的標準手法是三指標法:用 prev、cur、next 三個指標,從左往右走,每一步把 cur->next 反過來指向 prev,然後三個指標一起往後挪。難點在於「一組一組地反轉,還要把各組正確地縫回原串列」。為了避免第一組頭節點改變帶來的特例,我們建立一個 dummy 虛擬頭節點放在最前面。接著用一個 groupPrev 指標,代表「上一組的最後一個節點」(第一次就是 dummy)。每一輪先從 groupPrev 往後數 k 個,確認確實還有 k 個節點(不足就停止,剩下的保持原樣);找到這一組的邊界後,就對這 k 個節點做反轉,再小心地把三個接點接好:groupPrev->next 接到反轉後的新頭,原本的組頭(反轉後變成組尾)接到下一組的開頭。反轉完後,原本的組頭正好變成這組的尾巴,於是它就成為下一輪的 groupPrev。不斷重複直到剩下不足 k 個為止。這個做法每個節點只被走訪常數次,時間 O(n),只用了固定數量的指標,空間 O(1)。
English: The brute-force idea is to dump all values into an array, reverse each block of k, and rebuild — but the problem forbids changing values (only nodes may move) and the follow-up wants O(1) space, while an array costs O(n). So we manipulate pointers in place. The building block is "reverse a segment of k nodes," done with the classic three-pointer technique: walk prev, cur, next left to right, and at each step flip cur->next to point back at prev, then slide all three forward. The tricky part is stitching each reversed group back into the list correctly. To avoid special-casing the very first group (whose head may change), we add a dummy node in front. We keep a groupPrev pointer meaning "the last node before the current group" (initially the dummy). Each round, we first walk k steps from groupPrev to confirm a full group of k really exists (if not, we stop and leave the tail untouched). Once we know the group's boundary, we reverse those k nodes, then carefully reconnect three joints: groupPrev->next points to the new head of the reversed group, and the old group-head (now the group's tail) links to the start of the next group. Conveniently, that old group-head becomes the tail, so it serves as groupPrev for the next round. Repeat until fewer than k nodes remain. Each node is visited a constant number of times, giving O(n) time and O(1) extra space.
逐步走查 / Walkthrough
輸入 / Input: head = [1,2,3,4,5], k = 2。我們建立 dummy -> 1 -> 2 -> 3 -> 4 -> 5,groupPrev = dummy。
Round 1 — 反轉 [1,2] / reverse group [1,2]:
| 步驟 Step | 動作 Action | 串列狀態 List state |
|---|---|---|
| 檢查 Check | 從 groupPrev 往後數 2 個,存在 1 和 2 ✓ | dummy -> 1 -> 2 -> 3 -> 4 -> 5 |
| 記錄 Record | groupStart = 1(反轉後會變組尾);nextGroup = 3 |
— |
| 反轉 Reverse | 三指標把 1 -> 2 變成 2 -> 1 |
局部:2 -> 1 |
| 接頭 Join head | groupPrev(dummy)->next = 2(新組頭) |
dummy -> 2 -> 1 ... |
| 接尾 Join tail | groupStart(1)->next = nextGroup(3) |
dummy -> 2 -> 1 -> 3 -> 4 -> 5 |
| 移動 Advance | groupPrev = groupStart = 1(這組的尾) |
groupPrev 指向 1 |
Round 2 — 反轉 [3,4] / reverse group [3,4]:
| 步驟 Step | 動作 Action | 串列狀態 List state |
|---|---|---|
| 檢查 Check | 從 groupPrev(1) 往後數 2 個,存在 3 和 4 ✓ | ... 1 -> 3 -> 4 -> 5 |
| 記錄 Record | groupStart = 3;nextGroup = 5 |
— |
| 反轉 Reverse | 把 3 -> 4 變成 4 -> 3 |
局部:4 -> 3 |
| 接頭 Join head | groupPrev(1)->next = 4 |
... 1 -> 4 -> 3 ... |
| 接尾 Join tail | groupStart(3)->next = nextGroup(5) |
dummy -> 2 -> 1 -> 4 -> 3 -> 5 |
| 移動 Advance | groupPrev = 3 |
groupPrev 指向 3 |
Round 3 — 檢查 / check:
| 步驟 Step | 動作 Action | 結果 Result |
|---|---|---|
| 檢查 Check | 從 groupPrev(3) 往後數 2 個:只有 5,湊不滿 2 個 ✗ | 停止 / stop |
剩下的 5 不足 k 個,保持原樣。最終回傳 dummy->next:[2,1,4,3,5]。
The leftover 5 is fewer than k, so it stays. Return dummy->next → [2,1,4,3,5]. ✓
Solution — C
/*
* 演算法 / Algorithm:
* 用 dummy 虛擬頭簡化接線;每輪先數 k 個確認整組存在,
* 再用三指標原地反轉這 k 個節點,然後縫回前後兩段。
* Use a dummy head; each round verify k nodes exist, reverse them
* in place with three pointers, then re-stitch the joints. O(n) time, O(1) space.
*/
// LeetCode 的節點定義(此處僅為說明,實際由平台提供)
// LeetCode's node definition (shown for reference; the platform provides it)
// struct ListNode { int val; struct ListNode *next; };
struct ListNode* reverseKGroup(struct ListNode* head, int k) {
// 建立虛擬頭節點,next 先指向原本的 head
// Create a dummy node whose next points to the original head.
// 好處:反轉第一組時,dummy 扮演「上一組的尾巴」,不用寫特例
// Benefit: dummy acts as the "previous group's tail" so the first group needs no special case
struct ListNode dummy; // 放在堆疊上,函式結束前都有效 / lives on the stack, valid until we return
dummy.next = head; // dummy 接到真正的頭 / dummy links to the real head
// groupPrev:目前這一組「前面」的那個節點(一開始是 dummy)
// groupPrev: the node just before the current group (starts as dummy)
struct ListNode* groupPrev = &dummy; // & 取位址,得到指向 dummy 的指標 / & takes the address of dummy
while (1) { // 無窮迴圈,內部用 break 跳出 / infinite loop, we break out inside
// ---- 第一步:往後數 k 個,確認這一組是完整的 ----
// ---- Step 1: walk k nodes ahead to confirm a full group exists ----
struct ListNode* kth = groupPrev; // 從 groupPrev 開始數 / start counting from groupPrev
for (int i = 0; i < k; i++) { // 走 k 步 / take k steps
kth = kth->next; // -> 是「跟著指標找下一個節點」/ -> follows the pointer to the next node
if (kth == NULL) { // 走到底還沒數滿 k 個 / ran off the end before reaching k
return dummy.next; // 剩下的保持原樣,直接回傳結果 / leftover stays as-is; return the answer
}
}
// 走到這裡,kth 就是這一組的第 k 個(最後一個)節點
// Now kth is the k-th (last) node of this group.
// ---- 第二步:記下反轉前後需要用到的邊界節點 ----
// ---- Step 2: remember the boundary nodes we'll need ----
struct ListNode* nextGroup = kth->next; // 下一組的開頭 / the head of the next group
struct ListNode* groupStart = groupPrev->next; // 這組原本的頭(反轉後會變成尾)/ this group's old head (becomes the tail)
// ---- 第三步:三指標原地反轉這 k 個節點 ----
// ---- Step 3: reverse the k nodes in place with three pointers ----
struct ListNode* prev = nextGroup; // 反轉時的「前一個」,先設成下一組開頭,反轉完組尾就自動接上下一組
// 'prev' starts as nextGroup so the tail auto-links to the next group
struct ListNode* cur = groupStart; // 從這組的第一個節點開始反轉 / current node, start at the group's head
while (cur != nextGroup) { // 反轉到碰到下一組為止(正好 k 個)/ stop when we reach the next group (exactly k nodes)
struct ListNode* nxt = cur->next;// 先存下一個,否則改了指標就找不到它 / save next first, or we lose it after re-linking
cur->next = prev; // 把箭頭反過來指向前一個 / flip this node's arrow to point backward
prev = cur; // prev 前進到目前節點 / advance prev to current
cur = nxt; // cur 前進到剛剛存下的下一個 / advance cur to the saved next
}
// 迴圈結束後,prev 指向這組反轉後的新頭(原本的 kth)
// After the loop, prev points to the reversed group's new head (the old kth).
// ---- 第四步:把反轉後的組頭接回前一組 ----
// ---- Step 4: connect the previous group to the new head ----
groupPrev->next = prev; // 上一組尾巴 -> 這組新頭 / previous tail -> this group's new head
// ---- 第五步:把 groupPrev 移到這組的尾巴,準備下一輪 ----
// ---- Step 5: move groupPrev to this group's tail for the next round ----
groupPrev = groupStart; // groupStart 反轉後正好是這組的尾巴 / groupStart is now the tail
}
// 迴圈只會透過上面的 return 離開 / the loop only exits via the return above
}
Solution — C++
/*
* 演算法 / Algorithm:
* 與 C 版完全相同:dummy 虛擬頭 + 每輪數 k 個確認整組 + 三指標原地反轉 + 縫回接點。
* Same as the C version: dummy head + verify k nodes each round + three-pointer
* in-place reversal + re-stitch the joints. O(n) time, O(1) extra space.
*/
// LeetCode 提供的節點定義 / node definition provided by LeetCode:
// struct ListNode { int val; ListNode *next; ListNode(int x) : val(x), next(nullptr) {} };
class Solution {
public:
ListNode* reverseKGroup(ListNode* head, int k) {
// dummy 虛擬頭:用花括號 {} 建立值為 0、next 為 nullptr 的節點
// dummy head: brace-init makes a node with val 0 and next nullptr
ListNode dummy; // 在堆疊上,函式回傳前都有效 / on the stack, valid until we return
dummy.next = head; // 接到真正的頭 / link to the real head
// groupPrev:目前這組前面的節點 / the node just before the current group
ListNode* groupPrev = &dummy; // 取 dummy 的位址 / address-of dummy
while (true) { // 內部用 return 跳出 / we exit via return inside
// 往後數 k 個,確認整組存在 / walk k nodes to confirm a full group exists
ListNode* kth = groupPrev;
for (int i = 0; i < k; ++i) {
kth = kth->next; // 沿 next 前進 / advance along next
if (kth == nullptr) // 不足 k 個:剩餘保持原樣 / fewer than k left: keep as-is
return dummy.next; // 回傳結果 / return the result
}
// 記下邊界 / record boundaries
ListNode* nextGroup = kth->next; // 下一組開頭 / start of next group
ListNode* groupStart = groupPrev->next; // 這組原本的頭(將變成尾)/ old head (becomes tail)
// 三指標原地反轉 k 個節點 / reverse k nodes in place with three pointers
ListNode* prev = nextGroup; // 讓組尾反轉後自動接上下一組 / tail auto-links to next group
ListNode* cur = groupStart; // 從組頭開始 / start at the group head
while (cur != nextGroup) { // 反轉直到碰到下一組(正好 k 個)/ until we reach next group (exactly k)
ListNode* nxt = cur->next;// 先保存下一個 / save next before overwriting
cur->next = prev; // 反轉箭頭 / flip the arrow backward
prev = cur; // prev 前進 / advance prev
cur = nxt; // cur 前進 / advance cur
}
// 此時 prev 是反轉後的新組頭 / prev is now the reversed group's new head
groupPrev->next = prev; // 前一組尾 -> 新組頭 / previous tail -> new head
groupPrev = groupStart; // groupStart 現在是這組的尾,成為下輪的 groupPrev / it's the tail now
}
}
};
複雜度 / Complexity
- Time: O(n) —
n是節點總數。每個節點在「數 k 個確認」時被走訪一次、在「反轉」時被走訪一次,都是常數次;不同組不會重複走訪同一節點,所以總和是線性的。/ Each node is visited a constant number of times (once while counting the group, once while reversing), and groups don't overlap, so the total work is linear inn. - Space: O(1) — 只用了固定數量的指標變數(
groupPrev / kth / nextGroup / groupStart / prev / cur / nxt)和一個 dummy 節點,與n無關,符合 follow-up 的要求。/ Only a fixed set of pointer variables plus one dummy node, independent ofn— this satisfies the follow-up.
Pitfalls & Edge Cases
- 忘記先確認整組是否完整 / Forgetting to verify a full group first:如果直接反轉而不先數 k 個,最後不足 k 個的尾巴會被錯誤反轉。程式碼透過 Step 1 的 for 迴圈先數,數不到就
return,確保尾巴保持原樣。 - 反轉前沒存下一個節點 / Overwriting
nextbefore saving it:一旦執行cur->next = prev,原本的cur->next就消失了。務必先nxt = cur->next再改,否則串列就斷了。 - 接尾接錯造成斷鏈或成環 / Mis-linking the tail causing a break or a cycle:把
prev初始化成nextGroup是關鍵技巧——反轉後組尾(groupStart)的next自動指向下一組,省去手動接尾、也避免忘記接而斷鏈。 - 第一組頭節點改變 / The overall head changes after the first group:反轉第一組後真正的頭會變。用 dummy 虛擬頭並最終回傳
dummy.next,就不必為此寫特例,也天然處理了head為單組的情況。 - k == 1 / When k is 1:每組只有一個節點,反轉等於不變。程式碼會正常運作(反轉迴圈只跑一次且不改變順序),結果與輸入相同。
- 空串列或 head 為 NULL / Empty list:雖然本題約束
n >= 1,但若head為NULL,Step 1 第一次kth->next就得到NULL並立即回傳dummy.next(即NULL),安全不崩潰。 - 不要修改節點的值 / Don't swap values:題目要求只能改節點連接。本解全程只改
next指標、從不碰val,完全符合要求。