力扣热题——链表

51 阅读20分钟

一、概述

概念

  1. 「链表 linked list」是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。
  2. 链表的设计使得各个节点可以被分散存储在内存各处,它们的内存地址是无须连续的。
  3. 链表节点 ListNode 除了包含值,还需额外保存一个引用(指针)。因此在相同数据量下,链表比数组占用更多的内存空间。
  4. 常规操作:
// 链表初始化
class ListNode {
  val;
  next;
  constructor(val, next) {
    // 节点值
    this.val = val === undefined ? 0 : val;
    // 指向下一个节点的引用
    this.next = next === undefined ? null : next;
  }
}

// 初始化各个节点
const n0 = new ListNode(1);
const n1 = new ListNode(3);
const n2 = new ListNode(2);
const n3 = new ListNode(5);
const n4 = new ListNode(4);
// 构建引用指向
n0.next = n1;
n1.next = n2;
n2.next = n3;
n3.next = n4;

// 插入节点
function insert(n0, p) {
  /* 在链表的节点 n0 之后插入节点 P */
  const P = new ListNode(p);
  const n1 = n0.next;
  P.next = n1;
  n0.next = P;
}

/* 删除链表的节点 n0 之后的首个节点 */
function remove(n0) {
  if (!n0.next) return;
  // n0 -> P -> n1
  const P = n0.next;
  const n1 = P.next;
  n0.next = n1;
}

/* 访问链表中索引为 index 的节点 */
function access(head, index) {
  for (let i = 0; i < index; i++) {
    if (!head) {
      return null;
    }
    head = head.next;
  }
  return head;
}

/* 在链表中查找值为 target 的首个节点 */
function find(head, target) {
  let index = 0;
  while (head !== null) {
    if (head.val === target) {
      return index;
    }
    head = head.next;
    index += 1;
  }
  return -1;
}

适用场景

  1. 实现栈和队列:链表非常适合用来实现栈和队列这两种数据结构,因为这两种结构都需要在头部或尾部插入和删除数据,而链表由于是线性存储,插入和删除节点的时间复杂度是O(1), 比数组要快。
  2. 实现哈希表:哈希表中的冲突解决方案之一就是链地址法,也就是用链表来存储哈希值相同的数据。
  3. 处理动态数据:当我们需要处理的数据数量不定时,链表可以进行动态扩展。例如,在合并K个排序链表的问题中,我们可以使用链表来动态地合并所有列表。
  4. 逆向问题:有些问题可能需要我们从尾部处理数据,而链表的特性使得我们可以方便地插入一个dummy节点在尾部,从尾部开始处理问题。例如,链表的翻转、找到链表的倒数第N个节点等问题。
  5. 在空间有限或元素移动频繁的情况下:使用链表对元素进行插入或删除操作时,不需要像数组一样进行大量的元素移动或者空间预分配,因此对于空间有限制或者数据移动很频繁的情况,链表也有很大的优势。

总结的说来,链表在处理需要动态调整、大量插入删除、部分遍历等问题时有较大优势。但是同时,由于链表结构的特性,它在随机访问方面则没有数组高效,因此,在遇到问题时,需要根据问题具体特性选择使用链表还是其他数据结构。

优势

  1. 动态大小:与数组不同,链表的大小是可以动态调整的,因此在处理数据量未知或可扩展的情况下有优势。
  2. 插入和删除的效率高:在链表中,插入或删除一个节点的操作时间复杂度只需O(1),只需要改变一些指针引用即可,而无需移动大量元素,这使得在进行大量添加和删除操作的情况下比其他数据结构更加高效。
  3. 空间利用率高:每一个节点可以独立分配或释放,只占用实际使用的内存空间,无需像数组一样需要预先分配一大块连续的空间。
  4. 适合实现其他数据结构:链表是实现其他许多常见数据结构的基础,如栈、队列和哈希表等。

尽管链表具有这些优势,但也有它的局限性。比如,它不支持随机访问,每次查找都需要从头部或尾部开始顺序查找,时间复杂度为O(n);链表在存储上相较于数组更耗费内存空间,因为需要额外的空间去存储节点之间的链接信息(指针)。

二、刷题

相交链表

image.png
思路:双指针,由于题目已经给出不存在环,直接使用while循环判断就行,如果两个当前节点不相等(即指向的地址不同),则看是否到达链表的边界,如果是则指向另外一个链表的头节点否则指向下一个节点。理由如下:不存在相交节点,则会在两者同为null相等时退出循环,如果存在,则当遍历两链表相交部分长度加上两链表剩余部分长度和时,会到达实际相交的节点。
时间复杂度:最坏情况下,每个指针会遍历两个链表各一次,因此时间复杂度为 O(M+N),其中 M 和 N 分别是两个链表的长度。
空间复杂度:由于只使用了固定的额外空间(两个指针),空间复杂度为 O(1)。

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

/**
 * @param {ListNode} headA
 * @param {ListNode} headB
 * @return {ListNode}
 */
var getIntersectionNode = function(headA, headB) {
    if(!headA || !headB) return null

    let currentA = headA
    let currentB = headB
    while(currentA !== currentB){
        currentA = currentA === null ? headB : currentA.next
        currentB = currentB === null ? headA : currentB.next
    }

    return currentA
};

反转链表

image.png
思路:双指针,先设置一个前置为null的prev头节点,然后while循环判断当前节点是否为空,保存一下当前节点的下一个节点nextNode,以免后续当前节点变化后找不到,然后将当前节点的next指向prev,第一次循环时即指向链表最后的null末端,再把prev的地址设置为当前节点的地址,最后再将当前节点重置为之前保存下来的下一个节点即可。
时间复杂度:O(n),我们需要遍历链表中的每个节点一次,因此时间复杂度为 O(n)。
空间复杂度:O(1),我们并没有使用任何与链表长度相关的额外空间,仅使用了几个指针变量(currentNode, prev, nextNode),因此,空间复杂度是常数级别,即 O(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
 * @return {ListNode}
 */
var reverseList = function(head) {

    let currentNode = head
    let prev = null
    while(currentNode !== null){
        const nextNode = currentNode.next
        currentNode.next = prev
        prev = currentNode
        currentNode = nextNode
    }
    return prev
};

回文链表

image.png
思路:反转后半链表再比较前后两半段链表是否相等。注意,此处不能直接反转整个链表再同最开始的链表比较,因为在反转的过程中已经改变了原来链表的结构,即找不到head.next,无法再进行比较。其实简单一点的方法可以直接把所有的链表值放数组里,相当于做回文数组就行了。实际采用的方法是反转后半段链表,这里就需要找到中间节点,可以使用快慢双指针的方法,快指针走两步,慢指针走一步,当快指针到了最末端,此时的慢指针指向的节点便是中间节点(最中间或者中间靠后边那个)。之后便是上一题正常的链表反转再比较了。
时间复杂度:O(n)
空间复杂度:O(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
 * @return {boolean}
 */
var isPalindrome = function (head) {
    let left = head
    let right = reverse(getMid(head))
    while (right !== null) {
        if (left.val !== right.val) {
            return false
        }
        left = left.next
        right = right.next
    }
    return true

    // 获取中间节点
    function getMid(node) {
        let slow = node
        let fast = node
        while (fast && fast.next) {
            slow = slow.next
            fast = fast.next.next
        }
        return slow
    }
    // 反转
    function reverse(node) {
        let prev = null
        while (node !== null) {
            const nextNode = node.next
            node.next = prev
            prev = node
            node = nextNode
        }
        return prev
    }
};

环形链表

image.png
思路:快慢指针大法,另快指针走两步,慢指针走一步,若是存在环,则必然会相遇,否则直接走到链表末端退出while循环。
时间复杂度:O(n)
空间复杂度:O(1)

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

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function(head) {
    let fast = head
    let slow = head
    while(fast && fast.next){
        slow = slow.next
        fast = fast.next.next
        if(fast === slow) return true
    }
    return false
};

环形链表 II

image.png
思路:在原环形链表的解法基础上多了个数学理解部分,首先在我们的代码中,慢指针每次移动一步,而快指针每次移动两步,即f=2s,设'a'是从头节点到循环开始节点的距离,'b'是从循环开始节点到两指针相遇点的距离,'c'是从两指针相遇点回到循环开始节点的距离,那么有慢指针走过的长度 s = a + b,快指针走过的长度 f = a + b + c + b = a + 2b + c,带入回上式得:2(a + b) = a + 2b + c,即a = c,因此当快慢指针相遇时,如果从头节点和相遇点同时开始,每次都只移动一步,那么它们会在循环开始节点相遇
时间复杂度:O(n)
空间复杂度:O(1)

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

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

合并两个有序链表

image.png
思路:递归方法,先处理各自为空的情况,然后再依次判断,当前链表节点小,则节点的下一个值递归设置,同时传入下一个节点和另一链表此时的节点,并将当前节点返回
时间复杂度:O(n)
空间复杂度:O(n)

/**
 * 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) {
    if(!list1) return list2
    else if(!list2) return list1
    else if(list1.val < list2.val){
        list1.next = mergeTwoLists(list1.next, list2)
        return list1
    }else{
        list2.next = mergeTwoLists(list1, list2.next)
        return list2
    }
};

两数相加

image.png

image.png
思路:哨兵节点作为启动点,carray 控制进位
时间复杂度:一次 while 循环,复杂度为O(n)
空间复杂度:未使用额外的数据结构来存储链表的信息,空间复杂度是O(1)。

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * @param {ListNode} l1
 * @param {ListNode} l2
 * @return {ListNode}
 */
var addTwoNumbers = function(l1, l2) {
    let carray = 0
    let node = new ListNode()
    let currentNode = node
    while(l1 || l2 || carray){
        if(l1) carray += l1.val
        if(l2) carray += l2.val

        let newNode = new ListNode(carray % 10)
        currentNode.next = newNode
        currentNode = currentNode.next
        carray = Math.floor(carray / 10)
        if(l1) l1 = l1.next
        if(l2) l2 = l2.next
    }
    return node.next
};

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

image.png
思路:先获取当前总共节点数,然后计算出目标位置,再次循环遍历,如果到达指定位置则对节点进行移除操作,可用一个哨兵节点处理当要删除的节点恰好是链表的头节点时的特殊情况。当然,这种解法比较直观,更有效的方法也可以用快慢双指针的方法,提前将快指针移动 n 次。
时间复杂度:两次遍历。第一次遍历是为了得到链表长度,第二次遍历是为了找到并删除倒数第n个节点。所以时间复杂度为O(2n)。由于常数可以忽略不计,所以最终的时间复杂度为O(n)。
空间复杂度:未使用额外的数据结构来存储链表的信息,空间复杂度是O(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) {
    let currentNode = head
    let dummy = new ListNode()
    dummy.next = head
    let count = 0
    while(currentNode){
        count++
        currentNode = currentNode.next
    }
    let node = dummy
    let targetCount = count - n
    let currentCount = 0
    while(node){
        if(currentCount < targetCount){
            currentCount++
            node = node.next
        }else{
            let nextNode = node.next?.next
            node.next = nextNode
            break
        }
    }
    return dummy.next
};

两两交换链表中的节点

image.png
思路:哨兵节点+prev 节点与 current 节点,其中,prev 节点始终为当前需要交换节点的前一个节点,用于连接每一对交换过的节点,和即将要交换的下一对节点,以保持链表的正确顺序。在每次交换节点时,通过当前节点与下一节点的 next 指向进行交换,同时更新 prev 节点(如果没有prev,那么在交换节点后,我们无法将已交换的节点对和待交换的节点对正确连接起来,整个链表会被断开。)
时间复杂度:循环在链表中每次移动两个节点,只会遍历一次链表,因此时间复杂度是线性的,即O(n)。
空间复杂度:只使用了有限数量的变量,用来存储头节点、前一个节点和当前节点等,这些都是常数级别的,因此空间复杂度是O(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
 * @return {ListNode}
 */
var swapPairs = function(head) {
    // 创建虚拟节点作为新链表的头部
    let dummy = new ListNode(-1)
    dummy.next = head
    let prev = dummy
    let current = head

    while(current && current.next){
        // 保存当前的两个节点
        const firstNode = current
        const secondNode = current.next

        firstNode.next = secondNode.next
        secondNode.next = firstNode
        prev.next = secondNode

        prev = firstNode
        current = firstNode.next
    }

    return dummy.next
};

K 个一组翻转链表

image.png
思路:大致思路与上一题的两两交换一致,比较难以理解的是在 for 循环那里的链表节点反转操作,举一个简单简单的例子来详细解释:假设现在有一个链表:1 -> 2 -> 3 -> 4 ,并且k = 3。对前三个元素进行反转。一开始,prev指针是虚拟节点dummy,current指针指向1节点,nextNode指向2节点。

  1. 交换操作一
current.next = nextNode.next   // 1 --> 3 
nextNode.next = prev.next  // 2 --> 1
prev.next = nextNode  // dummy --> 2

此刻链表为:dummy -> 2 -> 1 -> 3 -> 4,current仍指向1,nextNode指向3。

  1. 交换操作 二
current.next = nextNode.next  // 1 --> 4
nextNode.next = prev.next  // 3 --> 2
prev.next = nextNode  // dummy --> 3

此刻链表为:dummy -> 3 -> 2 -> 1 -> 4,完成反转。通过指针操作,改变了节点间的链接顺序,达到每k个元素一组进行反转的操作。
时间复杂度:代码中涉及到两个主要循环,第一个用于计算链表长度,第二个用于执行反转操作。所以总的时间复杂度为O(2n),也就是O(n),其中n是链表的长度。
空间复杂度:没有使用额外的空间来存储链表节点,只是使用了几个指针变量。因此,空间复杂度为O(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} k
 * @return {ListNode}
 */
var reverseKGroup = function(head, k) {
    // 创建虚拟节点作为新链表的头部
    let dummy = new ListNode(-1)
    dummy.next = head
    let prev = dummy

    // 统计节点数
    let count = 0
    let node = head
    while(node){
        count++
        node = node.next
    }

    while(count >= k){
        let current = prev.next
        for(let i = 0; i < k - 1; i++){
            const nextNode = current.next
            current.next = nextNode.next
            // 注意,此处为nextNode.next = prev.next而不是nextNode.next = current
            // 因为下一次要处理翻转的已经不是current了,而是本次反转过来的nextNode
            nextNode.next = prev.next
            prev.next = nextNode
        }

        prev = current
        count -= k
    }

    return dummy.next
};

随机链表的复制

image.png

image.png
思路:哈希大法(哈希映射 (HashMap) 来实现链表的复制),其实解法还是比较容易的,就是题目难读懂点儿,题目要求是实现一个深拷贝的链表,也就是指向的值不能与原来链表的一致,这里主要考察 next 和 random 这种引用数据的拷贝,我们可以用 Map 数据结构来实现。先遍历一遍链表,将所有的节点与以该节点值为依据新建的一个节点赋值上去,二次遍历时从 map 时,从原来的 map 结构中寻找并更新 next 和 random 的值,因为本来 random 指向的位置我们是原链表中的某个位置,而我们在第一次遍历的时候已经通过newNode(current.val)赋值了一个了,故不会有题目中所说的依旧指向原链表中的节点的问题。
时间复杂度:两个主要循环,第一个用于创建新节点,第二个用于设置新节点的next和random指针。因此,总的时间复杂度为O(2n),也就是O(n),其中n是链表的长度。
空间复杂度:创建了一个额外的Map来存储链表节点的映射关系。假设源链表有n个节点,那么Map就需要存储n个键值对,因此,空间复杂度为O(n)。

/**
 * // Definition for a Node.
 * function Node(val, next, random) {
 *    this.val = val;
 *    this.next = next;
 *    this.random = random;
 * };
 */

/**
 * @param {Node} head
 * @return {Node}
 */
var copyRandomList = function(head) {
    const myMap = new Map()
    let current = head
    while(current){
        myMap.set(current, new Node(current.val))
        current = current.next
    }
    current = head
    while(current){
        myMap.get(current).next = myMap.get(current.next) || null
        myMap.get(current).random = myMap.get(current.random) || null
        current = current.next
    }

    return myMap.get(head)
};

排序链表

image.png
思路:归并排序。要是想直接做的话用内置的 sort 函数转换为数组再构建链表也可以,本次采用归并排序的分治思路(首先将未排序的列表分成大小大致相等的部分,然后对这两个部分分别进行排序,最后将两个已排序的部分“归并”成一个有序列表),先通过双指针获取链表的中间节点(注意提前保留一个 prev 节点用于断开),将链表断开为两部分,然后递归式调用主函数继续断开之前的两部分,最后通过新写的 merge 函数合并返回。
时间复杂度:在拆分过程中,我们使用快慢指针方法找到链表的中点,并将链表分成两半。这个过程的时间复杂度为O(n),在合并过程中,每次都将两个子链表进行合并。这个过程的时间复杂度也为O(n),因为每次合并操作需要遍历两个子链表的所有节点一次。因为每次我们都将链表拆成两半,所以总共需要执行logn次拆分和合并操作,所以整个算法的时间复杂度是O(nlogn)。
空间复杂度:额外使用了常数级别的空间存放变量,主要的空间消耗在于递归调用栈的深度,递归深度为logn,因此,空间复杂度为O(logn)。

/**
 * 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 sortList = function(head) {
    // 如果链表为空或者只有一个节点,直接返回
    if(!head || !head.next) return head
    let slow = head
    let fast = head
    // 添加一个指针,用于分割链表成两部分
    let prevSlow = null
    while(fast && fast.next){
        prevSlow = slow
        slow = slow.next
        fast = fast.next.next
    }
    let temp = slow
    // 分割链表
    prevSlow.next = null

    let left = sortList(head)
    let right = sortList(temp)

    return merge(left, right)
};
function merge(l1, l2) {
    let dummy = new ListNode(-1)
    let current = dummy
    while(l1 && l2){
        if(l1.val < l2.val){
            current.next = l1
            l1 = l1.next
        }else{
            current.next = l2
            l2 = l2.next            
        }
        current = current.next
    }
    // 若其中一个链表已经遍历完,将另一个链表剩余部分全部添加到新链表中
    if(l1){
        current.next = l1
    }
    else if(l2){
        current.next = l2
    }
    return dummy.next
}

合并 K 个升序链表

image.png
思路:优先队列(添加新元素或者取出元素的时候,都可以保证内部数据是有序的)。将K个链表的头结点加入到优先队列中,并且优先队列将根据链表头结点的值进行排序。然后依次取出最小节点,并将该节点的下一个节点放入优先队列中,重复这个过程,直到优先队列为空。
时间复杂度:使用了优先队列来维护K个链表的头节点,每次都从K个节点中选择最小的节点进行合并。每次选择都会通过优先队列进行一次O(logK)的调度,由于一共有N个节点,所以总的时间复杂度为O(NlogK)
空间复杂度:维护了一个大小为K的优先队列,所以空间复杂度就是队列的最大长度,也就是O(K)

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * @param {ListNode[]} lists
 * @return {ListNode}
 */
var mergeKLists = function(lists) {
    // 优先队列
    let PriorityQueue = class {
        constructor(){
            // 使用数组实现优先队列
            this.lists = []
        }
        // 添加元素,并根据元素的val值重新排序
        add(node){
            this.lists.push(node)
            this.lists.sort((a, b) => a.val - b.val)
        }
        // 移除并返回队列中的最小元素
        remove(){
            return this.lists.shift()
        }
        get length(){
            return this.lists.length
        }
    }

    const queue = new PriorityQueue()

    let dummy = new ListNode(-1)
    let prev = dummy
    // 将每个链表的头节点加入队列
    for(let node of lists){
        // 排除lists数组中的空元素
        if(node) queue.add(node)
    }

    while(queue.length){
        const node = queue.remove()
        prev.next = node
        if(node.next){
            queue.add(node.next)
        }
        prev = prev.next
    }

    return dummy.next
};

LRU 缓存

image.png
Map 简易版
思路:用过 Map 结构实现。构造一个类,当拿取时,先判断是否含有当前的 key,如果有的话,从 map 中删除该值再重新设置,返回删除的值,没有则直接返回-1 即可;当添加时,如果已经存在,则先删除,若存储数量超限,则将获取的第一个键值对删去,最后再统一将传入的键值对添加即可。

var LRUCache = function(capacity) {
    this.cache = new Map();
    this.capacity = capacity;
};

LRUCache.prototype.get = function(key) {
    if (this.cache.has(key)) {
        let temp = this.cache.get(key);
        this.cache.delete(key);
        this.cache.set(key, temp);
        return temp;
    }
    return -1;
};

LRUCache.prototype.put = function(key, value) {
    if (this.cache.has(key)) {
        this.cache.delete(key);
    } else if (this.cache.size >= this.capacity) {
        // this.cache.keys().next().value用于获取当前缓存中的第一个键值对的键
        this.cache.delete(this.cache.keys().next().value);
    }
    this.cache.set(key, value);
};

双向链表版

实现:

  • constructor(capacity): 构造函数初始化了缓存的容量、链表的哨兵节点,以及keyToNode哈希表。
  • getNode(key): 用于获取一个节点。如果哈希表中不存在对应的key,则返回null,否则返回对应的节点,并将其移动到链表头部。
  • get(key): 从缓存中获取key对应的value。如果节点存在则返回value, 否则返回-1。
  • put(key, value): 向缓存中添加一个键值对。如果key已经存在,更新对应节点的值并移到链表头部;否则创建新的节点,将其添加到哈希表和链表头部,并检查是否需要删除链表尾部的节点(如果当前缓存容量已超过最大容量)。
  • remove(x): 从链表中移除指定节点。
  • pushFront(x): 将指定节点移到链表头部。
  • dummy 节点:通过dummy 将链表围成一个环,即dummy 的前节点为链表尾节点,dummy 后节点为链表头节点。
// 定义链表中的一个节点
class Node {
    constructor(key = 0, value = 0) {
        this.key = key;  // key值
        this.value = value;  // 对应的value值
        this.prev = null;  // 指向前一个节点
        this.next = null;  // 指向后一个节点
    }
}

// 定义LRU缓存
class LRUCache {
    constructor(capacity) {
        this.capacity = capacity; // 设定缓存的容量
        this.dummy = new Node(); // 定义dummy节点作为哨兵,方便操作
        this.dummy.prev = this.dummy; // dummy节点的前驱是自己
        this.dummy.next = this.dummy; // dummy节点的后继也是自己
        this.keyToNode = new Map(); // 定义keyToNode哈希表,存储key和对应节点的映射,方便查询
    }

    // 获取一个节点
    getNode(key) {
        if (!this.keyToNode.has(key)) { // 如果哈希表中不存在这个key,返回null
            return null;
        }
        const node = this.keyToNode.get(key); // 从哈希表中获取key对应的节点
        this.remove(node); // 将这个节点从链表中删除
        this.pushFront(node); // 并将其插入到链表头部
        return node; // 返回这个节点
    }

    // 从缓存中获取键为key的值
    get(key) {
        const node = this.getNode(key); // 使用getNode方法获取key对应的节点
        return node ? node.value : -1; // 如果节点存在,返回对应的value,否则返回-1
    }

    // 将键为key,值为value的数据存到缓存中
    put(key, value) {
        let node = this.getNode(key); // 获取key对应的节点
        if (node) { // 如果节点存在
            node.value = value; // 更新节点的值
            return;
        }
        // 如果节点不存在
        node = new Node(key, value); // 创建新的节点
        this.keyToNode.set(key, node); // 在哈希表中添加key和节点的映射
        this.pushFront(node); // 将新节点插入链表头部
        if (this.keyToNode.size > this.capacity) { // 如果当前缓存的数量超过容量限制
            const backNode = this.dummy.prev; // 获取链表最后一个节点
            this.keyToNode.delete(backNode.key); // 删除哈希表中对应的映射
            this.remove(backNode); // 移除链表中的节点
        }
    }

    // 从链表中删除一个节点
    remove(x) {
        x.prev.next = x.next; // 修改被删除节点的前驱节点的后继指针
        x.next.prev = x.prev; // 修改被删除节点的后继节点的前驱指针
    }

    // 将一个节点添加到链表头部
    pushFront(x) {
        x.prev = this.dummy; // 新添加节点的前驱为dummy节点
        x.next = this.dummy.next; // 新添加节点的后继为原来头部的节点
        x.prev.next = x; // 修改dummy节点的后继指针
        x.next.prev = x; // 修改原来头部节点的前驱指针
    }
}