news 2026/8/2 16:13:26

华为OD机试“机智的外卖员”题解:动态规划与贪心思想的实战应用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
华为OD机试“机智的外卖员”题解:动态规划与贪心思想的实战应用

1. 项目概述:从一道题看华为OD机试的实战思维

最近在准备华为OD机试的朋友,估计没少被各种算法题“折磨”。今天我们不聊那些天花乱坠的理论,就聚焦一道非常经典的题目——“机智的外卖员”。这道题在华为OD的C++机试中出现的频率不低,它不像纯粹的动态规划那么烧脑,也不像单纯的模拟题那么枯燥,而是巧妙地结合了动态规划贪心思想,考察的是你能否在有限时间内,将实际问题抽象成数学模型,并写出高效、健壮的代码。很多朋友第一次看到题目描述可能会有点懵:一个外卖员在送餐,怎么还扯上“机智”了?其实,这里的“机智”指的就是寻找最优路径的策略,核心是最小化送餐时间或成本。这道题本质上是一个带约束的最短路径问题的变种,非常考验候选人的逻辑建模和C++编码基本功。

如果你正在用VSCode配置C++环境刷题,或者对着Visual Studio 2022调试代码,那么这篇文章就是为你准备的。我会以一个过来人的身份,拆解这道题的核心思路、边界条件、代码实现细节以及那些容易踩坑的地方。我们不仅要把题做出来,更要理解为什么这么做,以及如何在紧张的机考环境下,写出让考官眼前一亮的代码。毕竟,华为OD机试不光看结果对不对,代码的规范性、可读性和鲁棒性同样重要。

2. 问题深度解析与建模思路

2.1 题目场景还原与需求抽象

我们先来还原一下典型的题目描述(不同批次可能有细微出入,但核心不变):

外卖员小王在一条笔直的路上送餐,这条路可以用一条数轴表示。他的起点在坐标0,需要送餐到坐标N(N > 0)的客户家。他有两种移动方式:

  1. 步行:每分钟可以向左或向右移动1个单位距离。
  2. 骑行:每分钟可以向左或向右移动K个单位距离(K > 1),但每次骑行需要额外花费T分钟的时间来解锁/锁车(可以理解为准备时间)。

请问,小王从0点到达N点,最少需要多少分钟?

关键词提炼:数轴、起点0、终点N、步行速度1单位/分钟、骑行速度K单位/分钟、骑行准备时间T分钟、求最小总时间。

这描述看似简单,但隐藏了几个关键点,也是解题的突破口:

  1. 方向性:题目只说“向左或向右”,但由于终点N>0,最优策略显然不会主动向左走(绕远路)。因此,我们只需要考虑向右移动的策略。这是一个重要的简化。
  2. 骑行的代价:骑行虽然快,但有“准备时间”T。这意味着,如果距离很短,可能步行反而更快;距离长到一定程度,骑行的速度优势才能抵消其固定时间成本。
  3. 决策的连续性:外卖员可以在任何点选择开始骑行或结束骑行(切换为步行)。这引出了我们的核心思路——将整个行程视为在“步行状态”和“骑行状态”之间做选择,目标是找到状态切换的最优点

2.2 核心算法思路选择:为什么是动态规划?

面对“最优解”问题,我们本能地会想到动态规划(DP)、贪心或者搜索。我们先分析一下:

  • 贪心:能想到一个贪心策略吗?比如“能骑就骑”?这显然不对。如果T很大,而N很小,步行更快。贪心策略在这里不成立。
  • 搜索(BFS/DFS):把每个坐标点看作图的一个节点,两种移动方式看作边,可以建模成一个最短路径问题,用BFS求解。当N很大时,状态空间可能爆炸,效率不高。
  • 动态规划(DP):这是最自然也是最优雅的解法。我们可以定义dp[i]表示到达坐标i所需的最短时间。那么,到达i点的方式有两种:
    1. i-1点步行1分钟过来:dp[i] = dp[i-1] + 1
    2. 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,然后从i=1计算到i=N,最终dp[N]就是答案。

注意:这里有一个非常重要的细节!方程dp[i] = dp[i-K] + T + 1成立的前提是,我们假设在i-K点处决定开始骑行,并且一路骑到i。如果骑行中途可以停止呢?这个方程还成立吗?仔细想想,如果允许中途停止,那么最优策略一定是在某个点开始骑,一直骑到终点或某个更远的点,因为停下车再启动又需要时间T,这通常是不划算的(除非有特殊约束)。在标准题目描述下,我们通常默认骑行是一次性决策。这一点必须在编码前和审题时确认。

2.3 边界条件与特殊Case处理

动态规划最怕边界没处理好。针对这个DP方程,我们需要考虑几个特殊情况:

  1. i < K:无法从i-K点骑行过来(因为i-K是负数,不在考虑范围内)。此时,dp[i]只能由步行转移而来,即dp[i] = dp[i-1] + 1。在代码中,我们需要对i-K >= 0做判断。
  2. K 和 T 的相对大小:如果T非常大,可能全程步行都是最优的。我们的DP方程能自动处理这种情况,因为dp[i-K] + T + 1这个值会很大,min函数自然会选择步行方案。
  3. 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 所需的最短时间 vector<int> 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; }

代码精讲与避坑指南:

  1. 头文件与命名空间<algorithm>用于min函数,<climits>用于INT_MAX。使用using namespace std;在机试中节省时间,但在大型工程中不推荐。
  2. 特判if (N < K):如前所述,这是保证逻辑正确的关键。没有它,当i=1K=5时,i-K为负数,访问dp[-4]会导致未定义行为(很可能崩溃)。
  3. DP数组初始化vector<int> dp(N + 1, INT_MAX)。大小为N+1是为了让下标N对应终点。初始化为INT_MAX代表“暂时无法到达”,这是一个经典技巧。
  4. 状态转移循环for (int i = 1; i <= N; ++i)。注意是从1到N(包含)。
  5. 步行转移dp[i] = dp[i - 1] + 1;先赋值,这是最基础的保障。
  6. 骑行转移if (i - K >= 0)是边界检查。if (dp[i - K] != INT_MAX)这个检查至关重要!它确保了转移源状态是有效的。想象一下,如果dp[2]INT_MAX(无法到达2),那么从dp[2]骑行到dp[7]也是无效的。不加这个判断,INT_MAX + T + 1会导致整数溢出(虽然这里T+1是正数,但INT_MAX+任何正数在逻辑上是错误的),得到错误结果。
  7. 输出:直接输出dp[N]

这个版本逻辑正确,但还有优化空间。

3.2 空间优化与逻辑简化版本

我们注意到,dp[i]只依赖于dp[i-1]dp[i-K]。当N很大时(比如上百万),开一个N+1大小的数组可能内存吃紧(虽然本题通常N不会太大)。我们可以用滚动数组的思想,但更关键的是,我们可以优化掉INT_MAX的判断,让逻辑更简洁。

优化思路:其实我们不需要INT_MAX。因为对于任何i>=1,至少可以通过步行从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; } vector<int> 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极大,否则不必强求。

可能的变种与扩展

  1. 终点在左侧(N<0):题目可以变更为终点在负坐标。解决方案完全对称,可以将坐标平移,或者定义dp数组时考虑负索引(使用map或偏移数组)。
  2. 骑行速度与准备时间非固定:例如,骑行速度与剩余电量有关,或者准备时间与地点有关。这会增加状态维度,可能需要更复杂的DP。
  3. 求具体路径:不仅要求最短时间,还要输出一种最优的移动方式序列。这需要在DP时记录前驱状态,最后反向回溯。

4. 机试实战技巧与调试心得

在华为OD的机试环境中(比如他们常用的牛客网、OJ平台),写代码和平时在VSCode里不太一样。下面分享一些直接相关的实战经验。

4.1 环境适应与编码习惯

  1. 输入输出格式:华为OD机试通常是标准的ACM模式,即从cin读,向cout写。务必不要打印任何多余的提示信息(如“请输入:”)。代码模板通常只包含solve()函数或直接在main里写。仔细看题目示例的输入输出。
  2. 全局变量与局部变量:在main函数内定义变量是安全的。如果使用全局变量,务必在每次测试用例前初始化(或者直接在main里定义)。机试是多个测试用例连续运行,不清空全局变量是常见错误。
  3. 数组大小:根据题目给出的数据范围定义数组。例如,如果N最大为10^5,那么vector<int> dp(100010)是安全的。稍微开大一点(比如+10)可以防止边界溢出。绝对不要int dp[N+1]这种变长数组(C++标准不支持,有些编译器扩展支持,但不保证OJ环境支持)。一律用vector
  4. 时间复杂度估算:在动手前,心里要对算法复杂度有数。O(N)解决N=10^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-K>=0) cout << ", from ride: " << dp[i-K]+T+1; cout << endl; #endif

自测用例设计:不要只相信题目给的例子。自己构造边缘案例:

  • 最小输入N=1, K=2, T=1。预期输出:1(因为N<K,步行)。
  • 步行更优N=5, K=10, T=100。预期输出:5(骑行准备时间太长)。
  • 骑行更优N=100, K=10, T=5。步行需要100分钟。骑行:从0开始,准备5分钟,骑行10分钟到10,再准备5分钟?不对!我们的模型是“在某个点开始骑,骑1分钟到另一个点”。更准确的计算是:从0骑到100,需要骑10次(因为每次骑K=10距离),每次骑行动作花费1分钟,但准备时间T只算一次(从步行切换到骑行的瞬间)。这是对题意的另一种常见理解!这里出现了歧义!

4.3 对题目歧义的理解与代码调整

这是本题最大的坑!“每次骑行需要额外花费T分钟”中的“每次”如何理解?

  • 理解A(本文之前采用):每次执行“骑行1分钟”这个动作,都需要花费T分钟准备。那么从0骑到100,如果K=10,需要骑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的方式:

  1. 步行到达:dp[i] = dp[i-1] + 1
  2. 骑行到达:这意味着在之前的某个点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] vector<int> walk(N + 1, INT_MAX); vector<int> ride(N + 1, INT_MAX); walk[0] = 0; // 起点步行状态,时间为0 ride[0] = T; // 起点如果直接准备骑行,时间为T?这里需要斟酌。 // 更合理的初始化:ride[0] = INT_MAX,因为无法从0开始就处于“骑行到达”状态(没有移动)。 // 或者,我们认为在起点,可以花费T时间进入骑行状态,但此时位置还是0。 // 让我们重新思考:状态定义是“到达”i点时的状态。 // 对于i=0,walk[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 面试官可能追问的问题

  1. 你的算法时间复杂度/空间复杂度是多少?能否优化?

    • :时间复杂度O(N),空间复杂度O(N)。空间上可以使用滚动数组优化到O(K),因为dp[i]只依赖于dp[i-1]dp[i-K],我们只需要维护一个大小为K+1的滑动窗口即可。但考虑到N通常不会极大,O(N)的空间在机试中是可接受的,优化可能增加代码复杂度。
  2. 如果K非常大,比如K > N,你的代码还能工作吗?

    • :可以。我们在开头做了特判if (N < K),直接返回N。这保证了代码的鲁棒性。如果没有这个特判,在DP循环中,i-K永远小于0,ride[i]永远无法更新,最终答案会是walk[N],也就是N,结果也是对的。但显式的特判让逻辑更清晰,也避免了任何潜在的边界访问错误。
  3. 如果道路不是无限的(比如有障碍),你的思路如何调整?

    • :这变成了一个图上的最短路径问题。我们可以把每个坐标点看作图节点,步行和骑行看作两种边(权重分别为1和T+1)。如果有障碍的点不能经过,那么在构建图时忽略这些点对应的节点和边即可。然后使用Dijkstra算法求最短路。这考察了将问题泛化和抽象到经典模型的能力。
  4. 为什么选择动态规划?贪心算法不行吗?

    • :因为这个问题具有“最优子结构”和“重叠子问题”的特性。到达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思想、边界处理和实际场景建模。掌握它,不仅是为了通过一次机试,更是锻炼你解决复杂问题的一种思维模式。在真正的开发工作中,这种将模糊的业务需求转化为清晰可计算的模型的能力,价值连城。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/2 16:12:44

【单片机课程设计/毕业设计】单片机控制的带优先级病床双向呼叫系统设计 基于射频无线传输的病房病患呼叫报警装置开发(020201)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华
网站建设 2026/8/2 16:11:38

Grove OLED 1.12‘ SH1107显示屏:SPI/I2C双模驱动与嵌入式显示实战

1. 项目概述&#xff1a;一块能说会道的“小窗口”如果你玩过Arduino、树莓派或者ESP32这类开发板&#xff0c;大概率见过那种小小的、能显示几行文字或简单图形的屏幕。今天要聊的这块Grove - OLED 显示屏 1.12 (SH1107) V3.0&#xff0c;就是这类屏幕中一个非常经典且实用的型…

作者头像 李华
网站建设 2026/8/2 16:10:43

如何在5分钟内搭建原神私服:KCN-GenshinServer一键GUI服务端终极指南

如何在5分钟内搭建原神私服&#xff1a;KCN-GenshinServer一键GUI服务端终极指南 【免费下载链接】KCN-GenshinServer 基于GC制作的原神一键GUI多功能服务端。 项目地址: https://gitcode.com/gh_mirrors/kc/KCN-GenshinServer 还在为无法自定义原神游戏体验而烦恼吗&am…

作者头像 李华
网站建设 2026/8/2 16:06:12

视频制作:Timeline与Code思维对比及融合实战

大家好&#xff0c;我是专注于技术实战分享的博主。在视频内容创作和技术开发领域&#xff0c;我们常常会遇到两种截然不同的工作流&#xff1a;一种是基于直观的 时间线&#xff08;Timeline&#xff09; 进行非线性编辑&#xff0c;另一种则是通过编写 代码&#xff08;Co…

作者头像 李华
网站建设 2026/8/2 16:05:53

终极Windows优化方案:AtlasOS如何让旧电脑重获新生?

终极Windows优化方案&#xff1a;AtlasOS如何让旧电脑重获新生&#xff1f; 【免费下载链接】Atlas &#x1f680; An open and lightweight modification to Windows, designed to optimize performance, privacy and usability. 项目地址: https://gitcode.com/GitHub_Tren…

作者头像 李华