灶台导航 (六):时间统筹算法——让多道菜同时上桌
大家好,这是我们“灶台导航”项目专栏的第四篇。在分享了数据库设计和 RAG 检索方案后,今天想和大家聊一个非常“接地气”但算法含量不低的问题:如何让多道菜同时上桌?
你是否也有过这样的经历:想给家人做一顿丰盛的晚餐,三菜一汤,结果忙活了两个小时,最后一道菜出锅时,第一道菜已经凉透了。今天要分享的时间统筹算法,就是为了解决这个厨房里的“经典难题”。
一、问题场景:多菜烹饪的挑战
1.1 一个典型的困境
假设我们要同时做 3 道菜:
红烧肉:总耗时 60 分钟(其中大量时间是炖煮等待)
清炒时蔬:总耗时 15 分钟
番茄蛋汤:总耗时 20 分钟
如果按顺序执行(做完一道再做下一道),结果是这样的:
错误安排(顺序执行):
红烧肉 0-60分钟 → 时蔬 60-75分钟 → 蛋汤 75-95分钟
结果:最后一道菜出锅时,第一道已经凉了
而正确的安排应该是这样的:
红烧肉 0-60分钟(主要等待时间在炖煮) 时蔬 45-60分钟(最后15分钟开始) 蛋汤 40-60分钟(提前20分钟开始) 结果:三道菜同时出锅上桌
1.2 算法目标
我们的时间统筹算法要实现以下四个核心目标:
| 目标 | 说明 |
|---|---|
| 同时完成 | 所有菜品尽可能在同一时间上桌 |
| 最少等待 | 减少用户空闲等待时间 |
| 清晰指引 | 明确每个时间点该做什么操作 |
| 容错处理 | 支持烹饪过程中的暂停和调整 |
二、逆向调度思想
2.1 核心原理
这个算法的核心思想很简单:从终点倒推起点。
我们不是从“现在开始做”往后推,而是从“目标完成时间”往前倒推每一步应该什么时候开始。
目标完成时间:12:00(午餐时间)
红烧肉(总时长60分钟):
→ 11:00 开始番茄蛋汤(总时长20分钟):
→ 11:40 开始清炒时蔬(总时长15分钟):
→ 11:45 开始
这种“逆向调度”的思路,确保了所有菜品能在同一时间点完成,而不是一个接一个地出锅。
2.2 步骤类型分析
不同类型的烹饪步骤,在调度中的策略完全不同:
| 步骤类型 | 特点 | 调度策略 |
|---|---|---|
| prepare(准备) | 需要专注操作,如切菜、腌制 | 优先安排,不能并行 |
| cook(烹饪) | 需要专注操作,如翻炒、调味 | 顺序执行,不能并行 |
| wait(等待) | 无需操作,如炖煮、腌制入味 | 可并行,穿插安排其他任务 |
识别出“等待”步骤,是算法的关键。正是这些等待窗口,为我们提供了处理其他菜品的“时间缝隙”。
2.3 并行窗口识别
// 步骤示例 const steps = [ { type: 'prepare', duration: 5 }, // 准备工作 { type: 'cook', duration: 10 }, // 烹饪操作 { type: 'wait', duration: 45 }, // 等待(可并行) { type: 'cook', duration: 5 } // 最后收尾 ] // wait 步骤就是"并行窗口" // 在这个窗口内,用户可以处理其他菜品的步骤三、算法实现
3.1 数据结构
首先定义清晰的数据结构:
// 菜谱步骤结构 interface RecipeStep { order: number; // 步骤序号 content: string; // 步骤内容 duration: number; // 时长(秒) type: 'prepare' | 'cook' | 'wait'; // 步骤类型 } // 调度任务结构 interface ScheduledTask { recipeId: string; recipeName: string; step: RecipeStep; startTime: number; // 相对于计划开始时间的偏移(秒) endTime: number; parallel: boolean; // 是否可并行 }3.2 核心算法
/** * 多菜谱时间统筹算法 */ function calculateSchedule(recipes) { if (!recipes || recipes.length === 0) { return { tasks: [], totalTime: 0 } } // 单菜谱直接返回 if (recipes.length === 1) { return singleRecipeSchedule(recipes[0]) } // 多菜谱统筹计算 return multiRecipeSchedule(recipes) } /** * 多菜谱统筹调度 */ function multiRecipeSchedule(recipes) { // 1. 分析每道菜的步骤和可并行窗口 const recipeAnalysis = recipes.map(recipe => ({ recipe, totalDuration: recipe.steps.reduce((sum, s) => sum + s.duration, 0), waitWindows: findWaitWindows(recipe.steps) })) // 2. 找到最长的菜谱(决定总时长) const longestRecipe = recipeAnalysis.reduce((max, r) => r.totalDuration > max.totalDuration ? r : max ) const totalDuration = longestRecipe.totalDuration // 3. 从完成时间倒推,安排每道菜 const allTasks = [] for (const analysis of recipeAnalysis) { const { recipe, totalDuration: duration } = analysis const startOffset = totalDuration - duration // 倒推开始时间 let currentTime = startOffset for (const step of recipe.steps) { const task = { recipeId: recipe._id, recipeName: recipe.name, step: step, startTime: currentTime, endTime: currentTime + step.duration, parallel: step.type === 'wait' } allTasks.push(task) currentTime += step.duration } } // 4. 检测冲突并调整 const adjustedTasks = resolveConflicts(allTasks) return { tasks: adjustedTasks, totalDuration, recipeCount: recipes.length } } /** * 解决任务冲突 */ function resolveConflicts(tasks) { const exclusiveTasks = tasks.filter(t => !t.parallel) const parallelTasks = tasks.filter(t => t.parallel) // 互斥任务(prepare/cook)不能重叠 const resolved = [] let lastExclusiveEnd = 0 for (const task of exclusiveTasks) { if (task.startTime < lastExclusiveEnd) { // 有冲突,调整开始时间 const delay = lastExclusiveEnd - task.startTime task.startTime += delay task.endTime += delay } resolved.push(task) lastExclusiveEnd = Math.max(lastExclusiveEnd, task.endTime) } // 并行任务(wait)可以重叠 resolved.push(...parallelTasks) // 重新排序 return resolved.sort((a, b) => a.startTime - b.startTime) }3.3 时间轴生成
/** * 生成时间轴展示数据 */ function generateTimeline(schedule) { const { tasks, totalDuration } = schedule const timeline = [] // 按时间点分组 const timePoints = new Set() tasks.forEach(t => { timePoints.add(t.startTime) timePoints.add(t.endTime) }) const sortedTimes = [...timePoints].sort((a, b) => a - b) for (const time of sortedTimes) { const startingTasks = tasks.filter(t => t.startTime === time) const endingTasks = tasks.filter(t => t.endTime === time) if (startingTasks.length > 0 || endingTasks.length > 0) { timeline.push({ time, timeFormatted: formatTime(time), starting: startingTasks.map(t => ({ recipeName: t.recipeName, content: t.step.content, duration: t.step.duration })), ending: endingTasks.map(t => ({ recipeName: t.recipeName, step: t.step.order })), active: tasks.filter(t => t.startTime <= time && t.endTime > time) }) } } return timeline } /** * 格式化时间 */ function formatTime(seconds) { const minutes = Math.floor(seconds / 60) const secs = seconds % 60 if (secs === 0) { return `${minutes}分钟` } return `${minutes}分${secs}秒` }四、云函数实现
// cloudfunctions/cookSchedule/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async (event, context) => { const { action, recipeIds } = event if (action === 'calculate') { return await calculateMultiCookSchedule(recipeIds) } return { errCode: 400, errMsg: '未知操作' } } /** * 计算多菜谱统筹方案 */ async function calculateMultiCookSchedule(recipeIds) { if (!recipeIds || recipeIds.length === 0) { return { errCode: 400, errMsg: '请选择菜谱' } } // 获取所有菜谱详情 const recipes = [] for (const id of recipeIds) { const res = await db.collection('recipes').doc(id).get() if (res.data) { recipes.push(res.data) } } // 计算统筹方案 const schedule = calculateSchedule(recipes) return { errCode: 0, data: { schedule, summary: { totalDuration: schedule.totalDuration, recipeCount: recipes.length, parallelTime: calculateParallelTime(schedule.tasks) } } } }五、实时调整
5.1 动态调整类
/** * 实际烹饪中动态调整 */ class CookingScheduler { constructor(schedule) { this.originalSchedule = schedule this.currentTaskIndex = 0 this.startTime = null this.adjustments = [] } start() { this.startTime = Date.now() } /** * 获取当前应执行的任务 */ getCurrentTasks() { const elapsed = (Date.now() - this.startTime) / 1000 return this.originalSchedule.tasks.filter(task => { return task.startTime <= elapsed && task.endTime > elapsed }) } /** * 暂停(记录调整) */ pause() { const elapsed = (Date.now() - this.startTime) / 1000 this.adjustments.push({ type: 'pause', time: elapsed }) } /** * 继续(调整时间) */ resume() { const pauseDuration = (Date.now() - this.pauseTime) / 1000 // 调整后续任务时间 this.originalSchedule.tasks.forEach(task => { if (task.startTime > this.elapsedBeforePause) { task.startTime += pauseDuration task.endTime += pauseDuration } }) } /** * 跳过某步骤 */ skipStep(recipeId, stepOrder) { // 重新计算后续任务 // ... } }5,2 容错处理
在真实烹饪场景中,时间不可能完全精准。因此我们加入了:
暂停/继续:用户需要临时离开时可以暂停计时
步骤跳过:如果某个步骤提前完成,可以手动触发下一步
时间偏移:所有后续任务会自动重新计算,保持计划同步
六、踩坑记录
问题1:等待窗口识别不准
现象:某些步骤明明不需要操作,但没有被识别为wait类型,导致失去了并行机会。
解决:在菜谱数据结构中明确标注type字段,并在录入数据时严格区分。同时对于“炖煮XX分钟”这类文本,也可以辅助用正则表达式自动识别。
问题2:冲突解决导致总时间延长
现象:当多道菜的prepare步骤同时开始时,冲突解决会让某些任务后移,导致总时间超出预期。
解决:改进算法——让总时间最长的菜谱优先安排,其他菜谱的prepare步骤在其等待窗口内穿插。同时增加了一个优化策略:如果某道菜的prepare步骤可以提前完成(不影响后续等待),就尽量提前。
问题3:时间轴展示信息过载
现象:当菜品较多时(5道以上),时间轴上的信息密密麻麻,用户难以阅读。
解决:引入“折叠”机制——默认只显示当前时间点前后10分钟的任务,用户可以通过滚动或点击展开查看更多。
七、总结
时间统筹算法的核心要点可以总结为以下五点:
| 要点 | 说明 |
|---|---|
| 逆向调度 | 从完成时间倒推开始时间,确保同时出锅 |
| 步骤分类 | 区分 prepare/cook/wait,不同类型不同策略 |
| 并行识别 | wait 步骤是并行窗口,用于穿插其他菜品的任务 |
| 冲突解决 | 互斥任务不能重叠,需要排队执行 |
| 动态调整 | 支持暂停、跳过、时间偏移,适应真实场景 |
通过合理的时间统筹,可以让多道菜同时上桌,大幅提升烹饪效率和用餐体验。这个算法不仅适用于厨房,也适用于任何需要“多任务并行 + 最终同步完成”的场景。
