代码随想录算法训练营第三天 | 203.移除链表元素、707.设计链表、206.反转链表

104 阅读12分钟

第二章-链表 part01

今日任务

  • 链表理论基础 203.移除链表元素 707.设计链表 206.反转链表

链表理论基础

了解一下链接基础,以及链表和数组的区别

文章链接:代码随想录 (programmercarl.com)

  • 链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针)

    • 最后一个节点的指针域指向 null(空指针的意思)
    • 链表的入口节点称为链表的头结点也就是 head

单链表

image.png

双链表

  • 单链表中的指针域只能指向节点的下一个节点

    • 双链表:每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点
    • 双链表 既可以向前查询也可以向后查询

image.png

循环链表

  • 链表首尾相连

    • 循环链表可以用来解决约瑟夫环问题

image.png

链表的存储方式

  • 数组是在内存中是连续分布的,但是链表在内存中可不是连续分布的
  • 这个链表起始节点为 2, 终止节点为 7, 各个节点分布在内存的不同地址空间上,通过指针串联在一起

image.png

操作链表

定义链表

public class ListNode {
    // 结点的值
    int val;

    // 下一个结点
    ListNode next;

    // 节点的构造函数(无参)
    public ListNode() {
    }

    // 节点的构造函数(有一个参数)
    public ListNode(int val) {
        this.val = val;
    }

    // 节点的构造函数(有两个参数)
    public ListNode(int val, ListNode next) {
        this.val = val;
        this.next = next;
    }
}

删除节点

  • 删除 D 节点,如图所示:

链表-删除节点

  • 只要将 C 节点的 next 指针 指向 E 节点就可以了

  • D 节点不是依然存留在内存里 -> 只不过是没有在这个链表里而已

    • 所以在 C++ 里最好是再手动释放这个 D 节点,释放这块内存
    • Java 就有自己的内存回收机制,就不用自己手动释放了。

添加节点

  • 如图所示:

链表-添加节点

  • 可以看出链表的增添和删除都是 O(1)操作,也不会影响到其他节点。
  • 但是要注意,要是删除第五个节点,需要从头节点查找到第四个节点通过 next 指针进行删除操作,查找的时间复杂度是 O(n)。

性能分析

  • 对比链表和数组

image.png

  • 数组在定义的时候,长度就是固定的,如果想改动数组的长度,就需要重新定义一个新的数组 -> 查询快
  • 链表的长度可以是不固定的,并且可以动态增删, 适合数据量不固定,频繁增删,较少查询的场景 -> 增删快

203.移除链表元素

建议: 本题最关键是要理解 虚拟头结点的使用技巧,这个对链表题目很重要

题目链接:203. 移除链表元素 - 力扣(LeetCode)

文章讲解/视频讲解:代码随想录 (programmercarl.com)

方法一 - 普通链表删除

  • 头节点: head 移动到下一个的 head (head next)
  • 中间节点: head 给前一个的 next next 给后一个的 head

image.png

链表定义

/**
 * 定义单链表
 * Definition for singly-linked list.
 */
class ListNode {
    // 结点的值
    int val;

    // 下一个结点
    ListNode next;

    // 节点的构造函数(无参)
    public ListNode() {
    }

    // 节点的构造函数(有一个参数)
    public ListNode(int val) {
        this.val = val;
    }

    // 节点的构造函数(有两个参数)
    public ListNode(int val, ListNode next) {
        this.val = val;
        this.next = next;
    }
}

具体实现

Code:

class Solution {
    /**
     * 方法一 - 普通链表删除
     *
     * @param head
     * @param val
     * @return
     */
    public ListNode removeElements(ListNode head, int val) {

        // 1. 头节点: head 移动到下一个的 head (head.next)
        while (head != null && head.val == val) { // head 不能为 null 否则 NPE
            head = head.next;
        }

        // 2. 中间节点: head 给前一个的 next next 给后一个的 head
        // currentNode 临时指针为当前节点 -> head
        ListNode currentNode = head;// 从头结点开始 因为经过上面的操作 头节点一定是空或者不是要删除的点 这样设置可以保证我们从第二个节点开始遍历

        while (currentNode != null) { // 判断 当前节点是不是空
            while (currentNode.next != null && currentNode.next.val == val) { // 若当前节点下一个节点不是空 且下一个节点满足目标值
                currentNode.next = currentNode.next.next; // 将当前节点下个一个节点 -> 下下一个节点
            }
            currentNode = currentNode.next; // 向后移动当前指针
        }

        return head; // 返回头结点
    }
}
  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

对上面代码剖析:

  1. 由于头结点和其他节点判断是否删除的操作不相同

    1. 头结点的值若是目标值 -> 则将当前节点头结点指向下一个节点 head = head.next;​​​
    2. 其他节点的值若是目标值 -> 则将其节点上一个节点 next 指向 其下一个节点 currentNode.next = currentNode.next.next​​​
  2. 头结点处理:​while (head != null && head.val == val)​​​

    1. 判断头结点是不是 null 若是 则不进入循环 ​head != null​​​​

    2. 判断头结点的值是不是 目标值​ head.val == val​​​​

    3. 需要用 while​​ 遍历直到头结点一定是 null 或 不是目标值

      • 若用 if​​​ 这种情况 [1,1,1,1] 当第一次将头结点 head 指向 下一个节点 则新的头节点将无法进行判断
  3. 中间节点处理:

    1. ​ListNode currentNode = head​​ 定义一个临时指针 currentNode​​ 将 head​​ 赋值给 currentNode​​

      • 由于 Java 特性 currentNode​​ 和 head​​ 指向的是同一块内存区域 -> 即两者地址相同

      • 这样在后进行操作时

        • 既保证了 处理当前节点 currentNode​ ​同时就是在处理 head​​ 这个链表
        • 又能保证我们返回时 head​​ 头结点保持不变
      • Debug:[1,2,2,1] 2 -> [1,1]​​ 观察一下更清晰 image.png

    2. ​while (currentNode != null)​​ 判断 当前节点是不是空

      • 配合 currentNode = currentNode.next​​ 向后移动当前指针

      • 实现了 遍历中间节点的每一个节点 并且当 currentNode​​ 是最后一个节点时 currentNode.next​​ 一定是 null 即 currentNode​​ 为 null

        这样配合 循环里的判断语句 恰好实现了退出循环

    3. ​while (currentNode.next != null && currentNode.next.val == val)​​ -> 若当前节点下一个节点不是空 且下一个节点满足目标值

      • 用 while 循环思路 与头结点处理使用 while 原理基本相同

方法二 - 使用虚拟头结点删除

  • 问题:方法一中 逻辑判断是两种方式来完成的 -> 增加了代码量和复杂程度 不够优雅 -> 用虚拟头结点

  • 设置一个虚拟头结点 - 这样原链表的所有节点就都可以按照统一的方式进行移除了

    • 此时要移除这个旧头结点元素 1 -> 统一操作流程了~
  • return 头结点的时候 需要返回 return dummyNode->next;​​ -> 这才是新的头结点

    • head 已经还是指向原来的那个地址 -> 但我们知道新的头结点可能已经不是它了

image.png

Code:

class Solution {
     /**
     * 方法二 - 虚拟头结点删除
     *
     * @param head
     * @param val
     * @return
     */
    public ListNode removeElements(ListNode head, int val) {

        // 定义虚拟头节点
        ListNode dummyNode = new ListNode(Integer.MAX_VALUE, head); // val:任意 next:指向 head
        // 临时指针
        ListNode currentNode = dummyNode;

        while (currentNode != null) { // 判断 当前节点是不是空
            while (currentNode.next != null && currentNode.next.val == val) { // 若当前节点下一个节点不是空 且下一个节点满足目标值
                currentNode.next = currentNode.next.next; // 将当前节点下个一个节点 -> 下下一个节点
            }
            currentNode = currentNode.next; // 向后移动当前指针
        }

        return dummyNode.next; // 返回虚拟节点所指向节点才是真正的头节点
    }
}

方法三 - 递归方式链表删除

  • 据说还有递归的方法 - 以后再补充~挖个坑

707.设计链表

建议: 这是一道考察 链表综合操作的题目,不算容易,可以练一练 使用虚拟头结点

题目链接:707. 设计链表 - 力扣(LeetCode)

文章讲解/视频讲解:programmercarl.com/0707.%E8%AE…

在链表类中实现这些功能:

  • get(index):获取链表中第 index 个节点的值。如果索引无效,则返回-1。

  • addAtHead(val):在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。

  • addAtTail(val):将值为 val 的节点追加到链表的最后一个元素。

  • addAtIndex(index,val):在链表中的第 index 个节点之前添加值为 val 的节点。

    • 如果 index 等于链表的长度,则该节点将附加到链表的末尾。
    • 如果 index 大于链表长度,则不会插入节点。如果 index 小于 0,则在头部插入节点。
  • deleteAtIndex(index):如果索引 index 有效,则删除链表中的第 index 个节点。

image.png

方法一 - 虚拟头结点的方式实现单链表

  • 使用虚拟头结点可以使处理更统一

  • 注意链表长度 size​​ 的的增减

  • 画图理解细节部分

    • 详细解析见代码部分

    class ListNode {

        int val; // 结点的值
        ListNode next;// 下一个结点

        // 节点的构造函数(无参)
        public ListNode() {}

        // 节点的构造函数(有一个参数)
        public ListNode(int val) {this.val = val;}

        // 节点的构造函数(有两个参数)
        public ListNode(int val, ListNode next) {
            this.val = val;
            this.next = next;
        }
    }

    class MyLinkedList {

        int size;//定义链表长度
        ListNode dummyHead;//定义虚拟头节点

        // 无参构造器
        public MyLinkedList() {
            // 初始化
            size = 0;
            dummyHead = new ListNode(0);
        }

        /**
         * 获取索引处值
         *
         * @param index
         * @return
         */
        public int get(int index) {
            if (index < 0 || index > size - 1) {
                return -1;
            }
            ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表
            // 遍历到最后一个结点
            while (index-- != 0) {
                currentNode = currentNode.next;
            }
            return currentNode.next.val; // 注意:由于虚拟节点的存在 这里要返回当前节点的下一个节点 才是我们要找的节点
        }

        /**
         * 插入头结点
         *
         * @param val 新节点的值
         */
        public void addAtHead(int val) {
            ListNode newNode = new ListNode(val); // 新节点

            // 注意顺序:1. 新节点 -> 虚拟节点的下一个节点 2. 虚拟节点 -> 指向新节点
            newNode.next = dummyHead.next; // 新节点指向 虚拟节点的下一个节点
            dummyHead.next = newNode; // 虚拟节点指向 新节点
            size++;
        }

        /**
         * 插入尾节点
         *
         * @param val 新节点的值
         */
        public void addAtTail(int val) {
            ListNode newNode = new ListNode(val); // 新节点
            ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表

            // 遍历到最后一个结点
            while (currentNode.next != null) {
                currentNode = currentNode.next;
            }
            currentNode.next = newNode;
            size++;
        }

        /**
         * 在指定索引处添加节点
         *
         * @param index
         * @param val
         */
        public void addAtIndex(int index, int val) {

            // 添加索引小于等于 0 -> 添加到头部
            if (index <= 0) {
                addAtHead(val);
                return;
            }
            // 添加索引大于 链表长度 -> 直接返回
            if (index > size) {
                return;
            }
            // 添加索引等于 链表长度 -> 添加到尾部
            if (index == size) {
                addAtTail(val);
                return;
            }

            // 中间节点情况
            ListNode newNode = new ListNode(val); // 新节点
            ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表

            while (index-- != 0) {
                currentNode = currentNode.next;
            }
            //注意顺序: 原理同插入头结点
            newNode.next = currentNode.next;
            currentNode.next = newNode;
            size++;
        }

        /**
         * 删除索引处的结点
         * @param index
         */
        public void deleteAtIndex(int index) {
            // 非法索引值
            if (index < 0 || index > size - 1) {
                return;
            }

            ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表
            while (index-- != 0) {
                currentNode = currentNode.next;
            }
            currentNode.next = currentNode.next.next;
            size--;
        }

    }

方法二 - 方法一的优化版

  • 观察需求 在指定节点插入 和 在头或尾插入 本质上是一个逻辑

    • 虽然方法一进行了复用 -> 但依旧不够精炼
    • 方案:将添加头节点和尾节点都算入添加节点一个逻辑!

    class ListNode {

        int val; // 结点的值
        ListNode next;// 下一个结点

        // 节点的构造函数(无参)
        public ListNode() {}

        // 节点的构造函数(有一个参数)
        public ListNode(int val) {this.val = val;}

        // 节点的构造函数(有两个参数)
        public ListNode(int val, ListNode next) {
            this.val = val;
            this.next = next;
        }
    }

    /**
     * 方法二 - 方法一优化版
     */
    class MyLinkedList {

        int size;//定义链表长度
        ListNode dummyHead;//定义虚拟头节点

        // 无参构造器
        public MyLinkedList() {
            // 初始化
            size = 0;
            dummyHead = new ListNode(0);
        }

        /**
         * 获取索引处值
         *
         * @param index
         * @return
         */
        public int get(int index) {
            if (index < 0 || index > size - 1) {
                return -1;
            }
            ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表
            // 遍历到最后一个结点
            while (index-- != 0) {
                currentNode = currentNode.next;
            }
            return currentNode.next.val; // 注意:由于虚拟节点的存在 这里要返回当前节点的下一个节点 才是我们要找的节点
        }

        /**
         * 插入头结点
         *
         * @param val 新节点的值
         */
        public void addAtHead(int val) {
            addAtIndex(0, val);
        }

        /**
         * 插入尾节点
         *
         * @param val 新节点的值
         */
        public void addAtTail(int val) {
            addAtIndex(size, val);
        }

        /**
         * 在指定索引处添加节点
         *
         * @param index
         * @param val
         */
        public void addAtIndex(int index, int val) {

            // 添加索引大于 链表长度 -> 直接返回
            if (index > size) {
                return;
            }

            // 中间节点情况
            ListNode newNode = new ListNode(val); // 新节点
            ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表

            while (index-- != 0) {
                currentNode = currentNode.next;
            }
            //注意顺序: 原理同插入头结点
            newNode.next = currentNode.next;
            currentNode.next = newNode;
            size++;
        }

        /**
         * 删除索引处的结点
         *
         * @param index
         */
        public void deleteAtIndex(int index) {
            // 非法索引值
            if (index < 0 || index > size - 1) {
                return;
            }

            ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表
            while (index-- != 0) {
                currentNode = currentNode.next;
            }
            currentNode.next = currentNode.next.next;
            size--;
        }

    }

方法三 - 双链表实现

  • 以后再补充~

206.反转链表

建议先看我的视频讲解,视频讲解中对 反转链表需要注意的点讲的很清晰了,看完之后大家的疑惑基本都解决了。

题目链接:206. 反转链表 - 力扣(LeetCode)

文章讲解/视频讲解:programmercarl.com/0206.%E7%BF…

image.png

方法一 - 双指针

  • 双指针 -> 一个 currentNode 一个 preNode

    • currentNode 指向 head
    • preNode 指针是 head 的前一个节点 初始化为 Null
  • 临时节点 -> tempNode​ 保存当前节点的下一个节点

    • 因为我们在将 将当前节点指向翻转 - currentNode.next = preNode​ 时
    • 会使 currentNode​ 失去原本的指向节点 -> 用临时节点进行存储
/**
 * 方法一 - 双指针
 */
class Solution {
    public ListNode reverseList(ListNode head) {


        ListNode preNode = null; // 前指针 - 当前节点的前一个节点
        ListNode currentNode = head; // 当前指针 - 当前节点 -> 从头结点开始
        ListNode tempNode; // 临时存储节点

        while (currentNode != null) {

            tempNode = currentNode.next; // 临时存储节点 - 保存当前节点的下一个节点

            currentNode.next = preNode; // 将当前节点指向翻转

            preNode = currentNode; // 前指针 向后移动
            currentNode = tempNode; // 当前指针 向后移动

        }

        return preNode;

    }
}

方法二 - 递归解法

  • 与双指针写法一一对应

    • 代码更加简洁 更加晦涩难懂~ OvO
/**
 * 方法二 - 递归
 */
class Solution {

    public ListNode reverseList(ListNode head) {
        return reverse(null, head);
    }

    public ListNode reverse(ListNode preNode, ListNode currentNode) {
        if (currentNode == null) {
            return preNode;
        }
        ListNode tempNode = currentNode.next; // 临时存储节点 - 保存当前节点的下一个节点
        currentNode.next = preNode; // 将当前节点指向反转
        preNode = currentNode; // 前指针 向后移动
      
        // currentNode = tempNode; // 当前指针 向后移动
        return reverse(preNode, tempNode); // 进行递归~

    }
}
  • 搞定搞定 - 去吃午饭 下午继续下一节~