约瑟夫(Josephus)环问题来源是这样的:据说著名犹太历史学家 Josephus有过以下的故事:在罗马人占领乔塔帕特后,39个犹太人与Josephus及他的朋友躲到一个洞中,39个犹太人决定宁愿死也不要被敌人抓到,于是决定了一个自杀方式,41个人排成一个圆圈,由第1个人开始报数,每报数到第3人该人就必须自杀,然后再由下一个重新报数,直到所有人都自杀身亡为止。然而Josephus和他的朋友并不想遵从。首先从一个人开始,越过k-2个人(因为第一个人已经被越过),并杀掉第k个人。接着,再越过k-1个人,并杀掉第k个人。这个过程沿着圆圈一直进行,直到最终只剩下一个人留下,这个人就可以继续活着。问题是,给定了和,一开始要站在什么地方才能避免被处决?Josephus要他的朋友先假装遵从,他将朋友与自己安排在第16个与第31个位置,于是逃过了这场死亡游戏(来自百度百科)。
也就有了之后比较常见的面试题即:0,1,...,n-1这n个数字排成一个圆圈,从数字0开始每次从这个圆圈里面删除第m个数字。求出这个圆圈里剩下的最后一个数字。
面对这个问题场景,我们先画画图,然后很自然的习惯用一个数据结构来模拟这个围成的圆圈,再然后模拟整个报数删除的游戏过程,最后整个圈子只剩下一个数字,那么顺着我们以后的这个思路,采取什么数据结构去实现呢?数组?链表?好像都可以实现。对于常规的做法来说这是比较行得通的,毕竟我们模拟了整个过程,符合我们的思考的逻辑,通过模拟整个过程,具体的数组和链表实现代码如下所示:
1.数组实现方式:因为整个圈子的人只有2个状态in和out,就像撕名牌游戏一样,只有名牌还在和已经被撕,因此可以给所有参与人统一设置标志,数到3的人标志为false,然后从下一位接着数再次数3标志为false等等。 所以笼统的说数组的实现方式是初始化所有参与人都是true,一遍遍循环找出符合条件的人然后贴上false标签,直到圈子中最后只剩下一个元素。圈子的大小一直都没变,圈子的人贴上了不同的标签。
代码实现:
/**
* 约瑟夫环问题 数组实现
* 简单的讲又叫数三砍一问题 一圈人从1开始报数 报3的退出 然后从下一个重新开始数
* 1.数组实现
*/
public class JosephusQuestionByArray {
public static void main(String[] args) {
int nums = 10;
int flag = 3;
boolean[] arr = new boolean[nums];
for (int i = 0; i < arr.length; i++) {
arr[i] = true;//一开始每个在圈子里的人都设置为true
}
int leftCount = arr.length; //剩下的人数
int countNum = 0; //充当计数器的角色 到3归0
int index = 0; //从头开始数 数到哪里的标志位
while (leftCount > 1) {
if (arr[index] == true) {
//说明还在圈子里 计数器加1
countNum++;
if (countNum == flag) {
countNum = 0; //计数器归0
arr[index] = false; //此人退出圈子
leftCount--;//圈子中剩余的人数-1
}
}
index++;
//因为是一个圈子 要是到了尾部 则index重新开始
if (index == arr.length) {
index = 0;
}
}
//以上代码 整个一套下来 就可以设置圈子里留下来的标志为true 其他人都标志为false
for (int j = 0; j < arr.length; j++) {
if (arr[j] == true) {
System.out.println(+nums + "个人围成的圈子,最后剩下的数字是" + j);
}
}
}
}
输出结果:
2.链表实现方式:我们使用的是系统自带的链表,要是有具体要求的话也可自己先实现链表。循环链表跟数组的实现有很大的不同,因为链表的remove()方法,可以使我们找到符合条件的元素后删除之,此时整个链表的size()就减一,其实正好如此的巧妙,假设初始位置是index=0,圈子人数是5,数到2删除,那么链表的整个模拟过程如下:
==》 0 1 2 3 4 (n=5,m=2) 从index=0开始 index=1删除
==》 0 2 3 4 本来第一轮中index=1指向的是1,删除之后要从下一个开始报数,恰好链表size-1,此时index=1指向的是2,也就说index不用变
==》 0 2 4 remove(index=3)
==》 2 4
==》 2 最后留下的即为2;
代码实现如下:
/**
* 使用循环链表方式
*
* 算法真是一个神奇的东西 真是太巧妙了
*/
public class JosephusQuestionByList {
public static void main(String[] args) {
int nums = 10; //圈子的人数
int flag = 3; //数到flag退出
LinkedList<Integer> list = new LinkedList<>();
for (int i = 0; i < nums; i++) {
list.addLast(i);
}
int index = 0;
while (list.size() > 1) {
//真是巧妙的东西 根据链表的特性 按照正常逻辑来说 到了3 应该从下一位开始重新数 但是链表的特性正好满足了删除一位之后 index指向下一位 神奇
//删除index=2后 list长度-1 新的index=2对应的元素正好是老链表的下一位 这样开始正好
for (int i = 1; i < flag; i++) {
if (index == list.size() - 1) {
index = 0;
} else {
index++;
}
}
list.remove(index);
}
list.get(0);
System.out.println("循环链表实现:"+nums+"人围成的圈子,数3去1最后输出的数字是" + list.get(0));
}
}
输出结果:
3.上面用的是系统自带的LinkedList模拟循环链表,现在是自己定义的链表,第一次是在马士兵老师的java基础视频上看到的,在java基础上考虑到面向对象的思想,模拟的场景是几个小孩手拉手围成一个圈子,那么对象就是小孩Kid,先想属性后想方法,那么属性有代编小孩的id、小孩子左右手都有小孩子(要是只有一个的话,那左右手边的小孩子都是自己);还有一个对象是KidCircle,小孩组成的圈子,属性有圈子的人数,方法有添加、删除小孩。其实通过这个方式更让人理解面向对象的思想和双向循环链表的特点,可以多敲几遍。
代码如下: 因为只是测试方法就用了内部类。
/**
* 结合面向对象的思想
* 使用自己创建的链表来模拟整个过程 对象就是Kid 小孩子
* 10个小孩拉成圈子 什么是对象呢 kid和kidcircle 这就是对象
*/
public class JosephusQuestionByMyLinkedList {
public static void main(String[] args) {
int nums = 6; //定义要添加的人数
int flag = 2; //数到2退出
KidCircle kidCircle = new KidCircle();
for (int i = 0; i < nums; i++) {
Kid kid = new Kid(i);
kidCircle.addKid(kid); //圈子中添加人 这里的addKid()本质上是链表中的addLast()方法,添加到尾部
}
int countNum = 0;//用来计数
Kid kid = kidCircle.first; //先拿到第一个小孩
while (kidCircle.count > 1) {
countNum++;
if (countNum == flag) {
countNum = 0;
kidCircle.deleteKid(kid);
}
kid = kid.right;//一个一个往后报数 这样更好理解
}
System.out.println("自己写的循环链表:" + nums + "小孩围成的圈子,数2去1最后剩余的小孩是:" + kidCircle.first.id);
}
/**
* 对象kid 那些属性呢?
* 1.最后要数那个小孩留下,所以每个小孩都有一个编号 id
* 2.要围城一圈 必然有左边的小孩 和右边的小孩 当只有一个小孩时 左右小孩都是自己
*/
private static class Kid {
int id;
Kid left; //因为左边也是小孩 所以明确定义其类型
Kid right;
Kid(int i) {
this.id = i;
}
}
/**
* 对象kidcircle 先属性后方法 对于这个圈来说 属性有啥?圈里人数count 圈有开头和结束
* 1.添加小孩子
* 2.删除小孩子
*/
private static class KidCircle {
int count = 0;
Kid first = null;
Kid last = null;
/**
* 添加小孩子 注意 添加的小孩子不是随意位置的而是在尾部添加
*
* @param newKid
*/
public void addKid(Kid newKid) {
if (count <= 0) {
//说明圈子里一个小孩子也没有 添加的是时候
first = newKid; //第一个和最后一个都是他
last = newKid;
newKid.left = newKid; //此小孩子的左右边都是自己
newKid.right = newKid;
} else {
//画图理解 因为是尾部 所以操作的就是first和last
last.right = newKid;//原先last指向最后的小孩子变成了新添加的
newKid.left = last;
newKid.right = first;
first.left = newKid;
last = newKid;
}
count++;
}
/**
* 删除小孩子 这个跟add真不一样 添加小孩子 那是只是添加到尾部 本质是addLast()
* 而deleteKid是删除的位置是随机的
*
* @param deleteKid
*/
public void deleteKid(Kid deleteKid) {
if (count <= 0) {
return;
} else if (count == 1) {
//如果圈子中只有一个小孩子 那么直接让first和last指向null即可
first = last = null;
} else {
//圈子中的人数足够多 但是删除人要是位于首位和尾部是特殊考虑的
if (deleteKid == first) {
first = deleteKid.right;
} else if (deleteKid == last) {
last = deleteKid.left;
}
deleteKid.left.right = deleteKid.right; //删除小孩子左边的右手指向原来的右侧
deleteKid.right.left = deleteKid.left;
}
count--;
}
}
}
输出结果:
小结:以上我们是通过选择数组或是链表为数据结构然后模拟整个游戏的过程,最后输出结果。其实无论是用链表实现还是用数组实现都有一个共同点:要模拟整个游戏过程,不仅程序写起来比较烦,而且时间复杂度高达O(nm),当n,m非常大(例如上百万,上千万)的时候,几乎是没有办法在短时间内出结果的。所以有了之后采用数学角度,打破常规寻找规律的方式解决此问题,其时间复杂度是O(n),空间复杂度是O(1),具体分析过程,之后将进行介绍。
4.使用数学方法,就是根据人数n和报数flag,确定最后留下的下标。比较绕比较抽象比较难以理解,希望能说明白。
推理:主要是通过映射关系
人数是n,此轮去除的下标是(m-1)%n,编号为(m+0)%n将作为下轮(人数是n-1)编号为0的人,类推此轮编号为(m+i)%n将作为下轮编号为i的人。
反过来说,这轮(人数为n-1)的i编号相当于上轮(人数为n) (m+i)%n的编号。
假设最后留下来的编号是i = x(n-1),那么在上轮表示的就是编号 [m+x(n-1)]%n 等于x(n);
即公式x(n) = [m+x(n-1)]%n;当n=1时,显然x(1) = 0;
我自己画了一图,可以看一看,能不能想通,图示:
图 约瑟夫问题数学归纳推导
代码如下:
/**
* 使用数学规律高效解决
* 公式是x(1) = 0;
* x(n) = [m+x(n-1)]%n; 然后使用递归
*/
public class JosephusQuestionByMath {
public static void main(String[] args) {
int nums = 10;
int flag = 3;
int index = getJosephusQuestionByMath(nums, flag);
System.out.println("依靠数学规律:" + nums + "个小孩子围成的圈子,数3去1最后留下的是" + index);
}
private static int getJosephusQuestionByMath(int nums, int flag) {
if (nums == 1) {
return 0;
} else {
return (flag + getJosephusQuestionByMath(nums - 1, flag)) % nums;
}
}
}
输出结果:
显然解决约瑟夫环问题这个是最最高效的,算法的时间复杂度是O(n),显然有了很大的提高。
解决约瑟夫环问题我给出了以上4种方式,代码如有问题请及时指出。