题目
每年六一儿童节,牛客都会准备一些小礼物去看望孤儿院的小朋友,今年亦是如此。HF作为牛客的资深元老,自然也准备了一些小游戏。其中,有个游戏是这样的:首先,让小朋友们围成一个大圈。然后,他随机指定一个数m,让编号为0的小朋友开始报数。每次喊到m-1的那个小朋友要出列唱首歌,然后可以在礼品箱中任意的挑选礼物,并且不再回到圈中,从他的下一个小朋友开始,继续0…m-1报数….这样下去….直到剩下最后一个小朋友,可以不用表演,并且拿到牛客名贵的“名侦探柯南”典藏版(名额有限哦!!^_^)。请你试着想下,哪个小朋友会得到这份礼品呢?(注:小朋友的编号是从0到n-1)
解析
预备知识
重新抽象该问题,n个人(编号0~(n-1)),从0开始报数,报到(m-1)的退出,剩下的人继续从0开始报数,就这样不断的退出,直到剩下最后一个人,求这个人的编号。
思路一
我们采用标记数组的做法,用来标记所有的小朋友的状态。从第0个人开始报数,期间只对未标记的小朋友进行标记,直到等于m - 1,则标记该小朋友(表示退出)。继续从下一个小朋友开始报数。由于小朋友围了一个大圈,所以当遍历到最后一个小朋友的时候要回到第0个小朋友继续报数。其实就是对一个环中数不断剔除第m个数字。
/**
* 不使用任何数据结构,纯用标记数组
* @param n
* @param m
* @return
*/
public static int LastRemaining_Solution(int n, int m) {
if(n == 0 || m <= 0) {
return -1;
}
boolean[] visited = new boolean[n];
int count = -1;
int index = -1;
int sum = 0;
while(true) {
index = index + 1 == n ? 0 : index + 1;
if(!visited[index]) {
count++;
}
if(count == m - 1) {
visited[index] = true;
if((++sum) == n) {
return index;
}
count = -1;
}
}
}
思路二
在思路一我们已经得到了这个题其实就是对一个环中数不断剔除第m个数字问题,加之使用标记数组会不断访问已经退出的小朋友,所以复杂度偏高。所以这里我们采用链表结构,结合链表的O(1)的删除开销来不断降低问题规模,避免不必要的遍历。由于这里使用的单链表,所以还是要像思路一一样模拟循环遍历。
/**
* 借助工具库 链表集合
* @param n
* @param m
* @return
*/
public static int LastRemaining_Solution2(int n, int m) {
if(n == 0 || m <= 0) {
return -1;
}
List<Integer> students = new LinkedList<>();
for(int i = 0; i < n; i++) {
students.add(i);
}
int index = 0;
while(students.size() > 1) {
index = (index + m - 1) % students.size();
students.remove(index);
}
return students.get(0);
}
思路三
其实这个问题的模型是约瑟夫问题,可以借助数学的知识来把复杂度降低为O(n),果然数学是算法的重要功底,道路且长啊!
对于n个人,第一个人出列的人必然是(m - 1) % n,假设出列的人的编号为k - 1, 剩下的n-1个人组成了一个新 的约瑟夫环(以编号为k + 1的人开始): k k+1 ... n-2, n-1, 0, 1, 2, ... k - 2
并且从k开始报0。我们假设f(n)表示n个人不断剔除报号为m - 1的最终解,而f(n - 1)是f(n)的一个子阶段,所以f(n - 1)的解与f(n)是一致的。所以我们只要知道了f(n - 1)问题的解,那么f(n)也就知道了。
那么f(n - 1)的问题怎么解呢? f(n - 1)的问题的开始报号的人的编号为k, 我们通过映射规则把它改为0就可以把问题规模减小了1且处理过程与f(n)的一致,看到这里,问题规模不断减小且处理逻辑一样,当然使用递归或者循环啊。这样当我们知道了f(n - 1)的解的时候,再采用反向的映射恢复原来的编号,这样就可以使得这个子问题变为f(n)的下一阶段,因而具有相同的解。
比如对于0,1,2,3,4
的问题,m = 2, 第一次删除的人编号为1,那么之后重新报号的人的编号为2,问题变为2,3,4,0
,为了使得该子问题能够与父问题一致(报号都从0开始,这样就可以采用相同的逻辑处理问题,这是递归经常使用的),现在我们把他们的编号做一下转换:
2 –> 0
3 –> 1
4 –> 2
0 –> 3
这样子问题变为0,1,2,3
中删除第m个人,因为问题与父问题一致,所以可以采用相同的逻辑继续求解,这样直到问题规模为1,递归结束,得到最后胜利者。但是我们要记住,子问题的结果都是父问题为了使得问题逻辑一样而改变了编号值,所以我们要向上不断恢复编号值。因为2,3,4,0
的其实是0,1,2,3,4
的一个必然的阶段,所以他们的解一样,只要知道了2,3,4,0
的解,就知道了原问题的解。
上述父问题向子问题的编号映射规则为(x - m) % n
, 所以子问题向父问题编号逆向映射规则为`(x’ + m) % n
f(n)=
\begin{cases}
1, & \text {if $n$ == 1} \\
(f(n - 1) + m) \% n, & \text{otherwise}
\end{cases}
/**
* 约瑟夫环问题
* @param n
* @param m
* @return
*/
public static int LastRemaining_Solution3(int n, int m) {
if(n == 0 || m <= 0) {
return -1;
}
int last = 0;
for(int i = 2; i <= n; i++) {
last = (last + m) % i;
}
return last;
}
总结
当子问题与父问题不是相同问题的时候,可以尝试映射为相同问题,这样得到最终解的时候,再不断向上恢复为原问题的解。