链表 ——关于算法的学习

183 阅读12分钟

今天我们来学习一下链表这种数据结构。看看它有些什么特点。

1. 链表的概念

链表和数组一样,都是有序的,线性的。它和数组不一样的地方是:数组是连续的,而链表是离散的。

数组中的值一个紧接着一个,而链表可以不是。链表由节点组成,每个节点由值和地址组成,只要它的地址指向了下一个值,那么这个节点处于哪里都无所谓。

链表的结构大概长这样:

PixPin_2024-12-12_13-03-59.png

它的第一个值的地址指向下一个值。

在JS中我们可以用对象来模拟链表这种数据结构,比如:

head = {
    val: 1,
    next: {
        val: 2,
        next: {
            val: 3,
            next: {
                val: 4,
                next: null
            }
        }
    }
}

这就是一个链表,由对象嵌套而成。最外面的这个对象就叫头节点,里面的对象就是子节点。

当我们想要访问第一个值时,就要这样写:

head.val

要访问后续的值就这样写:

head.val
head.next.val
head.next.next.val
head.next.next.next.val

我们想访问第二个节点的值,就必须先知道第一个节点的地址;想访问第三个节点的值,就必须知道第二个节点的地址。这就是链表的特性。

如果我们想往2和3之间添加一个x节点,应该怎么做呢?我们可以让2节点的地址指向x节点,再让x节点的地址指向3节点就行了。

如果我们想要删除3节点,我们可以直接让2节点的地址指向4节点,跳过3节点。

那我们怎么创建一个链表呢?我们可以自己来写一个构造函数:

 function ListNode(val) {
   this.val = val;
   this.next = null;
 }
 
const node = new ListNode(1)
node.next = new ListNode(2)
node.next.next = new ListNode(3)

这样node就是一个节点了,我们再去定义node的地址指向下一个节点。这样就会形成一个链表。

那链表相较于数组的优势是什么?如果我们想在数组中增删某一个值,是不是会带来时间复杂度,这个值后面所有的值都得往后挪,而链表不需要。链表中删除一个节点只需要找到它的上一个节点的地址,让它指向这个节点的下一个节点就行了。

但是在数组中访问一个值就比链表方便,直接用下标访问。而在链表中想访问一个节点,就必须知道它的上一个节点,知道上一个节点的上一个节点...

所以链表有这两个特性:

  • 高效的增删节点
  • 低效的访问节点

如果我们想遍历访问链表的每一个值,我们要这样访问:

head = {
    val: 1,
    next: {
        val: 2,
        next: {
            val: 3,
            next: {
                val: 4,
                next: null
            }
        }
    }
}

const index = 4
let node = head
for (let i = 0; i < index && node; i++) {
    console.log(node.val)
    node = node.next
}

先定义一个变量node等于头节点head,每循环一次让node等于它的下一个节点。这样就可以实现一个链表的遍历。

这样我们差不多学完了链表的概念,让我们一起来做几道关于链表常考的算法题。

2. leet21 合并两个有序链表

原题:合并两个有序链表

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

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

示例 2:
输入: l1 = [], l2 = []
输出: []

示例 3:
输入: l1 = [], l2 = [0]
输出: [0]

这道题还是比较容易的。我们这样想想看:如果这道题目是让我们将两个有序数组合并,我们就会准备两个指针,分别指向每个数组的第一个元素,然后拿这两个指针去比较大小。小的就放入一个新数组中,然后这个指针向后挪一位,再去比大小。

链表也可以用这个思想。我们分别获取两个链表的头节点,然后去比较头节点的值的大小。小的就存入新链表中,然后让这个头节点等于自己的子节点再去比大小。

有了思路代码就呼之欲出了。

var mergeTwoLists = function (list1, list2) {
    let l1 = list1
    let l2 = list2
    let res = new ListNode()
    let head = res
};

我们声明两个变量 l1 和 l2 用来获取list1和list2的头节点,再准备一个新链表res。可以调用题目已经给我们打造好的构造函数ListNode创建。我们先将这个新链表的头节点head保存下来,一般要对新链表做操作都要将头节点保存下来。此时这个新链表res的头节点值为0,我们会从res的下一个节点开始存入新的值,到时候返回head.next就行了。

就按照我们的思路来,去比较两个链表头节点的值。

var mergeTwoLists = function (list1, list2) {
    let l1 = list1
    let l2 = list2
    let res = new ListNode()
    let head = res
    
    if (l1.val <= l2.val) {
            res.next = l1
            l1 = l1.next
        } else {
            res.next = l2
            l2 = l2.next
        }
};

值更小的节点存入res.next中,然后让它等于自己的子节点,再去比较。这个过程是要重复执行的,所以我们要写个循环。

var mergeTwoLists = function (list1, list2) {
    let l1 = list1
    let l2 = list2
    let res = new ListNode()
    let head = res
    
     while (l1 && l2) {
        if (l1.val <= l2.val) {
            res.next = l1
            l1 = l1.next
        } else {
            res.next = l2
            l2 = l2.next
        }
        res = res.next
    }
};

循环条件就是 l1 && l2 都要存在。每存入一个节点给res,res也要赋值为自己的字节点,去进行下一次存值。

这里会有一个问题:有可能l1已经走到终点了,不存在了,而l2后面还有很多值没存入,此时循环就不会执行了。所以我们需要处理一下。如果 l1 已经走到终点 l2 里还有值,说明 l2 中剩余的值一定是最大的了,因为链表是有序的,所以我们直接将剩余的全部存入res中就行了。

var mergeTwoLists = function (list1, list2) {
    let l1 = list1
    let l2 = list2
    let res = new ListNode()
    let head = res
    
     while (l1 && l2) {
        if (l1.val <= l2.val) {
            res.next = l1
            l1 = l1.next
        } else {
            res.next = l2
            l2 = l2.next
        }
        res = res.next
    }
        res.next = l1 !== null ? l1 : l2
        return head.next
};

我们这样写:res.next = l1 !== null ? l1 : l2。用一个三元运算符,如果l1不为null,说明 l1 中还有剩余值,直接存入res中;如果l1为null,就将l2中剩余的值存入res中。

最后返回 head.next 就行了。head头节点值为0,我们只要它的字节的就行。

这样就完成了这道题目的解答,还是比较容易的,我们继续。

3. leet83 删除排序链表中的重复元素

原题: 删除排序链表中的重复元素

我们再来看一下力扣上的第83题。

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

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

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

注意题目说:返回已排序的链表。它不让我们创建一个新的链表,而是在原来的链表上进行操作。

应该怎么做呢?我们最好不要在头节点上进行操作,因为到时候我们需要返回它,所以我们准备一个当前节点cur,先赋值为头节点,再对cur去进行操作。

我们说过,在链表中删除元素其实就是将上一个节点的地址指向下一个节点,跳过这个我们要删除的节点就行。所以,我们可以拿cur与cur的子节点的值进行比较,如果发现相等,就跳过这个相等的节点,让cur的子节点指向下一个子节点;如果不相等,就向后挪一位,让cur等于自己的子节点,再去比较。

var deleteDuplicates = function (head) {
    let cur = head
    if (cur.val === cur.next.val) {
            cur.next = cur.next.next
        } else {
            cur = cur.next
        }
};

其实逻辑就是这样,然后我们要重复去比较,所以我们要写一个循环。循环条件就为cur && cur.next都要存在。当cur.next不存在时,说明cur已经走到最后一个节点上了,就不需要再进行比较了。循环结束后返回head就行。

var deleteDuplicates = function (head) {
    let cur = head
    while (cur && cur.next) {
        if (cur.val === cur.next.val) {
            cur.next = cur.next.next
        } else {
            cur = cur.next
        }
    }
    return head
};

4. leet82 删除排序链表中的重复元素 II

我们继续,再来看力扣上的第82题。

原题: 删除排序链表中的重复元素 II

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

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

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

这道题是上一道题的进阶版,上一道题是去重,这道题是只要出现了重复的值就要一起去掉。

这道题的思路其实和上面那道差不多,只不过这次是如果发现了重复的节点,所有重复的节点都要跳过。

这里还有个细节:因为头节点也可能是重复的值,也可能被去除,而要是头节点都被去除了,我们就不可能访问到后面的节点了。所以我们要准备一个虚拟节点dummy,让它成为这个链表的头节点,到时候返回dummy.next就行了。

var deleteDuplicates = function (head) {
    let dummy = new ListNode()
    dummy.next = head

    let cur = dummy
    
    return dummy.next
};

因为此时dummy就是链表的头节点了,我们不对它进行操作,再定义一个变量cur等于dummy,我们去对cur进行操作。

应该怎么做呢?因为此时cur是那个虚拟节点,所以我们要从cur.next开始去操作。我们还是拿着cur.next去和它的子节点进行比较,如果不相等,就挪一位。这里同样要写一个循环,循环条件就是cur.next && cur.next.next存在。

var deleteDuplicates = function (head) {
    let dummy = new ListNode()
    dummy.next = head

    let cur = dummy
    while (cur.next && cur.next.next) {
        if (cur.next.val === cur.next.next.val) {
            
            }
        } else {
            cur = cur.next
        }
    }
    return dummy.next
};

那如果相等呢?逻辑应该怎么写呢?我们就将这个相等的值保存起来,然后从第一个出现重复值的节点开始,将所有等于这个值的节点移除,直到碰到不等于这个值的节点。这样我们就可以跳过出现重复值的节点了。

var deleteDuplicates = function (head) {
    let dummy = new ListNode()
    dummy.next = head

    let cur = dummy
    while (cur.next && cur.next.next) {
        if (cur.next.val === cur.next.next.val) {
            let value = cur.next.val
            while (cur.next && cur.next.val === value) {
                cur.next = cur.next.next
            }
        } else {
            cur = cur.next
        }
    }
    return dummy.next
};

这道题其实和上一道题差不多,就是多了两个步骤。一,要设置一个虚拟节点,防止头节点被破坏;二,如果出现了重复的值,要从这个出现重复值的节点开始,移除所有等于这个重复值的节点,直到碰到不等于重复值的节点。

5. leet206 反转链表

原题:反转链表

我们再来看最后一道题:反转链表。这是一道很经典的面试题。我们一起来看一下。

题目是说:给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

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


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


示例 3:
输入: head = []
输出: []

这道题应该怎么做呢?我们要反转链表,就是要让每个节点的指向都指反,原本是1指向2指向3指向4指向5,就要变成5指向4指向3指向2指向1。假如当前节点是2,我们就要让2指向节点1;假如当前节点是3,我们就要让3指向节点2。规律就是让每一个节点指向它的前一个节点,

同样,我们也不去动头节点,我们准备一个当前节点cur为head。还需要准备一个前节点pre,用来保存当前节点的前一个节点,到时候就要让cur指向pre。当改变了指向后,我们就要让cur和pre都往后挪一位,所以我们还需要准备一个后节点next保存当前节点的下一个值,当改变了指向后,就让cur等于next,让pre等于cur,让next = next.next。

当然开头就要判断一下,如果头节点为空,直接返回头节点。

var reverseList = function (head) {
    if (!head) return head

    let pre = null 
    let cur = head  
    let next = cur.next
    
    cur.next = pre
    pre = cur
    cur = next
    next = next.next
};

因为我们将cur赋值为了头节点,头节点就要指向null,作为反转后的链表的尾节点,所以这里pre初始值为null。然后去执行我们分析好的逻辑,让cur.next = pre,改变了指向后,pre,cur和next都要向后挪一位。这里要重复执行,所以我们要写一个循环。

var reverseList = function (head) {
    if (!head) return head

    let pre = null 
    let cur = head  
    let next = cur.next   

    while (cur) {
        cur.next = pre
        pre = cur
        cur = next
        next = next.next
    return pre
};

循环条件就是当前节点cur要存在,不存在说明已经走到最后一个节点了,就不需要循环了。而此时,会有一个问题,当执行最后一次循环时,cur = next语句就将cur变为不存在了,说明next已经是不存在了,就不需要再next = next.next,因为此时next.next根本没有这个节点,就会报错。所以,在这里,我们要加一个判断:

var reverseList = function (head) {
    if (!head) return head

    let pre = null 
    let cur = head  
    let next = cur.next   

    while (cur) {
        cur.next = pre
        pre = cur
        cur = next
        if (next) {
            next = next.next
        }
    }
    return pre
};

当next存在时再去执行next = next.next,最后一次循环next为null了,就不会执行next = next.next,也就不会报错。最后pre会成为新链表的头节点,返回pre即可。