约瑟夫环解法大全(C语言版)

前言: 约瑟夫环不愧是一道经典的算法题,原来也经常看到,但一直没有动手编码研究。最近又被同学提起这个问题,就研究了一下,发现这个问题可以挖掘的东西有很多,怪不得一直是面试的热门问题。

解法一,使用链表模拟:

使用链成环的单链表完全模拟这个计数淘汰的过程,计数到要淘汰的结点时,直接删除该结点。

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,,n1,每报数 m ( m < n ) m(mm(m<n) 次淘汰一个人。
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(n1)+m)%n

递推公式解释:

n n n 个人,编号为 0 , 1 , ⋯   , n − 1 0,1,\cdots,n-1 0,1,,n1,从 编号为 0 的人开始报数,第一个被淘汰的人,编号为 m − 1 m-1 m1。此时剩下 n − 1 n-1 n1 个人,下一次报数从编号为 m m m 的人开始。将这剩下的 n − 1 n-1 n1 个人按报数顺序一字排开,序列为: m , m + 1 , ⋯   , n − 1 , 0 , 1 , ⋯   , m − 2 m,m+1,\cdots,n-1,0,1,\cdots,m-2 m,m+1,,n1,0,1,,m2 对比总人数为 n − 1 n-1 n1 个人时的编号序列: 0 , 1 , ⋯   , n − 2 0,1,\cdots,n-2 0,1,,n2 可以得到两者的对应关系为 f ( n ) = ( f ( n − 1 ) + m ) % n f(n) = (f(n-1)+m)\%n f(n)=(f(n1)+m)%n。可以认为这个递推公式就是通过上述找规律的方式看出来的。
这就意味着,如果我们已知总人数为 n − 1 n-1 n1 时最终存活者的编号,就可以得到这个人在总人数为 n n n 时对应的编号。

上面为了方便,我们假设的是 m < n mm<n,其实当 m > n m>n m>n 时,递推式子不变。因为当 m > n m>n m>n 时,每报数 m m m 次淘汰一人,相当于每报数 m % n m\%n m%n 次淘汰一人,所以有 f ( n ) = ( f ( n − 1 ) + m % n ) % n = ( f ( n − 1 ) + m ) % n f(n) = (f(n-1)+m\%n)\%n=(f(n-1)+m)\%n f(n)=(f(n1)+m%n)%n=(f(n1)+m)%n,式子不变。

递推示例:

如果感觉上面描述的实在不好理解,可以自己找个例子用这个递推的式子实战一下,应该就有点感觉了。例如我们要求 n = 5 , m = 3 n=5,m=3 n=5,m=3 的情况,dp 过程是这样的:

  1. f ( 1 ) = 0 f(1)=0 f(1)=0,即人数为 1 时,最终存活者的编号为 0。
  2. f ( 2 ) = ( f ( 1 ) + 3 ) % 2 = ( 0 + 3 ) % 2 = 1 f(2)=(f(1)+3)\%2=(0+3)\%2=1 f(2)=(f(1)+3)%2=(0+3)%2=1,即那个在人数剩 1 个人时最终存活者的编号在人数为 2 时,对应的编号为 1。
  3. 同理 f ( 3 ) = ( 1 + 3 ) % 3 = 1 f(3)=(1+3)\%3=1 f(3)=(1+3)%3=1,即最终存活者的编号对应到总人数为 3 时,编号为 1。
  4. 同理 f ( 4 ) = ( 1 + 3 ) % 4 = 0 f(4)=(1+3)\%4=0 f(4)=(1+3)%4=0,对应为总人数为 4 时,编号为 0。
  5. 同理 f ( 5 ) = ( 0 + 3 ) % 5 = 3 f(5)=(0+3)\%5=3 f(5)=(0+3)%5=3,对应为总人数为 5 时,编号为 3。递推结束。

递归解法如下:

// 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;
}

你可能感兴趣的:(算法)