数组是一种线性数据结构。建立数组时会在内存中划分出一块连续的内存区域,数据会保存在这块连续区域的每块索引。java
首先说明经过下标获取元素的时间复杂度为O(1)。由于数组是一块连续的内存区域,而且每一个元素的大小都相等,经过一个线性方程就很快能找到改下标对应的内存地址。例如若是是一个int数组,0位置的内存地址为b,index是你要找的下标,那么显然你要获取的内存地址就是b+4*index,4就是一个int占的字节数。因此计算机经过b+type_size*index来计算下标。固然,并非全部的数组都是如此计算下标的,例若有的虚拟机在开辟数组空间时并非开辟的连续空间。node
增长时若是要指定增长到的数组下标通常将要对数组元素进行移动,例若有一个十个元素的整型数组,要将一个数字A插入到第一个元素的位置,那么就须要先将全部元素从后往前向后移动一个位置再将A插入到第一个位置。因此数组的插入操做平均时间复杂度为O(n)算法
和插入同样,指定下标进行删除。例若有十个元素,删除第一个元素,那么就须要将数组元素从当前元素由前至后向前移一个下标。因此数组的删除操做平均时间复杂度为O(n)数组
修改就比较简单了,直接获取下标改变当前元素的值便可浏览器
不少高级语言都有数组这个基本结构,可是在使用他们的时候若是咱们增长的元素超过一开始定义它的总个数的话是没办法继续添加的。因此,当咱们一开始不知道这个数组的大小时这就比较麻烦了,咱们就须要本身定义动态数组,咱们不须要管他的初始容量。bash
实现动态数组主要须要重写数组的增长、删除操做,还要实现扩容操做数据结构
本身实现的增长操做和原来的区别就是要判断是否须要将数组扩容。扩容的条件为当数组的长度和元素的个数相同时就须要扩容,通常扩容为两倍。 扩容的步骤:app
本身实现的删除操做和原来的区别就是要判断是否须要将数组缩容,缩容是有必要的。缩容的条件比扩容的条件多一点,就是缩容前的数组大小要大于等于2,而且当前元素个数为数组大小的一半。 缩容的步骤:函数
产生的震荡和咱们的扩容,缩容条件有关,若是按照上面的条件进行扩容和缩容,那么若是这个数组的元素个数若是在8和9之间徘徊,那么数组的大小就会在8,16中徘徊。测试
而震荡的解决方法和具体的需求有关,咱们能够将缩容的条件改成"缩容前的数组大小要大于等于2,而且当前元素个数为数组大小的1/4"
扩容确定在
//新增方法
public boolean add(E e) {
//modCount是记录修改次数的(迭代器判断结构是否变),迭代器fail-fast机制就靠它
modCount++;
//elementData是数组元素,size是数组大小
add(e, elementData, size);
return true;
}
private void add(E e, Object[] elementData, int s) {
//数组大小和数组元素个数相等,要扩容了
if (s == elementData.length)
elementData = grow(s+1);
elementData[s] = e;
size = s + 1;
}
private Object[] grow(int minCapacity) {
//能够看到也是经过复制一个新数组
return elementData = Arrays.copyOf(elementData,
newCapacity(minCapacity));
}
//接下来就是如何真正实现扩容的!!!
private int newCapacity(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
//新大小是老大小的1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
//若是新大小比oldCapacity+1小
if (newCapacity - minCapacity <= 0) {
//若是数组是空的,第一次添加元素,就直接扩容到10和minCapacity中大的那个
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
return Math.max(DEFAULT_CAPACITY, minCapacity);
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return minCapacity;
}
//返回大的那个,不超过最大
return (newCapacity - MAX_ARRAY_SIZE <= 0)
? newCapacity
: hugeCapacity(minCapacity);
}
复制代码
能够看到扩容的思路仍是很简单的,本人环境是jdk1.9,至于其余版本的扩容思路我想都差很少。至于缩容,这里就再也不阐述了,arraylist的源码仍是比较容易理解的。
链表和数组都是线性结构,可是链表是一个一个的节点组成的,这个节点有一个next指针指向下一个节点,也就是说链表不须要连续的数组空间。如图:
下面来看一下如何对链表进行增删改查
增长的操做时间复杂度为O(1),不用像数组同样去移动数组元素,如A->C,增长B到A的后面 增长步骤:
删除的时间复杂度为O(1),不用像数组同样去移动数组元素,如A->B->C,删除B 删除步骤:
查询的时间复杂度为O(n),由于链表不像数组能够直接经过下标计算出内存地址。因此必须经过遍历找到相应下标节点。
这里的增长和删除时间复杂度为O(1)很好理解,咱们可能会得出一个结论那就是数组的查询修改效率高,链表的删除增长效率高。但实际上这也是分状况的。当咱们在对一个节点进行插入或删除时咱们要去遍历到指定位置(由于咱们只有头节点地址或者尾节点地址)。以前作过一个测试,对于java中的结构LinkedList(双向链表)和ArrayList(双向链表),在增长大概一百万个元素的时候会发现数组的增长方法效率更高,由于在指定位置对链表添加元素链表要去遍历。
虚拟头节点:最后,通常链表都会有一个头节点,这个节点指向链表的首节点,这个头节点的用处是当咱们将删除最后一个元素的时候不用专门判断删除只有一个元素的状况
栈和队列很类似,因此结合起来一块儿看。
结构:数组array,top变量
数组用来保存进入的数据。top指针指向最后一个元素的下一个位置,若是栈为空top指向0。
举个例子:
结构:top指针,节点
如图:
结构:虚拟头节点,节点
如图:
基于数组的队列和基于数组的栈有一些不一样。能够想到,当数组队列入队时能够增长到数组后面,出队时将前面的元素移出,那么就会出现问题就是前面出队的元素会变为不可用但又无法用,有一种解决方式能够在出队的时候把后面的元素放在前面,就像前面动态数组那样删除首元素便可,可是若是是这样每出队一次就总体移动一次元素未免也太耗时了一些。因此这里引入循环队列。
循环队列要解决的问题就是数组队列浪费空间的问题。循环队列并非在物理地址上是循环的,而是在逻辑上循环的。
结构:数组,front指向头,rear指向队尾的后一位元素
front=front%array.length
如图:
要注意的点
当咱们想在学校想找到某我的的信息,咱们会向教务处去查询学号,而教务处得到你提供的学号就会给你一个学生的信息。这里经过学号得到学生信息就是用了哈希表的思想,而学号和学生信息的对应关系就是哈希函数,而若是两个学号对应到了同一个学生信息就是哈希冲突。
因此接下来来看一下哈希表中的名词:
哈希表的查找时间复杂度为O(1),哈希表查找的思路是直接经过关键字查找到元素。增长删除的时间复杂度也是O(1),也就是说哈希表
底层有一个Node数组array,当前元素个数M
由于咱们要实现对于不一样的key尽量的经过哈希函数得出不一样的值。因此对于哈希函数的选取是比较重要的。
如下为常见哈希函数:
接下来咱们将哈希函数都设置为除留取余法进行分析。
哈希冲突就是不一样的两个key,他们hash(key)以后获得的结果相同。对于哈希冲突的解决也有多种
接下来咱们将哈希冲突解决方式设置为链地址法进行分析。
例如用户要增长一个K,V键值对,先将K的哈希值计算出来,再将哈希值对数组长度取模得到下标,若是下标没有元素,就将K,V封装成Node放入这个下标,若是下标已经有元素,就K,V封装成Node加入到这个下标元素的最后面。
例如一个用户要经过K查找这个节点,经过hash(K)再取模获得这个数组的下标,若是这个下标没有元素,表明查找失败,若是这个下标有元素,那就从这个元素向后面的链表遍历,有就返回。
删除和修改逻辑相似
java中的HashMap是封装的很是好的一个哈希表,经过分析它的实现也可让咱们更完善本身的哈希表设计
咱们来看一下它有哪些关键字段
//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//哈希表中的数组
transient Node<K,V>[] table;
//数组中每一个元素保存的下面这个节点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
//..一些节点其余的方法
}
复制代码
接下来来看一个put操做(增长操做)的流程。
//增长一个key,value
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//采用的hash算法
static final int hash(Object key) {
int h;
//能够看到这里hash算法用的key的hashCode和h右移16位的异或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//最后调用的putVal操做
/**
* hash是传来的哈希值
* key是键,value是值
* onlyIfAbsent为false表明多个key会重写
* evict为false表明表处于建立状态
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//若是一开始hashmap没有元素的话初始化hashmap大小
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//没有哈希冲突的话直接构造新的链表节点添加进数组中,i为计算出的下标
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//接下来是有哈希冲突的状况
else {
Node<K,V> e; K k;
//这里计算出下标的节点的哈希值要等于以前传过来计算好的哈希值。而且要引用同一个对象而且equals方法也要相等!!这里表示判断为同一个对象的逻辑!!我曾经在这里踩过坑。。其次,若是两次的key都为null的话
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//这里不是同一个对象而且是TreeNode结构(当链表节点个数大于等于8的时候会转化为红黑树)
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//接下来正常添加链表节点
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//若是value变换以后这里会返回老的value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
复制代码
关于HashMap中的put操做,有如下几点注意的地方
下一篇会总结树结构数据类型,而且下一篇会把本身实现的数据类型分享出来