Leetcode:刷完31道链表题的一点总结

  头几天第一次在 Segmentfault 发文—JavaScript:十大排序的算法思绪和代码完成,发明人人好像挺喜好算法的,所以本日再分享一篇前两个礼拜写的 Leetcode 刷题总结,愿望对人人能有所协助。

  本文首发于我的blog

媒介

  本日终究刷完了 Leetcode 上的链表专题,虽然只需 31 道题(统共是 35 道,但有 4 道题加了锁)罢了,但也陆陆续续做了两三个礼拜,严峻跟不上原本设计啊。原本盘算数据结构课程先生讲完一个专题,我就用 JS 在 Leetcode 做一个专题的。但是先生如今都讲到图了,而我连二叉树都还没刷 Orz(附上一张 AC 图,看着照样挺有成就感的嘛)。

《Leetcode:刷完31道链表题的一点总结》

  先写一篇博客总结一下这阵子刷链表题的收成吧,有输入也要有输出。这里就不花篇幅引见链表的一些基础观点了,不清楚的看官就自行谷歌一下吧,本文重要引见一些罕见的链表题和解题思绪。文中提到的 Leetcode 题目都有给出题目链接以及相干解题代码,运用其他要领的解题代码,或许更多 Leetcode 题解能够接见我的GitHub 算法客栈

正文

缓存

  不能不说运用数组 / map 来缓存链表中结点的信息是处置惩罚链表题的一大杀器,掩盖题目标局限包括但不限于:在链表中插进去 / 删除结点、反向输出链表、链表排序、翻转链表、兼并链表等,Leetcode 上 31 道链表绝大部分都能够运用这类要领解题。详细完成思绪是先运用一个数组或许 map 来存储链表中的结点信息,比方结点的数据值等,以后依据题目请求对数组举行相干操纵后,再从新把数组元素做为每一个结点连接成链表返回即可。虽然运用缓存来解链表题很 dirty,有违链表题的本意,而且空间庞杂度也到达了 O(n)(纵然我们常经常使用空间来换时刻,不过照样能防止就防止吧),但这类要领确实很简朴易懂,看完题目后险些就能够立时着手不加思索地敲代码一次 AC 了,不像通例操纵那样须要去考虑到许多边境状况和结点指向题目。

  固然,并非很首倡这类解法,如许就失去了做链表题的意义。假如只是同心专心想要解题 AC 的话那不妨。不然的话我发起能够运用数组缓存先 AC 一遍题,再运用通例要领解一次题,我个人就是这么刷链表题的。以至运用通例要领的话,你还能够离别运用迭代和递返来解题,迭代写起来比较轻易,而递归的难点在于把握递归边境和递归式,但只需明白清楚了的话,递归的代码写起来真的很少啊(背面会说到)。

  先找道题 show the code 吧,不然只是纯真的说能够会半知半解。比方这道反转链表 II:反转从位置 m 到 n 的链表。假如运用数组缓存的话,这道题就很轻易了。只须要两次遍历链表,第一次把从 m 到 n 的结点值缓存到一个数组中,第二次遍历的时刻再替换掉链表上 m 到 n 的结点的值就能够了(是不是是很简朴很清楚啊,假如运用通例要领的话就庞杂得多了)。完成代码以下:

var reverseBetween = function(head, m, n) {
  let arr = [];
  function fn(cur, operator) {
    let index = 1;
    let i = 0;
    while(cur) {
      if(index >= m && index <= n) {
        operator === "get" ?  arr.unshift(cur.val) : cur.val = arr[i++];
      }
      else if(index > n) {
        break;
      }
      index++;
      cur = cur.next;
    }
  }
  // 猎取从 m 到 n 的结点数值
  fn(head, "get");
  // 从新赋值
  fn(head, "set");
  return head;
};

  其他的题目比方链表排序、结点值交流等也是大抵雷同的代码,运用缓存解题就是这么简朴。至于上面这题的通例解法,能够戳这里检察,我已在代码中标注好解题思绪了。

  运用缓存来解题的时刻,我们能够运用数组来存储信息,也能够运用 map,一般状况下二者是能够通用的。但由于数组和对象的下标只能是字符串,而 map 的键名能够是恣意数据范例,所以 map 有时刻能做一些数组没法做到的事。比方当我们要存储的不是结点值,而是全部结点的时刻,这时刻运用数组就无计可施了。举个例子,环形链表:推断一个链表中是不是有环。先看一下环形链表长什么样。

《Leetcode:刷完31道链表题的一点总结》

  照样运用缓存的要领,我们在遍历链表的历程当中能够把全部结点看成键名放入到 map 中,并把它标记为 true 代表这个结点已涌现过。同时边推断 map 中以这个结点为键名的值是不是为 true,是的话申明这个结点反复涌现了两次,即这个链表有环。在这道题中我们是没方法用数组来缓存结点的,由于当我们把全部结点(一个对象)看成下标放入数组时,这个对象会先自动转化成字符串[object Object]再作为下标,所以这时刻只需链表结点数目大于即是 2 的话,推断结果都邑为 true。运用 map 解题的详细完成代码见下。

var hasCycle = function(head) {
  let map = new Map();
  while(head) {
    if(map.get(head) === true) {
      return true;
    }
    else {
      map.set(head, true);   
    }
    head = head.next;
  }
  return false;
}

  Leetcode 上另有一道题充分体现了 map 缓存解题的壮大,复制带随机指针的链表:给定一个链表,每一个节点包括一个分外增添的随机指针,该指针能够指向链表中的任何节点或空节点,请求返回这个链表的深拷贝。详细的这里就不再多说了。另外,该题另有一种 O(1) 空间庞杂度,O(n) 时刻庞杂度的解法(来自于《剑指offer》第187页)也很值得一学,引荐人人看看,概况能够看这里

快慢指针

  在上面环形链表一题中,假如不运用 map 缓存的话,通例解法就是运用快慢指针了。指针是 C++ 的观点,JavaScript 中没有指针的说法,但在 JS 中运用一个变量也能够一样到达 C++ 中指针的结果。先轻微解释一下我对 C++ 指针的明白吧,详细的知识点看官能够自行谷歌。在 C++ 中声明一个变量,实在声明的是一个内存地点,能够经由历程取址符&来猎取这个变量的地点空间。而我们能够定义一个指针变量来指向这个地点空间,比方int *address = &a。这时刻 address 就是指 a 的地点,而 *addess 则代表对这个地点空间举行取值,也就是 a 的值了。(既然说到地点空间了就顺带说一下上面环形链表这道题的另一种很 6 的解法吧。运用的是堆的地点是从低到高的,而且链表的内存是递次请求的,所以假如有环的话当要回到环的进口的时刻,下一个结点的地点就会小于当前结点的地点! 以此推断就能够获得链表中是不是有环的存在了。不过 JS 中没有供应猎取变量地点的操纵要领,所以这类解法和 JS 是无缘的了。C++ 解法能够戳这里检察。)

  有无以为这很像 JS 的按援用通报?之所以说在 JS 中运用一个变量就能够到达一样的结果,这和 JS 是弱言语范例变量的客栈存储体式格局有关。由于 JS 是弱言语范例,所以定义一个变量它既能够是基础数据范例,也能够是对象数据范例。而对象数据范例是将全部对象存放在堆中的,存储在栈中的只是它的接见地点。所以对象数据范例之间的赋值实际上是地点的赋值,指向堆中同一个内存空间的变量会牵一发而动全身,只需个中一个转变了内存空间中存储的值,都邑影响到其他变量对应的值。但假如是转变变量的接见地点的话,则对其他变量不会有任何影响。明白这部分内容非常重要,由于通例的链表操纵都是基于这些动身的。举最基础的链表轮回来申明。

let cur = head;
while(cur) {
  cur = cur.next;
}

  上面的几行代码是最基础的链表轮回历程,个中 head 示意一个链表的头节点,是一个链表的进口。cur 示意当前轮回到的结点,当链表到达了尽头即 curnull 的时刻就完毕了轮回。须要注重的是,每一个结点都是一个对象,简朴的链表结点都有两个属性valnextval代表了当前结点的数据值,next则代表了下一个结点。而由每一个结点的next不停连接起其他的结点,就构成了一个链表。由于对象是按援用通报,所以能够在轮回到恣意一个结点的时刻转变这个结点cur的信息,比方转变它的数据值或是指向的下一个结点,而且这会跟着修正到原链表上去。而转变当前的结点cur,由于是直接修正其接见地点,所以并不会影响到原链表。链表的通例操纵恰是在这一变一稳定的基础上完成的,因而操纵链表的时刻每每须要一个辅佐链表,也就是cur,来修正原链表的各个结点信息却不转变全部链表的指向。每次轮回完毕后head照样指向本来的链表,而cur则指向了链表的末端null。在这个历程当中,除了最最先把head赋值给cur和末了的return外,险些都不须要再操纵到head了。

  引见完通例操纵链表的一些基础知识点后,如今回到快慢指针。快慢指针实际上是运用两个变量同时轮回链表,区分在于一个的速率快一个的速率慢。比方慢指针slow的速率是 1,每趟轮回都指向当前结点的下一个结点,即slow = slow.next。而快指针fast的速率能够是 2,每趟轮回都指向当前结点的下下个结点,即fast = fast.next.next(运用的时刻须要特别注重fast.next是不是为null,不然极能够会报错)。如今设想一下,两个速率不雷同的人在同一个环形操场跑步,那末这两个人末了是不是是肯定会相遇。一样的原理,一个环形链表,快慢指针同时在里面挪动,那末它们末了也肯定会在链表的环中相遇。所以只需在轮回链表的历程当中,快慢指针相等了就代表该链表中有环。完成代码以下。

var hasCycle = function(head) {
  if(head === null) {
    return false;
  }
  let slow = head;
  let fast = head;
  while(fast !== null && fast.next !== null) {
    slow = slow.next;
    fast = fast.next.next;
    if(slow === fast) {
      return true;
    }
  }
  return false;
};

  除了推断链表中有无环外,快慢指针还能够找出链表中环形的进口。假定 A 是链表的进口结点,B 是环形的进口结点,C 是快慢指针的相遇点,x 是 AB 的长度(也就是 AB 之间的结点数目),y 是 BC 的长度,z 是 CB 的长度。由于快指针挪动的间隔(x + y)是慢指针挪动的间隔(x + y + z + y)的两倍(当快慢指针相遇时,快指针比慢指针多挪动了一圈),所以 z = x。因而,只需在快慢指针相遇的时刻,再让一个新指针重新节点 A 最先挪动,与此同时慢指针也继承从 C 点挪动。但新指针和慢指针相遇的时刻,也就是在链表环形的进口处 B。该题的三种完成代码能够戳这里检察

《Leetcode:刷完31道链表题的一点总结》

  假如我们把快指针的速率设置为 2,即每趟轮回都指向当前结点的下下个结点。那末快慢指针在挪动的历程当中,快指针挪动的间隔都邑是慢指针挪动间隔的两倍,运用这个性子我们能够很方便地获得链表的中心结点。只需让快慢指针同时重新节点最先挪动,当快指针走到链表的末了一个结点(链表长度是奇数)或是倒数第二个结点(链表长度是偶数)的时刻,慢指针就走到了链表中点。这里给出题目链接和完成代码。

var middleNode = function(head) {
  let slow = head;
  let fast = head;
  while(fast && fast.next) {
    slow = slow.next;
    fast = fast.next.next;
  }
  return slow;
};

前后指针

  前后指针和快慢指针很相似,差别的是前后指针的挪动速率是一样的,而且二者并没有同时最先挪动,是一前一后重新节点动身的。前后指针重要用来寻觅链表中倒数第 k 个结点。一般我们寻觅链表中倒数第 k 个结点能够有两种方法。 一是先轮回一遍链表盘算它的长度n,再正向轮回一遍找到该结点的位置(正向是第 n – k + 1 个结点)。二是运用双向链表,先挪动到链表结尾处再最先回溯 k 步,但大多时刻给的链表都是单向链表,这就又须要我们先轮回一遍链表给每一个结点增添一个先驱了。

  运用前后指针的话只须要一趟轮回链表,完成思绪是先让快指针走 k-1 步,再让慢指针重新节点最先走,如许当快指针走到末了一个结点的时刻,慢指针就走到了倒数第 k 个结点。解释一下为何,假定链表长度是 n,那末倒数第 k 个结点也就是正数的第 n – k + 1 个结点(不明白的话能够画一个链表看看就清楚了)。所以只需重新节点动身,走 n – k 步就能够到达第 n – k + 1 个结点了,因而如今的题目就变成了怎样掌握指针只走 n – k 步。在长度为 n 的链表中,重新节点走到末了一个结点统共须要走 n – 1 步,所以只需让快指针先走 (n – 1) – (n – k)= k – 1 步后再让慢指针重新节点动身,如许快指针走到末了一个结点的时刻慢指针也就走到了倒数第 n – k + 1 个结点。详细完成代码以下。

var removeNthFromEnd = function(head, k) {
  let fast = head;
  for(let i=1; i<=k-1; i++) {
    fast = fast.next;
  }
  let slow = head;
  while(fast.next) {
    fast = fast.next;
    slow = slow.next;
  }
  return slow;
}

  Leetcode 上有一道题是对寻觅倒数第 k 个结点的简朴变形,题目请求是要删除倒数第 k 个结点。代码和上面的代码大抵雷同,只是要再用到一个变量pre来存储倒数第 k 个结点的前一个结点,如许才能够把倒数第 k 个结点的下一个结点连接到pre背面完成删除结点的目标。完成代码能够戳这里检察

双向链表

  双向链表是在一般的链表上给每一个结点增添pre属性来指向它的上一个结点,如许就能够经由历程某一个结点直接找到它的先驱而不须要特地去缓存了。下面的代码是把一个一般的链表转化为双向链表。

let pre = null;
let cur = head;
while(cur) {
  cur.pre = pre;
  pre = cur;
  cur = cur.next;
}

  双向链表的运用场景照样挺多,比方上例寻觅倒数第 n 个结点,或许是推断回文链表。能够运用两个指针,从链表的首尾一同向链表中心挪动,一边推断两个指针的数据值是不是雷同。完成代码能够戳这里检察

  除了借助双向链表外,还能够先翻转链表获得一个新的链表,再重新节点最先轮回比较两个链表的数据值(固然运用数组缓存也是一种要领)。能够列位看官看到上面这句话以为没什么缺点,经由历程翻转来推断链表 / 字符串 / 数组是不是是回文的也是一个很罕见的解法,但不晓得看官有无考虑到一个题目,翻转链表是会修正到原链表的,对后续轮回链表比较两个链表结点的数据值是有影响的!一发明了这个题目,是不是是立时遐想到了 JS 的深拷贝。没错,一最先为了处置惩罚这个题目我是直接采纳JSON.parse + JSON.stringify来粗犷完成深拷贝的(横竖链表中没有 Date,Symbol 、RegExp、Error、function 以及 null 和 undefined 这些特别的数据),但不晓得为何JSON.parse(JSON.stringify(head))报了栈溢出的毛病,如今还没想通缘由 Orz。所以只能运用递回去深拷贝一次链表了,下面给出翻转链表和深拷贝链表的代码。

// 翻转链表
function reverse(head) {
  let pre = null;
  let cur = head;
  while(cur) {
    let temp = cur.next;
    cur.next = pre;
    pre = cur;
    cur = temp;
  }
  return pre;
}

// 翻转链表的递归写法
var reverseList = function(head) {
  if(head === null || head.next === null) {
    return head;
  }
  let cur = reverseList(head.next);
  head.next.next = head
  head.next = null;
  return cur;
}
// 深拷贝链表
function deepClone(head) {
  if(head === null)  return null;
  let ans = new ListNode(head.val);
  ans.next = clone(head.next);
  return ans;
}

  回文链表的 3 种解题要领(数组缓存、双向链表、翻转链表)能够戳这里检察,题目链接在这里

  除此之外另有一道重排链表的题,解题思绪和推断回文链表大抵雷同,列位看官有兴致的话能够试着 AC 这道题。一样的,这道题我也给出了 3 种解题要领

递归

  运用递归处置惩罚链表题目不能不说是非常符合的,由于许多链表题目都能够分割成几个雷同的子题目以减少题目范围,再经由历程挪用本身返回部分题目标答案从而来处置惩罚大题目标。比方兼并有序链表,当两个链表长度都只需 1 的时刻,就是只需推断头节点的数据值大小并兼并二者罢了。当链表一长题目范围一大,也只需挪用本身来推断二者的下一个结点和已有序的链表,经由历程不停递归处置惩罚小题目末了便能获得大题目标解。

  更多题目比方删除链表中反复元素删除链表中的特定值两两交流链表结点等也是能够经由历程递返来处置惩罚的,看官有兴致能够自行尝试 AC,相干的处置惩罚代码能够在这里找到。运用递归处置惩罚题目标上风在于递归的代码非常简约,有时刻运用迭代能够须要十几二十行的代码,运用递归则只须要短短几行罢了,有无以为很短小精悍啊啊啊。不过递归也照样得警惕运用,不然一旦递归的条理太多很轻易致使栈溢出(有无遐想到什么,实在就是函数实行上下文太多使实行栈炸了)。

一个小技能

  有时刻我们在轮回链表举行一些推断的时刻,须要仇人结点举行特别推断,比方要新创建一个链表 newList 并依据一些前提在上面增添结点。我们一般是直接运用newList.next来修正结点指向从而增添结点的。但第一次增加结点的时刻,newList 是为空的,不能直接运用newList.next,须要我们对 newList 举行推断看看它是不是为空,为空的话就直接对 newList 赋值,不为空再修正newList.next

  为了防止仇人节点举行特别处置惩罚,我们能够在 newList 的初始化的时刻先给它一个头结点,比方let newList = new ListNode(0)。如许在操纵历程当中只运用newList.next就能够了而不须要另行推断,而末了结果只需返回newList.next(固然,在轮回的时刻须要运用一个辅佐链表来轮回 newList ,不然会转变到 newList 的指向)。能够你会以为不就是多了一个else if推断吗,对代码也没多大影响,但假如在这个if中包括了许多其他相干操纵呢,如许的话ifelse if里就会有许多代码是反复的,不仅代码量变多了还很冗余耶。

后话

  关于链表本文就说这么多啦,假如人人发明有什么毛病、或许有什么疑问和补充的,迎接在下方留言。更多 LeetCode 题目标 JavaScript 解法能够参考我的GitHub算法客栈,现在已 AC 了一百多道题,并延续更新中。

  假如人人以为有协助的话,就点个 star 勉励勉励我吧,蟹蟹人人😊

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