【超详细】一文学会链表解题(建议收藏!)
簡(jiǎn)介:?如果說(shuō)數(shù)據(jù)結(jié)構(gòu)是算法的基礎(chǔ),那么數(shù)組和鏈表就是數(shù)據(jù)結(jié)構(gòu)的基礎(chǔ)。 因?yàn)橄穸?#xff0c;棧,對(duì),圖等比較復(fù)雜的數(shù)組結(jié)基本上都可以由數(shù)組和鏈表來(lái)表示,所以掌握數(shù)組和鏈表的基本操作十分重要。本文將為大家講解鏈表的基本操作及其在面試中的常見解題思路。
一、前言
如果說(shuō)數(shù)據(jù)結(jié)構(gòu)是算法的基礎(chǔ),那么數(shù)組和鏈表就是數(shù)據(jù)結(jié)構(gòu)的基礎(chǔ)。 因?yàn)橄穸?#xff0c;棧,對(duì),圖等比較復(fù)雜的數(shù)組結(jié)基本上都可以由數(shù)組和鏈表來(lái)表示,所以掌握數(shù)組和鏈表的基本操作十分重要。
今天就來(lái)看看鏈表的基本操作及其在面試中的常見解題思路,本文將從以下幾個(gè)點(diǎn)來(lái)講解鏈表的核心知識(shí)
二、什么是鏈表
相信大家已經(jīng)開始迫不及待地想用鏈表解題了,不過(guò)在開始之前我們還是要先來(lái)溫習(xí)下鏈表的定義,以及它的優(yōu)勢(shì)與劣勢(shì),磨刀不誤砍柴功!
鏈表的定義
鏈表是物理存儲(chǔ)單元上非連續(xù)的、非順序的存儲(chǔ)結(jié)構(gòu),它是由一個(gè)個(gè)結(jié)點(diǎn),通過(guò)指針來(lái)聯(lián)系起來(lái)的,其中每個(gè)結(jié)點(diǎn)包括數(shù)據(jù)和指針。
鏈表的非連續(xù),非順序,對(duì)應(yīng)數(shù)組的連續(xù),順序,我們來(lái)看看整型數(shù)組 1,2,3,4 在內(nèi)存中是如何表示的。
可以看到數(shù)組的每個(gè)元素都是連續(xù)緊鄰分配的,這叫連續(xù)性,同時(shí)由于數(shù)組的元素占用的大小是一樣的,在 Java 中 int 型大小固定為 4 個(gè)字節(jié),所以如果數(shù)組的起始地址是 100, 由于這些元素在內(nèi)存中都是連續(xù)緊鄰分配的,大小也一樣,可以很容易地找出數(shù)組中任意一個(gè)元素的位置,比如數(shù)組中的第三個(gè)元素起始地址為 100 + 2 * 4 = 108,這就叫順序性。查找的時(shí)間復(fù)雜度是O(1),效率很高!
那鏈表在內(nèi)存中是怎么表示的呢?
可以看到每個(gè)結(jié)點(diǎn)都分配在非連續(xù)的位置,結(jié)點(diǎn)與結(jié)點(diǎn)之間通過(guò)指針連在了一起,所以如果我們要找比如值為 3 ?的結(jié)點(diǎn)時(shí),只能通過(guò)結(jié)點(diǎn) 1 從頭到尾遍歷尋找,如果元素少還好,如果元素太多(比如超過(guò)一萬(wàn)個(gè)),每個(gè)元素的查找都要從頭開始查找,時(shí)間復(fù)雜度是O(n),比起數(shù)組的 O(1),差距不小。
除了查找性能鏈表不如數(shù)組外,還有一個(gè)優(yōu)勢(shì)讓數(shù)組的性能高于鏈表,這里引入程序局部性原理,啥叫程序局部性原理。
我們知道 CPU 運(yùn)行速度是非常快的,如果 CPU 每次運(yùn)算都要到內(nèi)存里去取數(shù)據(jù)無(wú)疑是很耗時(shí)的,所以在 CPU 與內(nèi)存之間往往集成了挺多層級(jí)的緩存,這些緩存越接近CPU,速度越快,所以如果能提前把內(nèi)存中的數(shù)據(jù)加載到如下圖中的 L1, L2, L3 緩存中,那么下一次 CPU 取數(shù)的話直接從這些緩存里取即可,能讓CPU執(zhí)行速度加快,那什么情況下內(nèi)存中的數(shù)據(jù)會(huì)被提前加載到 L1,L2,L3 緩存中呢,答案是當(dāng)某個(gè)元素被用到的時(shí)候,那么這個(gè)元素地址附近的的元素會(huì)被提前加載到緩存中。
以上文整型數(shù)組 1,2,3,4為例,當(dāng)程序用到了數(shù)組中的第一個(gè)元素(即 1)時(shí),由于 CPU 認(rèn)為既然 1 被用到了,那么緊鄰它的元素 2,3,4 被用到的概率會(huì)很大,所以會(huì)提前把 2,3,4 加到 L1,L2,L3 緩存中去,這樣 CPU 再次執(zhí)行的時(shí)候如果用到 2,3,4,直接從 L1,L2,L3 緩存里取就行了,能提升不少性能
_畫外音:如果把CPU的一個(gè)時(shí)種看成一秒,則從 L1 讀取數(shù)據(jù)需要 3 秒,從 L2 讀取需要 11 秒,L3讀取需要 25秒,而從內(nèi)存讀取呢,需要 1 分 40 秒,所以程序局部性原理能對(duì) CPU 執(zhí)行性能有很大的提升_
而鏈表呢,由于鏈表的每個(gè)結(jié)點(diǎn)在內(nèi)存里都是隨機(jī)分布的,只是通過(guò)指針聯(lián)系在一起,所以這些結(jié)點(diǎn)的地址并不相鄰,自然無(wú)法利用 程序局部性原理 來(lái)提前加載到 L1,L2,L3 緩存中來(lái)提升程序性能。
_畫外音:程序局部性原理是計(jì)算機(jī)中非常重要的原理,這里不做展開,建議大家查閱相關(guān)資料詳細(xì)了解一下_
如上所述,相比數(shù)組,鏈表的非連續(xù),非順序確實(shí)讓它在性能上處于劣勢(shì),那什么情況下該使用鏈表呢?考慮以下情況
- 大內(nèi)存空間分配
由于數(shù)組空間的連續(xù)性,如果要為數(shù)組分配 500M 的空間,這 500M 的空間必須是連續(xù)的,未使用的,所以在內(nèi)存空間的分配上數(shù)組的要求會(huì)比較嚴(yán)格,如果內(nèi)存碎片太多,分配連續(xù)的大空間很可能導(dǎo)致失敗。而鏈表由于是非連續(xù)的,所以這種情況下選擇鏈表更合適。
- 元素頻繁刪除和插入
如果涉及到元素的頻繁刪除和插入,用鏈表就會(huì)高效很多,對(duì)于數(shù)組來(lái)說(shuō),如果要在元素間插入一個(gè)元素,需要把其余元素一個(gè)個(gè)往后移(如圖示),以為新元素騰空間(同理,如果是刪除則需要把被刪除元素之后的元素一個(gè)個(gè)往前移),效率上無(wú)疑是比較低的。
(在 1,2 間插入 5,需要把2,3,4 同時(shí)往后移一位)
而鏈表的插入刪除相對(duì)來(lái)說(shuō)就比較簡(jiǎn)單了,修改指針位置即可,其他元素?zé)o需做任何移動(dòng)操作(如圖示:以插入為例)
綜上所述:如果數(shù)據(jù)以查為主,很少涉及到增和刪,選擇數(shù)組,如果數(shù)據(jù)涉及到頻繁的插入和刪除,或元素所需分配空間過(guò)大,傾向于選擇鏈表。
說(shuō)了這么多理論,相信讀者對(duì)數(shù)組和鏈表的區(qū)別應(yīng)該有了更深刻地認(rèn)識(shí)了,尤其是 程序局部性原理,是不是開了不少眼界^_^,如果面試中問(wèn)到數(shù)組和鏈表的區(qū)別能回答到程序局部性原理,會(huì)是一個(gè)非常大的亮點(diǎn)!
接下來(lái)我們來(lái)看看鏈表的表現(xiàn)形式和解題技巧
需要說(shuō)明的是有些代碼像打印鏈表等限于篇幅的關(guān)系沒(méi)有在文中展示,我把文中所有相關(guān)代碼都放到 github 中了,大家如果需要,可以訪問(wèn)我的 github 地址: https://github.com/allentofight/algorithm 下載運(yùn)行(微信不支持外鏈,建議大家 copy 之后瀏覽器打開再下載運(yùn)行),文中所有代碼均已用 Java 實(shí)現(xiàn)并運(yùn)行通過(guò)
三、鏈表的表示
由于鏈表的特點(diǎn)(查詢或刪除元素都要從頭結(jié)點(diǎn)開始),所以我們只要在鏈表中定義頭結(jié)點(diǎn)即可,另外如果要頻繁用到鏈表的長(zhǎng)度,還可以額外定義一個(gè)變量來(lái)表示。
需要注意的是這個(gè)頭結(jié)點(diǎn)的定義是有講究的,一般來(lái)說(shuō)頭結(jié)點(diǎn)有兩種定義形式,一種是直接以某個(gè)元素結(jié)點(diǎn)為頭結(jié)點(diǎn),如下
一種是以一個(gè)虛擬的節(jié)點(diǎn)作為頭結(jié)點(diǎn),即我們常說(shuō)的哨兵,如下
定義這個(gè)哨兵有啥好處呢,假設(shè)我們不定義這個(gè)哨兵,來(lái)看看鏈表及添加元素的基本操作怎么定義的
發(fā)現(xiàn)問(wèn)題了嗎,注意看下面代碼
有兩個(gè)問(wèn)題:
如果定義了哨兵結(jié)點(diǎn),以上兩個(gè)問(wèn)題都可解決,來(lái)看下使用哨兵結(jié)點(diǎn)的鏈表定義
publicclass LinkedList {int length = 0; // 鏈表長(zhǎng)度,非必須,可不加Node head = new Node(0); // 哨兵結(jié)點(diǎn)public void addNode(int val) {Node tmp = head;while (tmp.next != null) {tmp = tmp.next;}tmp.next = new Node(val);length++} }可以看到,定義了哨兵結(jié)點(diǎn)的鏈表邏輯上清楚了很多,不用每次插入元素都對(duì)頭結(jié)點(diǎn)進(jìn)行判空,也統(tǒng)一了每一個(gè)結(jié)點(diǎn)的添加邏輯。
所以之后的習(xí)題講解中我們使用的鏈表都是使用定義了哨兵結(jié)點(diǎn)的形式。
做了這么多前期的準(zhǔn)備工作,終于要開始我們的正餐了:鏈表解題常用套路–翻轉(zhuǎn)!
四、鏈表常見解題套路–翻轉(zhuǎn)
熱身賽
既然我們要用鏈表解題,那我們首先就構(gòu)造一個(gè)鏈表吧 題目:給定數(shù)組 1,2,3,4 構(gòu)造成如下鏈表 head–>4—->3—->2—->1
看清楚了,是逆序構(gòu)造鏈表!順序構(gòu)造我們都知道怎么構(gòu)造,對(duì)每個(gè)元素持續(xù)調(diào)用上文代碼定義的 addNode 方法即可(即尾插法),與尾插法對(duì)應(yīng)的,是頭插法,即把每一個(gè)元素插到頭節(jié)點(diǎn)后面即可,這樣就能做到逆序構(gòu)造鏈表,如圖示(以插入1,2 為例)
頭插法比較簡(jiǎn)單,直接上代碼,直接按以上動(dòng)圖的步驟來(lái)完成邏輯,如下
小試牛刀
現(xiàn)在我們加大一下難度,來(lái)看下曾經(jīng)的 Google 面試題: 給定單向鏈表的頭指針和一個(gè)節(jié)點(diǎn)指針,定義一個(gè)函數(shù)在 O(1) 內(nèi)刪除這個(gè)節(jié)點(diǎn)。
如圖示:即給定值為 2 的結(jié)點(diǎn),如何把 2 給刪了。
我們知道,如果給定一個(gè)結(jié)點(diǎn)要?jiǎng)h除它的后繼結(jié)點(diǎn)是很簡(jiǎn)單的,只要把這個(gè)結(jié)點(diǎn)的指針指向后繼結(jié)點(diǎn)的后繼結(jié)點(diǎn)即可
如圖示:給定結(jié)點(diǎn) 2,刪除它的后繼結(jié)點(diǎn) 3, 把結(jié)點(diǎn) 2 的 next 指針指向 3 的后繼結(jié)點(diǎn) 4 即可。
但給定結(jié)點(diǎn) 2,該怎么刪除結(jié)點(diǎn) 2 本身呢,注意題目沒(méi)有規(guī)定說(shuō)不能改變結(jié)點(diǎn)中的值,所以有一種很巧妙的方法,貍貓換太子!我們先通過(guò)結(jié)點(diǎn) 2 找到結(jié)點(diǎn) 3,再把節(jié)點(diǎn) 3 的值賦給結(jié)點(diǎn) 2,此時(shí)結(jié)點(diǎn) 2 的值變成了 3,這時(shí)候問(wèn)題就轉(zhuǎn)化成了上圖這種比較簡(jiǎn)單的需求,即根據(jù)結(jié)點(diǎn) 2 把結(jié)點(diǎn) 3 移除即可,看圖
不過(guò)需要注意的是這種解題技巧只適用于被刪除的指定結(jié)點(diǎn)是中間結(jié)點(diǎn)的情況,如果指定結(jié)點(diǎn)是尾結(jié)點(diǎn),還是要老老實(shí)實(shí)地找到尾結(jié)點(diǎn)的前繼結(jié)點(diǎn),再把尾結(jié)點(diǎn)刪除,代碼如下
入門到進(jìn)階:鏈表翻轉(zhuǎn)
接下來(lái)我們會(huì)重點(diǎn)看一下鏈表的翻轉(zhuǎn),鏈表的翻轉(zhuǎn)可以衍生出很多的變形,是面試中非常熱門的考點(diǎn),基本上考鏈表必考翻轉(zhuǎn)!所以掌握鏈表的翻轉(zhuǎn)是必修課!
什么是鏈表的翻轉(zhuǎn):給定鏈表 head–>4—>3–>2–>1,將其翻轉(zhuǎn)成 head–>1–>2–>3–>4 ,由于翻轉(zhuǎn)鏈表是如此常見,如此重要,所以我們分別詳細(xì)講解下如何用遞歸和非遞歸這兩種方式來(lái)解題
- 遞歸翻轉(zhuǎn)
關(guān)于遞歸的文章之前寫了三篇,如果之前沒(méi)讀過(guò)的,強(qiáng)烈建議點(diǎn)擊這里查看,總結(jié)了遞歸的常見解題套路,給出了遞歸解題的常見四步曲,如果看完對(duì)以下遞歸的解題套路會(huì)更加深刻,這里不做贅述了,我們直接套遞歸的解題思路:
首先我們要查看翻轉(zhuǎn)鏈表是否符合遞歸規(guī)律:問(wèn)題可以分解成具有相同解決思路的子問(wèn)題,子子問(wèn)題…,直到最終的子問(wèn)題再也無(wú)法分解。
要翻轉(zhuǎn) head—>4—>3–>2–>1 鏈表,不考慮 head 結(jié)點(diǎn),分析 4—>3–>2–>1,仔細(xì)觀察我們發(fā)現(xiàn)只要先把 3–>2–>1 翻轉(zhuǎn)成 3<—-2<—-1,之后再把 3 指向 4 即可(如下圖示)
圖:翻轉(zhuǎn)鏈表主要三步驟
只要按以上步驟定義好這個(gè)翻轉(zhuǎn)函數(shù)的功能即可, 這樣由于子問(wèn)題與最初的問(wèn)題具有相同的解決思路,拆分后的子問(wèn)題持續(xù)調(diào)用這個(gè)翻轉(zhuǎn)函數(shù)即可達(dá)到目的。
注意看上面的步驟1,問(wèn)題的規(guī)模是不是縮小了(如下圖),從翻轉(zhuǎn)整個(gè)鏈表變成了只翻轉(zhuǎn)部分鏈表!問(wèn)題與子問(wèn)題都是從某個(gè)結(jié)點(diǎn)開始翻轉(zhuǎn),具有相同的解決思路,另外當(dāng)縮小到只翻轉(zhuǎn)一個(gè)結(jié)點(diǎn)時(shí),顯然是終止條件,符合遞歸的條件!之后的翻轉(zhuǎn) 3–>2–>1, 2–>1 持續(xù)調(diào)用這個(gè)定義好的遞歸函數(shù)即可!
既然符合遞歸的條件,那我們就可以套用遞歸四步曲來(lái)解題了(注意翻轉(zhuǎn)之后 head 的后繼節(jié)點(diǎn)變了,需要重新設(shè)置!別忘了這一步)
1、定義遞歸函數(shù),明確函數(shù)的功能 根據(jù)以上分析,這個(gè)遞歸函數(shù)的功能顯然是翻轉(zhuǎn)某個(gè)節(jié)點(diǎn)開始的鏈表,然后返回新的頭結(jié)點(diǎn)
2、尋找遞推公式 上文中已經(jīng)詳細(xì)畫出了翻轉(zhuǎn)鏈表的步驟,簡(jiǎn)單總結(jié)一下遞推步驟如下
- 針對(duì)結(jié)點(diǎn) node (值為 4), 先翻轉(zhuǎn) node 之后的結(jié)點(diǎn) ? invert(node->next) ,翻轉(zhuǎn)之后 4—>3—>2—>1 變成了 4—>3<—2<—1
- 再把 node 節(jié)點(diǎn)的下個(gè)節(jié)點(diǎn)(3)指向 node,node 的后繼節(jié)點(diǎn)設(shè)置為空(避免形成環(huán)),此時(shí)變成了 4<—3<—2<—1
- 返回新的頭結(jié)點(diǎn),因?yàn)榇藭r(shí)新的頭節(jié)點(diǎn)從原來(lái)的 4 變成了 1,需要重新設(shè)置一下 head
3、將遞推公式代入第一步定義好的函數(shù)中,如下 (invertLinkedList)
/*** 遞歸翻轉(zhuǎn)結(jié)點(diǎn) node 開始的鏈表*/public Node invertLinkedList(Node node) {if (node.next == null) {return node;}// 步驟 1: 先翻轉(zhuǎn) node 之后的鏈表Node newHead = invertLinkedList(node.next);// 步驟 2: 再把原 node 節(jié)點(diǎn)后繼結(jié)點(diǎn)的后繼結(jié)點(diǎn)指向 node (4),node 的后繼節(jié)點(diǎn)設(shè)置為空(防止形成環(huán))node.next.next = node;node.next = null;// 步驟 3: 返回翻轉(zhuǎn)后的頭結(jié)點(diǎn)return newHead; } public static void main(String[] args) {LinkedList linkedList = new LinkedList();int[] arr = {4,3,2,1};for (int i = 0; i < arr.length; i++) {linkedList.addNode(arr[i]);}Node newHead = linkedList.invertLinkedList(linkedList.head.next);// 翻轉(zhuǎn)后別忘了設(shè)置頭結(jié)點(diǎn)的后繼結(jié)點(diǎn)!linkedList.head.next = newHead;linkedList.printList(); // 打印 1,2,3,4 }畫外音:翻轉(zhuǎn)后由于 head 的后繼結(jié)點(diǎn)變了,別忘了重新設(shè)置哦!
4、計(jì)算時(shí)間/空間復(fù)雜度 由于遞歸調(diào)用了 n 次 invertLinkedList 函數(shù),所以時(shí)間復(fù)雜度顯然是 O(n), 空間復(fù)雜度呢,沒(méi)有用到額外的空間,但是由于遞歸調(diào)用了 ?n 次 invertLinkedList 函數(shù),壓了 n 次棧,所以空間復(fù)雜度也是 O(n)。
遞歸一定要從函數(shù)的功能去理解,從函數(shù)的功能看,定義的遞歸函數(shù)清晰易懂,定義好了之后,由于問(wèn)題與被拆分的子問(wèn)題具有相同的解決思路,所以子問(wèn)題只要持續(xù)調(diào)用定義好的功能函數(shù)即可,切勿層層展開子問(wèn)題,此乃遞歸常見的陷阱!仔細(xì)看函數(shù)的功能,其實(shí)就是按照下圖實(shí)現(xiàn)的。(對(duì)照著代碼看,是不是清晰易懂^_^)
- 非遞歸翻轉(zhuǎn)鏈表(迭代解法)
我們知道遞歸比較容易造成棧溢出,所以如果有其他時(shí)間/空間復(fù)雜度相近或更好的算法,應(yīng)該優(yōu)先選擇非遞歸的解法,那我們看看如何用迭代來(lái)翻轉(zhuǎn)鏈表,主要思路如下
步驟 1: 定義兩個(gè)節(jié)點(diǎn):pre, cur ,其中 cur 是 pre 的后繼結(jié)點(diǎn),如果是首次定義, 需要把 pre 指向 cur 的指針去掉,否則由于之后鏈表翻轉(zhuǎn),cur 會(huì)指向 pre, 就進(jìn)行了一個(gè)環(huán)(如下),這一點(diǎn)需要注意
步驟2:知道了 cur 和 pre,翻轉(zhuǎn)就容易了,把 cur 指向 pre 即可,之后把 cur 設(shè)置為 pre ,cur 的后繼結(jié)點(diǎn)設(shè)置為 cur 一直往前重復(fù)此步驟即可,完整動(dòng)圖如下
注意:同遞歸翻轉(zhuǎn)一樣,迭代翻轉(zhuǎn)完了之后 head 的后繼結(jié)點(diǎn)從 4 變成了 1,記得重新設(shè)置一下。
知道了解題思路,實(shí)現(xiàn)代碼就容易多了,直接上代碼
用迭代的思路來(lái)做由于循環(huán)了 n 次,顯然時(shí)間復(fù)雜度為 O(n),另外由于沒(méi)有額外的空間使用,也未像遞歸那樣調(diào)用遞歸函數(shù)不斷壓棧,所以空間復(fù)雜度是 O(1),對(duì)比遞歸,顯然應(yīng)該使用迭代的方式來(lái)處理!
花了這么大的精力我們總算把翻轉(zhuǎn)鏈表給搞懂了,如果大家看了之后幾道翻轉(zhuǎn)鏈表的變形,會(huì)發(fā)現(xiàn)我們花了這么大篇幅講解翻轉(zhuǎn)鏈表是值得的。
接下來(lái)我們來(lái)看看鏈表翻轉(zhuǎn)的變形
變形題 1: 給定一個(gè)鏈表的頭結(jié)點(diǎn) head,以及兩個(gè)整數(shù) from 和 to ,在鏈表上把第 from 個(gè)節(jié)點(diǎn)和第 to 個(gè)節(jié)點(diǎn)這一部分進(jìn)行翻轉(zhuǎn)。 例如:給定如下鏈表,from = 2, to = 4 head–>5–>4–>3–>2–>1 將其翻轉(zhuǎn)后,鏈表變成 head–>5—>2–>3–>4–>1
有了之前翻轉(zhuǎn)整個(gè)鏈表的解題思路,現(xiàn)在要翻轉(zhuǎn)部分鏈表就相對(duì)簡(jiǎn)單多了,主要步驟如下:
知道了以上的思路,代碼就簡(jiǎn)單了,按上面的步驟1,2,3 實(shí)現(xiàn),注釋也寫得很詳細(xì),看以下代碼(對(duì) from 到 to 結(jié)點(diǎn)的翻轉(zhuǎn)我們使用迭代翻轉(zhuǎn),當(dāng)然使用遞歸也是可以的,限于篇幅關(guān)系不展開,大家可以嘗試一下)。
變形題 2: 給出一個(gè)鏈表,每 k 個(gè)節(jié)點(diǎn)一組進(jìn)行翻轉(zhuǎn),并返回翻轉(zhuǎn)后的鏈表。k 是一個(gè)正整數(shù),它的值小于或等于鏈表的長(zhǎng)度。如果節(jié)點(diǎn)總數(shù)不是 k 的整數(shù)倍,那么將最后剩余節(jié)點(diǎn)保持原有順序。
示例 : 給定這個(gè)鏈表:head–>1->2->3->4->5 當(dāng) k = 2 時(shí),應(yīng)當(dāng)返回: head–>2->1->4->3->5 當(dāng) k = 3 時(shí),應(yīng)當(dāng)返回: head–>3->2->1->4->5 說(shuō)明 :
- 你的算法只能使用常數(shù)的額外空間。
- 你不能只是單純的改變節(jié)點(diǎn)內(nèi)部的值,而是需要實(shí)際的進(jìn)行節(jié)點(diǎn)交換。
這道題是 LeetCode 的原題,屬于 hard 級(jí)別,如果這一題你懂了,那對(duì)鏈表的翻轉(zhuǎn)應(yīng)該基本沒(méi)問(wèn)題了,有了之前的翻轉(zhuǎn)鏈表基礎(chǔ),相信這題不難。
只要我們能找到翻一組 k 個(gè)結(jié)點(diǎn)的方法,問(wèn)題就解決了(之后只要重復(fù)對(duì) k 個(gè)結(jié)點(diǎn)一組的鏈表進(jìn)行翻轉(zhuǎn)即可)。
接下來(lái),我們以以下鏈表為例
來(lái)看看怎么翻轉(zhuǎn) 3 個(gè)一組的鏈表(此例中 k = 3)
- 首先,我們要記錄 3 個(gè)一組這一段鏈表的前繼結(jié)點(diǎn),定義為 startKPre,然后再定義一個(gè) step, 從這一段的頭結(jié)點(diǎn) (1)開始遍歷 2 次,找出這段鏈表的起始和終止結(jié)點(diǎn),如下圖示
- 找到 startK 和 endK 之后,根據(jù)之前的迭代翻轉(zhuǎn)法對(duì) startK 和 endK 的這段鏈表進(jìn)行翻轉(zhuǎn)
- 然后將 startKPre 指向 endK,將 startK 指向 endKNext,即完成了對(duì) k 個(gè)一組結(jié)點(diǎn)的翻轉(zhuǎn)。
知道了一組 k 個(gè)怎么翻轉(zhuǎn),之后只要重復(fù)對(duì) k 個(gè)結(jié)點(diǎn)一組的鏈表進(jìn)行翻轉(zhuǎn)即可,對(duì)照?qǐng)D示看如下代碼應(yīng)該還是比較容易理解的
/*** 每 k 個(gè)一組翻轉(zhuǎn)鏈表* @param k*/public void iterationInvertLinkedListEveryK(int k) {Node tmp = head.next;int step = 0; // 計(jì)數(shù),用來(lái)找出首結(jié)點(diǎn)和尾結(jié)點(diǎn)Node startK = null; // k個(gè)一組鏈表中的頭結(jié)點(diǎn)Node startKPre = head; // k個(gè)一組鏈表頭結(jié)點(diǎn)的前置結(jié)點(diǎn)Node endK; // k個(gè)一組鏈表中的尾結(jié)點(diǎn)while (tmp != null) {// tmp 的下一個(gè)節(jié)點(diǎn),因?yàn)橛捎诜D(zhuǎn),tmp 的后繼結(jié)點(diǎn)會(huì)變,要提前保存Node tmpNext = tmp.next;if (step == 0) {// k 個(gè)一組鏈表區(qū)間的頭結(jié)點(diǎn)startK = tmp;step++;} elseif (step == k-1) {// 此時(shí)找到了 k 個(gè)一組鏈表區(qū)間的尾結(jié)點(diǎn)(endK),對(duì)這段鏈表用迭代進(jìn)行翻轉(zhuǎn)endK = tmp;Node pre = startK;Node cur = startK.next;if (cur == null) {break;}Node endKNext = endK.next;while (cur != endKNext) {Node next = cur.next;cur.next = pre;pre = cur;cur = next;}// 翻轉(zhuǎn)后此時(shí) endK 和 startK 分別是是 k 個(gè)一組鏈表中的首尾結(jié)點(diǎn)startKPre.next = endK;startK.next = endKNext;// 當(dāng)前的 k 個(gè)一組翻轉(zhuǎn)完了,開始下一個(gè) k 個(gè)一組的翻轉(zhuǎn)startKPre = startK;step = 0;} else {step++;}tmp = tmpNext;} }時(shí)間復(fù)雜度是多少呢,對(duì)鏈表從頭到尾循環(huán)了 n 次,同時(shí)每 k 個(gè)結(jié)點(diǎn)翻轉(zhuǎn)一次,可以認(rèn)為總共翻轉(zhuǎn)了 n 次,所以時(shí)間復(fù)雜度是O(2n),去掉常數(shù)項(xiàng),即為 O(n)。 注:這題時(shí)間復(fù)雜度比較誤認(rèn)為是O(k * n),實(shí)際上并不是每一次鏈表的循環(huán)都會(huì)翻轉(zhuǎn)鏈表,只是在循環(huán)鏈表元素每 k 個(gè)結(jié)點(diǎn)的時(shí)候才會(huì)翻轉(zhuǎn)
變形3: 變形 2 針對(duì)的是順序的 k 個(gè)一組翻轉(zhuǎn),那如何逆序 k 個(gè)一組進(jìn)行翻轉(zhuǎn)呢
例如:給定如下鏈表, head–>1–>2–>3–>4–>5 逆序 k 個(gè)一組翻轉(zhuǎn)后,鏈表變成(k = 2 時(shí)) head–>1—>3–>2–>5–>4
這道題是字節(jié)跳動(dòng)的面試題,確實(shí)夠變態(tài)的,順序 k 個(gè)一組翻轉(zhuǎn)都已經(jīng)屬于 hard 級(jí)別了,逆序 k 個(gè)一組翻轉(zhuǎn)更是屬于 super hard 級(jí)別了,不過(guò)其實(shí)有了之前知識(shí)的鋪墊,應(yīng)該不難,只是稍微變形了一下,只要對(duì)鏈表做如下變形即可
代碼的每一步其實(shí)都是用了我們之前實(shí)現(xiàn)好的函數(shù),所以我們之前做的每一步都是有伏筆的哦!就是為了解決字節(jié)跳動(dòng)這道終極面試題!
由此可見,掌握基本的鏈表翻轉(zhuǎn)非常重要!難題多是在此基礎(chǔ)了做了相應(yīng)的變形而已
五、鏈表解題利器—快慢指針
快慢指針在面試中出現(xiàn)的概率也很大,也是務(wù)必要掌握的一個(gè)要點(diǎn),本文總結(jié)了市面上常見的快慢指針解題技巧,相信看完后此類問(wèn)題能手到擒來(lái)。本文將詳細(xì)講述如何用快慢指針解決以下兩大類問(wèn)題
尋找/刪除第 K 個(gè)結(jié)點(diǎn)
小試牛刀之一
LeetCode 876:給定一個(gè)帶有頭結(jié)點(diǎn) head 的非空單鏈表,返回鏈表的中間結(jié)點(diǎn)。如果有兩個(gè)中間結(jié)點(diǎn),則返回第二個(gè)中間結(jié)點(diǎn)。
解法一
要知道鏈表的中間結(jié)點(diǎn),首先我們需要知道鏈表的長(zhǎng)度,說(shuō)到鏈表長(zhǎng)度大家想到了啥,還記得我們?cè)谏衔闹姓f(shuō)過(guò)哨兵結(jié)點(diǎn)可以保存鏈表的長(zhǎng)度嗎,這樣直接 從 head 的后繼結(jié)點(diǎn) 開始遍歷 ?鏈表長(zhǎng)度 / 2 次即可找到中間結(jié)點(diǎn)。為啥中間結(jié)點(diǎn)是 鏈表長(zhǎng)度/2,我們仔細(xì)分析一下
_畫外音:多畫畫圖,舉舉例,能看清事情的本質(zhì)!_
哨后結(jié)點(diǎn)的長(zhǎng)度派上用場(chǎng)了,這種方式最簡(jiǎn)單,直接上代碼
解法二
如果哨兵結(jié)點(diǎn)里沒(méi)有定義長(zhǎng)度呢,那就要遍歷一遍鏈表拿到鏈表長(zhǎng)度(定義為 length)了,然后再?gòu)念^結(jié)點(diǎn)開始遍歷 length / 2 次即為中間結(jié)點(diǎn)
解法三
解法二由于要遍歷兩次鏈表,顯得不是那么高效,那能否只遍歷一次鏈表就能拿到中間結(jié)點(diǎn)呢。
這里就引入我們的快慢指針了,主要有三步 1、 快慢指針同時(shí)指向 head 的后繼結(jié)點(diǎn) 2、 慢指針走一步,快指針走兩步 3、 不斷地重復(fù)步驟2,什么時(shí)候停下來(lái)呢,這取決于鏈表的長(zhǎng)度是奇數(shù)還是偶數(shù)
- 如果鏈表長(zhǎng)度為奇數(shù),當(dāng) fast.next = null 時(shí),slow 為中間結(jié)點(diǎn)
- 如果鏈表長(zhǎng)度為偶數(shù),當(dāng) fast = null 時(shí),slow 為中間結(jié)點(diǎn)
由以上分析可知:當(dāng) fast = null 或者 fast.next = null 時(shí),此時(shí)的 slow 結(jié)點(diǎn)即為我們要求的中間結(jié)點(diǎn),否則不斷地重復(fù)步驟 2, 知道了思路,代碼實(shí)現(xiàn)就簡(jiǎn)單了
有了上面的基礎(chǔ),我們現(xiàn)在再大一下難度,看下下面這道題
輸入一個(gè)鏈表,輸出該鏈表中的倒數(shù)第 k 個(gè)結(jié)點(diǎn)。比如鏈表為 head–>1–>2–>3–>4–>5。求倒數(shù)第三個(gè)結(jié)點(diǎn)(即值為 3 的節(jié)點(diǎn))
分析:我們知道如果要求順序的第 k 個(gè)結(jié)點(diǎn)還是比較簡(jiǎn)單的,從 head 開始遍歷 k 次即可,如果要求逆序的第 k 個(gè)結(jié)點(diǎn),常規(guī)的做法是先順序遍歷一遍鏈表,拿到鏈表長(zhǎng)度,然后再遍歷 鏈表長(zhǎng)度-k 次即可,這樣要遍歷兩次鏈表,不是那么高效,如何只遍歷一次呢,還是用我們的說(shuō)的快慢指針解法
注:需要注意臨界情況:k 大于鏈表的長(zhǎng)度,這種異常情況應(yīng)該拋異常
知道了如何求倒序第 k 個(gè)結(jié)點(diǎn),再來(lái)看看下面這道題
給定一個(gè)單鏈表,設(shè)計(jì)一個(gè)算法實(shí)現(xiàn)鏈表向右旋轉(zhuǎn) K 個(gè)位置。舉例: 給定 head->1->2->3->4->5->NULL, K=3,右旋后即為 head->3->4->5–>1->2->NULL
分析:這道題其實(shí)是對(duì)求倒序第 K 個(gè)位置的的一個(gè)變形,主要思路如下
- 先找到倒數(shù)第 K+1 個(gè)結(jié)點(diǎn), 此結(jié)點(diǎn)的后繼結(jié)點(diǎn)即為倒數(shù)第 K 個(gè)結(jié)點(diǎn)
- 將倒數(shù)第 K+1 結(jié)點(diǎn)的的后繼結(jié)點(diǎn)設(shè)置為 null
- 將 head 的后繼結(jié)點(diǎn)設(shè)置為以上所得的倒數(shù)第 K 個(gè)結(jié)點(diǎn),將原尾結(jié)點(diǎn)的后繼結(jié)點(diǎn)設(shè)置為原 head 的后繼結(jié)點(diǎn)
有了上面兩道題的鋪墊,相信下面這道題不是什么難事,限于篇幅關(guān)系,這里不展開,大家可以自己試試
輸入一個(gè)鏈表,刪除該鏈表中的倒數(shù)第 k 個(gè)結(jié)點(diǎn)
小試牛刀之二
判斷兩個(gè)單鏈表是否相交及找到第一個(gè)交點(diǎn),要求空間復(fù)雜度 O(1)。 如圖示:如果兩個(gè)鏈表相交,5為這兩個(gè)鏈表相交的第一個(gè)交點(diǎn)
_畫外音:如果沒(méi)有空間復(fù)雜度O(1)的限制,其實(shí)有多種解法,一種是遍歷鏈表 1,將鏈表 1 的所有的結(jié)點(diǎn)都放到一個(gè) set 中,再次遍歷鏈表 2,每遍歷一個(gè)結(jié)點(diǎn),就判斷這個(gè)結(jié)點(diǎn)是否在 set,如果發(fā)現(xiàn)結(jié)點(diǎn)在這個(gè) set 中,則這個(gè)結(jié)點(diǎn)就是鏈表第一個(gè)相交的結(jié)點(diǎn)_
分析:首先我們要明白,由于鏈表本身的性質(zhì),如果有一個(gè)結(jié)點(diǎn)相交,那么相交結(jié)點(diǎn)之后的所有結(jié)點(diǎn)都是這兩個(gè)鏈表共用的,也就是說(shuō)兩個(gè)鏈表的長(zhǎng)度主要相差在相交結(jié)點(diǎn)之前的結(jié)點(diǎn)長(zhǎng)度,于是我們有以下思路
1、如果鏈表沒(méi)有定義長(zhǎng)度,則我們先遍歷這兩個(gè)鏈表拿到兩個(gè)鏈表長(zhǎng)度,假設(shè)分別為 L1, L2 (L1 >= L2), 定義 p1, p2 指針?lè)謩e指向各自鏈表 head 結(jié)點(diǎn),然后 p1 先往前走 L1 – L2 步。這一步保證了 p1,p2 指向的指針與相交結(jié)點(diǎn)(如果有的話)一樣近。
2、 然后 p1,p2 不斷往后遍歷,每次走一步,邊遍歷邊判斷相應(yīng)結(jié)點(diǎn)是否相等,如果相等即為這兩個(gè)鏈表的相交結(jié)點(diǎn)
進(jìn)階
接下來(lái)我們來(lái)看如何用快慢指針來(lái)判斷鏈表是否有環(huán),這是快慢指針最常見的用法
判斷鏈表是否有環(huán),如果有,找到環(huán)的入口位置(下圖中的 2),要求空間復(fù)雜度為O(1)
首先我們要看如果鏈表有環(huán)有什么規(guī)律,如果從 head 結(jié)點(diǎn)開始遍歷,則這個(gè)遍歷指針一定會(huì)在以上的環(huán)中繞圈子,所以我們可以分別定義快慢指針,慢指針走一步,快指針走兩步, 由于最后快慢指針在遍歷過(guò)程中一直會(huì)在圈中里繞,且快慢指針每次的遍歷步長(zhǎng)不一樣,所以它們?cè)诶锩娌粩嗬@圈子的過(guò)程一定會(huì)相遇,就像 5000 米長(zhǎng)跑,一人跑的快,一人快的慢,跑得快的人一定會(huì)追上跑得慢的(即套圈)。
還不明白?那我們簡(jiǎn)單證明一下
1、 假如快指針離慢指針相差一個(gè)結(jié)點(diǎn),則再一次遍歷,慢指針走一步,快指針走兩步,相遇
2、 ?假如快指針離慢指針相差兩個(gè)結(jié)點(diǎn),則再一次遍歷,慢指針走一步,快指針走兩步,相差一個(gè)結(jié)點(diǎn),轉(zhuǎn)成上述 1 的情況
3、 假如快指針離慢指針相差 N 個(gè)結(jié)點(diǎn)(N大于2),則下一次遍歷由于慢指針走一步,快指針走兩步,所以相差 N+1-2 = N-1 個(gè)結(jié)點(diǎn),發(fā)現(xiàn)了嗎,相差的結(jié)點(diǎn)從 N 變成了 N-1,縮小了!不斷地遍歷,相差的結(jié)點(diǎn)會(huì)不斷地縮小,當(dāng) N 縮小為 2 時(shí),即轉(zhuǎn)為上述步驟 2 的情況,由此得證,如果有環(huán),快慢指針一定會(huì)相遇!
_畫外音:如果慢指針走一步,快指針走的不是兩步,而是大于兩步,會(huì)有什么問(wèn)題,大家可以考慮一下_
判斷有環(huán)為啥要返回相遇的結(jié)點(diǎn),而不是返回 true 或 false 呢。 因?yàn)轭}目中還有一個(gè)要求,判斷環(huán)的入口位置,就是為了這個(gè)做鋪墊的,一起來(lái)看看怎么找環(huán)的入口,需要一些分析的技巧
假設(shè)上圖中的 7 為快慢指針相遇的結(jié)點(diǎn),不難看出慢指針走了 L + S 步,快指針走得比慢指針更快,它除了走了 L + S 步外,還額外在環(huán)里繞了 n ?圈,所以快指針走了 L+S+nR 步(R為圖中環(huán)的長(zhǎng)度),另外我們知道每遍歷一次,慢指針走了一步,快指針走了兩步,所以快指針走的路程是慢指針的兩倍,即 2 (L+S) = L+S+nR,即 ?L+S = nR
- 當(dāng) n = 1 時(shí),則 L+S = R 時(shí),則從相遇點(diǎn) 7 開始遍歷走到環(huán)入口點(diǎn) 2 的距離為 R – S = L,剛好是環(huán)的入口結(jié)點(diǎn),而 head 與環(huán)入口點(diǎn) 2 的距離恰好也為 L,所以只要在頭結(jié)點(diǎn)定義一個(gè)指針,在相遇點(diǎn)(7)定義另外一個(gè)指針,兩個(gè)指針同時(shí)遍歷,每次走一步,必然在環(huán)的入口位置 2 相遇
- 當(dāng) n > 1 時(shí),L + S = nR,即 L = nR – S, ?nR-S 怎么理解?可以看作是指針從結(jié)點(diǎn) ?7 出發(fā),走了 n 圈后,回退 S 步,此時(shí)剛好指向環(huán)入口位置,也就是說(shuō)如果設(shè)置一個(gè)指針指向 head(定義為p1), 另設(shè)一個(gè)指針指向 7(定義為p2),不斷遍歷,p2 走了 nR-S 時(shí)(即環(huán)的入口位置),p1也剛好走到這里(此時(shí) p1 走了 nR-S = ?L步,剛好是環(huán)入口位置),即兩者相遇!
綜上所述,要找到入口結(jié)點(diǎn),只需定義兩個(gè)指針,一個(gè)指針指向head, 一個(gè)指針指向快慢指向的相遇點(diǎn),然后這兩個(gè)指針不斷遍歷(同時(shí)走一步),當(dāng)它們指向同一個(gè)結(jié)點(diǎn)時(shí)即是環(huán)的入口結(jié)點(diǎn)
public Node getRingEntryNode() {// 獲取快慢指針相遇結(jié)點(diǎn)Node crossNode = detectCrossNode();// 如果沒(méi)有相遇點(diǎn),則沒(méi)有環(huán)if (crossNode == null) {returnnull;}// 分別定義兩個(gè)指針,一個(gè)指向頭結(jié)點(diǎn),一個(gè)指向相交結(jié)點(diǎn)Node tmp1 = head;Node tmp2 = crossNode;// 兩者相遇點(diǎn)即為環(huán)的入口結(jié)點(diǎn)while (tmp1.data != tmp2.data) {tmp1 = tmp1.next;tmp2 = tmp2.next;}return tmp1;}思考題:知道了環(huán)的入口結(jié)點(diǎn),怎么求環(huán)的長(zhǎng)度?
六、總結(jié)
本文詳細(xì)講解了鏈表與數(shù)組的本質(zhì)區(qū)別,相信大家對(duì)兩者的區(qū)別應(yīng)該有了比較深刻的認(rèn)識(shí),尤其是程序局部性原理,相信大家看了應(yīng)該會(huì)眼前一亮,之后通過(guò)對(duì)鏈表的翻轉(zhuǎn)由淺入深地介紹,相信之后的鏈表翻轉(zhuǎn)對(duì)大家應(yīng)該不是什么難事了,之后再介紹了鏈表的另一個(gè)重要的解題技巧:快慢指針,這兩大類是面試的高頻題,大家一定要掌握!建議大家親自實(shí)現(xiàn)一遍文中的代碼哦,這樣印象會(huì)更深刻一些!有一些看起來(lái)思路是這么一回事,但真正操作起來(lái)還是會(huì)有不少坑,紙上得來(lái)終覺(jué)淺,絕知此事要躬行!
來(lái)源 | 五分鐘學(xué)算法
作者 | 程序員小吳
總結(jié)
以上是生活随笔為你收集整理的【超详细】一文学会链表解题(建议收藏!)的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 收藏!一张图帮你快速建立大数据知识体系
- 下一篇: 预览速度提升30倍,这是什么黑科技?(天