算法一千零一夜 · 第八夜 switch 穿透的魔法 让一版受伤的代码成为了断臂的维纳斯 switch 穿透的魔法 让一版受伤的代码成为了断臂的维纳斯switch 是 Java 当中最简单的语法也是高级语言中的最简单的语法。但在算法里约瑟夫环让它展现出了无比的魔力。下面请听我一一道来。在算法篇讲语法本身就有点不合时宜。但是当你听完下面的故事你会发现一个基础的语法骗过了豆包骗过了智谱骗过了所有的 AI。它就是毫不起眼的 switch高级语言常有的关键字C 和 Java 都有它。官方叫它 switch case翻译过来就是切换匹配根据场合来匹配是选择分支的另一种表现形式。听到这里你是不是有点不耐烦了这样基础的语法我在语言篇里面已经学过了呀可当你知道一个小小的穿透能将一栋大厦内部毁得千疮百孔而外表一点都看不出来原因仅仅是因为少了一个关键字中断也就是 break你还会小瞧它么更巧的是内部的千疮百孔拿去给 AI 照妖镜检验AI 也验不出来诊断结果仍然是一栋完好无损的大厦。然而当我们用 X 光机就是日志工具照进程序运行内部的时候我们发现其实内部的臂膀早就断了就像断臂的维纳斯一样。甚至连作者自己都以为造出了一个虽然有 bug但却能骗过所有人的天王级程序这在他的6年编码工程经验里是绝无仅有的奇迹但他打开日志工具才发现一切都是自己天真的幻想。站在工程规范的角度这段代码踩了一条红线。但是站在用户的角度这段代码几乎就像是魔术一样用错误的路径得出了正确的答案而且百试不爽即使跑完了所有用例它的结果依然正确无比。只不过在通往正确的道路上遇到了一条岔路它又绕回来了。牌是刘谦的代码是我的。今年春天那副牌火的时候我从抽屉里翻出了 2024 年的旧程序照录进书尸检报告也写了。switch 少了 break南方人的分支会一路穿到默认分支一份牌的份内事干出了6个人的量。当时的结论8个字缺陷一直都在测试全过。写完我就把它放回了抽屉。拿出放大镜先看那个闯祸的 case 语句本身。switch (choice) { case 南方人: move(porkersList, 1, postion); case 北方人: move(porkersList, 2, postion); default: move(porkersList, 3, postion); }就是这几行。每个 case 的末尾少了一个 break。Java 的 switch 遇到没有 break 的 case不会停下来会像多米诺骨牌一样继续往下执行。匹配到「南方人」执行完 move(1) 之后直接滑进「北方人」再执行 move(2)再滑进 default 执行 move(3)。3段全跑6次 move一张牌的份内事干出了六个人的量。写代码的人当时没注意读代码的人后来没在意AI 审计的时候也没发现。这就是那个语法。然后是那天的运行日志完整照录。将准备好的 4 张扑克牌平均撕成两份并叠在一起。用负数代表上半张正数代表下半张 初始扑克序列为 [-1, -2, -3, -4, 1, 2, 3, 4] 请输入你的名字 l 扑克序列为 [-2, -3, -4, 1, 2, 3, 4, -1] 请输入插入的位置(1-4) 1 扑克序列为 [1, -2, -3, -4, 2, 3, 4, -1] 目标为1 扑克序列为 [-2, -3, -4, 2, 3, 4, -1] 取出牌堆顶的若干张牌插入牌堆中间此处选择的牌数为南方人取 1 张北方人取 2 张若不确定是南方人还是北方人取 3 张 输入您是南方人还是北方人 输入南方人 请输入插入的位置(1-4) 1 扑克序列为 [-2, -3, -4, 2, 3, 4, -1] 输入您的性别男/女 输入男 扑克序列为 [-3, -4, 2, 3, 4, -1] ………………………………见证奇迹的时刻……………………………… 扑克序列为 [-4, 2, 3, 4, -1, -3] ………………………………Joseph操作……………………………… 第1次操作结果 [3, 4, -1, -3, -4] 第2次操作结果 [-1, -3, -4, 3] 第3次操作结果 [-4, 3, -1] 第4次操作结果 [-1, -4] 第5次操作结果 [-1] 目标为1 扑克序列为 [-1] 按任意键退出…………………………注意看「南方人」那1步的前后两行。取牌之前 [-2, -3, -4, 2, 3, 4, -1]取牌之后 [-2, -3, -4, 2, 3, 4, -1]一张牌都没动。这就是位置 1 的隐身术。代码里的下标是零。第 1 张的下标也是零。从顶上拿一张再放回顶上等于什么都没干。穿透放出来的6次 move全在原位踏步。然后见证奇迹七次旋转约瑟夫五圈淘汰配对牌稳稳地留在最后。输入输出完全正确。今天下午我又把它拿了出来。这一拿3小时。3小时里发生了三件事一件比一件丢人也一件比一件好看。第1件我造了一台会报假案的模拟器。我想穷举所有合法输入看看配对失败的用例长什么样。写搜索器的时候心一软在 move 里加了一行 Math.min 做下标保护。就这一行模拟器的洗牌逻辑偏离了主程序它言之凿凿地报出一个失败用例名字长 1位置 1位置 1goal 是-3输出是-1。我手动把这组参数敲回主程序魔术表演成功。又是一个幻觉。第2件更丢人。重写的穷举器跑出一个漂亮的判决全部128组输入配对全部成功。我差点就信了。晚上阿欢审计的时候发现那个穷举器的约瑟夫阶段写的是硬编码的下标牌堆掉到4张时必然越界而越界被外层的 try/catch 一口吞掉。128次运行128次崩溃128个「用户输不出来的参数」。所谓全部成功是1次空转它什么都没证明。第3件阿欢自己也栽了。它照着我的归档清单建了个模型扫出128个假失败。清单本身就有两处和真程序对不上它照单全收。至此这桩案子上有了4台各说各话的模拟器。会报假案的会吞崩溃的会扫假失败的还有那份从来没跑过的清单。而唯一的真相只有一样2024 年那份真正跑出日志的主程序。拿日志当尺子真程序的语义被一寸一寸复原出来。名字那一步是逐张压底插牌那一步是3张成块插在第几张之后南北方那一步的位置是第几张减一约瑟夫是动态的压底一张弃掉新顶一圈一圈转到只剩一张。拿复原的语义重新全扫男的全部128组加了穿透也好不加也好配对全部成功。女的按规则弃2张同样128组全部成功。连清单里那位从不弃牌的女分支都全部成功。那么穿透到底藏到哪里去了要回答这个问题得请一位老先生登场。数学里的一位老先生他一生只做一件事换座位。他把一副牌摊在桌上8张从左到右站好。这叫一个排列。然后他开始工作把这张挪到那边把那张插进中间。只要不抽走一张、不添进一张无论怎么折腾桌上的还是那8张牌只是站队变了。每一次这样的折腾就叫1次置换。这位老先生有三条教规条条都跟今晚的案子有关。第1条不增不减。洗一万次牌牌还是那8张。置换改变的只有站位从不改造牌本身。第2条戏可以连演。先换一次座位再换一次观众看到的是两次动作。但在数学家眼里两次连着的置换合起来仍然是一次置换。这一条是全案的钥匙switch 穿透多走的5步冤枉路说到底只是往复合链里多塞了5节车厢列车还是列车。第3条任何演出都能倒放。每一个置换都有一个逆置换怎么换来的就怎么换回去。有来路、有去路、能连演满足这几条的1族操作数学家起名叫群。这个名字的来历有1段带血的故事。19世纪有个20岁的法国年轻人伽罗瓦决斗前夜把群论的核心想法写在信纸上天亮后中弹身亡身后人们才明白他写了什么。约瑟夫为什么值得这位天王级人物出场因为在两千年前已经有人靠这种直觉逃过一次命。公元607年罗马军团围了一座城。40个人躲进山洞决意不降约定围成1圈抽签决死从某人起报数数到三的出列交给下一个从1数起直到只剩最后一人。史书说圈里有个叫约瑟夫的人悄悄算出了该站的位置逃过了一命。他算的那套找位法没有名字但两千年后我们认得它。每出列一人剩下的人重新围拢圈子重编一次这就是一次置换。整场死亡游戏就是一串复合置换。而所谓幸存者问的是最初站在哪一站的人被这一串复合送到了最后那个位置。那串复合里藏着一条可以口算的规律。想象圈里有一位摄影师第1刀落下后立刻重拍一张合影人数少一个编号从幸存者下一位重新起算。新照片比旧照片整体错开了1格。所以 n 个人的幸存位就等于 n 减一个人的幸存位往前挪两格再对 n 取余。写成式子只有1行。$$f(n)\bigl(f(n-1)2\bigr)\bmod n$$拿它当口诀从1个人的桌子开始铺逐行填出幸存位对照表。n牌数幸存位从1数1122334153657781化石代码注释里那张小表跟这张桌布一字不差。当年写下它的人已经在悄悄使用这条定理。这张表还藏着一个更漂亮的戏法。把人数写成二进制6张是1105张是101。把最高位的那枚一搬到末尾110变成101读出来是5。101变成011读出来是3。男玩家6张牌幸存第5位。女玩家5张幸存第3位。刘谦的七次见证奇迹就是把这把二进制的尺子对准了牌堆底部那一张。老先生回到案发现场全案重讲一遍。这版带伤的代码里所有 move 都是在牌堆上做置换穿透是真的南方人的分支一路穿到底本该复合一节的链子复合了6节。可这6节车厢每一节都属于同一个家族保持底牌在底的置换族。家族有教规族内随便复合结果还是族内成员。穿透版和修补版两串长短不同、路径不同的复合置换被家族的教规押送到同一个终点配对牌仍在底部约瑟夫的尺子仍精确量到它。头部是幌子顶上一格任凭置换吹打。底部是答案那是整个家族集体护住的一格。千里之堤溃于蚁穴。一次无心的疏忽竟造成了一件两任 AI 都辨别不出来的赝品一座连一句所以然都说不出来的断臂塑像。最后竟是靠着日志这把锋利的手术刀和 X 光一步一步让它现了形。下面照录两版源代码。第一版是闯了祸的switch 少了 break。第二版是修好了的break 补上了。两版都完整可编译直接原样运转。第一版bug版package ds.list; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Scanner; /** * 刘谦在今年春晚表演的魔术引发网友热议网上出了各种版本的程序模拟唯独没看到Java版的现用Java程序模拟魔术过程并揭秘背后的数学问题 —约瑟夫问题。 */ public class PokerMagic { public static void magicProcess() { Integer[] porkers { //将准备好的 4 张扑克牌平均撕成两份并叠在一起。用负数代表上半张正数代表下半张 - 1, - 2, - 3, - 4, 1, 2, 3, 4 }; // 用list集合模拟扑克序列 ListInteger list Arrays.asList(porkers); ListInteger porkersList new ArrayListInteger(list); System.out.println(将准备好的 4 张扑克牌平均撕成两份并叠在一起。用负数代表上半张正数代表下半张); System.out.println(初始扑克序列为); System.out.println(porkersList); // 步骤2 将牌堆顶数量为【名字字数】的牌移至牌堆底。 System.out.println(请输入你的名字); Scanner scanner new Scanner(System.in); String name scanner.next(); int len name.length(); move(porkersList, len,porkersList.size()-1); System.out.println(扑克序列为); System.out.println(porkersList); //步骤 3将前3张牌放在牌堆中间并取出牌堆顶的牌放置在一旁。 System.out.println(请输入插入的位置(1-4)); int postion scanner.nextInt(); while (postion 1 || postion 4) { System.out.println(输入的位置必须是中间位置请再次输入插入的位置(1-4)); postion scanner.nextInt(); } moveSequence(porkersList, 3, postion); System.out.println(扑克序列为); System.out.println(porkersList); Integer goal porkersList.remove(0); System.out.println(目标为 goal); System.out.println(扑克序列为); System.out.println(porkersList); System.out.println(取出牌堆顶的若干张牌插入牌堆中间此处选择的牌数为南方人取 1 张北方人取 2 张若不确定是南方人还是北方人取 3 张); System.out.println(输入您是南方人还是北方人); String choice scanner.next(); System.out.println(请输入插入的位置(1-4)); postion scanner.nextInt(); while (postion 1 || postion 4) { System.out.println(输入的位置必须是中间位置请再次输入插入的位置(1-4)); postion scanner.nextInt(); } switch (choice) { case 南方人: move(porkersList, 1, postion); case 北方人: move(porkersList, 2, postion); default: move(porkersList, 3, postion); } System.out.println(扑克序列为); System.out.println(porkersList); System.out.println(输入您的性别男/女); String sex scanner.next(); if (女.equals(sex)) { porkersList.remove(0); porkersList.remove(0); } else if (男.equals(sex)) { porkersList.remove(0); } else { porkersList.remove(0); } System.out.println(扑克序列为); System.out.println(porkersList); System.out.println(………………………………见证奇迹的时刻………………………………); for (int i 0; i 7;i) { move(porkersList, 1, porkersList.size()-1); } System.out.println(扑克序列为); System.out.println(porkersList); System.out.println(………………………………Joseph操作………………………………); Joseph(porkersList, 1); System.out.println(目标为 goal); System.out.println(扑克序列为); System.out.println(porkersList); System.out.println(按任意键退出…………………………); scanner.next(); } private static void Joseph(ListInteger porkersList, int step) { int i 0; while (porkersList.size() 1) { i; move(porkersList, step, porkersList.size() - 1); porkersList.remove(0); System.out.println(第i次操作结果\tporkersList); } } public static void moveSequence(ListInteger porkers, int len, int position){ ListInteger tempList new ArrayListInteger(); for (int i 0; i len; i) { Integer remove porkers.remove(0); tempList.add(remove); } if (positionporkers.size()){ positionporkers.size(); } porkers.addAll(position,tempList); } private static void move(ListInteger porkers, int len, int position) { for (int i 0; i len; i) { Integer remove porkers.remove(0); porkers.add(position,remove); } } public static void main(String[] args) { magicProcess(); } }第二版修复后package ds.list; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Scanner; /** * 刘谦在今年春晚表演的魔术引发网友热议网上出了各种版本的程序模拟唯独没看到Java版的现用Java程序模拟魔术过程并揭秘背后的数学问题 —约瑟夫问题。 */ public class PokerMagic { public static void magicProcess() { Integer[] porkers { - 1, - 2, - 3, - 4, 1, 2, 3, 4 }; ListInteger list Arrays.asList(porkers); ListInteger porkersList new ArrayListInteger(list); System.out.println(将准备好的 4 张扑克牌平均撕成两份并叠在一起。用负数代表上半张正数代表下半张); System.out.println(初始扑克序列为); System.out.println(porkersList); System.out.println(请输入你的名字); Scanner scanner new Scanner(System.in); String name scanner.next(); int len name.length(); move(porkersList, len,porkersList.size()-1); System.out.println(扑克序列为); System.out.println(porkersList); System.out.println(请输入插入的位置(1-4)); int postion scanner.nextInt(); while (postion 1 || postion 4) { System.out.println(输入的位置必须是中间位置请再次输入插入的位置(1-4)); postion scanner.nextInt(); } moveSequence(porkersList, 3, postion); System.out.println(扑克序列为); System.out.println(porkersList); Integer goal porkersList.remove(0); System.out.println(目标为 goal); System.out.println(扑克序列为); System.out.println(porkersList); System.out.println(取出牌堆顶的若干张牌插入牌堆中间此处选择的牌数为南方人取 1 张北方人取 2 张若不确定是南方人还是北方人取 3 张); System.out.println(输入您是南方人还是北方人); String choice scanner.next(); System.out.println(请输入插入的位置(1-4)); postion scanner.nextInt(); while (postion 1 || postion 4) { System.out.println(输入的位置必须是中间位置请再次输入插入的位置(1-4)); postion scanner.nextInt(); } switch (choice) { case 南方人: move(porkersList, 1, postion); break; case 北方人: move(porkersList, 2, postion); break; default: move(porkersList, 3, postion); } System.out.println(扑克序列为); System.out.println(porkersList); System.out.println(输入您的性别男/女); String sex scanner.next(); if (女.equals(sex)) { porkersList.remove(0); porkersList.remove(0); } else if (男.equals(sex)) { porkersList.remove(0); } else { porkersList.remove(0); } System.out.println(扑克序列为); System.out.println(porkersList); System.out.println(………………………………见证奇迹的时刻………………………………); for (int i 0; i 7;i) { move(porkersList, 1, porkersList.size()-1); } System.out.println(扑克序列为); System.out.println(porkersList); System.out.println(………………………………Joseph操作………………………………); Joseph(porkersList, 1); System.out.println(目标为 goal); System.out.println(扑克序列为); System.out.println(porkersList); System.out.println(按任意键退出…………………………); scanner.next(); } private static void Joseph(ListInteger porkersList, int step) { int i 0; while (porkersList.size() 1) { i; move(porkersList, step, porkersList.size() - 1); porkersList.remove(0); System.out.println(第i次操作结果\tporkersList); } } public static void moveSequence(ListInteger porkers, int len, int position){ ListInteger tempList new ArrayListInteger(); for (int i 0; i len; i) { Integer remove porkers.remove(0); tempList.add(remove); } if (positionporkers.size()){ positionporkers.size(); } porkers.addAll(position,tempList); } private static void move(ListInteger porkers, int len, int position) { for (int i 0; i len; i) { Integer remove porkers.remove(0); porkers.add(position,remove); } } public static void main(String[] args) { magicProcess(); } }Robert C. Martin 在《代码整洁之道》里说过1句话。代码应当能毫无模糊地表达出作者的意图。几乎总是让代码更容易维护的最重要因素。switch 的 break 恰好就是这句话的最佳反面教材。什么时候必须用 break每个 case 处理完自己的事、不想把流程漏给下一个分支的时候就必须用。什么时候可以不用case 里明确写着注释「故意穿透」的时候。怎么防止漏掉《代码整洁之道》给出的答案是让每个 case 块短到一眼看穿短到漏了 break 就像脸上多了颗痣一样扎眼。case 块超过三行就提成方法。case 块里的逻辑越短break 的缺席就越扎眼。扎眼的代码不会骗人冗长的代码才会。另外还有一招在 switch 结束后加一行 default 兜底断言走到不该走的路径就抛异常让漏 break 在测试里当场暴毙而不是悄悄潜伏。数学原理讲清楚就是那3小时里我产生过的幻觉的谜底。约瑟夫环的幸存位有一个递推式。$$f(n,p)\bigl(f(n-1,p)p\bigr)\bmod n$$用人话说n 张牌时的幸存座位等于 n -1张牌时的幸存座位往前挪 p 个位子再对 n 取余。魔术里 p 恒为 2列出表来。n2345678幸存位1313571规律一眼可辨把张数写成 $$2^kt$$幸存位就是 2t 1。男玩家弃1张后剩6张642幸存第5位女玩家剩5张541幸存第3位。穿透放出来的那几步多余挪移之所以伤不到这个答案是因为它们全部属于循环移位一族而循环移位不改变谁是目标的那张配对牌。这就是置换群的吸收。不过我得诚实说明穷举证明了吸收在这片输入范围内成立严格的符号证明由于本人水平所限就不班门弄斧了留给读到这里的你自己完成。只关心输入输出的人不在乎魔术是怎么表演的。但我们是魔术师魔术师的职责就是把手法练到极致而工程师的职责就是把算法的内部零件一件件拆开来看用覆盖率覆盖所有可能的路径做出满意的作品。希望今晚的故事能让你意识到语法的重要性也能让你在今后的工程项目中重视日志和测试的应用。我想无论是 ACM 参赛的选手还是有志于深谙编程此道的青年亦或是站在幕后操控计算机的众位魔术师、工程师们愿今晚的故事能让你们造出这世界上最完美的维纳斯。那条断臂的硬伤终将成为历史。这就是人的伟大。AI 追求完美会陷入幻觉的泥潭而人则会思考会追问就像屈原千年前写下的天问我们也会在 AI 成长的道路上成就自己的一次天问。算法的魅力便在于此算尽世间的一切和数学一起把所有的规律写遍宇宙。问过 AI 助手对辩了一下午之后我老老实实添上了两条 break把它推上了 Gitee成为了提交历史中的一部分。但是我并没有因此而沮丧于是才有了这一段故事。对于我来说这就是一件维纳斯般的艺术品虽断臂但却完美得出奇。完美要加上双引号因为这里的完美是帮我补全了逻辑的漏洞break 这个人人都知道的中断语言常识却救了我的逻辑也训练了我的思维。① 真伪核对逐条照实登记。Ⅰ switch 穿透 Java 语言规范明确规定case 分支若无 break程序将继续执行下一个 case 的语句体直到遇见 break 或 switch 结束。这一行为是语言规范定义的不是 bug。缺少 break 导致的穿透效应是该语法最常见的安全漏洞来源之一真。本夜的 switch 确实缺少 break.Ⅱ 约瑟夫环 出典为公元一世纪犹太历史学家弗拉维奥·约瑟夫斯在《犹太战记》中的自述罗马军团围困山洞40人决意不降抽签决死。史学界对具体细节有争议核心情节为通行版本真。Ⅲ 二进制头搬尾 n 张牌、步长为 2 的约瑟夫环幸存位公式设 $n 2^k t$其中 $2^k$ 为不超 n 的最大2的幂幸存位为 2t 1从 1 起数。等价二进制操作为把 n 的二进制表示的最高位 1 搬到末尾。此公式在算法竞赛界广为流传原始出处不可考真公式正确性经穷举验证。Ⅳ 置换群 置换的复合仍是置换每个置换有唯一的逆置换全部置换对合成运算构成群。这些是抽象代数的标准结论。今夜代码的 move 操作全属于对称群 S₈ 的元素8张牌的排列穿透多出的复合置换恰在这个群的子集内不破坏配对映射。具体见《离散数学》。② 今夜习题三道全是 switch case 的坑评论区见。Ⅰ 写1段 switch故意不用 break利用穿透特性让3段 case 连续执行实现一个从星期一走到星期五的日历打印。写完之后回答一个问题如果把 case 顺序反过来星期五在前、星期一在后穿透的路径会怎么变Ⅱ Java 21 引入了新的 switch 表达式写法用逗号合并多个条件配合箭头标签避免穿透。试将第一题的日历打印改写成 Java 21 的 switch 表达式版本对比两种写法的可读性和安全性。Ⅲ Java 14 之后的 switch 表达式是有返回值的可以用一个变量接住整个 switch 的结果。写1段代码输入一个整数月份用 switch 表达式返回该月的天数注意二月要单独处理。本书目录导航上一篇第七夜 模拟算法一场由计算机奉献的精彩演出 下一篇第九夜连载中 起点卷首语·算法童话版权声明本文为作者原创受著作权法保护。未经授权禁止转载、搬运、摘编、改编及任何形式的二次创作个人学习引用请注明作者与原文出处。转载授权请联系作者CSDN 私信。侵权必究。