这是一个非常经典的算法问题,通常用来模拟一种淘汰游戏。

(图片来源网络,侵删)
问题定义
Countoff / 约瑟夫问题
有 n 个人围成一圈,从某个指定的人开始报数,数到 k 的那个人就被淘汰,然后从被淘汰的人的下一个人重新开始报数,数到 k 的 next person 再被淘汰,如此重复,直到所有人都被淘汰,求出这 n 个人被淘汰的顺序。
简单例子: 假设有 5 个人(编号 1, 2, 3, 4, 5),从第 1 个人开始报数,数到 2 的人被淘汰。 淘汰过程如下:
- 第一轮: 1, 2 (淘汰),剩下 [1, 3, 4, 5]。
- 第二轮: 从 3 开始,3, 4 (淘汰),剩下 [1, 3, 5]。
- 第三轮: 从 5 开始,5, 1 (淘汰),剩下 [3, 5]。
- 第四轮: 从 3 开始,3, 5 (淘汰)。
- 第五轮: 只剩下 3,3 被淘汰。
最终淘汰顺序是:2, 4, 1, 5, 3。

(图片来源网络,侵删)
解决思路
解决这个问题的关键在于如何模拟“围成一圈”以及如何找到下一个要被淘汰的人。
主要有两种经典的解决方法:
- 模拟法(直观,易于理解):使用数据结构(如循环链表)来模拟整个淘汰过程。
- 数学递推法(高效,算法思想强):通过数学公式推导出最终结果,时间复杂度更低。
方法一:模拟法(使用循环链表)
这种方法最符合问题的直观描述。
算法思路
- 创建数据结构:使用一个循环链表来表示围成一圈的人,每个节点存储一个人的编号,并且最后一个节点的指针指向头节点,形成一个环。
- 初始化:创建一个包含 n 个节点的循环链表,节点值为 1 到 n。
- 模拟过程:
a. 设置一个指针
current指向链表的起始位置(比如编号为1的人)。 b. 设置一个计数器count。 c. 循环进行,直到链表为空: i. 从current开始移动指针,count递增。 ii. 当count等于k-1时,说明下一个节点就是要被淘汰的人。 iii. 删除这个节点,并将current指针移动到被删除节点的下一个节点(即新的起始点)。 iv. 重置count为 0,开始下一轮淘汰。
C语言代码实现
#include <stdio.h>
#include <stdlib.h>
// 定义链表节点结构
typedef struct Node {
int number;
struct Node* next;
} Node;
// 创建循环链表
Node* createCircularList(int n) {
if (n <= 0) return NULL;
Node *head = (Node*)malloc(sizeof(Node));
head->number = 1;
Node *current = head;
for (int i = 2; i <= n; i++) {
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->number = i;
current->next = newNode;
current = newNode;
}
// 将链表首尾相连,形成循环
current->next = head;
return head;
}
// 模拟淘汰过程并打印顺序
void countoff(int n, int k) {
if (n <= 0 || k <= 0) {
printf("Invalid input: n and k must be positive.\n");
return;
}
Node *head = createCircularList(n);
Node *current = head;
Node *prev = NULL;
// 找到尾节点,方便删除
Node *tail = head;
while (tail->next != head) {
tail = tail->next;
}
int remaining = n;
while (current->next != current) { // 当不止一个节点时
// 数 k-1 个人
for (int i = 1; i < k; i++) {
prev = current;
current = current->next;
}
// 删除 current 节点
printf("%d ", current->number);
prev->next = current->next;
Node *toDelete = current;
current = current->next; // 从下一个人重新开始
free(toDelete);
remaining--;
}
// 打印最后一个被淘汰的人
printf("%d\n", current->number);
free(current); // 释放最后一个节点
}
int main() {
int n, k;
printf("请输入总人数 n: ");
scanf("%d", &n);
printf("请输入报数 k: ");
scanf("%d", &k);
printf("淘汰顺序为: ");
countoff(n, k);
return 0;
}
代码分析
Node结构体:定义了链表的节点,包含number(编号)和next(指向下一个节点的指针)。createCircularList:创建一个1到n的单向链表,然后将尾节点的next指针指向头节点,形成循环。countoff:prev指针总是指向current的前一个节点,这是为了方便删除current。for (int i = 1; i < k; i++)这个循环的作用是“走 k-1 步”,这样current就指向了第 k 个人。prev->next = current->next;这句是标准的链表删除操作,将前一个节点跳过当前节点,直接指向当前节点的下一个节点。free(toDelete);释放被淘汰节点的内存,防止内存泄漏。
- 时间复杂度:O(n * k),外层循环执行 n 次,内层循环最多执行 k 次,当 k 接近 n 时,效率较低。
方法二:数学递推法(高效)
这种方法不模拟过程,而是直接计算出结果,效率极高。

(图片来源网络,侵删)
算法思路
这个问题有一个著名的数学递推公式,通常被称为约瑟夫环公式。
我们定义 f(n, k) 为 n 个人、报数 k 时,最后剩下的人的初始编号。
-
基本情况:
- 当
n = 1时,圈里只有一个人,他肯定是最后剩下的。f(1, k) = 0(如果编号从0开始) 或f(1, k) = 1(如果编号从1开始),我们这里用从0开始的编号,方便公式推导。
- 当
-
递推关系: 假设我们已经知道
f(n-1, k)的值,如何求出f(n, k)?- 在第一轮淘汰中,第
k % n个人(编号为k-1)被淘汰。 - 淘汰之后,剩下
n-1个人,新的起始点是原来编号为k的人。 - 问题变成了一个规模为
n-1的新问题,在这个新问题中,原来编号为k的人相当于新编号的0号。 - 假设我们已经算出了这个新问题的解
f(n-1, k),这个解是新编号中的,我们需要把它转换回原来的编号。 - 转换公式是:原编号 = (新编号 + 起始点) % n
- 这里的起始点是
k,f(n, k) = (f(n-1, k) + k) % n。
- 在第一轮淘汰中,第
C语言代码实现
#include <stdio.h>
// 递归实现(效率不高,容易栈溢出)
int josephus_recursive(int n, int k) {
if (n == 1) {
return 0; // 返回从0开始的编号
}
return (josephus_recursive(n - 1, k) + k) % n;
}
// 迭代实现(推荐,高效且不会栈溢出)
int josephus_iterative(int n, int k) {
int result = 0; // f(1, k) = 0
for (int i = 2; i <= n; i++) {
result = (result + k) % i;
}
return result;
}
// 打印完整的淘汰顺序
void print_countoff_order(int n, int k) {
if (n <= 0 || k <= 0) return;
int *order = (int*)malloc(n * sizeof(int));
int *people = (int*)malloc(n * sizeof(int));
// 初始化人员列表 (0-based)
for (int i = 0; i < n; i++) {
people[i] = i;
}
int current_pos = 0;
int remaining = n;
for (int i = 0; i < n; i++) {
// 找到下一个要被淘汰的人
current_pos = (current_pos + k - 1) % remaining;
// 记录淘汰顺序
order[i] = people[current_pos];
// 从数组中移除被淘汰的人
for (int j = current_pos; j < remaining - 1; j++) {
people[j] = people[j + 1];
}
remaining--;
}
// 打印结果 (转换为1-based)
printf("淘汰顺序为: ");
for (int i = 0; i < n; i++) {
printf("%d ", order[i] + 1);
}
printf("\n");
free(order);
free(people);
}
int main() {
int n, k;
printf("请输入总人数 n: ");
scanf("%d", &n);
printf("请输入报数 k: ");
scanf("%d", &k);
// 1. 找出最后剩下的人
int last_survivor_0_based = josephus_iterative(n, k);
int last_survivor_1_based = last_survivor_0_based + 1;
printf("\n最后剩下的人是: %d\n", last_survivor_1_based);
// 2. 打印完整的淘汰顺序
printf("\n");
print_countoff_order(n, k);
return 0;
}
代码分析
josephus_recursive:直接实现了递推公式f(n, k) = (f(n-1, k) + k) % n,虽然代码简洁,但当n很大时(n=10000),会导致递归深度过大,栈溢出。josephus_iterative:用循环代替递归,从i=2开始,逐步计算f(i, k),直到i=n,这是解决该问题的最高效方法,时间复杂度为 O(n)。print_countoff_order:这个函数展示了如何用数学思想来模拟过程,以得到完整的淘汰顺序。current_pos = (current_pos + k - 1) % remaining;这句是核心,它模拟了在remaining个人中,从current_pos开始数k-1步,找到下一个被淘汰的人的位置。- 之后通过数组元素前移来模拟“淘汰”操作。
两种方法的比较
| 特性 | 模拟法(循环链表) | 数学递推法 |
|---|---|---|
| 直观性 | 高,直接模拟了游戏过程,容易理解。 | 低,需要理解数学推导过程。 |
| 时间复杂度 | O(n * k) | O(n) |
| 空间复杂度 | O(n) (需要链表/数组) | O(1) (迭代法) / O(n) (递归法栈空间) |
| 适用场景 | 当 n 和 k 都比较小,或者需要模拟详细过程时。 | 当 n 非常大时,或者只关心最后剩下的人时。 |
| 功能 | 容易实现打印完整淘汰顺序。 | 容易实现计算最后剩下的人,打印完整顺序需要额外逻辑。 |
- Countoff(约瑟夫问题)是一个经典的算法题。
- 模拟法使用循环链表,直观但效率较低,适合初学者理解。
- 数学递推法非常高效,体现了算法的数学之美,是解决此类问题的最优方法。
- 在实际面试或编程中,如果要求高效,应该优先选择数学迭代法,如果要求清晰易懂或者需要输出完整过程,模拟法也是一个不错的选择。
