iOS 字典的实现原理

一、NSDictionary使用原理

1.NSDictionary(字典)是使用hash表来实现key和value之间的映射和存储的,hash函数设计的好坏影响着数据的查找访问效率。
-(void)setObject:(id)anObject forKey:(id)aKey;

2.Objective-C中的字典NSDictionary底层其实是一个哈希表,实际上绝大多数语言中字典都通过哈希表实现.

二、哈希的原理

1.根据key计算出它的哈希值h。

2.假设箱子的个数为n,那么这个键值对应该放在第(h % n)个箱子中。

3.如果该箱子中已经有了键值对,就使用开放寻址法或者拉链法解决冲突。
在使用拉链法解决哈希冲突时,每个箱子其实是一个链表,属于同一个箱子的所有键值对都会排列在链表中。

哈希表还有一个重要的属性:负载因子(load factor),它用来衡量哈希表的空/满程度,一定程度上也可以体现查询的效率,计算公式为:
负载因子=总键值对数/箱子个数
负载因子越大,意味着哈希表越满,越容易导致冲突,性能也就越低。因此,一般来说,当负载因子大于某个常数(可能是1,或者0.75等)时,哈希表将自动扩容。

重哈希概念:

哈希表在自动扩容时,一般会创建两倍于原来个数的箱子,因此即使key的哈希值不变,对箱子个数取余的结果也会发生改变,因此所有键值对的存放位置都有可能发生改变,这个过程也称为重哈希(rehash)。

哈希表的扩容并不总是能够有效解决负载因子过大的问题。假设所有key的哈希值都一样,那么即使扩容以后他们的位置也不会变化。虽然负载因子会降低,但实际存储在每个箱子中的链表长度并不发生改变,因此也就不能提高哈希表的查询性能。

四、总结,细心的读者可能会发现哈希表的两个问题:

1.如果哈希表中本来箱子就比较多,扩容时需要重新哈希并移动数据,性能影响较大。

2.如果哈希函数设计不合理,哈希表在极端情况下会变成线性表,性能极低。

关于hash表

想想一下,我们有一个数组,数组长度是100个,现在的需求是:给出这个数组是否包含一个对象obj?

如果这是个无序的数组,那么我们只能用遍历的方法来查找是否包含这个对象obj了。这是我们的时间复杂度就是O(n)。

这种查找效率是很低的,所以hash表应运而生。

hash表其实也是一个数组,区别数组的地方是它会建立 存储的值 到 存储的下标 索引的一个映射,也就是散列函数。

我们来举一个通俗易懂的例子:

现在我们有个hash表,表长度count = 16,现在我们依次把3,12,24,30依次存入hash表中。

首先我们来约定一个简单的映射关系:存储的索引下表(index) = 存储值(value) % hash表长度(count);

[注:实际的映射并不是简单的存储值,而是经过计算得到的hash值]

算下来hash表的存储分布是这样的:hash[3] = 3、hash[12] = 12、hash[8] = 24、hash[14] = 30

还是一样的需求,当我们给出24的时候,求出hash表中是否存有24?

此时,按照原先约定的映射关系:index = 24 % 16 = 8,然后我们在hash[8]查询等于24。这样,通过数组需要O(n)的时间复杂度,通过hash表只需要O(1);

散列碰撞

上面提到的hash表在存入3,12,24,30后,如果要面临存入19呢?

此时index = 19 % 16 = 3,而之前hash[3] 已经存入了3这个值了!这种情况就是发送了散列碰撞。

此时,我们可以改进一下我们的hash表,让它存储的是一个链表。这样发送散列碰撞的元素就可以以链表的形式共处在hash表的某一个下标位置了。

hash存储

所以,只要发生了散列碰撞,我们查找的时间复杂度就不能像O(1)这么小了,因为还要考虑链表的查找时间复杂度O(n)。

负载因子、自动扩容

哈希表还有一个重要的属性: 负载因子(load factor),它用来衡量哈希表的 空/满 程度

负载因子 = 总键值对数 / 箱子个数

当存储的元素个数越来越多,在hash表长度不变的前提下,发生散列碰撞的概率就会变大,查找性能就变低了。所以当负载因子达到一定的值,hash表会进行自动扩容。

哈希表在自动扩容时,一般会扩容出一倍的长度。元素的hash值不变,对哈希表长度取模的值也会改变,所以元素的存储位置也要相对应重新计算,这个过程也称为重哈希(rehash)。

哈希表的扩容并不总是能够有效解决负载因子过大而引起的查询性能变低的问题。假设所有 key 的哈希值都一样,那么即使扩容以后他们的位置也不会变化。虽然负载因子会降低,但实际存储在每个箱子中的链表长度并不发生改变,因此也就不能提高哈希表的查询性能。所以,设计一个合理有效的散列函数显得相当的有必要,这个合理有效应该体现在映射之后各元素均匀的分布在hash表当中。

说回NSDictionary

字典是开发中最常见的集合了。当我们调用

- (void)setObject:(ObjectType)anObject forKey:(KeyType <NSCopying>)aKey;

我们来探究下字典存储键值对的过程,有两个方法对hash存储起着关键的影响:

- (NSUInteger)hash;
- (BOOL)isEqual:(id)object;

demo1

@interface KeyType : NSObject<NSCopying>

@property (nonatomic, copy) NSString *keyName;

- (instancetype)initWithKeyName:(NSString *)keyName;

@end

@implementation KeyType

- (instancetype)initWithKeyName:(NSString *)keyName {
    if (self = [super init]) {
       _keyName  = keyName;
    }
    return self;
}

//直接电影父类hash方法
- (NSUInteger)hash {
    NSLog(@"hash func");
    return [super hash];
}

//直接调用父类isEqual方法
- (BOOL)isEqual:(id)object {
    NSLog(@"isEqual func");
    return [super isEqual:object];
}

- (nonnull id)copyWithZone:(nullable NSZone *)zone {
    NSLog(@"copy funcr");
    KeyType *key = [[KeyType alloc] initWithKeyName:self.keyName];
    return key;
}

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSMutableDictionary *dic = [NSMutableDictionary new];
    KeyType *key1 = [[KeyType alloc] initWithKeyName:@"key1"];
    
    NSLog(@"for value");
    NSLog(@"%@",[key1 valueForKey:@"retainCount"]);
    [dic setObject:key1 forKey:@"valueKey"];
    NSLog(@"%@",[key1 valueForKey:@"retainCount"]);
    
    NSLog(@"for key");
    [dic setObject:@"object1" forKey:key1];
    NSLog(@"%@",[key1 valueForKey:@"retainCount"]);
}

@end

控制台打印:
for value
1
2
for key
hash func
copy func
2

分析:

  • key1作为键值对的value时,不会去计算hash值,dictionary会对key1进行一次强引用。
  • key1作为键值对的key时,会先去计算hash值,然后[key1 copy]拷贝一份key1和value存储在字典中
    下面来看第二个测试用例:Demo2
- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSMutableDictionary *dic = [NSMutableDictionary new];
    KeyType *key1 = [[KeyType alloc] initWithKeyName:@"key1"];
    [dic setObject:@"object1" forKey:key1];
    NSLog(@"%ld",dic.count);
    NSLog(@"%@",dic[key1]);
}
控制台打印:
hash func
copy func
1
hash func
isEqual func
(null)

dic.count = 1,说明{key1 : @"object1"}已经存储进去了。然而通过这个key去获取竟然返回null?

从打印也可以看出来,现在isEqual函数开始被调用了。

分析:

  • dic[key1]作为key去字典中查询value时,也会先计算hash值,来确定在hash表中的存储下标位置

  • 因为存储散列碰撞的可能,所以找到下标后,会调用isEqual方法来匹配链表上面的各个元素之间的key值。当isEqual:返回YES时,会把对应的value返回。

  • 调用父类的isEqual,NSObject的- (BOOL)isEqual:(id)object比较的是内存地址

- (BOOL)isEqual:(id)object {
    return self == object;
}
  • 根据demo1的分析,key1作为键值对的key时,会拷贝一份存储到字典中。既然是拷贝,那当然和原始对象不是同一个对象,所以- (BOOL)isEqual:(id)object返回NO。所以我们在链表的查询中找不到对应的key,最终返回null
//我们可以强制重写KeyType的isEqual:返回YES,demo2的返回值就不是null了
- (BOOL)isEqual:(id)object {
    return YES;
}

由此可见,当一个类需要作为字典的key,重写hash和isEqual:方法显得很有必要。

重写hash方法

为什么要重写hash方法?

我们先来看看NSObject的hash方法返回什么:

KeyType *key1 = [[KeyType alloc] initWithKeyName:@"key1"];
NSLog(@"%p",key1);
NSLog(@"%lx",[key1 hash]);
控制台打印:
0x600000640610
600000640610

由此可见,NSObject是把对象的内存地址作为hash值返回。

以内存地址作为hash可以保证唯一性,但是这样好不好?

这样不好!

来看下这个场景:

@interface KeyType : NSObject<NSCopying>

@property (nonatomic, copy) NSString *keyName;

- (instancetype)initWithKeyName:(NSString *)keyName;

@end

@implementation KeyType

- (instancetype)initWithKeyName:(NSString *)keyName {
    if (self = [super init]) {
       _keyName  = keyName;
    }
    return self;
}

- (NSUInteger)hash {
    return [super hash];
}

//强制返回YES
- (BOOL)isEqual:(id)object {
    return YES;
}

- (nonnull id)copyWithZone:(nullable NSZone *)zone {
    KeyType *key = [[KeyType alloc] initWithKeyName:self.keyName];
    return key;
}

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSMutableDictionary *dic = [NSMutableDictionary new];
    KeyType *key1 = [[KeyType alloc] initWithKeyName:@"key1"];
    KeyType *key2 = [[KeyType alloc] initWithKeyName:@"key1"];
    [dic setObject:@"object1" forKey:key1];
    NSLog(@"%@",dic[key2]);
}

很明显,最后打印是null。

但是在一般的业务场景,因为key1和key2的keyName属性都一样,所以应该被看为同一个key。

所以我们要重新hash方法。

如何重写hash方法

一个合理的hash方法要尽量让hash表中的元素均匀分布,来保证较高的查询性能。

如果两个对象可以被视为同一个对象,那么他们的hash值要一样。

mattt在文章Equality 中给出了一个普遍的算法:

- (NSUInteger)hash {
    return [self.property1 hash] ^ [self.property2 hash] ^ [self.property3 hash];
}
//假设对象有三个属性,那么对这三个属性分别算出hash值,然后进行异或运算

Instagram在开源IGListKit的同时,鼓励这么写hash方法:

- (NSUInteger)hash
{
  NSUInteger subhashes[] = {[self.property1 hash], [self.property2 hash], [self.property3 hash]};
  NSUInteger result = subhashes[0];
  for (int ii = 1; ii < 3; ++ii) {
    unsigned long long base = (((unsigned long long)result) << 32 | subhashes[ii]);
    base = (~base) + (base << 18);
    base ^= (base >> 31);
    base *=  21;
    base ^= (base >> 11);
    base += (base << 6);
    base ^= (base >> 22);
    result = base;
  }
  return result;
}

重写isEqual:

如何写一个合理高效的判等方法?

首先对内存地址进行判断,地址相等return YES;
进行判空处理,self == nil || object == nil ,return NO;
类型判断,![object isKindOfClass:[self class]] , return NO;
对对象的其他属性进行判断
根据这四个步骤,我们可以发现,我们都是先判断时间开销最少的属性。所以对于第4个步骤,如果对象有很多属性,我们也要依照这个原则来!比如[self.array isEqual:other.array] && self.intVal == other.intVal这种写法是不合理的,因为array的判等会去遍历元素,时间开销大。如果intVal不相等的话就可以直接return NO了,没必要进行数组的判等。应该这么写: self.intVal == other.intVal && [self.array isEqual:other.array]

示例如下:

- (BOOL)isEqual:(PersonModel *)object
{
  if (self == object) {
    return YES;
  } else if (self == nil || object == nil || ![object isKindOfClass:[self class]]) {
    return NO;
  }
  return
    (_property1 == object->_property1 ? YES : [_property1 isEqual:object->_property1]) &&
    (_property2 == object->_property2 ? YES : [_property2 isEqual:object->_property2]) &&
    (_property3 == object->_property3 ? YES : [_property3 isEqual:object->_property3]);
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270