声明
:以下文章是以jdk7
之前源码分析,而非现在jdk8
源码分析,学习过去源码可以方便打牢源码基础,点击此处学习jdk8之Map语法
1 分析Hash存储机制
1.1 概述
HashSet
和 HashMap
之间有很多相似之处,对于 HashSet
而言,系统采用 Hash算法
决定集合元素的存储位置,这样可以保证能快速存、取集合元素;对于 HashMap
而言,系统key-value
当成一个整体进行处理,系统总是根据 Hash
算法来计算 key-value
的存储位置,这样可以保证能快速存、取 Map
的 key-value
对。
在介绍集合存储之前需要指出一点:虽然集合号称存储的是 Java
对象,但实际上并不会真正将 Java
对象放入 Set
集合中,只是在 Set
集合中保留这些对象的引用而言。也就是说:Java
集合实际上是多个引用变量所组成的集合,这些引用变量指向实际的 Java
对象。
1.2 HashMap的存储实现
当程序试图将多个 key-value
放入 HashMap
中时,以如下代码片段为例:
HashMap map = new HashMap();
map.put("语文" , 80.0);
map.put("数学" , 89.0);
map.put("英语" , 78.2);
HashMap
采用一种所谓的Hash 算法
来决定每个元素的存储位置。
当程序执行 map.put(“语文” , 80.0);
时,系统将调用语文
的 hashCode()
方法得到其 hashCode
值——每个Java
对象都有 hashCode()
方法,都可通过该方法获得它的 hashCode
值。得到这个对象的 hashCode
值之后,系统会根据该 hashCode
值来决定该元素的存储位置。
我们可以看 HashMap
类的 put(K key , V value)
方法的源代码:
public V put(K key, V value)
{
// 如果 key 为 null,调用 putForNullKey 方法进行处理
if (key == null)
return putForNullKey(value);
// 根据 key 的 keyCode 计算 Hash 值
int hash = hash(key.hashCode());
// 搜索指定 hash 值在对应 table 中的索引
int i = indexFor(hash, table.length);
// 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素
for (Entry e = table[i]; e != null; e = e.next)
{
Object k;
// 找到指定 key 与需要放入的 key 相等(hash 值相同
// 通过 equals 比较放回 true)
if (e.hash == hash && ((k = e.key) == key
|| key.equals(k)))
{
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 如果 i 索引处的 Entry 为 null,表明此处还没有 Entry
modCount++;
// 将 key、value 添加到 i 索引处
addEntry(hash, key, value, i);
return null;
}
上面程序中用到了一个重要的内部接口:Map.Entry
,每个 Map.Entry
其实就是一个 key-value
对
从上面程序中可以看出:当系统决定存储 HashMap
中的 key-value
对时,完全没有考虑 Entry
中的 value
,仅仅只是根据 key
来计算并决定每个 Entry
的存储位置。这也说明了前面的结论:可以把 Map
集合中的 value
当成 key
的附属,当系统决定了 key
的存储位置之后,value
随之保存在那里即可。
上面方法提供了一个根据 hashCode()
返回值来计算 Hash
码的方法:hash()
,这个方法是一个纯粹的数学计算,其方法如下:
static int hash(int h)
{
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
对于任意给定的对象,只要它的 hashCode()
返回值相同,那么程序调用 hash(int h)
方法所计算得到的 Hash码值
总是相同的。接下来程序会调用 indexFor(int h, int length)
方法来计算该对象应该保存在 table
数组的哪个索引处。indexFor(int h, int length)
方法的代码如下:
static int indexFor(int h, int length)
{
return h & (length-1);
}
这个方法非常巧妙,它总是通过 h &(table.length -1)
来得到该对象的保存位置——而 HashMap
底层数组的长度总是 2 的 n 次方
当 length
总是 2
的倍数时,h & (length-1)
将是一个非常巧妙的设计:假设 h=5,length=16
, 那么h & length - 1
将得到5
;如果 h=6,length=16
, 那么 h & length - 1
将得到 6
……
如果h=15,length=16
, 那么 h & length - 1
将得到 15
;但是当 h=16
时 , length=16
时,那么 h & length - 1
将得到 0
了;当 h=17
时 , length=16
时,那么 h & length - 1
将得到1
了……
这样保证计算得到的索引值总是位于 table
数组的索引之内。
根据上面 put
方法的源代码可以看出,当程序试图将一个 key-value
对放入 HashMap
中时,程序首先根据该 key
的 hashCode()
返回值决定该 Entry
的存储位置:如果两个 Entry
的 key
的 hashCode()
返回值相同,那它们的存储位置相同。如果这两个 Entry
的 key
通过 equals
比较返回 true
,新添加 Entry
的 value
将覆盖集合中原有 Entry
的 value
,但 key
不会覆盖。如果这两个 Entry
的 key
通过 equals
比较返回 false
,新添加的 Entry
将与集合中原有 Entry
形成 Entry
链,而且新添加的 Entry
位于 Entry
链的头部。
上面程序中还调用了 addEntry(hash, key, value, i);
代码,其中 addEntry
是 HashMap
提供的一个包访问权限的方法,该方法仅用于添加一个 key-value
对。下面是该方法的代码:
void addEntry(int hash, K key, V value, int bucketIndex)
{
// 获取指定 bucketIndex 索引处的 Entry
Entry e = table[bucketIndex]; // ①
// 将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry
table[bucketIndex] = new Entry(hash, key, value, e);
// 如果 Map 中的 key-value 对的数量超过了极限
if (size++ >= threshold)
// 把 table 对象的长度扩充到 2 倍。
resize(2 * table.length); // ②
}
上面方法的代码很简单,但其中包含了一个非常优雅的设计:系统总是将新添加的Entry
对象放入 table
数组的 bucketIndex
索引处——如果bucketIndex
索引处已经有了一个 Entry
对象,那新添加的 Entry
对象指向原有的 Entry
对象(产生一个 Entry
链),如果 bucketIndex
索引处没有 Entry
对象,也就是上面程序①号代码的 e 变量是null
,也就是新放入的 Entry
对象指向 null
,也就是没有产生 Entry
链
1.3 Hash算法的性能选项
根据上面代码可以看出,在同一个 bucket
存储 Entry链
的情况下,新放入的 Entry
总是位于 bucket
中,而最早放入该 bucket
中的 Entry
则位于这个 Entry
链的最末端。
上面程序中还有这样两个变量:
-
size
:该变量保存了该HashMap
中所包含的key-value
对的数量。 -
threshold
:该变量包含了HashMap
能容纳的key-value
对的极限,它的值等于HashMap
的容量乘以负载因子(load factor
)。
从上面程序中②号代码可以看出,当 size++ >= threshold
时,HashMap
会自动调用resize
方法扩充 HashMap
的容量。每扩充一次,HashMap
的容量就增大一倍。
上面程序中使用的 table
其实就是一个普通数组,每个数组都有一个固定的长度,这个数组的长度就是 HashMap
的容量。HashMap
包含如下几个构造器:
-
HashMap()
:构建一个初始容量为16
,负载因子为0.75
的HashMap
。 -
HashMap(int initialCapacity)
:构建一个初始容量为initialCapacity
,负载因子为0.75
的HashMap
。 -
HashMap(int initialCapacity, float loadFactor)
:以指定初始容量、指定的负载因子创建一个HashMap
。
当创建一个 HashMap
时,系统会自动创建一个 table
数组来保存 HashMap
中的 Entry
,下面是 HashMap
中一个构造器的代码:
// 以指定初始化容量、负载因子创建 HashMap
public HashMap(int initialCapacity, float loadFactor)
{
// 初始容量不能为负数
if (initialCapacity < 0)
throw new IllegalArgumentException(
"Illegal initial capacity: " +
initialCapacity);
// 如果初始容量大于最大容量,让出示容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 负载因子必须大于 0 的数值
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException(
loadFactor);
// 计算出大于 initialCapacity 的最小的 2 的 n 次方值。
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
// 设置容量极限等于容量 * 负载因子
threshold = (int)(capacity * loadFactor);
// 初始化 table 数组
table = new Entry[capacity]; // ①
init();
}
找出大于 initialCapacity
的、最小的 2
的 n
次方值,并将其作为 HashMap
的实际容量(由capacity
变量保存)。例如给定 initialCapacity
为 10
,那么该 HashMap
的实际容量就是 16
。
程序①号代码处可以看到:table
的实质就是一个数组
,一个长度为 capacity
的数组。
对于 HashMap
及其子类而言,它们采用 Hash
算法来决定集合中元素的存储位置。当系统开始初始化 HashMap
时,系统会创建一个长度为 capacity
的 Entry
数组,这个数组里可以存储元素的位置被称为桶(bucket)
,每个 bucket
都有其指定索引,系统可以根据其索引快速访问该 bucket
里存储的元素。
无论何时,HashMap
的每个桶
只存储一个元素(也就是一个 Entry
),由于 Entry
对象可以包含一个引用变量(就是 Entry
构造器的的最后一个参数)用于指向下一个 Entry
,因此可能出现的情况是:HashMap
的 bucket
中只有一个 Entry
,但这个Entry
指向另一个 Entry
——这就形成了一个 Entry
链。如图 1 所示:
图 1. HashMap 的存储示意
1.4 HashMap 的读取实现
当 HashMap
的每个 bucket
里存储的 Entry
只是单个 Entry
——也就是没有通过指针产生Entry
链时,此时的 HashMap
具有最好的性能:当程序通过 key
取出对应 value
时,系统只要先计算出该 key
的 hashCode()
返回值,在根据该 hashCode
返回值找出该 key
在 table
数组中的索引,然后取出该索引处的 Entry
,最后返回该 key
对应的 value
即可。看 HashMap
类的 get(K key)
方法代码:
public V get(Object key)
{
// 如果 key 是 null,调用 getForNullKey 取出对应的 value
if (key == null)
return getForNullKey();
// 根据该 key 的 hashCode 值计算它的 hash 码
int hash = hash(key.hashCode());
// 直接取出 table 数组中指定索引处的值,
for (Entry e = table[indexFor(hash, table.length)];
e != null;
// 搜索该 Entry 链的下一个 Entr
e = e.next) // ①
{
Object k;
// 如果该 Entry 的 key 与被搜索 key 相同
if (e.hash == hash && ((k = e.key) == key
|| key.equals(k)))
return e.value;
}
return null;
}
从上面代码中可以看出,如果 HashMap
的每个 bucket
里只有一个 Entry
时,HashMap
可以根据索引、快速地取出该 bucket
里的 Entry
;在发生Hash 冲突
的情况下,单个 bucket
里存储的不是一个Entry
,而是一个Entry 链
,系统只能必须按顺序遍历每个 Entry,直到找到想搜索的Entry
为止——如果恰好要搜索的 Entry
位于该Entry
链的最末端(该 Entry
是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素。
归纳起来简单地说,HashMap
在底层将 key-value
当成一个整体进行处理,这个整体就是一个 Entry
对象。HashMap
底层采用一个 Entry[]
数组来保存所有的 key-value
对,当需要存储一个 Entry
对象时,会根据 Hash
算法来决定其存储位置;当需要取出一个 Entry
时,也会根据 Hash
算法找到其存储位置,直接取出该Entry
。由此可见:HashMap
之所以能快速存、取它所包含的Entry
,完全类似于现实生活中母亲从小教我们的:不同的东西要放在不同的位置,需要时才能快速找到它。
当创建 HashMap
时,有一个默认的负载因子(load factor
),其默认值为0.75
,这是时间和空间成本上一种折衷:增大负载因子可以减少 Hash
表(就是那个 Entry
数组)所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的的操作(HashMap
的 get()
与 put()
方法都要用到查询);减小负载因子会提高数据查询的性能,但会增加 Hash
表所占用的内存空间。
掌握了上面知识之后,可以在创建 HashMap
时根据实际需要适当地调整 load factor
的值;如果程序比较关心空间开销、内存比较紧张,可以适当地增加负载因子;如果程序比较关心时间开销,内存比较宽裕则可以适当的减少负载因子。通常情况下,程序员无需改变负载因子的值。
如果开始就知道 HashMap
会保存多个 key-value
对,可以在创建时就使用较大的初始化容量,如果 HashMap
中 Entry
的数量一直不会超过极限容量(capacity * load factor
),HashMap
就无需调用 resize()
方法重新分配 table
数组,从而保证较好的性能。当然,开始就将初始容量设置太高可能会浪费空间(系统需要创建一个长度为 capacity
的 Entry
数组),因此创建 HashMap
时初始化容量设置也需要小心对待。
1.5 HashSet 的实现
对于 HashSet
而言,它是基于 HashMap
实现的,HashSet
底层采用 HashMap 来保存所有元素,因此 HashSet
的实现比较简单,查看 HashSet
的源代码,可以看到如下代码:
public class HashSet
extends AbstractSet
implements Set, Cloneable, java.io.Serializable
{
// 使用 HashMap 的 key 保存 HashSet 中所有元素
private transient HashMap map;
// 定义一个虚拟的 Object 对象作为 HashMap 的 value
private static final Object PRESENT = new Object();
...
// 初始化 HashSet,底层会初始化一个 HashMap
public HashSet()
{
map = new HashMap();
}
// 以指定的 initialCapacity、loadFactor 创建 HashSet
// 其实就是以相应的参数创建 HashMap
public HashSet(int initialCapacity, float loadFactor)
{
map = new HashMap(initialCapacity, loadFactor);
}
public HashSet(int initialCapacity)
{
map = new HashMap(initialCapacity);
}
HashSet(int initialCapacity, float loadFactor, boolean dummy)
{
map = new LinkedHashMap(initialCapacity
, loadFactor);
}
// 调用 map 的 keySet 来返回所有的 key
public Iterator iterator()
{
return map.keySet().iterator();
}
// 调用 HashMap 的 size() 方法返回 Entry 的数量,就得到该 Set 里元素的个数
public int size()
{
return map.size();
}
// 调用 HashMap 的 isEmpty() 判断该 HashSet 是否为空,
// 当 HashMap 为空时,对应的 HashSet 也为空
public boolean isEmpty()
{
return map.isEmpty();
}
// 调用 HashMap 的 containsKey 判断是否包含指定 key
//HashSet 的所有元素就是通过 HashMap 的 key 来保存的
public boolean contains(Object o)
{
return map.containsKey(o);
}
// 将指定元素放入 HashSet 中,也就是将该元素作为 key 放入 HashMap
public boolean add(E e)
{
return map.put(e, PRESENT) == null;
}
// 调用 HashMap 的 remove 方法删除指定 Entry,也就删除了 HashSet 中对应的元素
public boolean remove(Object o)
{
return map.remove(o)==PRESENT;
}
// 调用 Map 的 clear 方法清空所有 Entry,也就清空了 HashSet 中所有元素
public void clear()
{
map.clear();
}
...
}
由上面源程序可以看出,HashSet
的实现其实非常简单,它只是封装了一个 HashMap
对象来存储所有的集合元素,所有放入 HashSet
中的集合元素实际上由 HashMap
的 key
来保存,而 HashMap
的 value
则存储了一个 PRESENT
,它是一个静态的 Object
对象。
HashSet
的绝大部分方法都是通过调用 HashMap
的方法来实现的,因此 HashSet
和 HashMap
两个集合在实现本质上是相同的。
1.6 HashMap的put与HashSet的add
由于HashSet
的add()
方法添加集合元素时实际上转变为调用 HashMap
的 put()
方法来添加 key-value
对,当新放入 HashMap
的 Entry
中 key
与集合中原有 Entry
的 key
相同(hashCode()
返回值相等,通过 equals
比较也返回 true
),新添加的 Entry
的 value
将覆盖原来 Entry
的 value
,但 key
不会有任何改变,因此如果向 HashSet
中添加一个已经存在的元素,新添加的集合元素(底层由 HashMap
的 key
保存)不会覆盖已有的集合元素。
掌握上面理论知识之后,接下来看一个示例程序,测试一下自己是否真正掌握了 HashMap
和 HashSet
集合的功能。
class Name
{
private String first;
private String last;
public Name(String first, String last)
{
this.first = first;
this.last = last;
}
public boolean equals(Object o)
{
if (this == o)
{
return true;
}
if (o.getClass() == Name.class)
{
Name n = (Name)o;
return n.first.equals(first)
&& n.last.equals(last);
}
return false;
}
}
public class HashSetTest
{
public static void main(String[] args)
{
Set s = new HashSet();
s.add(new Name("abc", "123"));
System.out.println(
s.contains(new Name("abc", "123")));
}
}
上面程序中向 HashSet
里添加了一个 new Name(“abc”, “123″)
对象之后,立即通过程序判断该 HashSet
是否包含一个 new Name(“abc”, “123″)
对象。粗看上去,很容易以为该程序会输出 true
。
实际运行上面程序将看到程序输出 false
,这是因为HashSet
判断两个对象相等的标准除了要求通过 equals()
方法比较返回 true
之外,还要求两个对象的 hashCode()
返回值相等。而上面程序没有重写 Name
类的hashCode()
方法,两个 Name
对象的 hashCode()
返回值并不相同,因此 HashSet
会把它们当成 2
个对象处理,因此程序返回 false
。
由此可见,当我们试图把某个类的对象当成 HashMap
的 key
,或试图将这个类的对象放入 HashSet
中保存时,重写该类的equals(Object obj)
方法和 hashCode()
方法很重要,而且这两个方法的返回值必须保持一致:当该类的两个的 hashCode()
返回值相同时,它们通过 equals()
方法比较也应该返回 true
。通常来说,所有参与计算 hashCode()
返回值的关键属性,都应该用于作为 equals()
比较的标准。
如下程序就正确重写了 Name
类的 hashCode()
和equals()
方法,程序如下:
class Name
{
private String first;
private String last;
public Name(String first, String last)
{
this.first = first;
this.last = last;
}
// 根据 first 判断两个 Name 是否相等
public boolean equals(Object o)
{
if (this == o)
{
return true;
}
if (o.getClass() == Name.class)
{
Name n = (Name)o;
return n.first.equals(first);
}
return false;
}
// 根据 first 计算 Name 对象的 hashCode() 返回值
public int hashCode()
{
return first.hashCode();
}
public String toString()
{
return "Name[first=" + first + ", last=" + last + "]";
}
}
public class HashSetTest2
{
public static void main(String[] args)
{
HashSet set = new HashSet();
set.add(new Name("abc" , "123"));
set.add(new Name("abc" , "456"));
System.out.println(set);
}
}
上面程序中提供了一个 Name
类,该 Name
类重写了 equals()
和 toString()
两个方法,这两个方法都是根据 Name
类的 first
实例变量来判断的,当两个 Name
对象的 first
实例变量相等时,这两个 Name
对象的 hashCode()
返回值也相同,通过 equals()
比较也会返回 true
。
程序主方法先将第一个 Name
对象添加到 HashSet
中,该 Name
对象的 first
实例变量值为abc
,接着程序再次试图将一个 first
为abc
的 Name
对象添加到 HashSet
中,很明显,此时没法将新的 Name
对象添加到该 HashSet
中,因为此处试图添加的 Name
对象的 first
也是abc
,HashSet
会判断此处新增的 Name
对象与原有的 Name
对象相同,因此无法添加进入,程序在①号代码处输出 set
集合时将看到该集合里只包含一个 Name
对象,就是第一个last
为123
的 Name
对象。
2 通过例子分析Hash算法
2.1 问题引入
搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255
字节。假设目前有一千万个记录(这些查询串的重复度比较高,虽然总数是1千万
,但如果除去重复后,不超过3百万
个。一个查询串的重复度越高,说明查询它的用户越多,也就是越热门。),请统计最热门的10
个查询串,要求使用的内存不能超过1G
。
2.1.1 什么是哈希表?
哈希表(Hash table,也叫散列表)
,是根据关键码值(Key value
)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
哈希表的做法其实很简单,就是把Key
通过一个固定的算法函数既所谓的哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将value
存储在以该数字为下标的数组空间里。而当使用哈希表进行查询的时候,就是再次使用哈希函数将key
转换为对应的数组下标,并定位到该空间获取value
,如此一来,就可以充分利用到数组的定位性能进行数据定位
2.1.2 问题解析
要统计最热门查询,首先就是要统计每个Query
出现的次数,然后根据统计结果,找出Top 10
。所以我们可以基于这个思路分两步来设计该算法。
即,此问题的解决分为以下俩个步骤:
第一步:Query
统计
Query
统计有以下俩个方法,可供选择:
- 直接排序法
首先我们最先想到的算法就是排序了,首先对这个日志里面的所有Query
都进行排序,然后再遍历排好序的Query
,统计每个Query
出现的次数了。
但是题目中有明确要求,那就是内存不能超过1G
,一千万条记录,每条记录是255Byte
,很显然要占据2.375G
内存,这个条件就不满足要求了。
让我们回忆一下数据结构课程上的内容,当数据量比较大而且内存无法装下的时候,我们可以采用外排序
的方法来进行排序,这里我们可以采用归并排序
,因为归并排序有一个比较好的时间复杂度O(NlgN)。
排完序之后我们再对已经有序的Query
文件进行遍历,统计每个Query
出现的次数,再次写入文件中。
综合分析一下,排序的时间复杂度是O(NlgN)
,而遍历的时间复杂度是O(N)
,因此该算法的总体时间复杂度就是O(N+NlgN)=O(NlgN)
。 - Hash Table法
在第1
个方法中,我们采用了排序的办法来统计每个Query
出现的次数,时间复杂度是NlgN
,那么能不能有更好的方法来存储,而时间复杂度更低呢?
题目中说明了,虽然有一千万个Query
,但是由于重复度比较高,因此事实上只有300万
的Query
,每个Query255Byte
,因此我们可以考虑把他们都放进内存中去,而现在只是需要一个合适的数据结构,在这里,Hash Table
绝对是我们优先的选择,因为Hash Table
的查询速度非常的快,几乎是O(1)
的时间复杂度。
那么,我们的算法就有了:维护一个Key
为Query
字串,Value
为该Query
出现次数的HashTable
,每次读取一个Query
,如果该字串不在Table
中,那么加入该字串,并且将Value
值设为1
;如果该字串在Table
中,那么将该字串的计数加一即可。最终我们在O(N)
的时间复杂度内完成了对该海量数据的处理。
本方法相比算法1:在时间复杂度上提高了一个数量级,为O(N)
,但不仅仅是时间复杂度上的优化,该方法只需要IO
数据文件一次,而算法1
的IO
次数较多的,因此该算法2比算法1在工程上有更好的可操作性。
第二步:找出Top 10
- 普通排序
想对于排序算法大家都已经不陌生了,这里不在赘述,我们要注意的是排序算法的时间复杂度是NlgN
,在本题目中,三百万条记录,用1G
内存是可以存下的。 - 部分排序
题目要求是求出Top 10
,因此我们没有必要对所有的Query
都进行排序,我们只需要维护一个10
个大小的数组,初始化放入10
个Query,按照每个Query
的统计次数由大到小排序,然后遍历这300
万条记录,每读一条记录就和数组最后一个Query
对比,如果小于这个Query
,那么继续遍历,否则,将数组中最后一条数据淘汰,加入当前的Query
。最后当所有的数据都遍历完毕之后,那么这个数组中的10
个Query
便是我们要找的Top10
了
不难分析出,这样,算法的最坏时间复杂度是N*K
, 其中K
是指top
多少。 - 堆
在算法二中,我们已经将时间复杂度由NlogN
优化到NK
,不得不说这是一个比较大的改进了,可是有没有更好的办法呢?
分析一下,在算法二中,每次比较完成之后,需要的操作复杂度都是K
,因为要把元素插入到一个线性表之中,而且采用的是顺序比较。这里我们注意一下,该数组是有序的,一次我们每次查找的时候可以采用二分的方法查找,这样操作的复杂度就降到了logK
,可是,随之而来的问题就是数据移动,因为移动数据次数增多了。不过,这个算法还是比算法二有了改进。
基于以上的分析,我们想想,有没有一种既能快速查找,又能快速移动元素的数据结构呢?回答是肯定的,那就是堆
。
借助堆结构,我们可以在log
量级的时间内查找和调整/移动。因此到这里,我们的算法可以改进为这样,维护一个K(该题目中是10)
大小的小根堆,然后遍历300万
的Query
,分别和根元素进行对比。
思想与上述算法二一致,只是算法在算法三,采用了最小堆这种数据结构代替数组,把查找目标元素的时间复杂度有O(K)
降到了O(logK)
。
那么这样,采用堆数据结构,算法三,最终的时间复杂度就降到了N‘logK,和算法二相比,又有了比较大的改进。 - 总结
至此,算法就完全结束了,经过上述第一步、先用Hash
表统计每个Query
出现的次数,O(N)
;然后第二步、采用堆数据结构找出Top 10,N*O(logK)
。所以,我们最终的时间复杂度是:O(N) + N’*O(logK)
。(N为1000万,N’为300万)
2.2 Hash表 算法的详细解析
2.2.1 什么是Hash
Hash
,一般翻译做散列
,也有直接音译为哈希
的,就是把任意长度的输入(又叫做预映射,pre-image
),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,而不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
HASH
主要用于信息安全领域中加密算法,它把一些不同长度的信息转化成杂乱的128
位的编码,这些编码值叫做HASH
值. 也可以说,hash
就是找到一种数据内容和数据存放地址之间的映射关系。
数组的特点是:寻址容易,插入和删除困难
;而链表的特点是:寻址困难,插入和删除容易
。那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表,哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法——拉链法,我们可以理解为链表的数组
元素特征转变为数组下标的方法就是散列法。散列法当然不止一种,下面列出三种比较常用的:
- 除法散列法
最直观的一种,公式:
index = value % 16
学过汇编的都知道,求模数其实是通过一个除法运算得到的,所以叫除法散列法
。 - 平方散列法
求index
是非常频繁的操作,而乘法的运算要比除法来得省时(对现在的CPU
来说,估计我们感觉不出来),所以我们考虑把除法换成乘法和一个位移操作。公式:
index = (value * value) >> 28 (右移,除以2^28。记法:左移变大,是乘。右移变小,是除。)
如果数值分配比较均匀的话这种方法能得到不错的结果,但我上面画的那个图的各个元素的值算出来的index都是0——非常失败。也许你还有个问题,value
如果很大,value * value不
会溢出吗?答案是会的,但我们这个乘法不关心溢出,因为我们根本不是为了获取相乘结果,而是为了获取index
。 - 斐波那契(Fibonacci)散列法
平方散列法的缺点是显而易见的,所以我们能不能找出一个理想的乘数,而不是拿value
本身当作乘数呢?答案是肯定的。
对于16位整数而言,这个乘数是40503
对于32位整数而言,这个乘数是2654435769
对于64位整数而言,这个乘数是11400714819323198485
这几个“理想乘数”是如何得出来的呢?这跟一个法则有关,叫黄金分割法则
,而描述黄金分割法则的最经典表达式无疑就是著名的斐波那契数列,即如此形式的序列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144,233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946,…
。另外,斐波那契数列的值和太阳系八大行星的轨道半径的比例出奇吻合。
对我们常见的32位
整数而言,公式:
index = (value * 2654435769) >> 28
用斐波那契散列法调整之后要比原来的取摸散列法好很多。
适用范围:快速查找,删除的基本数据结构,通常需要总数据量可以放入内存。
基本原理及要点:hash函数选择,针对字符串,整数,排列,具体相应的hash方法。
碰撞处理,一种是open hashing,也称为拉链法;另一种就是closed hashing,也称开地址法,opened addressing。
扩展
d-left hashing中的d是多个的意思,我们先简化这个问题,看一看2-left hashing。2-left hashing指的是将一个哈希表分成长度相等的两半,分别叫做T1和T2,给T1和T2分别配备一个哈希函数,h1和h2。在存储一个新的key时,同 时用两个哈希函数进行计算,得出两个地址h1[key]和h2[key]。这时需要检查T1中的h1[key]位置和T2中的h2[key]位置,哪一个 位置已经存储的(有碰撞的)key比较多,然后将新key存储在负载少的位置。如果两边一样多,比如两个位置都为空或者都存储了一个key,就把新key 存储在左边的T1子表中,2-left也由此而来。在查找一个key时,必须进行两次hash,同时查找两个位置。
问题实例(海量数据处理)
我们知道hash
表在海量数据处理中有着广泛的应用,下面,请看另一道百度
2.3 最快的Hash表算法
由一个简单的问题逐步入手:有一个庞大的字符串数组,然后给一个单独的字符串,从这个数组中查找是否有这个字符串并找到它,会怎么做?有一个方法最简单,老老实实从头查到尾,一个一个比较,直到找到为止,我想只要学过程序设计的人都能把这样一个程序作出来,但要是有程序员把这样的程序交给用户,我只能用无语来评价,或许它真的能工作,但…也只能如此了。
最合适的算法自然是使用HashTable(哈希表),先介绍介绍其中的基本知识,所谓Hash
,一般是一个整数,通过某种算法,可以把一个字符串压缩
成一个整数。当然,无论如何,一个32位
整数是无法对应回一个字符串的,但在程序中,两个字符串计算出的Hash值
相等的可能非常小,下面看看在MPQ
中的Hash算法
:
2.3.1 函数一
以下的函数生成一个长度为0×500(合10进制数:1280)的cryptTable[0x500]
void prepareCryptTable()
{
unsigned long seed = 0x00100001, index1 = 0, index2 = 0, i;
for( index1 = 0; index1 < 0x100; index1++ )
{
for( index2 = index1, i = 0; i < 5; i++, index2 += 0x100 )
{
unsigned long temp1, temp2;
seed = (seed * 125 + 3) % 0x2AAAAB;
temp1 = (seed & 0xFFFF) << 0x10;
seed = (seed * 125 + 3) % 0x2AAAAB;
temp2 = (seed & 0xFFFF);
cryptTable[index2] = ( temp1 | temp2 );
}
}
}
2.3.2 函数二
以下函数计算lpszFileName
字符串的hash值,其中dwHashType
为hash
的类型,在下面的函数三、GetHashTablePos
函数中调用此函数二,其可以取的值为0、1、2
;该函数返回lpszFileName
字符串的hash值:
unsigned long HashString( char *lpszFileName, unsigned long dwHashType )
{
unsigned char *key = (unsigned char *)lpszFileName;
unsigned long seed1 = 0x7FED7FED;
unsigned long seed2 = 0xEEEEEEEE;
int ch;
while( *key != 0 )
{
ch = toupper(*key++);
seed1 = cryptTable[(dwHashType << 8) + ch] ^ (seed1 + seed2);
seed2 = ch + seed1 + seed2 + (seed2 << 5) + 3;
}
return seed1;
}
Blizzard
的这个算法是非常高效的,被称为One-Way Hash
( A one-way hash is a an algorithm that is constructed in such a way that deriving the original string (set of strings, actually) is virtually impossible)。举个例子,字符串”unitneutralacritter.grp”通过这个算法得到的结果是0xA26067F3。
是不是把第一个算法改进一下,改成逐个比较字符串的Hash
值就可以了呢,答案是,远远不够,要想得到最快的算法,就不能进行逐个的比较,通常是构造一个哈希表(Hash Table)来解决问题,哈希表是一个大数组,这个数组的容量根据程序的要求来定义,例如1024,每一个Hash
值通过取模运算 (mod
) 对应到数组中的一个位置,这样,只要比较这个字符串的哈希值对应的位置有没有被占用,就可以得到最后的结果了,想想这是什么速度?是的,是最快的O(1),现在仔细看看这个算法吧:
typedef struct
{
int nHashA;
int nHashB;
char bExists;
......
} SOMESTRUCTRUE;
2.3.3 函数三
下述函数为在Hash
表中查找是否存在目标字符串,有则返回要查找字符串的Hash值
,无则,return -1
int GetHashTablePos( har *lpszString, SOMESTRUCTURE *lpTable )
//lpszString要在Hash表中查找的字符串,lpTable为存储字符串Hash值的Hash表。
{
int nHash = HashString(lpszString); //调用上述函数二,返回要查找字符串lpszString的Hash值。
int nHashPos = nHash % nTableSize;
if ( lpTable[nHashPos].bExists && !strcmp( lpTable[nHashPos].pString, lpszString ) )
{ //如果找到的Hash值在表中存在,且要查找的字符串与表中对应位置的字符串相同,
return nHashPos; //则返回上述调用函数二后,找到的Hash值
}
else
{
return -1;
}
}
看到此,我想大家都在想一个很严重的问题:“如果两个字符串在哈希表中对应的位置相同怎么办?”,毕竟一个数组容量是有限的,这种可能性很大。解决该问题的方法很多,首先想到的就是用“链表”,感谢大学里学的数据结构教会了这个百试百灵的法宝,遇到的很多算法都可以转化成链表来解决,只要在哈希表的每个入口挂一个链表,保存所有对应的字符串就OK了。事情到此似乎有了完美的结局,如果是把问题独自交给解决,此时我可能就要开始定义数据结构然后写代码了。
然而使用的方法则是更精妙的方法。基本原理就是:他们在哈希表中不是用一个哈希值而是用三个哈希值来校验字符串。
MPQ
使用文件名哈希表来跟踪内部的所有文件。但是这个表的格式与正常的哈希表有一些不同。首先,它没有使用哈希作为下标,把实际的文件名存储在表中用于验证,实际上它根本就没有存储文件名。而是使用了3种不同的哈希:一个用于哈希表的下标,两个用于验证。这两个验证哈希替代了实际文件名。
当然了,这样仍然会出现2个不同的文件名哈希到3个同样的哈希。但是这种情况发生的概率平均是:1:18889465931478580854784,这个概率对于任何人来说应该都是足够小的。现在再回到数据结构上,Blizzard使用的哈希表没有使用链表,而采用顺延
的方式来解决问题,看看这个算法:
2.3.4 函数四
lpszString为要在hash
表中查找的字符串;lpTable 为存储字符串hash值的hash表;nTableSize 为hash表的长度:
int GetHashTablePos( char *lpszString, MPQHASHTABLE *lpTable, int nTableSize )
{
const int HASH_OFFSET = 0, HASH_A = 1, HASH_B = 2;
int nHash = HashString( lpszString, HASH_OFFSET );
int nHashA = HashString( lpszString, HASH_A );
int nHashB = HashString( lpszString, HASH_B );
int nHashStart = nHash % nTableSize;
int nHashPos = nHashStart;
while ( lpTable[nHashPos].bExists )
{
/*如果仅仅是判断在该表中时候存在这个字符串,就比较这两个hash值就可以了,不用对
*结构体中的字符串进行比较。这样会加快运行的速度?减少hash表占用的空间?这种
*方法一般应用在什么场合?*/
if ( lpTable[nHashPos].nHashA == nHashA
&& lpTable[nHashPos].nHashB == nHashB )
{
return nHashPos;
}
else
{
nHashPos = (nHashPos + 1) % nTableSize;
}
if (nHashPos == nHashStart)
break;
}
return -1;
}
上述程序解释:
- 计算出字符串的三个哈希值(一个用来确定位置,另外两个用来校验)
- 察看哈希表中的这个位置
- 哈希表中这个位置为空吗?如果为空,则肯定该字符串不存在,返回-1。
- 如果存在,则检查其他两个哈希值是否也匹配,如果匹配,则表示找到了该字符串,返回其Hash值。
- 移到下一个位置,如果已经移到了表的末尾,则反绕到表的开始位置起继续查询
- 看看是不是又回到了原来的位置,如果是,则返回没找到
- 回到3
2.3.5 补充1
一个简单的hash
函数:
/*key为一个字符串,nTableLength为哈希表的长度
*该函数得到的hash值分布比较均匀*/
unsigned long getHashIndex( const char *key, int nTableLength )
{
unsigned long nHash = 0;
while (*key)
{
nHash = (nHash<<5) + nHash + *key++;
}
return ( nHash % nTableLength );
}
2.3.6 补充2
一个完整测试程序:
哈希表的数组是定长的,如果太大,则浪费,如果太小,体现不出效率。合适的数组大小是哈希表的性能的关键。哈希表的尺寸最好是一个质数。当然,根据不同的数据量,会有不同的哈希表的大小。对于数据量时多时少的应用,最好的设计是使用动态可变尺寸的哈希表,那么如果你发现哈希表尺寸太小了,比如其中的元素是哈希表尺寸的2倍时,我们就需要扩大哈希表尺寸,一般是扩大一倍。
下面是哈希表尺寸大小的可能取值:
17, 37, 79, 163, 331,
673, 1361, 2729, 5471, 10949,
21911, 43853, 87719, 175447, 350899,
701819, 1403641, 2807303, 5614657, 11229331,
22458671, 44917381, 89834777, 179669557, 359339171,
718678369, 1437356741, 2147483647
以下为该程序的完整源码,已在linux下测试通过:
#include
#include //多谢citylove指正。
//crytTable[]里面保存的是HashString函数里面将会用到的一些数据,在prepareCryptTable
//函数里面初始化
unsigned long cryptTable[0x500];
//以下的函数生成一个长度为0x500(合10进制数:1280)的cryptTable[0x500]
void prepareCryptTable()
{
unsigned long seed = 0x00100001, index1 = 0, index2 = 0, i;
for( index1 = 0; index1 < 0x100; index1++ )
{
for( index2 = index1, i = 0; i < 5; i++, index2 += 0x100 )
{
unsigned long temp1, temp2;
seed = (seed * 125 + 3) % 0x2AAAAB;
temp1 = (seed & 0xFFFF) << 0x10;
seed = (seed * 125 + 3) % 0x2AAAAB;
temp2 = (seed & 0xFFFF);
cryptTable[index2] = ( temp1 | temp2 );
}
}
}
//以下函数计算lpszFileName 字符串的hash值,其中dwHashType 为hash的类型,
//在下面GetHashTablePos函数里面调用本函数,其可以取的值为0、1、2;该函数
//返回lpszFileName 字符串的hash值;
unsigned long HashString( char *lpszFileName, unsigned long dwHashType )
{
unsigned char *key = (unsigned char *)lpszFileName;
unsigned long seed1 = 0x7FED7FED;
unsigned long seed2 = 0xEEEEEEEE;
int ch;
while( *key != 0 )
{
ch = toupper(*key++);
seed1 = cryptTable[(dwHashType << 8) + ch] ^ (seed1 + seed2);
seed2 = ch + seed1 + seed2 + (seed2 << 5) + 3;
}
return seed1;
}
//在main中测试argv[1]的三个hash值:
//./hash "arr/units.dat"
//./hash "unit/neutral/acritter.grp"
int main( int argc, char **argv )
{
unsigned long ulHashValue;
int i = 0;
if ( argc != 2 )
{
printf("please input two arguments/n");
return -1;
}
/*初始化数组:crytTable[0x500]*/
prepareCryptTable();
/*打印数组crytTable[0x500]里面的值*/
for ( ; i < 0x500; i++ )
{
if ( i % 10 == 0 )
{
printf("/n");
}
printf("%-12X", cryptTable[i] );
}
ulHashValue = HashString( argv[1], 0 );
printf("/n----%X ----/n", ulHashValue );
ulHashValue = HashString( argv[1], 1 );
printf("----%X ----/n", ulHashValue );
ulHashValue = HashString( argv[1], 2 );
printf("----%X ----/n", ulHashValue );
return 0;
}