Java之数据结构


在这里插入图片描述

Collection 集合接口

1、List接口

List是一个允许重复元素的指定索引、有序集合。

ArrayList

定义与特点

1、ArrayList实现了List接口的可变大小的数组。(数组可动态创建,如果元素个数超过数组容量,那么就创建一个更大的新数组)
2、它允许所有元素,包括null
3、它的size, isEmpty, get, set, iterator,add这些方法的时间复杂度是O(1),如果add n个数据则时间复杂度是O(n)
4、ArrayList没有同步方法

remove(index):
使用System.arraycopy把需要删除index后面的都往前移一位然后再把最后一个去掉

LinkedList

定义与特点

1、LinkedList是一个实现了List接口的链表维护的序列容器
2、允许null元素。
3、LinkedList提供额外的get,remove,insert方法在LinkedList的首部或尾部。这些操作使LinkedList可被用作堆栈(stack),队列(queue)或双向队列(deque)。
4、LinkedList没有同步方法。如果多个线程同时访问一个List,则必须自己实现访问同步。

单向链表
双向链表
结构 : 含有当前节点的值与前后节点的指针,添加和删除直接断开指针,改变指针指向。

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;
    }
}

以下情况使用 ArrayList :

频繁访问列表中的某一个元素。
只需要在列表末尾进行添加和删除元素操作。

以下情况使用 LinkedList :

你需要通过循环迭代来访问列表中的某些元素。
需要频繁的在列表开头、中间、末尾等位置进行添加和删除元素操作。

数组与链表区别

相同点
两种数据结构都属于线性表。所谓线性表,就是所有数据都排列在只有一个维度的“线”上,对其中任意一个节点来说,除了头尾,只有一个前趋,也只有一个后继。

数组:

  • 数组占用的是一块连续的内存区
  • 查找访问:数组因为内存地址连续,支持随机访问,时间复杂度O(1) ,性能较好
  • 增加删除:因为数组的内存连续性,增加删除都是往后/往前移动后面的元素,性能差
  • 内存预读方面:数组会提前预读数组申请的内存大小到缓存中,大小固定。申请过大浪费资源,申请较小,容易出界

链表:

  • 而链表在内存中,是分散的,因为是分散的,由指针串联
  • 查找访问:链表没有下标的概念,只有前后节点指针,所以访问时只能迭代依次查找,时间复杂度O(n),性能差
  • 增加删除: 只需要断开指针,改变指针指向元素,性能快
  • 内存空间动态申请,根据增加删除操作动态操控内存空间

Set接口

在这里插入图片描述
不包含重复的元素的Collection,即任意的两个元素e1和e2都有e1.equals(e2)=false,Set最多有一个null元素。

HashSet

定义与特点

HashSet 基于 HashMap 来实现的,是一个不允许有重复元素的集合。
HashSet 允许有 null 值。
HashSet 是无序的,即不会记录插入的顺序。
HashSet 不是线程安全的, 如果多个线程尝试同时修改 HashSet,则最终结果是不确定的。 您必须在多线程访问时显式同步对 HashSet 的并发访问。

LinkedHashSet : 保持顺序的 Set集合

TreeSet

TreeSet即是一组有次序的集合,如果没有指定排序规则Comparator,则会按照自然排序。(自然排序即e1.compareTo(e2) == 0作为比较)

Queue队列

队列是一种先进先出的数据结构,元素在队列末尾添加,在队列头部删除。Queue接口扩展自Collection,并提供插入、提取、检验等操作。

offer:向队列添加一个元素
poll:移除队列头部元素(队列为空返回null)
remove:移除队列头部元素(队列为空抛出异常)
element:获取头部元素
peek:获取头部元素

Map接口

在这里插入图片描述

定义与特点

Map是图接口,存储键值对映射的容器类。Map提供key到value的映射。一个Map中不能包含相同的key,每个key只能映射一个value

接口定义
public interface Map<K,V> {
    ... 
    interface Entry<K,V> {
        K getKey();
        V getValue();
        ...
    } 
}

泛型<K,V>分别代表key和value的类型。这时候注意到还定义了一个内部接口Entry,其实每一个键值对都是一个Entry的实例关系对象,所以Map实际其实就是Entry的一个Collection,然后Entry里面包含key,value。再设定key不重复的规则,自然就演化成了Map。

迭代方式

方式一:Set keySet() 获得所有key的集合然后通过key访问value

Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");

Set<String> keySet = map.keySet();//先获取map集合的所有键的Set集合
Iterator<String> it = keySet.iterator();//有了Set集合,就可以获取其迭代器。

while(it.hasNext()) {
       String key = it.next();
       String value = map.get(key);//有了键可以通过map集合的get方法获取其对应的值。
       System.out.println("key: "+key+"-->value: "+value);//获得key和value值
}

方式二:Collection values() 获得value的集合

Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");

Collection<String> collection = map.values();//返回值是个值的Collection集合
System.out.println(collection);

方式三:Set< Map.Entry< K, V>> entrySet() 获得key-value键值对的集合

Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");

//通过entrySet()方法将map集合中的映射关系取出(这个关系就是Map.Entry类型)
Set<Map.Entry<String, String>> entrySet = map.entrySet();
//将关系集合entrySet进行迭代,存放到迭代器中                
Iterator<Map.Entry<String, String>> it = entrySet.iterator();

while(it.hasNext()) {
       Map.Entry<String, String> me = it.next();//获取Map.Entry关系对象me
       String key = me.getKey();//通过关系对象获取key
       String value = me.getValue();//通过关系对象获取value
}

HashTable

定义与特点

Hashtable继承Map接口,实现一个key-value映射的哈希表。任何非空(non-null)的对象都可作为key或者value。
添加数据使用put(key, value),取出数据使用get(key),这两个基本操作的时间开销为常数。HashTable是同步方法,线程安全但是效率低。

为什么重写hashCode 也必须重写equals

相同的对象有不同的hashCode,对哈希表的操作会出现意想不到的结果(期待的get方法返回null),要避免这种问题,只需要牢记一条:要同时复写equals方法和hashCode方法,而不要只写其中一个.
防止重复,也要满足规则:

  • 如果两个对象相同,即obj1.equals(obj2)=true,则它们的hashCode必须相同
  • 如果两个对象不相同,即obj1.equals(obj2)=false,则它们的hashCode不一定不相等
  • 如果两个对象的hsahCode相等,则obj1.equals(obj2)=true 不一定成立
  • 如果两个对象的hsahCode不相等,则obj1.equals(obj2)=true 一定不成立

HashMap(下面详解)

TreeMap

  • 内部红黑树实现
  • key-value不为空
  • TreeMap有序

WeakHashMap

WeakHashMap是一种改进的HashMap,它对key实行“弱引用”,如果一个key不再被外部所引用,那么该key可以被GC回收

ConcurrentHashMap

JDK1.7 :

底层采用数组+链表的存储结构。其包含两个核心静态内部类 Segment和HashEntry
Segment:分段锁。继承ReentrantLock用来充当锁的角色,类似HashMap的结构,内部拥有一个Entry数组,数组中每个元素又是一个链表。每个 Segment 对象守护每个散列映射表的若干个桶。
HashEntry用来封装映射表的键 / 值对;每个桶是由若干个 HashEntry 对象链接起来的链表
ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问

JDK1.8
利用CAS+Synchronized来保证并发更新的安全,底层采用数组+链表+红黑树的存储结构。
ConcurrentHashMap 成员变量使用 volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用 CAS 操作和 synchronized 结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。

CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

HashMap详解

定义与特点

HashMap就是最基础最常用的一种Map,它无序,以散列表(数组+链表/红黑树)的方式进行存储,存储内容是键值对映射。是一种非同步的容器类,故它的线程不安全。

原理结构

通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。
变量
在这里插入图片描述
常量
在这里插入图片描述

HashMap 怎么设定初始容量大小

一般如果new HashMap() 不传值,默认大小是 16,负载因子是 0.75, 如果自己传入初始大小 k,初始化大小为 大于 k 的 2 的整数次方,例如如果传 10,大小为 16

Hash计算和碰撞问题

Hash函数计算

计算出的哈希值需要满足:
(1)结果为Int类型
(2)数组长度范围内(0~n-1)
(3)尽可能充分利用数组中每一个位置

哈希函数设计

hash 函数是先拿到通过 key 的 hashcode,是 32 位的 int 值,然后让 hashcode 的高 16 位和低 16 位进行异或操作

为什么如此设计

这里就是解决Hash的的冲突的函数,通过让高位参与运算使得结果尽可能不一样,均匀分布,充分利用数组中每一个位置。

为什么不能用hashCode当key

因为 key.hashCode()函数调用的是 key 键值类型自带的哈希函数,返回 int 型散列值。int 值范围为-2147483648~2147483647,前后加起来大概 40 亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。你想,如果 HashMap 数组的初始大小才 16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。

为什么 HashMap 的数组长度要取 2 的整数幂?
(n - 1) & hash的计算方法,其中n是集合的容量,hash是添加的元素经过hash函数计算出来的hash值。
符号&是按位与的计算,这是位运算,特别高效,按位与&的计算方法是,只有当对应位置的数据都为1时,运算结果也为1,当HashMap的容量是2的n次幂时,(n-1)的2进制也就是1111111***111这样形式的,这样与添加元素的hash值进行位运算时,能够充分的散列,使得添加的元素均匀分布在HashMap的每个位置上,减少hash碰撞

扰动函数
把哈希值右移 16 位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性,让数据元素更加均衡的散列,减少碰撞。

实例:

// 计算二次Hash    
int hash = hash(key.hashCode());

// 通过Hash找数组索引
int i = indexFor(hash, table.length);

1、第一次Hash:String.hashCode()
JDK的String的Hash算法。
2、第二次Hash:hash(key.hashCode())

static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

Hash冲突及解决

Hash冲突
如果两个不同对象的hashCode相同,这种现象称为冲突

Hash冲突解决

  • 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
  • 再哈希法(上面代码)
  • 链地址法(HashMap目前采用的方法)

HashMap put()解析

在这里插入图片描述
总流程
(1) 判断数组是否为空,为空进行初始化;
(2) 不为空,计算 k 的 hash 值,通过(n - 1) & hash计算应当存放在数组中的下标 index;
(3) 查看 table[index] 是否存在数据,没有数据就构造一个 Node 节点存放在 table[index] 中;
(4) 存在数据,说明发生了 hash 冲突(存在二个节点 key 的 hash 值一样), 继续判断 key 是否相等,相等,用新的 value 替换原数据(onlyIfAbsent 为 false);
(5) 如果不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;
(6) 如果不是树型节点,创建普通 Node 加入链表中;判断链表长度是否大于 8, 大于的话链表转换为红黑树;
(7) 插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。

初始化时怎么设定HashMap的大小

一般如果new HashMap() 不传值,默认大小是 16,负载因子是 0.75, 如果自己传入初始大小 k,初始化大小为 大于 k 的 2 的整数次方,例如如果传 10,大小为 16

HashMap get()解析

计算key的hashcode,再用计算的结果二次hash,通过indexFor(hash, table.length);找到Entry数组的索引i。然后遍历以table[i]为头节点的链表,如果发现有节点的hash,key都相同的节点时,取出该结点的值。

JDK1.7 & JDK1.8 对比

Hashmap性能优化:
(1)最重要的一点是底层结构不一样,1.7是数组+链表,1.8则是数组+链表+红黑树结构;
原因:防止发生 hash 冲突,链表长度过长,将时间复杂度由O(n)降为O(logn)

(2)插入键值对的put方法的区别,1.8中会将节点插入到链表尾部,而1.7中是采用头插
原因(头插法弊端)
因为 1.7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;
A 线程在插入节点 B,B 线程也在插入,遇到容量不够开始扩容,重新 hash,放置元素,采用头插法,后遍历到的 B 节点放入了头部,这样形成了环

(3)jdk1.7中的hash函数对哈希值的计算直接使用key的hashCode值,而1.8中则是采用key的hashCode异或上key的hashCode进行无符号右移16位的结果,避免了只靠低位数据来计算哈希时导致的冲突,计算结果由高低位结合决定,使元素分布更均匀
原因

(4) 扩容策略:1.7中是只要不小于阈值就直接扩容2倍;而1.8的扩容策略会更优化,当数组容量未达到64时,以2倍进行扩容,超过64之后若桶中元素个数大于等于8就将链表转换为红黑树,但如果红黑树中的元素个数小于6就会还原为链表,当红黑树中元素不小于32的时候才会再次扩容。

(5)扩容的时候 1.7 需要对原数组中的元素进行重新 hash 定位在新数组的位置,1.8 采用更简单的判断逻辑,位置不变或索引+旧容量大小
原因
扩容的时候为什么 1.8 不用重新 hash 就可以直接定位原节点在新数据的位置呢?
这是由于扩容是扩大为原数组大小的 2 倍,用于计算数组位置的掩码仅仅只是高位多了一个 1,怎么理解呢?
扩容前长度为 16,用于计算(n-1) & hash 的二进制 n-1 为 0000 1111,扩容为 32 后的二进制就高位多了 1,为 0001 1111。
因为是& 运算,1 和任何数 & 都是它本身,那就分二种情况,如下图:原数据 hashcode 高位第 4 位为 0 和高位为 1 的情况;
第四位高位为 0,重新 hash 数值不变,第四位为 1,重新 hash 数值比原来大 16(旧数组的容量)

(6)concurrentHashMap的数据结构由数组+链表 变成了数组+链表+红黑树。
(7)concurrentHashMap 的分段锁 变成了CAS+Synchronized 对每个数组元素Node加锁。
线程安全
ConcurrentHashMap 成员变量使用 volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用 CAS 操作和 synchronized 结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。

总结

此章节面试必问内容,理解越深越细越出众。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值