约瑟夫环的解法
参考:
约瑟夫环问题的三种解法讲解 - 力扣(LeetCode) (leetcode-cn.com)、
找出游戏的获胜者 - 找出游戏的获胜者 - 力扣(LeetCode) (leetcode-cn.com)
约瑟夫环——公式法(递推公式)_陈浅墨的博客-CSDN博客_约瑟夫环数学公式
问题
约瑟夫环问题是算法中相当经典的一个问题,其问题理解是相当容易的,并且问题描述有非常多的版本。
什么是约瑟夫环问题?
如下:
1823. 找出游戏的获胜者
共有
n
名小伙伴一起做游戏。小伙伴们围成一圈,按 顺时针顺序 从1
到n
编号。确切地说,从第i
名小伙伴顺时针移动一位会到达第(i+1)
名小伙伴的位置,其中1 <= i < n
,从第n
名小伙伴顺时针移动一位会回到第1
名小伙伴的位置。游戏遵循如下规则:
- 从第
1
名小伙伴所在位置 开始 。- 沿着顺时针方向数
k
名小伙伴,计数时需要 包含 起始时的那位小伙伴。逐个绕圈进行计数,一些小伙伴可能会被数过不止一次。- 你数到的最后一名小伙伴需要离开圈子,并视作输掉游戏。
- 如果圈子中仍然有不止一名小伙伴,从刚刚输掉的小伙伴的 顺时针下一位 小伙伴 开始,回到步骤
2
继续执行。- 否则,圈子中最后一名小伙伴赢得游戏。
给你参与游戏的小伙伴总数
n
,和一个整数k
,返回游戏的获胜者。
示例1:
输入:n = 5, k = 2
输出:3
解释:游戏运行步骤如下:1) 从小伙伴 1 开始。
2) 顺时针数 2 名小伙伴,也就是小伙伴 1 和 2 。
3) 小伙伴 2 离开圈子。下一次从小伙伴 3 开始。
4) 顺时针数 2 名小伙伴,也就是小伙伴 3 和 4 。
5) 小伙伴 4 离开圈子。下一次从小伙伴 5 开始。
6) 顺时针数 2 名小伙伴,也就是小伙伴 5 和 1 。
7) 小伙伴 1 离开圈子。下一次从小伙伴 3 开始。
8) 顺时针数 2 名小伙伴,也就是小伙伴 3 和 5 。
9) 小伙伴 5 离开圈子。只剩下小伙伴 3 。所以小伙伴 3 是游戏的获胜者。
方法一:模拟 + 队列
最直观的方法是模拟游戏过程。
- 使用队列存储圈子中的小伙伴编号,初始时将 1 到 n 的所有编号依次加入队列,队首元素即为第 1 名小伙伴的编号。
- 每一轮游戏中,从当前小伙伴开始数 k 名小伙伴,数到的第 k 名小伙伴离开圈子。
模拟游戏过程的做法是:
- 将队首元素取出并将该元素在队尾处重新加入队列,重复该操作 k - 1 次,则在 k - 1 次操作之后,队首元素即为这一轮中数到的第 k 名小伙伴的编号
- 将队首元素取出,即为数到的第 k 名小伙伴离开圈子。
上述操作之后,新的队首元素即为下一轮游戏的起始小伙伴的编号。
每一轮游戏之后,圈子中减少一名小伙伴,队列中减少一个元素。重复上述过程,直到队列中只剩下 11 个元素,该元素即为获胜的小伙伴的编号。
1 class Solution { 2 public int findTheWinner(int n, int k) { 3 Queue<Integer> queue = new ArrayDeque<Integer>(); 4 for (int i = 1; i <= n; i++) { 5 queue.offer(i); 6 } 7 while (queue.size() > 1) { 8 for (int i = 1; i < k; i++) { 9 queue.offer(queue.poll()); 10 } 11 queue.poll(); 12 } 13 return queue.peek(); 14 } 15 }
当然,这种算法太复杂了,大部分的OJ你提交上去是无法AC的,因为超时太严重了,具体的我们可以下面分析。
方法二:有序集合模拟
上面使用链表直接模拟游戏过程会造成非常严重非常严重的超时,n个数字,数到第m个出列。因为m如果非常大远远大于m,那么将进行很多次转圈圈。
所以我们可以利用求余的方法判断等价最低的枚举次数,然后将其删除即可。
在确定最低的枚举次数以后,怎么求的具体位置呢?继续模拟吗?
可以,但是,还是麻烦了,事实上,可以直接求的下一次具体的位置,公式就是为:
1 index=(index+m-1)%(list.size());
因为index是从1计数,如果是循环的再往前m-1个就是真正的位置,但是这里可以先假设先将这个有序集合的长度扩大若干倍,然后从index计数开始找到假设不循环的位置index2,最后我们将这个位置index2%lens(集合长度)即为真正的长度。
使用这个公式一举几得,既能把上面m过大循环过多的情况解决,又能找到真实的位置,就是将这个环先假设成线性的然后再去找到真的位置,如果不理解的话可以再看看这个图:
这种情况的话大部分的OJ是可以勉强过关的,面试官的层面也大概率差不多的。代码如下:
1 class Solution { 2 public int findTheWinner(int n, int m) { 3 if (m == 1) 4 return n - 1 + 1; 5 List<Integer> list = new ArrayList<>(); 6 for (int i = 0; i < n; i++) { 7 list.add(i); 8 } 9 int index = 0; 10 while (list.size() > 1) { 11 index = (index + m - 1) % (list.size()); 12 list.remove(index); 13 } 14 return list.get(0) + 1; 15 } 16 }
方法三:递归公式解决
我们回顾上面的优化过程,上面用求余可以解决m比n大很多很多的情况(即理论上需要转很多很多圈的情况)。但是还可能存在n本身就很大的情况,无论是顺序表ArrayList还是链表LinkedList去频繁查询、删除都是很低效的。
所以聪明的人就开始从数据找一些规律或者关系。
这边我们先把结论抛出了。之后带领大家一步一步的理解这个公式是什么来的。
递推公式:
1 f(N,M)=(f(N−1,M)+M)%N
-
f(N,M) 表示,N个人报数,每报到M时杀掉那个人,最终胜利者的编号
-
f(N−1,M)表示,N-1个人报数,每报到M时杀掉那个人,最终胜利者的编号。
下面举例说明, 我们不用字母表示每一个人,而用数字。
1、2、3、4、5、6、7、8、9、10、11
表示11个人,他们先排成一排,假设每报到3的人被杀掉。
-
刚开始时,头一个人编号是1,从他开始报数,第一轮被杀掉的是编号3的人。
-
编号4的人从1开始重新报数,这时候我们可以认为编号4这个人是队伍的头。第二轮被杀掉的是编号6的人。
-
编号7的人开始重新报数,这时候我们可以认为编号7这个人是队伍的头。第三轮被杀掉的是编号9的人。
-
……
-
第九轮时,编号2的人开始重新报数,这时候我们可以认为编号2这个人是队伍的头。这轮被杀掉的是编号8的人。
-
下一个人还是编号为2的人,他从1开始报数,不幸的是他在这轮被杀掉了。
-
最后的胜利者是编号为7的人。
下图表示这一过程(先忽视绿色的一行)
现在再来看我们递推公式是怎么得到的!
将上面表格的每一行看成数组,这个公式描述的是:幸存者在这一轮的下标位置
-
f(1,3):只有1个人了,那个人就是获胜者,他的下标位置是0
-
f(2,3)=(f(1,3)+3)%2=3%2=1:在有2个人的时候,胜利者的下标位置为1
-
f(3,3)=(f(2,3)+3)%3=4%3=1:在有3个人的时候,胜利者的下标位置为1
-
f(4,3)=(f(3,3)+3)%4=4%4=0:在有4个人的时候,胜利者的下标位置为0
-
……
-
f(11,3)=6
很神奇吧!现在你还怀疑这个公式的正确性吗?上面这个例子验证了这个递推公式的确可以计算出胜利者的下标,下面将讲解怎么推导这个公式。
问题1: 假设我们已经知道11个人时,胜利者的下标位置为6。那下一轮10个人时,胜利者的下标位置为多少?
答: 其实吧,第一轮删掉编号为3的人后,之后的人都往前面移动了3位,胜利这也往前移动了3位,所以他的下标位置由6变成3。
问题2: 假设我们已经知道10个人时,胜利者的下标位置为3。那下一轮11个人时,胜利者的下标位置为多少?
答: 这可以看错是上一个问题的逆过程,大家都往后移动 3 位,所以 f(11,3)=f(10,3)+3。不过有可能数组会越界,所以最后模上当前人数的个数,f(11,3)=(f(10,3)+3)%11
问题3: 现在改为人数改为 N,报到 M 时,把那个人杀掉,那么数组是怎么移动的?
答: 每杀掉一个人,下一个人成为头,相当于把数组向前移动 M 位。若已知 N-1 个人时,胜利者的下标位置位 f(N−1,M),则 N 个人的时候,就是往后移动 M 位,(因为有可能数组越界,超过的部分会被接到头上,所以还要模N),
即 f(N,M) = (f(N−1,M) + M) % n
注:理解这个递推式的核心在于关注胜利者的下标位置是怎么变的。每杀掉一个人,其实就是把这个数组向前移动了M位。然后逆过来,就可以得到这个递推式。
因为求出的结果是数组中的下标,最终的编号还要加1。
代码如下:
1 class Solution { 2 public int f(int n, int m) { 3 if (n == 1) return 0; 4 return (f(n - 1, m) + m) % n; 5 } 6 7 public int findTheWinner(int n, int k) { 8 return f(n, k) + 1; 9 } 10 }
但是递归效率因为有个来回的规程,效率相比直接迭代差一些,也可从前往后迭代:
1 class Solution { 2 public int findTheWinner(int n, int k) { 3 int p = 0; 4 for (int i = 2; i <= n; i++) { 5 p = (p + k) % i; 6 } 7 return p + 1; 8 } 9 }