iOS总结-NSDictionary的底层实现

参考:https://blog.csdn.net/zixiweimi/article/details/56677203
NSDictionary(字典)是使用hash表来实现key和value之间的映射和存储的。hash函数设计的好坏影响着数据的查找访问效率。数据在hash表中分布的越均匀,其均匀效率越高。在oc中,通常是利用NSString来作为键值,其内部使用的hash函数也是通过使用NSString对象作为键值来保证数据的各个节点在hash表中均匀分布。
- (void)setObject:(id)anObject  forKey:(id <NSCodying>)aKey:
key值,必须遵循NSCoding协议。也就是说在NSDictionary内部,会对aKey对象copy一份新的,而anObject对象在其内部是作为强引用retain/strong,所以在MRC中,向该方法发送消息之后,我们会向anObect发送release消息进行释放。
- (NSUInteger)hash;
hash方法是用来计算该对象的hash值,最终的hash值决定了该对象的hash表中存储的位置,同样,如果想重写该方法,我们尽量设计一个能让数据分布均匀的hash函数。
- (BOOL)isEqual:(id)object;
是为了通过hash值来找到对象在hash表中的位置。

有关哈希表:http://ios.jobbole.com/87716/
哈希表概述:
oc中字典NSDictionary底层其实是一个哈希表,实际上绝大多数语言中字典都是通过哈希表实现.
哈希表本质是一个数组,数组中每一个元素称为一个箱子(bin),箱子中存放的是键值对.

哈希表存储过程如下:
1.根据key计算出它的哈希值h
2.假设箱子的个数为n , 那么这个键值对应该放在第(h % n)个箱子中
3.如果该箱子中已经有了键值对,就使用开放寻址法或者拉链法解决冲突.

在使用拉链法解决哈希冲突时,每个箱子其实是一个链表,属于同一个箱子的所有键值对都会排列在链表中.
哈希表还有重要的属性:负载因子(load factor),它用来衡量哈希表的空/满 程度,一定程度上可以提现查询的效率
负载因子  = 总键值对数 /  箱子个数
负载因子越大,意味着哈希表越满,越容易导致冲突,性能也就越低.因此,一般来说,当负载因子大于某个常数(可能是1,或者0.75等)时,哈希表将自动扩容.
哈希表在自动扩容时,一般会创建两倍于原来的箱子,因此即使key的哈希值不变,对箱子个数取余的结果也会发生变化,因此所有键值对的存放位置都有可能发生变化,这叫重哈希(rehash).
哈希表的扩容并不总是能够有效解决负载因子过大的问题,假设所有key的哈希值都一样, 那么即使扩容以后他们的位置也不会变化.虽然负载因子会降低,但实际存储在每个箱子中的链表长度并不会发生变化,因此也就不能提高哈希表的查询性能.

哈希表的两个问题:
   1. 如果哈希表中本来箱子就比较多,扩容时需要重新哈希并移动数据,性能影响较大
   2.如果哈希函数设计不合理,哈希表在极端情况下会变成线性表,性能极低.

Java 8 中的哈希表
 HashMap是基于HashTable的一种数据结构,在普通哈希表的基础上,它支持多线程操作以及空的key和value.
在HashMap中定义了几个常量: static final inr DEFAULT_INITIAL_CAPACITY = 1
依次解释以上常量:
  1.DEFAULT_INITIAL_CAPACITY:初始容量,也就是默认会创建16个箱子,箱子的个数不能太多或太少.如果太少,很容易触发扩容,如果太多,遍历哈希表会比较慢.
 2.MAXIMUM_CAPACITY:哈希表最大容量,一般情况下只要内存够用,哈希表不会出现问题
 3.DEFAULT_LOAD_FACTOR:默认的负载因子.因此初始情况下,当键值对的数量大于16 * 0.75 = 12 时,就会触发扩容
4. TREEIFY_THRESHOLD:如果哈希表函数不合理,即使扩容也无法减少箱子中链表的长度,因此java的处理方案是当链表太长时,转换为红黑树.这个值表示当某个箱子中,链表长度大于8时,有可能会转化为树.
5. UNTREEIFY_THRESHOLD:在哈希表扩容时,如果发现链表长度小于6,则会由树重新退化为链表.
6.MIN_TREEIFY_CAPACITY:在转变树之前,还会有一次判断,只要键值对数量大于64才会发生转换.这是为了避免在哈希表建立初期,多个键值对恰好放入同一个链表中而导致不必要的转化.
java 对哈希表的设计一定程度上避免了不恰当的哈希函数导致的性能问题,每一个箱子中的链表可以与红黑树切换.
Redis
Redis是一个高效的key-value缓存系统,也可以理解为基于键值对的数据库.它对哈希表的设计有非常值得学习的地方.
数据结构
在Redis中,字典是一个dict类型的结构体.
typdef struct dict{
  dictht ht[2];
  long rehashidex;
} dict;
这个dict是用于存储数据的结构体.定义一个长度为2的数组,为了解决扩容时速度较慢而引入的.
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long used;
} dictht;
结构体有一个二维数组table,元素类型是dictEntry,对应着存储的一个键值对:
typedef struct dictEntry{
  void *key;
  union{
      void *val;
uint64_t u64;
   int64_t s64;
  double d;
}v;
struct dictEntry  *next;
} dictEntry;
从next 指针以及二维数组可以看出,Redis的哈希表采用拉链法解决冲突.
Redis新插入的键值对会放在箱子中链表的头部,而不是尾部继续插入.
好处: 1.找到链表尾部的时间复杂度o(n),或者需要使用额外的内存地址来保存链表尾部的位置.头插法可以节省插入耗时.
2.对于一个数据库系统来说,最新插入的数据往往更有可能频繁的被获取.头插法可以节省查找耗时.
增量式扩容
所谓增量式扩容是指,当需要重哈希时,每次只迁移一个箱子里的链表,这样扩容时不会出现性能的大幅度下降.
为了标记哈希表正处于扩容阶段,我们在dict结构日中使用rehashidx来表示当前正在迁移哪个箱子里的数据.由于在结构体中实际上有两个哈希表,如果添加新的键值对时哈希表正在扩容,我们首先从第一个哈希表中迁移一个箱子的数据到第二个哈希表中,然后键值对会被插入到第二个哈希表中.

对比java 和 Redis
Java 长处在于当哈希函数不合理导致链表过长时,会使用红黑树来保证插入和查找的效率.缺点是当哈希表比较大时,如果扩容会导致瞬时效率降低.
Redis通过增量式扩容解决了这个缺点,同时拉链法的实现(放在链表头部)值得我们学习.Redis 还提供了一个经过严格测试,表现良好的默认哈希函数,避免了链表过长的问题.
OC的实现和Java比较类似,当我们需要重写isEqual()方法时,还需要重写hash方法. 这两种语言没有提供一个通用的,默认的哈希函数,主要考虑到isEqual()方法可能会被重写,两个内存数据不同的对象可能在语义上被认为是相同的.如果使用默认的哈希函数就会得到不同的哈希值,这两个对象就会同时被添加到NSSet集合中,这可能违背我们的期望结果.
Redis不支持重写哈希方法,它是一个高效的,kei-value存储系统,它的key并不会是一个对象,而是一个用来唯一确定对象的标记.
有两个字典,分别存有100条数据和10000条数据,如果用一个不存在的key去查找数据,在哪个字典中速度更快?

在Redis中,得益于自动扩容和默认哈希函数,两者查找速度一样快.在Java和OC中,如果哈希函数不合理,返回值过于集中,会导致大字典更慢.Java由于存在链表和红黑树互换机制,搜索时间呈对数级增长,非线性增长.在理想的哈希函数下,无论字典多大,搜索速度都是一样快.

猜你喜欢

转载自blog.csdn.net/qq_28551705/article/details/85042450