数据结构之——数组

数据结构:数组java

  • 1:什么是数组?
  • 2:Java中数组的声明及数组的遍历
  • 3:数组天生的优点——索引
  • 4:动态数组
  • 5:封装本身的数组类——增长元素
  • 6:封装本身的数组类——删除元素
  • 7:封装本身的数组类——修改元素,查询元素
  • 8:简单的时间复杂度分析
  • 9:均摊复杂度与复杂度震荡

1:什么是数组?

数组是咱们在学习任何一种编程语言最先接触到的数据结构。它是一种相同数据类型的元素存储的集合;数组中各个元素的存储是有前后顺序的,而且它们在内存中也会按照这样的顺序连续存放在一块儿。git

2:Java中数组的声明及数组的遍历

Java中数组的声明

Java语言当中,数组常规的声明方式有三种。github

// 第一种
int [] students = new int [50];
// 第二种
int [] scores = new int [3]{99,88,79};
// 第三种
String [] hobby = {"拳击","健身","跑步"}
复制代码

不管是哪种声明方式,均可以看出数组的声明直接或间接地指定了数组的大小,只有在声明数组时,告诉计算机数组的大小,计算机才能够在指定的内存中为你声明的数组开辟一串连续的空间。咱们能够想象一连串小格子一个挨着一个紧密地拼凑在一块儿,每个小格子都装着一个数据,而装着数据的小格子又对应计算机内存上的一个地址,每一个小格子所对应的地址是连续的......算法

Java中数组的遍历

除了while循环,for循环等基本的遍历方式,数组还支持一种特殊的遍历:foreach.举一个简单的例子:编程

// 声明数组:
int [] scores = new int[50];
// 普通for循环
for(int i=0;i<scores.length;i++){
      System.out.println(score);
}
// foreach遍历
for(int score:scores){
      System.out.println(score);
}
复制代码

由于数组在内存中连续排布,因此数组自己就具备可遍历的能力。数组

3:数组天生的优点——索引

数组最大的优点就是经过索引能够快速查询一个元素。由于数组在内存中开辟了一段空间,这一段连续的空间就是用来存储数组元素的,因此当咱们想获取某一个数组索引的元素时,计算机只要经过这个索引就能够在开辟的内存空间中,找到存放这个元素的地址,继而经过内存地址就能够快速查询到这个元素。咱们将索引查询的时间复杂度定义为O(1)。在后文有关于时间复杂度的介绍。当数组的索引变得有必定的语意时,数组的应用就更加方便了,例如:int [] students = new int [50]; 若是索引表明的是班级里学生们的学号,如:students[21] 表明的是学号为21号的学生,那么这种索引就变得很是有意义。但并不是全部有语意的数组索引都适用,例如一个公司有10名员工,如今须要将员工信息存储于一个emp[]数组当中,若是将员工的身份证号做为索引去建立一个数组,那么显然是不合理的。虽然索引变得有意义,可是计算机为了存储10名员工的信息就要在内存上开辟身份证号长度的内存去存储,实在是大大浪费了空间。索引最好创建在有语意的前提下,可是必定要合理。bash

4:动态数组

什么是动态数组?

了解Java的人必定知道,Java Collecion里面,ArrayList的底层实现原理就是动态数组,那么动态数组的含义是什么呢?在上文咱们说过,若是想要声明定义一个数组,都须要直接或间接地告诉计算机咱们要声明的数组的大小,只有计算机知道数组的大小后,才能够为咱们的数组分配具体的内存空间。可是这样一来,数组就变得不是那么灵活了,当数组元素已满,咱们就没法继续添加元素了。若是咱们开辟了1000个元素空间的数组,可是仅仅存储10个元素,那这种状况也是不合理的,咱们但愿数组可以经过本身的存储元素的实际数量去调节本身的容量大小,这就须要数组具有动态的能力。Java 提供的数组不具有动态能力,因此,咱们须要本身封装一个咱们本身的数组,这个数组须要具有动态调节自身容量大小的能力,即:动态数组。数据结构

动态数组的原理

现有一个数组:int [] data = new int[5];jvm

arr.png

该数组已经没法继续添加元素了,因此咱们再初始化一个新的数组,其容量为10,即数组arr容量的2倍:int [] newData = new int [10]; 编程语言

newArr.png

而后将原数组的全部元素所有都赋值给新的数组。

image.png

再将原数组的引用 arr指向 新的数组。

image.png

这个过程转换为伪码为:

public void resize(int newCapacity){
        E [] newData = (E[]) new Object[newCapacity];
        for(int i=0;i<size;i++){
                newData[i] = data[i];
        } 
        data= newData;
}
复制代码

动态数组扩容或者缩容的过程封装成了一个方法:resize.在方法中,使用了泛型,用来表明全部类型的数组。

5:封装本身的数组类——增长元素

如今咱们要实现添加元素的方法,这个方法能够在指定的合法的索引位置进行元素的添加。例如:在索引index=1处添加元素88

原数组:

1.png

在索引 index=1处添加元素 88

image.png

过程转换为代码:

public void add(int index,E e){
        if(index<0 || index>size)
            throw new IllegalArgumentException("Index is Illegal");

        if(size == data.length)
            resize(2*data.length);

        for(int i=this.size-1;i>=index;i--){
            data[i+1] = data[i];
        }
        data[index] = e;
        size++;
    }
复制代码

6:封装本身的数组类——删除元素

举例:删除索引为index=1处的元素88.

  • 原数组

    image.png

  • 在索引 index=1处删除元素 88

    image.png

  • 删除后的数组

    image.png
    能够看到,在本例中,删除元素88后,咱们使用size这样一个变量去维护实际数组元素的数量,实际元素的数量已经变为4了,可是本来索引为 index=4 处的元素仍然还在,以用户的角度来看,用户是没法访问data[size] 这样的一个元素的,因此最后的这个元素的存在已经没有意义了,理应被GC回收。这样的元素,被称为:"Loitering Objects",它们存在并无意义,理应被Java的GC回收机制回收,因此咱们须要手动对其进行回收工做(非必需),data[size] = null 就可让Java的GC进行回收。删除元素的代码为:

public E remove(int index){
        if(index<0 || index>=this.size)
            throw new IllegalArgumentException("Index is Illegal");

        E ret = data[index];
        for(int i=index+1;i<=this.size-1;i++){
            data[i-1] = data[i];
        }
        size--;
        // data[size] 为loitering Object 最好将其赋值为null 让jvm GC自动进行垃圾回收
        data[size] = null;

        // &&data.length/2!=0的缘由:防止出现当 data.length = 1 且当前无元素即size=0时
        if(this.size == data.length/4 && data.length/2!=0)
            resize(data.length/2);

        return ret;
    }
复制代码

对于代码:

if(this.size == data.length/4 && data.length/2!=0)
            resize(data.length/2);
复制代码

在后面的文章中,有详细的解释。

7:封装本身的数组类——修改元素,查询元素

  • 修改元素
public void set(int index,E e){
        if(index<0 || index>=this.size)
            throw new IllegalArgumentException("Index is Illegal");

        data[index] = e;
    }
复制代码
  • 查询元素
// 查:get
    public E get(int index){
        if(index<0 || index>=this.size)
            throw new IllegalArgumentException("Index is Illegal");

        return data[index];
    }

    // 查:contains
    public boolean contains(E e){
        for(int i=0;i<size;i++){
            if(data[i].equals(e)){
                return true;
            }
        }
        return false;
    }

    // 查:find
    public int find(E e){
        for(int i=0;i<size;i++){
            if(data[i].equals(e)){
                return i;
            }
        }
        return -1;
    }
复制代码

8:简单的时间复杂度分析

时间复杂度分析

常见的时间复杂度有:O(1),O(n),O(n logn),O(n^2),等等,其中O( ) 描述的是算法的运行时间和输入数据的关系。拿数组的索引举例,数组的索引就是一个O(1) 级别的算法,由于知道索引获取元素的过程和数据的数量级没有关系,也就是说不管数组开辟了10的空间仍是开辟了100万的内存空间,索引任一下标的时间都是一个常量。再例如程序:

public static int sum(int[]nums){
        int sum = 0;
        for(int num:nums){
              sum+=num;
        }
        return sum;
}
复制代码

上面的程序就是一个O(n) 级别的算法,程序是计算nums数组全部元素的和,计算出结果须要将nums数组从头到尾遍历一边,而遍历这个过程则与nums元素的数量n呈线性关系,随着元素个数n愈来愈大,遍历须要的时间就愈来愈长。固然,这个时间复杂度分析其实也忽略了不少常数及一些细节,包括使用的语言不一样,程序消耗的时间也是有差别的,因此*O( )*时间复杂度分析分析的只是一种趋势。

简单的时间复杂度分析与比对

O(1)O(n)O(n^2) 这三种级别的算法 哪种更优秀呢?首先,*O(1)级别的算法确定是最优的,可是也有必定的弊端。拿数组的索引来讲,数组之因此可以快速索引,就是由于它是一种以空间来换取时间的数据结构。若是数据的数量级是千万级的,那么数组就要在内存中开辟千万级的内存空间来支持索引,这显然是不现实。那么O(n)级别的算法必定要比O(n^2)级别的算法更优吗?其实也是不必定的,如T1 = 2000n+10000 这是一个O(n)级别的算法,T2 = n*n 这是一个O(n^2)*级别的算法。当n取值很小如100时,很显然 T2的时间要小于T1,可是随着n的取值愈来愈大,*O(n)算法的优点就会愈来愈大。因此从总体上来看O(1)*算法最优,*O(n)算法要优于O(n^2)*级别的算法,实际上也确实如此,O(n)算法不只仅是优于O(n^2),在海量级的数据下,这两种算法的差别是巨大的。

分析动态数组的时间复杂度
添加操做
除了``add(int index,E e)``方法,为了方便一些功能 我增长了方法``addLast(E e)``以及 ``addFirst(E e)``。先不考虑resize操做带来的影响。
复制代码
  • addLast(E e) O(1)
  • addFirst(E e) O(n)
  • add(int index,E e) 当index=0时,至关于向数组的头添加一个元素,全部的元素都须要向后挪动一个位置,因此是*O(n)的时间复杂度,当index取值为size时,则至关于addLast操做,即向数组的末尾添加一个元素,是O(1)的时间复杂度。index的取值在0~size 的几率是相等的,这里面涉及到几率论的问题,平均而言,add(int index,E e) 的时间复杂度为:O(n/2) 。也就是说addLast (E e) 直接就能够将增长的元素添加到数组的末尾,addFirst (E e)操做,数组挪动了n个元素,add(int index,E e) 操做平均来说,数组须要挪动n/2个元素,它消耗的时间也同数组的个数n 呈线性关系,因此能够将add(int index,E e) 看做一个O(n)*时间复杂度的操做(仅仅表明该方法的时间复杂度同元素个数呈线性关系)。

总体来看 动态数组的添加元素方法:add是一个*O(n)*级别时间复杂度的算法。

删除操做
除了``remove(int index)``方法,为了方便一些功能,我也增长了方法``removeFirst()`` 以及``removeLast()``方法。一样,也不考虑resize()方法对删除操做带来的影响。
复制代码
  • removeFirst() O(n)
  • removeLast() O(1)
  • remove(int index) 对于remove(int index)方法时间复杂度的分析和add(int index,E e)方法的分析过程相似,index的取值在 0~n的几率是相等的,平均上来看,remove(int index)方法会使数组移动n/2个元素,也就是说remove(int index)操做的时间与数组元素的个数n呈线性关系,remove(int index)也是一个*O(n)*级别时间复杂度的算法。

总体上来看,删除操做remove为 *O(n)*的时间复杂度。

修改操做
  • set(int index,E e) 修改操做只有一个方法即:set(int index,E e)。由于其利用了数组快速索引的特性,因此修改操做为*O(1)*的时间复杂度。
查询操做
  • get(int index) O(1)
  • contains(E e)
public boolean contains(E e){
        for(int i=0;i<size;i++){
            if(data[i].equals(e))
                return true;
        }
        return false;
}
复制代码

contains方法为查看数组中是否包含某个元素,由于contains方法须要将数组总体进行一次遍历,因此contains方法为*O(n)*的时间复杂度。

  • find(E e)
public int find(E e){
        for(int i=0;i<size;i++){
            if(data[i].equals(e))
                return i;
        }
        return -1;
    }
复制代码

find方法为查看是否数组中包含某个元素,若是包含则返回这个元素所在的索引,若是没有则返回-1。find方法为*O(n)的时间复杂度。 5. resize操做 resize操做的本质是将一个数组的全部元素依次赋值给一个空数组,它涉及到数组的遍历,因此resize方法为O(n)*的时间复杂度。

9:均摊复杂度与复杂度震荡

以上,咱们简单分析了增删该查操做的时间复杂度,可是除了改查两种操做不涉及到resize扩容或缩容操做外,添加元素和删除元素都有resize这种机制在里面。

均摊复杂度

以时间复杂度为*O(1)*的方法addLast(E e)来举例,若是初始化数组的原始capacity为10,开始时,数组内没有任何元素。一直使用addLast(E e)向数组末尾添加元素,在添加10次后,即第十一次再次添加元素则触发了一次扩容操做,扩容后的capacity为20即 原来的capacity的2倍。而在第21次添加元素操做时,又再次触发了扩容的操做。

image.png

也就是说:第n+1次addLast操做会触发依次resize方法。若是将O(1)的操做称做1次基本操做的话,从第1次添加元素至第n+1次添加操做共进行了2n+1次基本操做(resize为O(n),至关于n次基本操做)。n+1次addLast操做,计算机作了2n+1次基本操做即O(1) 的操做,也就是说,平均下来,每1次addLast,计算机就要作(2n+1)/(n+1)次基本的*O(1)操做。也就是说当n这个数字趋近无穷大时,则每1次addLast操做,计算机会进行2次基本的O(1)操做,也就是说——addLast操做和n没有关系,它仍然是一个O(1)级别的算法。以上分析的思想就是均摊复杂度的分析思想,同理:其余的方法也能够用均摊复杂度来进行分析,获得的结果是一致的,resize虽然会触发O(n)的操做,可是将resize的O(n)操做平均到每一次O(1)*操做上,对咱们以前分析的时间复杂度并没有结果上的变化。

复杂度震荡

还记得这段代码么?

if(this.size == data.length/4 && data.length/2!=0)
            resize(data.length/2);
复制代码

这段代码是remove(int index)方法中,data.length/2!=0是为了防止出现:数组无元素,capacity已经缩容到1的这种状况,防止resize缩容到capacity=0,这很显然是错误的。代码中,当数组元素的个数减小到 数组capacity的四分之一时,触发了缩容,且缩容为当前capacity的一半,为何要这样写呢?这种写法叫作 Lazy 机制,它是为了解决复杂度震荡的方法。若是咱们将代码写成:

if(this.size == data.length/2 && data.length/2!=0)
            resize(data.length/2);
复制代码

那么就会出现一个问题:

image.png

当前数组的size为5,capacity为5,如今对数组进行这样的操做:

  1. addLast,触发扩容扩容成capacity=10
  2. removeLast,触发缩容,又缩容成capacity=5
  3. addLast,触发扩容扩容成capacity=10
  4. removeLast,触发缩容,又缩容成capacity=5 ... ...

想必咱们已经看到问题的所在了,本来为O(1) 时间复杂度的addLast和removeLast方法硬生生地被玩成了*O(n)*的算法,出现这个问题的缘由就是由于咱们在remove操做时,resize太过于着急了(too Eager),因此形成了复杂度震荡。其实解决方法已经给出,就是Lazy机制。当数组元素的个数到capacity的一半时,不着急去缩容,而是等到size==capacity/4时,将capacity的容积缩容为capacity/2。这种看似懒惰的机制,却解决了这样的一个问题。

最后附上 GitHub的代码连接:MyArray 测试代码:Main