Golang学习-约瑟夫环问题(Josephus Problem)

约瑟夫环问题(Josephus Problem)

一、问题描述

约瑟夫环是一个经典的数学与计算机科学问题。其情景如下:

N 个人围成一圈,编号 1~N。从编号 1 开始报数,报到 M 的人出列;下一位从 1 重新开始报数,再报到 M 的人出列......如此循环,直到只剩最后一人。求最后幸存者的原始编号。

这个问题的历史可以追溯到公元 1 世纪,犹太历史学家约瑟夫(Flavius Josephus)在罗马围攻中与 40 名同胞藏身洞穴,他们决定宁可自杀也不做俘虏。约瑟夫通过数学计算站到了幸存位置------他自述的这段经历便成了这个问题的名称。

二、三种解法对比

解法 时间复杂度 空间复杂度 特点
数组模拟法 O(n × m) O(n) 直观易懂,但慢
循环链表模拟法 O(n × m) O(n) 贴合问题本质,但同样慢
递推公式法 O(n) O(1) 最优解,纯数学推导

三、解法一:数组模拟法

用一个 []int 表示还在圈中的人,1 表示在场,0 表示已出列。从头到尾循环扫描,遇到未出列的人就计数,数到 m 时标记出列。

3.1 实现思路

  1. 初始化数组,所有位置标记为 1(在场)
  2. 遍历数组,对在场的人报数
  3. 报到 m 时标记出列,计数器归零
  4. 当出列人数 = n-1 时停止,剩下那个就是幸存者

3.2 代码

go 复制代码
package main

import "fmt"

// JosephusArray 数组模拟法
func JosephusArray(n, m int) int {
	if n <= 0 || m <= 0 {
		return -1
	}

	alive := make([]bool, n) // true=在场
	for i := range alive {
		alive[i] = true
	}

	count := 0   // 已出列人数
	number := 0  // 报数器
	idx := 0     // 当前下标

	for count < n-1 {
		if alive[idx] {
			number++
			if number == m {
				alive[idx] = false // 出列
				number = 0
				count++
			}
		}
		idx = (idx + 1) % n // 环形绕回
	}

	// 找到唯一幸存者
	for i, a := range alive {
		if a {
			return i + 1 // 编号从 1 开始
		}
	}
	return -1
}

func main() {
	fmt.Println("n=41, m=2:", JosephusArray(41, 2)) // 19
	fmt.Println("n=8, m=5:", JosephusArray(8, 5))   // 3
	fmt.Println("n=5, m=3:", JosephusArray(5, 3))   // 4
}

运行结果:

复制代码
n=41, m=2: 19
n=8, m=5: 3
n=5, m=3: 4

缺点:每次循环都要跳过已出列的人,时间复杂度为 O(n × m),当 n 和 m 都很大时效率很低。

四、解法二:循环链表模拟法

用循环链表直接模拟"围成一圈"的场景,每报到 m 就删除当前节点,天然贴合问题模型。

4.1 代码

go 复制代码
package main

import "fmt"

type CLNode struct {
	No   int
	Next *CLNode
}

// JosephusCircularList 循环链表模拟法
func JosephusCircularList(n, m int) int {
	if n <= 0 || m <= 0 {
		return -1
	}

	// 构建循环链表:1 -> 2 -> ... -> n -> 1
	head := &CLNode{No: 1}
	cur := head
	for i := 2; i <= n; i++ {
		cur.Next = &CLNode{No: i}
		cur = cur.Next
	}
	cur.Next = head // 尾指头,形成环

	// 找到头节点的前驱(辅助删除)
	prev := head
	for prev.Next != head {
		prev = prev.Next
	}

	// 开始报数,每次报 m 个人就删除
	for cur.Next != cur { // 直到只剩一个人
		// 报数 m-1 次(当前节点算第1个,走 m-1 步到第 m 个)
		for i := 1; i < m; i++ {
			prev = cur
			cur = cur.Next
		}
		// 删除 cur 节点
		prev.Next = cur.Next
		cur = cur.Next
	}

	return cur.No
}

func main() {
	fmt.Println("n=41, m=2:", JosephusCircularList(41, 2)) // 19
	fmt.Println("n=8, m=5:", JosephusCircularList(8, 5))   // 3
	fmt.Println("n=5, m=3:", JosephusCircularList(5, 3))   // 4
}

运行结果:

复制代码
n=41, m=2: 19
n=8, m=5: 3
n=5, m=3: 4

优点 :模型直观,不需要额外的标记数组

缺点:时间复杂度仍然是 O(n × m),空间复杂度 O(n)

五、解法三:递推公式法(最优解)

这是约瑟夫问题的数学精华。通过递推关系,可以做到 O(n) 时间、O(1) 空间。

5.1 推导过程

f(n, m) 表示 n 个人、报数为 m 时最后幸存者的编号(从 0 开始编号)。

基本情况:当 n = 1 时,只有一个人,幸存者就是 0 号。

复制代码
f(1, m) = 0

递推 :当 n 个人中第一个出列的是第 m 个人(编号 m-1)后,剩下 n-1 个人。关键在于,这 n-1 个人的编号体系发生了偏移

复制代码
原始编号:   0, 1, 2, ..., m-2, m-1, m, m+1, ..., n-1
出列后:     m, m+1, ..., n-1,   0,   1, ..., m-2

重新编号:   0,  1,  ..., n-m-1, n-m, n-m+1, ..., n-2

从原始编号到新编号的映射关系:

复制代码
新编号 = (旧编号 - m) % n
旧编号 = (新编号 + m) % n

所以:

复制代码
f(n, m) = (f(n-1, m) + m) % n

这就是约瑟夫问题的核心递推公式!

5.2 从 0 编号到 1 编号

上述公式假设编号从 0 开始。如果要从 1 开始编号,最终结果加 1 即可:

复制代码
result = f(n, m) + 1

5.3 代码实现

go 复制代码
package main

import "fmt"

// JosephusRecursive 递归版本(可能栈溢出,n 大时不推荐)
func JosephusRecursive(n, m int) int {
	if n == 1 {
		return 0
	}
	return (JosephusRecursive(n-1, m) + m) % n
}

// JosephusIterative 迭代版本(推荐,O(n) 时间 O(1) 空间)
func JosephusIterative(n, m int) int {
	if n <= 0 || m <= 0 {
		return -1
	}

	survivor := 0 // f(1, m) = 0
	for i := 2; i <= n; i++ {
		survivor = (survivor + m) % i
	}
	return survivor + 1 // 转为 1~n 编号
}

func main() {
	fmt.Println("n=41, m=2:", JosephusIterative(41, 2)) // 19
	fmt.Println("n=8, m=5:", JosephusIterative(8, 5))   // 3
	fmt.Println("n=5, m=3:", JosephusIterative(5, 3))   // 4
	fmt.Println("n=1000000, m=3:", JosephusIterative(1000000, 3)) // 大规模也能秒出
}

运行结果:

复制代码
n=41, m=2: 19
n=8, m=5: 3
n=5, m=3: 4
n=1000000, m=3: 374433

5.4 手动验证递推过程

以 n=5, m=3 为例(编号 0~4):

i survivor 计算
1 0 f(1)=0
2 (0+3)%2 = 1 f(2)
3 (1+3)%3 = 1 f(3)
4 (1+3)%4 = 0 f(4)
5 (0+3)%5 = 3 f(5)

最终 survivor = 3,转为 1 编号 = 4。验证正确!

六、扩展:出列顺序

如果不仅要求最后幸存者,还要知道每个人的出列顺序,递推公式就不够了,需要模拟。循环链表法可以轻松记录出列顺序:

go 复制代码
func JosephusOrder(n, m int) []int {
	if n <= 0 || m <= 0 {
		return nil
	}

	// 构建循环链表
	head := &CLNode{No: 1}
	cur := head
	for i := 2; i <= n; i++ {
		cur.Next = &CLNode{No: i}
		cur = cur.Next
	}
	cur.Next = head

	prev := head
	for prev.Next != head {
		prev = prev.Next
	}

	var order []int
	for cur.Next != cur {
		for i := 1; i < m; i++ {
			prev = cur
			cur = cur.Next
		}
		order = append(order, cur.No)
		prev.Next = cur.Next
		cur = cur.Next
	}
	order = append(order, cur.No) // 最后幸存者

	return order
}
相关推荐
吃好睡好便好2 小时前
近期几点体会
学习·生活
xlrqx2 小时前
商丘家电清洗培训零基础学习需掌握哪些要点零基础到底能不能学
python·学习
编程圈子3 小时前
电机驱动开发学习19. 霍尔 BLDC 三段式 FOC 启动算法
驱动开发·学习·算法
米码收割机3 小时前
【Python】Django 电子设备商城系统(源码+说明文档)[独一无二]
开发语言·python·django
互联网中的一颗神经元3 小时前
小白python入门 - 38. 动态内容:接口优先与自动化扫盲
开发语言·python·自动化
MartinYeung53 小时前
[论文学习]LLM 中的个性化安全:一个基准测试与基于规划的智能体方法
人工智能·学习·安全
黑客-秋凌3 小时前
使用Python+selenium实现第一个自动化测试脚本
开发语言·自动化测试·软件测试·python·selenium·测试工具
小王师傅663 小时前
英语学习记
java·学习
geovindu4 小时前
CSharp: Iterative Algorithms
开发语言·后端·算法·c#·.net·迭代算法
一只月月鸟呀4 小时前
移动端tap与click的区别 && 点透事件
开发语言·前端·javascript