你好呀,我是歪歪。
前几天在网上冲浪的时候看到一个消息,关于智能密码锁的。
就是这种玩意:

当时我看到的那个消息说,开密码锁的时候,你输入的数字串只要包含你真正的密码就能开锁。
比如,假设你的密码是:250818。
那你在按密码的时候输入"123250818456"也能开锁。
怎么可能是这样的开锁逻辑呢,密码都没匹配上,门就开了,这不扯呢吗?
所以,我当时以为拍视频的人在一本正经的搞抽象呢,
直到有天晚上回家,在电梯里我突然又想起了这个段子。
于是想着验证一下。
嘿,你猜怎么着?
我开锁的时候在正确的密码前后故意多输入了几个数字,然后再按"#",门开了。
还真不是段子。
当时我大概是这样的:

这玩意有点意思啊。
一般来说我都是用指纹解锁,但是有时候晚上出去跑步,回来之后手上都是汗,指纹识别老是失败。
这种情况下,我就会选择输入密码。
而我之前输入密码偶尔按快了,会出现按错一位的情况。
这个时候我就会轻轻的叹一口气,表示无奈,然后先输入一个"#",让电子锁喊一声"密码错误",再重新输入。
那天我验证了"在按#之前只要包含正确密码输入,门就能打开的这个逻辑"之后,显得我之前的一些操作像是个傻子。
同时我也兴奋的把 Max 同学叫来,给她分享了我的伟大发现。
她说:这不会是 BUG 吧?
我作为程序员,就听不得 BUG 这个东西。
于是我又浅浅的研究了一下,发现这玩意,还真是 feature,不是 BUG。
"这不是 BUG,这是 feature",没想到这句话还真会出现在一些非狡辩的场景下。
甚至这个 feature 几乎是密码锁的标配,而这个功能还有个专门的名称叫:虚位密码。
我在购物网站上随便找一个密码锁,都有相关的介绍:

看介绍,这个功能的使用场景主要就是当有人在你旁边,你又不方便让他回避的时候,你就可以在真实的密码前面输入一些干扰项,输入的长一点,也不怕别有用心的人偷窥了。
这个功能怎么说呢?
我个人认为是聊胜于无,因为我没有这个场景。
但是如果你告诉我,在输密码的时候,自己纯纯手滑,输错了,不用按"#",让密码锁喊一声"密码错误",而是可以直接重新输入一遍。
那我觉得这个功能是真好用。
因为这个场景是我真有。

问题就来了
我在了解到这个现象之后,自然而然的就带入了程序员思维。
所以,那么问题就来了。
假设,现在这是一个面试的场景,面试官要求你写一个逻辑来实现上面"虚位算法"的逻辑:
//判断sourceStr中是否有targetStr
public static boolean checkStr(String sourceStr,String targetStr) {}
你会怎么搞?
首先我们来分析一下。
假设我的密码是:250818。
要判断我输入的一串数字中是否也有 250818 这个序列存在,首先可以确定的是,我们要拿到这两个输入串,然后按照字符,逐个对比。
也就是要把 sourceStr、targetStr 转化为 char[],然后在 for 循环中逐一对比每个字符是否能对上。
而且因为有两个数组,所以这个 for 循环还得是双重 for 循环。
外层循环的是什么?
因为我们是要在 sourceStr,也就是用户输入的密码里面找正确的密码,所以外层循环的肯定得是 sourceStr。
拿着输入密码的第一个字符和 targetStr,也就是正确密码的字符串对应的整个数组进行逐一比较,如果匹配上了,再拿输入密码的后续字符和正确密码进行对比,循环往复,直到对比成功,或者整个输入串对比完成。
这就对应着第二层循环的逻辑。
大体思路还是非常清晰的,但是我们还要解决一个问题:外层 for 循环的次数是多少次?
假设下面这个 for 循环就是在循环 sourceStr,也就是我们要知道这里的 max 值应该是多少?
for (int i = 0; i <= max; i++) {}
这里我们做个假设:
sourceStr=123250818456
targetStr=2501818
那么我们外层的循环,从 sourceStr 的第一个字符"1"开始,最多循环到哪里的时候就能知道密码是否能匹配上了?
是不是循环到123250【8】18456,这个【8】的时候?
因为算上这个【8】后面就只剩下 6 位长度了,如果这个 【8】 都还没匹配上,那它后面的长度已经小于 6 位长度,再去匹配已经没有意义了。
而这个"6 位长度"怎么来的?
是不是就是 targetStr 的长度?
所以 max 的值就是 sourceStr.length-targetStr.length。
按照上面的思路,完整的代码就是长这样的:
arduino
public static boolean checkStr(String sourceStr,String targetStr) {
char[] source = sourceStr.toCharArray();
char[] target = targetStr.toCharArray();
int sourceCount = sourceStr.length();
int targetCount = targetStr.length();
char first = target[0]; // 目标串首字符
int max = sourceCount - targetCount; // 最大可匹配起始位置
// 2. 外层循环:遍历源字符串
for (int i = 0; i <= max; i++) {
// 2.1 快速跳过不匹配位置
if (source[i] != first) {
while (++i <= max && source[i] != first); // 持续跳过直到找到首字符匹配
}
// 2.2 首字符匹配后,校验后续字符
if (i <= max) {
int j = i + 1; // 源字符串下一个位置
int end = j + targetCount - 1; // 目标串结束位置
int k = 1; // 目标串下一个位置,在下面的for循环中进行递增
// 内层循环:逐字符比对
for (; j < end && source[j] == target[k]; j++, k++);
// 3. 校验是否完全匹配
if (j == end) {
return true; // 返回匹配起始索引
}
}
}
return false; // 未找到
}
看到这里可能有小伙伴心理早就开始嘀咕了:整这么复杂干啥玩意?
我一行代码就能秒了这题啊:
sourceStr.contains(targetStr);
好,不错,很有精神!
那我问你:contains 的底层逻辑是怎么样的?
啥,你说你不知道?
那你现在知道了,因为前面的实现逻辑,就是 Java 中 String 类 contains 方法的源码:
contains 方法最终会调用到这个 indexOf 方法:

看了前面的逻辑,你再看这个 indexOf 方法你就会觉得:有点眼熟。
所以这个问题的关键,就是你要抓住关键的问题。
如果是在面试,你就答上面这个按照字符逐个对比的,然后补一句:这个思路和 contains 方法是一样的。
如果是实际写代码,你一句话都不用说,contains 一把梭直接收工就完事。
这玩意就像面试的问你:手撕个 LFU 算法(最近最少使用算法) 来看看。
你回答的时候不能说"可以利用 LinkedHashMap 来实现",对不对?
面试中,你得从 Node 开始撕,拿出双向链表+哈希表的方案来。
至于实际写代码嘛...
对不起,我一个写业务 的 Javaer,用不上这么高级的东西。

还有一个问题
其实在写文章的时候,我突然还想到了一个问题。
一个非常致命的问题。
如果密码锁的"虚位密码"这个逻辑真的成立,说明了什么?
说明密码锁的密码是明文存储的啊。
正常来说,密码肯定是要加密存储的,那不管你用什么加密方式。
250818 和 123250818456 加密出来的密文肯定是完全不一样,天差地别的。
加密后,sourceStr.contains(targetStr) 的逻辑就完全不成立了啊。
所以,从这个现象来看,支持"虚位密码"的密码锁的密码可能是明文存储的。
看到"明文存储"这几个字,是不是感觉很可怕?
如果来一次信息泄露,那不就变成"我家大门常打开"了吗?
关于这个点,我是这样想的。
密码锁的密码并不会存储在商家的服务器里面,而且存储在密码锁的本地。
其实一般来说,明文存在本地也是有风险的,但是在密码锁的这个场景下,其实也是能接受的。
你想想,别人为了拿到你的明文密码,是不是得把锁拆下来搞搞逆向工程啥的。
那锁都拆下来了,门不是轻轻一推就开了吗,还要啥密码?

One More Thing
我第一次得知密码锁的这个 feature ,并在自己家的密码锁上验证过后,确实大为震惊。
震惊的点在于,我日常生活中每天都在用的东西居然还有隐藏功能。
这让我莫名其妙的想到了另外一个点。
这个点是关于微信的。
微信,你点击这个"拍摄",有时候拍出来的照片质感很差:

因为调用的不是手机的原相机。
但是,如果你是安卓手机,那长按"相册"按钮大概 3 到 5 秒,就会唤醒手机的原相机。
当我第一次用上面的方式唤醒手机的原相机的时候,内心活动大概也是这样的:

如果你不知道的话,你可以试一试。