数据结构-动态数组(ArrayList)

什么是数据结构?

  • 数据结构就是计算机用来进行存储,组织数据的方式,
    也可以想象成一个容器,用来装载数据。

常见的数据结构有以下三种分类数据结构-动态数组(ArrayList)_第1张图片
而每一种数据结构则根据不同的场合以及不同的需求根据情况选择使用。

可以看到数组是属于线性结构下的一种数据结构。

那什么是线性结构?

线性结构其实就是把每一个需要存储的数据通过像线一样将他们存储起来;如生活中排队打饭,烤的鸡翅,等红灯的车队等数据结构-动态数组(ArrayList)_第2张图片
上面那些图片中的每一个人,每一颗糖葫芦,每一只狗都可以看作是要存储的数据,将他们连成线,存储在线性表中,这就体现了数据结构的定义。

那么再给出线性表的定义:

线性表是具有n相同类型元素有限序列(n>=0)数据结构-动态数组(ArrayList)_第3张图片
除了头节点a1和尾节点an,剩下的每一个元素只有一个前驱节点和一个后继节点。

那动态数组和数组有什么区别呢?我们为什么要使用动态数组?

首先数组也是一种线性结构,但是它在计算机存储的时候,往往都是直接在内存中声明的时候就定义好了自身容量的大小,这就造成了往往后面在使用的时候,容量不够,又需要重新开辟一个更大的数组,将原来的数组内容全部拷贝过去,浪费了大量的时间和资源。
数组的内存图如下
数据结构-动态数组(ArrayList)_第4张图片
动态数组就是可以动态的改变数组大小,如容量不够时会自动扩容(ensureCapacity);容量太大而使用的容量又太少时会自动缩容(trim),解决了数组的缺点,开发更加高效且提高程序的性能。

下面给出动态数组的接口设计,你也可以理解为动态数组可以做什么

数据结构-动态数组(ArrayList)_第5张图片

在Java中,接口相当于定义了一些规范,谁实现这个接口,谁就要遵循这个接口中定义的规范,实现方式可能完全不同,但是大家都有这些规范(行为)

动态数组的设计:

虽然动态数组是一种数据结构,而且是线性存储的,但是在Java中,一切皆对象~。
所以我们也可以将动态数组看成是一个对象那么对象就有它的属性,和行为
行为可以通过实现接口去实现,那么属性呢?动态数组应该具备什么属性
首先能想到的就是动态数组的元素个数(size),我们需要一个size属性来在判断动态数组什么时候需要扩容,什么时候需要缩容,在添加和删除元素的时候也需要根据size属性进行判断;
那么动态数组底层通过什么进行元素的存储?很容易想到,使用数组来进行存储,我们是在原有数组的属性上给它动态的加了一些行为,使得数组可以动态的调整大小,所以动态数组的底层容器就是数组。
同时也需要一个默认容量,在创建动态数组的时候,如果不指定大小,那么我们需要给动态数组一个默认容量的大小(DEFAULT_CAPACITY_SIZE)

那么给出属性和构造方法的代码:

public class ArrayList<E> implements List<E>{  
//我们使用泛型技术来对任何类型的元素都能进行使用
    private E[] elements;  //底层泛型数组
    private int size; //元素个数
    private static final int DEFAULT_CAPACITY = 10; //动态数组默认大小

    public ArrayList(){
        this(DEFAULT_CAPACITY);
    }

    public ArrayList(int capacity){
        capacity = Math.max(capacity,DEFAULT_CAPACITY);
        elements = (E[]) new Object[capacity];
        size = 0;
    }

为了增强我们自己实现的动态数组的健壮性,所以我们需要一些方法来在增删改查的时候判断边界条件,所以我们有一些检查的方法:

	private void outOfBounds(int index){ //元素越界方法
        throw new IndexOutOfBoundsException("Index:"+index+",Size:"+size);
    }

	//当你在查,删,改的操作时,index只能在 [0,size-1]取值
	//无法取到size处,取size处的值取到的只是空值,因此我们要做考虑
    private void rangeCheck(int index){ 
        if(index<0 || index >=size){
            outOfBounds(index);
        }
    }
	//当你在添加的时候,index的取值范围是[0,size]
	//可以在size处添加
    private void rangeCheckForAdd(int index){
        if(index<0 || index >size){
            outOfBounds(index);
        }
    }

实现的一些比较简单的操作

	@Override
    public int size() {  //返回动态数组容量大小
        return size;
    }

    @Override
    public boolean isEmpty() { //判断动态数组是否为空
        return size == 0;
    }

    @Override
    public boolean contains(E element) { 
    	//判断动态数组是否包含某个元素
        return indexOf(element) != ELEMENT_NOT_FOUND;
    }

我在接口中定义了元素找不到的常量值,在上面的contains方法需要,
一般你需要对外提供的常量,在接口中定义即可。

public interface List<E>{
    int ELEMENT_NOT_FOUND = -1; // 常量 元素不存在返回-1

查找元素从左到右第一次出现的位置
该方法使得我们的动态数组可以存放null值

	@Override
    public int indexOf(E element) {
        if(element == null){ //考虑你要查找的元素为null的情况
            for(int i=0;i<size;++i){
                if(elements[i] == null) return i;
            }
        }else{
            for(int i=0;i<size;++i){
                if(element.equals(elements[i])) return i;
            }
        }
        return ELEMENT_NOT_FOUND; //找不到就返回-1
    }

查询和修改操作:

	@Override
    public E get(int index) {
        rangeCheck(index);
        return elements[index]; //直接取值即可
    }

    @Override
    public E set(int index, E element) {
        rangeCheck(index);
        E ret = elements[index]; //拿到修改前的值
        elements[index] = element; //修改值
        return ret;
    }

清空动态数组的操作:

 	@Override
    public void clear() {
    	//如果我们存储的是基本类型,那么下面这个循环就不需要执行
    	//但是我们使用了泛型,那么有可能存储对象类型
    	//所以需要将每一个对象都清空
    	//因为动态数组每一个值都指向一块内存地址,
    	//如果不清空,就会浪费内存空间
        for(int i=0;i<size;++i){
            elements[i] = null;
        }
        size = 0; //修改元素个数

		//如果你的动态数组容量大于默认容量
		//我们可以考虑把它的容量缩小为默认容量,为了避免销毁内存
		//当然你也可以不用
        if(elements!=null && elements.length>DEFAULT_CAPACITY){
            elements = (E[]) new Object[DEFAULT_CAPACITY];
        }
    }

重点:

其实数据结构的操作无非就是增删改查,动态数组中查和改的操作非常简单,需要理解增加和删除的方法,在增加和删除的方法中使用到了扩容和缩容,放到各自方法下面去讲:

增加的操作
看着图会更容易理解:
当index =2, 表明我们需要在index = 2 这个位置添加一个元素
但是此时 index = 2位置已经有其他元素占用了,所以我们需要将该元素移动到其他位置,然后再将我们添加的元素放入2这个位置;
如何移动?向前向后?
肯定向后!!!,向前移动就将前面的元素覆盖了!那为什么向后不会覆盖?
我们看第二个数组,如果我们是从index=2这个位置开始向后覆盖,那么你向后一直覆盖的元素都是33,直到你的size处,所以你整个数组的元素都被错误的覆盖。
现在看第一个数组,我们需要从数组的最后一个元素55开始,将这个元素向后移动一个,移动到6这个位置上,然后在用前面的元素44来替代你刚才移动的元素55,以此迭代,直到index处;
一定要明白你要覆盖的元素在你上一步都被保存了!!!
数据结构-动态数组(ArrayList)_第6张图片
代码:

	@Override
    public void add(E element) { 
    	//往动态数组最后添加元素,理解了下面的方法即可
        add(size,element);
    }

	//在指定索引index处,添加指定的元素
    @Override
    public void add(int index, E element) {
        rangeCheckForAdd(index); //安全性检查

        ensureCapacity(size + 1); //动态扩容,下面会将
	
		//1.这段代码是我上面所形容的
		for(int i =size - 1;i>=index;--i){
			elements[i+1] = elements[i];
		}
		
		//2.这段代码是优化的
		//注意:int i = size - 1 和 int i = size 
        for(int i = size;i>index;--i){
            elements[i] = elements[i-1];
        }
        
        elements[index] = element; //此时将元素放入即可
        size++;
    }

理解了添加,我们来谈一下扩容操作,这也是动态数组的精华所在:
数据结构-动态数组(ArrayList)_第7张图片
在每次添加之前都需要判断动态数组是否满了,是否需要扩容;
判断条件就是当你添加元素时,添加后的(size+1)这个容量,和当前动态数组的容量是否能装的下,足够那么就不需要扩容;如果不够,就自动扩容为原来的1.5倍(别问我为啥1.5倍,科学家说的~~)

	// 扩容的方法
	// capacity == size + 1
    private void ensureCapacity(int capacity){
        int oldCapacity = elements.length; //拿到原数组容量
        if(oldCapacity >= capacity) return; //判断条件

		//扩容为原来的1.5倍 位运算 >> 右移你可以理解为除2
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        //创建一个新的扩容后的动态数组
        E[] newElements = (E[]) new Object[newCapacity];
        for(int i=0;i<size;++i){
        	//将原来的数组的每一个值移动到新的扩容后的数组
            newElements[i] = elements[i];
        }
        //在让原动态数组指向这个新数组
        elements = newElements;

        System.out.println(oldCapacity+"扩容为:"+newElements);
    }

删除的操作:
跟添加相反,我们需要将要删除位置的元素进行覆盖,就达到了删除效果
如下图:index等于3,我们要删除的元素在3这个索引处,所以我们需要从4位置开始,将每一个元素向前移动一个,也就是覆盖操作,直到size-1,最后一个元素,最后一个元素由于上一次已经向前移动覆盖了,所以我们只需要将最后一个元素指向null即可
数据结构-动态数组(ArrayList)_第8张图片

	@Override
    public E remove(int index) {
        rangeCheck(index); //安全检查
        E ret = elements[index]; //返回需要删除的元素
        //从目标index的下一个开始,直到size-1
        for(int i=index+1;i<size;++i){
        	//元素向前覆盖
            elements[i-1] = elements[i];
        }
        //将最后一个元素指向null,同时也将size--
        elements[--size] = null;

        trim(); //缩容操作在下面讲 
        return ret;
    }

缩容的操作:
缩容你可以实现,也可以不实现,具体看实际需求;
判断的条件:当你动态数组的元素个数小于动态数组容量的一半
就进行缩容操作;缩容为原来的一半,也别问我为啥,也是科学家说的
数据结构-动态数组(ArrayList)_第9张图片
缩容的代码:

	//缩容
    private void trim() {
    	//拿到现在动态数组的容量
        int oldCapacity = elements.length;
        //拿到缩容后的容量
        int newCapacity = oldCapacity >> 1;
        //判断条件
        if(size > newCapacity) return;

		//操作和扩容差不多
        E[] newElements = (E[]) new Object[newCapacity];
        for(int i=0;i<size;++i){
            newElements[i] = elements[i];
        }
        elements = newElements;

        System.out.println(oldCapacity+"缩容为:"+newCapacity);
    }

经过删除和增加后我们发现:只有在添加之前会判断是否需要扩容;
在删除之后判断是否需要缩容;

因为要保证添加之前和删除之后动态数组都是不会浪费内存的;但是如果你的动态数组扩容和缩容的容量不科学,那么就可能导致大量的扩容缩容操作,使得内存更加浪费!

重写toString方法进行测试:

	@Override
    public String toString() {
        StringBuilder sb = new StringBuilder(String.format("ArrayList:%d/%d[",size,elements.length));
        if(isEmpty()){
            sb.append("]");
        }else{
            for(int i=0;i<size;++i){
                sb.append(elements[i]);
                if(i!=size-1){
                    sb.append(",");
                }else {
                    sb.append("]");
                }
            }
        }
        return sb.toString();
    }
//测试
 public static void main(String[] args) {
        ArrayList<Integer> list = new ArrayList<>();
        System.out.println(list);
        for(int i=0;i<50;++i){
            list.add(i);
        }
        for(int i=0;i<50;++i){
            list.remove(0);
        }
        System.out.println(list);
    }

数据结构-动态数组(ArrayList)_第10张图片
至此,动态数组的分析就全部完毕,感谢观看。
小码哥的恋上数据结构是我看过的讲的最好的一门数据结构,因此我希望将我所学所得的内容写出来,给有所需要的人,在慢慢学习的路上彼此共勉。

你可能感兴趣的:(恋上数据结构,数据结构,算法)