华为OD机试“机智的外卖员”题解:动态规划与贪心思想的实战应用
1. 项目概述从一道题看华为OD机试的实战思维最近在准备华为OD机试的朋友估计没少被各种算法题“折磨”。今天我们不聊那些天花乱坠的理论就聚焦一道非常经典的题目——“机智的外卖员”。这道题在华为OD的C机试中出现的频率不低它不像纯粹的动态规划那么烧脑也不像单纯的模拟题那么枯燥而是巧妙地结合了动态规划和贪心思想考察的是你能否在有限时间内将实际问题抽象成数学模型并写出高效、健壮的代码。很多朋友第一次看到题目描述可能会有点懵一个外卖员在送餐怎么还扯上“机智”了其实这里的“机智”指的就是寻找最优路径的策略核心是最小化送餐时间或成本。这道题本质上是一个带约束的最短路径问题的变种非常考验候选人的逻辑建模和C编码基本功。如果你正在用VSCode配置C环境刷题或者对着Visual Studio 2022调试代码那么这篇文章就是为你准备的。我会以一个过来人的身份拆解这道题的核心思路、边界条件、代码实现细节以及那些容易踩坑的地方。我们不仅要把题做出来更要理解为什么这么做以及如何在紧张的机考环境下写出让考官眼前一亮的代码。毕竟华为OD机试不光看结果对不对代码的规范性、可读性和鲁棒性同样重要。2. 问题深度解析与建模思路2.1 题目场景还原与需求抽象我们先来还原一下典型的题目描述不同批次可能有细微出入但核心不变外卖员小王在一条笔直的路上送餐这条路可以用一条数轴表示。他的起点在坐标0需要送餐到坐标NN 0的客户家。他有两种移动方式步行每分钟可以向左或向右移动1个单位距离。骑行每分钟可以向左或向右移动K个单位距离K 1但每次骑行需要额外花费T分钟的时间来解锁/锁车可以理解为准备时间。请问小王从0点到达N点最少需要多少分钟关键词提炼数轴、起点0、终点N、步行速度1单位/分钟、骑行速度K单位/分钟、骑行准备时间T分钟、求最小总时间。这描述看似简单但隐藏了几个关键点也是解题的突破口方向性题目只说“向左或向右”但由于终点N0最优策略显然不会主动向左走绕远路。因此我们只需要考虑向右移动的策略。这是一个重要的简化。骑行的代价骑行虽然快但有“准备时间”T。这意味着如果距离很短可能步行反而更快距离长到一定程度骑行的速度优势才能抵消其固定时间成本。决策的连续性外卖员可以在任何点选择开始骑行或结束骑行切换为步行。这引出了我们的核心思路——将整个行程视为在“步行状态”和“骑行状态”之间做选择目标是找到状态切换的最优点。2.2 核心算法思路选择为什么是动态规划面对“最优解”问题我们本能地会想到动态规划DP、贪心或者搜索。我们先分析一下贪心能想到一个贪心策略吗比如“能骑就骑”这显然不对。如果T很大而N很小步行更快。贪心策略在这里不成立。搜索BFS/DFS把每个坐标点看作图的一个节点两种移动方式看作边可以建模成一个最短路径问题用BFS求解。当N很大时状态空间可能爆炸效率不高。动态规划DP这是最自然也是最优雅的解法。我们可以定义dp[i]表示到达坐标i所需的最短时间。那么到达i点的方式有两种从i-1点步行1分钟过来dp[i] dp[i-1] 1从i-K点开始骑行花费T分钟准备然后骑行1分钟到达idp[i] dp[i-K] T 1这里假设i-K 0 取两者的最小值即可dp[i] min(dp[i-1] 1, dp[i-K] T 1)这个DP方程就是本题的灵魂。它完美刻画了“每一步都做出最优选择”的思想。初始化dp[0] 0然后从i1计算到iN最终dp[N]就是答案。注意这里有一个非常重要的细节方程dp[i] dp[i-K] T 1成立的前提是我们假设在i-K点处决定开始骑行并且一路骑到i点。如果骑行中途可以停止呢这个方程还成立吗仔细想想如果允许中途停止那么最优策略一定是在某个点开始骑一直骑到终点或某个更远的点因为停下车再启动又需要时间T这通常是不划算的除非有特殊约束。在标准题目描述下我们通常默认骑行是一次性决策。这一点必须在编码前和审题时确认。2.3 边界条件与特殊Case处理动态规划最怕边界没处理好。针对这个DP方程我们需要考虑几个特殊情况当i K时无法从i-K点骑行过来因为i-K是负数不在考虑范围内。此时dp[i]只能由步行转移而来即dp[i] dp[i-1] 1。在代码中我们需要对i-K 0做判断。K 和 T 的相对大小如果T非常大可能全程步行都是最优的。我们的DP方程能自动处理这种情况因为dp[i-K] T 1这个值会很大min函数自然会选择步行方案。N 可能小于 K这是上一条的一个具体案例。如果N K那么外卖员根本无法享受完整的“骑行1分钟”过程因为从0骑到N距离小于K用时不是1分钟。但我们的DP方程是基于“骑行一分钟移动K距离”这个模型推导的。当N K时方程中的dp[N-K]是负下标无效。因此对于N K的情况答案直接就是N全程步行。这是一个非常重要的特判很多人在此栽跟头。3. C代码实现与逐行精讲理论清晰了我们来看代码。我会提供两个版本的实现一个基础清晰的版本和一个优化后的版本并解释每一行代码的意图和注意事项。3.1 基础DP解法实现#include iostream #include vector #include algorithm #include climits // 用于INT_MAX using namespace std; int main() { int N, K, T; // 题目通常输入 N, K, T cin N K T; // 特判如果终点距离小于骑行速度则只能步行 if (N K) { cout N endl; return 0; } // dp[i] 表示到达位置 i 所需的最短时间 vectorint dp(N 1, INT_MAX); // 初始化为最大值表示不可达 dp[0] 0; // 起点时间为0 for (int i 1; i N; i) { // 方式1从 i-1 步行过来 dp[i] dp[i - 1] 1; // 方式2从 i-K 开始骑行过来 (需要 i-K 0) if (i - K 0) { // 注意dp[i-K] 必须是一个有效的、计算过的状态不是INT_MAX if (dp[i - K] ! INT_MAX) { dp[i] min(dp[i], dp[i - K] T 1); } } } cout dp[N] endl; return 0; }代码精讲与避坑指南头文件与命名空间algorithm用于min函数climits用于INT_MAX。使用using namespace std;在机试中节省时间但在大型工程中不推荐。特判if (N K)如前所述这是保证逻辑正确的关键。没有它当i1而K5时i-K为负数访问dp[-4]会导致未定义行为很可能崩溃。DP数组初始化vectorint dp(N 1, INT_MAX)。大小为N1是为了让下标N对应终点。初始化为INT_MAX代表“暂时无法到达”这是一个经典技巧。状态转移循环for (int i 1; i N; i)。注意是从1到N包含。步行转移dp[i] dp[i - 1] 1;先赋值这是最基础的保障。骑行转移if (i - K 0)是边界检查。if (dp[i - K] ! INT_MAX)这个检查至关重要它确保了转移源状态是有效的。想象一下如果dp[2]是INT_MAX无法到达2那么从dp[2]骑行到dp[7]也是无效的。不加这个判断INT_MAX T 1会导致整数溢出虽然这里T1是正数但INT_MAX任何正数在逻辑上是错误的得到错误结果。输出直接输出dp[N]。这个版本逻辑正确但还有优化空间。3.2 空间优化与逻辑简化版本我们注意到dp[i]只依赖于dp[i-1]和dp[i-K]。当N很大时比如上百万开一个N1大小的数组可能内存吃紧虽然本题通常N不会太大。我们可以用滚动数组的思想但更关键的是我们可以优化掉INT_MAX的判断让逻辑更简洁。优化思路其实我们不需要INT_MAX。因为对于任何i1至少可以通过步行从0一步步走过来所以dp[i]总是有解最大值就是i。因此我们可以直接初始化一个足够大的值或者利用递推关系。#include iostream #include vector #include algorithm using namespace std; int main() { int N, K, T; cin N K T; // 特判 if (N K) { cout N endl; return 0; } vectorint dp(N 1, 0); // 这次初始化为0 // dp[0]已经是0 for (int i 1; i N; i) { // 默认方式步行 dp[i] dp[i - 1] 1; // 尝试骑行 if (i - K 0) { // 关键优化不再判断dp[i-K]是否有效因为一定有效。 // 从 i-K 点开始骑行到 i 点总时间是 dp[i-K] T 1。 // 为什么dp[i-K]一定有效因为i-K 0且我们是从小到大计算的 // dp[i-K]已经在之前的循环中计算过了。 dp[i] min(dp[i], dp[i - K] T 1); } // 对于 i K 的情况上面的if不会执行dp[i]就是步行结果。 } cout dp[N] endl; return 0; }这个版本更简洁也更容易理解。它利用了“步行总能到达”这一性质避免了复杂的有效性判断。这是面试官更希望看到的清晰代码。3.3 复杂度分析与潜在变种时间复杂度O(N)因为只有一个从1到N的循环。空间复杂度O(N)用于存储dp数组。如果使用滚动数组可以优化到O(K)但代码会稍复杂在机试中除非N极大否则不必强求。可能的变种与扩展终点在左侧N0题目可以变更为终点在负坐标。解决方案完全对称可以将坐标平移或者定义dp数组时考虑负索引使用map或偏移数组。骑行速度与准备时间非固定例如骑行速度与剩余电量有关或者准备时间与地点有关。这会增加状态维度可能需要更复杂的DP。求具体路径不仅要求最短时间还要输出一种最优的移动方式序列。这需要在DP时记录前驱状态最后反向回溯。4. 机试实战技巧与调试心得在华为OD的机试环境中比如他们常用的牛客网、OJ平台写代码和平时在VSCode里不太一样。下面分享一些直接相关的实战经验。4.1 环境适应与编码习惯输入输出格式华为OD机试通常是标准的ACM模式即从cin读向cout写。务必不要打印任何多余的提示信息如“请输入”。代码模板通常只包含solve()函数或直接在main里写。仔细看题目示例的输入输出。全局变量与局部变量在main函数内定义变量是安全的。如果使用全局变量务必在每次测试用例前初始化或者直接在main里定义。机试是多个测试用例连续运行不清空全局变量是常见错误。数组大小根据题目给出的数据范围定义数组。例如如果N最大为10^5那么vectorint dp(100010)是安全的。稍微开大一点比如10可以防止边界溢出。绝对不要用int dp[N1]这种变长数组C标准不支持有些编译器扩展支持但不保证OJ环境支持。一律用vector。时间复杂度估算在动手前心里要对算法复杂度有数。O(N)解决N10^6通常没问题O(N^2)可能就超时了。本题的O(N)是线性完全在安全范围内。4.2 调试与自测方法机试环境没有IDE的强力调试器printf/cout调试法是王道。// 在关键位置添加调试输出提交前注释掉或删除 // #define DEBUG #ifdef DEBUG cout [DEBUG] i i , dp[i] dp[i] , from walk: dp[i-1]1; if(i-K0) cout , from ride: dp[i-K]T1; cout endl; #endif自测用例设计不要只相信题目给的例子。自己构造边缘案例最小输入N1, K2, T1。预期输出1因为NK步行。步行更优N5, K10, T100。预期输出5骑行准备时间太长。骑行更优N100, K10, T5。步行需要100分钟。骑行从0开始准备5分钟骑行10分钟到10再准备5分钟不对我们的模型是“在某个点开始骑骑1分钟到另一个点”。更准确的计算是从0骑到100需要骑10次因为每次骑K10距离每次骑行动作花费1分钟但准备时间T只算一次从步行切换到骑行的瞬间。这是对题意的另一种常见理解这里出现了歧义4.3 对题目歧义的理解与代码调整这是本题最大的坑“每次骑行需要额外花费T分钟”中的“每次”如何理解理解A本文之前采用每次执行“骑行1分钟”这个动作都需要花费T分钟准备。那么从0骑到100如果K10需要骑10次总时间 10 * (T 1) 10T 10。理解B更常见每次从“步行状态”切换到“骑行状态”需要花费T分钟。一旦开始骑可以连续骑任意多分钟每次移动K距离直到主动切换回步行。那么从0骑到100可以一直在骑行状态总时间 T ceil(N/K)。ceil是向上取整因为可能最后一段不足K。哪种理解对这需要从题目的示例或描述细节判断。如果题目说“每次骑行需要时间T”倾向理解A如果说“切换到骑行需要时间T”倾向理解B。很多真题的描述更接近理解B。按照理解B修改DP方程dp[i]仍然表示到达i的最短时间。 到达i的方式步行到达dp[i] dp[i-1] 1骑行到达这意味着在之前的某个点j (j i) 切换到了骑行状态然后一路骑到i。那么从j骑到i需要的时间是ceil((i-j)/K)。但j是哪里我们不知道。这需要枚举j复杂度O(N^2)不可取。更优的建模理解B 我们定义两个状态dp_walk[i]: 最后一步是步行到达i的最短时间。dp_ride[i]: 最后一步是骑行到达i的最短时间。状态转移dp_walk[i] min(dp_walk[i-1], dp_ride[i-1]) 1// 无论之前状态如何最后一步步行过来都花1分钟。dp_ride[i]的转移需要考虑最后一步是骑行到达i那么前一步i-K可能是什么状态如果前一步也是骑行状态dp_ride[i-K] 1如果前一步是步行状态那么在i-K点需要切换到骑行dp_walk[i-K] T 1所以dp_ride[i] min(dp_ride[i-K] 1, dp_walk[i-K] T 1)前提是i-K 0。 最终答案min(dp_walk[N], dp_ride[N])按照理解B的最终代码实现#include iostream #include vector #include algorithm #include climits using namespace std; int main() { int N, K, T; cin N K T; // dp_walk[i], dp_ride[i] vectorint walk(N 1, INT_MAX); vectorint ride(N 1, INT_MAX); walk[0] 0; // 起点步行状态时间为0 ride[0] T; // 起点如果直接准备骑行时间为T这里需要斟酌。 // 更合理的初始化ride[0] INT_MAX因为无法从0开始就处于“骑行到达”状态没有移动。 // 或者我们认为在起点可以花费T时间进入骑行状态但此时位置还是0。 // 让我们重新思考状态定义是“到达”i点时的状态。 // 对于i0walk[0]0是合理的。 // ride[0]表示“通过骑行方式到达0点”这没有意义所以初始化为INT_MAX。 ride[0] INT_MAX; for (int i 1; i N; i) { // 计算 walk[i]: 可以从 i-1 点步行或骑行过来但最后一步是步行 int from_walk (walk[i-1] INT_MAX) ? INT_MAX : walk[i-1] 1; int from_ride (ride[i-1] INT_MAX) ? INT_MAX : ride[i-1] 1; walk[i] min(from_walk, from_ride); // 计算 ride[i]: 可以从 i-K 点骑行或步行过来但最后一步是骑行 if (i - K 0) { int continue_ride (ride[i-K] INT_MAX) ? INT_MAX : ride[i-K] 1; int start_ride (walk[i-K] INT_MAX) ? INT_MAX : walk[i-K] T 1; ride[i] min(continue_ride, start_ride); } // 如果 i K则 ride[i] 保持 INT_MAX无法通过骑行直接到达 } int ans min(walk[N], ride[N]); cout ans endl; return 0; }这个双状态DP模型更能精准刻画“切换成本T只发生在状态改变时”的语义也是应对此类“带状态机”的最短路径问题的通用方法。在真正的机试中务必仔细审题明确“每次花费T分钟”的具体含义。如果题目描述模糊可以尝试从样例输入输出反推模型。5. 从解题到面试的延伸思考一道好的机试题解出来只是第一步。在后续的技术面试中面试官可能会围绕你的代码和思路深入提问。5.1 面试官可能追问的问题你的算法时间复杂度/空间复杂度是多少能否优化答时间复杂度O(N)空间复杂度O(N)。空间上可以使用滚动数组优化到O(K)因为dp[i]只依赖于dp[i-1]和dp[i-K]我们只需要维护一个大小为K1的滑动窗口即可。但考虑到N通常不会极大O(N)的空间在机试中是可接受的优化可能增加代码复杂度。如果K非常大比如K N你的代码还能工作吗答可以。我们在开头做了特判if (N K)直接返回N。这保证了代码的鲁棒性。如果没有这个特判在DP循环中i-K永远小于0ride[i]永远无法更新最终答案会是walk[N]也就是N结果也是对的。但显式的特判让逻辑更清晰也避免了任何潜在的边界访问错误。如果道路不是无限的比如有障碍你的思路如何调整答这变成了一个图上的最短路径问题。我们可以把每个坐标点看作图节点步行和骑行看作两种边权重分别为1和T1。如果有障碍的点不能经过那么在构建图时忽略这些点对应的节点和边即可。然后使用Dijkstra算法求最短路。这考察了将问题泛化和抽象到经典模型的能力。为什么选择动态规划贪心算法不行吗答因为这个问题具有“最优子结构”和“重叠子问题”的特性。到达i点的最优时间可以由子问题到达i-1和i-K的最优时间推导出来。贪心策略例如只要骑行节省的时间大于T就骑是局部最优但无法保证全局最优因为骑行的固定成本T会影响后续决策。动态规划通过枚举所有可能的状态转移确保了全局最优解。5.2 代码风格与规范建议在华为OD机试和后续面试中干净的代码能加分不少命名变量使用有意义的英文名如totalTime,dp_walk避免a,b,c。注释对关键步骤、复杂逻辑、边界处理添加简短注释。例如// 特判距离太短无法发挥骑行优势。函数化即使题目简单将核心算法逻辑封装成一个函数如int minDeliveryTime(int N, int K, int T)会显得结构更清晰也便于面试官阅读。错误处理虽然机试输入保证合法但考虑一下非法输入如N, K, T非正数的处理能体现你的严谨性可以在注释中说明。5.3 心理准备与时间分配华为OD机试通常时间紧张2-3小时2-3道题。面对“机智的外卖员”这类中等问题前5-10分钟彻底读懂题目用笔在纸上画图列举简单例子确认对“骑行时间T”的理解无误。这是最重要的一步理解偏差满盘皆输。10-20分钟设计算法写出状态转移方程考虑边界条件。在脑子里或纸上模拟小数据。20-40分钟编码实现并加上关键注释。最后10分钟设计多个测试用例进行验证包括常规用例、最小最大边界、步行更优、骑行更优等情况。确保通过后再提交。这道“机智的外卖员”题很好地融合了基础DP思想、边界处理和实际场景建模。掌握它不仅是为了通过一次机试更是锻炼你解决复杂问题的一种思维模式。在真正的开发工作中这种将模糊的业务需求转化为清晰可计算的模型的能力价值连城。