数据结构
- 数据结构的分类
线性结构:数据、栈、队列、链表
树结构:二叉树、二分搜索树、AVL、红黑树、Treap、Splay、堆、Trie(前缀树)、线段树、K-D树、并查集、哈夫曼树。。。
图结构:邻接矩阵、邻接表
数据结构 + 算法 = 程序
- 数据结构举例
数组
栈
队列
链表
二分搜索树
堆
线段树
Trie
并查集
AVL
红黑树
哈希表
数组
最大优点:快速查询
数据最好应用于“索引有寓意”的情况
但并不是所有有寓意的索引都适用于数组
数组也可以处理“索引没有寓意”的情况
动态数组实例
public class Array<E> {
private E[] data;
private int size;
/**
* 构造函数,传入数组的容量capacity构造Array
*
* @param capacity 容量
*/
public Array(int capacity) {
data = (E[]) new Object[capacity];
size = 0;
}
/**
* 无参构造函数,默认数组容量capacity=10
*/
public Array() {
this(10);
}
/**
* 获取数组中元素个数
*
* @return
*/
public int getSize() {
return size;
}
/**
* 获取数据的容量
*
* @return
*/
public int getCapacity() {
return data.length;
}
/**
* 返回数组是否为空
*
* @return
*/
public boolean isEmpty() {
return size == 0;
}
/**
* 向所有元素后添加一个新元素
*
* @param e 新元素
*/
public void addLast(E e) {
add(size, e);
}
/**
* 在所有元素前添加一个新元素
*
* @param e 新元素
*/
public void addFirst(E e) {
add(0, e);
}
/**
* 在第index个位置插入一个新元素e
*
* @param index 插入位置
* @param e 新元素
*/
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("Add failed. Require index >= 0 and index <= size.");
}
if (size == data.length) {
resize(2 * data.length); // 为数组扩容
}
for (int i = size - 1; i >= index; i--) {
data[i + 1] = data[i];
}
data[index] = e;
size++;
}
/**
* 获取index位置的元素
*
* @param index 位置
* @return
*/
public E get(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("Get failed. Index is illegal.");
}
return data[index];
}
/**
* 设置index位置的元素
*
* @param index
* @param e
*/
public void set(int index, E e) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("Set failed. Index is illegal.");
}
data[index] = e;
}
/**
* 查找数组中是否有元素e
*
* @param e 目标元素
* @return
*/
public boolean contains(E e) {
for (int i = 0; i < size; i++) {
if (data[i].equals(e)) return true;
}
return false;
}
/**
* 查找数组中元素e所在的索引,如果不存在元素e,则返回-1
*
* @param e 目标元素
* @return
*/
public int find(E e) {
for (int i = 0; i < size; i++) {
if (data[i].equals(e)) return i;
}
return -1;
}
/**
* 从数组中删除index位置的元素,返回删除的元素
*
* @param index 位置
* @return
*/
public E remove(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("Remove failed. Index is illegal.");
}
E e = data[index];
for (int i = index + 1; i < size; i++) {
data[i - 1] = data[i];
}
size--;
data[size] = null; // loitering objects != memory leak -- 闲散对象
// lazy方式处理remove
if (size == data.length / 4 && data.length / 2 != 0) {
resize(data.length / 2);
}
return e;
}
/**
* 从数组中删除第一个位置的元素,返回删除的元素
*
* @return
*/
public E removeFirst() {
return remove(0);
}
/**
* 从数组中删除最后一个位置的元素,返回删除的元素
*
* @return
*/
public E removeLast() {
return remove(size - 1);
}
/**
* 从数组中删除元素e
*
* @param e
*/
public void removeElement(E e) {
int index = find(e);
if (index != -1) remove(index);
}
@Override
public String toString() {
StringBuilder result = new StringBuilder();
result.append(String.format("Array:size = %d, capacity = %d\n", size, data.length));
result.append("[");
for (int i = 0; i < size; i++) {
result.append(data[i]);
if (i != size - 1) {
result.append(", ");
}
}
result.append("]");
return result.toString();
}
/**
* 为数组扩容,长度为newCapacity
*
* @param newCapacity 新长度
*/
private void resize(int newCapacity) {
E[] newData = (E[]) new Object[newCapacity];
for (int i = 0; i < size; i++) {
newData[i] = data[i];
}
data = newData;
}
}
时间复杂度
1 简单的时间复杂度
O(n),O(lng),O(nlogn),O(n^2)
大O描述的是算法的运行时间和输入数据之间的关系
O(n):n是nums中的元素个数,算法和n呈现线性关系
O是忽略常数,实际时间T = c1*n + c2
大O实际叫渐进时间复杂度,描述n趋近于无穷的情况
数组
添加操作:O(n)
addLast(e)—>O(1):与n无关,在常数时间内完成
addFirst(e)—>O(n)
add(index,e)—>O(n/2)=O(n)
删除操作:O(n)
removeLast(e)—>O(1):
removeFirst(e)—>O(n)
remove(index,e)—>O(n/2)=O(n)
修改操作:
set(index,e)—>O(1)
查询操作:
get(index)—>O(1)
contains(e)—>O(n)
find(e)—>O(n)
总结:
增:O(n)
删:O(n)
改:已知索引O(1);未知索引O(n)
查:已知索引O(1);未知索引O(n)
2 均摊复杂度
resize()—>O(n)
addLast()、removeLast()的均摊复杂度为O(1)
3 复杂度震荡
addLast与removeLast同时进行造成复杂度震荡
产生原因:removeLast时,resize的过于着急(Eager)
解决方案:Lazy
当size == capacity / 4时,才将capacity减半
栈 Stack
栈也是一种线性结构
相比数组,栈对应的操作是数组的子集
只能从一端添加元素,也只能从一端取出元素
(后进先出,先进后出)Last In First Out — LIFO
这一端称为栈顶
应用举例:
1.Undo操作(撤销)
2.程序调用的系统栈:方法中断调用另一个方法
3.括号匹配
复杂度分析:
int getSize(); —> O(1)
boolean isEmpty(); —> O(1)
void push(E e); —> O(1)
E pop(); —> O(1)
E peek(); —> O(1)
队列 Queue
队列也是一种线性结构
相比数组,队列对应的操作是数组的子集
只能从一端(队尾)添加元素,只能从另一端(队首)取出元素
队列是一种先进先出的数据结构(先到先得)
First In First Out — FIFO
数组队列复杂度分析:
int getSize();—> O(1)
boolean isEmpty();—> O(1)
void enqueue(E e);—> O(1) 均摊
E dequeue();—> O(n) 均摊
E getFront();—> O(1)
循环队列
front == tail 队列为空
(tail + 1) % c == front 队列满
Capacity中会浪费一个空间
时间复杂度:
int getSize();—> O(1)
boolean isEmpty();—> O(1)
void enqueue(E e);—> O(1) 均摊
E dequeue();—> O(1) 均摊
E getFront();—> O(1)
动态数组、栈、队列、链表区别
动态数组、栈、队列:底层都是依托静态数组,靠resize解决固定容量问题
链表:真正的动态数据结构,最简单的动态数据结构
树结构 – 二分搜索树
1、树结构 - ?
举例:文件夹/图书馆/组织架构等
2、二分搜索树 - Binary Search Tree
二叉树:
1).和链表一样,动态数据结构
2).具有唯一根节点
3).每个节点最多有两个子节点:左孩子/右孩子
4).没有子节点的节点称为叶子节点
5).每个节点最多有一个父节点,只有根节点没有父节点
二叉树具有天然的递归结构:
每个节点的左子树/右子树也是二叉树
二叉树不一定是“满”的
一个节点也是二叉树
空也是二叉树
3、二分搜索树
1).二分搜索树树二叉树
2).每个节点的值
大于其左子树的所有节点的值
小于其右子树的所有节点的值
3).每一棵子树也是二分搜索树
4).存储的元素必须有可比较性
⚠️注意:我们的二分搜索树不包含重复元素!!!
如果想包含重复元素,需要改变定义:
左子树小于等于节点的值,或右子树大于等于节点的值
二分搜索树添加元素的非递归写法,和链表很像
在二分搜索树中,递归比非递归实现简单
4、递归
1).先写递归终止条件
2).再写递归逻辑代码
5.遍历
1)前序遍历:最自然/最常用的遍历方式
访问该节点
tranverse(node.left)
tranverse(node.right)
2)中序遍历:遍历结果是顺序的
tranverse(node.left)
访问该节点
tranverse(node.right)
3)后序遍历:释放内存的应用,先释放子程序,再释放本身
tranverse(node.left)
tranverse(node.right)
访问该节点
6.深度优先遍历/广度优先遍历(层序遍历)
广度优先遍历的意义:更快的找到问题的解,常用于算法设计中-最短路径(无全图)
7.删除操作
找到后继节点或前驱节点,替换原节点
链表
- 1 数据存储在“节点”(Node)上,包含两部分:数据以及下一个节点
优点:是真正的动态,不需要处理固定容量问题
缺点:丧失了随机访问的能力
数组在内存中是连续的空间,所以可以根据索引对应的偏移量直接找到数据存储的内存地址,时间复杂度为O(1)
链表是靠next链接下一个节点的,而每个节点所在内存地址又不是连续,所以时间复杂度O(n)
链表不适合索引有寓意的情况
技巧:虚拟头节点 -> dummyHead = new Node(null, null)
时间复杂度:
addLast(e) —> O(n)
addFirst(e) —> O(1)
add(index, e) —> O(n)
removeLast() —> O(n)
removeFirst() —> O(1)
remove(index) —> O(n)
set(index, e) —> O(n)
get(index) —> O(n)
contains(e) —> O(n)
- 2 链表栈与数组栈对比
相同:时间复杂度都是O(1)
不同点:
数组栈在resize中重新定义新数组比较耗时
链表栈在new node时可能会比较耗时
而new操作一般都比resize更耗时,因此,在数据量比较小时,链表栈更省时间,而比较大时,则数组栈更优
结论,两者没有很大的时间差异,最多几倍,不会几百倍级别
- 3 链表队列
链表实现队列思想:
1.在链表队尾新增标记元素tail
2.head端作为队首出列,tail端作为队尾入列
3.因为是两端操作,因此不再适用dummyHead来实现虚拟头节点
注意:由于没有dummyHead,要注意链表为空的情况
链表队列与循环队列的区别:
相同:时间复杂度都是O(1)
不同点:
循环队列采用循环数组实现,在resize中比较耗时拷贝新数组
链表队列采用新增尾节点实现,在new Node时比较耗时
结论:二者相差不是很大
集合和映射
- 1.集合时间复杂度
项目 | LinkedListSet | BSTSet(平均) | 最差 |
---|---|---|---|
Add | O(n) | O(h)/O(logn) | O(n) |
Contains | O(n) | O(h)/O(logn) | O(n) |
Remove | O(n) | O(h)/O(logn) | O(n) |
二分搜索树最差的情况下,将退化成链表结果,因此时间复杂度将变为O(n)
为解决这个问题,因此可以创建平衡二叉树来完成
- 2.有序集合和无序集合
有序集合中元素具有顺序性 ⬅️ 基于搜索树的表现
无序集合中元素没有顺序性 ➡️ 基于哈希表的表现
- 3.映射(字典)
链表实现的映射与二分搜索树实现的映射的时间复杂度类似于链表集合O(n)与二分搜索树集合O(logn)
- 4.有序映射与无序映射
有序映射中的键具有顺序性 ⬅️ 基于搜索树的实现
无序映射中的键没有顺序性 ➡️ 基于哈希表的实现
堆和优先队列
- 1.优先队列
普通队列:先进先出 后进后出
优先队列:出队顺序和入队顺序无关,和优先级相关
- 2.时间复杂度比较
项目 | 入队 | 出队(拿出最大元素) |
---|---|---|
普通线性结构 | O(1) | O(n) |
顺序线性结构 | O(n) | O(1) |
堆 | O(logn) | O(logn) |
- 3.二叉堆:是一棵完全二叉树
满二叉树:每一个层的结点数都达到最大值,都有左孩子和右孩子,直到最后一层
完全二叉树:不一定是满二叉树,但它不满(缺失)节点的部分一定在树结构的右下侧
特点:
堆中任一节点的值总是不大于其父亲节点的值
上一层节点值不一定大于下层节点的值
使用数组存储二叉树
父节点( i ) = ( i - 1 ) / 2
左孩子( i ) = 2 * i + 1
右孩子( i ) = 2 * i + 2
- 4.时间复杂度
add和extractMap都是O(logn),而且是最差的情况
- 5.replace:取出最大元素后,放入新元素
实现:可以先extractMax,再add,两次O(logn)的操作
优化:可以直接将堆顶元素替换以后直接siftDown,一次O(logn)的操作
- 6.heapify:将任意数组整理成堆的形状
将n个元素逐个插入到一个空堆中,算法复杂度是O(nlogn)
heapify的过程,算法复杂度是O(n),具体是从最后一个元素的父亲节点到根节点,逐个下沉操作即可
- 7.java自带的priorityQueue默认是最小堆
- 8.二叉堆可以扩展为d叉堆(d-ary heap)
索引堆
二项堆
斐波那契堆
- 9.从广义队列角度划分,栈也可以作为队列的一种
线段树(区间树)
- 1.举例:
1)区间染色
M次染色后,可以在[i,j]区间内看到多少种颜色?
2)区间查询
在[i,j]区间内查询最大值/最小值
- 2.时间复杂度分析:
项目 | 数组实现 | 线段树实现 |
---|---|---|
更新 | O(n) | O(logn) |
查询 | O(n) | O(logn) |
- 3.线段树不一定是一棵满二叉树,也不一定是一棵完全二叉树,但是一棵平衡二叉树
平衡二叉树:最大深度与最小深度的差最大为1
堆也是一棵平衡二叉树
- 4.区间有n个元素,数组表示需要有4n的空间
线段树不考虑添加元素,即区间固定,使用4n的静态空间即可
- 5.时间复杂度
项目 | 数组 | 线段树 |
---|---|---|
更新 | O(n) | O(logn) |
查询 | O(n) | O(logn) |