Java进阶学习——数据结构基础(一)

0.前言

最近在刷LeetCode的,有点吃力,明显是由于自己的数据结构基础不牢固,自己大二上学数据结构的时候就迷迷糊糊的,于是我急需要补一补数据结构的知识.简单复习了一些数据结构的比较靠前的知识,心得分享如下.

1.顺序存储和链式存储

1.1.顺序存储

这一块,我当时学得时候感觉,一听就明白,但一写代码就抓瞎,在我期末突击的时候,慢慢的才理解了这一部分的奥秘.所谓顺序存储就是,占用了一整块连续的内存空间,典型代表就是数组.
在这里插入图片描述
因为内存空间是连续的,假设首个元素的内存地址是a,因此当我想获取到第n个元素的时候,我只需要根据公式a+8*n(因为大多数计算机都是64bit的,64bit=8Byte,因此一个字节是8Byte,这里就用8代替了,n从0开始取),这样的操作的时间复杂度为O(1)(无需遍历,只需要一个行代码就给出结果),因此数组的查找某个元素很方便.
如果需要新增元素,假设在末尾增加,无需移动别的元素,直接添加在后面即可,假设增加在首位,需要把所有的元素都向后移动一位,如果是在中间,只需要移动其后的元素.平均下来,时间复杂度是O(N/2),一般直接取O(n),删除同理.因此顺序存储一般适合于高频查询,低频插入和删除的情况.
特别的,在初学Java的时候会有一个烦恼,数组为啥不能插入,也不能删除某个元素,但可以替换(替换的本质就是查找之后赋值),原因如上.

1.2.链式存储

如果真的需要一个"数组"能实现动态的添加或者删除元素,怎么办?这时候可以用列表(list).
Java中的列表本质是一个接口类,常用的接口实现类有ArrayList和LinkedList.这二者的最大区别就是,前者是顺序存储,后者是链式存储.所谓链式存储,就是各元素之间采用一条锁链连接起来,在逻辑上是连续的,在物理内存上不一定连续,顺序存储相反.而这条锁链就是指针,对于很多没学过C语言的人来说,刚刚接触到指针有点蒙,不过没关系,一点点理解.指针可以理解为一个箭头,从当前节点指向下一个节点,告诉CPU,我的下一位在哪(因为在物理内存上不连续,如果不告诉CPU下一个节点在哪,CPU是不知道的)
在这里插入图片描述
这样链式存储的最小单位,节点就由两个部分组成,一个是数据部分(称为数据域),一个是指针部分(称为指针域),通过指针,链表中的多个节点就连接了起来,可以作为一个整体了.
接下来分析一下链表的增删改查.新增一个节点,只需要把需要插入位置的上一个节点的指针指向需要插入的节点,然后把需要插入的节点的指针指向当前位置的节点.大概就是这样.
在这里插入图片描述
这样的话,插入不需要遍历,只需要改变节点的指针即可.因而时间复杂度O(1),删除同理,把需要删除的节点的上一个指针指向需要删除节点的下一个节点,并把需要删除节点的指针指向null.
在这里插入图片描述
删除的时间也是复杂度O(1).
当然这里是没有计算查找到删除/新增节点的时间的,因为根据下标查找的话,因为没有对应的公式可套用,只能从前往后一个一个的查找.这样查找的时间复杂度就是O(n)了.但链式储存新增的时候,不一定都会采用根据下标一个一个的查找,大多数情况会有迭代器(iterator),时间复杂度不会这么高.
修改的方式和相当于先删除再新增,比较容易理解.
因此链式存储适合高频增删,低频查改

1.3.自己实现一个简单的ArrayList

说是实现一个简单的ArrayList,命名为MyArrayList其实就是实现一个基于顺序存储的列表,不考虑泛型,单纯实现增删改查基本功能.
首先,底层是一个数组array,我们对MyArrayList的操作都是基于对这个数组array的操作,我们还需要一个变量size表示MyArrayList的大小,有人可能会问,既然是基于一个数据array,那size不直接就是array的大小,array.length吗?我需要知道的一点是,数据在初始化的时候需要指定大小,用来向内存申请空间,但这个列表是可以动态增加元素的,所以我们初始化的数据大小肯定是比较大的,如果不够用会扩容,这样array就会有一部分数据是冗余的,但返回的length是包括冗余的,因此我们需要额外定义一个size变量,并且不等于array.length.
先实现最基本的查询功能.

public class MyArrayList {

    //此处需要声明一个数组,作为底层存储
    int[] array = new int[20];
    int size = 0;

    public MyArrayList() {

    }

    // 获取数组的长度
    public int size() {
        return this.size;
    }

    // 数组获取某个索引值
    public int get(int index) {
        return this.array[index];
    }
}

查询实现还是比较简单的,因为数据都存储在array中,因此直接根据传递的下标返回对于的数值即可.
这里如果Java基础不是很牢固的同学可能会有疑问,为非要加一个this.
首先理解一下这个this,this指代当前的实例变量,举个例子.

 MyArrayList myArrayList = new MyArrayList();
 myArrayList.size();

上面一行这是我们常用的实例化一个类的代码,下面是我们获取当前列表的大小的方法,在方法内部执行的时候,this就相当于myArrayList.而因为size变量不是静态(static)的,也就是说每一个size变量对应一个实例变量.但对于本题来说,其实不加this也是可以的,但是为了代码的规范,还是加上比较好.为啥这样是规范,大家常见这种情况

private String id;
 public void setId(String id) {
        this.id = id;
    }

用this.id表示方法体外声明的id,用id表示方法体内的id.

一句题外话,如果是静态的(加了static),那么这个变量就是类所共有,每个实例变量对应的都是同一个静态变量,此时调用就采用类名.变量名调用,具体可自行查找资料.

下一步,我们实现插入,插入分两种情况,第一种是从末尾插入,第二种是在中间插入,末尾插入比较简单,直接赋值给array[size]即可.中间插入就比较麻烦,需要根据插入的位置把其后的元素全部后移一位,再把这个元素插入.后移一位可以通过遍历从列表末尾到当前位置,把当前位置的值赋值给下一位置,注意一定要从后往前,因为如果从前往后就会导致末尾值的丢失,还需要考虑一种情况就是,边界判断,插入的位置不能小于0大于size.
代码如下

// 添加元素在末尾
    public void add(int element) {
        this.array[this.size] = element;
        this.size++;
    }

    // 添加元素在中间
    public void add(int index, int element) {
        if (index >= 0 && index <= size) {
            for (int i = this.size; i > index; i--) {
                array[i] = array[i - 1];
            }
            this.size++;
            array[index] = element;
        }
    }

定义参照上文.自己琢磨一下就很容易理解(上手自己敲一下最好),最后还需要把size+1.
最后还有一个删除,思路和增加差不多,注意问题还是移动位置

 // 删除元素
    public void remove(int index) {
        if (index >= 0 && index < size) {
            for (int i = index; i < size - 1; i++) {
                array[index] = array[index + 1];
            }
            this.size--;
        }
    }

注意size-1.
更新也比较简单,本质就是查找之后替换,就不赘述了.
不过我们可以完善一下代码,比如新增的时候之前新建的array的容量不够了,需要扩容,我们把这个功能也加上,这样就算是一个比较合格的ArrayList了.

public class MyArrayList {

    //此处需要声明一个数组,作为底层存储
    int[] array = new int[20];
    int size = 0;

    public MyArrayList() {

    }
    // 获取数组的长度
    public int size() {
        return this.size;
    }

    // 数组获取某个索引值
    public int get(int index) {
        return this.array[index];
    }

    // 添加元素在末尾
    public void add(int element) {
        //相当于调用传入this.size
        this.add(this.size, element);
    }

    // 添加元素在中间
    public void add(int index, int element) {
        if (index < 0 || index > this.size) {
            return;
        }

        // 支持扩容
        if (this.size > this.array.length) {
            int[] newArr = new int[this.array.length * 2];
            for (int i = 0; i < this.array.length; i++) {
                newArr[i] = array[i];
            }
            array = newArr;
        }
        // 元素依次右移
        for (int i = this.size - 1; i >= index; i--) {
            this.array[i + 1] = this.array[i];
        }
        // 插入元素
        this.array[index] = element;
        // 调整size
        this.size++;
    }

    // 删除元素
    public void remove(int index) {
        if (index < 0 || index > this.size - 1) {
            return;
        }

        // 元素依次左移
        for (int i = index; i < this.size - 1; i++) {
            this.array[i] = this.array[i + 1];
        }
        // 删除最后一个元素
        this.array[this.size - 1] = 0;
        // 调整size
        this.size--;
    }
}

完整的代码如图,看明白了是一回事,建议自己敲一个来实现.

1.4.自己实现一个简单的LinkedList

相对来说,ArrayList还算比较简单,LinkedList就有点难度了.因为LinkedList是由指针和数值组成的,因此我们需要先定义一个有指针和数值组成的节点作为LinkedList的基本单元.

class ListNode {
    //数值
    int value;
    //下一个节点
    ListNode next;

    ListNode(int x) {
        value = x;
    }
}

大概就是这样,默认新建一个节点的时候需要定义数值.
接下来我们想一下一个MyLinkedList的变量应该由哪些?比较容易想到的有,size表示容量,头节点,指向第一个元素,表示MyLinkedList的开始,其实还需要一个尾指针,这只是针对我这一种实现方式的叫法,它也可以是预指针,如果它是预指针,用法就不一样了.为啥需要两个指针呢?针对我的情况来说,尾指针很快的锁定最后一个节点,进而可以不用从头指针遍历就可以直接在后面增加元素,这样很方便,时间复杂度可以控制在O(1).
有了上面的基础,我先直接展示代码,一些关键地方都有注释.可以先自行理解之后再继续往下看.

public class MyLinkedList {
    private ListNode head = new ListNode(0);
    private ListNode rear = head;
    private int size = 0;

    public MyLinkedList() {
    }

    public int size() {
        return this.size;
    }
    //默认增加到最后
    public void add(int value) {
        //新建一个节点
        ListNode node = new ListNode(value);
        //如果插入的是当前链表的第一个节点
        if (head == rear) {
            head.next = node;
            rear = node;
            size++;
            return;
        }
        //不是第一个节点
        rear.next = node;
        rear = node;
        this.size++;
    }

    public int get(int index) {
        ListNode result = head;
        //需要遍历index+1次,因此范围为[0,index]
        for (int i = 0; i <= index; i++) {
            result = result.next;
        }
        return result.value;
    }

    public void remove(int index) {
        //下标判断
        if (index < 0 || index >= size) {
            return;
        }
        ListNode temp = head;
        for (int i = 0; i < index; i++) {
            temp = temp.next;
        }
        temp.next = temp.next.next;
        this.size--;
    }

}

class ListNode {
    //数值
    int value;
    //下一个节点的指针
    ListNode next;

    ListNode(int x) {
        value = x;
    }
}

首先是新增,因为大多数情况下新增都是从后面新增,因此实现了从后新增的方法,如果需要从中间新增,方式和remove方法类似.就不赘述了.需要特别说明的是,因为新建一个MyLinkedList的时候,头节点和尾节点都是在一起的,而且其指针没有指向别的元素(下一个节点为空),因此如果是新增的是第一个的时候,需要把对head.next进行赋值.
查找的时候,因为给的是下标index,因此需要遍历index+1次([0,index])才能获取到对应的节点
删除的时候,需要遍历index次,因为需要获取的时候删除节点的上一个节点,把删除节点的上一个节点的指针指向需要删除节点的下一个节点.
简单的实现思路如上,实际的LinkedList的实现要比这个复杂很多,虽然也是链式存储,但采用了双向链表等实现方式,大幅度减少了查询的时间复杂度,不然不管是从中间删除或者从中间新增都需要花费大量的时候在查找上,就不是很划算了.
另外特别说一下,对LinkedList遍历的时候,建议采用迭代器的形式,而不是由下标递增的形式.

 LinkedList<String> linkedList = new LinkedList<>();
        //迭代器
        for (String s : linkedList) {
        }
        //下标递增
        for (int i = 0; i < linkedList.size(); i++) {
        }

区别如上,原因在于,LinkedList内部存储了每个列表元素在内存中的位置,但并未存储下标和列表元素内存空间之间的映射,毕竟下标和元素在内存中的位置没有什么关系,不如之间存储地址,当需要遍历的时候从地址中一次取值.
而ArrayList,其底层就是数组,下标和元素在内存中的位置满足一个函数关系,查找起来就很快.至于ArrayList对迭代有没有优化我就不清楚了,但我见到的大多数情况对于ArrayList,用下标递增的更多一些.

2.总结

数据结构之前一直是我的一块软肋,初学的时候真的是没学好,其实自己实现一个ArrayList和LinkedList在初学的时候就作为课堂作业让我们完成,但真的是不会只能面向百度编程.后面随着自己使用的加深,逐渐理解了这两种储存结构.于是就自己实现了一下.如果你是初学数据结构的朋友,作为一个过来人告诉你,一定要好好学,你可以学不会,但不能学不会就不学了.这一块很重要,理解了之后在日后的编码过程中会帮助你很多,并且数据结构与算法一直都是考研和工作的重中之重,多研究没坏处.
经过进一步学习,我也逐渐明白了为什么用ArrayList的比较多,首先是它相对数组更灵活,其次在大多数使用情景下,它都比LinkedList优越,毕竟大多数使用列表的人不会总是从中间插入元素,大多数还只是需要从最后插入并经常读取.虽然LinkedList在某些方面比ArrayList优越,但代价是在常用的功能上时间复杂度或者空间复杂度比较高.因此ArrayList成了首选.但这不代表我们不用了解链式存储和LinkedList,后续的数据结构学习中链式存储和LInkedList用处很大.且学且珍惜.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值