前几天第一次在 Segmentfault 发文—JavaScript:十大排序的算法思路和代码实现,发现你们彷佛挺喜欢算法的,因此今天再分享一篇前两个星期写的 Leetcode 刷题总结,但愿对你们能有所帮助。html
本文首发于个人blognode
今天终于刷完了 Leetcode 上的链表专题,虽然只有 31 道题(总共是 35 道,但有 4 道题加了锁)而已,但也陆陆续续作了两三个星期,严重跟不上原先计划啊。原本打算数据结构课程老师讲完一个专题,我就用 JS 在 Leetcode 作一个专题的。然而老师如今都讲到图了,而我连二叉树都还没刷 Orz(附上一张 AC 图,看着仍是挺有成就感的嘛)。git
先写一篇博客总结一下这阵子刷链表题的收获吧,有输入也要有输出。这里就不花篇幅介绍链表的一些基本概念了,不清楚的看官就自行谷歌一下吧,本文主要介绍一些常见的链表题和解题思路。文中提到的 Leetcode 题目都有给出题目连接以及相关解题代码,使用其余方法的解题代码,或者更多 Leetcode 题解能够访问个人GitHub 算法仓库。github
不得不说使用数组 / map 来缓存链表中结点的信息是解决链表题的一大杀器,覆盖问题的范围包括但不限于:在链表中插入 / 删除结点、反向输出链表、链表排序、翻转链表、合并链表等,Leetcode 上 31 道链表绝大部分均可以使用这种方法解题。具体实现思路是先使用一个数组或者 map 来存储链表中的结点信息,好比结点的数据值等,以后根据题目要求对数组进行相关操做后,再从新把数组元素作为每个结点链接成链表返回便可。虽然使用缓存来解链表题很 dirty,有违链表题的本意,并且空间复杂度也达到了 O(n)(即便咱们经常用空间来换时间,不过仍是能避免就避免吧),但这种方法的确很简单易懂,看完题目后几乎就能够立刻动手不加思考地敲代码一次 AC 了,不像常规操做那样须要去考虑到不少边界状况和结点指向问题。算法
固然,并非很提倡这种解法,这样就失去了作链表题的意义。若是只是一心想要解题 AC 的话那无妨。不然的话我建议可使用数组缓存先 AC 一遍题,再使用常规方法解一次题,我我的就是这么刷链表题的。甚至使用常规方法的话,你还能够分别使用迭代和递归来解题,迭代写起来比较容易,而递归的难点在于把握递归边界和递归式,但只要理解清楚了的话,递归的代码写起来真的不多啊(后面会说到)。segmentfault
先找道题 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 有时候能作一些数组没法作到的事。好比当咱们要存储的不是结点值,而是整个结点的时候,这时候使用数组就无能为力了。举个例子,环形链表:判断一个链表中是否有环。先看一下环形链表长什么样。数据结构
仍是使用缓存的方法,咱们在遍历链表的过程当中能够把整个结点看成键名放入到 map 中,并把它标记为 true 表明这个结点已经出现过。同时边判断 map 中以这个结点为键名的值是否为 true,是的话说明这个结点重复出现了两次,即这个链表有环。在这道题中咱们是没办法用数组来缓存结点的,由于当咱们把整个结点(一个对象)看成下标放入数组时,这个对象会先自动转化成字符串[object Object]
再做为下标,因此这时候只要链表结点数量大于等于 2 的话,判断结果都会为 true。使用 map 解题的具体实现代码见下。dom
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
表示当前循环到的结点,当链表达到了终点即 cur
为 null
的时候就结束了循环。须要注意的是,每个结点都是一个对象,简单的链表结点都有两个属性val
和next
,val
表明了当前结点的数据值,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。该题的三种实现代码能够戳这里查看。
若是咱们把快指针的速度设置为 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
中包含了不少其余相关操做呢,这样的话if
和else if
里就会有不少代码是重复的,不只代码量变多了还很冗余耶。
关于链表本文就说这么多啦,若是你们发现有什么错误、或者有什么疑问和补充的,欢迎在下方留言。更多 LeetCode 题目的 JavaScript 解法能够参考个人GitHub算法仓库,目前已经 AC 了一百多道题,并持续更新中。
若是你们以为有帮助的话,就点个 star 鼓励鼓励我吧,蟹蟹你们😊