约瑟夫问题
一道例题
题目解析:
如果第一次接触这道题,难免有些陌生。但如果只是在脑子里想象的话,那大概率会是一团浆糊,所以建议在纸上画一画,模拟一下,其实这道题也是能锻炼一定的代码能力的。(这里分享一个做题的小技巧:不要死磕一个题太久,该看答案咱还是得看,有时候退一步是为了更好的进步)。
好了,回归正题。题目的大概意思就是:有n个人,围成一个圈,从第一个人开始报数,报到数字m的人出列,同时从下一个人开始继续报数,就这样循环往复,直到所有人都出列。如果听到这,有些人肯定会感觉很熟悉,毕竟可能玩过相似的游戏;但是又有些玄幻,因为,如果仔细一想这个报数,出列的过程就乱了,想象不出来整个过程,此时就不要吝啬你的纸和笔了,直接分析,更有利于理解。
算法原理(含代码解析):
使用模拟
既然题目都说了要挨个报数,出列。那我们就认为创建一个链表,来模拟这个过程。
1,创建链表节点类
//使用单链表模拟:
class Node{
int val;
Node next;
public Node(int val) {
this.val = val;
}
}
2,一些基本变量输入处理
public class Demo111314 {
public static void main(String[] args) {
//基本变量的输入
Scanner sc = new Scanner(System.in);
int n =sc.nextInt();
int m = sc.nextInt();
}
创建一个环形链表:
Node head = new Node(0);//这里head的创建就是为了,方便创建环形链表
Node cur = head;
for(int i =0;i<n;i++) {
Node node = new Node(i+1);
cur.next = node;
cur = node;
}
//将链表头尾相连:(注意细节,head不参与环形链表)
cur.next = head.next;
创建好后的环形链表参考如下:
进行模拟:
还是假设,n = 10,m = 3;
从起始位置开始报数,即从1位置开始(这里要注意,其实这个1也要算在报数里的,不能想当然的:直接1+3 = 4,4出列),编号为3的人出队;然后再从4开始报数,编号为6的人出队,依此类推……
简图如下:(歩奏很多,我只画出了前几步)
由上述简图,下一步就是将这个过程模拟出来;
- 首先就是定义一个指向节点的指针,方便操作。但是,仔细思考一下:会发现,一个指针根本不够用,因为:如果我们只是3个3个的移动指针找到那个删除的元素还好。但是我们还要进行删除节点操作啊,而要想删掉指针指向的这个节点,就必须找到上一个节点啊,因为 p.next = q.next (p代表上一个节点,q代表该节点)这个操作才能正确删掉某个节点啊。因此,经过分析,我们需要两个指针,一个指向本节点,一个指向上一个节点,这样才能完成删除节点操作。
- 但是,单单思路想到这还远远不够,因为如果要定义两个指针,一个q指向该节点,一个p 指向 上一个节点,在初始化的时候我们会遇到一个问题,q直接指向第一个节点就行了,但是p怎么初始化?难不成先找到最后一个节点,然后p指向它,其实啊,都不用,还记得我们是如何创建环形队列的吗?创建的最后那个cur 一定指向的是最后一个节点,所以我们直接用cur就行了,而不用再找最后一个节点在哪里了。
- 其实还有一个思路,就是直接让 p 指向 head,虽然head没有意义,但是这个初始化的p的方式并不会干扰到结果,为啥呢?举个例子:我们知道p 只是在删除节点的时候用的,想象一下,此时链表只有一个节点,此时的m>=1,不可能是0,所以元素一定会出队,但是此时只有一个元素啊,那你还删他干啥,直接输出不是更简便吗? 如果链表里的元素个数大于1,你可以模拟着试试,这个对结果不会有影响。(但是有一点需要注意,下边有涉及到)
//下一步:模拟:
Node p = head;//这里的head 改成cur也对。
Node q = p.next;
while(p != q) {
for(int i =1;i<m;i++) {
p = p.next;
q = q.next;
}
System.out.print(q.val+" ");
//找到以后,再将这个节点删掉:q指向下一个节点
p.next = q.next;
q = p.next;
}
System.out.print(q.val);
根据上面的代码,先说明一个问题:这里的while循环里的条件可能是很多人想不到的,一开始我写的条件是 q != null 但是运行以后发现死循环了。于是我又重新分析了一遍歩奏。发现,无论什么时候,最终链表里删的就只剩下一个元素了,而这个元素总是自己指向自己,就是因为 p.next = q.next 这句代码带来的作用,所以,只要是当 p 和 q 指针指向了同一个节点,那就说明,循环结束了,p和q同时指向的节点是最后一个应该删除的节点。
所以循环条件应该是 p != q。
- 特殊情况 :当m =1,n =1时:
-
假如我们用的是cur,由于初始化就将整个链表首尾相连了,此时就不用做任何操作,图解如下:
-
假如我们用的是head,会发生死循环,简图如下:
因此我们要在开头直接加上一个判断,来解决使用head的时候n = 1的情况
-
//解决使用head的时候 n = 1的情况
if(n == 1) {
System.out.print(1);
return ;
}
使用队列
如果是一个一个的删除,那不难想到队列,如果我们将所有元素都放进一个队列里,然后挨个出队列判断,如果此时的元素是我们要删除的元素,那就将他出队列,如果不是,那就再将这个元素入队尾。利用队列的先入者先出,后入者后出的特性,不难解答问题。
public class Demo111415 {
public static void main(String[] args) {
Queue<Integer> queue = new LinkedList<>();
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
//先将所有的值都加入到队列里:
for(int i =0;i<n;i++) {
queue.offer(i+1);
}
while(!queue.isEmpty()) {
//找到要删除的元素
for(int i =1;i<m;i++) {
queue.add(queue.poll());
}
//将该元素删除:
System.out.print(queue.poll()+" ");
}
}
}
看到这不知道你们会不会有疑问:反正有那么一瞬间,我是有点疑问的:他怎么直接就poll 了不用判断队列是否为空吗?
但是如果你仔细的再研究研究,就会发现,其实并不用,下面有简图,可供参考理解
使用数学公式(进阶)
这个方法只适用于一定的场景,请看一道题:
此问题题意:就是只求最后一个出队的元素,因此我们有两种思路:
第一种:就是将之前写的代码稍作修改直接就的出答案了,但是时间复杂度太高,过不了题目
代码仅供参考
import java.util.*;
public class Demo111424 {
public static void main(String[] args) {
Queue<Integer> queue = new LinkedList<>();
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
//先将所有的值都加入到队列里:
for(int i =0;i<n;i++) {
queue.offer(i+1);
}
while(queue.size() != 1) {
for(int i =1;i<m;i++) {
queue.add(queue.poll());
}
queue.poll();
}
System.out.println(queue.poll());
}
}
第二种就是我们要讲的数学方法:
上述简图描述了歩奏的一部分,但足以让我们发现规律:
知道为啥突然要标记下标吗?因为要用到下标了。(我们还是以m = 3为例,使用例子讲解更容易理解)
仔细观察:我们会发现每次删除一个元素,所有的元素都要减少3个单位
比如:上面的1 在进行第一次删除的时候下标从0->5 正好对应一个下表变换公式(i-m+len)%len;
再用这公式看元素2 也是一样。所以这就是规律啊。
但是知道了这个规律和我们的结果又有什么关系呢?如下:
逆向思维:如果我每删除一个元素,某个元素的下标都会减少3的话,那我总会删到只剩下一个元素:此时的元素不就是我们要求的那个元素吗?但是问题又来了,我怎么确定这个“某个元素”到底是哪个元素呢?问题就在这,这也是我们要求的啊。
所以这样想不对,我们在换个思路,如果我每删除一个元素,所有元素下标的都会减少3,那么如果此时我加上一个元素,那么元素的下标是不是都会增加3呢? 如果我逆着想,此时就只有一个元素,即为我所求的答案,但这个答案是未知的的,因为我也不知道这个元素是谁,此时我就需要求这个元素的下标,因为要想用到之前我们发现的规律,就必须向下表靠近,和下标扯关系。此时只有一个元素,他的下标是0,但是我们经过增加元素,所求的下标早已经变了,此时如果能找到改变后的元素的下标,那不就找到这个元素了吗,也就求出了答案。
- 算法原理
此时只有一个元素,他的下标是0,记作: F(1) = 0
增加了一个元素后 下标 :(0+3)%2 = 1 ; F(2) = 1
增加了两个元素后 下标: (1+3)%3 = 1 ;F(3) = 1
增加了三个元素后 下标: (1+3)%4 = 0 ;F(4) = 0
……
通过以上例子我们能总结出一个规律:
(F(2) = 1 表示:2个元素 时候所求答案的下标为1)
F(n) = (F(n-1)+m)%n
我知道公式可能有点抽象,但是如果你真正自己动手写,照着理解一遍,其实很好理解的。
这样我们就得到了一个递推公式,能推出来答案的下标到底是啥:
public class Demo111458 {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int ret =0;
//使用该递推公式就能算出来最终结果:
for(int i =2;i<=n;i++) {
ret = (ret+m)%i;
}
System.out.println(ret+1);
}
}
代码实现:
模拟代码:
import java.util.*;
//使用单链表模拟:
class Node{
int val;
Node next;
public Node(int val) {
this.val = val;
}
}
public class Demo111314 {
public static void main(String[] args) {
//先创建一个约瑟夫环:
Scanner sc = new Scanner(System.in);
int n =sc.nextInt();
int m = sc.nextInt();
if(n == 1) {
System.out.print(1);
return ;
}
Node head = new Node(0);
Node cur = head;
for(int i =0;i<n;i++) {
Node node = new Node(i+1);
cur.next = node;
cur = node;
}
//将链表头尾相连:
cur.next = head.next;
//下一步:模拟:
Node p = head;
//Node p = cur;//都能用
Node q = p.next;
while(p != q) {
for(int i =1;i<m;i++) {
p = p.next;
q = q.next;
}
System.out.print(q.val+" ");
//找到以后,再将这个节点删掉:q指向下一个节点
p.next = q.next;
q = p.next;
}
System.out.print(q.val);
}
}
队列代码:
import java.util.*;
public class Demo111415 {
public static void main(String[] args) {
Queue<Integer> queue = new LinkedList<>();
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
//先将所有的值都加入到队列里:
for(int i =0;i<n;i++) {
queue.offer(i+1);
}
while(!queue.isEmpty()) {
for(int i =1;i<m;i++) {
queue.add(queue.poll());
}
System.out.print(queue.poll()+" ");
}
}
}
数学公式代码
import java.util.Scanner;
public class Demo111458 {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int ret =0;
//使用该递推公式就能算出来最终结果:
for(int i =2;i<=n;i++) {
ret = (ret+m)%i;
}
System.out.println(ret+1);
}
}