前言: 约瑟夫环不愧是一道经典的算法题,原来也经常看到,但一直没有动手编码研究。最近又被同学提起这个问题,就研究了一下,发现这个问题可以挖掘的东西有很多,怪不得一直是面试的热门问题。
使用链成环的单链表完全模拟这个计数淘汰的过程,计数到要淘汰的结点时,直接删除该结点。
typedef struct NODE{
int num;
struct NODE* next;
}PN;
int cycle0(int n, int m){ // 使用链表实现
PN* head = (PN*)malloc(sizeof(PN));
head->num = 0;
head->next = NULL;
PN* tail = head; // 恒指向链表尾
for(int i = 1; i < n; ++i){
tail->next = (PN*)malloc(sizeof(PN));
tail = tail->next;
tail->num = i;
tail->next = NULL;
}
tail->next = head; // 链成环
PN* p = head;
int j = 0; // 报数器
while(p->next != p){ // 如果 p 的下一个结点指向自己,说明环中只剩一个结点
if(j == m-2){ // 每次报到 m-2 删除当前 p 指向结点的下一个结点
PN* q = p->next;
p->next = q->next;
free(q); // 释放内存
p = p->next;
j = 0;
}else{
p = p->next;
++j;
}
}
return p->num;
}
数组模拟法用数组下标对应人的编号。最简单直白的模拟方式,就是使用数组值来表示存活或者淘汰,一般我们用 0 和 1 来表示,如果数组元素值对应淘汰,则在计数时跳过该元素。
int cycle1(int n, int m){ // 使用数组实现 1 代表活 0 代表淘汰(反过来也可以)
int a[n];
for(int i = 0; i < n; ++i){
a[i] = 1;
}
int i = 0, j = 0, count = 0;
while(count < n-1){
i %= n;
if(a[i] == 1){
if(j == m-1){
j = 0;
a[i] = 0;
++count;
}else ++j;
++i;
}else while(a[(++i)%n] == 0){} // 跳过淘汰者,这些人不计入报数
}
for(int i = 0; i < n; ++i){
if(a[i] == 1){
return i;
}
}
}
上面的代码写了一个循环跳过淘汰者,代码形式上太不美观。我们可以通过借助标志值来计数,优化代码形式,让代码看起来更美观,但并未优化代码效率。
int cycle2(int n, int m){ // 优化数组模拟法的代码 0 代表活 1 代表淘汰(反过来也可以)
int a[n] = {0};
int i = 0, j = 0, count = 0;
while(count < n-1){
if(a[i] == 0 && j == m-1){
j = 0;
a[i] = 1;
++count;
}
j += 1-a[i]; // 利用 a[i] 的值来对 j 进行计数
i = (i+1)%n;
}
for(int i = 0; i < n; ++i){
if(a[i] == 0) return i;
}
}
上面的代码虽然没有能提升效率,但给了一个优化思路,即充分利用数组元素的值。我们可以利用数组值来辅助定位到当前存活者的下一存活者,尽量跳过中间的淘汰者。辅助定位的方法是,当数组元素值等于元素下标时,表示此人存活,当需要淘汰当前的人时,就用后面一个元素的数组值覆盖当前的元素值,这样当前元素值和下标不等,表示当前这个人已被淘汰,还可以借助数组值定位到下一个可能存活的人身上。
int cycle3(int n, int m){ // 继续优化数组模拟法的代码,数组值 等于 下标表示存活
// 使用数组值引导到下一个人的下标
int a[n];
for(int i = 0; i < n; ++i){
a[i] = i;
}
int i = 0, j = 0, count = 0;
while(count < n-1){
i %= n;
if(a[i] == i){
if(j == m-1){
j = 0;
a[i] = a[(i+1)%n];
++count;
}else ++j;
++i;
}else i = a[i]; // 优化的点
}
for(int i = 0; i < n; ++i){
if(a[i] == i){
return i;
}
}
}
最后沿袭这个思路还可以进一步优化算法,还是通过数组值来确定下一存活者,但这次是精准定位到下一存活者。与上一方法不同的是,数组值存储的是下一个存活者的编号,使用两个索引,分别为 p 和 c,p 为上一个存活者的编号,其数组值为当前存活者编号,c 为当前存活者的编号,其数组值为下一个存活者的编号,当需要淘汰当前存活者时,令 a[p] = a[c] 即可,即上一存活者指向的是下一存活者,当前存活者的编号被覆盖,相当于在数组中删除了当前存活者。这种改进算法不仅不需要每次判断数组值等于多少,而且可达的数组值一定表示的是真实的下一个存活者,大大提升了上一算法的效率。
// 你可以类比二叉树的双亲表示法(使用数组表示二叉树)来理解这个算法
// 这个算法的本质是,相当于使用数组来模拟链表,数组值就是指针,覆盖数组值就相当于链表中的删除结点操作。
int cycle4(int n, int m){ // 继续优化数组模拟法的代码
int a[n];
a[n-1] = 0;
for(int i = 0; i < n-1; ++i){
a[i] = i+1;
}
int c = 0, p = n-1, j = 0, count = 0;
while(count < n-1){
if(j == m-1){
j = 0;
a[p] = a[c]; // 删除当前存活者,p 此时指向的就是下一存活者,所以 p 指针不需要移动。
++count;
}else{
++j;
p = c;
}
c = a[c];
}
return c;
}
如果你理解了上述算法的本质是模拟链表,那么就像我们给出的第一个链表模拟法的算法一样,我们使用一个指针便可以完成遍历和删除结点的操作,并不需要使用 p,c 两个索引来配合遍历和删除操作。
int cycle5(int n, int m){ // 使用单索引
int a[n];
a[n-1] = 0;
for(int i = 0; i < n-1; ++i){
a[i] = i+1;
}
int c = n-1, j = 0, count = 0;
while(count < n-1){
if(j == m-1){
j = 0;
a[c] = a[a[c]]; // 删除当前存活者
++count;
}else{
++j;
c = a[c];
}
}
return c;
}
优点是代码简洁,时间复杂度仅为 O ( n ) O(n) O(n)。缺点是只能获得最后存活者的编号,无法像模拟法一样可以获取淘汰过程中的编号序列。
假设有 n n n 个人,编号为 0 , 1 , ⋯ , n − 1 0,1,\cdots,n-1 0,1,⋯,n−1,每报数 m ( m < n ) m(m
f ( n ) f(n) f(n) 表示 n n n 个人中最终存活者的编号。
{ f ( 1 ) = 0 f ( n ) = ( f ( n − 1 ) + m ) % n \begin{cases}f(1) = 0\\f(n) = (f(n-1)+m)\%n\end{cases} {f(1)=0f(n)=(f(n−1)+m)%n
n n n 个人,编号为 0 , 1 , ⋯ , n − 1 0,1,\cdots,n-1 0,1,⋯,n−1,从 编号为 0 的人开始报数,第一个被淘汰的人,编号为 m − 1 m-1 m−1。此时剩下 n − 1 n-1 n−1 个人,下一次报数从编号为 m m m 的人开始。将这剩下的 n − 1 n-1 n−1 个人按报数顺序一字排开,序列为: m , m + 1 , ⋯ , n − 1 , 0 , 1 , ⋯ , m − 2 m,m+1,\cdots,n-1,0,1,\cdots,m-2 m,m+1,⋯,n−1,0,1,⋯,m−2 对比总人数为 n − 1 n-1 n−1 个人时的编号序列: 0 , 1 , ⋯ , n − 2 0,1,\cdots,n-2 0,1,⋯,n−2 可以得到两者的对应关系为 f ( n ) = ( f ( n − 1 ) + m ) % n f(n) = (f(n-1)+m)\%n f(n)=(f(n−1)+m)%n。可以认为这个递推公式就是通过上述找规律的方式看出来的。
这就意味着,如果我们已知总人数为 n − 1 n-1 n−1 时最终存活者的编号,就可以得到这个人在总人数为 n n n 时对应的编号。
上面为了方便,我们假设的是 m < n m
如果感觉上面描述的实在不好理解,可以自己找个例子用这个递推的式子实战一下,应该就有点感觉了。例如我们要求 n = 5 , m = 3 n=5,m=3 n=5,m=3 的情况,dp 过程是这样的:
递归解法如下:
// dp 的递归解法
int cycle6(int n, int m){
if(n == 1) return 0;
return (cycle5(n-1,m) + m)%n;
}
递推解法如下:
// dp 的递推解法
int cycle7(int n, int m){
int alive = 0; // 对应 i = 1 的结果
for(int i = 2; i <= n; ++i){
alive = (alive + m)%i;
}
return alive;
}
最后附上完整的测试代码:
#include
#include
typedef struct NODE{
int num;
struct NODE* next;
}PN;
int cycle0(int n, int m){ // 使用链表实现
PN* head = (PN*)malloc(sizeof(PN));
head->num = 0;
head->next = NULL;
PN* tail = head; // 恒指向链表尾
for(int i = 1; i < n; ++i){
tail->next = (PN*)malloc(sizeof(PN));
tail = tail->next;
tail->num = i;
tail->next = NULL;
}
tail->next = head; // 链成环
PN* p = head;
int j = 0; // 报数器
while(p->next != p){ // 如果 p 的下一个结点指向自己,说明环中只剩一个结点
if(j == m-2){ // 每次报到 m-2 删除当前 p 指向结点的下一个结点
PN* q = p->next;
p->next = q->next;
free(q); // 释放内存
p = p->next;
j = 0;
}else{
p = p->next;
++j;
}
}
return p->num;
}
int cycle1(int n, int m){ // 使用数组实现 1 代表活 0 代表淘汰(反过来也一样)
int a[n];
for(int i = 0; i < n; ++i){
a[i] = 1;
}
int i = 0, j = 0, count = 0;
while(count < n-1){
i %= n;
if(a[i] == 1){
if(j == m-1){
j = 0;
a[i] = 0;
++count;
}else ++j;
++i;
}else while(a[(++i)%n] == 0){}
}
for(int i = 0; i < n; ++i){
if(a[i] == 1){
return i;
}
}
}
int cycle2(int n, int m){ // 优化数组的代码 0 代表活 1 代表淘汰(反过来也一样)
int a[n] = {0};
int i = 0, j = 0, count = 0;
while(count < n-1){
if(a[i] == 0 && j == m-1){
j = 0;
a[i] = 1;
++count;
}
j += 1-a[i]; // 利用 a[i] 的值来对 j 进行计数
i = (i+1)%n;
}
for(int i = 0; i < n; ++i){
if(a[i] == 0) return i;
}
}
int cycle3(int n, int m){ // 继续优化数组的代码,数组值 等于 下标表示活
// 使用数组值引导到下一个人的下标
int a[n];
for(int i = 0; i < n; ++i){
a[i] = i;
}
int i = 0, j = 0, count = 0;
while(count < n-1){
i %= n;
if(a[i] == i){
if(j == m-1){
j = 0;
a[i] = a[(i+1)%n];
++count;
}else ++j;
++i;
}else i = a[i]; // 优化的点
}
for(int i = 0; i < n; ++i){
if(a[i] == i){
return i;
}
}
}
// 本质是使用数组模拟链表
int cycle4(int n, int m){ // 继续优化数组的代码 不需要每次都判断 数组值
// 和上一种方法不同的是,数组值表示的是下一个存活的人
int a[n];
a[n-1] = 0;
for(int i = 0; i < n-1; ++i){
a[i] = i+1;
}
int c = 0, p = n-1, j = 0, count = 0;
while(count < n-1){
if(j == m-1){
j = 0;
a[p] = a[c]; // 删除当前存活的人 即 p 索引的数组值
++count;
}else{
++j;
p = c;
}
c = a[c];
}
return c;
}
// 使用单索引实现
int cycle5(int n, int m){
int a[n];
a[n-1] = 0;
for(int i = 0; i < n-1; ++i){
a[i] = i+1;
}
int c = n-1, j = 0, count = 0;
while(count < n-1){
if(j == m-1){
j = 0;
a[c] = a[a[c]]; // 删除当前存活者
++count;
}else{
++j;
c = a[c];
}
}
return c;
}
// dp 的解法只能输出最后存活者的序号,无法输出淘汰序列
int cycle6(int n, int m){ // dp 的递归解法
if(n == 1) return 0;
return (cycle5(n-1,m) + m)%n;
}
int cycle7(int n, int m){ // dp 的递推解法
int alive = 0; // 对应 i = 1 的结果
for(int i = 2; i <= n; ++i){
alive = (alive + m)%i;
}
return alive;
}
int main(){
int n = 10000, m = 3;
printf("%d\n",cycle0(n,m));
printf("%d\n",cycle1(n,m));
printf("%d\n",cycle2(n,m));
printf("%d\n",cycle3(n,m));
printf("%d\n",cycle4(n,m));
printf("%d\n",cycle5(n,m));
printf("%d\n",cycle6(n,m));
printf("%d\n",cycle7(n,m));
return 0;
}