本文分享「灶台导航」小程序中多菜谱同时烹饪的核心实现,重点讲解 calculateSchedule 统筹算法。代码和讲解各占一半,帮你真正理解背后的设计思路。


一、多菜谱烹饪场景与核心挑战

1.1 场景描述

用户选择多道菜同时烹饪,系统自动计算统筹方案,指导用户有序完成所有菜品。

选择菜谱:红烧肉 + 清炒时蔬 + 番茄蛋汤
    │
    ▼
计算统筹方案
    │
    ▼
时间轴展示:
  00:00 红烧肉:准备食材
  00:05 红烧肉:焯水
  00:10 红烧肉:炒糖色
  ...
  00:40 番茄蛋汤:开始准备
  00:45 清炒时蔬:开始准备
  01:00 全部完成!

1.2 核心挑战

挑战 解决方案
时间冲突 统筹算法合理安排
进度同步 全局计时器
用户偏离 动态调整机制
提醒通知 关键节点播报

二、数据结构设计

2.1 核心数据结构

下面的数据结构是整个统筹方案的基础。关键字段我已经加了注释说明:

// 统筹方案结构
interface CookingSchedule {
  totalDuration: number;      // 总时长(秒),从开始到最后一道菜完成
  recipes: RecipeSchedule[]; // 每道菜的详细安排
  timeline: TimelineEvent[];  // 时间轴事件,方便UI渲染
  parallelWindows: ParallelWindow[]; // 并行窗口,提示用户可同时做的事
}

interface RecipeSchedule {
  recipeId: string;
  recipeName: string;
  totalDuration: number;      // 这道菜的总时长
  startOffset: number;        // 相对于全局开始时间的偏移(秒)
  steps: ScheduledStep[];     // 这道菜的步骤列表
}

interface ScheduledStep {
  order: number;              // 步骤序号
  content: string;            // 步骤内容,如"焯水"
  duration: number;           // 步骤耗时(秒)
  type: string;               // 类型:'cook'主动操作 或 'wait'等待
  globalStartTime: number;    // 全局开始时间(秒)
  globalEndTime: number;      // 全局结束时间(秒)
}
 

2.2 设计要点说明

为什么要区分 type?

  • cook 类型:需要占用灶台/锅具,不能和别的 cook 任务同时进行

  • wait 类型:只是等待(如炖煮),期间用户可以去做别的事

为什么要算两层时间(本地时间+全局时间)?

  • 本地时间:相对于这道菜自己的开始时间,便于分析单菜结构

  • 全局时间:所有菜统一的时间轴,用于判断冲突和渲染UI


三、核心算法实现

3.1 入口函数 calculateSchedule

这个函数是整个算法的入口,处理单菜谱和多菜谱两种情况:

/** * 多菜谱统筹计算 * 思路:先分析每道菜的结构,找出最长的菜作为基准, *       其他菜从后往前安排,最后解决时间冲突 */
function calculateSchedule(recipes) {
  if (!recipes || recipes.length === 0) {
    return null
  }
  // 单菜谱情况:无需统筹,直接返回
  if (recipes.length === 1) {
    return singleRecipeSchedule(recipes[0])
  }
  // 多菜谱统筹
  const schedules = recipes.map(recipe => analyzeRecipe(recipe))
  // 找出耗时最长的菜,它的时长决定了总时长
  const maxDuration = Math.max(...schedules.map(s => s.totalDuration))
  // 为每道菜计算开始偏移(从完成时间倒推)
  schedules.forEach(schedule => {
    schedule.startOffset = maxDuration - schedule.totalDuration
    // 调整每个步骤的全局时间
    schedule.steps.forEach(step => {
      step.globalStartTime = schedule.startOffset + step.localStartTime
      step.globalEndTime = schedule.startOffset + step.localEndTime
    })
  })
  // 生成时间轴
  const timeline = generateTimeline(schedules)
  // 检测并行窗口
  const parallelWindows = detectParallelWindows(schedules)
  // 解决冲突
  const resolved = resolveConflicts(schedules)
  return {
    totalDuration: maxDuration,
    recipes: resolved,
    timeline,
    parallelWindows,
    createdAt: Date.now()
  }
}
 

算法核心思想——逆向调度:

不用从前往后推(那样会导致串行,总时长 = 所有菜时长之和),而是从后往前推:

  1. 找出耗时最长的菜,它的结束时间就是全局结束时间

  2. 其他菜的结束时间与它对齐,往前推算开始时间

  3. 这样所有菜同时完成,总时长 ≈ 最长那道菜的时长

3.2 分析单道菜谱

把一道菜的步骤展开,计算出每个步骤的本地时间区间:

举个例子:红烧肉步骤分析结果

步骤 耗时 本地开始 本地结束
准备食材 300秒 0 300
焯水 300秒 300 600
炒糖色 300秒 600 900
炖煮(wait) 1800秒 900 2700

总时长 = 2700秒(45分钟)

3.3 生成时间轴

把所有步骤的开始/结束事件汇总,按时间排序:

/** * 生成时间轴 * 把每道菜每个步骤的开始和结束都变成独立事件 */
function generateTimeline(schedules) {
  const events = []
  schedules.forEach(schedule => {
    schedule.steps.forEach(step => {
      // 步骤开始事件
      events.push({
        time: step.globalStartTime,
        type: 'start',
        recipeId: schedule.recipeId,
        recipeName: schedule.recipeName,
        step: step
      })
      // 步骤结束事件
      events.push({
        time: step.globalEndTime,
        type: 'end',
        recipeId: schedule.recipeId,
        recipeName: schedule.recipeName,
        step: step
      })
    })
  })
  // 按时间排序,形成时间轴
  events.sort((a, b) => a.time - b.time)
  return events
}
 

时间轴的作用:UI可以按时间顺序渲染所有事件,方便用户预览整个烹饪流程。也用于提醒管理器判断什么时候该提醒。

3.4 检测并行窗口

找出可以同时做事的空闲时间段:

/** * 检测并行窗口 * 找出所有 wait 类型步骤的时间重叠区间 * 这些时间段里,用户可以同时处理多件事 */
function detectParallelWindows(schedules) {  
  const windows = []  
  // 找出所有 wait 类型步骤(等待期间可以去做别的事)  
  const waitSteps = []  
  schedules.forEach(schedule => {    
    schedule.steps      
      .filter(s => s.type === 'wait')      
      .forEach(step => {        
        waitSteps.push({          
          ...step,          
          recipeId: schedule.recipeId,          
          recipeName: schedule.recipeName        
        })      
      })  
  })  
  // 检查这些 wait 步骤是否有时间重叠  
  for (let i = 0; i < waitSteps.length; i++) {    
    for (let j = i + 1; j < waitSteps.length; j++) {      
      const a = waitSteps[i]      
      const b = waitSteps[j]      
      // 判断两个时间段是否重叠      
      if (a.globalStartTime < b.globalEndTime && b.globalStartTime < a.globalEndTime) {        
        const overlap = {          
          start: Math.max(a.globalStartTime, b.globalStartTime),          
          end: Math.min(a.globalEndTime, b.globalEndTime),          
          recipes: [a.recipeName, b.recipeName]        
        }        
        windows.push(overlap)      
      }    
    }  
  }  
  return windows
}
 

并行窗口的价值:告诉用户"现在红烧肉在炖,你可以同时去准备番茄蛋汤",提升效率。

3.5 解决冲突(核心难点)

互斥任务不能同时执行,遇到重叠就往后推:

/** * 解决冲突(互斥任务不能同时执行) * 互斥任务:type !== 'wait' 的步骤,如炒菜、切菜等需要主动操作的任务 */
function resolveConflicts(schedules) {
  // 收集所有非 wait 步骤(互斥任务)
  const exclusiveSteps = []
  schedules.forEach(schedule => {
    schedule.steps
      .filter(s => s.type !== 'wait')
      .forEach(step => {
        exclusiveSteps.push({
          ...step,
          recipeId: schedule.recipeId
        })
      })
  })

  // 按开始时间排序
  exclusiveSteps.sort((a, b) => a.globalStartTime - b.globalStartTime)

  // 检测冲突并调整
  let lastEndTime = 0
  exclusiveSteps.forEach(step => {
    if (step.globalStartTime < lastEndTime) {
      // 发现冲突:当前任务开始时间 < 上一个任务的结束时间
      const delay = lastEndTime - step.globalStartTime  // 需要推迟的时长
      step.globalStartTime += delay
      step.globalEndTime += delay
      // 同步更新原 schedule 中的步骤
      const schedule = schedules.find(s => s.recipeId === step.recipeId)
      const originalStep = schedule.steps.find(s => s.order === step.order)
      originalStep.globalStartTime = step.globalStartTime
      originalStep.globalEndTime = step.globalEndTime
    }
    lastEndTime = Math.max(lastEndTime, step.globalEndTime)
  })

  return schedules
}
 

冲突解决示例

假设红烧肉炒糖色需要10:00-10:10,清炒时蔬炒制原本安排在10:05-10:15:

冲突前:
红烧肉: [10:00────10:10]
清炒:      [10:05────10:15]  ← 重叠了5分钟

检测到冲突,推迟清炒:
红烧肉: [10:00────10:10]
清炒:            [10:10────10:20]  ← 错开,冲突解决

四、总结

要点 核心原理 代码对应函数
逆向调度 从完成时间倒推,让最长菜最先开始 calculateSchedule
并行优化 利用 wait 窗口插入其他任务 detectParallelWindows
冲突解决 互斥任务按顺序排开,重叠时自动推后 resolveConflicts
数据结构 startOffset 区分开始时间,type 区分任务性质 analyzeRecipe

通过这套算法,原本需要串行执行的多道菜可以并行进行,总时长接近耗时最长的单道菜,大幅提升烹饪效率。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐