爲一個已排序的鏈表去重,考慮到很長的鏈表,須要尾調用優化。系列目錄見 前言和目錄 。javascript
實現一個 removeDuplicates()
函數,給定一個升序排列過的鏈表,去除鏈表中重複的元素,並返回修改後的鏈表。理想狀況下鏈表只應該被遍歷一次。java
var list = 1 -> 2 -> 3 -> 3 -> 4 -> 4 -> 5 -> null removeDuplicates(list) === 1 -> 2 -> 3 -> 4 -> 5 -> null
若是傳入的鏈表爲 null
就返回 null
。node
這個解決方案須要考慮鏈表很長的狀況,遞歸會形成棧溢出,因此遞歸方案必須用到尾遞歸。git
由於篇幅限制,這裏並不解釋什麼是尾遞歸,想詳細瞭解的能夠先看看 尾調用 的定義。github
對數組或者鏈表去重自己是個花樣不少的算法,但若是鏈表是已排序的,解法就單一不少了,由於重複的元素都是相鄰的。假定鏈表爲 a -> a1 -> a2 ... aN -> b
,其中 a1
到 aN
都是對 a
的重複,那麼去重就是把鏈表變成 a -> b
。算法
由於遞歸版本沒有循環,因此一次遞歸操做只能減去一個重複元素,好比第一次去除 a1
,第二次去除 a2
。segmentfault
先看一個簡單的遞歸版本,這個版本遞歸的是 removeDuplicates
自身。先取鏈表的頭結點 head
,若是發現它跟以後的節點有重複,就讓 head
指向以後的節點(減去一個重複),而後再把 head
放入下一個遞歸裏。若是沒有重複,則遞歸 head
的下一個節點,並把結果指向 head.next
。數組
function removeDuplicates(head) { if (!head) return null const nextNode = head.next if (nextNode && head.data === nextNode.data) { head.next = nextNode.next return removeDuplicates(head) } head.next = removeDuplicates(nextNode) return head }
這個版本只有第一個 return removeDuplicates(head)
處是尾遞歸,最後的 return head
並非。因此這個解法並不算徹底的尾遞歸,但性能並不算差。經我測試能夠處理 30000 個節點的鏈表,但 40000 個就必定會棧溢出。dom
不少遞歸沒辦法天然的寫成尾遞歸,本質緣由是沒法在屢次遞歸過程當中維護共有的變量,這也是循環的優點所在。上面例子中的 head.next = removeDuplicates(nextNode)
就是一個典型,咱們須要保留 head
這個變量,好在遞歸結束把結果賦值給 head.next
。尾遞歸優化的基本思路,就是把共有的變量繼續傳給下一個遞歸過程,這種作法每每須要用到額外的函數參數。下面是一個改變後的尾遞歸版本:函數
function removeDuplicatesV2(head, prev = null, re = null) { if (!head) return re re = re || head if (prev && prev.data === head.data) { prev.next = head.next } else { prev = head } return removeDuplicatesV2(head.next, prev, re) }
咱們加了兩個變量 prev
和 re
。prev
表明 head
的前一個節點,在遞歸過程當中咱們判斷的是 prev
和 head
是否有重複。爲了最後能返回鏈表的頭咱們加了 re
這個參數,它是最後的返回值。re
僅僅指向最開始的 head
,也就是第一次遞歸的鏈表的頭結點。由於這個算法是修改鏈表自身,只要鏈表非空,頭結點做爲返回值就是肯定的,即便鏈表開頭就有重複,被移除的也是頭結點以後的節點。
首先咱們須要一個支持尾遞歸優化的環境。我測試的環境是 Node v7 。Node 應該是 6.2 以後就支持尾遞歸優化,但須要指定 harmony_tailcalls
參數開啓,默認並不啓動。我用的 Mocha 寫測試,因此把參數寫在 mocha.opts
裏,配置以下:
--use_strict --harmony_tailcalls --require test/support/expect.js
其次咱們須要一個方法來生成很長的,隨機重複的,生序排列的鏈表,個人寫法以下:
// Usage: buildRandomSortedList(40000) function buildRandomSortedList(len) { let list let prevNode let num = 1 for (let i = 0; i < len; i++) { const node = new Node(randomBool() ? num++ : num) if (!list) { list = node } else { prevNode.next = node } prevNode = node } return list } function randomBool() { return Math.random() >= 0.5 }
而後就能夠測試了,爲了方便同時測試溢出和不溢出的狀況,寫個 helper ,這個 helper 簡單的判斷函數是否拋出 RangeError
。由於函數的邏輯已經在以前的測試中保證了,這裏就不測試結果是否正確了。
function createLargeListTests(fn, { isOverflow }) { describe(`${fn.name} - max stack size exceed test`, () => { it(`${isOverflow ? 'should NOT' : 'should'} be able to handle a big random list.`, () => { Error.stackTraceLimit = 10 expect(() => { fn(buildRandomSortedList(40000)) })[isOverflow ? 'toThrow' : 'toNotThrow'](RangeError, 'Maximum call stack size exceeded') }) }) } createLargeListTests(removeDuplicates, { isOverflow: true }) createLargeListTests(removeDuplicatesV2, { isOverflow: false })
完整的測試見 GitHub 。
順帶一提,以上兩個遞歸方案在 Codewars 上都會棧溢出。這是由於 Codewars 雖然用的 Node v6 ,但並無開啓尾遞歸優化。
思路一致,就不贅述了,直接看代碼:
function removeDuplicatesV3(head) { for (let node = head; node; node = node.next) { while (node.next && node.data === node.next.data) node.next = node.next.next } return head }
能夠看到,由於循環體外的共有變量 node
和 head
,這個例子代碼比遞歸版本要簡單直觀不少。
循環和遞歸沒有孰優孰劣,各有合適的場合。這個 kata 就是一個循環比遞歸簡單的例子。另外,尾遞歸由於要傳遞中間變量,因此寫起來的感受會更相似循環而不是正常的遞歸思路,這也是爲何我對大部分 kata 沒有作尾遞歸的緣由 -- 這個教程的目的是展現遞歸的思路,而尾遞歸有時候達不到這一點。
算法相關的代碼和測試我都放在 GitHub 上,若是對你有幫助請幫我點個贊!