按 ‘ 编程 ’ 标签归档

鸟名查询

鸟类的学名俗名、中文英文互查,我比较相信AVIBASE,但它(从墙内)访问起来太慢,我屏蔽了里面的各色敏感站点都没用。一发狠,写了个网页代我访问,再把数据丢过来。为避免滥用AVIBASE,这个网页里有意加了一些延迟,但还是比直接访问快多了。

调试时遇到的小惊喜:抓取网页内容空白,即使我把phpQuery里file_get_contents扩写为cURL的也没用。最后找到的原因是,AVIBASE拒绝爬虫,需要自称是个浏览器,表现在程序里,就是在cURL初始化时加这么一句:

curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 Chrome/77.0.3865.90');

打僵尸大捷报告会

这是一篇迟到的总结,其实今年2月就已经赢了的。写这个总结是为了详解打僵尸程序的进化过程,帮大家看懂它的战术和走位。

众所周知,渣浪微博的僵尸化很严重,并且渣浪无计可施。很多人以为“微博反垃圾”有用(起初我也这么以为),但微博反垃圾这样解释自己的功能:

也就是说,当某用户被举报封号时,渣浪并不会及时清理它和正常用户的关注/被粉关系,直到该用户手动修正粉丝为止(并且只修好他自己的)。其实这是把流程缺失的责任转嫁到用户身上。并且,从这个解释来看,让绝大多数人头疼的僵尸粉,在渣浪眼里是正常用户:不说话当然不会被举报封号啦。

第一次使用微博反垃圾的,会很欣喜,因为立竿见影,一下子粉丝数就降下来了。那是当然,堆积N年的陈年老尸,被火化时肯定狼烟滚滚。但再使用时,它只能帮着清掉从上次大扫除以来积累的垃圾,效果就会令人失望了。

所以我决定自己搞定这件事。从去年11月开始,用Python语言写了个程序,中间随着战斗局势变化,修改几回。大约到了12月中,程序功能基本上就是今天的样子了。

我把僵尸粉分为这样三种:

  • 经典僵尸,就是简单地沉默寡言。
  • 打榜僵尸,“They were once men.” 结群抱团滚滚而来,发博量如超新星爆发。爱豆活动一结束,立即坍缩成黑洞。爆发时显得脑残,坍缩后更像垃圾。
  • 自语僵尸,程控喃喃自语,诉说爱情痛楚和人生感悟,每天一两次,莫名瘆人。这也是目前卖粉的主打产品。

对这三种僵尸的识别处理,是推进程序的主要动力。下面细说程序的演化过程。

首先说说起始状态。一个被僵尸粉占据的帐户,其粉丝列表大约是这样的结构:

被僵尸攻陷的粉丝列表,数字是页数

图中,红色代表僵尸,蓝色代表正常粉丝,灰色代表250页之后、被渣浪隐藏的粉丝列表(可以想象,其中的僵尸依然是多数)。数字是粉丝列表的页数。渣浪的规则:即使看自己的粉丝列表,也只能看至多250页,每页20个(即最多5000个)。假如看别人的,则最多只能看5页(100个)。

我们期待的理想健康状态,当然是下图这样的,红色泡沫被挤空,蓝色真粉致密地聚集在一起:

健康状态的粉丝列表

知道了自己在哪儿,目标在何方,就可以着手制订路线,准备工具。

最初考虑的是渣浪自己的微博API——它自己家的东西嘛,用起来肯定最趁手。但仔细阅读其功能后就会发现,根本没有删除粉丝的接口(曾经有,后来撤了,让人高度怀疑,僵尸粉都是渣浪自己喂养释放的)。

那就只能自备工具了,我使用早有成熟功能库的Python + Selenium。

和网络相关的编程,最简单直接的操作是“访问链接”,于是,可行性预研从这个办法入手。经过调研发现,“访问1个链接就能删除1个粉丝”的功能,在手机浏览器彩版微博(不是手机客户端)可以实现,但彩版微博最多只给看1000个粉丝。

所以程序最初分为两节:第一节从网页版微博抓取粉丝列表(至多5000个),分析僵尸后写进数据库。第二节读取数据库,把标记为僵尸的,通过访问彩版微博删除。删除之后,前250页留下的空位,会从原本隐藏的地方(上面图中所示灰区)抽调名额填补,这样就又可以循环以上过程,轮转不已。

这个方法确实有用,帮着我删除了最初一两千个。还让我发现,刷页不能过快,否则会被微博拒访几分钟。最初的删除速度,可以点这里了解,但此视频的续集就是几分钟的空白页面,飞速删除所节省的时间全赔进去了。从此,大约两秒1个的“稳健”速度一直保持到最新版。认为这个程序不够快的用户,读到这里应该能理解了。

接下来,我开始对程序的分节运行感到不爽。眼前分明一群僵尸,却要等几十分钟后才能看到它们被删除。更重要的是,抓取和删除间隔久远,谁冤枉了,谁漏网了,根本对不上号。所以我开始做即见即删的版本,看到僵尸就立刻操纵鼠标去打开菜单,点删除按钮,不再攒到一起秋后问斩了。

拉菜单点按钮敲回车的操作当然要比访问链接复杂得多,幸好Python + Selenium的功能还挺齐全,很快就搞定了改写工作。

最初程序分两节运行时,只要能把粉丝列表逐页看完就行, 正着看倒着看并不重要 。但到了即见即删的阶段,访问顺序就变得很重要。即见即删的第一版继承了正向浏览的基础,那时的工作方法是:浏览第1页,删除本页上的僵尸,腾出空位来了,刷新本页,把原在第2页的粉丝填上来几个,再删再刷,直到本页全是活粉,再挪到第2页如法炮制。

说真的,假如不考虑效率的话,这个操作过程比现在的版本还花样繁多,令人沉迷,每次运行时我都能盯着看很久。也正是因为看很久,看出问题了:20个位置,前19个都坐满了活粉,只剩1个空位时,程序还不屈不挠地刷呀删呀,每刷只能删1个,太弱智了。

于是做了个改写,每次从250页逆行到第1页,刷完删完不停留,继续上行。这样每次看到的都是新面孔。当有僵尸从第1页正门持续涌入时,逆流而上更是所见常新。程序朴实了,却变得高效。

读到这里,用户应该明白为何程序总是从后往前扫了。至于每次开始程序总是先看第1页,那是因为要读取最大页码,好知道从哪里开始做起。

逆向即见即删版做完后,程序夜以继日地干活,很快把粉丝删成了这样的分布:

典型的删粉进度

这是一个典型的删粉进度。大蓝块是沙里淘金浓缩成的活粉大军,它左边是正门涌入的僵尸粉,右边是有待处理的旧僵尸。

这时我开始担心两件事:一是假如程序还这么傻不拉几地从250扫到1,那么它的大部分时间会浪费在活粉大军上,并且越来越耗时。二是最终删成下图这样时,前250页挤满了活粉,按关注时间排序的新僵尸只从左边进,旧僵尸从右边冒不出来,程序必然熄火。

熄火状态

第二个问题无计可施(也就是说,真粉极多的大用户,不要指望这个程序带来太大的改观),先解决第一个。我在程序里加了几段代码,让它一旦撞到大蓝块,就指数级速降,尽快跳到粉丝列表的头3页去工作。每清完头3页,就相当于把大蓝块往左拽一拽,从右边抽出更多的粉丝列表来。反正在活粉大军前面干活也是干,后面干活也是干,都没有浪费宝贵时间。

读到这里的用户应该能明白,为何250、249之后,突然就skipping active fans,快速地跑到第3页去了。也会有用户问,可有时程序还是继续老实巴交地248、247、246呀。那是因为我担心有些“活粉”会退化成僵尸,所以安排了5%的概率,让程序重新审视活粉大军,看看有没有蜕变了的。

说到活粉的退化,就要谈谈僵尸的定义了。最原始的定义当然是发博少,但怎么才叫少?注册10年的用户发10帖可以称作少,而来微博刚俩月的,发10博就还算可以了,所以发博量要用注册时间来衡量。准确的注册时间并未体现在粉丝列表里,不过从此人ID上可以看出来。通过采集大量的数据,我得到了这些信息:

  • 可以用“来去之间”的ID和注册时间作一个基准(早于他的可斜率外推)。
  • 微博每天用户增长量大约是1,418,780个。
  • 渣浪的用户ID没有4开头的,4000000000-4999999999这段ID不存在。

这些信息给了我一个经验公式。从粉丝列表读到他们的ID时,我大致可以知道他们何时来的微博,再按照每月一博的标准(很宽容了)来衡量,低于这个阈值的,就判为僵尸删除掉。

用户ID和注册时间的对应关系

所以,随着时间的流逝,曾经的活粉退化为僵尸是很有可能的。有人会说这是误判,但我认为,一个人如果喜欢潜水——后面会提到,一些僵尸都比他活跃——那么他对被粉的博主只是日常“视奸”,并未传播博主的信息,留他何用呢?

再说打榜僵尸。这些帐户的存在通常和某明星或某活动有关,是买来的帐户。在演唱会、见面会开始之前,异常活跃。并且抱团互粉,同进同退。单单从粉丝量、关注量、发博量上来看,都不易判为僵尸。下图是一坨样本。

打榜僵尸团样本

严格来说,他们并非僵尸,结群出现到处留言点赞,无非是想吸引你点开他们的页面,看看他们爱豆的最新动态。但他们是为爱豆来的,与我无关,留他们干嘛呢?

单从数字上识别这一类的僵尸很不容易,活动进行期间更是不可能,结群出现时容易感知其命名规律,但这种规律经不起时间考验,不能放到打算长期运行的程序里。幸好活动一过,他们就会闭嘴。于是,我在程序里做了一个新动作:翻检每个粉丝的主页,如果他们持续静默3个月,就判为僵尸。右图里的这些ID,在新规则出台后很快被删了个一干二净。

翻检主页的动作比较耗时,所以程序只在闲时才做。当它扫完一页,没看到经典僵尸时,就会认为咖啡时间到,从数据库里挑选10个最近没看过的ID,一个个翻阅其主页。“微博会员升级啦”、“生日快乐”、“抢了个大红包”这一类的自动信息不计入此人发帖。

读到这里的用户,应该能明白为何程序忽然不刷粉丝列表,而是去浏览用户主页了。

羽翼初丰的程序刚刚开开心心地运行了两三天,我遭遇了来自渣浪的第一次打击:它只给我看两页粉了!这种情况相当于提前进入了被活粉憋死的熄火状态(想象一下上面熄火图中的250改为2)。很有可能是前期可行性测试时,对微博访问过频,被渣浪的系统安全机器人留意到了。

挑战也是机遇嘛,逼迫我提前考虑如何在熄火状态下持续工作了。最终发现,“按最近联系人排序”的话,互动粉丝列表(最多50页)的后半段还能看到许多僵尸粉(并未互动过)。这个列表不会实时更新,删除僵尸后,第二天大约同一时间才会填充新的。不过,每天还能删大约500个,聊胜于无呀。

读到这里的用户应该能明白,为何程序一上来就先从第50页扫描互动粉丝列表了,而且一旦扫完,8小时内不会再碰。昨天有个用户哭着对我说,为什么要删互动粉丝啊?他们都是活粉真粉啊!我拿到她的活粉样本一看,2010年注册的,发帖24,僵尸都更活泼些……

还有潜水员一听我要打僵尸,马上跑来评论:“真粉报到!”“我是活的!”他们假如不吭气还没事,一说话,第二天就上了互动粉丝列表,被程序看到了,发博数本身就可疑,再翻翻主页三个月没说话,就手起刀落了。

同样,我不把这些叫冤杀,如果不想被判为僵尸粉,应该活出自己的精彩来,经营好自己的地盘,不要只在别人家做客。

对于活粉很多的大用户——哪怕僵尸也很多——相信活粉还是能挤满50页(1000个座位)的,这个放僵尸的门缝可能并不存在。

熄火运作了将近1个月,渣浪忽然又开放了250页列表(后来这类事情又发生过两次),迎来了重新开工后的大高峰。

熄火→开工大爆发

从此僵尸粉一溃千里,今年2月,最终删到了5000以下(250页以内),所有粉丝都移出灰区。终于有把握说,我赢得了这场打僵尸的战役。

此后又有几个次要更新,包括对“真是活人”的提前赦免,以及对自语僵尸的判断(逻辑尚在试运行中),都是细枝末节的事了。

最近有些饱受僵尸困扰的用户来下载使用这个程序,有一些给我的反馈里,提出了很好的建议,打开了新的思路。例如,自语僵尸,我这里没几个,而在别人那里则是主要问题。也有抱怨声,比如程序有点慢,为什么要翻看主页,为什么冤杀活粉,等等。我想说,僵尸没有一定之规,几套判断逻辑互相辅助制衡,共同把僵尸逼上绝路,推敲了大半年。随着局势变化,有些看似漫不经心的流程会渐渐凸显其作用。都是我亲自趟过的坑,好不容易填平了的,请不要再把我拽回坑里去了。

删除僵尸粉的自动化

在删除僵尸粉的自动化方面,做了一点微小的工作,免费分享一下。

准备过程有点复杂,既然是免费分享,服务支持是谈不上了,我只介绍大体如何设置,能看懂的应该就克服各种困难了,看不明白的也不能多解释了……

  1. 64位Windows点这里,32位Windows点这里,获得Python编程语言。确认勾选“Add Python 3.8 to PATH”,其他一切按默认设置安装。(约5分钟)
  2. 右击桌面左下角Windows图标,选“运行”,输入pip install selenium安装Selenium,这是用来遥控Chrome浏览器的Python模块。(约1分钟)
  3. 点这里获得Chrome浏览器并一切按默认设置安装(约5分钟,已有Chrome可跳过)。
  4. 点这里获得Chrome Driver,或者,假如你已经有Chrome,可以到这里下载一个版本匹配的Chrome Driver,把解压后的.exe文件放到一个可执行路径目录里,如:C:\Windows。(约2分钟)
  5. 点这里下载僵尸清除程序,解压后存到任何地方都可以(比如桌面)。右击解压后的spamfancleanse.py,Edit with IDLE,把下图所示的开头三行改成你真正的登录信息和用户信息(放心,源代码开放,信息安全经得起同行的严厉审视),Ctrl-S存盘,按F5运行。

对于首次使用、中途也无意外的用户来说,不疾不徐走完上述设置步骤大约需要15分钟。如果你的粉丝总数小于200,那么手工删除更加省时稳妥。

其它信息:

  1. 删除粉丝是个无法回退的操作,请谨慎使用,作者对使用此免费程序带来的任何后果不承担责任。作者自己是从295,000删到4,400左右的,程序很有效,但很打击虚荣心。
  2. 这个程序并非黑进了渣浪的后台数据库,所有的浏览和删除都和正常的手动操作无异,看到的数据也并无不同。它只是接管了枯燥的翻页和点按鼠标的动作,不叫苦不嫌累而已。另外,“病来如山倒,病去如抽丝”,删除僵尸粉的过程是很“稳健”的,最快两秒一个,不能更快了,否则会被渣浪服务器拒绝访问。
  3. 程序运行时,Chrome受控换页翻屏、菜单弹出收起的各种动作很好看,但不要多看,谨防沉迷。
  4. 如果设置第1步骤未选择“Add Python 3.7 to PATH”,那么第2步骤会出现一闪即关的窗口,内容是抱怨找不到pip在哪里。正常的第2步要花一两分钟才能完成。
  5. 如果你的Chrome开着自动更新,那么就要让Chrome Driver的版本跟上,否则二者相差太多时,程序就打不开Chrome窗口了。当这种事情发生时,到这里来重做一下第4步即可(我一直保持着第4步的链接指向最新版本)。假如设置第4步中Google APIs的站点不好用,可以到其镜像站点获得Chrome Driver。
  6. 如果登录密码含有单引号( ’ ),那么在第5步骤设置密码时,要在密码的单引号之前加个反斜杠( \ ),写为 \’ 。
  7. 开发是在Windows上完成的,设置步骤也只提到Windows,但有使用者反馈:在iOS上程序也可顺利运行,只需下载iOS版的Python、Chrome及Chrome Driver即可。
  8. 随时可以关闭Chrome浏览器窗口中止程序,关闭时,监控小窗里会出现可忽略的错误信息。程序会随时把执行进度存到本地同目录的一个数据库里面,下次运行时会记得处理过谁,没处理过谁。数据库文件是本地同目录的spamfans-userid.db,由程序第一次运行时产生。
  9. 第一次运行时它会根据上面3行代码登录,以后再运行时,它就记得登录状态了(存在上条提到的数据库里)。如果它偶尔忘记,还会根据代码里的信息再填。
  10. 如果程序跑了一段时间后,想转移到另一台机器上运行,除了设置说明里提到的那些步骤外,还要把spamfans-userid.db文件一道拷过去,和spamfancleanse.py放到同一目录下。
  11. 程序运行时,尽量不要把鼠标在Chrome窗口上指来指去,不要把Chrome窗口最小化(用其它窗口遮蔽没事),以免干扰到它。业务十分繁忙的大V最好关闭转发和评论的小黄签提醒,它们可能会让页面元素失效,并使程序异常退出(如其发生,重新运行即可)。
  12. 不是每次运行都需要IDLE打开程序再按F5的,只要把登录信息设置好了,在Windows下双击spamfancleanse.py文件图标即可运行程序。在这种模式下,左击调试监控窗口会造成程序暂停,右击则会继续。
  13. 不建议在多台机器上同时跑这个程序,虽然不会造成误删,也确实能提高效率,但多台机器对粉丝的密集访问可能会被渣浪留意到。被留意到之后,渣浪可能会只让你看少数几页粉丝列表,看不到粉丝列表=不知道谁粉了自己=无法继续删除。
  14. 对“僵尸”的初步定义是发帖少于10,或者跟此人ID体现的注册年代不般配的(很早的ID,却发帖甚少,没有传播力)。程序还会对初筛过的ID一个个翻看主页,最近3个月没说过话的会被移除(原创或转发都算“说话”,而系统产生的生日祝福、会员升级或者抢红包都不算)。躲过一次巡视的并非永久安全了,程序还会根据其发博情况安排下次巡视。如果不想被判为僵尸粉,最重要的不是跑博主这里点赞或发“活粉报到”之类的评论,而是赶紧自己发博转博,提升自己在程序眼里的传播力。次年9月2日,加入了对“会说话的僵尸粉”的判断。这种帐户会隔三岔五地喃喃自语,发表空洞的人生感悟或者爱情宣言。
  15. 有时会在监控窗口里观察到程序针对某个ID反复输出UNSURE,这种问题通常是因为这个用户资料发博甚多,主页却空空如也(渣浪登记的微博数常常大于用户实际的发博数,要不说它渣呢),但程序不确定是服务器原因还是别的什么,所以不敢轻易下手删除。对于这样的用户,另开个浏览器手动删除即可,程序再来观察时会发现已被删除,就不再理它了。
  16. 12月16日,在程序里加了个改进:当逐页扫描撞上沙里淘金浓缩而成的活粉大军时,它会跳过这段漫长的扫描,这样可以节约很多时间。另外,在反复筛查时,又会有较低随机概率扫描活粉大军,确保没有僵尸混进来。
  17. 12月18日发现,渣浪偶然会误报用户数据,把活跃用户的关注、粉丝、发博数报得极低(最甚者以上三个数都是零),造成“这是僵尸粉”的误判。对此偶发问题的应对方法是:当重逢某ID,发现微博所报其发博数急降时,则判为误报,不予理会。对于首次见到的ID,若有此问题,则无法解决。好在这种问题发作频度极低,写好应对代码之后,24小时未重现。
  18. 次年8月17日,解决这个问题:假如一个粉丝(或者自己的小号),确知是个活人,却非常低调,只看不说,表现和僵尸一样,怎么办?两个办法:一是互粉,本程序不会碰互粉帐户。二是做一个文本文件.txt,每行写一个要特赦的用户的10位ID,然后把此文件拖拽到exempt-fans.py文件图标上去。在spamfancleanse.zip压缩包里有一个示例exempted-uid-example.txt。
  19. 次年9月4日,关掉了图片加载。纯文字的页面显示得更快更稳定(我怎么不早这样做呢)。9月11日,支持微博的所有四种语言界面。
  20. 结合《打僵尸大捷报告会》服用,能帮助理解这个程序的执行流程。

和注册机器人的战斗

帮朋友代管一个问答站点。这个站点常常被发垃圾广告的盯上,而且使用注册发帖机器人,潮水般地往上冲。最近一次大规模攻击出现在10月15日晚上到16日早晨,总共产生了43000多条垃圾。

因为只是代管,所以我并不能接触到后台代码和数据库,只能使用网页管理界面来清理垃圾。而这些网页界面又有点残疾,例如,删除垃圾问题时,虽然提供了多选勾框,却只能删除顶头一个。

汇总一下,手边能用的抵抗工具有:

  • 关闭新用户注册。
  • 删除垃圾用户的网页界面(每次只给删20个)。
  • 删除垃圾问题的网页界面(每次只给删1个)。

好在我还是个程序员,虽然并不穿格子装。当天下午,我用JavaScript写了个网页,把垃圾用户/问题的编号范围输入之后,点一下“清理用户”,它就会循环往复地按照原站点期望的格式生成form,并提交删除。按照每秒清理10个用户的速度,很快就干干净净了。

这个清理工具有点小花样:提交删除表格时,会在新窗口打开(否则退不回来,就没法循环执行),打开的新窗又要在5秒钟后关闭(否则任务一多,浏览器的窗口就密密麻麻了)。这样,浏览器不停地弹出新窗又关掉,忙得不亦乐乎的样子,煞是好看。

写好这个清理工具之后,把它往自己的网站上一丢,就可以放心地出去玩了。即使问答站点再遭攻击,我也可以从手机上远程清理了。执行清理代码的权限依赖于事先在问答站点上成功登录,所以也不担心链接泄露被网络爬虫乱点按钮。

接下来我又想:事后发现并清理,终究显得有点被动。当我忙着做图写东西时,还得随时盯着站点,就太分心了。JavaScript在读取外域网页时功能有限,所以我用PHP写了个新版本,能代刷网页,发现出事时,能自己清理。不过,本质上,这个工具就和注册机器人一样了,这大概就是传说中的“终于活成了自己讨厌的人的样子”。

自动防御工事的逻辑是:

  • 每隔一段时间,读取主页上的注册用户数目。
  • 如果突然出现不合常理的激增,则判定为注册机器人攻击。
  • 判定攻击后,首先关闭新用户注册功能,掐断垃圾源头。
  • 接下来,向站点提交表格,删除在此期间出现的所有新用户。
  • 静候半小时后,重新开放注册功能,但半分钟后回来检视一下,看看攻击是不是还在继续。
  • 如果攻击还在继续,再次关闭新用户注册,清理这半分钟产生的小垃圾,如此循环往复。

刚刚写好新工具,一个小机器人就吹着喇叭发动了祭旗式的攻击(上图的UNDER ATTACK指的就是它),攻击从当天傍晚6点持续到次日凌晨零点。刚才说过,每次检测出批量攻击时,程序会先关闭用户注册半小时,然后打开,半分钟后再杀回来重新看一眼。对面这个小机器人在这每次间隔半小时的半分钟缝隙里,急急忙忙地创建垃圾用户,几个小时过去,它用掉了将近一百个帐户编号,也算是相当执着了。

今天早晨出了个巧之又巧的小意外。在网页刚刚启动,进行30秒热身刷新时,放在本站的服务器突然跳表,时间戳从23:01:32跳回23:00:20,呛死了我的防御工事。

它的判断逻辑是“假如新注册的用户数乘以某比率,大于刚才流逝的秒数,则判断是注册机器人攻击。”而这里,虽然新注册用户为零,但“刚才流逝的秒数”是个负值……万幸的是,我从做JavaScript版时,就设了个保险杠,此杠以内的老用户无论如何不能碰。所以虽然代码走进了“删除某些用户”的那一块,却立刻被驳回了。

感谢这个服务器提供的意外test case,这部分代码已加固,不会再被弄死了。

后记:晚上在本站服务器上把玩这个程序时,跳表现象又出现了。不禁大感兴趣,细细研究一下这个现象的来源。它发作得如此频繁,使我无法相信有个系统管理员坐在那里没事调时钟。更像是有多台负载均衡服务器,它们的时钟不一致。

写了个小网页,每5秒输出一次系统时间,在完美世界里,应该获得一条单调上升的直线。然而我实际得到的,是剧烈颠簸的数据。使用Excel画出来就更加一目了然,共有三条直线,说明至少有三台负载均衡,其中两台差异不那么大,而第三台慢了许多。如果拿中间一条线做参照的话,最上面的时钟快了23秒,最下面的则慢了108秒。

程序员入伙书——换还是不换

话说,这道题,十几年前,玩论坛的时代,就出现在网上了:

三道门,一道门后面有车,另外两道门,后面都是羊。
有个节目主持人,他知道车在那道门后。
他请你选一道门,但不立即打开。
他在剩下的两道门里,打开一道,放出一只羊给你看。
现在台上还剩两道门关着,一道是你选的,一道是谁也没碰过的。
主持人说:现在你有个机会,可以改变主意,挑另一道门。
不管你换还是不换,你最终选中的那道门,后头的东西都归你。
你换不换?——假设价值观是车比羊好。

当时,论坛上吵成一片,有说该换的,有说不该换的。对于不换而中奖的概率,大家比较一致:1/3。而对于换而中奖的概率,基本上有三个说法:1/3、1/2、2/3。

论坛上贴题的那人说:“正确答案是应该换,中奖概率会从1/3提升到2/3。并且,此答案经过了智商排名极靠前的一位人士的认可。不过,还是有许多不愿盲信权威的年轻人,自己动手,用各种办法做实验,有大活人亲自做的,有用计算机模拟的,而结果一致确认:该换,中奖概率确实提升到了2/3。另外,虽然实验结果如此,有相当多的亲手做实验的人依然坚持(理论上)不该换。”

时至今日,这道题提到人口稠密的社交媒体上来,依然能够引起争论。

为了让读者老爷们怀着轻松的心情观看余下的内容,我提前把答案确认一下:

应该换。
我不是从一道门换到另一道门,
而是从一道门换到另外两道门。
“换并且选到车”的概率 = “第一次没选到车”的概率

三道门的迷惑性较大,读者可以极端化一下:如果是一亿道门,你挑一扇(选中车的概率很悲观),然后主持人把许许多多门打开,放出来满坑满谷的羊,最后只留下一道门,你换不换?

或者再换一个思路:主持人挑你和另外一个现场观众上来,让你选一道门,让那位观众选两道门,然后问你,愿不愿意拿你的一道门换那位观众的两道门?

“开门放羊”只是个迷惑动作,当门的总数大于2时,挑剩下的门里,一定能放出至少一只羊。所以它没有增加任何信息,也没有改变概率分布,这一步骤和事后开奖并无不同。

用概率论的运算过程则是:

  • 换而中车的概率 =
            首次选羊的概率 × 拿羊换到车的概率 +
            首次选车的概率 × 拿车换到车的概率
  • 首次选羊的概率 = 2/3
  • 首次选车的概率 = 1/3
  • 拿羊换到车的概率 = 100%
  • 拿车换到车的概率 = 0%
  • 因此,换而中车的概率 = 2/3

如果你看到这里还是觉得数学上不该换,那就可以关掉页面了。接下来并没有理论上的新内容,而且因为要用程序表达,技术上会比较烧脑。我不想让你同时背两个包袱走路,会芯片过热的。

 
虽然这道题没有数学理论难度,我还是想拿它做个编程的例子。原因是,我发现用计算机的思维,可以洞穿这道题的本质。做完实验依然坚持不该换的人里面,一定没有用计算机模拟的。

我的第一版程序很简单(手机读者可用手指左右拖动代码区):

import random

def lottery(n, change):  # n是门的个数,change设置“换还是不换”
    car = random.randint(0, n - 1)   # 为车生成一个随机的门号
    pick = random.randint(0, n - 1)  # 我选择一个随机的门号
    # 如果我未选中车但愿意换,或者我选中了车并且不换
    if (pick != car and change) or (pick == car and not change):
        return True      # 我都会中奖
    else:                # 否则
        return False     # 就不会

repeat = 100      # 重复做此实验100次
win = 0           # “中奖”计数器置零
for i in range(repeat):
    if lottery(3, True):  # 每次都选择“换”
        win += 1          # 如果中奖,计数器加一
# 打印中奖百分比
print("%.2f%%" % (win / repeat * 100))

结果如何呢?运行三遍:

=============== RESTART: cargoat.py ===============
68.00%
>>> 
=============== RESTART: cargoat.py ===============
71.00%
>>> 
=============== RESTART: cargoat.py ===============
68.00%
>>> 

100次实验,如果选择“换”,中奖次数确实在2/3左右。当然我们也得反过来做一下,把lottery函数的第二个参数设为False:

repeat = 100      # 重复做此实验100次
win = 0           # “中奖”计数器置零
for i in range(repeat):
    if lottery(3, False):  # 每次都选择“不换”
        win += 1           # 如果中奖,计数器加一
# 打印中奖百分比
print("%.2f%%" % (win / repeat * 100))

运行三遍,选择“不换”的中奖概率确实较低:

=============== RESTART: cargoat.py ===============
34.00%
>>> 
=============== RESTART: cargoat.py ===============
34.00%
>>> 
=============== RESTART: cargoat.py ===============
27.00%
>>> 

还可以发现,实验的重复次数(repeat的值)越高,中奖概率越是稳定在2/3上,下面是repeat = 100000的三遍运行结果:

=============== RESTART: cargoat.py ===============
66.55%
>>> 
=============== RESTART: cargoat.py ===============
66.81%
>>> 
=============== RESTART: cargoat.py ===============
66.70%
>>> 

在这个程序里,原本热热闹闹的挑门、开门放羊、问你换不换的那些动作,一下子被简化成了“你第一次猜中的概率有多大”的问题。我刚才说:“做完实验依然坚持不该换的人里面,一定没有用计算机模拟的。”是因为写程序前,我先假装认为不该换,而写下lottery函数的前两行时,就找到了事情本质的描述方式(上面的粗大字体)。

 
然而,读者可能认为,我并没有精确地按照剧本来写,所以运算结果不可信。为此,我必须按照剧本写一遍程序,像宋小宝吃面那样,辣根和蒜瓣这些步骤都不能省。说实在的,因为需要彻底改变思路,写新程序很累,但我还是勉力完成了:
猛击阅读全文

微博分享按钮

三年前往博客里添加了一个微博分享按钮,最近发现微博分享方式有WBML新版了,于是按照微博介绍的WBML界面重写了这段代码。

先贴代码。新版的添加微博分享按钮的方法:编辑正在使用的monochrome族主题(其它主题可能要微调)的header.php,做两个改动:

一、在<html>标签后增加wb命名空间:

<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:wb="http://open.weibo.com/wb">

二、在</head>之前加入以下代码:

<script src="http://tjs.sjs.sinajs.cn/open/api/js/wb.js" type="text/javascript" charset="utf-8"></script>
<script type="text/javascript">
jQuery(function(){
//  window.setTimeout(function() {  // Make it asynchronous if using the iframe mode.
    jQuery('div.post').each(function() {
        var url = jQuery(this).find('h2.post_title > a').first().attr('href');
        if (typeof(url) == 'undefined') {
            url = location.href;
        }
        var titlestr = jQuery(this).find('.post_title').text() + ': ' + jQuery(this).find('p').not('.wp-caption-text').text();
        titlestr = titlestr.replace(/\s\s+/g, ' ').substr(0, 110);

        var buttonstr = '<div style="width:95%;text-align:right;margin-top:-40px;margin-bottom:40px;">';

        var param = {
            url:url,
            type:'button',
            addition:'number',
            picture_search:'true',
            title:titlestr,
            ralateUid:'1691250947'  // 关联用户的微博用户号码
        };
        var temp = [];
        for(var p in param) {
            temp.push(p + '="' + encodeURIComponent(param[p] || '') + '"');
        }
        buttonstr += '<wb:share-button ' + temp.join(' ') + '></wb:share-button>';


        buttonstr += '</div>';
        jQuery(this).find('div.post_content').first().after(buttonstr);
    });        // jQuery.div.post.each
//  }, 5000);  // window.setTimeout
});
</script>

第一次重写后,IE不稳定地显示按钮、Firefox完全不行(IE竟然胜出,奇迹)。调试发现和一年前的一次优化有关。当时我把分享按钮的JS代码改为异步(橙色代码,页面加载完毕5秒后)运行,而此定时器似与WBML版按钮冲突。

之所以怀疑这个定时器,是因为观察了博客右边栏放置的静态代码“微博关注”按钮。我留意到它总能显示,并且当整个页面加载完毕、网页闲下来时才开始运作。猜想渣浪也发现这些按钮很耗时,于是也在按钮里内置定时器,把我这些外层的定时器顶得不干活了。

另注:渣浪程序员把“关联用户”的设置参数命名为ralateUid,嗯,从上一版延续至今以后也一直会继续错下去的拼写,厚道地猜想这是为了防鹅防寨故意拼错的。另外,微博文档里说抓图选项是searchPic,而快捷设置页里则可以看到是picture_search。我测试之后得知,picture_search是有效参数。

程序员入伙书——函数初步

以前的几个章节里,大家一定常见到print(…)、range(…)、max(…)之类的结构,它们是什么呢?

它们叫函数。不用管这个名字听起来多么奇怪,和数学上的函数、方程的概念多么不同,先接受这个名字,后面我会说这个名字的来历。

比如说,我们在《程序在干什么?》一节的最后一段举过一个求平方根的例子,后来在《初涉算法》一节讲解过它。就拿这个程序为例,如果现在我想求(根号2 + 根号3) x (根号5 – 根号7),该怎么写程序?

当然我们可以把这个例子复制粘贴四遍,第一遍求根号2,第二遍求根号3,第三遍求根号5,第四遍求根号七,然后用这四个中间结果算出最后的值。

这个思路能干活,可就是直觉太傻太啰嗦。四段程序只有喂给它们的数字的差别,实在是太浪费了。所以人们想,可以引入数学上的函数y = f(x)概念,定义好这个函数的公式(f)之后,把参数(x)喂给它,让同一段代码对参数进行运算,运算结果放在y里,这样岂不节约?

好主意!我们现在把求平方根的算法定义成一个函数:

>>> def mysqrt(n):
    high = max(1, n)
    low = 0
    while True:
        result = (high + low) / 2
        if abs(result * result - n)  < n * 1E-8:
            break
        if result * result > n:
            high = result
        else:
            low = result
    return result

>>>

这段程序里最关键的是第一行,以关键字def引导的函数名mysqrt,其后以括号包围要传递给这个函数的参数n,最后是冒号表示:以下的缩进内容是用来定义这个函数的算法。

最后一行return result也很重要,见到return语句时,程序就会返回到使用函数的那个位置,在相应的mysqrt(x)的地方,放上本次运行得到的result值。这个值被称为返回值

说得再多不如实际做一次:
猛击阅读全文

程序员入伙书——数组

从前我用过的程序示例里面,使用的数据都是分离的、独立的。就是说,每个常量和变量,各有各的用途,没有“我们是一伙的”这样的共性。而在现实世界里,成群结队地出现的数据是大多数。例如一架飞机上的旅客,一个班级的学生,奥运会的参赛国。

我见过的所有编程语言都有数组(Array,List)的概念,用来存储具有共同属性的批量数据。在Python里,数组用一对中括号包围的一群数据来表示,数据之间用逗号隔开。例如:

>>> countries = ['China', 'USA', 'Germany', 'Canada', 'Japan']
>>> countries[0]
'China'
>>> countries[3]
'Canada'
>>> countries[4]
'Japan'
>>> 
💡 Python语言允许数组的每个元素类型不同,例如字符串和数字可以混装在同一个数组里面,如[3.14, ‘China’]就是一个正常的Python数组。其它的编程语言通常没有这么大度。在实际应用中,即使是Python,也很少遇到各元素类型不同的案例。

在这里,countries就是一个数组,它有五个元素(Element),每个元素都是一个字符串表示的国名。引用这些元素的时候,我们使用一个整数,来指明它在数组里的位置,这个整数叫做下标(Index)。和大家的习惯稍有差异的是,在Python里,当我们说“数组的第一个元素”时,我们用的下标是0,第二个元素的下标是1,第三个元素的下标是2,依此类推。所以上面的程序范例里面,countries[0]打印出来的是第一个国名China,第五个国名使用的下标是4。

如果你觉得这个规则挺怪异的,想一想这个问题:进入一座楼房,爬到一楼需要爬几层楼梯?爬到五楼需要爬几层楼梯?然后你就释然了,因为对于计算机来说,数组就像楼层,一楼就是它的第一个元素,其它的楼层都是相对于一楼的偏移量。英式英语里,一楼叫做Ground Floor,二楼叫做First Floor,三楼叫做Second Floor,这就是计算机式的思维。

C/C++、Python、Perl、Java、PHP等语言里,第一个元素都是用下标0来表示。FORTRAN、BASIC、PASCAL等语言里,第一个元素是用下标1来表示。这两种表示方法都是合理的,照顾不同人群的思维习惯。

在刚才这个数组里,使用countries[5]会看到什么?
猛击阅读全文

程序员入伙书——轻松一下

一口气写了十几章,轻松一下。

读者老爷可能有点昏沉了,说,这么多章节了,怎么还没见到一个Windows程序呢?咱们就写一个。

在Python Shell里按Ctrl-N(或者点File菜单,选New Window),在弹出的编辑窗口里输入——或者拷贝-粘贴也行,反正今天是“轻松一下”:

import tkinter
root = tkinter.Tk()
root.geometry('240x180')
root.title('hello, world')
tkinter.Label(root, text = "hello, world").pack(fill="both", expand=1)
root.mainloop()

hello-world-tk

按Ctrl-S(或者点File菜单,选Save),随意起个名字,存好文件之后按F5。

怎么样,看到Windows版的hello, world了吧?虽然看起来十分傻,但既然能写hello, world,就能写别的,就能放按钮,就能画图表,就能放菜单,能写一切能想出来的东西。要紧的不在于界面是什么,而在于理解漂亮的外表背后发生的事情。况且,就连这个漂亮的外表本身,也是由灰头土脸的程序写成的。

程序员入伙书——两种循环

其实咱们早已看到过循环了,只是当时并没有要求大家看明白。

循环,就是让程序反复执行同一段代码。利用计算机速度快,脾气好,不怕麻烦的优势(这几点,人类确实比不上),也省得人们把相似的任务反复书写几百遍。

有两种循环,一种是事先预知要循环多少次的,一种事先不知道。这两种循环,咱们在《程序在干什么?》一章里见过。那里头讲过三个例子,第一个例子是从1加到100,我们都知道要循环100次。第二个例子是九九乘法表,我们知道要重复做9行,每行做N列(N等于当前的行数)。第三个例子是猜平方根,这个就不能实现确实要循环多少次了,只隐约觉得精度好过亿分之一就可以结束。

预知循环次数、知道每次循环时要喂什么数据给程序的,叫做for循环。for的意思是:对于(for)每次循环,使用这些值……

预先不知道循环次数,只知道循环的结束条件的,叫做while循环,意思是:当(while)某条件判断为真的情况下,反复执行这段程序……

其实for循环是while的一个特例,例如那个从1加到100的,下面的两段程序,一个用for,一个用while,得到的结果是一样的:

>>> total = 0
>>> for i in range(1, 101):
	total += i
	
>>> total
5050


>>> total = 0
>>> i = 1
>>> while i < = 100:
	total += i
	i += 1
	
>>> total
5050
>>> 

所以我们先讲while。在Python里,while的用法是:

while 条件判断表达式:
	做这个
	做那个
	东张西望

凹进去的这段程序,只要“条件判断表达式”为真(True),就会被反复执行。上面的累加例子里,只要i小于等于100,累加就会持续下去。

while太简单太直观了,没啥可说的,直接说for吧,for的用法是:
猛击阅读全文