1. 程式人生 > 實用技巧 >連結串列問題一些常用的套路與方法

連結串列問題一些常用的套路與方法

概述

連結串列問題應該是資料結構中比較基礎的一類問題,但同時也是在面試中常考的一類問題。但是圍繞連結串列問題的一些基本方法或者處理思想,也無外乎那幾類,因此本文嘗試對連結串列常用的一些方法或者套路進行總結。

常用方法

1.頭結點

增加頭結點或者說啞巴節點這種方式,應該是我們在處理連結串列問題最常用的處理方式。簡單來說引入頭結點有兩個優點:

  1. 由於開始結點的位置被存放在頭結點的指標域中,所以在連結串列的第一個位置上的操作和在表的其他位置上的操作一致,無需進行特殊處理。
  2. 無論連結串列是否為空,其頭指標是指向頭結點的非空指標(空表中頭結點的指標域為空),因此空表和非控表的處理也就統一了。

總而言之,通過增加頭結點,減少了在連結串列處理過程中對邊界情況的判斷,大大簡化了程式的編寫。

下邊我們看一個例子:

leetcode 82刪除排序連結串列中的重複元素 II

給定一個排序連結串列,刪除所有含有重複數字的節點,只保留原始連結串列中 沒有重複出現 的數字。

示例 1:

輸入: 1->2->3->3->4->4->5
輸出: 1->2->5
示例 2:

輸入: 1->1->1->2->3
輸出: 2->3

來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/remove-duplicates-from-sorted-list-ii
著作權歸領釦網路所有。商業轉載請聯絡官方授權,非商業轉載請註明出處。

這個問題,可能解決問題的思路比較容易想:

由於連結串列的節點是有序的,因此我們可以在對連結串列進行遍歷的過程中,可以比較當前遍歷的節點(current)和其下一個節點(current.next)是否相等,如果相等則刪除當前遍歷的節點(current),指標指向該節點的下一個節點,繼續進行該操作。

具體程式碼如下所示:

 public static ListNode deleteDuplicates(ListNode head) {
    ListNode current = head;
    current = current.next;
    while (current != null) {
      while (current.next != null && current.val == current.next.val) {
        current.next = current.next.next;
      }
      current = current.next;
    }
    return head;
  }

整個解法應該比較容易理解,但此時我們考慮如果此處不使用頭節點該如何解決該問題?

如果取消了頭結點,我們就需要考慮對連結串列第一個節點的處理,因為在該問題上,連結串列的第一個節點也是有很大可能為重複節點,因此我們此處顯然需要增加一個邊界情況的判斷,判斷頭結點是否為重複節點。(head.val == head.next.val)。並且需要針對其為頭結點的情況單獨進行處理。

因此,此處我們可以簡單總結一下頭結點方法的使用場景:

只要是要處理的連結串列第一個節點本身會發生變化的情況都要考慮使用頭結點,因為引入之後可能會極大的減少對邊界清理的處理。

2. 連結串列排序

連結串列排序,本身也可以是一個演算法的題目,同時也是我們在解決連結串列問題時常用的中間手段。

下邊我們看一個題目:

148. 排序連結串列:

在 O(n log n) 時間複雜度和常數級空間複雜度下,對連結串列進行排序。

示例 1:

輸入: 4->2->1->3
輸出: 1->2->3->4
示例 2:

輸入: -1->5->3->4->0
輸出: -1->0->3->4->5

來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/sort-list
著作權歸領釦網路所有。商業轉載請聯絡官方授權,非商業轉載請註明出處。

該問題明顯就是一個連結串列問題,但其難點可能在於對空間複雜度和時間複雜度的要求比較嚴苛。這就導致我們許多容易想到的方法都不能用,比如插入排序、儲存思想等。因此此處我們必須從複雜度為O(nlogn)的排序演算法中尋找到一個能用的,並且空間複雜度只有常數級別的演算法。

首先考慮,時間複雜度小於等於O(nlog(n))的演算法有:

  1. 折半插入排序
  2. 希爾排序
  3. 快速排序
  4. 堆排序
  5. 歸併排序
  6. 基數排序

同時我們考慮到連結串列本身效能比較差,因此如果排序過程涉及大量的隨機訪問,大概率該演算法不能用,比如快速排序、折半插入排序、希爾排序、堆排序(建堆的過程)。這些演算法都不適用於連結串列。

基數排序空間複雜度比較大一般是O(r),r是排序佇列的個數。因此也不實用該題目。

最後我們只能考慮使用歸併排序。

整個過程可以分成如下步驟:

  1. 拆分:找到連結串列中間節點,獲取左半連結串列和右半連結串列
  2. 排序:分別對左側鏈表和右側鏈表進行歸併排序
  3. 合併:將排序後的左側鏈表和右側鏈表進行合併

其過程可以簡單用下圖來表示:

程式碼實現如下:

// 使用歸併演算法進行連結串列排序
  public static ListNode mergesort(ListNode head) {
    // 如果連結串列只有一個節點直接返回
    if (head == null || head.next == null) {
      return head;
    }
    // 找到連結串列的中間節點
    ListNode middle = findMiddle(head);
    // 獲取後半段的連結串列節點,同時與前半段節點斷開
    ListNode tail = middle.next;
    middle.next = null;

    // 對左側進行排序
    ListNode left = mergesort(head);
    // 對右側進行排序
    ListNode right = mergesort(tail);
    // 合併兩條鏈,注意是將left和right進行合併
    ListNode result = merge(left, right);
    return result;
  }
  // 將兩個有序連結串列進行合併
  private static ListNode merge(ListNode left, ListNode right) {
    ListNode headNode = new ListNode(0);
    ListNode tail = headNode;
    headNode.next = left;

    while (left != null && right != null) {
      if (left.val < right.val) {
        tail.next = left;
        left = left.next;
      } else {
        tail.next = right;
        right = right.next;
      }
      tail = tail.next;
    }

    // 將非空的節點直接連結到temp後邊
    if (left != null) {
      tail.next = left;
    }
    if (right != null) {
      tail.next = right;
    }
    return headNode.next;
  }

  // 尋找連結串列的中間節點,可以使用快慢指標
  private static ListNode findMiddle(ListNode head) {
    ListNode slow = head;
    ListNode fast = head.next;
    while (fast != null && fast.next != null) {
      // 快指標一次走兩步
      fast = fast.next.next;
      // 慢指標一次走一步
      slow = slow.next;
    }
    return slow;
  }

3. 連結串列插入與刪除

連結串列的插入與刪除操作應該是解決連結串列問題最常用的基礎手段。但關於連結串列的插入和刪除還是有若干學問的,比如對連結串列進行插入,就可以簡單分成頭插法和尾插法。連結串列刪除也是有若干邊界情況要考慮。但關於這兩個操作是比較基礎的,此處不進行詳述,我們此處就看幾個例題來回顧一下。

83. 刪除排序連結串列中的重複元素

給定一個排序連結串列,刪除所有重複的元素,使得每個元素只出現一次。

示例 1:

輸入: 1->1->2
輸出: 1->2
示例 2:

輸入: 1->1->2->3->3
輸出: 1->2->3

來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/remove-duplicates-from-sorted-list
著作權歸領釦網路所有。商業轉載請聯絡官方授權,非商業轉載請註明出處。

這個問題比較容易,此處直接給出一種思路,通過雙指標來進行解決,一個指標指向當前遍歷節點,另一個指標指向當前遍歷節點的上一個節點,兩個節點值一直,則刪除當前遍歷節點,以此類推。

public ListNode deleteDuplicates(ListNode head) {
    ListNode current = head;
    while (current != null && current.next != null) {
        if (current.next.val == current.val) {
            current.next = current.next.next;
        } else {
            current = current.next;
        }
    }
    return head;
}

4. 翻轉連結串列

連結串列翻轉應該是我們解決某些特殊問題的時候,比較有效的突破口,尤其是那些對連結串列按照指定規則進行重排序的問題,在無計可施的時候,通過翻轉有時候可以有效的找到突破口。

143.重排連結串列

給定一個單鏈表 L:L0→L1→…→Ln-1→Ln ,
將其重新排列後變為: L0→Ln→L1→Ln-1→L2→Ln-2→…

你不能只是單純的改變節點內部的值,而是需要實際的進行節點交換。

示例 1:

給定連結串列 1->2->3->4, 重新排列為 1->4->2->3.
示例 2:

給定連結串列 1->2->3->4->5, 重新排列為 1->5->2->4->3.

來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/reorder-list
著作權歸領釦網路所有。商業轉載請聯絡官方授權,非商業轉載請註明出處。

通過連結串列翻轉,我們可以迅速想到一種思路

找到中點斷開,翻轉後面部分,然後合併前後兩個連結串列

具體程式碼實現如下:

/** 按照要求重新對連結串列進行排序: 給定連結串列 1->2->3->4, 重新排列為 1->4->2->3. */
  public static void reorderList(ListNode head) {
    // 邊界情況進行處理,如果只有一個節點或者節點為空,直接返回
    if (head == null || head.next == null) {
      return;
    }
    // 如果只有一個節點直接反掌結果
    if (head == null || head.next == null) {
      return;
    }
    ListNode headNode = new ListNode(0);
    headNode.next = head;
    // 獲取中間連結串列節點
    ListNode middle = getMiddle(head);
    ListNode tail = middle.next;
    middle.next = null;
    // 對後一半的連結串列進行翻轉
    tail = reverse(tail);
    ListNode temp = headNode;
    // 將兩段連結串列交替連線
    while (head != null && tail != null) {
      temp.next = head;
      head = head.next;
      temp = temp.next;

      temp.next = tail;
      tail = tail.next;
      temp = temp.next;
    }
    // 非空節點連線到連結串列末尾
    if (head != null) {
      temp.next = head;
    }
    if (tail != null) {
      temp.next = tail;
    }
  }
  // 對連結串列進行翻轉
  private static ListNode reverse(ListNode head) {
    ListNode pre = null;
    ListNode temp;
    while (head != null) {
      temp = head.next;
      // 此處應該通過head的next來實現翻轉
      head.next = pre;
      pre = head;
      head = temp;
    }
    return pre;
  }

  // 獲取連結串列的中間節點
  private static ListNode getMiddle(ListNode head) {
    ListNode slow = head;
    ListNode fast = head.next;
    while (fast != null && fast.next != null) {
      fast = fast.next.next;
      slow = slow.next;
    }
    return slow;
  }

當然解決該問題的方法不知一種,比如我們也可以用儲存的思路的來解決,將連結串列轉成ArrayList來進行解決。具體程式碼如下:

  /**
   *  使用儲存解決該問題
   *  基本思路:將連結串列轉換乘ArraryList型別然後從後向前來進行遍歷
   * @param head
   */
  public static void reorderListByStorage(ListNode head) {
    //邊界情況處理
    if(head == null || head.next == null){
      return;
    }
    //將連結串列轉換乘ArrayList進行操作
    ArrayList<ListNode> list = new ArrayList<ListNode>();
    while (head != null){
      list.add(head);
      head = head.next;
    }
    //通過雙指標連線乘新的連結串列
    int i=0,j=list.size()-1;
    while (i<j){
      list.get(i).next = list.get(j);
      i++;
      //邊界情況處理 i == j
      if(i == j){
        break;
      }
      list.get(j).next=list.get(i);
      j--;
    }
    //將最後一個節點的next置空
    list.get(i).next = null;
  }

5. 快慢指標

快慢指標或者說雙指標,毫無疑問是解決連結串列問題最常用的操作,比如在尋找連結串列中間節點的時候就很常用。

private static ListNode getMiddle(ListNode head) {
    ListNode slow = head;
    ListNode fast = head.next;
    while (fast != null && fast.next != null) {
        fast = fast.next.next;
        slow = slow.next;
    }
    return slow;
}

而且應用起來也比較靈活,比如可以用來判斷連結串列是否有環:

141. 環形連結串列

給定一個連結串列,判斷連結串列中是否有環。

如果連結串列中有某個節點,可以通過連續跟蹤 next 指標再次到達,則連結串列中存在環。 為了表示給定連結串列中的環,我們使用整數 pos 來表示連結串列尾連線到連結串列中的位置(索引從 0 開始)。 如果 pos 是 -1,則在該連結串列中沒有環。注意:pos 不作為引數進行傳遞,僅僅是為了標識連結串列的實際情況。

如果連結串列中存在環,則返回 true 。 否則,返回 false 。

來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/linked-list-cycle
著作權歸領釦網路所有。商業轉載請聯絡官方授權,非商業轉載請註明出處。

思路:快慢指標,快慢指標相同則有環,證明:如果有環每走一步快慢指標距離會減 1

程式碼如下:

/**
	 * 使用快慢指標來解決連結串列是否有環的判斷
	 * @param head
	 * @return
	 */
	public static boolean hasCycle(ListNode head){
		//邊界情況處理
		if (head ==null || head.next ==null){
			return false;
		}
		ListNode slow = head;
		ListNode fast = head.next;
		boolean hasCycle = false;
		while (fast != null && fast.next != null){
			//如果兩個指標重逢則證明一定有環
			if (fast == slow){
				hasCycle = true;
				break;
			}
			//slow指標每次走一步,fast指標每次走兩步
			slow = slow.next;
			fast = fast.next.next;
		}
		return hasCycle;
	}

總結

本文主要總結了解決連結串列問題時,常用的一些套路,比如增加頭結點、對連結串列進行排序、節點插入與刪除、連結串列翻轉以及快慢指標,希望能給讀者以幫助。