文章目录
一、前言
有的时候我们需要多个字段来标识用户的一些状态,比如计算日存留这种问题,我们需要标识一个用户,他在哪天活跃,假如活跃的话,状态标识为1
,该天不活跃则为0
,建表如下:
`day1` tinyint(1) NOT NULL DEFAULT '0',
`day2` tinyint(1) NOT NULL DEFAULT '0',
`day3` tinyint(1) NOT NULL DEFAULT '0',
`day4` tinyint(1) NOT NULL DEFAULT '0',
`day5` tinyint(1) NOT NULL DEFAULT '0',
`day6` tinyint(1) NOT NULL DEFAULT '0',
`day7` tinyint(1) NOT NULL DEFAULT '0',
`day8` tinyint(1) NOT NULL DEFAULT '0',
`day9` tinyint(1) NOT NULL DEFAULT '0',
.........
`day27` tinyint(1) NOT NULL DEFAULT '0',
`day28` tinyint(1) NOT NULL DEFAULT '0',
`day29` tinyint(1) NOT NULL DEFAULT '0',
`day30` tinyint(1) NOT NULL DEFAULT '0',
这里可以看到,当我们要记录某个用户近30
日的每天存留信息的话,需要新建30
个字段,实在是恐怖。更关键的是维护十分困难。因此我们这里引出我的主题:位运算
先说结果,使用位运算,我们只需要新建一个字段即可标识用户的近30
日存留:
`drr_daily` varchar(100) NOT NULL DEFAULT '0',
二、位运算实现逻辑
1、逻辑部分如下
/**
* d0是注册24小时内, d1是注册后24-48小时, 为次存,用2的指数来计算, d0的话, 0|pow(2,0-1)=0
* 比如计算d1, 时间差在1*24*3600-2*24*3600之间的,所以是floor((($field - unix_timestamp(log_time))/(1*24*3600)))=1的就是, 用1表示, 即2^0, 所以需要-1
* 比如计算d2, 时间差在2*24*3600-3*24*3600之间的,所以是floor((($field - unix_timestamp(log_time))/(1*24*3600)))=2的就是, 用2表示, 即2^1, 所以需要-1
* 比如计算d3, 时间差在3*24*3600-4*24*3600之间的,所以是floor((($field - unix_timestamp(log_time))/(1*24*3600)))=3的就是, 用4表示, 即2^2
* 结果按位或计算在一起, 所以前3天的就是(2^(1-1))|(2^(2-1))|(2^(3-1))=7, db中记录7, 计算的时候, 用按位与判断, 7&1=1, 7&2=2, 7&4=4,
* 如果是第一天和第三天有记录, 则数据是(2^(1-1))|(2^(3-1))=5, db中记录5, 计算的时候, 用按位与判断, 5&1=1, 5&2=0, 5&4=4, 为0的就说明当天没有数据,
* 如果是第八天和第20天有记录, 则数据是(2^(8-1))|(2^(20-1))=524416, db中记录524416, 计算的时候, 用按位与判断, 524416&1=0, 524416&2=0, 524416&128=128, 524416&524288=524288 为0的就说明当天没有数据,
*/
如注释所示,我们这里用到的方法就是按照位或进行计算,把用户的存留信息全部按照2的n次幂(n = 存留天数 -1)
给位或一下,获取一个唯一的值,存入到drr_daily
字段中。然后需要判断用户存留的时候,再取出来,进行位与运算即可。
网上百度的时候发现有类似的用法,不过他们用的稍微少一些,是通过位运算来存储权限相关的信息,类似于linux
的权限系统,大家可以参考下:
可以参考:
https://blog.csdn.net/kissxia/article/details/49584599
https://blog.csdn.net/e421083458/article/details/12975443
这里运用了按位与运算的特性:任意组合相加的值不会重复。
2、存入数据库部分的代码
$type = 'drr_daily';
$setStr = " pow(2, x-1) "; // x为时间差,即用户活跃时间与注册时间的时间差
$sql = "update aaaa as a, bbbb as b set drr_{$type} = drr_{$type} | {$setStr} where a.uuid = b.uuid {$sqlWhere} ";
}
存储的时候,每次都对当前的drr_daily
字段的值 和计算出来的2^(n-1)
的值进行位或计算,然后存储到数据库。
3、查询数据库示例
//按照查询当天注册用户在近30天内的留存
for ($i = 1; $i <= $this->days; $i++) {
$ok = number_format(pow(2, $i - 1), 0, '', '');
$sql = "select date_format(a.log_time, '%Y-%m-%d') as drr_day, count(*) as nums from xxxx as a
where 1 {$search_where} and drr_daily & {$ok} > 0 group by drr_day ";
$dataRows =$xxx->queryAll();
}
结果示例:
结果形如:
不存在的则代表当日用户没有上线,无留存信息
array(3) {
["2018-10-05"]=>
array(7) {
["Day 0"]=>
string(1) "1"
["Day 2"]=>
string(1) "1"
["Day 3"]=>
string(1) "1"
["Day 6"]=>
string(1) "1"
["Day 7"]=>
string(1) "1"
["Day 11"]=>
string(1) "1"
["Day 13"]=>
string(1) "1"
}
4、php读取字段,并用位与运算解开存留信息
function parseAOI($final_str)
{
$base_num = [];
$arr_finished = [];
$final_str = doubleval($final_str); //参数转化为float类型
for ($i = 0; $i <= 30; $i++) {
$base_num[$i + 1] = number_format(pow(2, $i), 0, '', '');//取整2的$i次方
$arr_finished[] = $i + 1;
}
$parse_val = [];
//下面循环的$key代表天数
foreach ($base_num as $key => $num) {
if ($final_str & $num) { // 这里对传入的参数和2的n次方进行位与运算
$parse_val[$key] = 1;//位与结果为1则代表当日有存留信息
} else {
$parse_val[$key] = 0;
}
}
return $parse_val;
}
如注释所示,这样我们就完美计算出了用户的每日存留信息。只需要一个字段即可,关键是逻辑上也清晰了很多,减少了不必要的冗余。
三、偶然发现的bug(php大数计算问题)
本来计算一个月存留信息是妥妥的,但是后来就想着能显示60
天的留存信息岂不美哉,于是咱们拓展循环到60
,结果发现了一些问题。
打印上面parseAOI()
方法的key
和位与计算的部分:
var_dump("$key D :".($final_str & $num));
打印结果:
string(7) "27 D :0"
string(15) "28 D :134217728"
string(15) "29 D :268435456"
string(15) "30 D :536870912"
string(7) "31 D :0"
string(16) "32 D :1000005119"
string(16) "33 D :1000005119"
string(16) "34 D :1000005119"
string(16) "35 D :1000005119"
当key=32
的时候,num = 2147483648
(接下来超出int
类型限制),之后的计算结果就不准确了,或者说用float
类型来进行位运算本来就是有问题的,下面咱们再来探究下。
1、科学计数法
计算的时候发现,当达到2^47
次方的时候,得出的值是科学计数法表示的大数,而当计算2^46
的时候,得出的是浮点型,如下所示:
$nums = pow(2,46);
var_dump($nums);
47:float(1.4073748835533E+14)
46: float(70368744177664)
2、科学计数法是否可以位运算?
/第1,2,50天都有活跃的话
$a = 3;
$b = number_format(pow(2, 50-1), 0, '', '');
var_dump($b); //string(15) "562949953421312"
$res = $a | $b;
var_dump($res); //2147483647 使用字符串进行或计算是有问题的。如果不转成字符串,那么将会转化为科学计数法,计算位或的结果为3
//第1,2,42天有活跃
$a = 3;
$b = pow(2,42-1);
var_dump($b); //float(2199023255552)
$res = $a | $b;
var_dump($res); // 3
//这里的结果为3明显是不正常的。虽然上面实验当2^47时才显示是科学计数法,但是php的位运算,当整型的值超过最大限制2147483648之后,计算就开始出现错误。到这里就可以明确知道了,我们显然是不能计算大于45天的存留的,数据准确才是第一要素。
如代码所示,这里试验了转成字符串,又试验了科学计数法的位运算,显然是失败的。就如我们知道的,位运算是要转成二进制进行计算的,实际上相当于限定了是整型之间的计算,因此显然是失败的。
3、php的位运算受int范围限制?
这里涉及到了int的范围。一般来说范围是和操作系统的位数有关的。如果是32
位的机器,int范围是:2^32 -1位,
检验方式是:
$nums = pow(2, 31) - 1 + pow(2, 31);
var_dump("----------------------------");
$nums = pow(2, 30) - 1 + pow(2, 30);
$nums1 = pow(2, 32) - 1 + pow(2, 32);
var_dump($nums); // int(2147483647)
var_dump($nums1); // float(8589934591) 这里已经超出了int范围,转化为了float类型
如果是64
位机器,那么范围是(-2^63 -1 ) 到 (2^63 - 1)
,检验方式:
pow(2, 62) - 1 + pow(2, 62);
$nums = pow(2, 62) - 1 + pow(2, 62);
var_dump($nums); //float(9.2233720368548E+18)
以上是理论上的,但是本地实验之后发现,本地虽然是64
位的机器,但是int
类型范围依然是2^32 -1
,大于这个数则直接变成了float
类型,已经不再是int
类型了。但是这里测试又引出另一个问题,为什么当字符长度突破14位的时候,就显示科学计数法呢,这个14是何方神圣?
4、关于float类型14位的限制
这部分直接参考官方手册:
https://www.php.net/manual/zh/ini.core.php#ini.precision
打开php.ini
,有个配置为:precision =14
,官方手册解释为
precision integer :浮点数中显示有效数字的位数。-1 means that an enhanced algorithm for rounding such numbers will be used.
可以理解为精度的取值范围,在14
位以内的就正常显示,大于14
位的就用科学计数法表示,看下面的例子:
$num = 12345678.12345600000000000;
//整数部分为12345678 ,位数为 8 ,小数部分末尾的 0 不计入位数,位数为6,所以总位数为 8 + 6
ini_set("precision", "12");
echo $num; // 12345678.1235
//超过精度值,显示的结果为 12345678.1235
ini_set("precision", "3");
echo $num; // 1.23E+7
//超过精度值,且整数部分位数超过精度,小数部分舍弃,且整数部分只取3位
ini_set("precision", "5");
echo $num; // 1.2346E+7
//超过精度值,且整数部分位数超过精度,小数部分舍弃,且整数部分只取5位,表现为科学计数法
上述例子中可以看到,精度值也关系到整数部分的截取。这样也就能理解,64
位的机器虽然int
范围可以到2^63 -1
,但是为什么到2^46
次方就显示为科学计数法了。并不是范围不够,而是精度位数的问题,整数位超过了规定的14
位精度,因此显示为科学计数法。
5、关于大数的计算
如果要计算大数,那么必须在获取到值之后就转化为字符串,不然赋值的时候就直接变成科学计数法了。这里不讨论太多,只需要知道我们可以通过这种位运算,实现用户的31
天内的存留计算即可。上限不要超过14
位,也不要超过2^63-1
位。超过14
位则运算不准确,这也是php
的float
类型的问题,有兴趣的可以深究一下。
参考:https://segmentfault.com/q/1010000004524597
OK,通过一系列的探索,咱们也算是明确了这个方法的优点和劣势,关于大数计算的问题,php
确实是有些劣势,我们使用的时候注意尽量不要超过int
范围就好了。整体来说是一个不错的方法,这里分享给大家。
end