Java單連結清單反轉圖文詳解
最近在回顧連結清單反轉問題中,突然有一些新的發現和收獲,特此整理一下,與大家分享 ð
背景回顧
單連結清單的存儲結構如圖:
資料域存放資料元素,指針域存放後繼結點位址
我們以一條 N1 -> N2 -> N3 -> N4 指向的單連結清單為例:
反轉後的連結清單指向如圖:
我們在代碼中定義如下結點類以友善運作測試:
/**
* 結點類
* (因為後續在main方法中運作,為了友善定義為static内部類)
*/
static class Node {
int val; // 資料域
Node next; // 指針域,指向下一個結點
Node(int x, Node nextNode) {
val = x;
next = nextNode;
}
}
通過循環周遊方式實作連結清單反轉
實作思路:從連結清單頭結點出發,依次循環周遊每一個結點,并更改結點對應的指針域,使其指向前一個結點
代碼如下:
/**
* 循環周遊方式實作連結清單反轉
*
* @param head 連結清單的頭結點
* @return
*/
public static Node cycleNode(Node head) {
Node prev = null; // 儲存前一個結點的資訊
// 循環周遊連結清單中的結點
while (head.next != null) {
// 1. 先儲存目前結點的下一個結點的資訊到tempNext
Node tempNext = head.next;
// 2. 修改目前結點指針域,使其指向上一個結點(如果是第一次進入循環的頭結點,則其上一個結點為null)
head.next = prev;
// 3. 将目前結點資訊儲存到prev中(以作為下一次循環中第二步使用到的"上一個結點")
prev = head;
// 4. 目前結點在之前的123步中指針域已經修改完畢,此時讓head重新指向待處理的下一個結點
head = tempNext;
}
// 上面的循環完成後,實際隻修改了原先連結清單中的頭結點到倒數第二個結點間的結點指向,倒數第一個結點(尾結點)并未處理
// 此時prev指向原先連結清單中的倒數第二個結點,head指向尾結點
// 處理尾結點的指針域,使其指向前一個結點
head.next = prev;
// 傳回尾結點,此時的尾結點既是原先連結清單中的尾結點,又是反轉後的新連結清單中的頭結點
return head;
}
測試效果:
public static void main(String[] args) {
// 構造測試用例,連結清單指向為 N1 -> N2 -> N3 -> N4
Node n4 = new Node(4, null);
Node n3 = new Node(3, n4);
Node n2 = new Node(2, n3);
Node n1 = new Node(1, n2);
Node head = n1;
// 輸出測試用例
System.out.println("原始連結清單指向為:");
printNode(head);
// 普通方式反轉連結清單
System.out.println("循環方式反轉連結清單指向為:");
head = cycleNode(head);
printNode(head);
}
/**
* 循環列印連結清單資料域
* @param head
*/
public static void printNode(Node head) {
while (head != null) {
System.out.println(head.val);
head = head.next;
}
}
運作結果如圖:
可以看到,原先指向為 N1 -> N2 -> N3 -> N4 的連結清單,運作反轉方法後,其指向已變為 N4 -> N3 -> N2 -> N1
通過遞歸方式實作連結清單反轉
實作思路:從連結清單頭結點出發,依次遞歸周遊每一個結點,并更改結點對應的指針域,使其指向前一個結點(沒錯,實際每一次遞歸裡的處理過程跟上面的循環裡是一樣的)
代碼實作:
/**
* 遞歸實作連結清單反轉
* 遞歸方法執行完成後,head指向就從原連結清單順序:頭結點->尾結點 中的第一個結點(頭結點) 變成了反轉後的連結清單順序:尾結點->頭結點 中的第一個結點(尾結點)
*
* @param head 頭結點
* @param prev 存儲上一個結點
*/
public static void recursionNode(Node head, Node prev) {
if (null == head.next) {
// 設定遞歸終止條件
// 當head.next為空時,表明已經遞歸到了原連結清單中的尾結點,此時單獨處理尾結點指針域,然後結束遞歸
head.next = prev;
return;
}
// 1. 先儲存目前結點的下一個結點的資訊到tempNext
Node tempNext = head.next;
// 2. 修改目前結點指針域,使其指向上一個結點(如果是第一次進入遞歸的頭結點,則其上一個結點為null)
head.next = prev;
// 3. 将目前結點資訊儲存到prev中(以作為下一次遞歸中第二步使用到的"上一個結點")
prev = head;
// 4. 目前結點在之前的123步中指針域修改已經修改完畢,此時讓head重新指向待處理的下一個結點
head = tempNext;
// 遞歸處理下一個結點
recursionNode(head, prev);
}
public static void main(String[] args) {
// 構造測試用例,連結清單指向為 N1 -> N2 -> N3 -> N4
Node n4 = new Node(4, null);
Node n3 = new Node(3, n4);
Node n2 = new Node(2, n3);
Node n1 = new Node(1, n2);
Node head = n1;
// 輸出測試用例
System.out.println("原始連結清單指向為:");
printNode(head);
// 遞歸方式反轉連結清單
System.out.println("遞歸方式反轉連結清單指向為:");
recursionNode(head, null);
printNode(head);
}
/**
* 循環列印連結清單資料域
* @param head
*/
public static void printNode(Node head) {
while (head != null) {
System.out.println(head.val);
head = head.next;
}
}
注意:在上面ð的測試代碼中,在調用遞歸函數時傳遞了Node類的執行個體
head
作為參數
根據Java中 方法調用傳參中,基本類型是值傳遞,對象類型是引用傳遞 可得 =>
因為在調用遞歸函數時傳遞了head對象的引用,且在遞歸函數運作過程中,我們已經數次改變了head引用指向的對象,
那麼當遞歸函數執行完畢時,head引用指向的對象此時理論上已經是原連結清單中的尾結點N4了,且連結清單順序也已經變成了 N4 -> N3 -> N2 -> N1
運作效果截圖:
最終的程式運作結果與我的設想大相徑庭!
那麼,問題出在哪裡呢?
遞歸方式反轉連結清單問題排查與延伸
問題定位
既然程式運作效果與預期效果不符,那我們就在head對象引用可能發生變化的地方加入注釋列印一下對象位址,看看能不能發現問題在哪:
加入注釋後的代碼如下:
public static void main(String[] args) {
// 構造測試用例,連結清單指向為 N1 -> N2 -> N3 -> N4
Node n4 = new Node(4, null);
Node n3 = new Node(3, n4);
Node n2 = new Node(2, n3);
Node n1 = new Node(1, n2);
Node head = n1;
// 輸出測試用例
System.out.println("原始連結清單指向為:");
printNode(head);
// 遞歸方式反轉連結清單
System.out.println("遞歸方式反轉連結清單指向為:");
System.out.println("遞歸調用前 head 引用指向對象: " + head.toString());
recursionNode(head, null);
System.out.println("遞歸調用後 head 引用指向對象: " + head.toString());
printNode(head);
}
/**
* 循環列印連結清單資料域
* @param head
*/
public static void printNode(Node head) {
while (head != null) {
System.out.println(head.val);
head = head.next;
}
}
/**
* 遞歸實作連結清單反轉
* 遞歸方法執行完成後,head指向就從原連結清單順序:頭結點->尾結點 中的第一個結點(頭結點) 變成了反轉後的連結清單順序:尾結點->頭結點 中的第一個結點(尾結點)
*
* @param head 頭結點
* @param prev 存儲上一個結點
*/
public static void recursionNode(Node head, Node prev) {
System.out.println("遞歸調用中 head引用指向對象: " + head.toString());
if (null == head.next) {
// 設定遞歸終止條件
// 當head.next為空時,表名已經遞歸到了原連結清單中的尾結點,此時單獨處理尾結點指針域,然後結束遞歸
head.next = prev;
System.out.println("遞歸調用傳回前 head引用指向對象: " + head.toString());
return;
}
// 1. 先儲存目前結點的下一個結點的資訊到tempNext
Node tempNext = head.next;
// 2. 修改目前結點指針域,使其指向上一個結點(如果是第一次進入循環的頭結點,則其上一個結點為null)
head.next = prev;
// 3. 将目前結點資訊儲存到prev中(以作為下一次遞歸中第二步使用到的"上一個結點")
prev = head;
// 4. 目前結點在之前的123步中指針域修改已經修改完畢,此時讓head重新指向待處理的下一個結點
head = tempNext;
// 遞歸處理下一個結點
recursionNode(head, prev);
}
運作結果:
從上面ð的運作結果看,在遞歸函數執行期間,head引用指向的對象确實發生了變化
注意 調用前 / 調用傳回前 / 調用後 這三個地方head引用指向對象的變化:
可以發現,雖然遞歸函數執行期間确實改變了head引用指向的對象,但實際上是變了個寂寞!ð¶
函數調用傳回後,head引用指向的對象還是調用前的那個!
在debug模式下,我們再繼續深入看看遞歸函數調用前跟調用後的head對象是不是完全一樣的:
從上面兩張圖可以發現,雖然遞歸調用前跟調用後head引用指向的對象都是同一個,但這個對象本身的屬性(指針域)已經發生了變化!
由此說明遞歸函數的執行并不是在做無用功,而是切切實實改變了原連結清單的各結點指向順序!
隻是因為遞歸函數執行完成後,并沒有成功讓傳入的head對象引用指向反轉後的新連結清單的頭結點N4,
此時head對象引用仍然跟調用前一樣指向了N1,而N1在反轉後的新連結清單中變成了尾結點,至此,我們已經完美的丢失了反轉後的新連結清單 ð,光靠指向尾結點的head根本無法周遊到新連結清單的其他結點。。。
問題延伸:探究Java方法調用中的參數傳遞實質
由上面的問題定位可知,問題出在我對Java方法調用中的參數傳遞了解有偏差,那麼接下來就來詳細探究一下Java方法調用中的參數傳遞過程吧!
形參與實參
測試示例代碼:
public static void recursionNode(Node headNode, Node prevNode) {
// do something...
}
public static void main(String[] args) {
// init head...
recursionNode(head, null); // 調用方法
}
在上面的示例代碼中,我們定義了recursionNode()方法,并在main()方法中調用它
方法定義中的
headNode
prevNode
是 形式參數,調用時傳入的
head
null
是 實際參數
值傳遞
方法定義中的形式參數類型如果是基本資料類型(byte、short、int、long、float、double、boolean、char),則調用方法時,實參到形參的傳遞實際是值傳遞,傳遞的是實際參數值的副本(拷貝)
是以,在方法體中任意修改形參的值,并不會影響到方法體外的實參的值
引用傳遞
方法定義中的形式參數類型如果是對象類型(含基本資料類型的數組),則調用方法時,實參到形參的傳遞實際也是值傳遞,傳遞的是實參對象的引用位址
如何了解這個
實參對象的引用位址
的概念呢?讓我們來看看示例代碼運作時的記憶體模型圖(簡單抽象了stack和heap的部分,如有不對歡迎指正 ð)
如圖,main方法和recursionNode方法執行時實際是作為不同的棧幀入棧到目前線程的虛拟機棧中
main方法中的 head 引用實際儲存的是一個位址,通過這個位址可以找到堆(heap)中的一個Node對象
recursionNode方法中的 headNode 引用實際儲存的也是一個位址,通過這個位址可以找到堆中的一個Node對象
那麼在main方法中調用recursionNode方法,實參 head 到形參 headNode 的傳遞過程中,到底傳遞的是什麼呢?
很明顯,傳遞的就是那個能尋址到堆中某個Node對象的 位址(劃重點,要考!)
由此,實參 head 對象引用和形參 headNode 對象引用具有了相同的位址值,指向堆中的同一個Node對象
通過這兩個引用中的任何一個,都可以改變堆中對應的那個對象的屬性和狀态
遞歸方法調用後發生了什麼
了解了對象引用傳遞的實質後,再回過頭來看上面遞歸方法調用後實際結果與預期結果不一緻的問題,一切就迎刃而解了
如圖,遞歸調用結束後,雖然遞歸方法recursionNode()方法體内的 headNode 引用确實已經變成了指向新的Node對象N4,但是main方法中,head 引用指向的仍然是遞歸方法調用前的Node對象N1(随着遞歸方法的執行,N1對象内部的指針域已經産生了變化)
正确的遞歸方式實作連結清單反轉
/**
* 遞歸實作連結清單反轉,遞歸方法執行完成後,head就從 頭結點->尾結點 中的起始點(頭結點)變成了 尾結點->頭結點 中的起始點(尾結點)
*
* @param head 頭結點
* @param prev 存儲上一個結點
*/
public static Node recursionNode2(Node head, Node prev) {
if (null == head.next) {
// 設定遞歸終止條件
head.next = prev;
return head;
}
Node tempNext = head.next;
head.next = prev;
prev = head;
head = tempNext;
Node result = recursionNode2(head, prev);
return result;
}
測試結果:
public static void main(String[] args) {
// 構造測試用例,連結清單指向為 N1 -> N2 -> N3 -> N4
Node n4 = new Node(4, null);
Node n3 = new Node(3, n4);
Node n2 = new Node(2, n3);
Node n1 = new Node(1, n2);
Node head = n1;
// 輸出測試用例
System.out.println("原始連結清單指向為:");
printNode(head);
// 新遞歸方式反轉連結清單
System.out.println("新遞歸方式反轉連結清單指向為:");
head = recursionNode2(head, null);
printNode(head);
}
/**
* 循環列印連結清單資料域
* @param head
*/
public static void printNode(Node head) {
while (head != null) {
System.out.println(head.val);
head = head.next;
}
}
運作結果截圖:
可以看到,經過改善的新遞歸方法實作了預期的效果!ð