链表知识总结

190 阅读7分钟

一、概念

链表是一个有序的列表,如下图所示

image.png 链表中的每个元素实际上是一个单独的对象,而所有对象都通过每个元素中的引用字段链接在一起。链表有两种类型:单链表和双链表。上面给出的例子是一个单链表,这里有一个双链表的例子:

image.png

二、单链表实战应用

1. 使用带 Head 的头节点实现单链表

场景:学校里的一个班级学生,他们有自己的学号和姓名,用链表来模拟对学生的增删改查。 定义链表节点:

public class StudentNode implements Serializable {
    // 学号
    public Long studentNo;
    // 学生姓名
    public String name;
    // 指向下一个节点
    public StudentNode next;

    public StudentNode(Long studentNo, String name) {
        this.studentNo = studentNo;
        this.name = name;
    }

    @Override
    public String toString() {
        return "{" + "studentNo=" + studentNo + ", name='" + name + ''' + '}';
    }
}

1.1 直接在链表尾部添加学生

image.png 思路:

  • 先创建一个头节点,头节点不存放数据,只用来表示链表的头
  • 通过遍历的方式不断地向链表尾部添加新节点
public class LinkedList implements Serializable {

    // 初始化一个头节点
    private StudentNode head = new StudentNode(0L, "");
    /**
     * 向链表尾部添加新节点
     * @param newNode
     */
    public void add(StudentNode newNode) {
        // 因为链表头节点head不能被改变,所用用临时变量 temp 来辅助遍历链表
        StudentNode temp = head;
        while (true) {
            // 到达链表尾部,结束遍历
            if (temp.next == null) {
                break;
            }
            temp = temp.next;
        }

        // 当跳出while循环后,temp指向了链表的尾部;将尾部节点的next指向新节点即可
        temp.next = newNode;
    }
 }

1.2 根据学号在链表指定位置添加学生

image.png

思路:

  • 通过遍历的方式找到新添加节点的位置,即 temp 指向被添加节点所在位置的前一个位置
  • 新节点.next = temp.next
  • 再将 temp.next = 新节点
public class LinkedList implements Serializable {

    // 初始化一个头节点
    private StudentNode head = new StudentNode(0L, "");

    /**
     * 按学号添加学生
     * @param newNode
     */
    public void addByNo(StudentNode newNode) {
        boolean flag = false; // 标记添加的编号是否已在链表中存在

        StudentNode temp = head;
        while (true) {
            // 说明当前链表是一个空链表
            if (temp.next == null) {
                break;
            }

            if (temp.next.studentNo > newNode.studentNo) {
                // 说明找到了要插入的节点的位置
                break;
            } else if (temp.next.studentNo == newNode.studentNo) {
                flag = true;
                break;
            }
            temp = temp.next;
        }

        if (flag) {
            System.out.printf("编号是 %d 学生已存在\n", newNode.studentNo);
        } else {
            // 新节点插入到 temp 后面
            newNode.next = temp.next;
            temp.next = newNode;
        }
     }

}

1.3 根据学号删除学生节点

image.png 思路:

  • 找到要删除的节点的前一个节点 temp
  • temp.next = temp.next.next
  • 被删除的节点,将不会有其他的引用指向,会被自动回收
public class LinkedList implements Serializable {

    // 初始化一个头节点
    private StudentNode head = new StudentNode(0L, "");  

    /**
     * 删除链表节点
     * @param newNode
     */
    public void deleteByNo(StudentNode newNode) {

        boolean flag = false; // 标记被删除的节点是否存在在链表中

        StudentNode temp = head;
        while (true) {
            if (temp.next == null) {
                break;
            }

            // 说明找到了要删除的节点的位置
            if (temp.next.studentNo == newNode.studentNo) {
                flag = true;
                break;
            }
            temp = temp.next;
        }

        if (flag) {
            temp.next = temp.next.next;
        } else {
            System.out.printf("编号是 %d 的学生不存在\n", newNode.studentNo);
        }
    }
 }

1.4 根据学号修改学生节点

思路:

  • 遍历链表找要修改的节点。注意:这里遍历的开始节点不是头节点,因为头节点的数据域是空的,所以应该从头节点的下一个节点开始遍历比较
  • 找到节点后进行数据域修改
public class LinkedList implements Serializable {
    // 初始化一个头节点
    private StudentNode head = new StudentNode(0L, "");
    
    /**
     * 根据学号修改
     */
    public void updateByNo(StudentNode newNode) {
        if (head.next == null) {
            System.out.println("链表为空!");
            return;
        }

        boolean flag = false;
        // 修改的时候要注意,遍历的其实节点不是头节点,因为头节点是没有数据域的
        StudentNode temp = head.next;
        while (true) {
            if (temp == null) {
                break;
            }

            // 说明找到了要删除的节点的位置
            if (temp.studentNo == newNode.studentNo) {
                flag = true;
                break;
            }
            temp = temp.next;
        }

        if (flag) {
            temp.name = newNode.name;
        } else {
            System.out.printf("编号是 %d 的学生不存在\n", newNode.studentNo);
        }
    } 
}

三、双向链表实战应用

  • 单向链表的只能沿着一个方向查找;但双向链表可以向前查找也可以向后查找。
  • 单链表删除节点时需要依赖辅助节点;而双向链表可以自我删除,不需要依赖辅助节点。

image.png

public class DoubleNode implements Serializable {
    // 学号
    public Long studentNo;
    // 学生姓名
    public String name;
    // 指向下一个节点
    public DoubleNode next;
    // 指向前一个节点
    public DoubleNode pre;

    public DoubleNode(Long studentNo, String name) {
        this.studentNo = studentNo;
        this.name = name;
    }
}

3.1 在链表尾部添加

思路:

  • 找到双向链表的最后的节点
  • 添加节点的核心代码
temp.next = newNode;
newNode.pre = temp;

image.png

public class DoubleLinkedList implements Serializable {

    // 初始化一个头节点
    private DoubleNode head = new DoubleNode(0L, "");

    /**
     * 向链表尾部添加新节点
     */
    public void add(DoubleNode newNode) {
        // 因为链表头节点head不能被改变,所用用临时变量 temp 来辅助遍历链表
        DoubleNode temp = head;
        while (true) {
            // 到达链表尾部,结束遍历
            if (temp.next == null) {
                break;
            }
            temp = temp.next;
        }

        temp.next = newNode;
        newNode.pre = temp;
    }
 }   

3.2 删除链表节点

思路:

  • 双向链表可以自我删除,直接找到要删除的节点 temp
  • 删除节点的核心代码:
//temp.next = temp.next.next; 单链表的删除节点
if (temp.next != null) { // 如果temp是链表的最后一个节点,就不需要执行删除
    temp.pre.next = temp.next;
    temp.next.pre = temp.pre;
}

image.png

public void deleteByNo(DoubleNode newNode) {
    boolean flag = false;

    DoubleNode temp = head.next;
    if (temp == null) {
        System.out.println("空链表无法操作!");
        return;
    }

    while (true) {
        if (temp == null) {
            break;
        }

        if (temp.studentNo == newNode.studentNo) {
            flag = true;
            break;
        }
        temp = temp.next;
    }

    if (flag) {
        //temp.next = temp.next.next; 单链表的删除节点
        if (temp.next != null) { // 如果temp是链表的最后一个节点,就不需要执行删除
            temp.pre.next = temp.next;
            temp.next.pre = temp.pre;
        }
    } else {
        System.out.printf("编号是 %d 的学生不存在\n", newNode.studentNo);
    }
}

3.2 修改链表节点

  • 双向链表的修改节点的逻辑和单链表的修改逻辑一致
public void updateByNo(DoubleNode newNode) {
    if (head.next == null) {
        System.out.println("链表为空!");
        return;
    }

    boolean flag = false;
    // 修改的时候要注意,遍历的其实节点不是头节点,因为头节点是没有数据域的
    DoubleNode temp = head.next;
    while (true) {
        if (temp == null) {
            break;
        }

        // 说明找到了要删除的节点的位置
        if (temp.studentNo == newNode.studentNo) {
            flag = true;
            break;
        }
        temp = temp.next;
    }

    if (flag) {
        temp.name = newNode.name;
    } else {
        System.out.printf("编号是 %d 的学生不存在\n", newNode.studentNo);
    }
}

四、链表面试题举例

1. 查找链表倒数第K个节点

  • 注意:本题中我们的链表是不带头节点!!!在带有头节点的链表中,链表的长度是不包括头节点的

  • 思路:
    (1) 遍历后得到链表元素的个数 size
    (2) 然后再次顺序遍历到链表的第 size - k 个节点即为倒数第 k 个节点

public ListNode getKthFromEnd(ListNode firstNode, int k) {
            if(firstNode == null) {
                return null;
            }

            int size = 0;
            ListNode temp = firstNode;
            while(true) {
                if(null == temp) {
                    break;
                }

                size ++;
                temp = temp.next;
            }

           if(k < 0 || k > size) {
               return null;
           }

           ListNode cur = firstNode;
           for(int i=0;i<size - k;i++) {
                cur=cur.next;
           }
           return cur;
}

2. 删除链表倒数第K个节点

这个题目其实是第一个题目的变种,唯一要注意的是如果要删除的倒数第K个节点刚好是链表的第一个节点的情况

  • 单链表删除节点的核心代码
temp.next = temp.next.next;
  • 本题的代码实现
public ListNode removeNthFromEnd(ListNode head, int k) {
        if(head == null) {
            return null;
        }

        int size = 0;
        ListNode temp = head;
        while(true) {
            if(null == temp) {
                break;
            }
            size ++;
            temp = temp.next;
        }

        if(k < 0 || k > size) {
            return null;
        }

        // 表示要删除的是链表的第一个节点
        if(size==k) {
            return head.next;
        }

        // cur表示被删除的倒数第K个节点的前一个节点
        ListNode cur = head; 
        for(int i=0;i<size - k - 1;i++) {
            cur=cur.next;
        }
        cur.next = cur.next.next;
        return head;
}

3. 单链表反转

其实这道题有两种解题思路:

3.1 利用栈先进先后出的特点来实现反转

其实这个思路也可用来解决 从尾到头打印链表的值 的问题

public ListNode reverseList(ListNode head) {
    if (null == head) {
        return head;
    }
    Stack<ListNode> stack = new Stack();
    while (null != head) {
        stack.push(head);
        head = head.next;
    }
    if (stack.isEmpty()) {
        return null;
    }
    ListNode head1 = stack.pop();
    ListNode newH = head1;
    while (!stack.isEmpty()) {
        head1.next = stack.pop();
        head1 = head1.next;
    }
    head1.next = null;
    return newH;
}

3.2 直接遍历链表的同时进行反转

思路:双指针法

  • prev 指向头节点的前一个位置,curr 指向头节点
  • 反转流程如下图所示 image.png
public ListNode reverseList(ListNode head) {
        if(null == head) {
            return head;
        }

        ListNode prev = null;
        ListNode curr = head;
        while(null != curr) {
            // 保存当前节点的下一个节点
            ListNode temp = curr.next;
            
            // 改变当前节点的next指向
            curr.next = prev;
            
            // 双指针同时往后移动
            prev = curr;
            curr = temp;
        }
        return prev;
}

五、单向循环链表的应用场景

单向循环链表其实就是一个首尾相连的单链表,如下图所示: image.png

5.1 约瑟夫环问题

设编号为 1,2,... n 的 n 个人围坐一圈,约定编号为 k(1<= k <=n) 的人从 1 开始报数,数到 m 的那个人出列,他的下一位又从 1 开始报数,数到 m 的那个人又出列,依次类推,直到所有的人出列为止。那么这些人出列的编号顺序是多少?

image.png

5.2 利用单向循环链表解决约瑟夫环问题

  • 举例说明出列的编号情况如下所示 image.png

如何创建环形链表?

public class JosephusNode implements Serializable {
    
    public int id;               // 编号      
    public JosephusNode next;    // 指向下一个节点

    public JosephusNode(int id) {
        this.id = id;
    }
}

环形链表创建示意图:

image.png

public class CircularLinkedList implements Serializable {

   /**
    * 表示环形链表的第一个节点
    */
   private JosephusNode first = null;

   /**
    * 创建环形链表
    * @param nodeCount 表示要创建的环形链表的节点个数
    */
   public void addNode(int nodeCount) {
       if (nodeCount < 1) {
           System.out.println("环形链表节点数不能小于1");
           return;
       }

       JosephusNode current = null;
       for (int i = 1; i <= nodeCount; i++) {
           JosephusNode temp = new JosephusNode(i);

           // 表示此时创建的是环形链表的第一个节点
           if (i == 1) {
               first = temp;
               first.next = first;
               current = first;
           } else {

               current.next = temp;
               temp.next = first;
               current = temp;
           }
       }
   }
   
}    

遍历环形链表

public class CircularLinkedList implements Serializable {

    /**
     * 表示环形链表的第一个节点
     */
    private JosephusNode first = null;

    public void print() {
        if (null == first) {
            System.out.println("环形链表为空!");
            return;
        }

        // 因为 first 节点不能改变,我们需要辅助变量来进行遍历
        JosephusNode current = first;
        while (true) {
            System.out.printf("当前节点编号:%d\n", current.id);
            if (current.next == first) {
                break;
            }
            current = current.next;
        }
    }

}

实现约瑟夫环问题

image.png

public class CircularLinkedList implements Serializable {

    /**
     * 表示环形链表的第一个节点
     */
    private JosephusNode first = null;
    
    /**
     * 打印约瑟夫问题的出圈的编号顺序
     * @param k   表示从第几个节点开始报数
     * @param m   表示报数报到是m的节点出圈
     * @param n   表示环形链表总共有多少个节点
     */
    public void printJosephusNo(int k, int m, int n) {
        if (n < 1 || m > n || k > n) {
            System.out.println("参数不合法!");
            return;
        }

        if (null == first) {
            System.out.println("环形链表为空!");
            return;
        }

        // 1.先让 prev 指向环形链表的最后一个节点
        JosephusNode prev = first;
        while (true) {
            if (prev.next == first) {
                // 此时说明prev指向的节点就是最后一个节点
                break;
            }
            prev = prev.next;
        }

        // 2.当报数开始前,让 prev 和 first 同时移动 k-1 次。目的是同时让 first 指向开始报数时的第一个节点
        for (int i = 0; i < k - 1; i++) {
            first = first.next;
            prev = prev.next;
        }

        // 3. 找到出圈的那个节点并删除;即让  prev 和 first 同时移动 m-1 次
        while (true) {
            if (prev == first) {
                // 说明此时圈中只有一个节点
                break;
            }
            for (int i = 0; i < m - 1; i++) {
                first = first.next;
                prev = prev.next;
            }
            // 3.1 删除此时 first 指向的节点,该节点就是要出圈的节点
            System.out.printf("节点 %d 出圈\n", first.id);
            first = first.next;
            prev.next = first;
        }
        System.out.printf("节点 %d 出圈\n", prev.id);
    }
}