数组
定义:数组是种线性表数据结构,他用一组连续的内存空间,来存储一组具有相同类型的数据。对内存的要求比较高
首先是线性表:每个数据只有前后俩个方向
连续的内存空间和相同的数据类型:可以支持下标随机访问。
插入操作:O(n),由于涉及到后续数组元素的迁移
如果是无序的话,假设现在要向 数组a中第三个元素后插入一个数据,我们可以直接将第四个元素一直数组末尾,然后将数据放入第四个元素,时间复杂度为O(1)
删除操作:O(n) ,但删除一个元素后,后续的元素需要进行数据的搬移。
优化:我们可以先将该位置的元素标记为已删除,当数组的空间不够时,可以触发一次真正的操作,这样就减少了数组的搬移。java中的标记回收垃圾算法于此类似。
链表
定义:通过指针将零散的内存块串联起来使用。对内存要求比较低。
节点:内存块
后继指针next:记录下一个节点的位置
头结点:链表的第一个节点
尾节点:链表的最后一个节点
单链表:每个节点有一个next节点,尾节点指向null。复杂度:查询O(n ) , 删除:O(1),插入O(1)
循环链表:在单链表的基础上,尾节点不指向null ,而是指向头结点
双向链表:在单链表的的基础上,每个节点加了pre 节点,指向上一个节点
常用的算法以及解决:
回文字符串:使用快慢两个指针找到链表中点,慢指针每次前进一步,快指针每次前进两步。在慢指针前进的过程中,同时修改其 next 指针,使得链表前半部分反序。最后比较中点两侧的链表是否相等。
所有的数据结构都可以归结为俩个数据结构,一个是数组,一个是链表
列数组 | 列表 | |
---|---|---|
优势 | 随机访问 | 只能顺序访问 |
劣势 | 需要连续的空间 | 需要的空间可以不连续 |
栈
定义:栈主要包含两个操作, 入栈和出栈, 也就是在栈顶插入一个数据和从栈顶删除一个数据。先进后出
出栈与入栈的复杂度均为O(1)比如
应用
函数调用栈,用来存储函数的参数以及返回值等
求运算表达式:
实际上, 编译器就是通过两个栈来实现的。 其中一个保存操作数的栈, 另一个是保存运算符的栈。我们从左向右遍历表达式, 当遇到数字, 我们就直接压入操作数栈; 当遇到运算符, 就与运算符栈的栈顶元素进行比较。
如果比运算符栈顶元素的优先级高, 就将当前运算符压入栈; 如果比运算符栈顶元素的优先级低或者相同, 从运算符栈中取栈顶运算符, 从操作数栈的栈顶取 2 个操作数, 然后进行计算, 再把计算完的结果压入操作数栈, 继续比较。
用来判断()表达式是否正确。
比如, {[{}]}或 [{()}([])] 等都为合法格式, 而{[}()] 或 [({)] 为不合法的格式。 那我现在给你一个包含三种括号的表达式字符串, 如何检查它是否合法呢?
这里也可以用栈来解决。 我们用栈来保存未匹配的左括号, 从左到右依次扫描字符串。 当扫描到左括号时, 则将其压入栈中; 当扫描到右括号时, 从栈顶取出一个左括号。 如果能够匹配, 比如“(”跟“)”匹配, “[”跟“]”匹配, “{”跟“}”匹配, 则继续扫描剩下的字符串。 如果扫描的过程中, 遇到不能配对的右括号, 或者栈中没有数据, 则说明为非法格式。
当所有的括号都扫描完成之后, 如果栈为空, 则说明字符串为合法格式; 否则, 说明有未匹配的左括号, 为非法格式
实现
public class ArrayStack {
private String [] items;
private int count; //栈内数量
private int size;
public ArrayStack(int size){
items = new String[size];
count = 0;
this.size = size;
}
public boolean push(String param){
if(size == count){
return false;
}
items[count++] = param;
return true;
}
public String pop(){
if(count ==0){
return null;
}
return items[--count];
}
}
队列:
定义:队列跟栈一样, 也是一种抽象的数据结构。 它具有先进先出的特性, 支持在队尾插入元素, 在队头删除元素
实现:
public class ArrayQueue<T> {
private Object[] items;
private int tail ;
private int head;
private int size;
public ArrayQueue(int size){
this.size = size;
tail = head = 0 ;
items = new Object[size];
}
public boolean enqueue(T t){
//队列满
if(tail == size && head == 0){
return false;
}
//由于不断的进行入队和出队,导致head 一直向后移,当tail 指向末尾时 , 此时需要进行数据的迁移
if(tail == size){
for (int i=head;i<=tail;i++){
items[i-head] = items[i];
}
tail = tail - head;
head = 0 ;
}
items[tail++] = t;
return true;
}
public T dequeue(){
if(tail == head){
return null;
}
return (T) items[head++];
}
}
循环队列:
重点是判空与队列是否满,判空与上述一样是 tail == head, 判断是否满:(tail + 1)%n =head; 会浪费一个空间。。
public boolean enqueue(T t){
//队列满
if((tail+1)% size == head){
return false;
}
items[tail]= t;
tail = (tail+1) %size ;
return true;
}
public T dequeue(){
if(tail == head){
return null;
}
Object item = items[head];
head = (head+1) %size;
return (T) item;
}
并发队列和阻塞队列。
应用:
线程池,对象池,连接池
跳表:
对于一个单链表来讲, 即便链表中存储的数据是有序的, 如果我们要想在其中查找某个数据, 也只能从头到尾遍历链表。 这样查找效率就会很低, 时间复杂度会很高, 是 O(n)
可以实现快速查找,插入和删除。
分析:
空间复杂度为O(n)
查询、删除、添加的时间复杂度为O(logn)
问题:当我们不停的向跳表中插入数据是,如果不更新索引,就有可能出现2个索引节点之间的数据非常多,甚至可能会退化成单链表。
当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中。如何选择加入哪些索引层呢?
我们通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。
散列表
散列表用的是数组支持按照下标随机访问数据的特性, 所以散列表其实就是数组的一种扩展, 由数组演化而来。 可以说, 如果没有数组, 就没有散列表。一般是通过对key 来进行hash,然后通过特定的算法 根据hash找到对应的下标,然后放入。
散列函数设计原则:
1 经过hash后得到的值为非负数,因为数组的的下标为非负数
2 如果key1 == key1 ,那么hash(key1) == hash(key2)
3 如果key1 != key2 , 那么hash(key1) != hash(key2) //这个很难保证
散列冲突解决方法:
1 开放寻址法:如果经过计算得到的hash值对应的下标已经有数据了,我们可以重新探测一个位置。
探测位置的方法:
直接向后找,如果有空的,就插入。
二次探测 : 跟线性探测很像, 线性探测每次探测的步长是 1, 那它探测的下标序列就是hash(key)+0, hash(key)+1, hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”, 也就是说, 它探测的下标序列就是 hash(key)+0, hash(key)+1*1 , hash(key)+2*2 ……
双重散列:使用多个hash函数,如果第一个哈希函数计算得到的已经有值,那么再使用第二个,知道找到符合条件的
但是使用了开放寻址法,删除就不能直接删除了,直接删除会对查询造成影响,我们只可以对该元素标记为已删除。
适用于装载因子很小,数据量很小的场景,java 的 threadLocalMap 使用的就是该方法
2 拉链法 :将数组中每个下标对应的元素都设置为链表,如果有冲突,就放入链表。优化:可以使用跳表,红黑树等数据结构来代替链表,即使最后所有数据全放在一个bucket中,查询效率优势logN,而不是O(n)。java中的HashMap使用的就是拉链法,当链表中元素个数大于8的时候,就会转化为红黑树,小于8的时候,红黑树会转化为链表
降低扩容效率:
如果负载因子过大的话,此时需要进行扩容,使用一次性扩容,会导致某次插入变得很慢,我们可以使用分批次的扩容。
当装载因子触达阈值之后, 我们只申请新空间, 但并不将老的数据搬移到新散列表中。当有新数据要插入时, 我们将新数据插入新散列表中, 并且从老的散列表中拿出一个数据放入到新散列表。 每次插入一个数据到散列表, 我们都重复上面的过程。 经过多次插入操作之后, 老的散列表中的数据就一点一点全部搬移到新散列表中了。 这样没有了集中的一次性数据搬移, 插入操作就都变得很快了。
查询的话,可以先去新的哈希表中进行查询,查不到的话,在去老的中查。
散列表这种数据结构虽然支持非常高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的。也就说,它无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中,然后排序,再遍历。如果我们希望散列表可以有某种顺序,那么可以将散列表和链表结合起来。例如:java中的LinkedHashMap ,就是利用双向链表和散列表组合得。redis有序集合使用的是跳表和散列表结合