继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

4-玩转数据结构-链表

慕虎7371278
关注TA
已关注
手记 1125
粉丝 201
获赞 871

本章我们介绍链表

前面我们已经介绍了动态数组,栈和队列。

5c1341f20001305b10190374.jpg

它们的底层依托静态数组;靠resize解决固定容量问题

链表是我们接触的第一个真正的动态数组。

为什么链表很重要

链表是重点,也是难点。它是最简单动态数据结构;后续我们还会学习更多的,比如二分搜索树,平衡二叉树,红黑树,后面很多的动态数据结构都可以在理解链表的基础上学习。

链表可以让我们更深入的理解引用(C++中指针),内存管理等有更深理解。对于更深入的理解递归有好处,树形中递归必须理解。

链表可以辅助组成其他数据结构。

链表Linked List

数据存储在“节点”(Node)中;

class Node{
  E e;
  Node next;
}

车厢和车厢进行连接,使用next进行连接。

5c1341f30001082c05570137.jpg

最后一个节点的next指向空,说明这个节点是最后一个节点了。优点:真正的动态,不需要处理固定容量的问题

不像数组一下子必须new出来一片空间,需要考虑空间不够用或浪费。链表是你需要多少个数据,就生成多少个节点将他挂接起来,这就是所谓的动态的意思。

缺点: 丧失了随机访问的能力。不能像数组一样,给定一个索引直接拿出对应元素。底层机制中数组开辟的空间在内存中是连续分布的,我们可以直接寻找索引对应的偏移,直接计算出数据所存储的内存地址,直接用O(1)复杂度拿出。链表靠next连接,每个节点存储地址不同,我们只能通过next顺藤摸瓜找到我们要找的元素。

数组最好用于索引有语意的情况。scores[2] 2是学号,身份证号不能做索引;最大的优点:支持快速查询。

我们在编写动态数组,但是其实这类索引没有语义的情况更适合链表。

链表不适合用于索引有语意的情况。最大的优点:动态

什么时候适合使用数组,什么时候适合使用链表。

链表实现

package cn.mtianyan;public class LinkedList<E> {    // private设计,不被用户感知
    private class Node{        public E e;        public Node next; // c++实现时是指针

        public Node(E e, Node next) {            this.e = e;            this.next = next;
        }        public Node(E e) {            this.e = e;            this.next = null;
        }        public Node() {            this(null,null);
        }        @Override
        public String toString() {            return "Node[" +                    "e=" + e +                    ", next=" + next +                    ']';
        }
    }
}

上面是我们对于链表节点的设计。注意private设计,以及Node的成员变量Node

5c1341f30001bea808560216.jpg

应该有一个链表头,声明出LinkedList基本的成员变量。

 private Node head;    private int size;    public LinkedList() {
        head = null;
        size = 0;
    }    public LinkedList(Node head, int size) {        this.head = head;        this.size = size;
    }    /**
     * 从数组创建链表的方法,待完善。
     *
     * @param e
     */
    public LinkedList(E[] e){

    }    /**
     * 获取链表中元素个数
     *
     * @return
     */
    public int getSize(){        return size;
    }    /**
     * 返回链表是否为空
     *
     * @return
     */
    public boolean isEmpty(){        return size == 0;
    }

上面是链表中应该有的成员变量和一些普通方法。

在链表头添加元素是非常方便的,数组在数组尾部添加元素不用挪位会非常方便。数组中有size指向下一个空位置跟踪队尾,链表中有head来标识链表的头部。

node.next = head
head = node

5c1341f30001792809950388.jpg

    public void addFirst(E e){        //        Node node = new Node(e);
        //        node.next = head;
        //        head = node;

        // 上面三行代码的等价实现
        head = new Node(e,head); // 值为e的Node的next是head;head = 这个Node
        size++;
    }

上面有两种等价的实现。

在索引为2的地方添加元素666,要找到之前的节点。关键:找到要添加的节点的前一个节点。前一个节点要特殊处理

5c1341f30001669e08870415.jpg

顺序是很重要的,不能颠倒。否则会丢失原本的prev.next。大多时候顺序可以省下一个old的备份临时变量。

    /**
     * 在链表的index(0-based)位置添加新的元素e
     * 在链表中不是一个常用的操作,练习题用,面试用。
     * @param index
     * @param e
     */
    public void add(int index,E e){        // index可以取到size,在链表末尾空位置添加元素。
        if (index < 0 || index >size){            throw new IllegalArgumentException("Add failed. Illegal index");
        }
        Node prevNode = head;        // 因为有了dummyHead,多遍历一次,遍历index次
        for (int i = 0; i < index-1; i++) {            // 验证。 12 index 1添加,index-1=0一次也不执行,正好是head。符合
            // 验证。 1234 index 2添加,index-1=1 运行一次pre指向head下一个也就是2,符合。
            prevNode = prevNode.next;
        }        //        Node insertNode = new Node(e);
        //        insertNode.next = prevNode.next;
        //        prevNode.next = insertNode;
        prevNode.next = new Node(e,prevNode.next); // 后半截是前两句完成任务

        size++;
    }

链表的添加操作时,要找的是前一个节点。而我们之前定义的头结点因为没有前一个节点,需要进行特殊处理,这样不够优雅。而如果我们往前面加一个虚拟的头结点,则可以将我们现在的头结点和其他节点统一起来。

5c1341f30001024310360219.jpg

    private Node dummyHead;    
    public LinkedList() {
        dummyHead = new  Node(null,null);
        size = 0;
    }

虚拟头结点对用户屏蔽不可见。

    /**
     * 在链表的index(0-based)位置添加新的元素e
     * 在链表中不是一个常用的操作,练习题用,面试用。
     * @param index
     * @param e
     */
    public void add(int index,E e){        // index可以取到size,在链表末尾空位置添加元素。
        if (index < 0 || index >size){            throw new IllegalArgumentException("Add failed. Illegal index");
        }
        Node prevNode = dummyHead;        // 因为有了dummyHead,多遍历一次,遍历index次
        for (int i = 0; i < index; i++) {            // 验证。 12 index 1添加,index-1=0一次也不执行,正好是head。符合
            // 验证。 1234 index 2添加,index-1=1 运行一次pre指向head下一个也就是2,符合。
            prevNode = prevNode.next;
        }        //        Node insertNode = new Node(e);
        //        insertNode.next = prevNode.next;
        //        prevNode.next = insertNode;
        prevNode.next = new Node(e,prevNode.next); // 后半截是前两句完成任务

        size++;
    }    /**
     * 在链表头添加新元素e
     */
    public void addFirst(E e){
        add(0,e);
    }    /**
     *  在链表末尾添加新的元素e
     */
    public void addLast(E e){
        add(size,e);
    }

添加元素操作时,注意指向,以及循环次数的验证。

/**
     *  获得链表的第index(0-based)位置元素
     *  链表中不是常用操作,练习用
     * @param index
     * @return
     */
    public E get(int index){        // index不可以取到size,索引从0开始,最多取到size-1
        if (index < 0 || index >=size){            throw new IllegalArgumentException("Add failed. Illegal index");
        }
        Node cur = dummyHead.next; // 从索引为0元素开始
        // 下面与找index-1个节点保持一致。上面执行了一次。所以从index-1个元素变成了找index个元素。
        for (int i = 0; i < index; i++) {
            cur = cur.next;
        }        return cur.e;

    }    public E getFirst(){        return get(0);
    }    public E getLast(){        return get(size-1);
    }

插入时我们要寻找的是index的前一个位置,而get时,我们要找的就是index的当前位置,因此要多找一次,在for循环不变情况下,从虚拟头结点下一个节点开始遍历。

 /**
     * 修改链表的第index(0-based)个位置的元素为e
     * 在链表中不是一个常用的操作,练习用
     */
    public void set(int index,E e){        // index不可以取到size,索引从0开始,最多取到size-1
        if (index < 0 || index >=size){            throw new IllegalArgumentException("Set failed. Illegal index");
        }
        Node cur = dummyHead.next; // 从索引为0元素开始
        // 下面与找index-1个节点保持一致。上面执行了一次。所以从index-1个元素变成了找index个元素。
        for (int i = 0; i < index; i++) {
            cur = cur.next;
        }
        cur.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.e +"->");//            cur = cur.next;//        }//        res.append("NULL");
        res.append("head: ");        for (Node cur=dummyHead.next;cur !=null;cur=cur.next){
            res.append(cur.e +"->");
        }
        res.append("NULL");        return res.toString();
    }

两种不同的遍历方式是等价的。

package cn.mtianyan;public class Main {    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);
        }
        linkedList.add(2,888);
        System.out.println(linkedList);
    }
}

运行结果:

5c1341f40001b99803540168.jpg

删除元素

删除索引为2位置的元素

5c1341f40001f26210360288.jpg

要找到它之前的元素。

5c1341f40001e3b110260290.jpg

prev.next = delNode.next
delNode.next = null
  • 链表元素删除时常见的错误。

5c1341f40001e71f10120320.jpg

cur 指向cur.next的位置。本质是对于引用概念糊涂,Java中类的对象都是一个引用,理解成一个实际内存的指向。cur = cur.next从原来指的位置,指到下一个位置,但对于链表来说没有发生任何改变。要想改变链表就应该改变节点的next指向。

    /**
     * 删除链表中指定index位置的元素
     * @param index
     * @return
     */
    public E remove(int index){        if (index < 0 || index >=size){            throw new IllegalArgumentException("Set 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);
    }
        linkedList.remove(2);
        System.out.println(linkedList);
        linkedList.removeFirst();
        System.out.println(linkedList);
        linkedList.removeLast();
        System.out.println(linkedList);

运行结果:

20180810045555_HASFiQ_Screenshot.jpeg

链表时间复杂度分析

  • 添加操作:

20180810045751_IDPeNw_Screenshot.jpeg

O(n)是因为往链表尾部添加,要遍历整个链表节点。O(n/2)可以看做操作中间的节点。

  • 删除操作:

20180810045934_LFZZna_Screenshot.jpeg

  • 修改操作:

set(index e)  // O(n/2) = O(n)
  • 查找操作:

20180810050028_jbkdWt_Screenshot.jpeg

get 和 contains 都是O(n/2) find操作是根据元素找index,链表中index没啥用。

20180810050128_29TY9O_Screenshot.jpeg

看起来,链表的增删改查全都是O(n)级别的,比数组看起来差。链表没有索引,无法像数组一样快速访问。

20180810050233_7GaOOo_Screenshot.jpeg

此时我们能利用的方法复杂度都是O(1)了;链表的改进,比数组节省空间。最基础动态数据结构,对二叉树平衡二叉树的学习都能有辅助作用。

链表实现栈

只对链表头进行操作,也就是只能对一端进行操作,很明显是栈。队列是要对两端都进行操作的。链表头作为栈顶。

    Interface Stack<E> implement LinkedListStack<E>    int getSize();    boolean isEmpty();    void push(E e);    E pop();    E peek();

比较两个栈的性能差异。

package cn.mtianyan;public class LinkedListStack<E> implements Stack<E> {    private LinkedList<E> list;    public LinkedListStack() {
        list = new LinkedList<>();
    }    @Override
    public int getSize() {        return list.getSize();
    }    @Override
    public boolean isEmpty() {        return list.isEmpty();
    }    @Override
    public void push(E e) {
        list.addFirst(e);
    }    @Override
    public E pop() {        return list.removeFirst();
    }    @Override
    public E peek() {        return list.getFirst();
    }    @Override
    public String toString() {
        StringBuilder res = new StringBuilder();
        res.append("LinkedList Stack :");
        res.append(list);        return  res.toString();
    }
}
    public static void main(String[] args) {
        LinkedListStack stack = new LinkedListStack();        for (int i = 0; i < 5; i++) {
            stack.push(i);
            System.out.println(stack);
        }

        stack.pop();
        System.out.println(stack);
    }

运行结果:

20180810051417_nNtud2_Screenshot.jpeg

package cn.mtianyan;import java.util.Random;public class mainTwoTest {    // 测试使用stack运行opCount个push和pop操作所需要的时间,单位:秒
    private static double testStack(Stack<Integer> stack, int opCount){        long startTime = System.nanoTime();

        Random random = new Random();        for(int i = 0 ; i < opCount ; i ++)
            stack.push(random.nextInt(Integer.MAX_VALUE));        for(int i = 0 ; i < opCount ; i ++)
            stack.pop();        long endTime = System.nanoTime();        return (endTime - startTime) / 1e9;
    }    public static void main(String[] args) {        int opCount = 100000000;

        ArrayStack<Integer> arrayStack = new ArrayStack<>();        double time1 = testStack(arrayStack, opCount);
        System.out.println("ArrayStack, time: " + time1 + " s");

        LinkedListStack<Integer> linkedListStack = new LinkedListStack<>();        double time2 = testStack(linkedListStack, opCount);
        System.out.println("LinkedListStack, time: " + time2 + " s");        // 其实这个时间比较很复杂,因为LinkedListStack中包含更多的new操作
    }
}

其实这个时间是比较不确定谁大谁小的。

运行结果:

100000000 数据:

20180810052240_UJg4Oc_Screenshot.jpeg

10000000 数据:

20180810052309_flpEgk_Screenshot.jpeg

1000000 数据:

20180810052402_0aM3JR_Screenshot.jpeg

100000 数据:

20180810052421_aVhJAy_Screenshot.jpeg

基本可以看出,数据量小于100万的时候LinkedList比较有优势,数据量大时ArrayList更优。但它们实际是同样级别时间复杂度的,最多相差几倍。

链表实现队列

队列势必会在链表的两端同时操作,一端为O(1)一端为O(n);使用数组时我们也遇到了这个问题,因此我们产生了使用循环队列的方式。

20180810053154_QR6w7O_Screenshot.jpeg

链表中我们为什么对于链表头部的操作都简单一些呢,因为我们有一个标识的head。那么想让尾部也可以操作简单,设置一个tail变量。从两端插入元素都是很容易的。

tail端前一个节点不容易找,得遍历一遍。此时: head添加删除都容易,tail添加容易,删除不易。

因此队列从head端删除元素,从tail端插入元素。head 队首负责出队,tail队尾负责入队。由于没有dummyHead,要注意链表为空的情况

package cn.mtianyan;public class LinkedListQueue<E> implements Queue<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, tail;    private int size;    public LinkedListQueue(){
        head = null;
        tail = null;
        size = 0;
    }    @Override
    public int getSize(){        return size;
    }    @Override
    public boolean isEmpty(){        return size == 0;
    }    @Override
    public void enqueue(E e){      // 如果队尾为空,说明队列是空的。因为tail一直指向最后一个非空节点。
        if(tail == null){
            tail = new Node(e);
            head = tail;
        }        else{            // 使用tail.next把新Node挂载上来。
            tail.next = new Node(e);            // tail后挪
            tail = tail.next;
        }
        size ++;
    }    @Override
    public E dequeue(){        if(isEmpty())            throw new IllegalArgumentException("Cannot dequeue from an empty queue.");

        Node retNode = head;
        head = head.next; // head后移
        retNode.next = null; // 元素置空
        if(head == null) // 如果头结点都没得删了
            tail = null;
        size --;        return retNode.e;
    }    @Override
    public E getFront(){        if(isEmpty())            throw new IllegalArgumentException("Queue is empty.");        return head.e;
    }    @Override
    public String toString(){
        StringBuilder res = new StringBuilder();
        res.append("Queue: front ");

        Node cur = head;        while(cur != null) {
            res.append(cur + "->");
            cur = cur.next;
        }
        res.append("NULL tail");        return res.toString();
    }    public static void main(String[] args){

        LinkedListQueue<Integer> queue = new LinkedListQueue<>();        for(int i = 0 ; i < 5 ; i ++){
            queue.enqueue(i);
            System.out.println(queue);            if(i % 3 == 2){
                queue.dequeue();
                System.out.println(queue);
            }
        }
    }
}

运行结果:

20180810054408_ltE9AU_Screenshot.jpeg

测试性能差异:

package cn.mtianyan;import java.util.Random;public class MainThree {    // 测试使用q运行opCount个enqueueu和dequeue操作所需要的时间,单位:秒
    private static double testQueue(Queue<Integer> q, int opCount){        long startTime = System.nanoTime();

        Random random = new Random();        for(int i = 0 ; i < opCount ; i ++)
            q.enqueue(random.nextInt(Integer.MAX_VALUE));        for(int i = 0 ; i < opCount ; i ++)
            q.dequeue();        long endTime = System.nanoTime();        return (endTime - startTime) / 1000000000.0;
    }    public static void main(String[] args) {        int opCount = 100000;

        ArrayQueue<Integer> arrayQueue = new ArrayQueue<>();        double time1 = testQueue(arrayQueue, opCount);
        System.out.println("ArrayQueue, time: " + time1 + " s");

        LoopQueue<Integer> loopQueue = new LoopQueue<>();        double time2 = testQueue(loopQueue, opCount);
        System.out.println("LoopQueue, time: " + time2 + " s");

        LinkedListQueue<Integer> linkedListQueue = new LinkedListQueue<>();        double time3 = testQueue(linkedListQueue, opCount);
        System.out.println("LinkedListQueue, time: " + time3 + " s");
    }
}

运行结果:

20180810055232_M2zlpx_Screenshot.jpeg



作者:天涯明月笙
链接:https://www.jianshu.com/p/d89021b37d86


打开App,阅读手记
1人推荐
发表评论
随时随地看视频慕课网APP