用 JavaScript 實現鏈表操做 - 08 Remove Duplicates

TL;DR

爲一個已排序的鏈表去重,考慮到很長的鏈表,須要尾調用優化。系列目錄見 前言和目錄javascript

需求

實現一個 removeDuplicates() 函數,給定一個升序排列過的鏈表,去除鏈表中重複的元素,並返回修改後的鏈表。理想狀況下鏈表只應該被遍歷一次。java

var list = 1 -> 2 -> 3 -> 3 -> 4 -> 4 -> 5 -> null
removeDuplicates(list) === 1 -> 2 -> 3 -> 4 -> 5 -> null

若是傳入的鏈表爲 null 就返回 nullnode

這個解決方案須要考慮鏈表很長的狀況,遞歸會形成棧溢出,因此遞歸方案必須用到尾遞歸。git

由於篇幅限制,這裏並不解釋什麼是尾遞歸,想詳細瞭解的能夠先看看 尾調用 的定義。github

遞歸版本 - 非尾遞歸

對數組或者鏈表去重自己是個花樣不少的算法,但若是鏈表是已排序的,解法就單一不少了,由於重複的元素都是相鄰的。假定鏈表爲 a -> a1 -> a2 ... aN -> b ,其中 a1aN 都是對 a 的重複,那麼去重就是把鏈表變成 a -> b算法

由於遞歸版本沒有循環,因此一次遞歸操做只能減去一個重複元素,好比第一次去除 a1 ,第二次去除 a2segmentfault

先看一個簡單的遞歸版本,這個版本遞歸的是 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)
}

咱們加了兩個變量 prevreprev 表明 head 的前一個節點,在遞歸過程當中咱們判斷的是 prevhead 是否有重複。爲了最後能返回鏈表的頭咱們加了 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
}

能夠看到,由於循環體外的共有變量 nodehead ,這個例子代碼比遞歸版本要簡單直觀不少。

總結

循環和遞歸沒有孰優孰劣,各有合適的場合。這個 kata 就是一個循環比遞歸簡單的例子。另外,尾遞歸由於要傳遞中間變量,因此寫起來的感受會更相似循環而不是正常的遞歸思路,這也是爲何我對大部分 kata 沒有作尾遞歸的緣由 -- 這個教程的目的是展現遞歸的思路,而尾遞歸有時候達不到這一點。

算法相關的代碼和測試我都放在 GitHub 上,若是對你有幫助請幫我點個贊!

參考資料

Codewars Kata
GitHub 的代碼實現
GitHub 的測試

相關文章
相關標籤/搜索