likes
comments
collection
share

金九银十,学会链表三板斧,面试无忧

作者站长头像
站长
· 阅读数 48

链表是一种重要的数据结构,无论是在我们的工作中,还是在我们的面试中都经常出现。 这种数据结构可以用在很多地方,比如内核的消息队列、缓存管理、跳表,B+ 树等。

在面试中面试官非常喜欢考察面试者的链表知识,主要有以下 3 个原因:

  • 操作链表需要非常小心,考虑各种边界情况;

  • 链表结构简单,但是查找、交换、翻转都非常容易出错;

  • 解决链表问题,需要有一定的算法思想,但是又并不太难。在面试过程中,需要你想到解题方法并实现出来,更加考察应试者的工程能力。

一、第一板斧:假头

什么是假头,假头通常也叫作 Dummy Head 或者“哑头”。实际上,就是在链表前面,加上一个额外的结点。此时,存放了 N 个数据的带假头的链表,算上假头一共有 N+1 个结点。

额外的结点不会存放有意义的数据。那么它的作用是什么呢? 添加假头后,可以省略掉很多空指针的判断。链表的各种操作会变得更加简洁。 比如:

1.1 初始化

初始化一个链表节点,并且让链表的 dummy 和 tail 指针都指向它。

// 初始化 dummy 
const dummy = new ListNode();
// 初始化链表 tail 指针
const tail = dummy;
// 记录链表长度
let length = 0;

注意

  • dummy 指针初始化好之后,永远都是静止的,不会在动
  • tail 指针在链表发生改变的时候,就需要移动调整

1.2 尾部添加节点

在链表的尾部添加一个节点。在有假头的情况下,是不需要判断 tail 节点是否为空的操作。

function addTail(val) {
  tail.next = new ListNode(val);
  tail = tail.next;
  length++;
}

1.3 头部添加节点

需要插入的新结点为 p,插入之后,新结点 p 会成为第一个有意义的数据节点。通过以下 3 步可以完成头部插入: 新结点 p.next 指向 dummy.next;dummy.next 指向 p;如果原来的 tail 指向 dummy,那么将 tail 指向 p。 由于有 dummy 结点的存在,代码并不会遇到空指针,当链表为空的时候,它依然是可以工作的。

function addHead(val) {
  const p = new ListNode(val);
  p.next = dummy.next;
  dummy.next = p;
  if (tail === dummy) {
    tail = p;
  }
  length++;
}

1.4 查找节点

在查找索引值为 index(假设 index 从 0 开始)的结点时,需要注意,大多数情况下,返回指定结点前面的一个结点 prev 更加有用。

所以查找节点,关键点在于查找前一个节点。 好处有以下两个方面:

  • 通过 prev.next 就可以访问到你想要找到的结点,如果没有找到,那么 prev.next 为 null;
  • 通过 prev 可以方便完成后续操作,比如在 target 前面 insert 一个新结点,或者将 target 结点从链表中移出去。
function getPrevNode(index) {
  let front = dummy.next;
  let back = dummy;

  for (let i = 0; i < index && front !== null; i++) {
    back = front;
    front = front.next;
  }
  return back;
}

function get(index) {
  if (index < 0 || index >= length) return -1;

  return getPrevNode(index);
}

有了假头的帮助,这段查找代码就非常健壮了,可以处理以下 2 种情况:

  • 如果 target 在链表中不存在,此时 prev 返回链表的最后一个结点;
  • 如果为空链表(空链表指只有一个假头的链表),此时 prev 指向 dummy。也就是说,返回的 prev 指针总是有效的。

1.5 在指定位置之前插入节点

在指定位置之前插入节点,也就是在指定节点的前一个节点后面挂入一个节点。但是在操作的过程中,需要考虑多种情况:

  • 如果 index 大于链表长度,则不会插入结点。
  • 如果 index 等于链表的长度,则该结点将附加到链表的末尾。
  • 如果 index 小于 0,则在头部插入结点。
  • 否则在指定位置前面插入结点
function addAtIndex(index, val) {
  if (index > length) return;
  else if (index === length) addAtTail(val)
  else if (index <=0 ) addAtHead(val)
  else {
    const pre = getPrevNode(index);
    const p = new ListNode(val);
    p.next = pre.next;
    pre.next = p;
    length++;
  }
}

1.6 删除节点

删除节点时,也需要考虑多种情况:

  • 如果 index 无效,那么什么也不做;
  • 如果 index 有效,那么将这个结点删除。
function deleteAtIndex(index) {
  if (index < 0 || index >= length) return;

  const pre = getPrevNode(index);
  if (tail === pre.next) {
    tail = pre;
  }
  pre.next = pre.next.next;
  length--;
}

二、第二板斧:新链表

做链表的反转、交换等操作时,不建议直接在原来的链表上进行操作。一种可取的思路是,把这些操作想象成要生成新的链表,然后借助这些新的链表,完成原本比较复杂的操作。

2.1 反转链表

简单的反转链表:利用新链表,就非常的简单,不操作原链表。

var reverseList = function(head) {
  const dummy = new ListNode();
  while(head !== null) {
    const temp = head.next;
    head.next = dummy.next
    dummy.next = head;
    head = temp
  }

  return dummy.next;
}

在稍微难一点,给你单链表的头指针 head 和两个整数 left 和 right ,其中 left <= right 。请你反转从位置 left 到位置 right 的链表节点,返回 反转后的链表 。

var reverseList = function(head, left, right) {
  const dummy = new ListNode(-1);
  dummy.next = head;
  let pre = dummy;

  // 找到需要反转节点的前一个节点
  for (let i = 0; i < right - 1; ++i) {
    pre = pre.next;
  }
 
  // 这里的 cur 节点就是已经需要反转的节点
  let cur = pre.next;
  
  // 开始进行节点反转
  for (let  i = 0; i < right - left; ++i) {
    // 当前节点的下一个节点
    const next = cur.next;
    cur.next = next.next;	
    next.next = pre.next;
    pre.next = next;
  }
  return dummy.next;
}

2.2 删除节点

假头 + 新链表

var removeNode = function(head, val) {
  const dummy = new ListNode(-1);
  let tail = dummy;

  while(head !== null) {
    const next = head.next;
    if (head.val !== val) {
      tail.next = head;
      tail = head;
    }
    head = next;
  }
  tail.next = null
  return dummy.next;
}

三、第三板斧:双指针

双指针,顾名思义就是两个指针在链表上移动。实际上,我们在前面链表的查找中已经使用过双指针了:比如链表中指定位置插入一个新结点,就使用了两个指针,一前一后两个指针在链表上前进。 其实两个指针在链表上前进时,有很多种形式,常见的主要有以下两种:

  • 间隔指针:前面的指针先走一步,然后后面的指针再一起走;前面的指针先走 k 步,后面的指针再一起走。
  • 快慢指针:两个指针的速度一快一慢前进,比如一个每次走一步,一个每次走两步。

当有偶数个结点,s2 是空指针,此时,s1 位于后半部分指针的头部,因此需要返回s1 的前驱;

当有奇数个结点,s2 是最后一个结点,此时 s1 指针位于前半部分的最后,直接返回 s1即可。

3.1 删除链表的倒数第 N 个结点

一般情况下,有的同学会先统计出整个链表的长度 len, 再去取第 len-k 结点的前驱进行删除。这样需要遍历链表两次,如果只能遍历链表一次。实际上就是在告诉你“用双指针吧”。思路如下:

  • 在原链表前面加上 dummy,变成带假头的链表
  • front 指针从 dummy 开始,走 k 步,然后停下来
  • back 指针指向链表 dummy 假头
  • 然后两个指针再一起走
  • 当 front 指针指向最后一个结点时,back 指针刚好指向倒数第 k 个结点的前驱。
var removeNthFromEnd = function(head, n) {
    const dummy = new ListNode();
    dummy.next = head;
    let front = dummy;
    let back  = dummy;

    while(head !== null) {
       front = front.next;
       if (n === 0) {
           back = back.next;
       } else {
           n--;
       }
       head = head.next;
    }
    back.next = back.next.next;

    return dummy.next;
};

3.2 拆分链表

给定一个链表,需要把链表从中间拆分成长度相等的两半(如果链表长度为奇数,那么拆分之后,前半部分长度更长一点)。关键点在于找到中间节点,当然你可以首先求出链表的长度,然后再利用 getPreNode(len/2) 函数的前驱,再把链表拆分成两半。但是还是只能遍历链表一次。实际上就是在告诉你“用双指针吧”。思路如下:

  • 假设链表头在左边,尾巴在右边,两个指针 s1、s2 从链表头开始往右走;
  • s1 表示每次只往前走一步,s2 则表示每次只往前走 2 步;
  • 在同样的时间内,当 s2 指向链表的末尾,s1 指针便指向链表的中间结点。
function findMiddleNode(head) {
    // 注意这里转化为带假头的链表,免去了空链表的判断
    const dummy = new ListNode();
    dummy.next = head;
    // 注意,假头并不算是链表的一部分,所以这里是从head开始走
    let s2 = head;
    let s1 = head;
    // dummy就是head的前驱,所以pre要指向dummy.
    ListNode pre = dummy;
    // 两个指针开始同时走
    // 因为s2指针每次都要走两步,所以判空需要这样判断。
    while (s2 != null && s2.next != null) {
        pre = s1;
        s1 = s1.next;
        s2 = s2.next.next;
    }
    // 当有偶数个结点的时候,s2是空指针,
    // 此时,s1位于后半部分指针的头部,因此需要返回s1的前驱。
    // 当有奇数个结点的时候,s2是最后一个结点,
    // 此时s1指针位于前半部分的最后,直接返回s1即可。
    return s2 != null ? s1 : pre;
}
function split(head) {
    // 这里获取了链表的中间结点
    let mid = findMiddleNode(head);
    // 拿到链表的中间结点之后,可以得到链表的后半部分的开头
    let back = mid.next;
    // 把链表拆分为两半
    mid.next = null;
    // 返回两个链表的头部
    return { head, back };
}

四、总结

  • 第一斧:假头。假头的作用主要是避免关于空链表的判断与讨论,假头还可以用来避免检查前驱结点为空的情况。
  • 第二斧:新链表。新链表的引入是为了解决在旧链表中进行原地的交换、插入、删除,把复杂的操作变成在新链表中头部插入或者尾部添加。
  • 第三斧:双指针。双指针主要是用于寻找链表中的特定结点,双指针的走法可以一次一步,可以有快有慢,出发点也可以有前有后。

参考

转载自:https://juejin.cn/post/7283830142056005651
评论
请登录