链表浅析及常用题目详解(链表反转,删除和环形链表等)

链表是物理存储单元上非连续的、非顺序的存储结构,它是由一个个结点,通过指针来联系起来的,其中每个结点包括数据和指针,如下图中单链表的表示。

 

文中统一使用以下单链表的定义:

public class Node {
    //节点next引用
    public Node next;

    //节点的数值域
    public int val;

    public Node() {

    }

    public Node(int val) {
        this.val = val;
    }
}

链表的表示,头结点即表示数据结点,如下表示方法。

头结点使用哨兵节点,如下表示方法。

定义哨兵节点的好处,我们看看如果不定义哨兵节点,在链表尾部添加节点时操作。我们可以看到,对于每一个节点的加入,都需要判断head是否为空,其实只有第一个元素加入列表时这样的判断才是有意义的,后续的元素都是多此一举,还增加了额外的开销,而且不就是往末尾加一个元素吗?咋还有if else?,我们接下来看看有哨兵节点时addNode是怎么样的?

public void addNode(int val) {
        //如果头结点为空,则创建头结点
        if (head == null) {
            head = new Node(val);
        } else {
            //不为空时依次向后找到根节点,在根节点的后面执行插入
            Node tmp = head;
            while (tmp.next != null) {
                tmp = tmp.next;
            }
            tmp.next = new Node(val);
        }
    }

有哨兵节点时。有没有很简单?而且插入时还省去了额外的空判断。后续都基于有哨兵头结点的情况来进行说明。

public class ListNode {
    //头结点,哨兵节点
    Node head = new Node();

    public ListNode() {

    }

    /**
     * 尾插法插入节点
     *
     * @param val
     */
    public void addNode(int val) {
        //找到根节点
        Node tmp = head;
        while (tmp.next != null) {
            tmp = tmp.next;
        }
        tmp.next = new Node(val);
    }
}

接下来我们看下头插法插入节点,即每一次都在头部进行插入。如下图所以,要 插入节点 1。假如新加入的节点定义为newNode

(1)先执行newNode.next = head.next;

            

(2)执行head.next = newNode;

         

综上所述,头插法的代码如下。

/**
     * 头插法
     *
     * @param val
     */
    public void headInsert(int val) {
        //待插入的节点
        Node newNode = new Node(val);
        //code-1
        newNode.next = head.next;
        //code-2
        head.next = newNode;
    }

上述代码中 code-1 和 code-2处的代码能交换位置吗?答案是肯定不行的如果先执行 code-2,即先执行head.next =newNode,会导致后续节点丢失。如下图。所以写链表相关的题目时一定得注意节点丢失的情况。必要时可以借助画图来辅助理解,当图划出来时,问题自然就清晰明了了。

      

删除给定节点。如下图,如果我们要删除 值为 1 的节点(node)。需要找到 待 删除节点的前一个结点(preNode),然后执行preNode.next = node.next  和 node.next = null 即可。

但是有没有更快的办法呢?如果node.next = null,表示是尾结点,那没得办法,只能找到node节点的前一个结点,如果node.next 节点不为空,表示node.next 节点 不是尾结点,那还有更快的办法。分析如下。如果要删除值为 1 的节点 node。

我们可以把node.next.val 的值 设置给node.val,如下图。

然后此时问题就相当于转换为删除node.next节点,此时完全不需要遍历找到 待删除结点node.next 的前一个节点,因为node本就是node.next的前一个结点。经分析代码如下。

/**
     * 删除给定节点
     *
     * @param node 给定的节点,假设节点一定存在于链表中
     */
    public void deleteNode(Node node) {
        //表示node是尾结点
        if (node.next == null) {
            Node tmp = head;
            while (tmp.next != node) {
                tmp = tmp.next;
            }
            // 找到尾结点的前继结点,把尾结点删除
            tmp.next = null;
        } else {
            //获取node的next节点
            Node nodeNext = node.next;

            // 将删除结点的后继结点的值赋给被删除结点
            node.val = nodeNext.val;

            //问题转换为删除nodeNext结点
            node.next = nodeNext.next;

            //删除node.next节点
            nodeNext.next = null;
        }
    }

这种删除的是不是很巧妙?时间复杂度是O(1)

接下来我们看LeetCode上的题目,返回倒数第k个结点,此处假设k是有效的。如下链表。若 k = 2,则返回结点 4 。

如果我们要求正数的第k个结点,那比较好处理,倒数的第k个结点就稍微比较麻烦了。有如下几个方向可以考虑

1、维护一个表示链表节点数量的属性length,则倒数第k,就可以转换为正数第length-k+1。

public class ListNode {
    //表示节点数量
    int length = 0;

    //头结点,哨兵节点
    Node head = new Node();

    public ListNode() {

    }
    //省略部分代码
}

2、遍历两遍,第一遍获取获取链表的节点数量length,然后又转换为和方法一一样的处理办法。

3、那能否一遍搞定?双指针(快慢指针)出场。其实我们可以分析下,如果我们定义一个指针slow指向倒数第k个结点,指针fast指向倒数第一个结点(尾结点),此处我们假设k为2,看下什么情况,如下图。

发现没,slow再走 k-1 步就可以与fast相遇了,那如果slow 和 fast同时指向头结点,如果fast先走 k-1 步,然后slow 和 fast再同时走,当fast走到尾结点时,slow指向的节点即为倒数第k个结点。

(1)首先定义两个节点分别指向第一个有效节点(即排除哨兵节点)

(2)fast节点先走k-1步(此处假设k为2),如下图。

(3)然后slow 和 fast同步走,当fast走到结尾时,slow节点即为倒数第k个结点,如下图。

经上面分析,代码如下。

public Node kthToLast(int k) {
        //fast 和 slow 节点指向第一个结点
        Node fast = head.next;
        Node slow = head.next;
        //节点fast 走 k -1 步
        while (k > 1 && fast != null) {
            fast = fast.next;
            k--;
        }
        //fast 和 slow节点同步走,直到fast节点走到尾结点
        while (fast.next != null) {
            slow = slow.next;
            fast = fast.next;
        }
        return slow;
    }

时间复杂度为O(n),空间复杂度为O(1)。

接下来看下链表中一个比较经典的问题,链表反转。

翻转前:

翻转后

 

其实这个题目本身还是比较好理解的。但是处理起来还是稍微有点点麻烦。但是只要想通逻辑就比较简单了。接下来从递归和迭代两个方面处理这个问题。

1、基于递归的解决方案

我们可以看下,如果除第一个元素外其他都已经翻转了,那直接把指针改一下就好。其实对于后续的每一个元素都是类似的,所以可以定义一个递归函数。

递归函数:

 /**
     * 翻转链表
     *
     * @return
     */
    public void reverseList() {
        Node node = reverseList(head.next);
        head.next = node;
    }

    /**
     * 翻转链表
     *
     * @param node
     * @return
     */
    private Node reverseList(Node node) {
        return null;
    }

递归终止条件:如果只有一个元素,如元素5时,直接返回即可。

最终的代码如下

/**
     * 翻转链表
     *
     * @return
     */
    public void reverseList() {
        Node node = reverseList(head.next);
        head.next = node;
    }

    /**
     * 翻转链表
     *
     * @param node
     * @return
     */
    private Node reverseList(Node node) {
        if (node.next == null) {
            return node;
        }
        Node newHead = reverseList(node.next);
        node.next.next = node;
        //避免形成回路
        node.next = null;
        return newHead;
    }

2、基于迭代的解决方案:还是使用双指针解决。我们可以看下以下链表。

(1)如果执行fast.next = slow 时会出现什么情况?如下图

(2)发现没,fast指针指向的节点与后续的节点断开了,所以我们执行fast.next = slow 时要先使用一个指针nextNode 把 关联关系记录下来,同时需要执行 slow.next = null,否则会形成环,如上图。所以修改后效果如下如。

(3)接着执行slow = fast 和 fast = nextNode,执行后如下

再接着执行(2),直到fast 为空,如下图

此时执行head.next = slow 即可完成整个链表的交换。经上面分析,代码如下,结合代码和图,应该比较好理解:

public void reverseList() {
        Node slow = head.next;
        //说明是空链表
        if (slow == null) return;
        Node fast = head.next.next;

        //需要执行slow.next = null,避免形成环
        slow.next = null;
        while (fast != null) {
            Node nextNode = fast.next;
            //fast指向slow
            fast.next = slow;
            //slow 和 fast 后移
            slow = fast;
            fast = nextNode;
        }
        //最终执行head.next = slow 即可
        head.next = slow;
    }

我们接下来再来看个题目,获取链表的中间节点,如果链表有两个中间节点,即链表个数为偶数时返回第二个节点。如下图所示,第一条链表节点数量为奇数,所以直接返回中间节点3,第二条链表,节点个数为偶数个,中间节点为2 和 3 ,返回第二个即 节点3。

其实这个问题,还是可以使用双指针解决,比较巧妙。如下图,假设一段路程长度为L,有甲 乙 两个人从起点一起出发,假设甲的的速度为 v ,乙的行走速度为 2v,则乙走到终点时,甲刚好走到一半。其实这个题目本身也是类似的,我们可以定义两个节点,一个为slow每次走一步,一个为fast一次走两步,这时fast走到终点时,slow大概就指向中间位置。但是节点个数为奇数时 和 节点个数为偶数时边界条件稍微有点点区别。下面单独分析下。

如果节点个数为奇数时。起始时slow 和 fast同时从起点出发,终止条件是fast.next = null。

如果节点个数为偶数时,起始时slow 和 fast同时从起点出发,当节点走到下面这种情况时,起始还不满足我们的要求,因为有两个中间节点时我们需要的是第二个。

再走一步看看,如下图,其实终止条件就是fast == null

经过上面的分析,代码如下。

public Node middleNode() {
        //起始时slow和fast指向起始节点,此处head是哨兵节点
        Node slow = head.next;
        Node fast = head.next;

        //对应奇偶的终止条件
        while (fast != null && fast.next != null) {
            //slow走一步,fast走两步
            slow = slow.next;
            fast = fast.next.next;
        }
        //最终slow指向的位置就是我们需要的节点
        return slow;
    }

判断链表是否有环,见博文环形链表

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值