代码解密 | 2024春晚刘谦魔术与约瑟夫环问题

简介: 2024春节联欢晚会中,刘谦老师的魔术节目可以说是我心目中的全场最佳~春晚刚结束网上就有大佬给出了第二个魔术(拼扑克牌)的数学模拟,也有大佬发布了代码程序。博主在模拟了魔术过程之后,也在此分享一下程序代码和思路。同时,也借此回顾一下经典的数学问题:约瑟夫环问题。

原创文章首发于CSDN@碳基肥宅:https://blog.csdn.net/wyd_333/article/details/136090646


一、拼扑克牌魔术编程揭秘


1.过程图解


假设初始的4张完整的牌为 [7,8,9,10]:






2.Python代码


用Python语言是因为它对列表操作(切片、拼接等)特别方便:

import random
 
 
# 初始卡片cards队列,4张牌,假设为 7,8,9,10
cards = [7, 8, 9, 10]
 
# 一、把4张牌撕开成8张,放到原来4张牌后
cards += cards
# print(cards)
 
# 二、名字有几个字,名字有几个字就从队头拿几张牌放到队尾
# 从队头取name_len个元素插入队尾
name_len = int(input('名字长度(2~7的整数):'))  # 名字长度
for _ in range(name_len):
    cards.append(cards.pop(0))
# print(cards)
 
# 三、拿起最上面三张,把这三张整体插进剩下卡片的中间任意位置
first_three = cards[:3]  # 队首3个元素,待插入 
other_cards = cards[3:]  # 剩下5个元素
 
# 队首3个元素插入到剩下5个元素的位置是随机的
index = random.randint(1, 4)  # index 可取 1,2,3
cards = other_cards[:index] + first_three + other_cards[index:]
# print(cards)
 
# 四、把第一张牌放到屁股下
key_card = cards.pop(0)
 
# 五、按南北方人取牌,重复上述过程
south_north = int(input('南北方人(南方1,北方2,分不清3):'))  # 南方人还是北方人
first_cards = cards[:south_north]
other_cards = cards[south_north:]
 
# 插入的位置是随机的
index = random.randint(1, 7 - south_north - 1)
cards = other_cards[:index] + first_cards + other_cards[index:]
# print(cards)
 
# 六、按性别取牌,并撒出去
gender = int(input('性别(男1,女2):'))  # 性别
for _ in range(gender):
    cards.pop(0)
# print(cards)
 
# 七、洗牌,“见证奇迹的时刻”
for _ in range(7):
    cards.append(cards.pop(0))
# print(cards)
# 此时若为女,队尾元素调整至倒数第3;若为男,倒数第2
 
# 八、好运留下来,烦恼丢出去
while len(cards) > 1:
    # 好运留下来
    cards.append(cards.pop(0))
    # 烦恼丢出去
    cards.pop(0)
    # print(cards)
 
print(f"assert cards[0] == key_card ? :{cards[0] == key_card}")


运行结果:






二、约瑟夫环问题


1.问题介绍


“约瑟夫环问题”也叫“丢手绢问题”。百度百科介绍了此问题的起源:


据说著名犹太历史学家Josephus有过以下的故事:在罗马人占领乔塔帕特后,39 个犹太人与Josephus及他的朋友躲到一个洞中,39个犹太人决定宁愿死也不要被敌人抓到,于是决定了一个自杀方式,41个人排成一个圆圈,由第1个人开始报数,每报数到第3人该人就必须自杀,然后再由下一个重新报数,直到所有人都自杀身亡为止。然而Josephus 和他的朋友并不想遵从。首先从一个人开始,越过k-2个人(因为第一个人已经被越过),并杀掉第k个人。接着,再越过k-1个人,并杀掉第k个人。这个过程沿着圆圈一直进行,直到最终只剩下一个人留下,这个人就可以继续活着。


问题是,给定了和,一开始要站在什么地方才能避免被处决?Josephus要他的朋友先假装遵从,他将朋友与自己安排在第16个与第31个位置,于是逃过了这场死亡游戏。


可以把分析约瑟夫环问题的过程总结成以下步骤:


  • 总共有 n 个人围成一桌坐下,编号从 1 到 n,从编号为 1 的人开始报数。
  • 报数也从 1 开始,报到 m 的人出局,从出局者的下一位在座成员开始继续从 1 开始报数。
  • 重复这个过程求各成员的出局次序,或求最后一个在座的成员编号。


为了便于接下来的讨论,这里取约瑟夫环问题的一个具体的变式:


30个人在一条船上,船只超载,现需15人下船。于是人们排成一队,排队的位置即为他们的编号。人们循环报数,从1开始,数到9的人下船。如此循环,直到船上仅剩15人为止,问:都有哪些编号的人下船了?


2.数组实现

思路模拟


用数组这种数据结构来描述船上每个人之间的关系。数组实现的方式非常简单,我们可以先手动进行以下模拟:




由此,便能得到出局的元素序列。将以上模拟过程编码就能得到解答程序。由于数组中每个元素是连续且位置固定的,当我们遍历约瑟夫环时,报数报到 m 的人要出局,此处的“出局”是逻辑删除,而不是物理删除(即只是把该号元素标记为“已出局”,而不是真的把这个元素从数组中删去)。

每次遍历约瑟夫环中元素时,通过判断该元素是否被标记为“已出局”来判断该元素是否需要进行报数,因为报数只在未出局的元素中进行。


需要特别注意的是,上述模拟中我们将第一个元素编号为1,而实际Java数组中第一个元素的下标为0。


代码实现

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
 
public class Solution1 {
    /**
     * 数组解决约瑟夫问题
     * @param n 一共有 n 个人
     * @param m 报数到 m 就出局
     * @param peopleLeft 要求最终幸存的人数
     */
    public static List<Integer> JosephusArray(int n, int m, int peopleLeft){
        // 存放出局元素序列
        List<Integer> out_items = new ArrayList<>();
        // 用于标记某位置的人是否还在船上,0表示未出局,1表示已出局
        // 共n个元素,但由于第一个元素的下标为0,而题干要求从1开始报数,为了和下标匹配,长度定义为n+1,下标为0的元素闲置
        int[] flag = new int[n + 1];
        // cnt:目前已出局的人数;i:某编号的人;k:当前报的数
        int cnt = 0, i = 0, k = 0;
 
        while (peopleLeft != n-cnt) {
            // 从第一个人依次进行报数
            i++;
            if (i > n) {
                i = 1;
            }
 
            if (flag[i] == 0) {  // 如果 i 位置的人未出局
                k++;  // 就报一个数
                if (k == m) {  // 如果报到要求出局的数 m
                    flag[i] = 1;  // 标记为出局
                    cnt++;  // 已出局人数+1
                    out_items.add(i);
                    k = 0;  // 清空k,下次重新从0开始报数
                }
            }
        }
        return out_items;
    }
 
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        System.out.println("数组实现约瑟夫环问题:");
 
        System.out.print("共有 n 人:");
        int n = scanner.nextInt();  // n 为总人数
        System.out.print("数到数字 m 时出局:");
        int m = scanner.nextInt();  // 数到 m 时出局
        System.out.print("要留下多少人:");
        int peopleLeft = scanner.nextInt();  // 要留下的人数
 
        System.out.println(JosephusArray(n, m, peopleLeft));
    }
}


运行结果:



3.循环链表实现


思路模拟


用循环链表的数据结构来描述船上每个人之间的关系,示意图如下:



遍历约瑟夫环(此时是一个循环链表),每要出局一个元素,就通过更改元素之间next指向的方式将该元素真正从循环链表中删去(物理删除),并记录到出局元素序列。多轮删除后,循环链表中留下的元素即幸存者。


代码实现

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
 
class Node {
    int data;
    Node next;
 
    Node(int data){
        this.data = data;
    }
}
 
public class Solution2 {
    /**
     * 循环链表解决约瑟夫问题
     * @param n 一共有 n 个人
     * @param m 报数到 m 就出局
     * @param peopleLeft 要求最终幸存的人数
     */
    static List<Integer> JosephusCircleList(int n, int m, int peopleLeft) {
        List<Integer> out_items = new ArrayList<>();    // 存放出局元素序列
 
        // 1-创建循环链表
        Node head = new Node(1);    // 节点编号从1开始
        head.next = head;
        Node pre = head;
        for (int i = 2; i <= n; i++) {
            Node node = new Node(i);
            pre.next = node;
            node.next = head;
            pre = pre.next;
        }
 
        int cnt = 0;    // 已经出局了的人数
        Node cur = head;
        // 2-遍历约瑟夫环
        while(peopleLeft != n-cnt) {    // 当没有达到要求剩下的人数时
            for (int k = 1; k < m; k++) {
                pre = cur;
                cur = cur.next;
            }
            out_items.add(cur.data);
            cnt++;
            pre.next = cur.next;
            cur = cur.next;
        }
        return out_items;
    }
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        System.out.println("循环链表实现约瑟夫环问题:");
 
        System.out.print("共有 n 人:");
        int n = scanner.nextInt();  // n 为总人数
        System.out.print("数到数字 m 时出局:");
        int m = scanner.nextInt();  // 数到 m 时出局
        System.out.print("要留下多少人:");
        int peopleLeft = scanner.nextInt();  // 要留下的人数
 
        System.out.println(JosephusCircleList(n,m,peopleLeft));
    }
}


运行结果:



4.递归实现


递归法的递推公式,这个公式描述了有 n 个人,报到数字 m 出局时,第 i 个出局的人的编号:


J(n, m, i)=( J(n−1, m, i-1) + m ) % n


  • n:参与报数的总人数 n(即约瑟夫环中的元素个数)


  • m:报数报到 m 的元素出局


  • i:第 i 次出局


以下对该递推公式的思路及代码进行详解。


思路模拟


假设约瑟夫环中初始一共有10个元素(即一共10个人参与报数),报数到3就出局,最终只留下唯一的胜利者。



  • 刚开始时,头一个人是编号1,从它开始报数,第一轮出局的元素是编号3:




  • 编号3出局后,编号4元素成为新的报数起点,从1开始重新报数,此时我们可以认为编号4是这个报数队伍的头。第二轮编号6出局:




  • 编号6出局后,编号7元素成为新的报数起点,从1开始重新报数,此时我们可以认为编号7是这个报数队伍的头,第三轮编号9出局……以此类推可知所有出局元素的情况:



令每一轮第一个报数的人都站在数组的最开始(也即下标0)的位置:




根据上图,用 公式J 来归纳每一次出局的情况。之所以每次公式后都要 mod n,是因为约瑟夫环是一个循环的逻辑结构,取模才能实现数字上的循环。


  • 旧环第一次出局:J(10, 3, 1) = 2 (即10个人报数,报到3出局的情况下,第一次出局的位置是下标2)。
  • 旧环第二次出局:J(10, 3, 2) = 5 ,其实它可以通过新环(J(9, 3, 1)+3) % 10 = 2+3 = 5来表示。
  • 旧环第三次出局: J(10, 3, 3) = 8 ,其实它可以通过新环 (J(9, 3, 2)+3) % 10 = ( (J(8, 3, 1) + 3) % 10 + 3) % 10 = 2+3+3 = 8来表示。
  • 旧环第四次出局:J(10, 3, 4) = 1,其实它可以通过新环 (J(9, 3, 3)+3) % 10 = 1 来表示。

……


通过公式 J(n, m, i)=( J(n−1, m, i-1) + m ) % n 可以推导出每次出局时出局元素的下标位置。大家可以更换 n ,m,i 的值重新尝试,比如10个人报数,报到 6 出局,求第一次和第二次出局的元素下标,也能得出公式是正确的。


验证只需要套公式,在公式中代入 n,m,i,然后不断分解,一直写下去直到 i 为 1 返回某个确定的值,将分解出来的每一项求和,最终就能求出公式的值即出局元素的下标位置。这也就是递归的过程。


这个过程可以通俗地理解为,按我们人来找要出局的元素的下标,无非就是把所有元素列到一起然后一个一个数,元素数完了又从头开始再数一遍然后记录下标。而递归的方法其实也是模拟这个过程,但是电脑没有人这么灵活,所以要找出每次出局元素下标和它上一次出局元素下标之间的规律关系。比如第5次出局,要把它与第4次出局联系起来;第4次又要与第3次联系起来,直到找到第1次,也就是初始的一个元素都不少的约瑟夫环之后,再从中得到某一个值,最终答案。


公式描述的就是每次出局元素下标和它上一次出局元素下标之间的规律关系,求得的就是每次出局元素的下标值。


代码实现


由此得到递归方法:

    /**
     * 递归方法
     * @param n 参与报数的总人数
     * @param m 报数到 m 就出局
     * @param i 第 i 次出局
     * @return 第 i 次出局的人(的编号)
     */
    static int J(int n, int m, int i){
        if(i == 1) {
            return (n+m-1) % n;
        }else{
            return (J(n-1, m, i-1) + m) % n;
        }
    }


如果要求出所有元素的出局顺序,只需调用:

     /**
     * 递归法(递推公式)解决约瑟夫问题
     * @param n 一共有 n 个人
     * @param m 报数到 m 就出局
     */
    static List<Integer> JosephusRecursion(int n, int m) {
        List<Integer> out_items = new ArrayList<>();    // 存放出局元素序列
 
        // 出局所有元素
        for (int i = 1; i <= n; i++) {
            out_items.add(J(n, m, i) + 1);    //J方法求的是下标,这里要加一把下标转换为元素本身
        }
 
        return out_items;
    }
 
 
    /**
     * 递归方法
     * @param n 参与报数的总人数
     * @param m 报数到 m 就出局
     * @param i 第 i 次出局
     * @return 第 i 次出局的人(的编号)
     */
    static int J(int n, int m, int i){
        if(i == 1) {
            return (n+m-1) % n;
        }else{
            return (J(n-1, m, i-1) + m) % n;
        }
    }


如果想要控制出局的次数,可以像上面两种方式一样引入一个peopleLeft变量来控制幸存人数(或者说进行出具操作的次数):

/**
     * 递归法(递推公式)解决约瑟夫问题
     * @param n 一共有 n 个人
     * @param m 报数到 m 就出局
     * @param peopleLeft 要求最终幸存的人数
     */
    static List<Integer> JosephusRecursion(int n, int m, int peopleLeft) {
        List<Integer> out_items = new ArrayList<>();    // 存放出局元素序列
 
        // n-people 即要进行出局操作的次数
        for (int i = 1; i <= n-peopleLeft; i++) {
            out_items.add(J(n, m, i) + 1);
        }
 
        return out_items;
    }
 
    /**
     * 递归方法
     * @param n 参与报数的总人数
     * @param m 报数到 m 就出局
     * @param i 第 i 次出局
     * @return 第 i 次出局的人(的编号)
     */
    static int J(int n, int m, int i){
        if(i == 1) {
            return (n+m-1) % n;
        }else{
            return (J(n-1, m, i-1) + m) % n;
        }
    }


加入main方法补充完整:

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
public class Solution3 {
    /**
     * 递归法(递推公式)解决约瑟夫问题
     * @param n 一共有 n 个人
     * @param m 报数到 m 就出局
     * @param peopleLeft 要求最终幸存的人数
     */
    static List<Integer> JosephusRecursion(int n, int m, int peopleLeft) {
        List<Integer> out_items = new ArrayList<>();    // 存放出局元素序列
 
        // n-people 即要进行出局操作的次数
        for (int i = 1; i <= n-peopleLeft; i++) {
            out_items.add(J(n, m, i)+1);
        }
 
        return out_items;
    }
 
    /**
     * 递归方法
     * @param n 参与报数的总人数
     * @param m 报数到 m 就出局
     * @param i 第 i 次出局
     * @return 第 i 次出局的人(的编号)
     */
    static int J(int n, int m, int i){
        if(i == 1) {
            return (n+m-1) % n;
        }else{
            return (J(n-1, m, i-1) + m) % n;
        }
    }
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        System.out.println("循环链表实现约瑟夫环问题:");
 
        System.out.print("共有 n 人:");
        int n = scanner.nextInt();  // n 为总人数
        System.out.print("数到数字 m 时出局:");
        int m = scanner.nextInt();  // 数到 m 时出局
        System.out.print("要留下多少人:");
        int peopleLeft = scanner.nextInt();  // 要留下的人数
 
        System.out.println(JosephusRecursion(n,m,peopleLeft));
 
 
    }
}


运行:


出局元素顺序和上面画图模拟的顺序相同



运行结果与用数组、链表两种方式实现的代码运行结果相同


相关文章
|
5月前
【蓝桥杯】[递归]母牛的故事
蓝桥杯——[递归]母牛的故事
65 1
【蓝桥杯】[递归]母牛的故事
|
6月前
力扣-2029-石子游戏-‘屎山’代码
力扣-2029-石子游戏-‘屎山’代码
48 3
|
7月前
|
存储 算法
【随想】每日两题Day.20(实则一题)
【随想】每日两题Day.20(实则一题)
39 0
|
7月前
【随想】每日两题Day.16(实则一题)
【随想】每日两题Day.16(实则一题)
36 0
|
7月前
【随想】每日两题Day.3(实则一题)
【随想】每日两题Day.3
32 0
|
7月前
【错题集-编程题】孩子们的游戏(圆圈中最后剩下的数)(约瑟夫环)
【错题集-编程题】孩子们的游戏(圆圈中最后剩下的数)(约瑟夫环)
|
7月前
35.鸡兔同笼问题:今有雉兔同笼,上有三十五头,下有九十四足,问雉兔各几何?
35.鸡兔同笼问题:今有雉兔同笼,上有三十五头,下有九十四足,问雉兔各几何?
58 0
|
7月前
|
算法
2024春晚纸牌魔术原理----环形链表的约瑟夫问题
2024春晚纸牌魔术原理----环形链表的约瑟夫问题
|
存储 算法 C语言
从古迷题到现代奇迹:神奇的约瑟夫环(C语言)
从古迷题到现代奇迹:神奇的约瑟夫环(C语言)
332 0
|
算法
回溯算法——我欲修仙(功法篇)
回溯算法——我欲修仙(功法篇)
106 0