詳細分析鏈表中的遞歸性質(Java 實現)

鏈表中的遞歸性質

前言

在前面的 鏈表的數據結構的實現 中,已經對鏈表數據結構的實現過程有了充分的了解了。但是對於鏈表而言,其實它還和遞歸相關聯。雖然一般來說遞歸在樹的數據結構中使用較多,因為在樹這個結構中使用遞歸是非常方便的。在鏈表這個數據結構中也是可以使用遞歸的,因為鏈表本身具有天然的遞歸性質,只不過鏈表是一種線性結構,通常使用非遞歸的方式也可以很容易地實現它,所以大多數情況下都是使用循環的方式來實現鏈表。不過如果在鏈表中使用遞歸,可以幫助打好遞歸的基礎以在後面可以更加深入地理解樹這種數據結構和一些遞歸算法,這是非常具有好處的。所以在這裡可以藉助 LeetCode 上的一道關於鏈表的問題,使用遞歸的方式去解決它,以此達到理解鏈表中的遞歸性質的目的。

LeetCode 上關於鏈表的一道問題

203 號題目 移除鏈表中的元素

題目描述:

刪除鏈表中等於給定值 val 的所有節點。

示例:

輸入: 1->2->6->3->4->5->6, val = 6
輸出: 1->2->3->4->5

來源:力扣(LeetCode)
鏈接://leetcode-cn.com/problems/remove-linked-list-elements
著作權歸領扣網絡所有。商業轉載請聯繫官方授權,非商業轉載請註明出處。

題目提供的鏈表結點類:

/**
 * Definition for singly-linked list.
 */
public class ListNode {
    int val;
    ListNode next;
    ListNode(int x) { 
        val = x; 
    }
}

題目提供的解題模板:

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
    public ListNode removeElements(ListNode head, int val) {
        
    }
}

-對於此題,可以先嘗試使用非遞歸的方式然後使用虛擬頭節點和不使用虛擬頭節點分別實現來回顧一下鏈表的刪除邏輯。

非遞歸方式及不使用虛擬頭節點題解思路:

  1. 如果不使用虛擬頭結點,那麼首先可以直接判斷 head 是否不為 null 以及它的值是否是要刪除的元素,如果是則刪除當前頭節點。此處需要注意的是,很可能會存在多個要刪除的元素都堆在鏈表頭部或者整個鏈表都是要刪除的元素,所以這裡可以使用 while 循環來判斷依次刪除鏈表的當前頭節點。

  2. 處理完頭部部分後,就處理中間部分需要刪除的元素,此時回顧一下鏈表的刪除邏輯,需要先找到待刪除節點的前置節點,所以以鏈表此時的頭節點 head 開始,將其作為第一個前置節點 prev(因為此時頭部已經處理完畢,沒有要刪除的元素了)。再通過 while 循環依次判斷 prev 的下一個節點是否需要刪除直到刪除完所有要刪除的元素為止。

  3. 最後返回頭節點 head 即可,此時通過 head 可以獲得刪除元素後的鏈表。

以上思路實現為代碼如下:

public class Solution {
    public ListNode removeElements(ListNode head, int val) {
        // 非遞歸不使用虛擬頭結點的解決方案
        // 把鏈表開始部分需要刪除的元素刪除
        while (head != null && head.val == val) {
            ListNode delNode = head;
            head = head.next;
            delNode.next = null;
        }

        // 如果此時 head == null,說明鏈表中所有元素都需要刪除,此時返回 head 或 null
        if (head == null) {
            return null;
        }

        // 處理鏈表中間需要刪除的元素
        ListNode prev = head;
        // 每次看 prev 的下一個元素是否需要被刪除
        while (prev.next != null) {
            if (prev.next.val == val) {
                ListNode delNode = prev.next;
                prev.next = delNode.next;
                delNode.next = null;
            } else {
                prev = prev.next;
            }
        }

        return head;
    }
}

提交結果:

提交結果-1

接下來就使用虛擬頭結點的方式來實現此題,思路如下:

  1. 創建一個虛擬頭節點,並指向鏈表的頭節點 head。

  2. 此時整個鏈表的所有元素都有一個前置節點,就可以統一使用通過前置節點的方式來刪除待刪除元素,此時以虛擬頭節點開始,將其作為第一個前置節點 prev。再通過 while 循環依次判斷 prev 的下一個節點是否需要刪除直到刪除完所有要刪除的元素為止。

  3. 最後返回虛擬頭節點的下一個節點即可,即返回 head。

以上思路實現為代碼如下:

public class Solution {
    public ListNode removeElements(ListNode head, int val) {
        // 非遞歸使用虛擬頭結點的解決方案
        // 創建虛擬頭節點
        ListNode dummyHead = new ListNode(-999);
        dummyHead.next = head;

        // 處理鏈表中需要刪除的元素
        ListNode prev = dummyHead;
        // 每次看 prev 的下一個元素是否需要被刪除
        while (prev.next != null) {
            if (prev.next.val == val) {
                ListNode delNode = prev.next;
                prev.next = delNode.next;
                delNode.next = null;
            } else {
                prev = prev.next;
            }
        }

        // 返回鏈表頭節點
        return dummyHead.next;
    }
}

提交結果:

提交結果-2

此時,兩種方案都正確的運行了。對於鏈表的刪除邏輯在使用虛擬頭節點和不使用虛擬頭節點的情況都實現了一遍,這也是在之前的鏈表的數據結構的實現中涉及到的部分,這裡再次回顧一遍加深印象,也方便後面使用遞歸方式實現該題目後對比兩種不同方式的異同。

遞歸的基本概念與示例

對於遞歸,本質上,就是將原來的問題,轉化為更小的同一問題,直到轉化為基本問題並解決基本問題後,再一步步的將結果返回達到求解原問題的目的。

舉個例子:數組求和。

數組求和遞歸示例

從圖中可以看出,其實遞歸也就是將原問題的規模一步步地縮小,一直縮小到基本問題出現然後解出基本問題的解再往上依次返回根據這個基本解依次求出各個規模的解直到求出原問題的解。

以上過程編碼實現如下:

/**
 * 數組求和遞歸示例
 *
 * @author 踏雪彡尋梅
 * @date 2020/2/8 - 10:30
 */
public class Sum {
    /**
     * 對 array 求和
     *
     * @param array 求和的數組
     * @return 返回求和結果
     */
    public static int sum(int[] array) {
        // 計算 array[0...n) 區間內所有數字的和
        return sum(array, 0);
    }

    /**
     * 計算 array[l...n) 這個區間內所有數字的和
     *
     * @param array 求和的數組
     * @param l 左邊界
     * @return 返回求和的結果
     */
    private static int sum(int[] array, int l) {
        // 基本問題: 數組為空時返回 0
        if (l == array.length) {
            return 0;
        }
        // 把原問題轉換為小問題解決
        return array[l] + sum(array, l + 1);
    }

    /**
     * 測試數組求和
     */
    public static void main(String[] args) {
        int[] nums = {1, 2, 3, 4, 5, 6, 7, 8};
        System.out.println(sum(nums));
    }
}

運行結果:

求和結果

對於以上例子,可以這樣理解:在使用遞歸時,可以注意遞歸函數的「宏觀」語意。在上面的例子中,「宏觀」語意就是計算 array[l…n) 區間內所有數字的和。這樣子理解遞歸函數再去觀看函數中的將原問題轉換成小問題時,會更好地理解這個函數要做的事情,簡單來說遞歸函數就是一個完成一個功能的函數,只不過是自己調用自己,每一次轉換成小問題時完成的功能都是數組的某個數加上剩餘數的和,直到無數可加為止。這個數組求和的遞歸過程如下圖所示:

數組求和遞歸過程-1

也可以使用下圖表示,下圖中的代碼是進行拆分後的代碼,為了更方便地展示過程:

數組求和遞歸過程-2

至此,已經大致了解了遞歸的基本概念和基本流程了,接下來就看看鏈表所具有的天然的遞歸性質。

鏈表天然的遞歸性

對於鏈表而言,本質上就是將一個個節點掛接起來組成的。也就是下圖的這個樣子:

鏈表的基本結構

而其實對於鏈表,也可以應用遞歸理解成是由一個頭節點後面掛接着一個更短的鏈表組成的。也就是下圖的這個樣子:

鏈表的遞歸結構

對於上圖中的一個更短的鏈表,其中也是由一個頭節點掛接着一個更短的鏈表形成的,依次類推,直到最後為 NULL 時,NULL 其實也就是一個鏈表了,此時就是遞歸方式的鏈表的基本問題。

所以此時再看回之前的 203 號題目:移除鏈表中的元素。就可以將題目提供的鏈表看成上圖所示的結構,然後使用遞歸解決更小的鏈表中要刪除的元素得到這個小問題的解,之後再看頭節點是否需要刪除,如果要刪除就返回小問題的解,此時也就是原問題的解了;不刪除的話就將頭節點和小問題的解組合起來返回回去得到原問題的解。這個過程用圖來表示為以下圖示:

遞歸方式題解思路

用代碼實現後如下所示:

public class Solution {
    public ListNode removeElements(ListNode head, int val) {
        // 使用遞歸解決鏈表中移除元素
        // 構建基本問題,鏈表為空時返回 null
        if (head == null) {
            return null;
        }

        // 構建小問題: 得到頭節點後掛接着的更小的鏈表的解
        ListNode result = removeElements(head.next, val);
        // 判斷頭節點是否需要刪除,和小問題的解組合得到原問題的解
        if (head.val == val) {
            // 頭節點需要刪除
            return result;
        } else {
            // 頭節點不需要刪除,和小問題的解組合得到原問題的解
            head.next = result;
            return head;
        }
    }
}

提交結果:

提交結果-3

從提交結果可以驗證實現的邏輯是沒有錯誤的。此時代碼還可以進行簡化如下:

public class Solution {
    public ListNode removeElements(ListNode head, int val) {
        // 使用遞歸解決鏈表中移除元素
        // 構建基本問題,鏈表為空時返回 null
        if (head == null) {
            return null;
        }

        // 構建小問題: 得到頭節點後掛接着的更小的鏈表的解,然後掛接在頭節點後面
        head.next = removeElements(head.next, val);
        // 判斷頭節點是否需要刪除,和小問題的解組合得到原問題的解
        return head.val == val ? head.next : head;
    }
}

提交結果:

提交結果-4

此時對比前面的非遞歸方式實現的題解,可以發現使用遞歸方式實現是非常優雅的,代碼十分簡潔易讀。接下來就分析一下該遞歸運行的機制。遞歸運行過程如下圖所示:

題解遞歸過程

至此,這個題目的遞歸流程就走完了,對於以上過程,就是子過程的一步步調用,調用完畢之後,子過程計算出結果,再一步步地返回結果給上層調用,最終得到了結果。節點的刪除發生在第 6 行語句上,這行語句也就是解決了更小規模的問題後得到解後組織當前調用構成了當前問題的解。

與此同時,需要注意的是遞歸調用是有代價的,代價則是函數的調用和使用系統棧空間這兩方面。在函數調用時是需要一些時間開銷的,其中包括需要記錄當前函數執行到哪個位置、函數中的局部變量是處於怎樣的等等,然後將這個狀態給壓入系統棧。然後在遞歸調用的過程中,是需要消耗系統棧的空間的,所以對於遞歸函數,如果不處理基本問題的話,遞歸函數將一直執行下去,直到將系統棧的空間使用完。同時如果使用遞歸處理數據量巨大的情況的時候,也有可能會使用完系統棧空間,比如上面的數組求和如果求和百萬級別、千萬級別的數據系統棧空間是不夠用的,在鏈表中刪除元素也是如此,如果鏈表過長系統棧空間也是不夠用的。所以在這一點需要有所注意。

總而言之,使用遞歸來書寫程序邏輯其實是比較簡單的,這個特點在非線性結構中,比如樹、圖這些數據結構,這個特點會體現地十分明顯。

小結

此時,對於遞歸和鏈表中的遞歸性質在使用了一個數組求和的例子和 LeetCode 上的一道題目的例子做了相應的過程分析之後已經有了充分的了解,也發現了使用遞歸來書寫邏輯是非常簡單易讀的,相比之前使用非遞歸方式實現的題解其中的代碼,遞歸方式的代碼只有短短几行。但是相對應的,遞歸也是有一定的局限性的,在使用的過程中需要注意系統棧空間的佔有,如果數據量太大很可能會撐爆系統棧空間,所以這一方面需要額外注意。


如有寫的不足的,請見諒,請大家多多指教。