数据结构 - 链表

链表

github:https://github.com/iguyi/data-structures-and-algorithms
代码持续更新中


概述

链表是一种线性数据结构,在内存上是非连续的,由若干个节点(结点)组成。每个节点都有两个部分:数据域和指针域

链表可以分为 3 类:单向链表、双向链表和有环链表。这三类的链表的数据都是存储在数据域中的,不同之处在于对指针域的处理方式。


单向链表

单向链表结构

单向链表的指针域只存储下一个节点的引用

在单向链表中:

  • 如果节点的引用不被任何节点的指针域存储,则表示这个节点是头结点(单向链表中的第一个节点)。
  • 如果节点中的指针域没有存储任何对象的引用,则表示这个节点是尾节点(单向链表中的最后一个节点)。

如果有了一个单向链表,我们使用时,需要先找到链表的头结点。为了能够找到单向链表的头节点,我们需要一个辅助变量来保存头节点的引用,示意图如下:

注意:这里的辅助变量存储的一定是整个头节点,而不是头结点的数据域或者指针域


由于单向链表中的节点中只存储了下一个节点的引用,因此在遍历单向链表时,只能从头节点向尾节点的方向遍历


实现单向链表

了解了单向链表的结构后,我们来实现一个简单的单向链表。


定义单向链表类

先来定义一个单向链表类 SinglyLinked,因为链表是由一个一个的节点构成的,因此需要在 SinglyLinked 类中创建节点内部类 Node

根据上面的示意图:

  • 我们需要在 SinglyLinked 中定义一个用于存储头节点的辅助变量 headNode
  • 对于每个节点都需要有数据域 data 和指针域,因此要在内部类 Node 中设置数据域指针域相关的变量:
    • 数据域:因为我们不知道将来单向链表具体会用来存储什么类型的数据,因此需要使用泛型(SinglyLinkedNode 都应该使用泛型)。
    • 指针域:单向链表中的指针域只需要用一个变量 next 来存储下一个节点即可。
/**
 * 单向链表
 *
 * @param <T> - 数据域存储的数据类型
 */
class SinglyLinked<T> {
    
    /**
     * 头节点
     */
    private Node<T> headNode;
    
	/**
     * 节点内部类
     *
     * @param <T> - 数据域存储的数据类型
     */
    private static class Node<T> {
        /**
         * 数据域
         */
        private T data;

        /**
         * 指针域 - 始终执行当前节点的下一个节点
         */
        private Node<T> next;

        // 构造方法、setter 和 getter 方法自行添加
    }
}

这里将内部类 Node 定义为private 是因为使用者只需要关心自己存储的数据是什么类型的,而不需要关心如何创建一个合适的节点来存储数据。


向单向链表中添加节点

现在我们来实现向单向链表中添加数据的功能,在 SinglyLinked 中添加 add()


当使用者向单向链表中添加内容时,应该只要给 add()传递需要存储的数据,而不是自己将节点创建好,然后将数据存入节点后传给单向链表对象。因此,add() 方法应该接受的是要存入数据域中的数据,也就是这样定义:add(T data),然后在使用者调用这个方法时,由 add() 创建一个新节点并将 data 存入数据域中。


现在我们来讨论如何将新节点插入单向链表。添加新节点的需要考虑以下情况:

  1. 在单向链表的什么位置插入新节点?这里我们先使用尾插(在链表最后添加)的方式向单向链表中插入新节点。
  2. 如果单向链表为空,我们应该将 headNode指向新节点
  3. 如果单向链表不为空,那么 headNode 不用动,将最后一个节点的next指向新节点。

单向链表的尾插法的示意图如下:

链表为空时:


链表不为空时:


思考:当链表不为空时,我们需要遍历整个单向链表来找到尾节点,再将尾节点的 next 指向新节点吗?

如果这样做的话,插入的时间复杂度将是 O(n),效率很低。我们可以在 SinglyLinked 中添加一个辅助变量,这个辅助变量用于记录当前单向链表中尾节点的位置:

private Node<T> tailNode;

添加了记录尾节点的辅助变量后,向单向链表插入新节点的逻辑要做出一点小改动:

  1. 如果单向链表为空,我们应该将 headNodetailNode都指向新节点
  2. 如果单向链表不为空,那么 headNode 不用动,将 tailNode 指向的节点的 next 指向新节点,再将 tailNode 指向新节点。

这里给出第 2 点的图解:


具体的代码实现如下:

/**
 * 向单向链表的最后添加数据
 *
 * @param data - 实际数据
 */
public void add(T data) {
	// 1. 方法被调用时创建新节点并将数据存入数据域中
    Node<T> node = new Node<>(data);
    if (headNode == null) {  // 链表为空
        headNode = node;
    } else {  // 链表不为空
        tailNode.next = node;
    }
	// 无论链表是否为空, tailNode 都要指向新节点
    tailNode = node;
}

获取链表中的数据

这里使用下标的形式来获取链表中的数据,使用者传入一个 index,然后我们遍历出第 inedx 个节点(头节点对应的 index 为 0),然后将这个节点的数据域返回给使用者。


问题:如果链表中只有 5 个节点,那么 maxIndex = 4,但是使用者传入的 index 大于 4,循环遍历单向链表会出现 NPE,怎么办?难道要每通过次遍历的时候都判断当前节点的 next 是否指向 null,如果是,就抛异常?

其实,我们可以通过在 SinglyLinked 添加一个辅助变量 size 来记录当前单向链表的长度(节点数),如果使用者传入的 index 大于或等于 size,就直接抛异常。

private int size;

此时,需要在 add() 方法的最后添加一行代码

// 链表插入新节点后,链表节点的大小一定要增加的
size++;

获取链表的数据的代码实现如下:

/**
 * 获取链表指定索引的节点中存储的数据
 *
 * @param index - 目标元素的索引位置
 * @return 数据
 */
public T get(int index) {
    return getNode(index).data;
}


/**
 * 获取链表指定索引的节点
 *
 * @param index - 目标元素的索引位置
 * @return 节点
 */
private Node<T> getNode(int index) {
    if (index >= size) {
        throw new IndexOutOfBoundsException();
    }
    Node<T> currentNode = headNode;
    for (int i = 1; i <= index; i++) {
        currentNode = currentNode.next;
    }
    return currentNode;
}

这里之所以不将 getNode() 中的逻辑直接写在 get() 方法中,原因是后面的删除单向链表中也要用到这段代码。


删除单向链表中的节点

假设现在有一个单向链表:


如果需要将 B 节点删除,那么我们只需要将 A 节点的 next 指向 C 节点即可:

C 节点的引用在 B 节点的 next 中可以获取到。

由于 B 节点不被任何内容引用,其会被 GC 回收,不需要 B 节点进行额外的操作。


现在再来看看需要注意的点:

  • 如果被删除节点是头节点时,只需要将 headNode 指向第二个节点即可。
  • 如果当前链表中只有一个节点并要删除这个节点时,只需要将 headNodetailNode 的值都设置为 null 即可。
  • 如果删除节点不是第一个节点,实际要获取到两个变量:**被删除节点 **和 被删除节点的上一个节点。因为我们需要将 **被删除节点的上一个节点 **的 next 指向 被删除节点下一个节点(可以从 **被删除节点 **的 next 得到)。
  • 如果被删除节点是尾节点时,tailNode 要前移,也就是指向被删除节点的上一个节点
  • 成功删除链表节点后,链表大小要更新,即 size 要减一。

这里仍然是通过下标的方式来确定需要删除的节点,具体代码实现如下:

/**
 * 删除链表指定索引的节点并返回节点中存储的数据
 *
 * @param index - 目标节点的索引位置
 * @return 被删除节点中存储的数据
 */
public T remove(int index) {
    Node<T> node = getNode(index);  // 获取被删除节点
    Node<T> lastNode = getNode(index - 1);  // 删除节点的上一个节点
    lastNode.next = node.next;
    if (node == tailNode) {  // 被删除节点是最后一个节点时
        tailNode = lastNode;
    }
    size--;
    return node.data;
}

修改单向链表中的数据

这里有两种方式:
1)一种方式是通过遍历找到对应的节点,然后将目标节点的 data 修改为新的数据**(推荐)**

public void update(int index, T data) {
    if (index >= size) {
        throw new IndexOutOfBoundsException();
    }
    Node current = head;
	while(index-- != 0) {  // 如果 index 减到 0, 就找到对应的节点了
        current = current.next;
    }
    current.data = data;
}

2)另一种方式是创建一个新的节点,然后通过遍历找到对应的节点,然后用新节点替换旧节点。实现过程是先找到对应的节点,将其从链表中删除,然后将新节点插入到对应位置。这种方式这里不做实现。


双向链表

双向链表结构

双向链表与单向链表的区别在于指针域。

在双向链表中,指针域中存在两个指针:

  • 后指针:指向下一个节点。如果后指针指向的是 null,表示当前节点是尾节点。
  • 前指针:指向上一个节点。如果前指针指向的是 null,表示当前节点是头结点。

双向链表结构图:

因为双向链表可以拿到当前节点的上一个节点和下一个节点,因此遍历方式可以从头节点向尾节点遍历,也可以从尾节点向头节点遍历。


实现双向链表

定义双向链表

先来定义一个双向链表类 DoublyLinked,和单向链表一样,需要在 DoublyLinkeded 类中创建节点内部类 Node

根据上面的示意图:

  • 我们需要在 DoublyLinked 中定义一个用于存储头节点的辅助变量 headNode和用于存储尾节点的辅助变量 tailNode
  • 对于每个节点都需要有数据域 data 和指针域,因此要在内部类 Node 中设置数据域指针域相关的变量:
    • 数据域:因为我们不知道将来单向链表具体会用来存储什么类型的数据,因此需要使用泛型(DoublyLinkedNode 都应该使用泛型)。
    • 指针域中需要辅助变量:
      • last 来存储上一个节点;
      • next 来存储下一个节点;
/**
 * 双向链表
 *
 * @param <T> - 数据域存储的数据类型
 */
class DoublyLinked<T> {

    /**
     * 头结点
     */
    private Node<T> headNode;

    /**
     * 尾节点
     */
    private Node<T> tailNode;

    /**
     * 双向链表大小
     */
    private int size;

    /**
     * 节点
     *
     * @param <T> - 数据域存储的数据类型
     */
    private static class Node<T> {
        /**
         * 存储的数据
         */
        private T data;

        /**
         * 上一个节点
         */
        private Node<T> last;

        /**
         * 下一个节点
         */
        private Node<T> next;

        public Node(T data) {
            this.data = data;
        }
    }
}

向双向链表中添加节点

现在我们来实现向双向链表中添加数据的功能,在 DoublyLinked 中添加 add()

和单向链表一样,add() 方法的参数是要存入节点数据域中的数据。

这里我们仍然使用尾插法来向双向链表中添加新节点,具体做法整体上和单向链表的处理方式差不多,双向链表的尾插法的示意图如下:


代码实现

/**
 * 向双向链表尾部添加元素
 *
 * @param data - 实际数据
 */
public void add(T data) {
    Node<T> newNode = new Node<>(data);
    if (headNode == null) {
        headNode = newNode;
    } else {
        tailNode.next = newNode;
        newNode.last = tailNode;
    }
    tailNode = newNode;
    size++;
}

/**
 * 获取链表指定索引的节点
 *
 * @param index - 目标元素的索引位置
 * @return 数据
 */
private Node<T> getNode(int index) {
    if (index >= size) {
        throw new IndexOutOfBoundsException();
    }
    Node<T> target = headNode;
    for (int i = 1; i <= index; i++) {
        target = target.next;
    }
    return target;
}

删除双向链表中的节点

假设有如下双向链表:


现在要删除其第二个节点,那么我们需要将第一个节点的 netx 指向第三个节点,然后将第三个节点的 last 指向第一个节点:


需要注意的点:

  • 当删除的是第一个节点时,我们除了要将 heaNode 指向第二个节点,还要将第二个节点的 last 指向 null
  • 当删除的是最后一个节点时,我们除了要将 tailNode 指向倒数第二个节点,还要将倒数第二个节点的 next 指向 null
  • 由于可以通过当前节点获取到其上一个节点和下一个节点,因此我们没必要刻意记录当前节点的上一个节点,可以等到变量到要删除的节点时,通过 last 获取。

具体代码实现:

/**
 * 删除链表指定索引的节点并返回节点中存储的数据
 *
 * @param index - 目标节点的索引位置
 * @return 被删除节点中存储的数据
 */
public T remove(int index) {
    if (index >= size) {
        throw new IndexOutOfBoundsException();
    }
    Node<T> removeNode = headNode;
    if (headNode == tailNode) {
        headNode = null;
        tailNode = null;
    } else {
        removeNode = getNode(index);
        Node<T> last = removeNode.last;
        last.next = removeNode.next;
        Node<T> next = removeNode.next;
        if (next != null) {
            next.last = removeNode.last;
        }
    }
    size = (size == 0) ? 0 : (size - 1);
    return removeNode.data;
}

获取或者修改双向链表中的数据

做法和单向链表是完全一致的这里不做讨论。


有环链表

有环链表是指链表的 “尾节点” 的 next 指向了当前链表的其他节点或者自己

注意这里的 “尾节点” 是有引号的,因为在有环链表中不存在真正的尾巴。


当尾节点的的 next 指向的是 “头节点” 时,会形成循环链表

对于双向链表而言:

  • “头节点” 的 last 指向 “尾节点”,
  • “尾节点” 的 next 指向 “头节点”。


对于环形链表,我们需要能够代码来判断出一个链表是否是有环的。

这里推荐两道 LeetCode 的题目:

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值