数据结构与算法(七)单链表——线性表的链式存储结构

单链表


数据在计算机中的存储形式有两种,顺序存储结构是把数据元素存放在地址连续的存储单元里,我们前面写的线性表、顺序栈、循环队列都是顺序存储结构,基于动态数组实现的,这个结构是占用连续的存储单元,比较浪费空间,把数据元素存放在任意的存储单元里,就可以充分的利用空间,这种存储结构就是链式存储结构。

链式存储结构并不能反映数据元素之间的逻辑关系,因此需要 用一个指针存放数据元素的地址,这样地址就可以通过相关联数据元素的位置,这样链式存储就灵活很多了,数据存在哪里不重要,只要有一个指针存放对应的地址就能找到它了。

为了表示每个数据元素a与其直接后继数据元素b之间的逻辑关系,对数据元素a来说,除了存储其本身的信息之外,还需存储一个指示其后继的信息。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称做指针或链。这两部分信息组成数据元素a的存储映象,称为结点(Node)。
在这里插入图片描述
n个结点(a的存储映象)链结成一个链表,即线性表的链式存储结构,因为此链表的每个结点中只包含一个指针域,所以叫做单链表。
在这里插入图片描述

头结点和头指针

头结点是指链表中的第一个结点,有真实头结点和虚拟头结点之分
真实头结点:其第一个结点用于存储数据
在这里插入图片描述

虚拟头结点:其第一个结点不许存储数据
在这里插入图片描述
顺便说一下,头指针和尾指针都仅仅是一个引用变量,分别是链表头结点和最后一个结点的指针而言。

其实我们发现链表和顺序表和基本操作差不多,只是把元素放入了结点中而言,所以对于单链表还是可以让它实现List接口,重写接口中的抽象方法即可。

下面用Java语言实现线性表链式存储结构,采用虚拟头结点方式,链式存储结构需要结点这个东西,把它单独放在一个类,作为内部类,类中定义结点的两个部分,数据域data和指针域next。链表的一些基本操作都是基于结点实现。

package DS02.动态链表;

import DS01.动态数组.List;

import java.util.Iterator;
//用动态链表实现线性表  链表
    //头插法:像栈
    //尾插法:像队列
    //头插尾插结合

public class LinkedList<E> implements List<E> {
    private Node head;  //链表的头指针
    private Node rear;  //链表的尾指针
    private int size;   //链表的元素个数(结点个数)

    //构造函数
    public LinkedList(){
        head=new Node();
        //刚开始给一个空的结点
        rear=head;      //头尾结点一样,即空表   再加结点头指针不动尾指针动就ok
    }

    //内部类
    class Node{   //链表内部的东西,内部类私有,外部不需要知道结点的存在
        E data;  //数据域      类型由外界决定
        Node next; //指针域
        //构造函数
        Node(){
            this(null,null);
        }
        Node(E data,Node next){
            this.data=data;
            this.next=next;
        }

        @Override
        public String toString() {
            return data.toString();     //由调用者决定,结点的toString
        }
    }

    @Override
    public int getSize() {
        return size;
    }

    @Override
    public boolean isEmpty() {
        return size==0&&head==rear;
    }
}

这里比较重要的是,单链表中插入和删除元素,先对插入元素进行分析,有头插法,尾插法,头插尾插结合还有一般插入,分别分析其过程:

  • 头插法: 先把虚拟头结点的指针域存放的物理地址给新结点的指针域,头结点的指针域指向新结点的地址,就完成了表头插入元素的操作
  • 尾插法:先把新结点的物理地址给尾结点的指针域,新结点的地址给尾指针
  • 头插尾插结合:头插和尾插在链表为空时,即插入第一个元素时实现方法是一样的,后面的元素进入就分头插尾插两方面
  • 一半插入:借助指针p遍历找要插入的位置,把p指针指向结点下一结点的地址给新结点的指针域,再将新结点的地址给p结点的指针域

代码实现如下:(代码中没有头尾结合插入的代码)

//插入元素
    @Override
    public void add(int index, E e) {
        if(index<0||index>size){    //角标越界
            throw new IllegalArgumentException("角标越界");
        }
        //创建新的结点
        Node n=new Node();
        n.data=e;   //结点的数据域存e
        if(isEmpty()){  //空表状态 特殊处理
            head.next=n;
            rear=n;
        }else if(index==0){     //头插法
            n.next=head.next;
            head.next=n;
        }else if(index==size){  //尾插法
            rear.next=n;
            rear=n;
        }else{                  //中间插入
            Node p=head;
            for(int i=0;i<index;i++){
                p=p.next;
            }
            n.next=p.next;    //把p指针指向结点下一结点的地址给新结点的指针域
            p.next=n;   //新结点的地址给p结点的指针域
        }
        size++;    //有效元素+1
    }

    @Override
    public void addFirst(E e) {
        add(0,e);
    }

    @Override
    public void addLast(E e) {
        add(size,e);
    }
    

获取角标对应元素,对一些特殊情况进行判断,链表为空,角标越界时抛异常

 //获取角标对应的元素
    @Override
    public E get(int index) {
        if(isEmpty()){
            throw new IllegalArgumentException("空表");
        }
        if(index<0||index>size){
            throw new IllegalArgumentException("角标越界");
        }
        if(index==0){
            return head.next.data;
        }else if(index==size-1) {
            return rear.data;
        }else{
            Node p=head;    //借助指针p找到index对应结点
            for(int i=0;i<=index;i++){
                p=p.next;
            }
            return p.data;  //返回指针p处结点数据域
        }

    }

    @Override
    public E getFirst() {
        return get(0);
    }

    @Override
    public E getLast() {
        return get(size-1);
    }

修改元素和查看是否包含该元素方法的前提是找到元素,借助指针p遍历链表查找角标index对应元素,实现代码如下:

扫描二维码关注公众号,回复: 8930134 查看本文章
 @Override
    public void set(int index, E e) {
        if(isEmpty()){
            throw new IllegalArgumentException("空表");
        }
        if(index<0||index>size){
            throw new IllegalArgumentException("角标越界");
        }
        Node p=head;
        for(int i=0;i<=index;i++){     //修改元素,先找再改
            p=p.next;
        }
        p.data=e;
    }
	
    @Override
    public boolean contains(E e) {
        return find(e)!=-1;
    }

    @Override
    public int find(E e) {
        Node p=head;
        for(int i=0;i<=size;i++){
            p=p.next;
            if(p.data.equals(e)){   //p指针结点处存放的元素与e比较
                return i;       //返回角标
            }
        }
        return -1;
    }

删除元素和插入元素一样分头删、尾删和中间删,其实三种方式的删除方法一样,都是断了要删除结点与其他结点的联系,让其被回收,下面具体讨论:

  • 头删:创建变量del存放要删除的结点,也就是头指针处结点,把要删除元素结点的元素给变量ret,用于返回。让头结点指针域指向删除元素的下一个,再将del的指针域指向空,断了del和其他结点的联系,del会被回收器回收

  • 尾删:要删除元素,要先找到要删除元素的前一个,将它的指针域指向空,将尾结点指针域指向空,尾结点会被回收器回收

  • 中间删:先将要删除的元素用变量del存放,中间删除也是找要删除结点的前一个,借助指针p遍历寻找,将它的指针域指向del的下一个结点地址,即跳过del,再将del的指针域指向空,del被回收

还有一种特殊情况,就是链表内只有一个元素的情况,把它单独考虑一下,减小时间复杂度,在执行删除后记得让size-1

//删除结点
    @Override
    public E remove(int index) {
        if(isEmpty()){
            throw new IllegalArgumentException("空表");
        }
        if(index<0||index>=size){
            throw new IllegalArgumentException("角标越界");
        }
        E ret=null;
        if(size==1){    //只有一个结点的情况
            ret=rear.data;
            head.next=null;
            rear=head;
        }else if(index==0){     //要删除元素在表头
            Node del=head.next;
            ret=del.data;
            head.next=del.next;
            del.next=null;
            del=null;
        }else if(index==size-1){    //要删除元素在表尾
            Node p=head;
            while(true){
                if(p.next!=rear){
                    p=p.next;
                }else{
                    break;
                }
            }
            ret=p.next.data;
            p.next=null;
            rear=p;
        }else{                  //要删除元素在中间的情况
            Node p=head;
            for(int i=0;i<index;i++){
                p=p.next;
            }
            Node del=p.next;
            ret=del.data;
            p.next=del.next;
            del.next=null;
            del=null;
        }
        size--;           //有效元素-1
        return ret;        //返回被删除的元素
    }

    @Override
    public E removeFirst() {
        return remove(0);
    }

    @Override
        public E removeLast() {
            return remove(size-1);
    }

    @Override
    public void removeElement(E e) {
        int index=find(e);
        if(index!=-1){
            remove(index);
        }else{
            throw new IllegalArgumentException("找不到");
        }
    }

清空链表和以字符串形式打印,方便测试方法的正确性

 @Override
    public void clear() {   //清空表
        head.next=null;
        rear=head;
        size=0;
    }

    //按数组的格式打印
    @Override
    public String toString() {
        StringBuilder sb=new StringBuilder();
        sb.append("LinkedList: "+size+"\n");
        sb.append('[');
        if(isEmpty()){
            sb.append(']');
        }else{
            Node p=head;
            while(true){
                if(p.next!=rear){
                    p=p.next;
                    sb.append(p.data);
                    sb.append(',');
                }else{
                    sb.append(rear.data);
                    sb.append(']');
                    break;
                }
            }
        }
        return sb.toString();
    }

迭代器,写内部类,创建对象LinkedListIterator,目的是可以循环输出链表内的元素,并支持foreach循环。

 //迭代器
    @Override
    public Iterator<E> iterator() {
        return new LinkedListIterator();
    }
    //内部类
    public class LinkedListIterator implements Iterator<E>{
        private Node p=head;

        @Override
        public boolean hasNext() {
            return p.next!=null;
        }

        @Override
        public E next() {
            p=p.next;
            return p.data;
        }
    }

当然的,每写一个方法,都在测试类中做个测试,有错误及时改正,养成一个良好的写代码习惯。

发布了70 篇原创文章 · 获赞 56 · 访问量 1983

猜你喜欢

转载自blog.csdn.net/qq_43624033/article/details/103588794