数据结构-01| 数组
1. 数据结构概述
数据结构的介绍
1) 数据结构是一门研究算法的学科,只从有了编程语言也就有了数据结构.学好数据结构可以编写出更加漂亮,更加有效率的代码。
2) 要学习好数据结构就要多多考虑如何将生活中遇到的问题,用程序去实现解决.
3) 程序 = 数据结构 + 算法
2. 数组
在大部分编程语言中,数组都是从 0 开始编号,为什么数组要从 0 开始编号,而不是从 1 开始呢?
从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。如果用 a 来表示数组的首地址,a[0] 就是偏移为 0 的位置,也就是首地址,a[k] 就表示偏移 k 个 type_size 的位置,所以计算 a[k] 的内存地址只需要用这个公式:
a[k]_address = base_address + k * type_size
但是,如果数组从 1 开始计数,那我们计算数组元素 a[k] 的内存地址就会变为:a[k]_address = base_address + (k-1)*type_size
从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。
最主要的原因可能是历史原因。C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言。
2.1 概念
数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
① 线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构。与之对立的是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。
② 连续的内存空间和相同类型的数据。正是因为这两个限制,它才有了一个堪称“杀手锏”的特性:“随机访问”。但有利就有弊,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。
2.2 数组是如何根据下标随机访问元素
数组-是内存中一段连续的存储空间,0--8是数组的下标;8位的数字是它的内存地址(简单示例),
通过 Memory-Controller内存管理器可以实现随机访问任何一个下标里的内存元素,即数组元素; 访问查找数组它的时间复杂度是O(1),因为它的硬件可以保证;
计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过寻址公式,计算出该元素存储的内存地址: a[i]_address = base_address + i * data_type_size(数组中每个元素的大小)
数组和链表的区别
很多人都回答说,“链表适合插入、删除,时间复杂度 O(1); 数组适合查找,查找时间复杂度为 O(1)”。
实际上,这种表述是不准确的。数组是适合查找操作,但是查找的时间复杂度并不为 O(1)。即便是排好序的数组,你用二分查找,时间复杂度也是 O(logn)。
所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。
数组具有随机访问的特点,常用的二分查找算法需要用数组来存储数据。但如果我们选择链表这种数据结构,二分查找算法就无法工作了,因为链表并不支持随机访问。
2.3 低效的“插入”和“删除”
插入操作:
①
假设数组的长度为 n,为了保证数组元素是连续的,如元素D要插入下标 k(如 3)的位置,则要把第 k~n 这部分的元素都顺序地往后挪一位(把EFG挪动到下标为4, 5, 6的位置);
如果插入到数组最后一个位置则它的时间复杂度是O(1);
如果是插入到第一个位置,所有的数据都需要依次往后移动一位,则最坏时间复杂度就是O(n);因为在每个位置插入元素的概率是一样的,所以平均时间复杂度 (1+2+3+...+n)/ n = (n+1) / 2 ;就是O
(n/2),就相当于是O(n)。
②
如果数组中的数据是有序的,我们在某个位置插入一个新的元素时,就必须按照刚才的方法搬移 k 之后的数据。但是,如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合。在这种情况下,如果要将某个数组插入到第 k 个位置,为了避免大规模的数据搬移,我们还有一个简单的办法就是:
- 直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。
例子:假设数组 a[10] 中存储了如下 5 个元素:a,b,c,d,e。
我们现在需要将元素 x 插入到第 3 个位置。我们只需要将 c 放入到 a[5],将 a[2] 赋值为 x 即可。最后,数组中的元素如下: a,b,x,d,e,c。
利用这种处理技巧,在特定场景下,在第 k 个位置插入一个元素的时间复杂度就会降为 O(1)。这个处理思想在快排中也会用到
删除操作:
①
要删除第 k 个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了。
如果删除数组末尾的数据,则最好情况时间复杂度为 O(1) ,如果删除开头数据,则最坏情况时间复杂度为O(n), 平均情况时间复杂度也为 O(n)。
②
实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢?
例如数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。
为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。
当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。
如果你了解 JVM,你会发现,这不就是 JVM 标记清除垃圾回收算法的核心思想吗?
2.4 警惕数组的访问越界问题
数组越界
数组越界在 C 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的, 那么程序就可能不会报任何错误。这种情况下,一般都会出现莫名其妙的逻辑错误,debug 的难度非常的大。而且,很多计算机病毒也正是利用到了代码中的数组越界可以访问非法地址的漏洞, 来攻击系统,所以写代码的时候一定要警惕数组越界。
但如Java语言本身就会做越界检查,会抛出 java.lang.ArrayIndexOutOfBoundsException异常。
2.5 容器能否替代数组
容器 VS 数组
如 Java 中的 ArrayList,在项目开发中,什么时候适合用数组,什么时候适合用容器呢?
ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。比如数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,就是支持动态扩容。
数组本身在定义的时候需要预先指定大小,因为需要分配连续的内存空间。如果我们申请了大小为 10 的数组,当第 11 个数据需要存储到数组中时,我们就需要重新分配一块更大的空间,将原来的数据复制过去,然后再将新的数据插入。
如果使用 ArrayList,就完全不需要关心底层的扩容逻辑,ArrayList 已经帮我们实现好了。每次存储空间不够的时候,它都会将空间自动扩容为 1.5 倍大小。
不过,这里需要注意一点,因为扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小,可以省掉很多次内存申请和数
据搬移操作。
有些时候用数组会更合适些:
1.Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
2. 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。
3. 当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList<ArrayList> array
对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组
就会优于容器。
数组复杂度分析
Array的时间复杂度
- Access: O(1)
- prepend: O(1)
- append: O(1)
- lookup: O(1)
- Insert: 平均 O(n)
- Delete: 平均 O(n)
对于数组的插入和删除操作的改善 ===>> 链表
3. Java中ArrayList
添加和删除操作源码如下:
/** 330: * Appends the supplied element to the end of this list. 331: * The element, e, can be an object of any type or null. 332: * 333: * @param e the element to be appended to this list 334: * @return true, the add will always succeed 335: */ 336: public boolean add(E e)//加到数组最后 337: { 338: modCount++; 339: if (size == data.length) //判断数组的size是否够,如果不够就增加; 340: ensureCapacity(size + 1); 341: data[size++] = e; //在末尾添加e元素, size++ 342: return true; 343: } 344: 345: /** 346: * Adds the supplied element at the specified index, shifting all 347: * elements currently at that index or higher one to the right. 348: * The element, e, can be an object of any type or null. 349: * 350: * @param index the index at which the element is being added 351: * @param e the item being added 352: * @throws IndexOutOfBoundsException if index < 0 || index > size() 353: */ 354: public void add(int index, E e) 355: { 356: checkBoundInclusive(index); 357: modCount++; 358: if (size == data.length) 359: ensureCapacity(size + 1); 360: if (index != size) 361: System.arraycopy(data, index, data, index + 1, size - index); //与上边不同的是会进行一轮拷贝的操作,参数data,index表示原位置;data,index+1表示它的目标位置;要做的就是把原位置的起点位置拷贝到目标地址的起点位置。 size-index表它的长度length,即数组后半部分要挪动的部分 362: data[index] = e; 363: size++; 364: } 160: /** 161: * Guarantees that this list will have at least enough capacity to 162: * hold minCapacity elements. This implementation will grow the list to 163: * max(current * 2, minCapacity) if (minCapacity > current). The JCL says 164: * explictly that "this method increases its capacity to minCap", while 165: * the JDK 1.3 online docs specify that the list will grow to at least the 166: * size specified. 167: * 168: * @param minCapacity the minimum guaranteed capacity 169: */ 170: public void ensureCapacity(int minCapacity) 171: { 172: int current = data.length; //查询当前数组长度, 173: 174: if (minCapacity > current) //长度不够,就new一个当前长度 * 2 175: { 176: E[] newData = (E[]) new Object[Math.max(current * 2, minCapacity)]; 177: System.arraycopy(data, 0, newData, 0, size); //然后把老数组的值拷贝到新数组中。 插入和删除操作大量的arraycopy,比较低效; 178: data = newData; 179: } 180: }
① ArrayList和Vector:(底层都是数组--称为动态数组) * Vector:是旧版本,线程安全的,扩容机制为原来的2倍,支持旧版的Enumeration迭代器 * ArrayList:是新版本,线程不安全的,扩容机制为原来的1.5倍,不支持Enumeration迭代器 * ArrayList只用:foreach和Iterator或ListIterator * 只看,不改:foreach * 看,并且删除:从前往后:Iterator * 看,并且删除、插入,从前往后,或从后往前:ListIterator
如果需要再细化:
ArrayList在JDK1.7之后,如果new ArrayList(),一开始初始化为一个长度为0的空数组,在第一次添加元素时,初始化为容量为10的数组。
Vector:new Vector()直接初始化为容量为10的数组。
ArrayList、 LinkedList、Vector三者的异同?
同: 3个类都实现了List接口,存储数据的特点相同,存储有序、可重复的数据
不同:
Collection接口: 单列集合, 用来存储一个一个的对象
List接口: 存储有序的、可重复的数据。 -> "动态"数组, 替换原有的数组
ArrayList: 作为List接口的主要实现类, 线程不安全的, 效率高; 底层使用Object[] elementData存储
Vector: 作为List接口的古老实现类, 线程安全的,效率低; 底层Object[] elementData存储
LinkedList:对于频繁插入、删除操作,使用此类效率比ArrayList高; 底层使用双向链表存储。
ArrayList源码分析:
JDK7:
ArrayList list = new ArrayList();//底层创建了长度是10的object[]数组 elementData
list.add(123) //elementData[0] = new Integer(123);
..
list.add(11) //如果此次的添加导致底层elementData数组容量不够,则扩容。
默认情况下,扩容为原来容量的1.5倍,同时需要将原来数组中的数据复制到新的数组中。
JDK8:
ArrayList list = new ArrayList();//底层object[] elementData 初始化为{},并没有创建长度为10的数组;
list.add(123) //第一次调用add()时,底层才创建了长度10的数组,并将数据123添加到 elementData[0]
..后序的添加和扩容操作与JDK7一样。
jdk7中Arraylist的对象的创建类似于单列的饿汉式,而jdk8 Arraylist的对象的创建类似于单列的懒汉式,延迟了数组的创建,节省了内存。
LinkedList源码分析:
List<Object> list = new LinkedList<>();内部声明了Node类型的first和last属性,默认值为null
list.add(123) //将123封装到Node中,创建了Node对象。
其中,Node定义体现了LinkedList双向链表的说法。
其中,Node定义为:
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
ArrayList
① 当我们装载的是基本类型的数据int,long,boolean,short,byte…的时候我们只能存储他们对应的包装类(泛型只能是对象类型),它的主要底层实现是数组Object[ ] elementData。
② ArrayList 和 LinkedList相比,底层是动态数组结构,它支持随机访问,根据下标随机访问和查找的时间复杂度为O(1) ,但插入,删除的时间复杂度为O(n) 。
③ 是线程不安全的(多个线程同一时刻对同一个全局变量(同一份资源)做写操作(读操作不会涉及线程安全)时,跟预期的结果不一样,就称之为线程不安全),
但是使用频率高。正常使用的场景中,都是用来查询,不会涉及太频繁的增删,如果涉及频繁的增删,可以使用LinkedList,如果需要线程安全就使用Vector。
Vector的实现很简单,就是把所有的方法统统加上synchronized就完事了。也可以不使用Vector,用Collections.synchronizedList把一个普通 ArrayList包装成一个线程安全版本的数组容器也可以,原理同Vector是
一样的,就是给所有的方法套上一层synchronized。不存在一个集合工具是查询效率又高,增删效率也高的,还线程安全。
④ 初始化默认数组大小为0,进行add时默认大小是10。
ArrayList可以通过构造方法在初始化的时候指定底层数组的大小。通过无参构造方法的方式ArrayList( )初始化,则赋值底层数 Object[ ] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = { } 为一个默认空数组,
所以数组容量为0,只有真正对数据进行添加add时,才分配默认DEFAULT_CAPACITY = 10的初始容量。
arrayList1.7开始变化有点大,一个是初始化的时候,1.7以前会调用this(10)才是真正的容量为10,1.7即本身以后是默认走了空数组,只有第一次add的时候容量会变成10。
ArrayList(int initialCapacity)会不会初始化数组大小?
List<Integer> list = new ArrayList<>(10); System.out.println(list.size()); //0 list.set(5, 6); //java.lang.IndexOutOfBoundsException: Index: 5, Size: 0
虽然对ArrayList设置了初始大小,但是我们打印List大小的时候还是0,我们操作下标set值的时候也会报错,数组下标越界。其实数组是初始化了,但是List没,那size就没变,set下标是和size比较的那就报错
了。会初始化数组大小!但是List的大小没有变,因为list的大小是返回size的。
而且将构造函数与initialCapacity结合使用,然后使用set()会抛出异常,尽管该数组已创建,但是大小设置不正确。
使用sureCapacity()也不起作用,因为它基于elementData数组而不是大小。
还有其他副作用,这是因为带有sureCapacity()的静态DEFAULT_CAPACITY。
进行此工作的唯一方法是在使用构造函数后,根据需要使用add()多次。
⑤ ArrayList适合做队列吗
队列一般是FIFO(先入先出)的,如果用ArrayList做队列,就需要在数组尾部追加数据,数组头部删除数组,反过来也可以。但是无论如何总会有一个操作会涉及到数组的数据搬迁,这个是比较耗费性能的。
所以ArrayList不适合做队列。 但数组是非常合适的。比如ArrayBlockingQueue内部实现就是一个环形队列,它是一个定长队列,内部是用一个定长数组来实现的。
另外著名的Disruptor开源Library也是用环形数组来实现的超高性能队列,具体原理不做解释,比较复杂。简单点说就是使用两个偏移量来标记数组的读位置和写位置,如果超过长度就折回到数组开头,前提是它
们是定长数组。
⑥ ArrayList的遍历和LinkedList遍历性能比较如何?
论遍历ArrayList要比LinkedList快得多,ArrayList遍历最大的优势在于内存的连续性,CPU的内部缓存结构会缓存连续的内存片段,可以大幅降低读取内存的性能开销。
⑦ 查看 ArrayList 的实现类源码时,发现对象数组 elementData 使用了 transient 修饰,transient 关键字修饰该属性,则表示该属性不会被序列化,然而我们并没有看到文档中说明 ArrayList 不能被序列化。
// 默认初始化容量
private static final int DEFAULT_CAPACITY = 10;
// 对象数组
transient Object[] elementData;
// 数组长度
private int size;
elementData 被关键字 transient 修饰了。transient 关键字修饰该字段则表示该属性不会被序列化,但 ArrayList 其实是实现了序列化接口,
由于 ArrayList 的数组是基于动态扩增,所以并不是所有被分配的内存空间都存储了数据。如果采用外部序列化法实现数组的序列化,会序列化整个数组。ArrayList 为了避免这些没有存储数据的内存空间被序列
化,内部提供了两个私有方法 writeObject 以及 readObject 来自我完成序列化与反序列化,从而在序列化与反序列化数组时节省了空间和时间。因此使用 transient 修饰数组,是防止对象数组被其他外部方法序
列化。
新增的逻辑
新增元素的方法有两种,一种是直接将元素加到数组的末尾,另外一种是添加元素到任意位置。
两个方法的相同之处是在添加元素之前,都会先确认ensureCapacityInternal 容量大小,如果容量够大,就不用进行扩容;如果容量不够大,就会按照原来数组的 1.5 倍大小进行扩容,在扩容之后需要将数组
复制到新分配的内存地址。
如果我们在初始化时就比较清楚存储数据的大小,就可以在 ArrayList 初始化时指定数组容量大小,并且在添加元素时,只在数组末尾添加元素,那么 ArrayList 在大量新增元素的场景下,性能并不会变差,反而比其他 List 集合的性能要好。
两个方法也有不同之处,添加元素到任意位置,会导致在该位置后的所有元素都需要重新排列,而将元素添加到数组的末尾,在没有发生扩容的前提下,是不会有元素复制排序过程的。
指定位置新增的时候,在校验之后的操作很简单,就是数组的copy。
arraycopy代码即,如果要插入到下标为index的元素,复制一份完整的数组,在index + 1留出一个空位,将元素插入到index的位置。(index之后的元素都往后移动)。
在扩容的时候,老版本的jdk和8以后的版本是有区别的,8之后的效率更高了,采用了位运算,右移一位,其实就是除以2这个操作。
1.7的时候3/2+1 ,1.8直接就是3/2。
删除的逻辑
删除方法和添加任意位置元素的方法是有些相同的。ArrayList 在每一次有效的删除元素操作之后,都要进行数组的重组,并且删除的元素位置越靠前,数组重组的开销就越大。
在代码里面我们发现,他还是在copy一个数组。 比如删除下标为index的元素,复制copy一个新数组, 将index+1的元素往前移动一个位置。