约瑟夫环的解法

参考:

约瑟夫环问题的三种解法讲解 - 力扣(LeetCode) (leetcode-cn.com)

找出游戏的获胜者 - 找出游戏的获胜者 - 力扣(LeetCode) (leetcode-cn.com)

约瑟夫环——公式法(递推公式)_陈浅墨的博客-CSDN博客_约瑟夫环数学公式

 

问题

约瑟夫环问题是算法中相当经典的一个问题,其问题理解是相当容易的,并且问题描述有非常多的版本。

什么是约瑟夫环问题?

如下:

1823. 找出游戏的获胜者

共有 n 名小伙伴一起做游戏。小伙伴们围成一圈,按 顺时针顺序 从 1 到 n 编号。确切地说,从第 i 名小伙伴顺时针移动一位会到达第 (i+1) 名小伙伴的位置,其中 1 <= i < n ,从第 n 名小伙伴顺时针移动一位会回到第 1 名小伙伴的位置。

游戏遵循如下规则:

  1. 从第 1 名小伙伴所在位置 开始 。
  2. 沿着顺时针方向数 k 名小伙伴,计数时需要 包含 起始时的那位小伙伴。逐个绕圈进行计数,一些小伙伴可能会被数过不止一次。
  3. 你数到的最后一名小伙伴需要离开圈子,并视作输掉游戏。
  4. 如果圈子中仍然有不止一名小伙伴,从刚刚输掉的小伙伴的 顺时针下一位 小伙伴 开始,回到步骤 2 继续执行。
  5. 否则,圈子中最后一名小伙伴赢得游戏。

给你参与游戏的小伙伴总数 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 }

 

posted @ 2022-05-04 21:17  r1-12king  阅读(1415)  评论(0编辑  收藏  举报