Js算法与数据结构拾萃(3):链表

  • 2020 年 2 月 25 日
  • 筆記

Js算法与数据结构拾萃(3):链表

补白

准备阅读: 《javascript数据结构和算法》读书笔记:链表

这仍然是笔者一年前的笔记。

对于一个数组,想要做篡改是复杂度是非常高的,设想你接到一个需求,让你记录存储一个人的简历,你用数组储存,可能是这样的:

resume=['克莱登大学','三闾大学']

如果你想增删改,比如:

// 第1个位置插入点金银行  resume.splice(1, 0, '点金银行');    // 删除克莱登大学  resume.splice(0,1)    // 修改  arr[1]='三闾大学';

这时考虑使用一个不连续的内存储存结构来降低复杂度。

链表储存有序的元素集合,但不同于数组,链表元素的内存不是连续放置的。每个元素包括:

•元素本身•指向下个元素的引用。

链表在js中同样也是没有定义的,需要的话得自己创建一个(LinkList):

class LinkList{      constructor(){          // 定义生成节点的工厂方法          this.Node=function(ele){              // 元素本身              this.ele=ele;              // 地址指针              this.next=null;          }            this.length=0;          this.head=null;      }        /**       * 在链表尾部追加节点       * @param {*} ele       */      append(ele){          // 如果链表为空(head为0,赋值node给head,          // 否则,根据索引值next找到最后一项)          const node=new this.Node(ele);          let current=null;          if(this.head==null){              this.head=node;          }else{              current=this.head;              while(current.next){                  current=current.next              }              current.next=node;          }          this.length+=1;      }        /**       * insert:在指定位置插入节点       * @param {number} pos       * @param {*} ele       */      insert(pos,ele){          // 如果在链表范围内,          if(pos>-1 && pos<this.length){              const node= new this.Node(ele);              let current=this.head;              let pre=null;              let index=0;              if(pos==0){                  node.next=this.head;                  this.head=node;              }else{                  // 一路寻找指针为pos的节点                  // 找到后:                  // 把上个节点的指针指向node                  // 把node的指针指向当前节点                  while (index++<pos) {                      pre=current;                      current=current.next;                  }                  node.next=current;                  pre.next=node;              }                this.length++;              return true;          }else{              // 插入位置超出链表范围              return false;          }      }        /**       * 移除指定位置的节点并返回       * @param {number} pos       */      removeAt(pos){          if(pos>-1&& pos<this.length){              let current=this.head;              let pre=null;              let index=0;              if(pos==0){                  // 移除第一项,把头部之后的节点赋值给头部。即可!                  this.head=current.next;              }else{                  while(index++<pos){                      pre=current;                      current=current.next;                  }                    pre.next=current.next;              }                this.length-=1;              return current.ele;          }      }        /**       * 返回指定元素的索引       * @param {*} ele       */      indexOf(ele){          let current=this.head;          let index=0;          while (current) {              if(ele==current.ele){                  return index;              }              index++;              current=current.next;          }            return -1;      }        /**       * 删除指定元素       * @param {*} ele       */      remove(ele){          const index=this.indexOf(ele);          return this.removeAt(index);      }        /**       * 返回长度       */      size(){          return this.length;      }        /**       * 是否为空       */      isEmpty(){          return this.length==0;      }        /**       * 打印链表方法:输出链表的元素值       */      toString(){          let current=this.head;          let string='';            while (current) {              string+=current.ele+(current.next?'->':'');              current=current.next;          }            return string;      }        /**       * 获取头部       */      getHead(){          return this.head;      }        /**       * 获取尾节点       */      getTail(){          let current=this.head;          while(current.next){              current=current.next;          }          return current.ele;      }    }  
/** 测试用例 **/  // 定义列表  let resume=new LinkList();  resume.append('克莱登大学');  resume.append('三闾大学');  console.log(resume.toString())    // 插入节点  resume.insert(1,'点金银行');  console.log(resume.toString())    // 删除节点  resume.removeAt(0)  console.log(resume.toString())

相对于传统的数组,链表是一个真正动态数据结构。遍历的核心在于一个while循环:

let current=this.head;  while(condition){      current=current.next;  }

基本方法封装做好后,它的编辑就像DNA修饰一样方便。好处在于添加或移动元素时,无需修改其他元素。

【案例1】Remove Linked List Elements 移除链表元素

对应leetcode第203题 难度:简单 https://leetcode.com/problems/remove-linked-list-elements/submissions/

Remove all elements from a linked list of integers that have value val.

Example:

Input:  1->2->6->3->4->5->6, val = 6  Output: 1->2-3->4->5

首先要明确这里给出的JavaScript链表形式是怎样的,以测试用例1->2->6->3->4->5->6举例:

ListNode {    val: 1,    next:     ListNode { val: 2, next: ListNode { val: 6, next: [ListNode] } } }

题解1:递归

这个思路没什么好说的。

var removeElements = function(head, val) {      if (head === null) return null      head.next = removeElements(head.next, val)      return head.val === val ? head.next : head  };

性能尚可。

如果用链表的思路呢?

题解2:链表迭代

用链表的思路,很容易想到做迭代:

var removeElements = function(head, val) {          let current=head;          let pre=null;            if(!head){              return head;          }            while(current){              if(current.val==val){                  pre.next=current.next;              }              pre=current;              current=current.next;          }            return head;  };

然而不行。当测试用例为1->2,11,1时头部判断会报错。也就是说,当由目标值val开头,无论怎样都会少算。

设想我们把head作为一个新链表的next,这个新链表就回避了开头的问题。

以通过哨兵节点去解决它,哨兵节点广泛应用于树和链表中,如伪头、伪尾、标记等,它们是纯功能的,通常不保存任何数据,其主要目的是使链表标准化,如使链表永不为空、永不无头、简化插入和删除。

var removeElements = function(head, val) {          let current=head;          let node={val:null,next:head};          let pre=node;            while(current){              if(current.val==val){                  pre.next=current.next;              }else{                  pre=current;              }              current=current.next;          }            return node.next;  };

运行性能也在一个主流的范围之内。

【案例2】Reverse Linked List 反转链表

对应leetcode第206题 难度:简单 https://leetcode.com/problems/reverse-linked-list/

Reverse a singly linked list.

Example:

Input: 1->2->3->4->5->NULL  Output: 5->4->3->2->1->NULL

Follow up:

A linked list can be reversed either iteratively or recursively. Could you implement both?

你可以迭代或递归地反转链表。你能否用两种方法解决这道题?

题解1:迭代

易错点在于,如何腾出空间,否则容易陷入内存溢出:

var reverseList = function(head) {      let current=head;      let pre=null;        while(current){          // 缓存next          let next=current.next;          // 调换箭头          current.next=pre;            // 从缓存取current.next          pre=current;          current=next;      }        return pre  };

题解2:递归

递归的思路是甩锅,解决不了推给下一代(“搁置争议,交给下一代的智慧去解决”)。

每代的智慧是什么呢?

•遇到尾部next为null,递归终止•遇到头部,让它的next为空。•不断将 next 放入递归方法反转链表,结果next = 当前节点. (Tip: 推进结果直到 next.next 为空)

var reverseList = function(head,pre) {      if(!head || !head.next)  return head;      let next=head.next;        let ret=reverseList(next);      head.next=null;      next.next=head;        return ret;  };

复杂度分析:

•时间: O(n). 等同于正常推进,故 O(n).•空间: O(1). 尾递归方式,重复使用一个空间故空间复杂度为 O(1).

执行结果:

Runtime: 64 ms, faster than 43.25% of JavaScript online submissions for Reverse Linked List.

Memory Usage: 35.3 MB, less than 32.61% of JavaScript online submissions for Reverse Linked List.

性能上要差了很多。

【案例3】Linked List Cycle 环形链表

对应leetcode第141题 难度:简单 https://leetcode.com/problems/linked-list-cycle/

Given a linked list, determine if it has a cycle in it.

To represent a cycle in the given linked list, we use an integer pos which represents the position (0-indexed) in the linked list where tail connects to. If pos is -1, then there is no cycle in the linked list.

给定一个链表,判断链表中是否有环。

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。

Example 1:

Input: head = [3,2,0,-4], pos = 1  Output: true  Explanation: There is a cycle in the linked list, where tail connects to the second node.

Example 2:

Input: head = [1,2], pos = 0  Output: true  Explanation: There is a cycle in the linked list, where tail connects to the first node.

Example 3:

Input: head = [1], pos = -1  Output: false  Explanation: There is no cycle in the linked list.

Follow up:

Can you solve it using O(1) (i.e. constant) memory?

你能用O(1)内存解决此问题吗?

链表尾连接到链表中的位置为pos,遍历的逻辑就不好使了。

题解一:快慢指针

设想链表是一条跑道,跑道上进行一场龟兔赛跑。乌龟必须一步一步走,兔子可以跳,那么,如果跑道不是环形,兔子将首先冲过跑道终点。这时可判断为false。跑道如果成环形,龟兔始终会再次相遇(套圈),那返回true。

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

时间复杂度:O(n),让我们将 n设为链表中结点的总数。为了分析时间复杂度,我们分别考虑下面两种情况。

•链表中不存在环:快指针将会首先到达尾部,其时间取决于列表的长度,也就是 O(n)。•链表中存在环:我们将慢指针的移动过程划分为两个阶段:非环部分与环形部分:•慢指针在走完非环部分阶段后将进入环形部分:此时,快指针已经进入环中迭代次数 = 非环部分长度 = N迭代次数= 非环部分长度=N•两个指针都在环形区域中:考虑两个在环形赛道上的运动员 – 快跑者每次移动两步而慢跑者每次只移动一步。其速度的差值为 1,因此需要经过 二者之间距离/速度差值 次循环后,兔子可套圈乌龟 ,假设环状长度为K,因此,在最糟糕的情形下,时间复杂度为 O(N+K),也就是 O(n)。

我们只使用了慢指针和快指针两个结点,所以空间复杂度为 O(1)。

题解二:哈希表

回想《Js算法与数据结构拾萃(1)[1]》中两数之和的相亲party问题。我们将场景稍微改一下:你组织一群单身青年参加一场天使与国王的游戏,但是抽签结果中出现了“套圈”的情形(A的国王是B,B的国王是C,C的国王是A),这时候你会怎么判断出来?

最直接的想法就是:到场签到时要求每个人告诉你,自己的国王是谁。由你写在记录本(集合中)上。如果发现某人的国王在签到名单中出现,那么就可以判套圈。

js已经支持了Set数据类型。这是个好消息。

var hasCycle = function(head) {      let book=new Set([]);        while(head!=null){          if(book.has(head)){              return true;          }else{              book.add(head);          }          head=head.next      }        return false;  };

这种解法相信更直观些。但是空间复杂度为O(n)

时间复杂度:O(n),对于含有 n 个元素的链表,我们访问每个元素最多一次。添加一个结点到哈希表中只需要花费 O(1)时间。

空间复杂度:O(n),空间取决于添加到哈希表中的元素数目,最多可以添加 n个元素。

【案例4】Linked List Cycle 环形链表

对应leetcode第142题 难度:中等 https://leetcode.com/problems/linked-list-cycle-ii/

Given a linked list, return the node where the cycle begins. If there is no cycle, return null.

To represent a cycle in the given linked list, we use an integer pos which represents the position (0-indexed) in the linked list where tail connects to. If pos is -1, then there is no cycle in the linked list.

Note: Do not modify the linked list.

给定一个链表,返回链表开始入环的第一个节点。如果链表无环,则返回 null。

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。

说明:不允许修改给定的链表。

Example 1:

Input: head = [3,2,0,-4], pos = 1  Output: tail connects to node index 1  Explanation: There is a cycle in the linked list, where tail connects to the second node.

Example 2:

Input: head = [1,2], pos = 0  Output: tail connects to node index 0  Explanation: There is a cycle in the linked list, where tail connects to the first node.

Example 3:

Input: head = [1], pos = -1  Output: no cycle  Explanation: There is no cycle in the linked list.

Follow-up: Can you solve it without using extra space?能否不使用额外空间解决

题解:引入第三个指针

在此不考虑哈希表方法,依然用龟兔赛跑为例子

•阶段1:用双指针判断当前为环形链表,快慢指针必定在环内相遇:•阶段2:如果是环形链表—>直接在head设定第三根指针,速度和fast一致,那么当它们相遇时,必定是链表的交叉路口。

阶段2的论证:

给定阶段 1 找到的相遇点,阶段 2 将找到环的入口。首先我们初始化额外的两个指针:cur,指向链表的头, 此时fast指向相遇点。然后,我们每次将它们往前移动一步,直到它们相遇,它们相遇的点就是环的入口,返回这个节点。

下面的图将更好的帮助理解和证明这个方法的正确性。

我们仍然以龟兔赛跑为例子:假设兔子在环上追上乌龟的地点是first。那么,乌龟走的距离为F+a。相同时间,兔子走了F+a+b(全程,完成至少一圈,套圈次数大于1时可不计)后,再走了a的距离才追上乌龟 。

因此,新指针cur和兔子必定在同速度走完F的路程后在路口相遇。

/**   * @param {ListNode} head   * @return {ListNode}   */  var detectCycle = function(head) {      let fast=head;      let slow=head;        if(head==null){ return null; }        while(fast!=null && fast.next!==null){          fast=fast.next.next;          slow=slow.next;            if(fast==slow){              // 有环              let cur=head;              while(cur!==fast){                  fast=fast.next;                  cur=cur.next;              }                return cur;            }        }        return null;  };

性能:

References

[1] Js算法与数据结构拾萃(1): http://mp.weixin.qq.com/s?__biz=MzIxMDQ2NjUwNg==&mid=2247484382&idx=1&sn=17002179d2b92645c6f5b4b1084a5721&chksm=9765607ba012e96d4a48894314b871f2074ec8b1dab86f42690de1648b9d3c0e7c568d4585d5&token=1920855520&lang=zh_CN#rd