http://aiyooyoo.com/index.php/archives/306/
我记得曾经有一篇帖子,介绍php数组,称《强大而高效的php数组》,并且被多处转载,此文被我指出过有很不客观和不专业的地方。后来此文在水区被一位童鞋再次提出并加以批判。我也曾批过谈内存的php程序员。具体讨论可见下列文章:
《强大高效的PHP数组》http://bbs.phpchina.com/thread-185391-1-1.html
《php的内存管理》http://bbs.phpchina.com/viewthread.php?tid=208659
《发现一个问题》http://bbs.phpchina.com/viewthread.php?tid=209617
《谁动了我的内存(PHP内存管理)》http://www.laruence.com/2011/03/04/1894.html
其实,所有的这一切都只是为了说明“凡是脱离了C语言和数学讨论底层都是耍流氓”。既然想知道底层,不知道这些东西就不该乱说。为什么那篇文章会被批判呢。
先引用鸟哥的原文:http://www.laruence.com/2009/07/23/994.html
“PHP数组的定义,本质上是一种键-值映射的关系,算是一种散列表(哈希表)。PHP的数组,关联数组,对象属性,函数表,符号表,等等都是用HashTable来做为容器的。PHP的Hash采用的是目前最为普遍的DJBX33A (Daniel J. Bernstein, Times 33 with Addition), 这个算法被广泛运用与多个软件项目,Apache, Perl和Berkeley DB等. 对于字符串而言这是目前所知道的最好的哈希算法,原因在于该算法的速度非常快,而且分类非常好(冲突小,分布均匀).
核心思想如下:hash(i) = hash(i-1) * 33 + str[i]”
我所批判的是《强大而高效》作者望文生义,拼拼凑凑,就认为php的数组十分强大高效,并且指出java里的数组要弱得多。没有从原理上认识过hash原理,也就更认识不到hash对内存的占用情况。Php数组是灵活而强大的,但是一点都不高效,是很占内存的,再加上php的变量使用的是封装的结构体,使得复杂度大大增加了。据网友测试,和C语言比,同样大小的数组,其所耗内存为C的10倍。
来看看hash的定义,“Hash,一般翻译做“散列”,也有直接音译为“哈希”的,就是把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不 同的输入可能会散列成相同的输出,而不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。”
Hash离我们远吗?不远,从MD5()散列函数到 memcache内存缓存系统,到简单的数据库分表设计,到处都有hash的阴影。
假设我们要在一本书里查找biang biang 面的biang字(陕西人都知道),那么我就要从这本书的第一页,一个字一个字的顺序查找,直到找到这个字而已,也许我花了三天时间翻完了这本书,都找不到这个字。我们说,这个算法的复杂度是O(n),此算法的完成时间和这本书的规模(字数)即N有很大关系。那假如说,我有一本发票,是按顺序编码的,有1000页厚,我要找到第A00100234号发票,我只需要直接翻到234页就能找到它,那我们呢就说这个问题的复杂度是O(1),也就是问题的解决和规模无关。
要想达成 O(1) 复杂度的查找,必须满足3个条件: 1. 存储单元(例如一页纸)中存储的内容(例如发票编码)与存储单元的地址(例如页码)必须是一一对应的。 2. 这种一一对应的关系必须是可以预先知道的。 3. 存储单元是可以随机读取的。这里“随机读取”的意思是可以以任意的顺序读取每个存储单元,并且每次读取所需时间都是相同的。
在这里,我模拟一个哈希表,开辟一块内存空间,来存放0-9这10个数字,
class hash1{
private $array;
public function __construct() {
$this->array=new SplFixedArray(10);
}
public function add($i){
$this->array[$i]=$i;
}
public function contain($i){
if ($this->array[$i]==null) {
return 'false';
} else {
return 'TRUE';
}
}
}
$hash1=new hash1();
$hash1->add(0);
$hash1->add(1);
echo $hash1->contain(3);
注意,我这里用的是fixedArray来作为存储的容器,而不是普通的array。因为fixedArray更接近于C语言里的数组概念,而array和C里面的经典数组根本是两码事。你在这个例子里可以把数组的长度改为1试试,运行时就会报错。就像C语言里一样,数组定义的时候就必须指明长度,分配内存。至于SplFixedArray的内部实现到底如何,是不是等同于C里的数组,我没看源码也不得而知。暂且这么认为,这并不影响下面的讲述。
新的需求出来了,同样只需要保存10个数字,只不过这次不是保存0~9,而是需要保存10~19。怎么办,我只要增加一个ha()函数,实现这个空间里的值与地址的映射。
class hash1{
private $array;
public function __construct() {
$this->array=new SplFixedArray(10);
}
public function Ha($i){
return $i-10;
}
public function add($i){
$this->array[$this->Ha($i)]=$i;
}
public function contain($i){
if ($this->array[$this->Ha($i)]==null) {
return 'false';
}else{
return 'TRUE';
}
}
}
$hash1=new hash1();
$hash1->add(11);
$hash1->add(14);
echo $hash1->contain(13).PHP_EOL;
echo $hash1->contain(14);
好,继续提需求。现在要在我这10个空间里放0-19这20个数字咋办?空间不够,挤着睡呗。
更改我们的映射函数如下:
public function Ha($i){
if(0<$i&&$i<10){
return $i;
} else {
return $i-10;
}
}
测试:
$hash1=new hash1();
$hash1->add(2);
$hash1->add(15);
$hash1->add(13);
var_dump($hash1);
var_dump($hash1->contain(2));
var_dump($hash1->contain(15));
var_dump($hash1->contain(13));
$hash1->add(3);
var_dump($hash1);
var_dump($hash1->contain(3));
var_dump($hash1->contain(13));
好了,测试下,可以看到,我们把20个数据硬塞到10个空间里了。在第一次塞数据时,我塞了2,15,13,它们互相占据不同的位置,我是能逐个找到他们的。然后我塞进来一个3,它和13占据的是同一个空间,现在3进来了,13不见了。3和13住到了同一间屋里,我无法确定第3个位置是3还是13,这时我们说发生碰撞了。怎么办?这个时候,我们可以用链法(也叫拉链法,很形象的说)来处理。所谓链法就是数组中每个元素均为一个链表,所有哈希到同一数据index的key,其值均位于同一个元素的链表中。简单的说,就是让发生碰撞的2人住2间,但是共用1个公共地址。这就是碰撞的处理-另安一个床位。为了简单起见,可以让数组的每个“元素”都指向一个链表:
<?php
class hashset2 {
public $array;
public function __construct() {
$this->array = new SplFixedArray(10);
}
public function Ha($i) {
if (0 <= $i && $i < 10) {
return $i;
} else {
return $i - 10;
}
}
public function add($i) {
if ($this->array[$this->Ha($i)] == null) {
$linklist = new SplDoublyLinkedList();
$linklist->push($i);
$this->array[$this->Ha($i)] = $linklist;
} else {
$linklist = $this->array[$this->Ha($i)];
$linklist->push($i);
}
}
public function contain($i) {
if ($this->array[$this->Ha($i)] == null) {
return FALSE;
} else {
$linklist = $this->array[$this->Ha($i)];
$count = $linklist->count();
for ($j = 0; $j <= $count; $j++) {
if($linklist->offsetGet($j)==$i){
return TRUE;
}
}
}
}
}
$hash1 = new hashset2();
$hash1->add(2);
$hash1->add(15);
$hash1->add(12);
var_dump($hash1->array[2]->offsetGet(1));
var_dump($hash1->array[5]);
var_dump($hash1->contain(2));
var_dump($hash1->contain(15));
var_dump($hash1->contain(12));
出于php的语言特性,为了实现代码最大程度的简洁,我对代码做了少许调整,利用了SPL的双向链表来解决碰撞。代码中尚存在一些不完善的地方。通过用链接法,我们就能很好的处理碰撞。那还有其他方法能处理碰撞吗?下面再介绍一种留余散列法。
公式如下:h(k) = k mod m
把我们的核心函数ha()修改为
public function Ha($i) {
return $i%10;
}
其他部分不变,这样我们的这个散列就能接受任意大小的数据,并给它找到一个合适的归属了。
$hashmod=new hash2();
var_dump($hashmod->Ha(3));
var_dump($hashmod->Ha(93));
var_dump($hashmod->Ha(45));
很明显,由余数的定理我们知道,任何一个整数对10取模的结果都是小于10的,那我们就能在我们给他开辟的空间里找到一个key,把value塞进去。你又要问了,93和3除10的余数都是3,那它们岂不是碰撞了呢。似的,没错,它们的确碰撞了。如果我们让可能产生碰撞的两个value比较分散,那是不是就可以降低碰撞的概率了呢?通常我们会选择指数做m,让 k%m的结果尽可能分散。一个好的散列函数应该保证一个均匀的分散。
也就是说当用户指定我要一个10个坑位的空间,我说好,但实际上我并不是真正的挖了10个坑位,而是找到一个最接近10的m值,让k%m的结果尽可能分散,而这个m一般是最接近10的一个质数,如11.
来看看修改后的程序:
<?php
class hash2 {
public $array;
private $reallysize;
private $primetable=array(3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107, 131, 163, 197, 239, 293, 353, 431, 521, 631, 761, 919,1103, 1327, 1597, 1931, 2333, 2801, 3371, 4049, 4861, 5839, 7013, 8419, 10103, 12143, 14591,17519, 21023, 25229, 30293, 36353, 43627, 52361, 62851, 75431, 90523, 108631, 130363, 156437,187751, 225307, 270371, 324449, 389357, 467237, 560689, 672827, 807403, 968897, 1162687, 1395263,1674319, 2009191, 2411033, 2893249, 3471899, 4166287, 4999559, 5999471, 7199369);
public function __construct($size){
$this->reallysize=$this->getPrime($size);
$this->array = new SplFixedArray($this->reallysize);
}
public function Ha($i) {
return $i%$this->reallysize;
}
public function isPrime($i){
if($i&1!=0){//如果是奇数
$middle=intval($i/2);
for ($j= 3; $j<=$middle; $j+=2) {
if($i%$j==0) return false;
}
return TRUE;
}
return ($i==2); //2是质数里唯一的偶数
}
//如果 min 是质数,返回 min;否则返回比 min 稍大的那个质数
public function getPrime($min) {
//从质数表中查找比 min 稍大的质数
for ($i = 0; $i < count($this->primetable); $i++) {
if($this->primetable[$i]>=$min){
return $this->primetable[$i];
}
}
//min 超过了质数表的范围时,探查 min 之后的每一个奇数,直到发现下一个质数
for ($i = ($min|1); $i < PHP_INT_MAX; $i+=2) {
if($this->isPrime($i)) return $i;
}
return min;
}
public function add($i) {
if ($this->array[$this->Ha($i)] == null) {
$linklist = new SplDoublyLinkedList();
$linklist->push($i);
$this->array[$this->Ha($i)] = $linklist;
} else {
$linklist = $this->array[$this->Ha($i)];
$linklist->push($i);
}
}
public function contain($i) {
if ($this->array[$this->Ha($i)] == null) {
return FALSE;
} else {
$linklist = $this->array[$this->Ha($i)];
$count = $linklist->count();
for ($j = 0; $j <= $count; $j++) {
if($linklist->offsetGet($j)==$i){
return TRUE;
}
}
}
}
}
$hashmod=new hash2(20);
var_dump($hashmod->Ha(3));
var_dump($hashmod->Ha(93));
var_dump($hashmod->Ha(45));
$hashmod->add(2);
$hashmod->add(15);
$hashmod->add(12);
var_dump($hashmod->contain(2));
var_dump($hashmod->contain(15));
var_dump($hashmod->contain(12));
?>
注:①由于php的语言特性,为了简便,我使用了php里的array来做质数表。
②本程序中所用的质数表和质数算法来自于已有系统中的实现。一般质数表都是事先编好的,至于是哪些质数,则是通过各种算法和试验所确定的。这里的很多数字都是神奇数字,关于这方面的,可以参考我在数学思维导图里提到过的一个数字,即《一个sqrt函数所引发的血案》里提到的神秘常数。在计算机世界里,类似的常数还有很多。
到此为止,我们就大致手工构造了一个hashtable,了解了hash的一些基本概念。除了链表法,还有开放寻址法用来解决冲突。简单地讲,也就是说,一间厕所,来了一个顾客就蹲其对应的位置,如果又来一个顾客,把厕所单间门拉开,一看里面有位童鞋正在用劲,那么怎么办?很自然的,拉另一个单间的门,看看有人不,有的话就继续找坑。当然了,一般来说,这个顾客不会按顺序一个一个地拉厕所门,而是会去拉他认为有可能没有被占用的单间的门,这可以通过闻味道,听声音来辨别,这就是寻址查找算法。如果找遍了所有厕所单间,看尽了所有人的光屁股,还是找不到坑,那么这座厕所就该扩容了。当然了,厕所扩容不会就只单单增加一个坑位,而是综合考虑成本和保证不让太多顾客拉到裤子里,会多增加几个坑位,比如增加现有坑位的0.72倍。为什么是0.72呢,这是所长多年经营所得到的经验值,为了让自己的经验发扬光大,需要出去演讲,又不能太俗,总不能说“厕所坑位因子”吧,那就把把0.72叫做“装填因子”或者“扩容因子”吧。目前很多产品使用0.72这个常数。我们现在了解了hash的基本原理和构造,以及其扩容方法。对于php而言,数组使用hashtable来实现,对Hashtable来说, 定义它的时候, 不可能一次性分配足够多的内存块, 来保存未知个数的元素, 所以PHP会在初始化的时候, 只是分配一小部分内存块给HashTable, 当不够用的时候RESIZE扩容。Hashtable只能扩容, 不会减少, 所以一般总是分配一块较大的内存空间。因此php的数组,表面上只有100个元素,但实际上内存在为其分配的不止是100个坑位。即使你unset了数组元素,在符号表中,这些空闲的坑位信息还会占据内存。
因此,php的数组并不高效,因为hash无论是性能还是内存占用都比较中庸。
经常有人提到MD5()加密,其实是错误的说法,MD5是一个散列函数,不属于加密。在信息安全算法中,加密算法和散列算法是两个大的不同的分类。把不定长度的输入映射到定长的128位输出上,所以MD5是一定会出现碰撞的。由于散列是单向的,所以从MD5反解出原文是不可能的。所谓的MD5破解并不是还原MD5的原文,而是构造碰撞。Hash的应用是很广泛的。
function md6($x){
return 250;
}
这是一个碰撞的很厉害的hash函数,但他仍然是个hash函数,并且反解出原文的难度是无限大,我把它定义为MD6函数,作为MD5的升级版,来抵御任何破解行为,O(∩_∩)O哈哈~现在,你再遇到一些开源软件里的hash算法时,就大致了解其为何物了。
你现在可以看看memcache里的hash算法了。