前端算法之链表

156 阅读10分钟

1.链表

image.png

前置知识-认识链表

问题1:为什么要有链表?他是为了解决什么问题?
我们之前看谍战剧的时候知道,地下工作者都是单向联系,b只能被动的由上级a联系,但是b可以主动联系自己的下级c。其他情况b都不知道。这就是典型的链表结构。 链表主要是为了解决连续存储空间不足的问题。此外,还额外解决了插入和删除的操作复杂度问题。

1.链表中节点元素结构

ListNode = { // 单向链表的元素结构 / 双向链表多一个pre
  val: value,
  next: 下个节点的地址,
  prenext: 如果是双向链表,那么就多一个上个元素的指针
}

2.链表的操作

链表头指针的理解
链表的头指针本质上是一个变量,存放的是链表第一个元素(对象)的地址,比如说: const obj = {a: 1}, 这里的变量obj就是栈存储的变量,值为对象的地址。那么我们使用这个地址本质上就是使用这个对象。因此我们所说的链表的头指针,本质上就是链表第一个元素(类型为对象)的存储地址。同样的,下一个元素的next也是存储的后面元素对象的存储地址值。

2.1 创建链表

// 定义一个链表节点类:初始化的时候传入value值,next为null
class listNodeClass {
  constructor(value) {
    this.val = value
    this.next = null
  }
}
// 基于链表节点类,创建一个链表节点实例
const listNode1 = new listNodeClass(1)
// 此时,这个链表节点的value是1,next指向null,而变量listNode1存储的就是这个链表节点的地址,可看成是头指针head。
const listNode2 = new listNodeClass(2)
// 这个也是单独的链表节点
listNode1.next = listNode2
const head = listNode1 

2.2 遍历链表

循环方式遍历 重要的是理解:current = current.next,不能乱。

293f80368587a60b5cb99ac35a9d27e.png

function printList(head) {
   const currentNode = head // 将第一个链表元素的存储地址值给另一个变量
   while(currentNode !== null) {
     const value = currentNode.value // 当前元素的地址值,所指向的那个元素对象,可以拿到对应node的value。
     currentNode = currentNode.next // 这个变量变成了下一个对象节点的地址值
   }
}

3.链表相关算法题

3.1 合并两个有序链表

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

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

var mergeTwoLists = function(list1, list2) {
  let head = new ListNode(0)
  let current = head
  let a = list1
  let b = list2
  if (a === null && b === null) { current.next = null }
  while (a !== null && b!== null) {
    if (a.val < b.val) {
        current.next = a
        a = a.next
    } else {
        current.next = b
        b = b.next
    }
    current = current.next
  }
  if (a === null) {
    current.next = b
  } else if (b === null) {
    current.next = a
  }
  return head.next
};

3.2 判断链表中是否存在环

链表中存在环就意味着,一个节点的地址走过了两遍,那么就可以将走过的节点地址放在数组中,只要后面遍历的节点地址存在数组中说明之前遍历过,即存在环。 但是这里因为存的是对象的地址,所以使用set存储更好

var hasCycle = function(head) {
    let currentNode = head
    const list = new Set()
    while (currentNode !== null) {
        if (list.has(currentNode)) {
            return true
        }
        list.add(currentNode)
        currentNode = currentNode.next // currnent变量赋值为下一个node的地址

    }
    return false
};

知识点:如何理解Js中的set结构
一般来说,set是和数组很接近的数据结构,打印出来是: Set(元素个数){1,2,3},很像数组,但是功能上比数组更集中,要求更严格,即会自动删除重复的元素,不管你是new Set([1,1,2]),还是通过set.add(1),结果都不会存在多个1,set都会自动去重。

3.2 反转链表1-反转整体

  • 两数反转:我们都知道两个数的反转,是通过额外的一个变量作为中间存储,然后调换位置即可。例如:【1,2】,中间变量a,那么先把要被赋值的那个保存到a中,然后将另一个变量赋值过去,此时就会短暂形成两个数字是一样的情况。这个时候再将变量a保存的数赋值过去,此时就完成了反转。
  • 数组反转:数组的反转和两个数反转的区别是什么?数组的反转在于首尾的反转,且向中间逐渐靠拢.即首尾两个指针两两翻转。
  • 链表反转:没有首尾指针,但是每个元素有向下寻找的指针,那么就可以将从头两个开始反转,然后向后顺序反转。

不管是局部反转还是整体反转,本质上和如何将两个节点反转是一样的。
核心思路
数组的反转:是通过首尾两个指针反转while(start < end)同时向中间移动,每次都通过一个stash变量来中间存储交替。
链表的翻转:不需要这个位置上的移动,而是指针指向变动即可,例如【1->2->3】,翻转为【1<-2<-3】即可,位置不变。基于此,链表的反转是:每次将当前的pre节点指向current节点,转为current节点指向pre节点,直到current到达最后一个节点,后者current值为null则停止反转。

function reverseList(head: ListNode | null): ListNode | null {
    let pre = null
    let current = head
    if (!current) {
        return current
    }
    let stash = null
    while (current) {
        // 1.暂存
        stash = current.next;
        // 2.改变指针翻转
        current.next = pre
        // 3.向前推进
        pre = current // 3.1 先将current赋值给pre
        current = stash // 3.2 再将暂存的赋值给current
    }
    return pre
};

3.3 反转链表2-反转局部

(跳转:leetcode.cn/problems/re…image.png
析:
我们在一开始对链表进行遍历的时候就可以根据left和right找到“四个指针”,并用四个变量记录下来:区间的头指针,区间的尾指针,左断点、右断点。
关键点1: 记录左右两个断点。我们反转了区间链表之后怎么和未反转的部分“相连”。所以我们需要两个变量来记录与区间相连的两个左右断点。原来与反转区间相连的“左断点”和“右断点”。只要将左断点的next指向反转后的“头指针”,将反转后的“尾指针”的next指向右断点,就可以完成相连。
关键点2: 找到区间,并隔断连接处,使其成为完整链表,然后进行完整链表的反转操作。我们执行链表的操作比较擅长的是反转完整链表,所以我们需要将区间与断点割裂,并在头尾两端连接null,那么我们就可以调用反转整个链表的函数。
关键点3: 连接 反转的核心思路是找到要反转的区间(这个区间的头指针和尾指针),然后对这个区间进行反转,反转之后原来的头指针变成了“尾指针”,原来的尾指针变成了“头指针”。并且我们也记录了左右断点,所以连结就是将左断点的next指向尾指针,头指针的next指向右断点

var reverseBetween = function(head, left, right) {
    const vnode = new ListNode(-1);
    vnode.next = head;
    // 先创建一个虚拟节点,指向这个链表,防止left是链表开头这种边界情况,那么pre和left就指向同一个
    // step1: 寻找左断点
    let leftPoint = vnode; // 左断点赋初始值
    for (let index = 1; index < left; index++) {
        leftPoint = leftPoint.next
    }
    // step2: 找到中间链表的头节点
    let part_head = leftPoint.next;
    // step3: 寻找链表的尾节点
    let part_end = part_head;
    for (let index = left; index < right; index++) {
        part_end = part_end.next;
    }
    // step4: 找到右断点
    let rightPoint = part_end.next;
    
    // step5: 断开链表
    leftPoint.next = null;
    part_end.next = null;
    
    // step6:反转区间链表
    reverse(part_head)
    function reverse(head) {
       let pre = null;
       let cur = head;
       while(cur) {
           let next = cur.next;
           cur.next = pre;
           pre = cur;
           cur = next;
       }
       return pre;
    }
    leftPoint.next = part_end;
    part_head.next = rightPoint;
    return vnode.next

};

3.4 反转数组-移动K个位置

我们知道数组的完全反转可以通过首尾指针交替反转即可。
例子如下
输入: nums = [1,2,3,4,5,6,7], k = 3
输出: [5,6,7,1,2,3,4]
解释:
向右轮转 1 步: [7,1,2,3,4,5,6]
向右轮转 2 步: [6,7,1,2,3,4,5]
向右轮转 3 步: [5,6,7,1,2,3,4]

我们可以看出这种相当于平移,尾部的几个数组会被平移到数组的头部,但是即使在头部,跑到头部的数组部分【5,6,7】依然是顺序排列。
主要有以下几种方法:
方法1:O(1)空间复杂度
我们可以将所有的位移k个位置转化为两个步骤: 第一步:将整个数组反转成【7654321】 第二步:因为是位移k个位置,因此可以看成是前k个数,index为【0,k-1】,【765】部分和后面的【k, 6】个对应的数组【54321】组成,那么我们只需要将这两部分数组再次反转为正序即可。 核心在于我通过反转整个数组就可以从正向拆分两个组成部分。

function rotate(nums: number[], k: number): void {
    const reverse = (nums:number[], start, end) => {
        while (start < end) {
            let stash = nums[start]
            nums[start] = nums[end]
            nums[end] = stash
            start = start + 1
            end = end - 1
        }
    }
    const realK = k % nums.length
    reverse(nums, 0, nums.length - 1)
    console.log(111, nums)
    reverse(nums, 0, realK - 1)
    reverse(nums, realK, nums.length - 1)
}


// 该方法基于如下的事实:当我们将数组的元素向右移动 k 次后,尾部 kmodn 个元素会移动至数组头部,其余元素向后移动 kmodn 个位置。

// 该方法为数组的翻转:我们可以先将所有元素翻转,这样尾部的 kmodn 个元素就被移至数组头部,然后我们再翻转 [0,kmodn−1] 区间的元素和 [kmodn,n−1] 区间的元素即能得到最后的答案。

方法二:移动k个位置,相当于从数组的末尾pop执行k次,然后依次在进入unshift数组的开头即可

function rotate(nums: number[], k: number): void {
   for(let i = 0;i<k;i++){
       nums.unshift(nums.pop())
   }
   
};

方法三:通过找出两个组成的数组部分【1234】【567】,然后拼接即可【567】【1234】。

3.删除链表中的节点

image.png image.png

4.删除链表的倒数第 n 个结点

(跳转:leetcode.cn/problems/SL…

image.png

5.两两交换链表中的节点

leetcode.cn/problems/sw…

image.png

6.合并两个有序链表(扩展到合并k个有序链表)

image.png

var deleteNode = function(head, val) {
    let link = head1 = new ListNode()
    link.next = head;
    while(link.next) {
        if (link.next.val === val) {
            link.next = link.next.next;
        } else {
            link = link.next;
        }
    }
    return head1.next;
};
[链表中倒数第k个节点]

leetcode.cn/problems/li…

image.png

2.栈

image.png

1.验证栈序列

image.png

思路:这题本质上是通过一个入栈的最终结果表,能否判断出这个栈在这一过程中(经历入栈和出栈),最终形成的出栈表是否为给定这个表。

例如,我对一个栈执行一系列的入栈出栈操作,最终将这个栈全部出栈,得到的结果是不是给定的出栈结果。 这一系列出栈入栈操作为:入栈1,2,3,4,出栈4,入栈5,出栈5,出栈3,2,1 我们通过栈的特点可以发现,pop这个出栈结果中的第一位一定是最先从pushed中pop的。为什么?因为加入我一开始全部执行入栈操作,还没有执行pop操作,那么第一个执行pop操作的元素,一定是放在poped数组的第一位(这里poped数组是指将出栈的元素push入栈),然后poped第二个元素就是pushed中第二个出栈的。那么我们需要做的就是通过poped的结果来逆向模拟这一系列入栈出栈的过程,最后如果这个pushed栈为空,那么就是一一对应,否则,两个不对应。

var validateStackSequences = function(pushed, popped) {
    let index2 = 0;
    let stack = [];
    for (let index = 0; index < pushed.length; index++) {
        stack.push(pushed[index]);
        while (stack.length > 0 && stack[stack.length - 1] === popped[index2]) {
            stack.pop();
            index2++;
        }
    }
    return stack.length === 0

};