教你手写LinkedList集合

我相信学习过ArrayList的源码的小伙伴对于集合的概念应该不陌生吧。以前是正着来,这次我们反着来。先手写一一个我们理解的LinkedList,再回去观察源码和我们代码之间的区别。如果对于ArrayList集合比较陌生的,可以先去学习一下它的底层源码,我认为对你绝对有帮助
传递门
ArrayList的源码解析





1、ArrayList引发的思考

优点:

  • 查询比较块:直接采用数组下标的方式,时间复杂度为1

缺点:

  • 增删慢:总体上来说,需要每次增删数据时,需要移动修改位置后面所有的数据
  • 某种程度上浪费内存空间:数据足够大时每次的扩容容易出现浪费空间

为了弥补这个集合的缺点,出现了我们今天的主角LinkedList




2、手写LinkedList简易版(单链表)

链表分为:单链表、双链表、循环链表

LinkedList是双向链表,我们只需要对单链表进行修改就能够得到双链表,所以我们先来手写一个单链表结构(像极了大学课程里面的数据结构)

观察LinkedList类的类图

457)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200404205744475.png)]

Serializable和Cloneable为常规的标记型接口,Deque为一个与队列相关的接口,所以就只剩下了AbstractSequentiaList这个类。我们将剩下的一条主线中的类统称为AbstractList(毕竟这些类都是抽象类,都是对上层的一个扩充),接口统称为List(实现了上层的接口,就是对上层接口的一个扩充)。


2.1、顶层的接口类

顶层的接口,一个声明大众方法的接口

package pers.mobian.test01;

public interface List01<E> {

    //ArrayList和LinkedList都继承了这个接口,且实现方式上相同
    //所以我们可以直接在抽象类中实现,增删改查再在具体的集合类中实现
    int size();
    boolean isEmpty();
    boolean contains(E element);


    //由于两种集合的数据结构与不同,所以实现的方式也就不同了
    void add(E element);
    void add(int index, E element);
    E remove(int index);
    E set(int index, E element);
    E get(int index);

    //获取对象的索引
    int indexOf(E element);
    //清空集合
    void clear();
    //打印集合
    String toString();
}

2.2、抽象类

为了减少顶层接口方法的实现代码,使用一个抽象类来提前编写重复方法的方法体(ArrayList类也同样继承这个抽象类,两个集合就都不再需要重写这三个方法)

package pers.mobian.test01;

public abstract class AbstractList01<E> implements List01<E> {

    //集合的长度
    //由于LinkedList与ArrayList结构的不同,所以这里的size既是集合长度又是集合容量
    protected  int size;

    //返回集合的长度
    @Override
    public int size() {
        return size;
    }

    //判空方法
    @Override
    public boolean isEmpty() {
        return size == 0;
    }

    //判断集合中是否包含指定的元素
    @Override
    public boolean contains(E element) {
        //如果找到对象就返回具体数字,数字不等于-1就返回true,即查找成功
        return indexOf(element) != -1;
    }
}

2.3、LinkedList实现类

基本结构:

  • 每一个节点包含两个值:一个是自身元素一个是下一个节点的地址值
  • 链头节点也包含两个值:一个是链表的长度,一个是first节点(用于指向第一个节点的地址值)
  • 最后一个节点只有一个值:由于是最后一个节点, 所以next为null

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BczpWyFq-1586070631463)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405094301989.png)]

1、是链表结构,所以我们需要书写一个节点类,用来存放数据和下一个节点的地址值

//添加一个我们的链头的first节点(它代表集合0号位置的节点地址值)
private Node first;

private static class Node<E> {
    //下一个节点的地址值
    Node<E> next;
    
    //本身节点的数据
    E element;
    
    //节点的全参构造
    public Node(Node<E> next, E element) {
        this.next = next;
        this.element = element;
    }
}

2、在业务代码部分,需要不止一次的根据对应index值获取相应节点的地址值,可以抽取一个公共方法

private Node<E> node(int index) {
    
    //将头节点指向的地址值赋值给x
    Node x = first;
    
    //一直遍历到x我们指定的index处
    for (int i = 0; i < index; i++) {
        x = x.next;
    }
    
    //返回对应index的节点地址值
    return x;
}

3、get() (增删改都需要借助这个方法,所以可以先写)

@Override
//重写接口中的get方法
public E get(int index) {
    //1.对传入的index进行校验
    checkElementIndex(index);
    
    //2.返回对应的element数据
    return node(index).element;
}

private void checkElementIndex(int index) {
    //如果数据不符合规范,抛出异常
    if (!isElementIndex(index)) {
        
        //为了与真实的LinkedList抛出异常相匹配,此处采用这种异常
        throw new IndexOutOfBoundsException(": Index: " + index + ", Size: " + size);
    }
}

private boolean isElementIndex(int index) {
    
    //集合长度 = index最大下标 + 1
    //index值应该为非负数,且要小于集合长度
    return index >= 0 && index < size;

}

4、set() 传入指定的index值以及新的数据内容,并且返回被修改的数据

@Override
public E set(int index, E element) {

    //1.对传入的index进行校验
    checkElementIndex(index);

    //2.获取指定index的对象,并通过对象获取到指定index的原数据内容
    Node<E> node = node(index);
    E oldElement = node.element;

    //3.将传入的新element赋值到对应节点的内容
    node.element = element;
    
    //4.返回原来的值
    return oldElement;
}

private void checkElementIndex(int index) {
    //如果数据不符合规范,抛出异常
    if (!isElementIndex(index)) {
        
        //为了与真实的LinkedList抛出异常相匹配,此处采用这种异常
        throw new IndexOutOfBoundsException(": Index: " + index + ", Size: " + size);
    }
}

private boolean isElementIndex(int index) {
    //集合长度 = index最大下标 + 1
    //index值应该为非负数,且要小于集合长度
    return index >= 0 && index < size;

}

5、add(int index, E element) 添加的节点 (重点)

附有插入四种可能的图示

@Override
public void add(int index, E element) {
    //1.对传入数据的校验(此处的校验不同于之前的get和set)
    checkPositionIndex(index);
    
    if (index == 0) {
        
        //2.创建一个新的节点
        //当链表的first = null时,添加的数据为头节点,所以next也为null
        //如果链表的size != 0时,新添加的节点的next为最开始first指向的地址值
        Node node = new Node(first, element);
        
        //3.将新创建的链表地址值赋值给first,使得能够连接
        first = node;
        size++;

    } else {
        
        //2.拿到想要添加位置的前一个节点
        Node<E> pre = node(index - 1);
        //3.利用前一个节点拿到想要添加位置的下一个节点
        Node<E> next = pre.next;
        //4.创建一个新的节点,然后进行添加
        pre.next = new Node<E>(next, element);
        size++;

    }

}

private void checkPositionIndex(int index) {
    if (!isPositionIndex(index)) {
        throw new IndexOutOfBoundsException(": Index: " + index + ", Size: " + size);
    }
}

//由于增加的节点,可以在第一个位置(first = null),也可以在最后一个位置(index = size)
//传入的index值,应该大于等于0,且小于等于size
private boolean isPositionIndex(int index) {
    return index >= 0 && index <= size;
}

(1)当没有节点是插入

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uGrnNFaG-1586070631465)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405094544592.png)]

(2)有节点时插入在0位置

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QscwDrZO-1586070631467)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405094632298.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jUvriTIm-1586070631469)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405094655063.png)]

(3)有节点时,插入在节点中间

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d2yxDJMh-1586070631470)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405095833567.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6Q8QRVv3-1586070631474)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405095842638.png)]

(4)有节点时插入在节点的末尾

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EFqQBNRt-1586070631476)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405095946739.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3G7h6rdQ-1586070631478)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405100007673.png)]

无参的add方法

@Override
public void add(E element) {
    //直接调用add的含有参数的方法
    add(size, element);
}

6、编写remove方法,返回被删除的值

@Override
public E remove(int index) {
    //1.将链头的first赋值给需要被删除的节点
    Node<E> oldNode = first;
    
    //2.检查传入的index是否符合要求
    checkElementIndex(index);
    if (index == 0) {
        //3.如果是想删除0号位,那就将我们要被删除的节点的下一个节点指向链头
        first = first.next;
    } else {
        
        //3.如果要删除的节点不是0号位,那就获取要被删除节点的上一个节点
        Node<E> pre = node(index - 1);
        
        //4.重新赋值我们的要被删除的节点
        oldNode = pre.next;
        
        //5.将要被删除的节点的next指向上一个节点的next
        pre.next = oldNode.next;
    }
    
    //6.集合大小-1,并返回被删除的节点
    size--;
    return oldNode.element;
}


private void checkElementIndex(int index) {
    //如果数据不符合规范,抛出异常
    if (!isElementIndex(index)) {
        throw new IndexOutOfBoundsException(": Index: " + index + ", Size: " + size);
    }
}
private boolean isElementIndex(int index) {
    //index值应该为非负数,且要小于集合长度
    return index >= 0 && index < size;
}

7、根据element返回对应的index值

@Override
public int indexOf(E element) {
    //1.拿到0号位置的节点
    Node x = first;
    //2.将index置于0号位置
    int index = 0;
    
    //为了防止空指针异常,可以添加一个判断
    if (element == null) {
        //3.遍历整个链表,拿到对应数据的index值,并返回
        for (Node i = x; i != null; i = i.next) {
            if (element == i.element) {
                return index;
            }
            index++;
        }
    } else {
        //3.遍历整个链表,拿到对应数据的index值,并返回
        for (Node i = x; i != null; i = i.next) {
            if (element.equals(i.element)) {
                return index;
            }
            index++;
        }
    }
    
    //如果没有找到,那就返回-1
    return -1;
}

8、清空集合数据

@Override
public void clear() {
    //将集合的长度变为0,链头的first指向null,等待垃圾回收
    size = 0;
    first = null;
}

9、编写toString方法

@Override
public String toString() {
    //1.如果集合长度为0,直接返回
    if (size == 0) {
        return "[]";
    }
    
    //2.为了避免空间的浪费,采用StringBuilder拼接集合数据
    StringBuilder sb = new StringBuilder().append("[");

    //3.获取位置为0的集合元素
    Node<E> x = first;
    
    //4.遍历整个链表,完成拼接
    for (int i = 0; i < size; i++) {
        //根据地址获取对应的元素
        E element = x.element;
        //追加元素
        sb.append(element);

        //如果下一个元素为null,就追加 ] 然后退出
        if (x.next == null) {
            return sb.append("]").toString();
        }
        //如果不是null,就追加", "
        if (x.next != null) {
            sb.append(", ");
        }
        x = x.next;
    }
    
    //5.返回拼接好的字符串
    return sb.toString();
}

到这里,我们已经基本上完成了单项链表基本功能的创建,和大学的数据结构很像,然后就是将我们的单向链表改写成双向链表!!!




3、将单链表改写成双向链表


3.1、概述

双向链表的结构

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DO6neBDC-1586070631480)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405151012015.png)]

变化:

  1. 每一个节点含有三个成员变量:element、下一个节点next、上一个节点pre
  2. 0号位置的节点pre为null,链表末尾节点的next为null
  3. 链头新增一个last节点,用于存放链表末尾节点的地址值
  4. 每一个节点的pre指向前一个节点的地址值

对于传统的单链表形式,访问数据只能从前到后,如果数据在最后一个位置,查找是十分消耗性能的。如果使用了双向链表,在查询之前,先去判断查询的位置靠近头还是靠近尾,然后再进行相关操作,能够减少系统的开销,继而提高查询效率。但是节点新增了pre成员变量,会占用更多的系统资源。所以具体使用哪一种链表,应该根据应用场景决定。


3.2、修改方法的实现类

1、修改每次新建的节点信息

private Node first;
//新增的指向尾部的节点
private Node last;

private static class Node<E> {
    //新增的指向前一个节点的成员变量
    Node<E> pre;
    Node<E> next;
    E element;

    public Node(Node<E> pre, E element, Node<E> next) {
        this.next = next;
        this.element = element;
        this.pre = pre;
    }
}

2、get方法

由于我们的get方法传入的参数是对应的index,所以不需要改变

3、set方法

由于set方法只是找到对应的index,然后在修改element,所以也不需要改变

4、add方法 (重点)

涉及到节点信息的添加,由于节点变化了,所以方法也需要进行相应的修改

附有插入四种可能的图示

@Override
public void add(int index, E element) {
    //1.判断index是否符合规定
    checkPositionIndex(index);

    //2.由于去维护last变量的方法,所以这里进行了一个分类
    if (index == size) {
        //3.处理添加到末尾或者是没有元素的(链表长度为0或者是添加到链表的末尾)
        linkedLast(element);
    } else {
        //3.普通的添加
        linkedBefore(element, node(index));
    }
    size++;
}

private void linkedLast(E element) {
 
	//1.拿到对应的last节点
    Node l = last;
    
	//2.构建node 完成他的指向关系,将原来的last节点中的next修改为新构建的node
    Node<E> newNode = new Node<>(l, element, null);
    
    //3.将链表头部的last进行修改
    last = newNode;
    
    //判断之前的集合size是否为null
    //为null则说明size == 0
    //不为null则说明 size != 0
    if (l == null) {
        //将新的节点指向链头中的first
        first = newNode;
    } else {
        //将新的节点追加在之前节点的后面
        l.next = newNode;
    }
}

private void linkedBefore(E element, Node lastNode) {
    //获取想要添加位置的元素,即成功添加后的后一个位置的节点
    //获取成功添加后元素的前一个节点
    
    //1.获取想要添加的index的对象的前节点
    Node<E> preNode = lastNode.pre;

    //2.创建一个新的节点(pre为前节点,element为数据元素,next为添加数据之后的下一个节点)
    Node<E> newNode = new Node<>(preNode, element, lastNode);
    
    //3.将新节点与它的下一个节点的pre进行连接
    lastNode.pre = newNode;

    //4.判断前节点是否为null,以此来决定新的节点是赋值给first还是上一个节点的next
    if (preNode == null) {
        first = newNode;
    } else {
        preNode.next = newNode;
    }
}

//数据校验
private void checkPositionIndex(int index) {
    if (!isPositionIndex(index)) {
        throw new IndexOutOfBoundsException(": Index: " + index + ", Size: " + size);
    }
}

private boolean isPositionIndex(int index) {
    return index >= 0 && index <= size;
}

无参的add方法不变

@Override
public void add(E element) {
    add(size, element);

}

(1)没有节点时添加节点

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ranKExXv-1586070631483)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405120812818.png)]

(2)往节点中间添加节点

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GseCPePx-1586070631485)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405121056240.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7Jlp4Xy9-1586070631487)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405121112585.png)]

(3)节点末尾添加节点

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-x354ZWlf-1586070631489)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405121319357.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bKRUBHPM-1586070631490)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405121341278.png)]

(4)有节点时,在0位置添加节点

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tLfDtMgt-1586070631492)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405122424359.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JhundtO9-1586070631494)(LinkedList%E6%BA%90%E7%A0%81.assets/image-20200405122441139.png)]

5、remove方法

@Override
public E remove(int index) {
    checkElementIndex(index);
    
    //1.获得要删除的元素node
    Node<E> node = node(index);

    //2.获得node节点的前一个节点
    Node<E> preNode = node.pre;

    //3.获得node节点的后一个节点
    Node<E> nextNode = node.next;


    if (preNode == null) {
        //删除的位置是第一个
        first = nextNode;
        
        //如果集合中只有一个元素,防止出现空指针异常
        if (nextNode == null && preNode == null) {
            first = null;
            last = null;
        } else {

            nextNode.pre = null;
        }

    } else {
        //4.需要改变前一个节点的next
        preNode.next = nextNode;
    }

    if (nextNode == null) {
        //删除的位置是最后一个
        last = preNode;

        //可以不用处理。如果集合中只有一个元素,防止出现空指针异常
        if (nextNode == null && preNode == null) {
            first = null;
            last = null;
        } else {

            preNode.next = null;
        }
    } else {
        //5.需要改变后一个节点的pre
        nextNode.pre = preNode;
    }

    //6.返回被删除的节点
    return node.element;
}

6、clear方法

@Override
public void clear() {

    //由于多出了一个last节点,所以也需要赋值为null
    size = 0;
    first = null;
    last = null;
}

7、toString方法

由于该方法为一个一个遍历数据,不涉及pre所以不需要修改




4、对比源码

这里我就不去对照了,不过我相信你能够完成到这一步,一定阅读源码也没有太大的问题。

源码中还涉及了其他的变量,但是核心的功能,我们手写的双向链表已经能够实现了。




5、并发修改异常

在使用迭代器遍历数据的时候,不能通过链表对数据进行修改。

在迭代器内部,有这样一句expectedModCount = modCount代码,如果链表的修改次数 != 期望的修改次数,就会产生并发修改异常。如果想要在使用迭代器的时候还能对数据进行增删操作,那么就需要使用迭代器对应的增删方法

private class ListItr extends Itr implements ListIterator<E> {
    ListItr(int index) {
        cursor = index;
    }

   ......
    public void set(E e) {
        if (lastRet < 0)
            throw new IllegalStateException();
        checkForComodification();
        try {
            AbstractList.this.set(lastRet, e);
            //如果期望的修改次数和链表的长度不匹配,就会抛出ConcurrentModificationException()
            expectedModCount = modCount;
        } catch (IndexOutOfBoundsException ex) {
            throw new ConcurrentModificationException();
        }
    }

    public void add(E e) {
        checkForComodification();
        try {
            int i = cursor;
            AbstractList.this.add(i, e);
            lastRet = -1;
            cursor = i + 1;
            
            //如果期望的修改次数和链表的长度不匹配,就会抛出ConcurrentModificationException()
            expectedModCount = modCount;
        } catch (IndexOutOfBoundsException ex) {
            throw new ConcurrentModificationException();
        }
    }
}




6、LikedList如何保证多线程的安全

1、使用Collections.synchronizedCollection()锁住集合

package pers.mobian.test01;

import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;

public class LinkedListDemo2 {
    public static void main(String[] args) {
        LinkedList<String> linkedList = new LinkedList<>();
        
        //使用Collections.synchronizedCollection()用来锁住linkedlist集合
        Collection strings = Collections.synchronizedCollection(linkedList);

        //开启一个线程
        new Thread(new Runnable() {
            @Override
            public void run() {
                strings.add("test");
                System.out.println(linkedList);
            }
        }).start();
    }
}

2、使用ConcurrentLinkedQueue类创建一个线程安全的集合

package pers.mobian.test01;

import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;

public class LinkedListDemo2 {
	public static void main(String[] args) {
        
        //使用ConcurrentLinkedQueue()创建一个集合
        ConcurrentLinkedQueue concurrentLinkedQueue = new ConcurrentLinkedQueue();
    	new Thread(new Runnable() {
    		@Override
    		public void run() {
                
                //使用创建的集合添加数据,保证线程安全
    			concurrentLinkedQueue.add("ss");
        		System.out.println(concurrentLinkedQueue);
        	}
    	}).start();
	}
}

学习ArrayList的时候,我是从上到下,一步一步的分析源码。而学习LinkedList的时候,我们是从下往上的学习,个人觉得收获颇丰!!!明白了程序的执行逻辑,手写一下整个集合,对于集合一定有更加深刻的认识,希望你也一样!!!个人觉得,一定要理解增删改查的编写方式,这应该是比较基础的数据结构吧。

发布了45 篇原创文章 · 获赞 17 · 访问量 3687

猜你喜欢

转载自blog.csdn.net/qq_44377709/article/details/105327367