1. 问题背景与核心挑战
这道算法题描述了一个有趣的场景:我们需要在一个整数数组中找到从起点到终点的最少跳跃次数,跳跃规则包含两种移动方式。第一种是常规的相邻位置移动(i+1或i-1),第二种则是基于质数的特殊传送机制——如果当前位置的数字是质数p,可以瞬间传送到数组中任何能被p整除的数字所在位置。
这种混合移动方式带来了几个关键挑战:
- 质数判断与质因数分解的效率问题,特别是当数组长度和数值范围较大时(题目中n≤100000,nums[i]≤1000000)
- 如何在跳跃过程中合理利用质数传送机制来优化路径
- 确保找到的路径确实是跳跃次数最少的方案
2. 解决方案的整体架构
2.1 预处理阶段:质因数筛法
我们采用改进的埃拉托斯特尼筛法来预处理每个数的质因数。这个预处理的核心优势在于:
- 只需要执行一次全局初始化
- 后续查询任何数的质因数都是O(1)时间复杂度
- 空间复杂度控制在合理范围内(1,000,001个元素的数组)
go复制const mx = 1_000_001
var primeFactors = [mx][]int{}
func init() {
for i := 2; i < mx; i++ {
if primeFactors[i] == nil { // i是质数
for j := i; j < mx; j += i {
primeFactors[j] = append(primeFactors[j], i)
}
}
}
}
2.2 构建质数分组索引
遍历输入数组,为每个质数建立位置索引表。这个步骤的关键点:
- 只处理真正的质数(质因数列表长度为1且大于1的数)
- 使用map[int][]int结构存储,键是质数,值是该质数出现的所有位置索引
- 这种结构为后续的BFS搜索提供了快速查询能力
go复制groups := map[int][]int{}
for i, x := range nums {
if len(primeFactors[x]) == 1 && x > 1 {
groups[x] = append(groups[x], i)
}
}
3. 广度优先搜索(BFS)的实现细节
3.1 反向搜索策略
与传统BFS不同,这里采用从终点向起点反向搜索的策略,这样做有几个优势:
- 只需要一次搜索就能找到起点到终点的最短路径
- 可以更高效地处理质数传送机制
- 当搜索到起点时可以直接返回结果,无需等待队列完全处理
3.2 BFS的核心循环结构
go复制vis := make([]bool, n)
vis[n-1] = true
q := []int{n - 1}
ans := 0
for {
tmp := q
q = nil
for _, i := range tmp {
if i == 0 {
return ans
}
// 处理相邻移动和质数传送...
}
ans++
}
3.3 三种移动方式的处理
3.3.1 向左移动(i-1)
- 检查索引是否有效(≥0)
- 确保目标位置未被访问过
- 标记为已访问并加入队列
3.3.2 向右移动(i+1)
- 检查索引是否有效(<n)
- 确保目标位置未被访问过
- 标记为已访问并加入队列
3.3.3 质数传送
这是最复杂的部分,处理流程:
- 获取当前位置数字的所有质因数
- 对于每个质因数p,查找预建的groups映射
- 将所有能被p整除的位置加入队列
- 关键优化:从groups中删除已处理的质因数,避免重复处理
go复制for _, p := range primeFactors[nums[i]] {
if js, ok := groups[p]; ok {
for _, j := range js {
if !vis[j] {
vis[j] = true
q = append(q, j)
}
}
delete(groups, p)
}
}
4. 复杂度分析与优化证明
4.1 时间复杂度分解
- 预处理阶段:O(M log log M),M=1,000,000(埃氏筛法的标准复杂度)
- 构建groups映射:O(N)
- BFS阶段:每个节点最多被访问一次,每条边最多被尝试一次
- 常规移动:每个节点产生最多2条边
- 质数传送:通过删除操作确保每个质因数对应的节点列表只被整体处理一次
总体时间复杂度:O(M log log M + N)
4.2 空间复杂度分析
- primeFactors数组:O(M)
- groups映射:最坏情况O(N)
- 访问标记数组:O(N)
- BFS队列:O(N)
总体空间复杂度:O(M + N)
5. 关键优化技巧与实现细节
5.1 质数传送的剪枝优化
删除已处理的质因数记录是控制复杂度的关键。这确保了:
- 每个质因数对应的位置列表最多被整体处理一次
- 避免了重复检查和无效的队列插入操作
- 大幅减少了最坏情况下的时间复杂度
5.2 访问标记的巧妙使用
使用vis数组记录已访问节点,不仅防止重复处理,还能:
- 确保首次访问时的路径就是最短路径
- 避免不必要的队列膨胀
- 简化条件判断逻辑
5.3 Go语言实现细节
- 使用切片而非链表实现队列,在Go中通常更高效
- 合理使用多值返回和命名返回值
- 利用Go的初始化函数特性处理全局预处理
- 使用map的删除操作实现高效剪枝
6. 完整代码实现与测试案例
6.1 Go完整实现
go复制package main
import "fmt"
const mx = 1_000_001
var primeFactors = [mx][]int{}
func init() {
for i := 2; i < mx; i++ {
if primeFactors[i] == nil {
for j := i; j < mx; j += i {
primeFactors[j] = append(primeFactors[j], i)
}
}
}
}
func minJumps(nums []int) (ans int) {
n := len(nums)
groups := map[int][]int{}
for i, x := range nums {
if len(primeFactors[x]) == 1 && x > 1 {
groups[x] = append(groups[x], i)
}
}
vis := make([]bool, n)
vis[n-1] = true
q := []int{n - 1}
for {
tmp := q
q = nil
for _, i := range tmp {
if i == 0 {
return
}
if i-1 >= 0 && !vis[i-1] {
vis[i-1] = true
q = append(q, i-1)
}
if i+1 < n && !vis[i+1] {
vis[i+1] = true
q = append(q, i+1)
}
for _, p := range primeFactors[nums[i]] {
if js, ok := groups[p]; ok {
for _, j := range js {
if !vis[j] {
vis[j] = true
q = append(q, j)
}
}
delete(groups, p)
}
}
}
ans++
}
}
func main() {
testCases := []struct {
nums []int
want int
}{
{[]int{1, 2, 4, 6}, 2},
{[]int{7, 7, 2, 13, 14, 17, 19}, 2},
{[]int{1, 3, 5, 7, 9, 11, 13, 15}, 4},
}
for _, tc := range testCases {
got := minJumps(tc.nums)
if got != tc.want {
fmt.Printf("nums=%v, want=%d, got=%d\n", tc.nums, tc.want, got)
} else {
fmt.Printf("PASS: nums=%v, steps=%d\n", tc.nums, got)
}
}
}
6.2 测试案例设计要点
- 基础案例:验证基本功能(如示例中的[1,2,4,6])
- 连续质数:测试质数传送的连锁反应
- 大质数间隔:验证长距离传送的有效性
- 全非质数:退化到常规BFS的情况
- 大规模数据:验证算法效率(需调整mx常量)
7. 扩展思考与变种问题
7.1 问题变种
- 如果允许在非质数位置也进行某种传送,算法如何调整?
- 如果传送代价与跳跃代价不同,如何计算最小总代价?
- 如果数组变成二维矩阵,算法该如何扩展?
7.2 性能优化方向
- 并行化预处理阶段
- 使用更紧凑的数据结构存储质因数
- 针对特定数值范围优化筛法实现
7.3 实际应用场景
- 网络路由中的最优路径选择
- 游戏地图中的传送点路径规划
- 物流配送中的多模式运输优化
这个算法问题的解决展示了如何将数论知识与图搜索算法巧妙结合,通过合理的预处理和剪枝策略,将看似复杂的问题转化为高效可解的方案。其中反向BFS和质数分组索引的设计尤其值得借鉴,可以应用于其他需要处理特殊移动规则的路径搜索问题。
