news 2026/10/1 18:53:55

飞机降落问题:回溯算法、窗口判断与边界调试全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
飞机降落问题:回溯算法、窗口判断与边界调试全解析

如果你也正被这个“飞机降落问题”卡住,提交结果只显示两项案例通过,先别急着怀疑人生。这道题我当年也栽过:本地样例怎么跑怎么对,逻辑读一遍没毛病,可提交后就是过不了几个测试点。后来花了一晚上逐层打日志,才发现问题根本不在“会不会回溯”,而在几处特别容易忽略的时间边界。这篇文章把这道题的完整解法和排查思路写下来,希望能帮同样卡住的人少走点弯路。

1. 别急着改代码,先确认你理解的是哪个“飞机降落问题”

1.1 题面在说什么:一个机场调度模型

飞机降落问题的题面通常长这样:有 n 架飞机,第 i 架有一个到达时间 t,一个可盘旋时间 d,一个降落占用跑道时间 l。飞机可以在到达后的任意时刻开始降落,但不能晚于 t+d,而且开始降落的时间不能早于 t。跑道同一时间只能服务一架飞机。问是否存在一个降落顺序,让所有飞机都能成功落地。

很多人把“可盘旋时间”理解成“可以一直等到 d 分钟、然后在任意时刻降落完成”,这是第一个误区。盘旋时间的真正含义是:飞机最多只能在空中多等 d 分钟,所以它最晚的开始降落时间是 t+d,而不是说降落过程必须在 t+d 前结束。

放在只有一个工位的汽车维修铺里理解会更直观:每辆车有一个最早到店时间 t,顾客最多愿意等到 t+d,维修要占工位 l 分钟。老板能不能把这批顾客全服务完?问题抽象后,唯一要决策的就是每个顾客(飞机)的进店顺序。飞机晚到没关系,只要在顾客离开前开始维修就行。

1.2 代码里最容易错的三个“窗口边界”

先说第一架飞机的处理。很多人的回溯函数里,代表“跑道空闲时间”的变量 last 初始化为 0,这本身没错,但第一架飞机如果到达时间很晚,比如 t=100,跑道从 0 开始就空着,飞机实际能开始降落的时间是 100 而不是 0。所以下一架飞机的开始时间必须写成 max(last, t[i]),而不是直接用 last 或 t[i]。一旦你写成了 last + l[i],就会把第一架飞机“凭空提前”,后面的状态全是错的。

再说窗口判断。合法条件应该是:

long long start = max(last, t[i]); if (start > t[i] + d[i]) continue; // 不合法,错过最晚开始时间

注意这里比较的是开始时间 start,不是完成时间。我给你一个反例:t=0, d=5, l=6。如果写成了last + l[i] > t[i] + d[i]来判断,last=0 时 0+6=6 > 5,会判成不合法,但实际上飞机在 0 时刻开始降落,6 时刻完成,完全在允许范围内。这个错法非常隐蔽,因为样例里很少给这种“窗口很短但降落很长”的数据。

第三个坑是数据溢出。t 和 d 的范围如果达到 1e9 量级,t+d 用 int 存会溢出成负数。一旦溢出,你的start > t[i] + d[i]判断就会乱掉,可能永远为 true,也可能永远为 false。这类题提交时经常有超大边界数据,所以时间相关变量一律用 long long,不要心存侥幸。

1.3 回溯代码里的变量语义要统一

我在调试这道题时最大的体会是:变量名一定要能准确表达语义。我常用的三个变量是:

// runwayFree: 跑道上一架飞机完成、重新空闲的时刻 // start: 当前飞机实际开始降落时刻 // finish: 当前飞机完成降落的时刻 // start = max(runwayFree, t[i]) // 合法条件: start <= t[i] + d[i] // finish = start + l[i]

写回溯时,递归函数里只需要传两个信息:已经安排了多少架飞机 pos,以及当前跑道最早空闲时间 runwayFree。下一层递归调用时传入 finish,也就是 start + l[i]。这样整个搜索的状态转移是确定的,不会因为变量混用而出现“这次用 last,下次用 finish”的错乱。

把三个变量的语义写在注释里,调 bug 的速度能快一半。很多人的代码只过两个案例,就是因为在递归里把“开始时间”和“结束时间”混着用,样例碰巧能跑通,一上边界数据就露馅。

2. 为什么你的代码只能过两项案例:四个高频雷区

2.1 状态回溯不完整:篡改现场却忘了恢复

回溯算法的核心是“尝试-递归-撤销”。最常见的错误是:递归返回 false 后,忘了把 vis[i] 重新置为 false。看下面这段有问题的代码:

bool dfs(int pos, long long last) { if (pos == n) return true; for (int i = 0; i < n; i++) { if (!vis[i]) { long long start = max(last, t[i]); if (start > t[i] + d[i]) continue; vis[i] = true; if (dfs(pos + 1, start + l[i])) return true; // 这里忘写 vis[i] = false; } } return false; }

这段代码在单组样例下可能表现正常,因为第一个分支如果恰好成功,整个程序就结束了,根本不需要撤销。但一旦第一组数据需要回溯,或者有多组测试样例,vis 数组就会被上一次搜索污染,导致后面的分支认为所有飞机都已经降落了,直接返回 false。

正确的写法是:只要 dfs 返回 false,就必须在本层撤销选择。注意,如果 dfs 返回 true 就不需要撤销,因为程序已经找到答案并开始返回了。这个细节背下来容易,但调试时真的很致命。

另外,多组样例输入时,每组的 vis 数组一定要重置。很多人只重置了 n,忘了重置 vis,导致第二组样例一开始就有飞机被标记为“已降落”,结果当然只能过第一个样例。

2.2 对“最晚开始时间”的错误理解

我见过三种典型的错误理解,每种都能让你的代码只过部分用例。

第一种是把 t+d 当成“降落完成的最晚时间”。假设 t=0, d=5, l=6,飞机在 0 时刻开始降落,6 时刻完成。如果代码用last + l[i] > t[i] + d[i]做判断,0+6 > 5,直接判不合法。但事实上这架飞机完全来得及。所以判断条件必须针对开始时间,而不是完成时间。

第二种是忽略了飞机到达时间的约束。有些简化写法只判断last > t[i] + d[i],然后认为只要跑道足够早空闲就能安排。可如果 t[i] 本身很大,比如 last=0, t=100, d=0,跑道确实空闲,但飞机要等到 100 才能开始降落,而它最晚开始时间也是 100,表面上看 last=0 没有超过 100,似乎合法。实际上用 max(0,100)=100 来判断,100 <= 100,恰好合法。但如果你把 start 写成了 max(last, 0) 或者直接用 last,就会错误地认为这架飞机可以在 0 时刻开始,导致后续调度全部提前。必须用 max(last, t[i]) 来算 start。

第三种是觉得飞机必须按照到达时间排序,于是先按 t 排序,再线性扫描判断。这个思路看似有道理,但忽略了“晚到的飞机可以先占用跑道”的可能性。比如飞机 A 到达时间为 0、盘旋 10 分钟、降落耗时 10 分钟;飞机 B 到达时间为 5、盘旋 0 分钟、降落耗时 1 分钟。如果按到达时间先安排 A,A 从 0 到 10 占着跑道,B 只能在 10 开始,但 B 最晚开始时间是 5,所以线性判断会误判为不可能。其实 B 先降落后 A 再降落才可行。这就说明,这道题必须搜索所有排列顺序,任何“先排序再贪心”的思路都可能出错。

2.3 long long 与输入解析:阴沟里翻船

这类数据结构的题,输入量通常不大,但数值范围可能很大。我之前就因为 int 溢出吃过亏:t=1e9, d=1e9,t+d=2e9,int 最大值是 2147483647,按说没有溢出,但如果 t 和 d 都取 1e9 稍微多一点就会超。更稳妥的做法是全程使用 long long,并且读入时直接读到 long long 变量里。

还有一个小细节:如果你混用 cin 和 scanf,又关闭了同步流,可能会导致读入顺序错乱。n 比较小的时候,老老实实用 cin 就行,或者统一用 scanf/printf。我见过有人在循环里先 scanf 读 n,再用 cin 读三个数,关掉 ios::sync_with_stdio(false) 之后,第二组数据直接读不出来,提交时只过前两个样例就超时或读入失败。

2.4 只跑一遍样例就提交,遗漏隐藏边界

很多人拿到题,样例能过就立刻提交,然后盯着“两项案例通过”发呆。要知道,样例通常只给最普通的场景,隐藏测试点会覆盖这些要命的情况:

  • 飞机数量为 1,而且 d 为 0。
  • 所有飞机的降落窗口完全错开,可以无缝衔接。
  • 有一架飞机窗口非常短,必须在最后安排。
  • 某一架飞机到达时间特别晚,导致前面所有飞机都要等它。

这些边界其实都可以在本地提前构造验证。别急着提交,先花五分钟把自己能想到的极端情况写成测试用例,跑一遍再交。这一个习惯能让你的通过率立刻提升一大截。

3. 一个能稳定 AC 的写法:回溯框架与可选优化

3.1 标准回溯版:数据量小的时候直接过

这类题通常数据范围给得很保守,n 不超过 10,10! 的排列也就 3628800 种,回溯加上剪枝完全够用。下面是我推荐的标准写法,直接抄就能用:

#include <bits/stdc++.h> using namespace std; typedef long long ll; int n; ll t[15], d[15], l[15]; bool vis[15]; bool dfs(int pos, ll runwayFree) { if (pos == n) return true; // 所有飞机都已安排完 for (int i = 0; i < n; i++) { if (vis[i]) continue; ll start = max(runwayFree, t[i]); // 跑道空闲时间和飞机到达时间取较晚 if (start > t[i] + d[i]) continue; // 错过了最晚开始时间,直接剪掉 vis[i] = true; if (dfs(pos + 1, start + l[i])) return true; vis[i] = false; // 回溯,撤销选择 } return false; } int main() { int T; cin >> T; while (T--) { cin >> n; for (int i = 0; i < n; i++) { cin >> t[i] >> d[i] >> l[i]; } memset(vis, 0, sizeof vis); if (dfs(0, 0)) cout << "YES\n"; else cout << "NO\n"; } return 0; }

这个代码的核心就两行:一行算 start,一行判断 start 是否在窗口内。看到max(runwayFree, t[i])千万不要简化成runwayFree,因为飞机晚到时,跑道早早就空出来了,但飞机还没到,实际开始时间必须等飞机到达。同样,不能简化成t[i],因为跑道可能还忙着,必须等上一架完成。

3.2 三个减少无效分支的实用技巧

如果你的数据范围稍微大一点,n 到 15 左右,纯回溯可能有点吃力,可以尝试下面三个技巧。先说最简单的:跳过完全重复的飞机。如果两架飞机的 t、d、l 完全相同,在当前状态下,先尝试哪一架效果一样。如果第一架尝试失败,第二架也没必要再试。实现方式可以先把飞机按参数排序,然后在循环里加一个判断:如果当前飞机和上一架参数完全相同,并且上一架还没被访问,说明上一架已经尝试过且失败,直接 continue。

第二个技巧是状态压缩 DP。n 不超过 20 时,可以用dp[mask]表示已经安排完 mask 集合内所有飞机后,跑道最快的空闲时间。由于跑道空闲时间越早越有利于后续安排,所以每个集合只保留最小的完成时间即可。转移时枚举下一个要安排的飞机 i,判断它能否在窗口内开始降落,然后更新新状态:

ll dp[1 << 20]; dp[0] = 0; for (int mask = 0; mask < (1 << n); mask++) { if (dp[mask] >= INF) continue; for (int i = 0; i < n; i++) { if (mask & (1 << i)) continue; ll start = max(dp[mask], t[i]); if (start > t[i] + d[i]) continue; dp[mask | (1 << i)] = min(dp[mask | (1 << i)], start + l[i]); } }

这个 DP 比回溯稳得多,因为它天然避免了重复状态的搜索,而且转移条件极其清晰。如果你只是在做练习,建议两种写法都写一遍,对理解“状态压缩”和“回溯剪枝”的区别非常有帮助。

第三个技巧是搜索顺序优化。在回溯的每一层,优先尝试当前 start 最小的飞机,可以更快地找到可行解,或者在找不到解时更快地触发剪枝。但这只是一个启发式优化,不保证一定比固定顺序快,也不建议在没搞懂回溯原理之前去依赖它。对于 n≤10,完全没必要。

3.3 构造自己的边界测试轮

我最推荐的做法是,在提交之前先构造一组边界用例,确保代码能正确响应。下面这些用例基本覆盖了常见的隐藏测试点:

测试用例预期结果说明
n=1, t=0, d=0, l=10YES单架飞机且不允许盘旋
n=1, t=5, d=3, l=10YES开始时间为5,完成15,完全合法
n=2, 飞机A: 0 0 10, 飞机B: 5 10 1YES必须安排A先降落,B可以等
n=2, 飞机A: 0 0 10, 飞机B: 5 0 1NOB窗口很短但A占着跑道,反之A等不了
n=3, 三架飞机窗口依次错开:0 0 1, 1 0 1, 2 0 1YES标准流水线
n=3, 三架飞机都在同一时间到达视降落时间而定检查所有排列

构造用例时,我习惯用一个简单的原则:每架飞机都设计一个“窗口结束前最后一刻才开始降落”的场景,看看代码会不会误判。比如 t=0, d=5, l=6 这种用例,如果代码用完成时间做判断,一定出错。把这些用例在本地跑一遍,再提交,基本就能避开“样例过了但隐藏用例挂掉”的尴尬。

4. 现场排查实录与调试心得

4.1 用打印回溯现场找坏点

调这类题,最有效的方法是在 dfs 函数里加打印,观察每次尝试的状态。我已经养成习惯:在进入循环前打印当前 pos 和 runwayFree,在每次选中一架飞机后打印“尝试第 i 架,start=xxx,finish=xxx”。日志长这样:

进入 dfs, pos=0, runwayFree=0 尝试第0架, t=0, d=5, l=6, start=0, finish=6 进入 dfs, pos=1, runwayFree=6 尝试第1架, t=5, d=0, l=1, start=6, finish=7

如果发现某一步 start 明显小于 t[i],说明 max 写漏了;如果某一步明明 start > t[i]+d[i] 但代码还是继续递归,说明判断条件写错了。打印日志之后,错误点几乎一眼就能看出来。调试完记得把打印注释掉,否则大量输出会导致超时。

4.2 我的两个阴间 Bug 复盘

我第一次做这道题,只过了两个案例,查了一晚上才发现两个问题。

第一个是把转移公式写成了start = max(runwayFree, t[i]) + l[i],然后在递归里传入了 start,这就把“开始时间”和“完成时间”混在了一起。逻辑上下一架飞机的跑道空闲时间应该是上一架的完成时间,而我把它写成了当前飞机的开始时间。结果所有后续窗口都被提前,样例又恰好没有触发这种错误,提交就只过了一项。这个教训告诉我:变量的命名必须和实际含义一一对应,不能为了省变量名就随便复用。

第二个更蠢:判断条件写成了if (runwayFree > t[i] + d[i]) continue;,漏掉了 max。当跑道空闲时间远早于飞机到达时间时,runwayFree 看起来没有超过最晚开始时间,但飞机其实已经错过了自己的窗口。比如跑道 0 时刻就空了,飞机 100 时刻才到,d=0,跑道空闲时间确实不大于 100,但这架飞机在 100 时刻才到达,而最晚开始时间也是 100,虽然能赶上,但如果 d<0 会错。这个坑的教训是:判断窗口时,必须比较“实际开始时间”和“最晚开始时间”,而实际开始时间是跑道空闲时间和到达时间的较大者。

4.3 交之前先做的三件事

根据我的经验,提交前做下面三件事,能大幅提高通过率:

第一,写一个全排列暴力版本,和数据量不大时的回溯版本对拍。暴力版本直接枚举所有排列顺序,检查是否可行。随机生成几百组数据,只要两个版本结果不一致,就说明回溯代码有隐蔽逻辑错误。对拍是排查回溯问题最可靠的方法,没有之一。

第二,检查所有用于时间计算的变量类型。把所有 t、d、l、start、finish 全部定义为 long long,绝对不要用 int。就算题目说数据范围小,也建议用 long long,因为代码以后复用起来更安全。

第三,确认多组样例之间的状态清理。重点看 vis 数组是否每轮都重置,全局变量 n 是否被覆盖,以及读入操作是否在每组样例开始前正确执行。这三类错误在所有回溯题里都是高频事故,飞机降落问题也不例外。

最后分享一点个人经验

我后来重新审视这道题,发现它考的不是“会不会写递归”,而是“能不能把一个动态过程里的每个时间点都搞清楚”。机场调度模型其实非常贴近现实:你既要尊重飞机到达时间,又要考虑跑道占用,还要理解每架飞机愿意等待的极限。把这三个要素拆开,按照“实际开始时间 = max(跑道空闲, 飞机到达)”这个公式一步步推,代码自然就对了。

如果你现在还卡在“只能通过两项案例”,先把判断条件和转移公式抄在纸上逐行模拟一遍,再打开代码对比。大部分问题都出在那一两个max和+ l[i]上。改完之后,记得把边界测试轮跑一遍,再提交。这道题值得你多花一点时间,因为回溯状态恢复和窗口判断的技巧,在别的调度类题目里还会反复出现。

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

Java课程设计人事管理系统:JDBC连接MySQL与Swing界面开发实战

简介&#xff1a;一套基于Java Swing与MySQL数据库的课程设计人事管理系统完整源码包&#xff0c;专门面向需要完成数据库课程设计或练习JDBC开发的Java初学者。系统自带图形化操作界面&#xff0c;通过JDBC实现与MySQL的交互&#xff0c;覆盖人事管理中的员工信息维护、考勤记…

作者头像 李华
网站建设 2026/10/1 18:52:08

综合能源系统P2G-CCS-CHP联合建模与Matlab优化调度

这个题目我拿到手的时候&#xff0c;第一反应是&#xff1a;终于有人把这三个东西放在一个框架里聊了。综合能源系统、电转气&#xff08;Power to Gas&#xff0c;P2G&#xff09;、碳捕集系统&#xff08;CCS&#xff09;&#xff0c;再加一个热电联产&#xff08;CHP&#x…

作者头像 李华
网站建设 2026/10/1 18:52:08

数据仓库ETL全链路实战:工具选型、增量拉链与幂等治理

很多人对数据仓库的理解停留在"建几张表、写几条SQL"的层面&#xff0c;直到真正接手一条从业务库到报表的完整链路&#xff0c;才发现最耗时间的从来不是建模&#xff0c;而是中间那段 ETL。数据仓库的成败&#xff0c;八成取决于 ETL 这一环做得稳不稳&#xff1a;…

作者头像 李华
网站建设 2026/10/1 18:51:33

因果图实战:从需求拆解到判定表生成的测试设计方法

1. 为什么因果图不是“画个图就完事”的花架子&#xff1f; 在功能测试现场&#xff0c;我见过太多人把因果图当成PPT里的装饰性流程图——画几个圆圈代表输入&#xff0c;几条线连到输出框&#xff0c;再配个“已覆盖所有组合”的结论&#xff0c;就直接进测试用例文档了。结果…

作者头像 李华
网站建设 2026/10/1 18:51:26

Caddy自动HTTPS原理与生产部署实战指南

1. 为什么今天还要认真学 Caddy&#xff1f;——从“自动 HTTPS”这个被忽略的细节说起我第一次在生产环境里用 Caddy&#xff0c;不是因为听说它多酷&#xff0c;而是被 Nginx 的 SSL 配置搞到凌晨三点。当时要上线一个内部工具&#xff0c;域名已备案&#xff0c;证书也买了&…

作者头像 李华
网站建设 2026/10/1 18:51:26

AI+CAD工程化落地实战:从Demo到交付的最后一公里

1. 从Demo到工程&#xff1a;AICAD落地的真实鸿沟过去两年&#xff0c;我参与过三个不同规模的AI辅助CAD项目&#xff0c;从最简单的图纸信息提取&#xff0c;到复杂的参数化建模生成&#xff0c;几乎把能踩的坑都踩了一遍。每次在技术评审会上放Demo&#xff0c;效果都很惊艳—…

作者头像 李华