跳转到内容

Lesson 10: 约瑟夫环 CNB

练习任务

本课包含两个递进练习:

练习 1(init_ring & print_ring):初始化一个模拟循环链表的 next[] 数组——next[i] = (i + 1) % n,使 n-1 号位的下一个指向 0 号位,形成闭环。读入 n 后打印 next[0]next[n-1],每个数字后跟一个空格。

期望输出(n=5):

1 2 3 4 0

练习 2(Josephus):在 next[] 循环链表上完成约瑟夫环淘汰——10 个人围成圈,每数到第 3 个人出局(ALL=10, OUT=3)。跟踪计数 counter,记录前驱 prev,出局时通过 next[prev] = next[i] 将出局者从链中移除。

期望输出:

3 is out
6 is out
9 is out
2 is out
7 is out
1 is out
8 is out
5 is out
10 is out
4 is out

提示:约瑟夫环的核心是「环形遍历 + 计数淘汰」。用数组 next[i] 记录第 i 个人的下一个人是谁——这等价于链表中的 next 指针,但无需 structmalloc。出局时需要知道「前一个人」是谁,这样才能修改链表跳过出局者。取模 % 是实现循环索引的关键工具。

核心知识点

  • 约瑟夫环问题 — n 人围圈,每数到第 m 个出局,求最后一个幸存者;源自公元 1 世纪真实历史事件
  • 数学递推公式 — f(1)=0; f(n)=(f(n-1)+m)%n,O(n) 时间直接计算幸存者位置
  • 数组模拟循环链表 — next[i] = (i + 1) % n 用数组下标实现指针链,无需 struct 和 malloc
  • 取模实现环形索引 — pos = (pos + 1) % n 保证索引在 0~n-1 之间循环,是环形数据结构的数学基础
  • 标志删除法 — 将出局者值置 0,后续遍历时跳过值为 0 的位置;简单但 O(n²) 复杂度
  • 链表跳转法 — next[prev] = next[i] 从链中移除节点,O(1) 删除,整体 O(n*m) 复杂度
  • 全局数组 — 函数间共享数据,无需传参,适合简单程序的数据结构
  • 局部数组 — 函数内部声明,作用域限制,适合模块化设计和大程序
  • #define 宏常量 — 编译前文本替换,一次定义多处使用;数组长度必须用宏(C89/C90)
  • const 限定符 — 编译期类型检查,有符号信息,但不适用于 C89 数组长度声明
  • 条件编译 #if / #else — 同一套源码中保留两种实现的编译器级别切换,零运行时开销
  • 计数器状态机 — step / counter 跟踪当前数到第几步、何时触发淘汰,是有限状态机的最简实例
  • 数据驱动编程 — 数据结构选择决定算法效率(Rob Pike 哲学)
  • XOR 交换 — a ^= b; b ^= a; a ^= b; 实现无临时变量的原地交换
  • struct 方案 vs 数组方案 — 用结构体封装链表节点与用下标数组模拟的权衡
  • 循环缓冲区 — 固定大小的环形队列,广泛应用于操作系统、网络协议、音视频处理

代码框架

练习 1:初始化循环链表

init_ring.c
c
#include <stdio.h>

#define MAX 1000

int next[MAX];
int n;

void init_ring(void)
{
    int i;
    // next[i] = (i + 1) % n —— 最后一个人的下一人是第 0 个
    for (i = 0; i < n; i++)
        /* 填写 next[i] 的值 */;
}

void print_ring(void)
{
    int i;
    // 打印 next[0] 到 next[n-1],每个后面跟一个空格
}

int main(void)
{
    scanf("%d", &n);
    init_ring();
    print_ring();
    return 0;
}

练习 2:约瑟夫环淘汰

josephus_ring.c
c
#include <stdio.h>

#define ALL 10
#define OUT 3

int next[ALL];

void init_ring(void)
{
    int i;
    for (i = 0; i < ALL; i++)
        next[i] = (i + 1) % ALL;
}

int main(void)
{
    int left = ALL;      // 剩余人数
    int counter = 0;     // 数到第几个
    int i = 0;           // 当前指向谁
    int prev = ALL - 1;  // i 的前一个人是谁(初始化为最后一人)

    init_ring();

    // while (left > 0)
    // {
    //     counter++;
    //     if (counter == OUT)
    //     {
    //         printf("%d is out\n", i + 1);
    //         // 从链表中删除 i —— 让 prev 跳过 i
    //         next[prev] = /* 填空 */;
    //         left--;
    //         counter = 0;
    //     }
    //     prev = i;
    //     i = next[i];          // 前进到下一人
    // }

    return 0;
}

填充框架的关键思考:prev 的初始值为什么是 ALL - 1(最后一个人)?出局时为什么是 next[prev] = next[i] 而不是 next[prev] = i + 1

TIP

先不要往下翻看参考解答。先在纸上画一个 5 人环,手动模拟一遍淘汰过程——理解 prev 的角色至关重要。


深度讲解

1. 约瑟夫环:一个流传千年的数学问题

1.1 历史起源

公元 1 世纪,犹太历史学家弗拉维乌斯·约瑟夫斯(Flavius Josephus)参与犹太反叛罗马的战争。战败后,他和 40 名战友被罗马军队包围在 Yodfat 的洞穴中。他们决定宁死不降,约定围成圈,从第一个人开始每数到第三个人就必须被同伴杀死,直到剩下最后一人。

约瑟夫斯不想死——他和另一个朋友通过快速心算站到了安全位置:第 16 号和第 31 号位置。最终两人幸存,向罗马军队投降。约瑟夫斯后来成为罗马公民,写出了《犹太战记》等重要历史著作。

这正是约瑟夫环问题的文学起源:已知 n = 41 人,m = 3,求最终幸存位置。

1.2 问题的数学化表述

给定 n 个人围成一圈(编号 0, 1, 2, ..., n-1),从 0 号位置开始计数,每数到第 m 个人(含当前正在数的人)令其出局,然后从出局者的下一位重新开始数 1。求:

  1. 出局顺序:每个人按什么顺序离开
  2. 幸存者位置:最后剩下的人位于哪个编号
n=5, m=3 的淘汰过程:

初始:    [1]  [2]  [3]  [4]  [5]
索引:    ↑0   ↑1   ↑2   ↑3   ↑4

数到 3:  [1]  [2]  ---  [4]  [5]     → 编号 3(索引 2)出局
              ↑3   ↑4        ↑0   ↑1   (从出局者下一位重新开始数)

数到 3:  [1]  ---  ---  [4]  [5]     → 编号 1(索引 0)出局
                          ↑2   ↑3

数到 3:  ---  ---  ---  [4]  [5]     → 编号 5(索引 4)出局
                    ↑0   ↑1   ↑2

数到 3:  ---  ---  ---  [4]  ---     → 编号 2(索引 1)出局
                    ↑3   ↑0   ↑1

最后:    ---  ---  ---  [4]  ---     → 编号 4(索引 3)幸存

1.3 为什么选这个题目

约瑟夫环不是一道简单的编程练习——它是数据结构、算法分析和数学思维的交叉训练场

层面学到的能力
数据结构用数组模拟链表(下标替代指针)
算法设计环形遍历、计数淘汰、状态管理
数学分析递推公式推导、时间复杂度证明
编程技巧取模运算符、条件编译、宏常量
工程哲学数据结构优先于算法(Rob Pike)

这个题目将贯穿你的整个编程生涯——从入门时的数组模拟,到算法课上的数学推导,再到操作系统中的轮转调度,约瑟夫环的影子无处不在。


2. 数组模拟循环链表

2.1 next[i] 的语义

在传统的链表中,每个节点有一个 next 指针指向下一个节点。在约瑟夫环中,我们用数组下标替代指针:

next_array_semantics.c
c
int next[ALL];

void init_ring(void)
{
    int i;
    for (i = 0; i < ALL; i++)
        next[i] = (i + 1) % ALL;
}

next[i] 存储的是第 i 个人的下一个人是谁——用下标编号表示。如果 n=5:

next[0] = (0+1)%5 = 1  → 0 号的下一人是 1 号
next[1] = (1+1)%5 = 2  → 1 号的下一人是 2 号
next[2] = (2+1)%5 = 3  → 2 号的下一人是 3 号
next[3] = (3+1)%5 = 4  → 3 号的下一人是 4 号
next[4] = (4+1)%5 = 0  → 4 号的下一人是 0 号  ← 闭环!

(i + 1) % n 正是取模实现环形的精髓——当 i = n-1 时,(n-1+1) % n = 0,自动绕回起点。不需要任何 if 判断,一个数学运算就完成了闭环。

2.2 与 struct 链表的等价性对比

为了理解「数组模拟」的精妙之处,我们先看如果用传统的 struct 链表来实现约瑟夫环:

struct_approach.c
c
#include <stdio.h>
#include <stdlib.h>

struct Node {
    int val;
    struct Node *next;
};

// 需要 malloc 分配每个节点,需要手动链接,最后还要 free
方面struct Node { int val; Node *next; }int next[n]
内存分配malloc 堆分配(需要 #include <stdlib.h>数组栈/数据段分配(自动管理)
下一个节点node->next(真实指针,需理解 *->next[i](下标模拟指针)
删除操作prev->next = cur->nextnext[prev] = next[i]
遍历方式p = p->nexti = next[i]
指针知识需求需要理解指针、内存地址、解引用只需数组下标
内存回收需要 free 每个节点自动回收(数组在栈或数据段)
节点数量运行时动态确定编译时确定(或 VLA)
节点值可以存储任意复杂数据下标即标识,值隐含在索引中

对于入门阶段,next[i] 数组将**「指针」的语义隐藏在「下标」**背后——逻辑上完全等价,语法上更友好。但理解 next[prev] = next[i] 的本质就是链表节点删除,是为后续真正链表编程铺路的关键思维跳跃。

2.3 下标模拟指针的本质

c
i = next[i];        // 等价于  p = p->next;
next[prev] = next[i]; // 等价于  prev->next = cur->next;

这里的下标 i 扮演了双重角色:它既是指向某个「人」的标识符(类似 p->val),又是定位 next[] 数组的索引(类似 p 本身)。这种索引即标识的设计,是「数据驱动编程」的典型体现——因为数据结构选得好(数组),算法(链表遍历)就自然浮现。

2.4 可视化:数组模拟的链表拓扑

初始化后的 next[] 数组(n=5):

        ┌───┐     ┌───┐     ┌───┐     ┌───┐     ┌───┐
 0 1 2 3 4
        └─┬─┘     └─┬─┘     └─┬─┘     └─┬─┘     └─┬─┘

next[0]=1  next[1]=2  next[2]=3  next[3]=4  next[4]=0


        ┌───┐     ┌───┐     ┌───┐     ┌───┐     ┌───┐
 1 │────▶│ 2 │────▶│ 3 │────▶│ 4 │────▶│ 0
        └───┘     └───┘     └───┘     └───┘     └───┘

           └────────────────────────────────────────────┘
                              闭环

3. 取模运算符实现环形索引

3.1 两种环形前进方式的对比

回顾 Lesson 04 中学到的取模运算符 <——。在约瑟夫环中,取模体现了它的「限界」功能:

modulo_vs_if.c
c
// 方案 A: 取模法(一行搞定)
pos = ++pos % ALL_NUM;

// 方案 B: if 判断法(更直观但较冗长)
pos++;
if (pos == ALL_NUM)
    pos = 0;

两段代码在功能上等价,但反映了不同的思维方式:

  • if 方式想的是「如果出界就回到 0」——边界检查思维
  • 取模方式想的是「用一个数学运算让结果落在 0~n-1 之间」——数学限界思维

3.2 取模的限界本质

n=5, pos 的变化过程:
0 1 2 3 4 0 1 2 ...

                  4+1=5, 5%5=0, 绕回起点

取模 % n 就像一个「自动回绕器」——无论 pos 从什么值开始递增,pos % n 永远落在 [0, n-1] 区间内。这是模算术在编程中最直观的应用。

3.3 取模在约瑟夫环中的双重用途

modulo_dual_usage.c
c
pos  = ++pos % ALL_NUM;    // 用途1: 空间上的环形——位置循环
step = step % COUNT_NUM;   // 用途2: 时间上的节奏——计数循环

两个 % 分别处理了:

  • 空间维度的环形:人在圈中循环移动
  • 时间维度的节奏:数 1, 2, 3 的周期性重复

这是取模运算符普适性的绝佳展示——同一个运算符,在空间和时间两个维度上同时发挥作用。

3.4 取模的模运算性质

取模运算符 % 在 C 语言中遵循以下性质(ISO C99 标准,Section 6.5.5):

(a + b) % n = ((a % n) + (b % n)) % n   // 加法分配律
(a * b) % n = ((a % n) * (b % n)) % n   // 乘法分配律

这些性质在递推公式的推导中会被用到。理解取模的代数结构,是理解约瑟夫环数学解法的前提。


4. 标志删除法与链表跳转法

约瑟夫环的核心操作是「从环形结构中删除一个元素」。我们用两种不同的数据结构实现了这一操作。

4.1 标志删除法(原始 josephus.c 方案)

标志删除法用一个 people[] 数组存储每个人的编号,出局时将对应位置标记为 0(表示「已死」),但保留在数组中:

flag_deletion.c
c
while (left > 0)
{
    if (people[pos] > 0)      // 这个位置的人还活着才计数
        step++;

    if (step == OUT_NUM && people[pos] != 0)
    {
        people[pos] = 0;       // 标志为「已死」
        left--;
    }

    pos = ++pos % ALL_NUM;     // 无论死活都向下推进
    step = step % COUNT_NUM;
}

特点:不修改数据结构,只标记 people[pos] = 0。遍历时跳过值 0 的位置。

缺点:每次都要测试这个位置是否有效if people[pos] > 0)——随着出局者增多,无效位置越来越多,浪费的比对次数越来越多。

出局人数    无效位置比例    平均扫描次数(找到下一个有效人)
   1          1/100 1
  50         50/100 2
  90         90/100 10
  99         99/100 100 最后一个要扫描 100 次!

4.2 链表跳转法(next[] 方案)

链表跳转法用 next[] 数组记录「下一个有效人的位置」,出局时直接修改链接关系:

linked_list_skip.c
c
while (left > 0)
{
    counter++;
    if (counter == OUT)
    {
        printf("%d is out\n", i + 1);
        next[prev] = next[i];   // prev 直接跳过 i,i 从链中消失
        left--;
        counter = 0;
    }
    else
    {
        prev = i;               // 只有当 i 没出局时才更新 prev
    }
    i = next[i];                // 总是跳到一个有效的人
}

特点i = next[i] 总是跳到下一个在位的人——不存在死节点遍历,每次迭代都处理一个有效的人。

关键细节——为什么 prev 只在 else 分支更新

i 出局时,prev 不能更新为 i,因为 i 已经不在链中了。prev 必须保持指向链中实际存在的节点,这样下一次出局时才能正确地执行 next[prev] = next[new_i]。这是链表删除操作中最容易出错的细节。

4.3 prev 初始值的奥秘

c
int prev = ALL - 1;   // 初始化为最后一个人

为什么 prev 初始化为 ALL - 1 而不是 0 或其他值?

因为程序从 i = 0(第一个人)开始遍历。在环形链表中,第一个人的前驱是最后一个人。如果 prev 初始化为 0,那么当第一个人(i=0)需要出局时,next[0] = next[0] 不会产生正确的删除效果——需要的是 next[prev] = next[0],即让最后一个人跳过第一个人。

环形链表中的前驱关系:

  ALL-1 ──→ 0 ──→ 1 ──→ 2 ──→ ... ──→ ALL-1

    └───────────────────────────────────────┘

0 的前驱是 ALL-1,所以 prev 初始化为 ALL-1。

4.4 两种方法的完整效率对比

标志法链表法
每次迭代可能访问无效位置总是处理在位的人
删除操作people[pos] = 0(O(1))next[prev] = next[i](O(1))
找下一个有效人O(n) 最坏情况(扫描)O(1)(直接跳转)
额外内存无(就地标记)额外 next[] 数组(O(n))
总时间复杂度O(n²)O(n*m) ≈ O(n)(当 m 为常数)
总空间复杂度O(n)O(n)

关键差异:标志法中,找到下一个有效的人需要通过循环去扫描;链表法中,next[i] 已经记录了直接跳转的路径。这就是 Rob Pike 所说的「数据结构选择决定算法效率」——两种方法的时间复杂度差异不是算法技巧造成的,而是数据结构选择造成的

4.5 算法执行轨迹可视化

以下是 n=5, m=3 时链表跳转法的完整执行轨迹:

初始状态:
  next[] = {1, 2, 3, 4, 0}
  prev = 4, i = 0, counter = 0, left = 5

Step 1: counter=1, prev=0, i=1
Step 2: counter=2, prev=1, i=2
Step 3: counter=3 出局!
 打印 "3 is out" (i=2, 编号=2+1=3)
 next[1] = next[2] = 3  (prev=1 跳过 i=2)
 left=4, counter=0
 next[] = {1, 3, 3, 4, 0}
 prev 不变 (=1), i = next[2] = 3

Step 4: counter=1, prev=3, i=4
Step 5: counter=2, prev=4, i=0
Step 6: counter=3 出局!
 打印 "1 is out" (i=0, 编号=0+1=1)
 next[4] = next[0] = 1  (prev=4 跳过 i=0)
 left=3, counter=0
 next[] = {1, 3, 3, 4, 1}
 prev 不变 (=4), i = next[0] = 1

... 以此类推,直到 left=0

5. 全局数组与局部数组

5.1 为什么 next[] 声明为全局

global_array.c
c
#define ALL 10
#define OUT 3

int next[ALL];     // 全局数组——所有函数都能访问

void init_ring(void) { ... }   // 不需要传参
int main(void) { ... }          // 不需要传参

全局数组避免了在函数间频繁传递数组参数——init_ringmain 都可以直接读写 next[]。这对于小型程序来说方便,但需要理解其背后的内存模型。

5.2 C 程序的内存布局

高地址  ┌──────────────┐
 (Stack)    │ ← 局部变量、函数调用帧
 向下增长
       ├──────────────┤
      ...
       ├──────────────┤
 (Heap)      │ ← malloc/free 动态分配
 向上增长
       ├──────────────┤
  BSS (未初始化) │ ← 全局未初始化变量(自动清零)
       ├──────────────┤
 数据段 (已初始化)  │ ← 全局已初始化变量、static 变量
       ├──────────────┤
  代码段 (Text)    │ ← 程序指令、只读数据
低地址  └──────────────┘

int next[ALL]; 作为全局变量,被分配在 BSS 段(如果未初始化)或数据段(如果初始化了)。这意味着:

  • 它在程序启动时就已经存在,生命周期贯穿整个程序
  • BSS 段在程序加载时被操作系统自动清零——所以全局数组默认值全是 0
  • 它不占用栈空间,不会因为数组太大导致栈溢出

5.3 全局 vs 局部的选择

场景推荐原因
小型教学程序全局数组简洁,无需传参
模块化设计局部数组封装性好,避免意外修改
多文件项目static 全局文件作用域限制,避免命名冲突
大型数组(>1MB)全局或 static避免栈溢出
递归函数局部数组每次调用独立的副本
global_vs_local.c
c
// 全局数组:所有函数共享
int global_arr[1000];

void func_a(void) { global_arr[0] = 42; }
void func_b(void) { printf("%d\n", global_arr[0]); }  // 能看到 42

// 局部数组:每个函数独立
void func_c(void) {
    int local_arr[1000];    // 在 func_c 的栈帧中
    local_arr[0] = 42;
}

void func_d(void) {
    int local_arr[1000];    // 在 func_d 的栈帧中,完全不同的数组
    printf("%d\n", local_arr[0]);  // 未初始化,值是随机的!
}

WARNING

局部数组(在函数内部声明的数组)如果不初始化,其元素值是不确定的(栈上的残留数据)。全局数组和 static 数组则会被自动初始化为 0。这是 C 语言初学者最常见的 bug 来源之一。


6. #define 宏常量与 const 对比

6.1 #define 的工作原理

c
#define ALL 10
#define OUT 3

#define 宏定义的常量在编译之前预处理器替换为具体值。比如 int next[ALL]; 在编译之前就被改为 int next[10];

预处理过程:

源代码:                        预处理后:
#define ALL 10
int next[ALL];                int next[10];
for (i=0; i<ALL; i++)   →      for (i=0; i<10; i++)

这种「文本替换」的特性意味着:

  • 没有类型信息ALL 可以被替换到任何需要整数的地方
  • 没有作用域:从定义处到文件末尾(或 #undef)一直有效
  • 没有地址:不能取 &ALL,因为它不是一个变量

6.2 #define vs const 完整对比

特性#define ALL 10const int ALL = 10;
处理阶段预处理期(文本替换)编译期(类型检查)
类型安全无类型有类型(const int
作用域文件作用域(可被 #undef遵循 C 作用域规则
取地址不可取地址可取地址 &ALL
调试可见不可见(已被替换为字面量)可见(有符号名和地址)
数组长度✓ C89/C90 支持✗ C89/C90 不支持(C99 VLA 支持)
内存占用不占内存(编译时替换)占用内存(通常放在只读数据段)
条件编译可与 #if 配合不能用于 #if

6.3 为什么数组长度必须用 #define

在 C89/C90 标准中,数组长度必须是编译时常量(compile-time constant)。const int 在 C89/C90 中不算是编译时常量——它只是「运行时常量」(runtime constant),即在程序运行时其值不可修改,但编译器在编译时无法确定其值。因此:

c
const int N = 10;
int arr[N];    // C89/C90: 编译错误!N 不是编译时常量
               // C99+: 合法(VLA——变长数组)

#define N 10 在预处理阶段就被替换为字面量 10,编译器看到的是 int arr[10];——这在所有 C 标准中都是合法的。

IMPORTANT

C 语言中定义数组长度必须用 #define(或 enum),因为 C89/C90 标准要求数组长度是编译时常量const int 在 C89 中不算是编译时常量,不能用作数组长度。


7. 条件编译 #if / #else

7.1 原始 josephus.c 中的条件编译

原始 josephus.c 中用 #if 1 / #else 保留了两种环形遍历的实现:

conditional_compilation.c
c
#if 1
    pos = ++pos % ALL_NUM;       // 取模法(当前启用的版本)
    step = step % COUNT_NUM;
#else
    pos++;                       // if 判断法(保留备选)
    if (pos == ALL_NUM)
        pos = 0;
    if (step == COUNT_NUM)
        step = 0;
#endif

#if 1 表示「始终编译这个分支」。如果改成 #if 0,则切换到另一分支。这是在开发过程中保留两种实现备选的常见做法——不需要注释掉大段代码,只需改一个数字。

7.2 #if 与 if 的本质区别

#if 1 / #elseif (x) / else
处理阶段预处理期(编译前)运行期(程序运行时)
决策依据编译时已知的常量表达式运行时变量值
代码生成不编译未选中的代码两个分支都进入可执行文件
性能代价零运行时代价每次执行都要分支判断
用途编译时选择实现、平台适配运行时条件判断

条件编译的核心价值:它是「编译时」而非「运行时」的选择——被排除的分支根本不会生成机器码,零执行代价。这在以下场景中非常有用:

  • 调试版本 vs 发布版本(#if DEBUG
  • 不同平台的适配代码(#if __linux__
  • 算法变体的 A/B 测试

7.3 条件编译的更多用法

conditional_more.c
c
#define METHOD 1   // 在文件顶部切换算法

#if METHOD == 1
    // 链表跳转法
    #define ALGO_NAME "linked-list skip"
#elif METHOD == 2
    // 标志删除法
    #define ALGO_NAME "flag deletion"
#else
    // 数学递推法
    #define ALGO_NAME "recurrence formula"
#endif

7.4 if 判断 vs 取模的性能讨论

课堂讨论中问「if 和直接赋值哪种效率高?」这本质上是一个分支预测问题(回顾 Lesson 04 中的讨论):

if_vs_modulo_perf.c
c
// 方案 A: 直接赋值(无分支,有除法)
pos = ++pos % ALL_NUM;

// 方案 B: if 判断(有分支,无除法)
pos++;
if (pos == ALL_NUM)
    pos = 0;
  • 方案 A:每次都要执行 idiv(整数除法)指令,成本较高(约 20-40 周期),但无分支
  • 方案 B:只有递增和比较跳转指令,成本低(通常 1-2 周期),但 pos == ALL_NUM 只有 1/n 的概率为真——分支预测命中率接近 100%,性能通常更好

在约瑟夫环场景下(n 较大时),方案 B 通常更快。但现代编译器在 -O2 下会把方案 A 中的 % n 优化为条件移动指令 cmov(当 n 不是 2 的幂时仍可能有除法),或把方案 B 的 if 也优化为 cmov,达到无分支效果。

TIP

课堂讨论这个问题的意义不在于「选择哪个更快」——而在于理解代码的底层实现,培养性能分析的意识。在绝大多数实际场景中,两种写法的性能差异可以忽略,选择更清晰易读的写法即可。


8. 计数器状态机设计

8.1 约瑟夫环中的状态机

约瑟夫环的淘汰循环本质上是一个有限状态机(Finite State Machine):

         counter++               counter == OUT
  ┌───────────┐    counter++    ┌────────────┐
  COUNTING  │──────────────▶│  ELIMINATE
  (计数中)   │               │  (淘汰中)    │
  └───────────┘                └─────┬──────┘

    counter = 0
       └─────────────────────────────┘
           (淘汰后重置计数器)

状态机有两个状态:

  1. COUNTING(计数中)counter 从 1 递增到 OUT-1,每次迭代 counter++
  2. ELIMINATE(淘汰中)counter == OUT 时触发,执行删除操作后 counter = 0 回到 COUNTING

8.2 计数器变量的生命周期

c
int counter = 0;   // 初始为 0

while (left > 0)
{
    counter++;                     // 每次迭代递增
    if (counter == OUT)            // 达到淘汰阈值
    {
        // 执行淘汰操作
        counter = 0;               // 重置计数器 ← 关键!
    }
    // ...
}

counter 在两种情况下被修改:

  • 递增:每次循环体开始,无条件 counter++
  • 重置:当 counter == OUT 时,counter = 0

这个看似简单的计数器,实际上是一个带有复位逻辑的累加器——它模拟了「数 1, 2, 3, 1, 2, 3, ...」的周期行为。

8.3 状态机的泛化

计数器状态机模式可以泛化到许多场景:

state_machine_generic.c
c
// 模式:周期性触发事件
int tick = 0;
while (condition) {
    tick++;
    if (tick == THRESHOLD) {
        do_periodic_action();   // 每 THRESHOLD 步触发一次
        tick = 0;
    }
}

这种模式在嵌入式系统(定时器中断)、游戏开发(帧计数)、网络协议(心跳包)中随处可见。约瑟夫环的计数器状态机是这些复杂系统的最简雏形。


9. 数据驱动编程:从 Rob Pike 的格言出发

9.1 数据结构先于算法

翻阅 README 中的名人名言,Rob Pike(Go 语言之父,和 Ken Thompson 一起设计了 UTF-8 编码格式,现 Google 首席工程师)说:

"Data dominates. If you've chosen the right data structures and organized things well, the algorithms will almost always be self-evident. Data structures, not algorithms, are central to programming."

"数据压倒一切。如果已经选择了正确的数据结构并且把一切都组织得井井有条,正确的算法也就不言自明。编程的核心是数据结构,而不是算法。"

在约瑟夫环中,这个思想体现得淋漓尽致:

  • next[] 数组替代链表指针:数据结构变了(从 struct 链表变成 int 数组),但算法的逻辑结构不变(环形遍历 + 节点删除)
  • (i+1) % n 替代条件判断:用数学性质消除了边界检查,环形结构从「if 判断」变成了「数学运算」
  • next[prev] = next[i] 替代标志置零:删除的语义从「标记为无效」变为「从结构中移除」,算法更简洁

9.2 两种数据结构的算法演化

数据结构: people[] 数组(存储编号,标记删除)

算法: 扫描数组 检查是否存活 计数 标记为 0

问题: 无效位置越来越多,扫描越来越慢

数据结构: next[] 数组(存储下一人位置,链表跳转)

算法: next[prev] = next[i] i = next[i] counter++

结果: 每次迭代都处理有效节点,O(n*m) 复杂度

核心启示:当你觉得算法变得复杂难懂,回到数据结构层面重新审视——也许换个结构就能让算法「不言自明」。

9.3 Pike 的第三句名言

Rob Pike 还有一句更直白的表述:

"给我看流程图而不让我看(数据)表,我仍会茫然不解;如果给我看(数据)表,通常就不需要流程图了;数据表足够说明问题了。"

在约瑟夫环中,next[] 数组本身就是一张「数据表」——它记录了环中每个人的下一人是谁。有了这张表,出局的流程自然浮现:找到出局者,让他的前驱跳过他。不需要画流程图,数据结构本身就描述了算法。


10. 数学递推公式

10.1 递推公式的推导

标志删除法和链表跳转法都是「模拟」——一步步复现约瑟夫环的淘汰过程。但数学上,我们可以直接计算幸存者的位置,无需模拟。

递推公式:

f(1) = 0
f(n) = (f(n-1) + m) % n    (n > 1)

其中 f(n) 表示 n 个人、每 m 个淘汰时,幸存者的 0-based 编号

10.2 公式的含义

f(n) = (f(n-1) + m) % n 的含义是:

如果你已经知道 n-1 个人时的幸存者位置是 f(n-1),那么加入第 n 个人(编号 n-1)后,新幸存者的位置就是在 f(n-1) 的基础上向后偏移 m 步,然后对 n 取模。

这是因为:当第一个人(从 0 开始数到 m-1)出局后,问题退化为 n-1 个人的约瑟夫环——但编号需要重新映射。

10.3 手动推导示例

m=3 时:

f(1) = 0                              → 1人中,第0号幸存

f(2) = (f(1) + 3) % 2 = (0+3) % 2 = 1
 2人中,第1号幸存
    验证: [0,1], 数到3 → 0出局(3%2=1, 即第1个), 1幸存 ✓

f(3) = (f(2) + 3) % 3 = (1+3) % 3 = 1
 3人中,第1号幸存
    验证: [0,1,2], 数到3 → 2出局, 数到3 → 0出局, 1幸存 ✓

f(4) = (f(3) + 3) % 4 = (1+3) % 4 = 0
 4人中,第0号幸存

f(5) = (f(4) + 3) % 5 = (0+3) % 5 = 3
 5人中,第3号幸存
    验证: [0,1,2,3,4], 淘汰顺序 2→0→4→1, 3幸存 ✓

10.4 递推公式的代码实现

josephus_math.c
c
// 递推公式实现——O(n) 时间,O(1) 空间
int josephus(int n, int m)
{
    int f = 0;
    int i;
    for (i = 2; i <= n; i++)
        f = (f + m) % i;
    return f;    // f 是幸存者的 0-based 位置
}

这是约瑟夫环问题的最优解:O(n) 时间,O(1) 额外空间。不需要任何数组,不需要模拟淘汰过程——纯数学计算。

10.5 三种方法的完整对比

方法时间复杂度空间复杂度能得到出局顺序?核心思想
标志删除法O(n²)O(n)数组标记 + 线性扫描
链表跳转法O(n*m)O(n)next[] 数组模拟链表
递推公式法O(n)O(1)✗(仅幸存者)数学归纳

NOTE

递推公式只给出最终幸存者位置,不能给出完整的出局顺序。如果需要打印每个人的出局顺序(如本课练习 2),仍需要用链表模拟。数学解法体现了「不同问题需要不同工具」的原则——不是所有场景下数学推导都能替代模拟。

10.6 约瑟夫斯问题的真实答案

回到历史:约瑟夫斯和 40 名战友(n=41),每数到第 3 人(m=3):

f(41) = ?
用递推公式:
f(1)=0 → f(2)=1 → f(3)=1 → ... → f(41)=30

0-based 位置 30,即 1-based 位置 31。约瑟夫斯站在第 31 号位置(倒数第二),他的朋友站在第 16 号位置——两人幸存。


11. XOR 交换

11.1 传统交换 vs XOR 交换

在约瑟夫环的实现中我们不需要交换操作,但本课的课后练习(奇偶排序)涉及元素交换。这里介绍一种有趣的替代方案:

xor_swap.c
c
// 传统交换(需要临时变量)
int tmp = a;
a = b;
b = tmp;

// XOR 交换(无需临时变量)
a = a ^ b;
b = a ^ b;
a = a ^ b;

11.2 XOR 交换的原理

XOR(异或)运算满足以下性质:

  • x ^ x = 0(自反性)
  • x ^ 0 = x(恒等性)
  • 交换律:a ^ b = b ^ a
  • 结合律:(a ^ b) ^ c = a ^ (b ^ c)

利用这些性质,XOR 交换的推导过程:

初始: a=A, b=B

第一步: a = A ^ B      (a 现在存储了 A B 的异或)
第二步: b = (A ^ B) ^ B = A ^ (B ^ B) = A ^ 0 = A
       (b 变成了原来的 a)
第三步: a = (A ^ B) ^ A = (A ^ A) ^ B = 0 ^ B = B
       (a 变成了原来的 b)

最终: a=B, b=A  —— 完成交换

11.3 XOR 交换的注意事项

CAUTION

XOR 交换有一个致命陷阱:如果 ab同一个变量(即 &a == &b),第一步 a = a ^ a = 0,之后无论怎么操作结果都是 0——数据被清零了!

xor_swap_danger.c
c
// 危险!如果 i == j,arr[i] 被清零
arr[i] = arr[i] ^ arr[j];
arr[j] = arr[i] ^ arr[j];
arr[i] = arr[i] ^ arr[j];

因此,在现代编程实践中,XOR 交换更多是一种智力游戏而非实用技巧。使用 tmp 变量的传统交换更安全、更清晰,编译器也会优化掉临时变量。


12. struct 方案 vs 数组方案

12.1 用 struct 重新实现约瑟夫环

如果我们使用传统的 struct 链表,约瑟夫环的实现如下:

struct_josephus.c
c
#include <stdio.h>
#include <stdlib.h>

struct Node {
    int id;
    struct Node *next;
};

int main(void)
{
    int n = 10, m = 3;
    struct Node *head, *prev, *cur;
    int i;

    // 创建循环链表
    head = malloc(sizeof(struct Node));
    head->id = 1;
    prev = head;

    for (i = 2; i <= n; i++) {
        cur = malloc(sizeof(struct Node));
        cur->id = i;
        prev->next = cur;
        prev = cur;
    }
    prev->next = head;   // 形成闭环

    // 约瑟夫环淘汰
    cur = head;
    prev = NULL;  // 需要单独跟踪前驱
    int left = n;
    int counter = 0;

    while (left > 0) {
        counter++;
        if (counter == m) {
            printf("%d is out\n", cur->id);
            prev->next = cur->next;
            free(cur);            // 需要手动释放内存
            cur = prev->next;
            left--;
            counter = 0;
        } else {
            prev = cur;
            cur = cur->next;
        }
    }

    return 0;
}

12.2 两种方案的深度对比

维度struct 链表next[] 数组
头文件依赖#include <stdlib.h>(malloc/free)无额外依赖
创建节点malloc 逐个分配数组声明一次性分配
节点标识cur->id(显式存储)数组下标 i(隐式标识)
删除节点free(cur)(手动回收)自动回收(数组生命周期管理)
内存泄漏风险有(忘记 free)
空指针风险有(NULL 解引用)
代码行数~30 行~20 行
灵活性可动态增减节点编译时固定大小
教学重点指针、malloc、内存管理下标模拟、取模、逻辑抽象

12.3 为什么入门阶段用数组方案

next[] 数组方案将「指针」的语义隐藏在「下标」背后。学生在理解 next[prev] = next[i] 时,实际上在理解链表删除的核心逻辑——当后续课程引入真正的 struct 链表时,学生只需要把 next[i] 换成 cur->next,把 i 换成 *cur,逻辑结构完全一致。

这是一种「延迟复杂性」的教学策略——先让学生掌握环形数据结构 + 链表删除的逻辑,再引入指针语法的实现细节


13. 循环缓冲区

13.1 从约瑟夫环到循环缓冲区

约瑟夫环中的 next[] 数组本质上是一个循环数据结构。这个结构在计算机科学中有一个更通用的名字:循环缓冲区(Circular Buffer / Ring Buffer)。

循环缓冲区的结构:

  ┌───┬───┬───┬───┬───┬───┬───┬───┐
 A B C
  └───┴───┴───┴───┴───┴───┴───┴───┘

  head (读指针)    tail (写指针)

head = 0, tail = 3 缓冲区中有 3 个元素

循环缓冲区是一个固定大小的环形队列:当 tail 到达数组末尾时,通过取模绕回起点,覆盖最旧的数据。

13.2 循环缓冲区的核心操作

circular_buffer.c
c
#define BUF_SIZE 8

int buf[BUF_SIZE];
int head = 0;   // 读位置
int tail = 0;   // 写位置
int count = 0;  // 当前元素数

// 写入一个元素
void enqueue(int val) {
    if (count < BUF_SIZE) {
        buf[tail] = val;
        tail = (tail + 1) % BUF_SIZE;   // 取模绕回
        count++;
    }
}

// 读取一个元素
int dequeue(void) {
    int val = -1;
    if (count > 0) {
        val = buf[head];
        head = (head + 1) % BUF_SIZE;   // 取模绕回
        count--;
    }
    return val;
}

13.3 循环缓冲区的应用场景

场景说明
操作系统内核键盘输入缓冲区、管道(pipe)实现
网络协议栈TCP 滑动窗口、网卡收发队列
音视频处理音频采样缓冲区、视频帧队列
嵌入式系统UART 串口收发缓冲、传感器数据采集
日志系统固定大小环形日志(最旧日志自动覆盖)

13.4 从约瑟夫环到轮转调度

操作系统的轮转调度(Round-Robin Scheduling)正是约瑟夫环的直接应用:n 个进程围成一个圈,每个进程获得固定时间片(time slice),时间片用完就轮到下一个进程。next[i] = (i + 1) % n 就是调度器的核心逻辑——CPU 在进程之间循环切换。

round_robin.c
c
// 简化的轮转调度
int current_process = 0;
int num_processes = N;
int time_slice = 100;  // ms

while (1) {
    run_process(current_process, time_slice);
    current_process = (current_process + 1) % num_processes;  // 下一个进程
}

约瑟夫环不是一道孤立的编程题——它背后的环形数据结构思想,贯穿了从操作系统内核到嵌入式系统的整个计算机科学领域。


参考解答

练习1: init_ring & print_ring(初始化循环链表)
init_ring_solution.c
c
#include <stdio.h>

#define MAX 1000

int next[MAX];
int n;

void init_ring(void)
{
    int i;
    for (i = 0; i < n; i++)
        next[i] = (i + 1) % n;
}

void print_ring(void)
{
    int i;
    for (i = 0; i < n; i++)
        printf("%d ", next[i]);
    printf("\n");
}

int main(void)
{
    scanf("%d", &n);

    init_ring();
    print_ring();

    return 0;
}

核心:next[i] = (i + 1) % n 建立闭环——最后一个人 n-1next 指向 0,形成循环结构。print_ring 打印所有 next 值以验证初始化正确。注意每个数字后跟一个空格(包括最后一个数字),最后输出换行符。

练习2: 约瑟夫环链表跳转法(ALL=10, OUT=3)
josephus_solution.c
c
#include <stdio.h>

#define ALL 10
#define OUT 3

int next[ALL];

void init_ring(void)
{
    int i;
    for (i = 0; i < ALL; i++)
        next[i] = (i + 1) % ALL;
}

int main(void)
{
    int left = ALL;
    int counter = 0;
    int i = 0;
    int prev = ALL - 1;

    init_ring();

    while (left > 0)
    {
        counter++;
        if (counter == OUT)
        {
            printf("%d is out\n", i + 1);
            next[prev] = next[i];
            left--;
            counter = 0;
        }
        else
        {
            prev = i;
        }
        i = next[i];
    }

    return 0;
}

核心逻辑:counter 每走一步加 1,当 counter == OUT 时触发淘汰。出局者通过 next[prev] = next[i] 从链表中跳过。prev 只在正常情况下更新(被淘汰时 prev 保持不变,因为被淘汰者不能作为前驱)。初始 prev = ALL-1 是因为链表是环形的,第一个人的前驱就是最后一个人。

标志删除法(原始 josephus.c 方案)
josephus_flag.c
c
#include <stdio.h>

#define ALL_NUM   100
#define COUNT_NUM 3
#define OUT_NUM   3

int people[ALL_NUM];

int main(void)
{
    int left = ALL_NUM;
    int pos = 0;
    int step = 0;
    int i;

    for (i = 0; i < ALL_NUM; i++)
        people[i] = i + 1;

    while (left > 0)
    {
        if (people[pos] > 0)
            step++;

        if (step == OUT_NUM && people[pos] != 0)
        {
            printf("%d out \n", people[pos]);
            people[pos] = 0;
            left--;
        }

        pos = ++pos % ALL_NUM;
        step = step % COUNT_NUM;
    }

    return 0;
}

标志删除法中 people[pos] = 0 标记出局。每次迭代不管当前位置是否有效都向下推进——无效位置越多,浪费的迭代次数越多。链表跳转法(练习 2)正好弥补了这一缺陷。注意条件编译 #if 1 / #else 的使用——保留了两种环形前进方式的备选实现。

对照检查next[i] = (i + 1) % n 写对了吗?prev 初始值是 ALL - 1 吗?出局时 next[prev] = next[i] 而非 next[i + 1] 吗?counter 出局后是否重置为 0?


课堂讨论

  1. posstep 变量,每次赋值用 if 和直接用取模 %,在执行效率上有什么不同?
  2. 在标志删除法中,留在环里面的人越少,无效的比较次数越多——有什么办法可以优化算法?
  3. 如何使用数组实现链表跳转法的优化?链表跳转法的核心改进点在哪里?
  4. 约瑟夫环问题有一个递推公式:f(1) = 0; f(n) = (f(n-1) + m) % n。试分析这个公式和链表模拟法之间的关系。
  5. 如果约瑟夫环要求逆时针方向计数(即从当前位置向左数),如何修改代码?
  6. 在约瑟夫环中,如果每次淘汰的不是第 m 个人,而是第 m 个存活的人(跳过已出局者),标志删除法和链表跳转法的实现分别需要如何修改?

讨论答案

Q1: if 判断 vs 取模 % 的执行效率差异

核心差异:分支预测 vs 整数除法。

q1_perf_compare.c
c
// 方案 A: 取模(有 div 指令)
pos = ++pos % ALL_NUM;

// 方案 B: if 判断(有分支跳转)
pos++;
if (pos == ALL_NUM)
    pos = 0;
层面方案 A(取模)方案 B(if 判断)
机器指令idiv(整数除法,20-40 周期)cmp + jne(比较跳转,1-2 周期)
分支预测无分支,确定执行pos==ALL_NUM 概率 1/n,几乎总能正确预测
代码体积1 条指令3-4 条指令
编译器优化-O2 下可能被优化为乘法+移位-O2 下可能改为 cmov(条件移动)

对于约瑟夫环(n 较大时),if 判断通常更快——分支预测几乎从来不错。但现代编译器在 -O2 下会优化掉两者的差异。结论:优先考虑可读性,不必为了这个级别的差异而牺牲代码清晰度。真正的性能瓶颈通常在 I/O 和算法复杂度上,而不是这种微优化。

更深层的思考:当 n 是 2 的幂(如 8、16、32)时,编译器可以将 % n 优化为 & (n-1)(按位与),此时取模方案反而比 if 判断更快。这是编译器优化的一个经典案例。

Q2: 标志删除法的无效比较如何优化?

标志删除法的核心问题是随着淘汰者增多,遍历到有效人的概率越来越小

 1 个人出局后:  1/100 的位置无效 平均 1.01 次迭代找到下一个有效人
 50 个人出局后: 50/100 的位置无效 平均 2 次迭代
 90 个人出局后: 90/100 的位置无效 平均 10 次迭代
 99 个人出局后: 99/100 的位置无效 平均 100 次迭代!

优化方案一:链表跳转法——用 next[] 数组记录「下一个有效人的位置」,让遍历总是直接跳到有效位置。这是本课的核心优化。

优化方案二:惰性跳过(Lazy Skip)。当 people[pos] == 0 时,连续向前扫描直到找到有效人:

lazy_skip.c
c
while (people[pos] == 0)
    pos = (pos + 1) % n;

虽然仍比链表法慢(每次找到有效人后还要再数 m-1 步),但避免了在无效位置上的计数和比较迭代。

优化方案三:压缩数组。每次出局后,将数组末尾的有效元素移到出局位置,然后缩小有效范围。这种方法改变了元素的相对顺序,但减少了无效位置的产生。

Q3: 链表跳转法如何用数组实现?核心改进是什么?

链表跳转法的数组实现已在练习 2 中展示。核心改进有两点:

改进 1:跳转的效率

q3_skip_efficiency.c
c
// 标志法——每次都要逐一扫描找下一个有效人
i = (i + 1) % n;          // 可能踩到无效位置

// 链表法——总是跳到下一个有效人
i = next[i];              // 100% 有效

改进 2:删除的语义

q3_delete_semantics.c
c
// 标志法——标记为「已死」,但保留在数组中
people[pos] = 0;

// 链表法——物理上从链中移除
next[prev] = next[i];   // prev 跳过 i,i 从链中消失

本质区别:标志法保留了死节点的所有数据结构代价(仍需遍历),链表法彻底解除了死节点与结构的耦合——删除了就不再访问

更进一步的理解:标志法中的数组既是「容器」又是「逻辑结构」,两者耦合在一起。链表法将它们分离——数组是容器(存储 next 值),而 next 值自身定义了一个独立的逻辑结构(链表)。这种「数据与结构分离」的思想是数据结构设计的核心原则之一。

Q4: 递推公式 f(n) = (f(n-1) + m) % n 与链表模拟的关系

这是约瑟夫环问题的数学优雅解。

递推公式:f(1) = 0; f(n) = (f(n-1) + m) % n

含义:n 个人中的幸存者位置 = 在 n-1 个人中的幸存者位置基础上,加上 m 步偏移,然后对 n 取模。

推导思路:

  1. n 个人的环,第一次淘汰的是编号为 (m-1) % n 的人
  2. 淘汰后剩下 n-1 个人,但编号需要重新映射
  3. 旧编号 k 映射为新编号 (k - m) % n(取模保证在 0~n-2)
  4. 反过来说,n-1 人环中的幸存者编号 f(n-1),映射回 n 人环中的编号为 (f(n-1) + m) % n
m=3 时:
f(1) = 0                           → 1人中,第0号幸存
f(2) = (0+3) % 2 = 1               → 2人中,第1号幸存
f(3) = (1+3) % 3 = 1               → 3人中,第1号幸存
f(4) = (1+3) % 4 = 0               → 4人中,第0号幸存
f(5) = (0+3) % 5 = 3               → 5人中,第3号幸存

链表模拟 vs 递推公式

  • 链表模拟:直接复现历史场景,时间复杂度 O(n*m),能得到完整出局顺序
  • 递推公式:数学归纳分析,时间复杂度 O(n),只能得到最终幸存者位置

递推公式不依赖任何数据结构——只用整数计算。这是数据结构和算法之间的典型张力:有时一个好的数学分析能完全替代复杂的结构实现。

josephus_recurrence.c
c
// 递推公式实现(最简洁的约瑟夫环解法)
int josephus(int n, int m)
{
    int f = 0;
    int i;
    for (i = 2; i <= n; i++)
        f = (f + m) % i;
    return f;    // f 是幸存者的 0-based 位置
}

NOTE

递推公式只给出最终幸存者位置,不能给出完整的出局顺序。如果需要打印每个人的出局顺序(如本课练习 2),仍需要用链表模拟。

Q5: 逆时针方向计数如何修改?

逆时针方向意味着每次向前(prev 方向)移动而不是向后(next 方向)。这需要修改两个地方:

方法一:建立双向链表

q5_reverse.c
c
int next[ALL];    // 顺时针的下一个
int prev[ALL];    // 逆时针的下一个(即顺时针的前一个)

// 初始化
for (i = 0; i < ALL; i++) {
    next[i] = (i + 1) % ALL;
    prev[i] = (i - 1 + ALL) % ALL;  // 注意处理负数取模
}

// 遍历时使用 prev 而不是 next
i = prev[i];

方法二:修改 next 数组的初始化方向

q5_reverse_next.c
c
// 让 next[i] 指向逆时针方向的下一个人
for (i = 0; i < ALL; i++)
    next[i] = (i - 1 + ALL) % ALL;

这样 i = next[i] 就是逆时针移动,其余淘汰逻辑完全不变。

关键点:在 C 语言中,(-1) % ALL 的结果是负数(C89/C90 中行为是实现定义的,C99 后规定向零取整)。因此必须写成 (i - 1 + ALL) % ALL 来确保结果为正。这是一个容易被忽略的边界情况。

Q6: 淘汰第 m 个存活的人时两种方法的修改

标志删除法:基本不需要修改!因为标志删除法已经通过 if (people[pos] > 0) 跳过了已出局者,step++ 只对存活的人计数。所以「数到第 m 个存活的人」就是当前的行为。

链表跳转法:同样不需要修改!因为链表跳转法中 i = next[i] 总是跳到下一个存活的人,counter 自然也只对存活的人计数。

这个问题的答案是:链表跳转法天然就是淘汰第 m 个存活的人,因为链表中不存在已出局者。而标志删除法需要显式的 if (people[pos] > 0) 判断来跳过。两种方法对「存活」的定义不同:

  • 标志法:存活 = people[pos] != 0(显式检查)
  • 链表法:存活 = 在链表中存在(隐式保证)

这再次验证了「数据结构选择决定算法复杂度」的原则——链表法中「只遍历存活者」不是算法技巧,而是数据结构的自然结果。


课后练习

  1. 统计比较次数:在标志删除法的程序中增加一个计数器,统计在执行过程中 if (people[pos] > 0) 总共执行了多少次(含对无效位置的检查)。用 ALL_NUM=100 测试。

    知识点提示:计数器变量、循环内累计、#define 宏常量的单点修改

  2. 万年历打印:已知 2012 年 1 月 1 日是星期日,打印出 2012 年全年的月历(12 个月,每个 7 列)。要求用函数、数组和循环组织代码。

    知识点提示:用数组存储每月天数、闰年判断、\n\t 格式化对齐、循环遍历 12 个月

  3. 奇偶排序:用户输入 10 个数字,将它们重新排列,使所有奇数排在前面,所有偶数排在后面。

    知识点提示:双指针交换、分类而非全排序、就地重排算法

  4. 数学递推法实现:编写一个函数 int josephus(int n, int m),使用递推公式 f(n) = (f(n-1) + m) % n 计算并返回幸存者的 1-based 编号。在 main 中读入 n 和 m,输出结果。对比与链表模拟法的运行结果是否一致。

    知识点提示:递推公式、函数封装、O(n) vs O(n*m) 复杂度对比

  5. 循环缓冲区实现:实现一个固定大小为 8 的循环缓冲区,支持 enqueue(入队)和 dequeue(出队)操作。当缓冲区满时,新的入队操作覆盖最旧的数据。用取模实现环形索引。

    知识点提示:取模绕回、head/tail 指针管理、满/空条件判断、覆盖策略

练习1: 统计标志删除法的比较次数
stats_comparisons.c
c
#include <stdio.h>

#define ALL_NUM   100
#define COUNT_NUM 3
#define OUT_NUM   3

int main(void)
{
    int people[ALL_NUM];
    int left = ALL_NUM;
    int pos = 0;
    int step = 0;
    int comparisons = 0;    // 新增:比较次数计数器
    int i;

    for (i = 0; i < ALL_NUM; i++)
        people[i] = i + 1;

    while (left > 0)
    {
        if (people[pos] > 0)
        {
            comparisons++;  // 计数:这次的 if 检查有效
            step++;
        }
        else
            comparisons++;  // 计数:这次的 if 检查也执行了(只是结果为假)

        if (step == OUT_NUM && people[pos] != 0)
        {
            printf("%d out \n", people[pos]);
            people[pos] = 0;
            left--;
        }

        pos = ++pos % ALL_NUM;
        step = step % COUNT_NUM;
    }

    printf("total comparisons = %d\n", comparisons);
    return 0;
}

运行后 total comparisons 的值远大于 ALL_NUM(100),因为标志法需要遍历许多无效位置。可以对比链表跳转法——后者只需约 ALL_NUM * OUT_NUM 次迭代,且每次迭代都对应一个有效人。

练习2: 万年历打印(核心逻辑)
calendar.c
c
#include <stdio.h>

int is_leap(int year)
{
    return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}

int main(void)
{
    int year = 2012;
    int month_days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
    int start_day = 0;    // 2012-01-01 is Sunday (0)
    int m, d;

    if (is_leap(year))
        month_days[1] = 29;

    for (m = 0; m < 12; m++)
    {
        printf("Month %d\n", m + 1);
        printf("Sun Mon Tue Wed Thu Fri Sat\n");

        for (d = 0; d < start_day; d++)
            printf("    ");     // 月初空白

        for (d = 1; d <= month_days[m]; d++)
        {
            printf("%3d ", d);
            if ((start_day + d) % 7 == 0)
                printf("\n");
        }

        printf("\n\n");
        start_day = (start_day + month_days[m]) % 7;
    }

    return 0;
}

核心:用数组存储每月天数,用取模 % 7 计算星期几。start_day 记录每月 1 号是星期几,月初先输出空白填充。(start_day + d) % 7 == 0 在逢 7 时换行。注意闰年 2 月有 29 天。

练习3: 奇偶排序(双指针交换)
odd_even_sort.c
c
#include <stdio.h>

int main(void)
{
    int arr[10];
    int i = 0, j = 9;
    int k;

    for (k = 0; k < 10; k++)
        scanf("%d", &arr[k]);

    while (i < j)
    {
        while (i < j && arr[i] % 2 == 1)
            i++;               // i 停在偶数上
        while (i < j && arr[j] % 2 == 0)
            j--;               // j 停在奇数上
        if (i < j)
        {
            int tmp = arr[i];
            arr[i] = arr[j];
            arr[j] = tmp;      // 交换:奇数→前,偶数→后
        }
    }

    for (k = 0; k < 10; k++)
        printf("%d ", arr[k]);
    printf("\n");

    return 0;
}

双指针:i 从左向右找偶数(需要移到后面的),j 从右向左找奇数(需要移到前面的),交换它们。算法终止时 i >= j。这不是完整排序,只是「分类重排」——时间复杂度 O(n),比完整排序 O(n log n) 更高效。

练习4: 数学递推法实现
josephus_math_solution.c
c
#include <stdio.h>

int josephus(int n, int m)
{
    int f = 0;
    int i;
    for (i = 2; i <= n; i++)
        f = (f + m) % i;
    return f + 1;   // 转换为 1-based 编号
}

int main(void)
{
    int n, m;
    scanf("%d %d", &n, &m);
    printf("Survivor: %d\n", josephus(n, m));
    return 0;
}

递推公式 f(n) = (f(n-1) + m) % n 以 O(n) 时间和 O(1) 空间直接计算幸存者位置。f(1)=0 是边界条件,循环从 i=2 开始递推到 i=n。返回时 f+1 将 0-based 转换为 1-based 编号。可以输入 n=10, m=3 验证:输出应为 4(与链表模拟法结果一致)。

练习5: 循环缓冲区实现
circular_buffer_impl.c
c
#include <stdio.h>

#define BUF_SIZE 8

int buf[BUF_SIZE];
int head = 0;
int tail = 0;
int count = 0;

void enqueue(int val)
{
    buf[tail] = val;
    tail = (tail + 1) % BUF_SIZE;
    if (count < BUF_SIZE)
        count++;
    else
        head = (head + 1) % BUF_SIZE;  // 满时覆盖最旧的
}

int dequeue(void)
{
    int val;
    if (count == 0) return -1;        // 空队列
    val = buf[head];
    head = (head + 1) % BUF_SIZE;
    count--;
    return val;
}

void print_buf(void)
{
    int i;
    printf("Buffer: ");
    for (i = 0; i < count; i++)
        printf("%d ", buf[(head + i) % BUF_SIZE]);
    printf("\n");
}

int main(void)
{
    int i;
    for (i = 1; i <= 10; i++) {
        enqueue(i);
        print_buf();
    }
    for (i = 0; i < 5; i++) {
        printf("Dequeued: %d\n", dequeue());
        print_buf();
    }
    return 0;
}

循环缓冲区的核心是 (index + 1) % BUF_SIZE 实现环形索引。当 count == BUF_SIZE(满)时,新元素覆盖最旧数据,head 前移。print_bufhead 开始遍历 count 个元素,用 (head + i) % BUF_SIZE 计算实际索引。这个实现展示了约瑟夫环中取模技术的实际工程应用。


参考资料

"Data dominates. If you've chosen the right data structures and organized things well, the algorithms will almost always be self-evident." — Rob Pike

Released under the MIT License.