数组
数组(Array) 是一种线性表(Linear List)
数据结构。它用一组连续的内存空间(对内存要求比较高),来存储一组具有相同类型的数据。
因如上特点,通过寻址公式
,可随机访问数组中元素,时间复杂度为O(1)
。但为保证连续性,在数组中删除插入数据时,需要做大量数据搬移工作,时间复杂度是O(n)
小技巧:插入操作做成两个元素互换;删除操作先记录下已删除的数据再统一删除。(JVM标记清除垃圾回收算法)
数组从0开始编号:考虑寻址公式 a[k]_address = base_address + k * type_size,便于计算。另历史原因,java沿用C习惯
ArrayList(容器类):将很多数组操作的细节封装
起来,如插入删除搬移数据。支持动态扩容(扩为1.5倍)
。但扩容耗时,最好事先指定数据大小。
Array优势:省去自动装箱拆箱的性能消耗,多维数组更直观,操作简单用不到ArrayList大部分方法时可直接用数组。
链表
链表(Linked List)通过指针将一组零散的内存块
串联起来使用。
结点:即内存块,存储数据并记录链上下一个结点的地址,记录下一个结点地址的指针叫后继指针next
。头结点记录链表基地址,尾结点指向空地址NULL。
单链表 的插入删除
操作只需要考虑相邻结点的指针改变,时间复杂度O(1)
单链表 随机访问
需根据指针一个结点一个结点依次遍历,时间复杂度O(n)
循环链表 尾结点指针指向链表头结点,处理环形结构的数据
方便。
双向链表 每个结点有后继指针next
和前驱指针prev
,需额外两个控件存储后继和前驱指针地址,占内存
。但支持双向遍历
,操作灵活,查找遍历高效。
LinkedHashMap实现用到双向链表结构。
空间换时间设计思想:内存充足时可选择空间复杂度较高时间复杂度较低的算法或数据结构,否则相反。
数组与链表:数组连续存储对CPU缓存友好
,链表不友好,链表需存储指向下一个结点的指针地址更耗内存
,且对链表频繁插入删除容易造成内存碎片(Java频繁GC)
;数组大小固定,链表没有大小限制天然支持扩容;
指针:将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。
带头链表不管链表是否为空,head指针一直指向不存储数据的哨兵结点。
因为哨兵结点一直存在,所以插入/删除第一个结点或其他结点都可以统一为相同的代码实现,这种利用哨兵简化编程难度的技巧很常用,如插排,归并,动态规划等。
常见的缓存淘汰策略:先进先出( FIFO),最少使用(LFU),最近最少使用(LRU,可考虑有序单链表实现,新入列存表头)
栈
栈(Stack)结构 操作受限的线性表数据结构,只允许在一端插删数据(入栈出栈),先进后出,后进先出
。
顺序栈 用数组实现,操作栈顶指针
,入栈出栈空间复杂度O(1);可改造以支持动态扩容,则入栈需重新申请内存和数据搬移时,时间复杂度O(n),按摊还分析法
,入栈均摊时间复杂度为O(1)。
链式栈 用链表实现,入栈出栈空间复杂度O(1)。
应用:函数调用栈;编译器实现表达式求值;括号匹配;使用两个栈实现浏览器前进后退
队列
队列(Queue) 同为操作受限的线性表数据结构,支持入队出队,先进先出
。
顺序队列 用数组实现有界队列
,操作队头(head)指针
,队尾(tail)指针
,判断队空(head == tail)队满(tail == n)需考虑数据搬移的成本
,入队队满时统一处理搬移。
大部分资源有限的场景如线程池请求排队,数据库连接池等,没有空闲资源时基本上都可以通过“队列”实现请求排队。
链式队列 用链表实现,可实现一个支持无限排队的无界队列
(过多等待响应时间过长)
循环队列 避免数据搬移,关键要确定好队空(head == tail)和队满((tail + 1)%n==head)的判定条件
阻塞队列队列为空阻塞队头读取操作,队列已满阻塞插入操作,可用于实现生产者-消费者
模型。
并发队列线程安全的队列
应用:具有额外特性的队列如循环队列,阻塞队列,并发队列,在很多偏底层系统框架中间件的开发中起关键作用。如高性能队列Disruptor,Linux环形存储(循环并发队列);Java concurrent 并发包用ArrayBlockingQueue实现公平锁。
跳表
跳表(Skip List) 是一种加多级索引结构
的链表,是一种优秀的动态数据结构,实现了基于链表的“二分查找”
,支持快速插入删除查找,实现不复杂
查询数据的时间复杂度:O(logn) , 🌰:n个数据,建h级索引,最高级索引有两个结点,故n/(2h)=2,h=(log2n)-1,设每层最多遍历m个结点,则时间复杂度O(mlogn)
空间复杂度:O(n) ,为等比数列相加和,🌰:若每2个结点抽1个,则和为(n/2+n/4+n/8+...+8+4+2)=n-2,间隔越大和越小,且存储的数据对象占用空间越大,索引空间占比越小
动态更新: 通过随机函数
维护索引与原始链表大小间的平衡性,随机函数决定将结点插入到哪几级索引。避免出现某几个索引结点之间数据非常多复杂度退化(极端情况退化成单链表),查找插入删除性能下降
跳表结构查询删除插入数据实现较简单,可读性好,且
区间查找数据
效率优于红黑树;但红黑树出现早,很多编程语言中的Map类型
都是通过红黑树实现,而跳表没有现成的实现
散列表
散列表(Hash Table) 源于数组,利用数组支持按下标随机访问元素的特性,借助散列函数进行拓展
散列函数:hash(key) key为元素键值,hash(key)为经过散列函数计算得到的散列值 ,要求
- 散列函数计算得到的散列值是一个非负整数
- 如果 key1 = key2,那hash(key1)==hash(key2)
- 如果key1≠key2,那hash(key1)≠hash(key2)
第三点要求即避免散列冲突
**散列冲突: ** 想完全避免散列冲突几乎不可能,常用的解决冲突的办法有两类开放寻址法(open addressing)
和链表法(chaining)
- 开放寻址法
- 核心思想:重新探测一个空闲位置插入数据
-
线性探测(Linear Probing) 往散列表
插入数据
时,若某个数据经过散列函数散列后,存储位置已被占用,则从当前位置开始依次往后查找看是否有空闲位置,找到为止;查找元素
同,如果遍历到数组中空闲位置还没找到说明要查找到元素并没有在散列表中;删除元素
将元素特殊标记为deleted;极端情况
需探测整张散列表,最坏情况时间复杂度O(n) - 二次探测(Quadratic probing) 较线性探测,步长变为原来二次方 hash(key)+0, hash(key)+12, hash(key)+22
- 双重散列(Double hashing) 使用一组散列函数 hash1(key), hash2(key), hash3(key)... ...
- 优缺点:数据存储在数组中,查询快;不含指针,
序列化
容易实现;数组存储内存利用率低;删除数据麻烦;冲突代价高,装载因子不能太大 - 适用:适合数据量比较小,装载因子小的时候,如Java中的
ThreadLocalMap
用
装载因子
表示空位多少
散列表的装载因子 = 填入表中的元素个数 / 散列表的长度
- 链表法
- 核心思想:每个“桶”或者“槽”对应一条链表,所有散列值相同的元素放到相同槽位对应的链表中
- 时间复杂度:插入O(1),查找删除时间复杂度O(k)(链表长度k,理论上均匀散列函数k=n/m,m为槽点个数)
- 优缺点:链表结点需要时创建,内存利用率高;装载因子容忍度高;链表对较小数据因为要存储指针比较耗内存;零散分布内存不友好
- 适用:适合存储大对象,大数据量散列表
- 改良方案:将链表改为更高效的动态数据结构如
跳表
,红黑树
,这样即便都散列到一个桶内时间也不过O(logn),即可避免散列碰撞攻击
树
树(Tree) 里每个元素称为节点
,用来连线相邻节点的关系叫父子关系
,共有同一个父节点的节点间称为兄弟节点
,没有父节点的节点称为根节点
,没有子节点的节点称为叶子节点
;
节点的高度 = 节点到叶子边数
节点深度 = 根节点到这个节点边树
节点层数 = 节点的深度 + 1
树的高度 = 根节点的高度
-
二叉树(只有左右两个子节点)
满二叉树: 除叶子节点,每个节点都有左右两个子节点
完全二叉树: 叶子节点都在最底下两层;最后一层叶子节点都靠左排列;除底层外各层节点个数达到最大
链式存储法: 每个节点有三个字段分别存储数据
,指向左子节点的指针
和指向右子节点的指针
,较常用
顺序存储法: 根节点位置i=1,左子节点2i=2,右子节点2i+1=3,类推;完全二叉树
运用顺序存储占用的是连续空间
省内存,非完全二叉树会浪费存储空间;数组存储方式不需要存储额外的左右子节点指针
,更省内存 -
二叉树的遍历
前序遍历: 节点->左子树->右子树
中序遍历: 左子树->节点->右子树
后序遍历: 左子树->右子树->节点
时间复杂度: O(n),因遍历过程每个节点最多被访问两次,与节点个数n成正比
代码示例:
void preOrder(Node* root) {
if (root == null) return;
print root // 此处为伪代码,表示打印 root 节点
preOrder(root->left);
preOrder(root->right);
}
void inOrder(Node* root) {
if (root == null) return;
inOrder(root->left);
print root // 此处为伪代码,表示打印 root 节点
inOrder(root->right);
}
void postOrder(Node* root) {
if (root == null) return;
postOrder(root->left);
postOrder(root->right);
print root // 此处为伪代码,表示打印 root 节点
}
-
二叉查找树(Binary Search Tree)
定义 树中任意节点,左子树中每个节点值小于这个节点值,右子树每个节点的值都大于这个节点的值-
查找操作
代码实现:
-
查找操作
public class BinarySearchTree {
private Node tree;
public Node find(int data) {
Node p = tree;
while (p != null) {
if (data < p.data) p = p.left;
else if (data > p.data) p = p.right;
else return p;
}
return null;
}
public static class Node {
private int data;
private Node left;
private Node right;
public Node(int data) {
this.data = data;
}
}
}
-
插入操作
代码实现:
public void insert(int data) {
if (tree == null) {
tree = new Node(data);
return;
}
Node p = tree;
while (p != null) {
if (data > p.data) {
if (p.right == null) {
p.right = new Node(data);
return;
}
p = p.right;
} else { // data < p.data
if (p.left == null) {
p.left = new Node(data);
return;
}
p = p.left;
}
}
}
-
删除操作
核心思想: 待删除节点没有子节点,则直接将父节点指向该节点的指针置null;待删除节点只有一个子节点,则更新父节点指向删除节点的指针,指向该子节点;待删除节点有两个节点,则找到该节点右子树中最小节点,替换到要删除的节点上
代码实现:
public void delete(int data) {
Node p = tree; // p 指向要删除的节点,初始化指向根节点
Node pp = null; // pp 记录的是 p 的父节点
while (p != null && p.data != data) {
pp = p;
if (data > p.data) p = p.right;
else p = p.left;
}
if (p == null) return; // 没有找到
// 要删除的节点有两个子节点
if (p.left != null && p.right != null) { // 查找右子树中最小节点
Node minP = p.right;
Node minPP = p; // minPP 表示 minP 的父节点
while (minP.left != null) {
minPP = minP;
minP = minP.left;
}
p.data = minP.data; // 将 minP 的数据替换到 p 中
p = minP; // 下面就变成了删除 minP 了
pp = minPP;
}
// 删除节点是叶子节点或者仅有一个子节点
Node child; // p 的子节点
if (p.left != null) child = p.left;
else if (p.right != null) child = p.right;
else child = null;
if (pp == null) tree = child; // 删除的是根节点
else if (pp.left == p) pp.left = child;
else pp.right = child;
}
- 重复数据处理办法
- 通过链表和支持动态扩容的数组等数据结构把值存储在同一个节点上
- 将相同数据插入右子树
中序遍历二叉查找树,可以输出有序数据序列,时间复杂度O(n),非常高效
要点补充:递归
递归需满足的三个条件
- 一个问题的解可分解为几个子问题的解 (思考时假设子问题已解决,屏蔽递归细节)
- 这个问题与分解之后的子问题,除数据规模不同,求解思路完全一样
- 存在递归终止条件
编写递归代码的关键点
- 写出递推公式
- 找到终止条件
n级台阶的走法:f(n) = f(n - 1) + f(n - 2),终止条件为f(1) = 1, f(2) = 2
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return f(n - 1) + f(n - 2);
}
递归代码需警惕的点
- 堆栈溢出 :递归求解数据规模较大,调用层次深一直压入栈,有堆栈溢出的风险。可在代码中限制递归调用
最大深度
解决该问题(实际跟当前线程剩余栈空间大小有关,深度较小适用)。 - 重复计算 : 可通过一个数据结构(如散列表)保存已求解过的f(k),调用时先检查。
- 时间效率
- 空间开销
- 脏数据造成的
无限递归
和递归环
参考:《数据结构与算法之美》王争