用 JavaScript 完成链表操纵 - 08 Remove Duplicates

TL;DR

为一个已排序的链表去重,斟酌到很长的链表,须要尾挪用优化。系列目次见 前言和目次

需求

完成一个 removeDuplicates() 函数,给定一个升序分列过的链表,去除链表中反复的元素,并返回修正后的链表。抱负状况下链表只应该被遍历一次。

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

假如传入的链表为 null 就返回 null

这个解决计划须要斟酌链表很长的状况,递归会形成栈溢出,所以递归计划必需用到尾递归。

由于篇幅限定,这里并不诠释什么是尾递归,想细致相识的能够先看看 尾挪用 的定义。

递归版本 – 非尾递归

对数组或许链表去重本身是个名堂许多的算法,但假如链表是已排序的,解法就单一许多了,由于反复的元素都是相邻的。假定链表为 a -> a1 -> a2 ... aN -> b ,个中 a1aN 都是对 a 的反复,那末去重就是把链表变成 a -> b

由于递归版本没有轮回,所以一次递归操纵只能减去一个反复元素,比方第一次去除 a1 ,第二次去除 a2

先看一个简朴的递归版本,这个版本递归的是 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 个就一定会栈溢出。

递归版本 – 尾递归

许多递归没办法天然的写成尾递归,实质缘由是没法在屡次递归历程当中保护共有的变量,这也是轮回的上风地点。上面例子中的 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 的测试

    原文作者:darkbaby123
    原文地址: https://segmentfault.com/a/1190000008049580
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞