[2017/08/22]高性能C/C++编程中的那些数据结构

来源:互联网 发布:打水软件是什么 编辑:程序博客网 时间:2024/06/05 15:12

本文首发于腾讯KM,如转载请注明作者,出处。

偶然在k吧首页看到了luckyzuo的分享,因为自己一直对这方面很感兴趣,所以在工作之余对照ppt听了讲座录音,受益匪浅。这次分享提到了几种数据结构,我结合了自己的一些理解,写了这篇文章。写的时候查阅了许多资料,越发认识到自己基础知识的薄弱(还是要学习一个)。由于水平有限,若有错误,请各位指正。

目录:

一. 哈希表的弱点

1.1 hash的硬伤

  1.1.1 没有通适的高效hash函数  1.1.2 容易存在冲突  1.1.3 预估容量困难,存储数据量增多导致冲突加剧,扩容的话需要对已保存元素做rehash

1.2 一些弥补:动态多阶哈希

二. 红黑树 VS skip list

2.1 介绍

  2.1.1 红黑树概述  2.1.2 skip list概述

2.2 高并发友好:红黑树 || skip list?

  2.2.1 什么样的数据结构才是高并发友好的?  2.2.2 链表——高并发友好的数据结构  2.2.3 红黑树——牵一发而动全身

2.3 一些缺点:红黑树 & skip list :

三.缓存友好的CP :有序数组+二分查找

3.1 使用场景

3.2 缓存命中率的那些事儿

  3.2.1 缓存不命中的时间处罚  3.2.2 CPU预取与缓存命中率  3.2.3 缓存命中率:线性结构 VS 非线性结构

3.3 缓存友好的CP :有序数组+二分查找


谈到高性能,选择合理的数据结构总是必要的。这次分享中提到了这几种数据结构:①AVL树,红黑树; ③skip list(跳表) ②Hash表 ④有序数组。下面我将结合自己的理解,谈一谈这几种数据结构。

一. 哈希表的弱点

谈到快速查找,必须要提到的数据结构就是哈希表。它以理论上O(1)的时间复杂度横扫四海八荒无敌手。分享中抛出了一个经验性的结论,就是在数据量很大(百万以上)的情况下,哈希表比AVL树的查找效率更高。hash的好处我就不多说了,主要总结一下分享中提到的三处hash的硬伤:

1.1 hash的硬伤

1.1.1 没有通适的高效hash函数
对于不同的场合,由于数据类型和数据量等因素各不相同,所以要综合考虑各种情况,选取最合适的hash函数,尽量减少冲突的发生。
1.1.2. 容易存在冲突
O(1)的时间复杂度毕竟还是理论上的,在实际使用中,经常会出现装载因子冲突的情况。
c. 预估容量困难,存储数据量增多导致冲突加剧,扩容的话需要对已保存元素做rehash
初期数据量小的时候,选用的hash函数尚能保证低冲撞,从而保证hash效率。但到数据量增多到一定程度,冲撞加剧,这个时候就要考虑对hash表进行扩容。扩容的时候,一方面增加桶的数量,另一方面要把所有已存储的数据rehash一遍,重新分配并加入新的桶内。这样做会使效率降低。
1.2 一些弥补:动态多阶哈希
在上面的三点硬伤中,预估容量困难和容易存在冲突是有缓解方法的。可以采用动态多阶hash的方法。这里就不再介绍多级hash了,km上有很多文章。动态多阶hash采用动态扩增hash阶数的方法,一方面降低了数据增长带来冲突的问题,另一方面不用预估容量(直接动态扩增hash阶数即可),最后还有效地节省了部分空间。
最后,如果想彻底避免以上的问题,可以考虑使用红黑树和skip list。在使用这两个数据结构的时候,不用考虑冲突和容量。

二. 红黑树 VS skip list

2.1 介绍

2.1.1 红黑树概述
红黑树就不多介绍了,和AVL树一样,都是二叉查找树。它的查找,插入,删除操作的时间复杂度都是O(log2 n)。在C++STL库中,以它作为实现set和map的底层数据结构。红黑树相对于AVL树的好处是,它并不追求完全平衡,所以避免了AVL树每次更改所进行的大量平衡度统计计算,开销要小得多。此外,红黑树以此来换取增删节点时候旋转次数的降低,从而提高插入效率。
2.1.2 skip list概述
除了红黑树,这场分享中还提到了一个对我来说十分陌生的数据结构:skip list,也就是跳表。由于录音中并没有对跳表做详细介绍,所以下去查了一些资料。skip list的实现我就不多说了。
skip list是一个很有趣的数据结构。它一方面保持了List的优势:插入删除灵活,另一方面通过逐层随机建索引,采用空间换时间的思想,巧妙地解决了List查找元素不易且效率低的硬伤。查了查资料,发现 skip list 和红黑树的性能是不相上下的。和红黑树一样,它的查找时间复杂度也是O(log2 n);从空间角度讲,除了数据存储,它们各自都需要分配另外的索引域。但是,比起 skip list, 红黑树有一些明显的缺点。比如它的实现太困难了(手写红黑树是无比的凶残),再比如它对高并发的友好度并不如 skip list。这也是接下来要深入讨论的一个点。

2.2 高并发友好:红黑树 || skip list?

2.2.1 什么样的数据结构才是高并发友好的?
在比较二者对高并发的友好度之前,我们首先要确定的是,什么样的数据结构才是高并发友好的?
首先,一个高并发友好的数据结构可以允许多个线程同时使用数据。如果多个线程要同时争夺这个数据结构,它可以允许读者和写者并发执行在这个数据结构的不同部分。其次,在多核系统中,由于存在缓存一致性,所以得考虑高速缓存同步的成本,使其最小化。当一个线程更新数据结构的一部分时,要想让这个改动对其他线程可见,需要在cache中移动的内存应尽量是其更改的那一部分,而不是更多其他与更改无关的部分。
2.2.2 链表——高并发友好的数据结构
为了方便讨论,我们在这里用链表代替skip list。首先抛出结论,链表是一个高度并发友好的数据结构,原因有以下两点:
①链表可以同时允许多个线程使用
下面来解释原因。先来看第一点,链表可以同时允许多个线程使用吗?答案是肯定的。因为在其上加的锁是可以高度局部化的。为什么呢?首先看写的情况:插入只需要锁定两个现有结点,即即将插入节点的前驱和后继;删除只需要锁定三个节点,即被删除的结点,及其前驱和后继。下面再看读的情况,一个读线程最多只会持有两个结点的锁,那就是遍历链表的时候。我们可以采用hand-by-hand的加锁方法,只需要保证下一个读的节点不会在读之前被其他线程修改(如果下个结点提前被删除,那么我们就会访问到一块已经不存在的内存),所以持有当前读的结点的锁和下一个结点的锁就足够了。更多情况下我们只对当前正在读的结点感兴趣,所以不需要持有两把锁,只需要锁住当前结点就够了。因为锁粒度低,所以允许大量线程在同一链表上工作:它们只要处理链表的不同部分,就不会发生冲突。
②链表可以降低高速缓存同步成本
再来看第二点,毋庸置疑的是,在多核系统里对链表进行操作的时候,进行高速缓存的同步成本很小。首先需要说明的是,在对高速缓存进行读写的时候,写的成本总是高于读的成本。因为写一个高速缓存时,需要广播其他高速缓存同步数据,而读则什么都不用做。基于这个认知,我们也可以知道,大量的写入会花费更多。那链表在这一点上表现如何呢?首先看写中删除结点的情况,需要传输的唯一数据是包含两个相邻结点的缓存,这些内存会被传输到所有其他cpu核的缓存,这些cpu会在随后访问该链表。再来看插入的情况,只需要传递三个结点的缓存,显然成本是很小的。
因为链表的每一个元素只是存储在单一结点,而不像线性表那样,插入和删除对其一侧的所有元素都有影响。那么是不是意味着,基于结点的数据结构都有利于高并发呢?答案是否定的,下面就来说说红黑树。
2.2.3 红黑树——牵一发而动全身
首先看一下红黑树的特性:它将每个结点标记成红色或黑色,且在每个插入和删除操作中,都要进行一次调整,来避免其不同分支太不平衡。而这种调整是通过子树的旋转来实现的。这就出现了一个问题:其中涉及的结点可能包括被操作结点的父亲节点or叔叔结点,往上到祖父母结点,甚至最后牵涉到根节点。因为树之间相隔任意距离的更新,都可能触及相同的结点。触及相同结点的概率随着结点所在层数的减少而增加,直到根节点。
现在我们来讨论它是否适合多线程操作。根据上面提到的红黑树特性来看,它的改变不可能是局部的。再来看它是否是缓存友好的,同理,由于牵涉到的结点诸多且难以预料,每次在红黑树上进行写操作时,我们都要在各个高速缓存中写入远远大于修改数据的数据量,成本太高。所以,红黑树不是一种高度并发友好的数据结构。

2.3 一些缺点:红黑树 & skip list :

红黑树和skip list这两种非线性结构虽然插入删除比较灵活,但有一个缺点,那就是它们浪费空间。原因之一就是它们需要额外的空间保存链域。另外,它们结点分配的内存对齐也会分配多余空间。还有另外一个缺点,它们的缓存命中率比较低。至于为什么,后面会讲到。考虑到以上两点缺点,我们可以想到另外一种数据结构:有序数组。这种线性的数据结构在这两方面的性能是明显更优的。

三.缓存友好的CP :有序数组+二分查找

先抛出一个结论:在查找的时候,由于有序数组+二分查找的缓存命中性好,所以它的查找效率比hash,avl树,skip list都快。那么,在什么样的场景下,使用有序数组做查找更合适呢?

3.1 使用场景

在这次分享中,luckyzuo提到了这样一种场景:假设你有一个稳定的数据集合,仅要从里面查找数据。可以考虑在程序启动的时候,对数据排序,然后在有序集合上做二分查找。这样做有以下优点:第一,数据结构和算法都实现简单;第二,效率高:仅仅需要简单的一次排序,以后的查找时间复杂度都是O(log2 n);第三点,有序数组有较高的缓存命中率,所以更高效。
不妨考虑一个问题,为什么线性结构的缓存命中率要比非线性结构高呢?在解释这个问题之前,先来解释一下缓存命中率这个概念。

3.2 缓存命中率的那些事儿

当CPU想取某块数据时,它首先从高速缓存中查找这个数据。如果其刚好存在,则称之为高速缓存命中,否则则称为高速缓存不命中。
3.2.1 缓存不命中的时间处罚
当出现缓存不命中的情况,大量的时间会被浪费。若不命中,会有不命中处罚。高速缓存L1从L2中取数据,处罚是10个指令周期的时间消耗。若还取不到数据,L2再去到L3取数据,会有50个指令周期的处罚。高速缓存从主存中得到的服务的处罚,是200个指令周期。这种层层取数据的时间消耗是很可怕的。
那么,怎样做可以提高命率,避免这些时间浪费呢?
3.2.2 CPU预取与缓存命中率
要提高缓存命中率,首先得了解CPU的一个能力,预取。
在内存和cache之间,预取是由CPU根据过去访问的数据地址信息,从此开始线性地往下获取接下来的若干个内存单元,把这些未来可能访问的数据预先存入cache,从而在数据真正被用到时不会造成cache失效。
在多级高速缓存之间,预取是当高一级缓存往低一级缓存中取数据时,除了获取目标数据所在的一个cache line,高一级缓存会自动预取与其相邻的一个cache line。
3.2.3 缓存命中率:线性结构 VS 非线性结构
通过了解预取我们可以知道,在对一个线性数据结构进行操作时,由于其中数据的内存地址都是连续的,所以要用到的数据有极大概率被装入缓存中,避免了频繁的各级cache数据交换所带来的时间惩罚。
再来看看非线性结构:因为每个结点的内存地址都是不连续的,所以在获取一个结点数据的时候,往往不能正确预取到这个数据结构的其他部分,缓存命中率较低。因为我们下一次需要的结点有很大概率不存在于cache中,所以需要去内存里去获取。这一个层层去取数据过程是很浪费时间的。

3.3 缓存友好的CP :有序数组+二分查找

我们已经知道了,有序数组是缓存友好的数据结构,同样,二分查找也是一种缓存友好的算法。
二分查找还有另外一个名字,折半查找,或许这个名字能更好地解释其原理。我们都知道,二分查找每一轮查找结束之后,查询范围都会在原有基础上缩小一半。所以,数据变得越来越密集,再加上有序数组的连续性,也就越容易被命中。所以,这就是有序数组+二分查找效率如此高的原因。

原创粉丝点击