美文网首页数据结构与算法
玩转数据结构之链表

玩转数据结构之链表

作者: 付凯强 | 来源:发表于2019-02-13 17:35 被阅读216次

    0. 序列

    之前有一篇文章讲解了“动态数组”,以及通过这个“动态数组”实现了栈和队列,而这里的“动态数组”的底层其实依靠的是静态数组,只是靠resize解决固定容量的问题。而今天所要讲解的“链表”数据结构,它是真正的动态数据结构。

    链表也属于线性表(不了解线性表概念的,可点击跳转阅读https://www.jianshu.com/p/efa6a9d3a975),由于其在内存中的空间分配是不连续的,所以它是动态数据结构。

    这篇文章我们讲解链表的添加、查询、遍历、删除操作。

    1. 简介

    链表简介

    ① 链表将数据存储在“节点”(Node)中
    ② e 就是我们存放数据的类型,next 相当于一个指针,指向下一个存储数据的节点
    ③ 节点的next 指针指向null,表明这是最后一个节点。

    2. 特点

    • 优点:
      ① 插入和删除数据相当方便,不需要移动大量数据。
      ② 内存分配发生在运行时期,不需要事先声明可能占用的最大的内存空间,能够充分节省内存。
    • 缺点:
      ① 设计时较为麻烦。
      ② 查找和修改数据必须按顺序找到数据为止,即丧失了随机访问的能力。

    3. 数组和链表的对比

    • 数组
      • 数组最好用于索引有语意的情况。
      • 最大的优点:支持快速查询修改
    • 链表
      • 链表不适合用于索引有语意的情况
      • 最大的优点:支持快速插入和删除

    4. 定义一个链表的节点

    public class LinkedList<E> {
    
        private class Node {
            public E e;
            public Node next;
    
            public Node(E e, Node next) {
                this.e = e;
                this.next = next;
            }
    
            public Node(E e) {
                this(e, null);
            }
    
            public Node() {
                this(null, null);
            }
    
            @Override
            public String toString() {
                return e.toString();
            }
        }
    
        private Node head; // 1. 指向第一个节点
        private int size; // 2. 记录元素个数
    
        public int getSize() {
            return size;
        }
    
        // 3. 初始化链表的时候 链表的头部为null
        public LinkedList() {
            head = null;
            size = 0;
        }
    
        // 4. 返回链表是否为空
        public boolean isEmpty() {
            return size == 0;
        }
    }
    

    ① 代码 1 :定义变量head,指向第一个Node节点
    ② 代码 2 :记录元素的个数
    ③ 代码 3 :初始化链表的时候,链表元素数量为0,头部节点指向null。
    ④ 代码 4 :当元素的个数为0的时候,证明链表是空的。

    5. 添加元素

    • 在链表头添加新的元素e
      如果是数组,向尾部添加元素比较方便,因为是静态的数据结构,在内存中的空间分配是连续的,记录下size,即tail == size,tail指向的位置就是下一个为数组开辟的空间。
      如果是链表,因为我们知道永远知道第一个元素的位置,即head指针所指向的内存空间。所以链表向首部添加元素比较方便,只需要让需要添加的元素的next指针指向原来的第一个元素节点,然后移动下head指针的指向就可以了,如图所示:


      链表首部添加元素
        public void addFirst(E e){
            /**
             *         Node node = new Node(e);
             *         node.next = head;
             *         head = node;
             */
            head = new Node(e,head); // 代码1
            size ++;
        }
    

    代码1相当于注释中的两句话,无非是把元素e传入,并且把next指针指向下一个节点,然后再把这个head指针指向新添加的Node节点。

    • 在链表中间添加元素


      添加元素到链表中间位置

      既然节点之间的链接是通过指针next来链接的,那如果我想添加节点数据为666的数据到节点数据为1和2的节点中间的位置,那么我只需要找到要添加的节点的前一个节点。我们指定这个节点是prev。此时,我们让想插入的节点的指针next指向prev节点指向的节点,然后让prev节点的指针next指向新添加的节点即可。

        // 在链表的index位置添加新的元素e
        // 在链表中不是一个常用的操作,练习用:
        public void add(int index, E e) {
            if (index < 0 || index > size) {
                throw new IllegalArgumentException("Add failed. Illegal index.");
            }
    
            if (index == 0)
                addFirst(e);
            else {
                Node prev = head;
                for (int i = 0; i < index - 1; i++)
                    prev = prev.next;
    
    //            Node node = new Node(e);
    //            node.next = prev.next;
    //            prev.next = node;
                prev.next = new Node(e, prev.next);
                size++;
            }
        }
    

    ① 引入了一个概念索引index,假设我们把第一个节点当做索引0,最后一个节点当作索引size -1 ,这样插入的时候就能和数组一样,插入到我们想要插入的位置。
    ② 这里首先判断index的合法性,最小0,最大size,然后判断下当index == 0 的时候调用addFirst方法即可,插入到链表中间和末尾的时候就可以执行下面的逻辑:找到要插入的位置的前一个节点prev即可。

    • 向链表末尾添加元素
        // 在链表末尾添加新的元素e
        public void addLast(E e) {
            add(size, e);
        }
    

    6. 设立虚拟头节点

    在上一小节我们发现,当插入元素的位置为0的时候,我们需要进行特殊处理,因为我们添加元素的方法是找到要添加元素的位置的前一个节点prev,而索引为0的节点并没有前一个节点,所以这里我们为链表设立一个虚拟头节点。


    虚拟头节点

    我们把这个虚拟头节点称为dummyHead,它存储的元素是null,对调用者来说是没有意义的,但是对逻辑非常有意义,和循环队列浪费一个空间的设计意义相同。下面我们修改下相关代码:

    public class LinkedList<E> {
    
        private class Node {
            public E e;
            public Node next;
    
            public Node(E e, Node next) {
                this.e = e;
                this.next = next;
            }
    
            public Node(E e) {
                this(e, null);
            }
    
            public Node() {
                this(null, null);
            }
    
            @Override
            public String toString() {
                return e.toString();
            }
        }
    
        private Node dummyHead; // 1. 指向第一个节点
        private int size; // 2. 记录元素个数
    
        public int getSize() {
            return size;
        }
    
        // 3. 初始化链表的时候 链表的头部为null
        public LinkedList() {
            dummyHead = new Node(); // 5.链表初始化
            size = 0;
        }
    
        // 4. 返回链表是否为空
        public boolean isEmpty() {
            return size == 0;
        }
    
        // 在链表的index位置添加新的元素e
        // 在链表中不是一个常用的操作,练习用:
        public void add(int index, E e) {
            if (index < 0 || index > size) {
                throw new IllegalArgumentException("Add failed. Illegal index.");
            }
    
            Node prev = dummyHead;
            for (int i = 0; i < index; i++) // 6. 前面添加了一个虚拟头节点,这里index-1 修改为index,即多执行一个循环
                prev = prev.next;
            
            prev.next = new Node(e, prev.next);
            size++;
        }
    
        // 在链表头添加新的元素e
        public void addFirst(E e) {
            add(0, e); // 7. 在链表头添加元素不用单独处理,调用add方法即可
        }
    
        // 在链表末尾添加新的元素e
        public void addLast(E e) {
            add(size, e);
        }
    }
    

    ① 代码1:现在的head节点,为虚拟头节点dummyHead
    ② 代码5:链表初始化的时候,虚拟头节点其存储的元素为null,指针next指向null
    ③ 代码6:由于我们在链表的头添加了一个虚拟头节点,所以当我们遍历的时候,需要多执行一次循环,才能找到prev节点
    ④ 代码7:由于添加了虚拟头节点,我们就不用额外的处理当索引为0的时候,而是可以复用add方法。

    7. 链表的遍历、查询和修改

    • 查询
        // 查询
        public E get(int index) {
            if (index < 0 || index >= size) {
                throw new IllegalArgumentException("Add failed. Illegal index.");
            }
            
            Node cur = dummyHead.next;
            for (int i = 0; i < index ; i++) {
                cur = cur.next;
            }
            return  cur.e;
        }
    

    ① 首先对index索引进行校验,在校验之前我们要明白一件事情:在链表初始化的时候会创建一个虚拟头节点,而这个节点并不会导致size++,只有add了有意义的元素,才会导致size++。假设添加了a、b、c、d、e五个元素,size == 5,而最大索引index == 4,所以这里index不能等于size。
    ② 现在链表中的元素分别是a、b、c、d、e五个元素,当index == 4 的时候,我们只需要遍历4次,即从索引0开始遍历,到索引3即可,就能找到索引为index为4的位置的节点cur。如下图:


    查询.jpg
    • 查询第一个和最后一个元素:
        // 查询第一个元素
        public E getFirst(){
            return get(0);
        }
        
        // 查询最后一个元素
        public E getLast(){
            return get(size - 1);
        }
    
    • 修改元素:
        // 修改
        public void set(int index, E e) {
            if (index < 0 || index >= size) {
                throw new IllegalArgumentException("Add failed. Illegal index.");
            }
    
            Node cur = dummyHead.next;
            for (int i = 0; i < index; i++)
                cur = cur.next;
            cur.e = e;
        }
    
    • 判断链表是否有元素e
        // 判断链表中是否有元素e
        public boolean contains(E e){
    
            Node cur = dummyHead.next;
            while (cur != null){
                if(cur.e.equals(e))
                    return true;
                cur = cur.next;
            }
            return false;
        }
    
    • 遍历:输出链表数据
     @Override
        public String toString() {
            StringBuilder res = new StringBuilder();
    
            Node cur = dummyHead.next;
            while (cur!=null){
                res.append(cur + "->");
                cur = cur.next;
            }
            res.append("Null");
    
            return  res.toString();
        }
    

    8. 删除元素

    删除索引2位置的元素

    如果想要删除索引为2的位置的元素,我们只需要找到这个目标删除节点delNode的上一个节点prev,让prev的next指针指向原来delNode的next指针指向的节点,然后将delNode节点的next指针指向null即可

    • 删除索引index位置的元素
        // 删除元素
        public E remove(int index){
             if (index < 0 || index >= size) {
                throw new IllegalArgumentException("Remove failed. Illegal index.");
            }
    
            Node prev = dummyHead;
            for (int i = 0; i < index ; i++) {
                prev = prev.next;
            }
    
            Node retNode = prev.next;
            prev.next = retNode.next;
            retNode.next = null;
            size--;
    
            return retNode.e;
        }
    
    • 删除链表第一个元素和最后一个元素
        // 从链表中删除第一个元素,返回删除的元素
        public E removeFirst(){
            return remove(0);
        }
        
        // 从链表中删除最后一个元素,返回删除的元素
        public E removeLast(){
            return  remove(size -1);
        }
    

    9. 测试

    public class Test_Linkedlist {
        public static void main(String[] args){
            LinkedList<Integer> linkedList = new LinkedList<>();
            for (int i = 0; i <5 ; i++) {
                linkedList.addFirst(i);
                System.out.println(linkedList.toString());
            }
            linkedList.set(2,666);
            System.out.println(linkedList.toString());
    
            Integer integer = linkedList.get(2);
            System.out.println("索引2的位置的元素为:"+integer);
    
            linkedList.remove(2);
            System.out.println(linkedList.toString());
    
            linkedList.removeFirst();
            System.out.println(linkedList.toString());
    
            linkedList.removeLast();
            System.out.println(linkedList.toString());
        }
    }
    
    0->Null
    1->0->Null
    2->1->0->Null
    3->2->1->0->Null
    4->3->2->1->0->Null
    4->3->666->1->0->Null
    索引2的位置的元素为:666
    4->3->1->0->Null
    3->1->0->Null
    3->1->Null
    

    10. 时间复杂度分析

    • 添加操作:O(n)
      ① addLast(e)---O(n)
      ② addFirst(e)---O(1)
      ③ add(index,e)---O(n)
    • 删除操作:O(n)
      ① removeLast---O(n)
      ② removeFirst---O(1)
      ③ remove(index,e)---O(n)
    • 查找操作:O(n)
      ① get(index)---O(n)
      ② contains(e)---O(n)
    • 修改操作:O(n)
      ① set(index,e)---O(n)
      综上:增删改查都是O(n),总体而言是比数组的性能要差的,所以它适合以下场景的操作:


      链表应用场景

    11. 后续

    如果大家喜欢这篇文章,欢迎点赞!
    如果想看更多 数据结构 方面的文章,欢迎关注!

    相关文章

      网友评论

        本文标题:玩转数据结构之链表

        本文链接:https://www.haomeiwen.com/subject/okhkeqtx.html