php使用位运算来实现日留存的算法

一、前言

      有的时候我们需要多个字段来标识用户的一些状态,比如计算日存留这种问题,我们需要标识一个用户,他在哪天活跃,假如活跃的话,状态标识为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位则运算不准确,这也是phpfloat类型的问题,有兴趣的可以深究一下。

参考:https://segmentfault.com/q/1010000004524597

      OK,通过一系列的探索,咱们也算是明确了这个方法的优点和劣势,关于大数计算的问题,php确实是有些劣势,我们使用的时候注意尽量不要超过int范围就好了。整体来说是一个不错的方法,这里分享给大家。

end

  • 8
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 12
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

铁柱同学

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值