前端算法 | 链表篇

113 阅读15分钟

本文是作者刷算法题之余,将刷题的经验分享出来,欢迎和我交流探讨。

(Easy) —— 合并两个有序链表

将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。 

 

示例 1:

输入: l1 = [1,2,4], l2 = [1,3,4]
输出: [1,1,2,3,4,4]

示例 2:

输入: l1 = [], l2 = []
输出: []

示例 3:

输入: l1 = [], l2 = [0]
输出: [0]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 链表
  • 合并
  • 排序

  两个链表本身就是有序的,我们只需要同时比较两个链表开头的一个结点的 val,比较大小,较小的一个结点就跳过,继续往后面寻找。

  两个链表的结点数量未知,存在一个链表查找完,另一个链表还有剩余的情况,需要对这种情况特殊处理。因为剩余的链表本身有序,剩余链表所有结点的 val 可以确定是更大的,所以直接连接即可。

指针穿针引线,合并链表

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * @param {ListNode} list1
 * @param {ListNode} list2
 * @return {ListNode}
 */
var mergeTwoLists = function (list1, list2) {
  // 定义dummy结点,方便比较两个链表的头部
  const head = new ListNode()
  let cur = head
  while (list1 && list2) {
    if (list1.val >= list2.val) {
      cur.next = list2
      list2 = list2.next
    } else {
      cur.next = list1
      list1 = list1.next
    }
    cur = cur.next
  }

  // 存在某个链表剩余的情况
  cur.next = list1 === null ? list2 : list1
  return head.next
}

总结

  • 无论是数组的合并,还是链表的合并,一般都可以使用 指针
  • 链表的操作,经常要创建一个伪结点 dummy 结点,有了 dummy结点,其他的结点想怎么玩怎么玩,最终 dummy.next 还是会指向头结点

(Easy) —— 删除排序链表中的重复元素

给定一个已排序的链表的头 head , 删除所有重复的元素,使每个元素只出现一次 。返回 已排序的链表 。

 

示例 1:

输入: head = [1,1,2]
输出: [1,2]

示例 2:

输入: head = [1,1,2,3,3]
输出: [1,2,3]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 链表
  • 删除
  • 排序

  删除是链表的常规操作,要删除某个结点,只需要把他跳过就好了。所以只需要考虑怎么判断结点是重复的。

  如果 当前结点 val === 下个结点 val,那么这两个结点就是重复的,我们选择把下一个节点跳过,保留当前结点,重复执行这一操作,直至遍历结束。

判断结点重复并删除

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var deleteDuplicates = function (head) {
  let cur = head // 给head创建一个帮手(指针),让他去查找删除,确保最后可以无脑 return head
  // 既然要比较当前结点和下个结点,那么两个结点都必须是有值的
  while (cur !== null && cur.next !== null) {
    if (cur.val === cur.next.val) {
      cur.next = cur.next.next // 跳过重复的结点
    } else {
      cur = cur.next // 不重复,指针往后移动
    }
  }
  return head
}

总结

  • 链表结点的删除,其实就是结点的跳过
  • 确保头结点不变的时候,可以借助指针来进行 增删,不用创建 dummy 结点

(Easy) —— 反转链表

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

 

示例 1:

输入: head = [1,2,3,4,5]
输出: [5,4,3,2,1]

示例 2:

输入: head = [1,2]
输出: [2,1]

示例 3:

输入: head = []
输出: []

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 链表
  • 反转

  很容易想到,实现链表的反转,就是反转每个结点的指向关系,将本该的指向下一个结点变成指向上一个结点,每个结点都这样做,整个链表就反转了。

  那么对于每个结点,要做的操作是一样的,那么就需要遍历来实现,思路如下:

  • 需要三个指针 curprenext
  • 断开 原有结点 next 的指向(断开前要先记录一下 next = cur.next,不然不知道下一个要遍历哪个结点了)
  • 将结点的 next 指向 pre,第一个结点反转后变成了最后一个结点,所以 pre 初始值为 null,随着遍历更新 pre,之后的每个结点的 next 都需要指向 pre
  • 循环终止条件不能再用 cur.next(因为next被主动断开了),而是将 cur 更新为记录好的 next,判断 cur 是否存在,来判断是否遍历到原有链表尾部

三个指针实现链表反转

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var reverseList = function (head) {
  // 初始化pre、cur指针
  let pre = null
  let cur = head
  while (cur) {
    // 先记录原有的next结点
    let next = cur.next
    cur.next = pre
    // 往后移动遍历
    pre = cur
    cur = next
  }
  // 最后 pre就到了原有链表的末尾,它成为了头结点
  return pre
}

总结

  • 链表的反转

  1. 反转链表有固定的套路,需要三个指针cur指向当前结点,next保证遍历方向,pre记录反转后的next指向

  2. 循环条件不能再用 cur.next,而是将 cur 更新为记录好的 next,判断 cur 是否存在,来判断是否遍历到原有链表尾部

(Easy) —— 环形链表

给你一个链表的头节点 head ,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。

如果链表中存在环 ,则返回 true 。 否则,返回 false 。

 

示例 1:

输入: head = [3,2,0,-4], pos = 1
输出: true
解释: 链表中有一个环,其尾部连接到第二个节点。

示例 2:

输入: head = [1,2], pos = 0
输出: true
解释: 链表中有一个环,其尾部连接到第一个节点。

示例 3:

输入: head = [1], pos = -1
输出: false
解释: 链表中没有环。

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 链表
  • 成环

  成环链表也是链表问题中的常见类型,成环链表的一大特征是在遍历中有结点可以重复被访问。这道题判断链表是否成环的思路有点类似于 哈希表

  本身链表每一个结点用 JavaScript 模拟就是一个 对象,只需要边遍历边存储,这里是给结点加一个 flag: true,一旦发现当前结点的 flagtrue,说明该结点已被遍历过,链表就是包含环的,反之,则没有环。

哈希表思路判断链表是否成环

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function (head) {
  while (head) {
    if (head.flag) {
      return true
    } else {
      head.flag = true
      head = head.next
    }
  }
  return false
}

总结

  • 哈希表 可以判断链表是否有环

(Medium) —— 环形链表 II

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

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

不允许修改 链表。

示例 1:

输入: head = [3,2,0,-4], pos = 1
输出: 返回索引为 1 的链表节点
解释: 链表中有一个环,其尾部连接到第二个节点。

示例 2:

输入: head = [1,2], pos = 0
输出: 返回索引为 0 的链表节点
解释: 链表中有一个环,其尾部连接到第一个节点。

示例 3:

输入: head = [1], pos = -1
输出: 返回 null
解释: 链表中没有环。

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 链表
  • 成环

  这道题跟上一道题【Easy题 环形链表】类似,加了点花。

  我们沿用上一道题的解法,成环链表的第一个入环元素必定是 flagtrue的元素,一旦发现,直接 return 即可。

哈希表思路找到第一个入环元素

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function (head) {
  while (head) {
    if (head.flag) {
      return head
    } else {
      head.flag = true
      head = head.next
    }
  }
  return false
}

  另一种解法,是使用快慢指针

  使用两个指针 fast, slowfast 每次走 2 步,slow 每次走1步。

  • 如果没有环,fastslow 永远不会相遇
  • 如果有环,fastslow 必定相遇

  我们来分析一下他们相遇的细节。

  • 首先,必定是 fast 先入环,之后 slow 入环,之后 fast 每走一步他和 slow 的距离就减少1,直至相遇。但是相遇点并不一定是入环结点,我们需要找到入环结点。
  • 我画了一个图,如下。假设 head入环结点 的距离是 x入环结点相遇点 的距离是 y相遇点入环结点 的距离是 z,那么可以得到:
// fast走过路程,n指fast在环内转过了几圈
x + n(y + z) + y

// slow走过路程
x + y

// 由于 fast 的路程是 slow 的两倍
2(x + y) = x + y + n(y + z)
x + y = n(y + z)

// 我们要求入环结点,也就是x
x = n(y + z) - y

// n(y + z)指沿着环转了n圈很好理解,关键是 -y,负数不好比较
// 尝试把他转化为正数,找n借一个
x = (n - 1 + 1)(y + z) - y
x = (n - 1)(y + z) + y + z - y
x = (n - 1)(y + z) + z

// 现在就好理解了,(n - 1)(y + z)同样指在环上转了整数圈。
// 那么只需同时从 head 和 slow 遍历,那么他们必定在入环结点相遇!

环形链表2.png

快慢指针转圈圈最终相遇

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function (head) {
  // 极端情况,确认无环,return null
  if (!head) return null
  // 初始化快慢指针,快指针每次走2步,慢指针每次走1步
  let slow = head
  let fast = head
  while (fast && fast.next) {
    slow = slow.next
    fast = fast.next.next
    // 相遇
    if (slow == fast) {
      // 头结点和slow同时走起,最终会在入环结点相遇
      while (slow !== head) {
        slow = slow.next
        head = head.next
      }
      return head
    }
  }
  return null
}

总结

  • 哈希表 可以判断链表是否有环,第一个入环结点在 哈希表 中会第一个匹配到
  • 快慢指针 用于成环链表,两指针相遇后,只需同时从 headslow 遍历,那么他们必定在入环结点相遇

(Medium) —— 删除排序链表中的重复元素 II

给定一个已排序的链表的头 head , 删除原始链表中所有重复数字的节点,只留下不同的数字 。返回 已排序的链表 。

 

示例 1:

输入: head = [1,2,3,3,4,4,5]
输出: [1,2,5]

示例 2:

输入: head = [1,1,1,2,3]
输出: [2,3]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 链表
  • 删除
  • 排序

  这道题是上一道题【Easy题 删除排序链表中的重复元素】的升级版。

  上一道题是删除重复的结点,但保留了一重复元素的一个结点,意思是 head 结点是不受影响必定会留下来的。

  而这道题要把所有重复的结点都删掉,这意味着 head 结点可能也会被删掉,那么就必须 dummy结点 登场了。

  从 dummy结点 出发,一旦发现后面 2 个结点的 val 相同,那就把这 2 个结点删除,为了更加优化算法,只删这两个结点还不算完,我们把他们后面所有相同的结点(如有)都删掉。

有了dummy结点,随便玩不怕玩坏

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var deleteDuplicates = function (head) {
  // 极端情况,0,1个结点,直接返回
  if (head === null || head.next === null) {
    return head
  }
  let dummy = new ListNode()
  // dummy 用于固定head,或者说固定链表的头部结点,即时head被删,dummmy.next 一定也是头部结点
  dummy.next = head
  let cur = dummy // cur指针用于更改结点next指向
  while (cur.next && cur.next.next) {
    if (cur.next.val === cur.next.next.val) {
      // 记录重复的val
      const val = cur.next.val
      while (cur.next && cur.next.val === val) {
        // 跳过所有值为val的结点
        cur.next = cur.next.next
      }
    } else {
      cur = cur.next
    }
  }
  return dummy.next
}

总结

  • dummy结点 的重要性在于,即使 cur 指针 怎么增删链表元素,只要 dummy.next = head,那么头结点永远都是 dummy.next

(Medium) —— 删除链表的倒数第 N 个结点

给定一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

 

示例 1:

输入: head = [1,2,3,4,5], n = 2
输出: [1,2,3,5]

示例 2:

输入: head = [1], n = 1
输出: []

示例 3:

输入: head = [1,2], n = 1
输出: [1]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 链表
  • 删除
  • 倒数第N

  只需拿到 倒数第 N+1 个结点,就可以解决这道题。

  链表不能反着遍历,只能正向一个个找。如果要获取倒数第 N 个元素,一般的思路是:先遍历一遍,求出链表的 len,这样就定位到了倒数第 N 个就是正数的第 len - n + 1个,由于题目要求是删除该结点(而不是仅仅取结点的 val),所以还需要遍历一次,遍历到该结点的前驱结点,将其删除。

  这道题可以用 快慢双指针 的思路来创造一个 卡口来实现一次遍历就定位到 倒数第 N+1 个结点,思路如下:

  • 如果必须要使用一次遍历,而第一次遍历又无法确定 倒数第 N 个结点是哪个,那么,我们希望在遍历到尾部的时候,有另一个指针就正好在 倒数第 N+1 的位置;

  • 那么,我们假设现在就有两个指针,在遍历结束时,一个在 链表尾部、一个在 倒数第N+1位置。我们把他们后退,看怎么能在遍历结束的时候正好实现现在的两个指针的效果。可以看出,只需要一对快慢指针 fastslowfast 早走 n 步,之后 fastslow 并驾齐驱,直至 fast 到达尾部时停止,这时候 slow 就正好在 倒数第 N+1 的位置。

快慢指针营造卡口定位倒数第N+1个结点

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * @param {ListNode} head
 * @param {number} n
 * @return {ListNode}
 */
var removeNthFromEnd = function (head, n) {
  const dummy = new ListNode()
  dummy.next = head
  let fast = dummy
  let slow = dummy
  // 快指针先走
  while (n > 0) {
    fast = fast.next
    n--
  }
  // 快慢指针一起走
  while (fast.next) {
    fast = fast.next
    slow = slow.next
  }

  // 倒数第N+1个结点删除倒数第N个结点
  slow.next = slow.next.next
  return dummy.next
}

总结

  • 快慢指针 可以创造一个 卡口,定位 倒数第N 个结点

(Medium) —— 反转链表 II

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

 

示例 1:

输入: head = [1,2,3,4,5], left = 2, right = 4
输出: [1,4,3,2,5]

示例 2:

输入: head = [5], left = 1, right = 1
输出: [5]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 链表
  • 局部反转

  局部反转链表的题还是挺恶心的,我们先来实例一下思路。

  题目要求要反转 [left, right] 区间里的链表,具体过程可以拆分为下面几步:

  • 先遍历到 left 的前驱结点,将其记录下来,方便后面使用
  • 反转 [left, right] 部分的结点
  • 连接前驱结点 + 反转后的 [left, right] + 后面结点

定位前驱 + 局部反转 + 连接

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * @param {ListNode} head
 * @param {number} left
 * @param {number} right
 * @return {ListNode}
 */
var reverseBetween = function (head, left, right) {
  const dummy = new ListNode()
  dummy.next = head
  let p = dummy // 指针,为了区别于反转链表的cur,起名叫p
  // 定位反转区间的前驱结点
  for (let i = 0; i < left - 1; i++) {
    p = p.next
  }
  // 此时的p就是反转区间的前驱结点
  // 定位反转区间的开始结点
  let start = p.next
  // 链表反转常规操作,初始化pre和cur
  let pre = p.next
  let cur = pre.next
  // 开始反转([2,4]区间3个元素只需要遍历两次即可实现局部反转,所以i<4)
  for (let i = left; i < right; i++) {
    const next = cur.next
    cur.next = pre
    pre = cur
    cur = next
  }
  // 反转后,pre是局部链表的头部,cur是局部链表后的第一个结点
  // 开始拼接
  p.next = pre
  start.next = cur
  return dummy.next
}

总结

  • 局部反转链表:除了反转链表的操作,关键在于定位 4 个结点:

    • 局部区间的前驱结点
    • 局部区间的后继结点
    • 局部区间两头的两个结点