常见的线性表有
- 数组
- 链表
- 栈
- 队列
- 哈希表(散列表)
一、数组
数组是一种顺序存储的线性表,所有元素的内存地址是连续的
1、动态数组 ArrayList
注意有以下接口
// 元素的数量
int size();
boolean isEmpty();
boolean contains(E element);
void add(E element);
E get(int index);
// 设置值,并将被替换的元素返回
E set(int index, E element);
void add(int index, E element);
E remove(int index);
// 查看元素的位置
int indexOf(E element);
void clear();
Java 有动态数组实现类 ArrayList。容量默认大小为10,当添加元素数组容量不够时,会重新创建一个 1.5 倍大小的新Arraylist,然后使用Array.copyOf复制元素到新的ArrayList。
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
但是,当ArrayList删除元素时不会缩容。例如,我们往ArrayList里面存了30个数,扩容了3次:10->15->22->33,然后不断删除元素,直到元素个数为空,会发现ArrayList的size一直是33。
为什么删除了元素,但是不缩小容量呢?
我觉得是因为每次改变容量的时候,都需要新建一个数组,然后把旧数组里面的元素复制到新数组里面去,这种操作是损耗性能的,而往往ArrayList里面的元素会经常会在一定范围内变化,因此这种操作将带来很大的性能损耗,我们为什么不能损耗一点内存来提高服务的响应速度呢?从这个角度来看,也是一种空间换时间的体现。另外,在新建ArrayList的时候,设置一个合理的容量也会对我们系统的服务性能有一定的帮助。
2、动态数组时间复杂度
忽略扩容时创建新的动态数组并复制元素。添加,删除和查询的时间复杂度如下:
操作 | 说明 | 最好 | 最坏 | 平均 |
添加 | 最后一个元素后移,直到被添加的元素位置,然后将数据插入 | 从最后面添加,不需要移动元素,时间复杂度O(1) | 从最前面添加,需要将每一个元素后移,时间复杂度O(n) | O(n) |
删除 | 后面的元素覆盖被删除的元素,直到最后一个元素覆盖它前面的元素 | 删除最后一个元素,不需要移动,时间复杂度O(1) | 删除第一个元素,需要移动后面的每一个元素,时间复杂度O(n) | O(n) |
查询 | 根据索引直接寻址 | 由于内存是连续的,直接寻址查询,时间复杂度O(1) | 时间复杂度O(1) | O(1) |
3、动态数组优化
从动态数组时间复杂度可以知道,动态数组的查询效率高。但是从中间或前面添加和删除元素时,需要不断移动后面的元素。导致添加和删除的时间复杂度为O(n)。有没有好的优化方法呢?
可以考虑在动态数组中添加一个成员变量 first,用来指示当前的首元素。例如循环双端队列就可以使用这种方式来实现。
4、对象数组
对象数组模型如下:
二、链表
动态数组有个明显的缺点,可能会造成内存空间的大量浪费。能否用到多少就申请多少内存呢?链表可以办到这一点。
链表是一种链式存储的线性表,所有元素的内存地址不一定是连续的。
1、接口设计
链表的接口设计和动态数组大部分是一致的
2、链表的时间复杂度
单向链表的添加和删除都需要先查找到需要添加和删除的位置,然后执行相关操作。
操作 | 说明 | 最好 | 最坏 | 平均 |
添加 | 创建node节点,找到添加的位置后,将上一个元素的next指向node,然后node的next指向上一个元素的下一个节点 | 从头部添加,时间复杂度O(1) | 从尾部添加,时间复杂度O(n) | O(n) |
删除 | 找到删除的位置后,将上一个元素的next指向需要删除的元素的next | 从头部删除,时间复杂度O(1) | 从尾部删除,时间复杂度O(n) | O(n) |
查询 | 从头部开始,不断循环遍历next | 元素在头部,时间复杂度O(1) | 元素在尾部,时间复杂度O(n) | O(n) |
以下是单向链表部分实现代码:定义一个内部类 Node,第一个元素和size
public class LinkedList<E> {
private Node<E> first;
private int size;
private static class Node<E> {
E element;
Node<E> next;
public Node(E element, Node<E> next) {
this.element = element;
this.next = next;
}
/**
* 添加
* @param index
* @param element
*/
public void add(int index, E element) {
rangeCheckForAdd(index);
if (index == 0) {
first = new Node<>(element, first);
} else {
Node<E> prev = node(index - 1);
prev.next = new Node<>(element, prev.next);
}
size++;
}
/**
* 删除
* @param index
* @return
*/
public E remove(int index) {
rangeCheck(index);
Node<E> node = first;
if (index == 0) {
first = first.next;
} else {
Node<E> prev = node(index - 1);
node = prev.next;
prev.next = node.next;
}
size--;
return node.element;
}
/**
* 查找
* @param element
* @return
*/
public int indexOf(E element) {
if (element == null) {
Node<E> node = first;
for (int i = 0; i < size; i++) {
if (node.element == null) return i;
node = node.next;
}
} else {
Node<E> node = first;
for (int i = 0; i < size; i++) {
if (element.equals(node.element)) return i;
node = node.next;
}
}
return -1;
}
......
}
3、双向链表
为了提高单向链表的效率,可以使用双向链表,在 前面定义的 ListedList 链表类中添加一个指向最后一个元素的成员变量 last。在Node节点中添加一个指向上一个节点的成员变量 prev。
只有一个节点时,如下所示:
Node几点定义如下:
private static class Node<E> {
E element;
Node<E> prev;
Node<E> next;
public Node(Node<E> prev, E element, Node<E> next) {
this.prev = prev;
this.element = element;
this.next = next;
}
}
查找元素的索引 index 小或等于 size 的一半时,从头部开始,循环遍历next。
查找元素的索引 index 大于size的一半时,从尾部开始,循环变量 prev。
添加和删除元素与单向链表类似,只是需要维护 prev 和 next 两个属性,Node 的构造方法已经维护了 当前节点的 prev 和当前节点的next,剩下的是 (prev 元素的next)和(next元素的prev)。以下为添加和删除代码:
/**
* 添加
* @param index
* @param element
*/
private void add(int index, E element) {
rangeCheckForAdd(index);
if (index == size) {
Node<E> oldLast = last;
last = new Node<>(oldLast, element, null);
if (oldLast == null) {
first = last;
} else {
oldLast.next = last;
}
} else {
Node<E> next = node(index);
Node<E> prev = next.prev;
Node<E> current = new Node<>(prev, element, next);
next.prev = current;
if (prev == null) {
first = current;
} else {
prev.next = current;
}
}
}
public E remove(int index) {
rangeCheck(index);
Node<E> node = node(index);
Node<E> prev = node.prev;
Node<E> next = node.next;
if (prev == null) {
first = next;
} else {
prev.next = next;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
}
}
Java 工具类中的LinkedList是一个双向链表,以下是部分成员变量和节点定义:
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
transient int size = 0;
/**
* Pointer to first node.
* Invariant: (first == null && last == null) ||
* (first.prev == null && first.item != null)
*/
transient Node<E> first;
/**
* Pointer to last node.
* Invariant: (first == null && last == null) ||
* (last.next == null && last.item != null)
*/
transient Node<E> last;
...
}
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;
}
}
4、动态数组和双向链表的对比
动态数组 | 双向链表 |
开辟,销魂内存空间的次数相对较少,但可能造成内存空间浪费(可以通过缩容解决) | 开辟,销毁内存空间的次数相对较多,但不会造成内存空间浪费 |
如果频繁在尾部进行添加,删除操作,动态数组,双向链表均可 | |
如果频繁在头部进行添加,删除操作,建议选择使用双向链表 | |
如果有频繁的(在任意位置)添加,删除操作,建议选择使用双向链表 | |
如果有频繁的查询操作(随机访问操作),建议选择使用动态数组 |
5、循环链表
循环链表有单向循环链表和双向循环链表
三、栈
1、简介
- 栈是一种特殊的线性表,只能在一端进行操作。
- 往栈中添加元素的操作,一般叫做 push,入栈
- 从栈中移除元素的操作,一般叫做 pob,出栈(只能移除栈顶元素,也叫做:弹出栈顶元素)
- 后进先出的原则,Last In First Out,LIFO
2、API
int size(); // 元素size
boolean isEmpty(); // 是否为空
void push(E element); // 入栈
E pop(); // 出栈
Java 的工具类中 LinkedList 有栈的实现,使用动态数组和链表都可以。
3、栈的应用
- 浏览器的前进和后退
- 软件的撤销(Undo),回复(Redo)功能
- 括号是否有效
- 等等
四、队列
1、简介
- 队列是一种特殊的线性表,只能在头尾两端进行操作
- 队尾(rear):只能从队尾添加元素,一般叫做enQueue,入队
- 对头(front):只能从队头移除元素,一般叫做deQueue,出队
- 先进先出的原则,First In First Out,FIFO
2、API
int size(); // 元素的数量
boolean isEmpty(); // 是否为空
void clear(); // 清空
void offer(E element); // 入队
E poll(); // 出队
E peek(); // 获取队列的头元素
因为队列主要是往头尾操作元素,优先使用 双向链表 实现
3、双端队列(Deque)
双向队列能在头尾两端添加,删除的队列
int size(); // 元素的数量
boolean isEmpty(); // 是否为空
void clear(); // 清空
void enQueueRear(E element); // 从队尾入队
E deQueueFront(); // 从对头出队
void enQueueFront(E element); // 从对头入队
E deQueueRear(); // 从队尾出队
E front(); // 获取队列的头元素
E rear(); // 获取队列的尾元素
LinkedList 实现了双向链表的接口
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
4、循环队列(Circle Queue)
循环队列是可以进行两端添加,删除操作的循环队列,循环队列底层用数组实现。
其实队列底层也可以使用 动态数组 实现,并且各项接口也可以优化到O(1)时间复杂度。
这个数组实现并优化之后的队列也叫做:循环队列
循环队列入队和出队简单实现如下:
入队时先获取队尾元素(front+size) 与 length 取模,然后设值,size++。
public void enQueue(E element) {
elements[(front + size) % elements.length] = element;
size++;
}
出队时front和size都加一,但是front需要和 length 取模。
public E deQueue() {
E frontElement = elements[front];
elements[front] = null;
front = (front + 1) % elements.length;
size--;
return frontElement;
}
扩容时,新建一个length为原来1.5倍的数组,然后将元素从队头开始映射回去,front重置为0。
private void ensureCapacity(int capacity) {
int oldCapacity = elements.length;
if (oldCapacity >= capacity) return;
// 新容量为旧容量的1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
E[] newElements = (E[]) new Object[newCapacity];
for (int i = 0; i < size; i++) {
newElements[i] = elements[(front + i) % elements.length];
}
elements = newElements;
// 重置front
front = 0;
}
考虑到入队,出队,扩容等等操作都需要映射index,我们可以抽取一个公共方法 index
public int index(int index){
return (front + index) % elements.length;
}
5、循环双向队列
循环双端队列:可以进行两端添加,删除操作的循环队列
其中 从头部出队,从尾部入队与循环队列一致
从头部入队,从尾部出队 的操作逻辑如下:
从头部入队,相当于从 front 的前一个位置入队,映射为 front-1 与 elemetns.length 取模,可以使用 index(-1)映射
public void enQueueFront(E element) {
ensureCapacity(size + 1);
front = index(-1);
elements[front] = element;
size++;
}
如果front = 0,调用 index(-1)时,出现了 -1 % elements.length 的情况,需要调整一下 index 方法
private int index(int index) {
index += front;
if(index < 0){
return index + elements.length;
}
return index % elements.length;
}
同理,从 尾部出队 是先取出尾部元素,index 为 (front + size-1 )% elements.length,可以使用 index(size-1) 表示
public E deQueueRear() {
int rearIndex = index(size - 1);
E rear = elements[rearIndex];
elements[rearIndex] = null;
size--;
return rear;
}
五、哈希表
哈希表也叫散列表,如下所示:
put("Tom", "a");
put("Jack","c");
put("Lucy", "f");
保存模型如下图所示:
- 哈希表添加,搜索,删除的流程是先通过哈希函数生成 key(O(1)),然后根据 index 操作定位数组元素(O(1))
- 哈希表内部的数组元素,也叫 Bucket(桶),整个数组叫 Buckets 或者 Bucket Array
- 哈希表是空间换时间的典型应用
Bucket 的长度一般设为 2 的倍数长度,可以通过与 Bucket 的长度取余计算索引值:hashCode % (buckets.length - 1)
1、哈希冲突
2 个不同的 key,经过哈希函数计算出相同的哈希值,解决哈希冲突冲突的常见方法:
- 开放定址法,按照一定规则向其他地址探测,直到遇到空桶
- 再哈希法,设计多个哈希函数
- 链地址法,比如通过链表将同一个 index 的元素串起来(常用)
JDK 1.8 的哈希冲突使用链地址法解决哈希冲突,单向链表的节点数大于8时,使用红黑树保存
2、哈希函数
哈希函数需要返回一个 int 类型的数据,int 是4个字节,32位,良好的哈希函数:
- 让哈希值均匀分布
- 减少哈希冲突次数
- 提升哈希表的性能
Java 常用类型都实现了 hashCode 方法,例如 Double 类,采用高 32 位与低 32 异或计算作为哈希值
public static int hashCode(double value) {
long bits = doubleToLongBits(value);
return (int)(bits ^ (bits >>> 32));
}
下面是 String 类的 haseCode 方法,字符串是由若干字符组成,例如字符串 “test”,由 t,e,s,t 组成。计算 hashCode 时,使用字符的ASCII 计算哈希值,表示成 t*n^3 +e*n^2+s*n+t,等价于 [(t * n + e )* n + s ] * n + t。
在 JDK 中 n 等于31,31是一个奇素数,可以优化成 (i << 5) - i
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
3、对象中的 hashCode 和 equals 方法
如果使用对象作为 Hash 中的 key,需要实现 hashCode 和 equals 方法,如下所示:
public class Person {
private Integer age;
private String name;
// get,set方法
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return Objects.equals(age, person.age) &&
Objects.equals(name, person.name);
}
@Override
public int hashCode() {
//Objects.hash(age, name);
int hashCode = Integer.hashCode(age);
hashCode = hashCode * 31 + (name == null ? 0 : name.hashCode());
return hashCode;
}
}
- hashCode 方法用于计算哈希,然后通过哈希函数计算索引
- equals 用于当存在哈希冲突时,判断当前节点与其他节点是否是相同的 key,如果是相同的 key 就覆盖
4、扩容
当数据规模变大时,需要扩大哈希表内部的数组元素长度,减少哈希冲突,提高效率。一般可以使用素数作为数组元素长度,减少哈希冲突,数据规模变大时,将长度变成原来长度的两倍。
考虑到长度扩大两倍时,就不是一个素数了,有以下素数数组长度可供参考:
下界 | 上界 | 素数 |
2^5 | 2^6 | 53 |
2^6 | 2^7 | 97 |
2^7 | 2^8 | 193 |
2^8 | 2^9 | 389 |
2^9 | 2^10 | 769 |