GESP2026年9月认证C++八级( 第三部分编程题(2、末班车))精讲



这道 第三部分编程题第二题《末班车》,表面上是在讲"坐地铁",实际上考的是一个非常重要的图论思想:

把"从起点出发能不能到终点"反过来思考:从终点倒着推,计算每个车站"最晚几点出发还能赶到终点"。

这道题非常值得同学们掌握,因为它把 图、最短路、优先队列、反向思维 全部串在了一起。

题目的完整数据范围是:最多 500个地铁站、1000条线路、50万组询问 。所以如果每个询问都重新跑一次普通最短路,会非常慢。我们程序可以采用"每个终点预处理一次,再回答大量询问"的方法。


一、先把题目变成"小朋友坐地铁"

城市里有很多地铁站:

复制代码
1号站
2号站
3号站
4号站
......

每条地铁线路都是单向的。

例如:

复制代码
1 ─────→ 2

假设这条线路:

  • 最晚发车时间:3分钟

  • 坐车需要:3分钟

那么:

复制代码
0分钟出发 → 3分钟到
1分钟出发 → 4分钟到
2分钟出发 → 5分钟到
3分钟出发 → 6分钟到

超过3分钟:

复制代码
4分钟
5分钟
......

就没有车了。


二、最重要的规则:车可以等

这是本题最容易忽略的地方。

假如你:

第2分钟到达2号站。

下一班车最晚5分钟发车。

你当然可以:

复制代码
2分钟到站
 ↓
等3分钟
 ↓
5分钟坐车

所以:

到达车站以后,可以等待下一班车。

题目规定:

某条线路从0分钟到最晚发车时间,每一分钟都有一班车。

所以只要:

当前时间 ≤ 最晚发车时间

就能赶上这条线路。


三、先看样例

题目的样例是:

复制代码
3 4 5

1 2 3 3
2 3 5 2
3 1 4 1
1 3 0 6

表示:

线路1

复制代码
1 → 2

最晚3分钟发车,路上3分钟。


线路2

复制代码
2 → 3

最晚5分钟发车,路上2分钟。


线路3

复制代码
3 → 1

最晚4分钟发车,路上1分钟。


线路4

复制代码
1 → 3

最晚0分钟发车,路上6分钟。

所以地图大概是:

复制代码
       3
   1 ───→ 2
   │       │
   │       │
   ↓       ↓
   3 ←─────

更准确地说:

复制代码
1 ──→ 2 ──→ 3
↑           │
└───────────┘

四、题目到底问什么?

例如:

复制代码
1 3 2

意思是:

我第2分钟从1号站出发,能不能到3号站?

我们来试:

第一步

2分钟在1号站。

坐:

复制代码
1 → 2

这趟车最晚3分钟发车。

所以可以在2分钟坐车。

3分钟到2号站。


第二步

到了2号站是3分钟。

线路:

复制代码
2 → 3

最晚5分钟发车。

可以等到5分钟再坐。

5分钟出发:

复制代码
5 + 2 = 7

7分钟到3号站。

所以:

Yes!


五、如果按照普通思路,怎么想?

我们可能会想:

"那我从1号站开始,一步一步往前走。"

也就是:

复制代码
当前时间
 ↓
看看有哪些车
 ↓
选择能坐的车
 ↓
到下一个站
 ↓
继续

这就是一个正向思维。

可是问题来了。

题目有:

q ≤ 5×10^5

也就是:

最多50万次询问!

如果每个询问都重新跑一遍图:

复制代码
第1次:跑一遍
第2次:跑一遍
第3次:跑一遍
......
第500000次:再跑一遍

电脑会累得哭出来。😂

所以必须想办法:

⭐ 一次计算,多次使用!


六、真正厉害的思路:反过来想!

假设现在有一个问题:

我想去3号站。

我们不从1号站往3号站走。

而是站在:

🎯 终点3号站

然后问:

"如果我要最终到达3号站,那么其他车站最晚几点出发还来得及?"

这个问题就漂亮多了!


七、我们计算"最晚出发时间"

假设:

复制代码
2 → 3

这条线路:

  • 最晚5分钟发车

  • 需要2分钟

那么:

从2号站最晚什么时候出发,还能到3号站?

当然是:

5

因为5分钟出发:

5 + 2 = 7

就能到3号站。

所以:

复制代码
d[3] = ∞
d[2] = 5

这里:

d[x] 表示:从x号站最晚几点出发,还能到达目标站。


八、再往前推一个车站

现在:

复制代码
1 → 2

这条线路:

  • 最晚3分钟发车

  • 需要3分钟

我们刚才已经知道:

复制代码
从2号站最晚5分钟出发

那么从1号站什么时候出发呢?

如果:

复制代码
1号站 2分钟出发

3分钟到2号站。

当然赶得上。

如果:

复制代码
1号站 3分钟出发

6分钟到2号站。

已经赶不上5分钟那班车了。

所以:

从1号站最晚只能3分钟出发。

于是:

复制代码
d[1] = 3
d[2] = 5
d[3] = ∞

九、出现一个非常重要的公式

假设有一条线路:

复制代码
u ─────→ v

它有:

  • 最晚发车时间:l

  • 行驶时间:t

我们已经知道:

从v最晚 d[v] 时间出发,还能完成任务。

那么从u出发,必须满足两个条件。


条件①:不能超过这条车的最晚发车时间

必须:

x ≤ l


条件②:到了v以后还必须来得及

从u出发时间是:

x

坐车需要:

t

到达v:

x + t

必须满足:

x+t ≤ dv

所以:

x ≤ dv−tx


于是两个条件同时满足:

x ≤ l

以及:

x ≤ dv−tx

那么x最大的值就是:

这就是整道题最最核心的公式!


十、用能懂的话解释下这个公式

假设:

复制代码
u → v

你从u坐车到v。

你要同时遵守两个"时间警察":

🚓 警察1

"这班车最晚只能3分钟发车!"

所以:

复制代码
不能超过 l

🚓 警察2

"你到了v以后,还得赶得上后面的车!"

所以:

复制代码
不能超过 d[v]-t

两个警察都说:

"不能超过我的时间!"

那么最终:

听最严格的那个!

所以取:


十一、这就是参考程序里的这一句

参考程序中有:

复制代码
int w = min(d[u] - t[i], l[i]);

这是参考程序是从当前点 u 向前反推,所以变量位置看起来和我们刚才讲的符号稍微不同。

它的本质就是:

计算前一个车站最晚什么时候还能出发。


十二、为什么要"反向思考"?

这是本题最精彩的地方。

普通最短路喜欢问:

最早什么时候到?

例如:

复制代码
从1号站出发
↓
最早几点到2号站?
↓
最早几点到3号站?

这是:

⏰ "最早到达"

但是本题其实更适合问:

最晚几点出发还能赶上?

也就是:

⏰ "最晚出发"

这两个思维方向是不一样的。


十三、给大家画一张"时间魔法图"

假设:

复制代码
1 ──3分钟──→ 2

最晚3分钟发车。

如果我们从2号站开始倒推:

复制代码
2号站
 ↑
 ↑  最晚3分钟出发
 ↑
1号站

得到:

复制代码
d[2] = 5
d[1] = min(3, 5-3)
     = min(3,2)
     = 2

所以:

从1号站最晚2分钟出发。


十四、为什么不是简单的"最晚发车时间减行驶时间"?

这里很容易犯错误。

假设:

复制代码
1 → 2

最晚发车:

3

行驶:

3

如果2号站最晚:

5

那么:

5−3=2

所以1号站最晚2分钟。

但是如果2号站最晚只能:

1

呢?

那么:

1−3=−2

而这条车又规定:

1号站最晚3分钟发车1号站最晚3分钟发车

所以两个限制一起看:

min⁡(3,−2)=−2

意味着:

根本不存在合法的出发时间。

所以程序里专门判断:

复制代码
if (w < 0)
    continue;

也就是说:

如果推出来的时间已经小于0,这条线路就没办法帮助我们到达终点。


十五、为什么使用优先队列?

现在我们知道:

要从终点开始,一层一层往前推。

但是一个车站可能有很多条线路。

例如:

复制代码
       → 2
      /
1 → 3
      \
       → 4

我们不断更新:

复制代码
d[1]
d[2]
d[3]
d[4]

这和Dijkstra非常像。

所以我们可以使用:

🏆 优先队列

程序:

复制代码
priority_queue<pair<int,int> > q;

这里用了C++默认的大根堆。


十六、为什么代码用负数?

你可能注意到了:

复制代码
q.push(make_pair(-d[u], u));

为什么好好的 d[u] 前面要加一个负号?

因为:

C++默认的 priority_queue 是大根堆。

也就是说:

复制代码
最大的数先出来。

但是我们希望:

时间最小的先处理。

所以:

复制代码
5 → -5
10 → -10
3 → -3

那么:

复制代码
-10
-5
-3

最大的其实是:

复制代码
-3

这就相当于原来的:

复制代码
3

最小的先出来。

所以通过:

复制代码
-d[u]

把大根堆"伪装"成小根堆,这也是一个小技巧。


十七、当然,也可以直接写小根堆

如果同学们已经学过:

复制代码
greater<pair<int,int> >

也可以写:

复制代码
priority_queue<
    pair<int,int>,
    vector<pair<int,int> >,
    greater<pair<int,int> >
> q;

不过官方的参考程序选择了:

复制代码
priority_queue<pair<int,int> >

然后把时间取负数。

我们可以先理解:

"负号是为了让大根堆模拟小根堆。"

就够了。


十八、还有一个非常重要的技巧:为什么要判断旧数据?

程序里面:

复制代码
pair<int,int> p = q.top();
q.pop();

if (d[u] != p.first)
    continue;

这里有的同学很容易看懵。

我们讲一个"小纸条"的故事。


📝 每次更新du,就往优先队列里塞一张纸条

比如:

复制代码
1号站最晚5分钟

后来发现:

复制代码
1号站最晚8分钟

那么队列里可能同时存在:

复制代码
5分钟
8分钟

但是:

5分钟已经是旧消息了。

真正有效的是:

复制代码
8分钟

所以弹出来以后:

复制代码
if (d[u] != p.first)
    continue;

就是在问:

"这张纸条还是最新的吗?"

如果不是:

扔掉!


十九、现在我们完整走一遍样例

样例线路:

复制代码
1 → 2,最晚3,耗时3
2 → 3,最晚5,耗时2
3 → 1,最晚4,耗时1
1 → 3,最晚0,耗时6

假设目标是:

🎯 3号站

我们从3号站开始。


第一步:3号站

目标已经到了。

所以:

d3 = +oo

程序里用:

复制代码
oo = 1e9;

表示无限大。

所以:

复制代码
d[3] = ∞

二十、第二步:处理2号站

有:

复制代码
2 → 3

最晚:

5

耗时:

2

3号站:

d3=∞

所以:

d2=min⁡(5,∞−2)

结果:


二十一、第三步:处理1号站

有:

复制代码
1 → 2

线路最晚:

3

耗时:

3

而:

d2=5

所以:

d1=min⁡(3,5−3)


于是我们得到了:

车站 最晚出发时间
1 2
2 5
3 ∞

这张表太重要了!


二十二、这张表怎么回答询问?

假设问:

复制代码
1 3 2

意思:

1号站,2分钟出发,能不能到3号站?

我们查:

复制代码
d[1] = 2

然后比较:

2 ≤ 2

成立。

所以:

复制代码
Yes

再问:

复制代码
1 3 3

我们比较:

3 ≤ 2

不成立。

所以:

复制代码
No

这就是:

⭐ 查询只需要比较一次!


二十三、这就是为什么它能处理50万次询问

假设有:

复制代码
500000

个询问。

我们提前把:

复制代码
d[1]
d[2]
d[3]
......
d[n]

全部算出来。

以后每个询问只做:

复制代码
if (s <= d[x])
    Yes
else
    No

这就是:

预处理一次,查询很多次。


二十四、但是有一个关键问题

不同的终点:

复制代码
终点1
终点2
终点3
......

对应的 d[] 不一样。

例如:

我要去3号站。

我们计算:

复制代码
d3[1], d3[2], d3[3]...

如果:

我要去1号站。

就要重新计算:

复制代码
d1[1], d1[2], d1[3]...

所以程序可以做的是:

复制代码
for (int i = 1; i <= n; i++)
    calc(i, d[i]);

也就是:

把每一个车站都当一次终点,提前计算一遍。


二十五、为什么不按照每个询问计算?

因为:

q ≤ 500000

但:

n ≤ 500

所以:

终点只有500个左右,但询问可能有50万个。

这是一个非常重要的观察。

我们应该:

❌ 错误思路

复制代码
每个询问
   ↓
跑一次算法

50万次,太慢。


✅ 正确思路

复制代码
每个终点
   ↓
跑一次算法
   ↓
保存结果

50万次询问
   ↓
直接查表

二十六、这其实是一种非常重要的算法思想

叫:

⭐ 预处理

先花一些时间把答案准备好。

以后:

查询的时候直接拿来用。

生活中也有类似的例子。


📚 查字典

如果每次查一个字都重新造一本字典:

当然很慢。

我们提前把字典做好:

复制代码
苹果 → apple
香蕉 → banana
西瓜 → watermelon

查询的时候直接查。


💻 算法也是一样

提前计算:

复制代码
终点3:
1号站 → 最晚2分钟
2号站 → 最晚5分钟
3号站 → 无限

以后:

复制代码
1 3 0
1 3 1
1 3 2
1 3 3
......

全部可以快速回答。


二十七、参考程序最核心的函数

现在再看:

复制代码
void calc(int u, int d[])
{
    for (int i = 1; i <= n; i++)
        d[i] = -oo;

    d[u] = oo;

    q.push(make_pair(-d[u], u));

    while (!q.empty())
    {
        pair<int, int> p = q.top();
        q.pop();

        if (d[u] != p.first)
            continue;

        u = p.second;

        for (int i = h[u]; i; i = nx[i])
        {
            int v = to[i];

            int w = min(d[u] - t[i], l[i]);

            if (w < 0)
                continue;

            if (w > d[v])
            {
                d[v] = w;
                q.push(make_pair(d[v], v));
            }
        }
    }
}

虽然看起来复杂,但可以把它翻译成:

复制代码
选定终点
   ↓
终点的最晚时间 = 无限
   ↓
优先处理最有希望的车站
   ↓
沿着地铁线路反向推
   ↓
计算前一个车站最晚什么时候出发
   ↓
如果更好,就更新
   ↓
继续扩散

二十八、注意:代码为什么是"沿着线路正向存储",却在"时间意义上倒着推"?

这是非常高级、也非常漂亮的地方。

线路本身还是:

复制代码
u → v

程序并没有真的把边改成:

复制代码
v → u

但是我们思考的时候:

复制代码
已知v最晚时间
 ↓
反推u最晚时间

所以叫:

⭐ 反向推理

不是一定要把图反过来,而是:

从终点的答案倒着推前面的答案。

这个区别同学们一定要理解。


二十九、为什么这有点像Dijkstra?

因为它确实使用了:

复制代码
priority_queue

并且不断进行:

复制代码
if (w > d[v])
    d[v] = w;

这和Dijkstra的"松弛"思想非常相似。

普通Dijkstra:

不断更新"最短距离"。

这道题:

不断更新"最晚可行时间"。

所以可以把它理解成:

🚇 "最晚出发版Dijkstra"

普通Dijkstra:

求最小

这道题:

求最大


三十、普通Dijkstra和本题对比

普通Dijkstra 本题
求最短距离 求最晚出发时间
从起点出发 从终点倒推
min越来越小 max越来越大
优先处理最小距离 优先处理当前最优时间
问"最早几点到" 问"最晚几点走还来得及"

这是理解本题最好的方法之一。


三十一、参考程序

复制代码
#include <cstdio>
#include <algorithm>
#include <queue>

using namespace std;

const int N = 505;
const int E = 1005;
const int INF = 1e9;

int n, m;

int h[N];
int to[E], nx[E];
int l[E], t[E];

int d[N][N];

priority_queue<pair<int, int> > q;


// target:终点
// d[]:保存"到达target时,每个车站最晚几点出发"
void calc(int target, int d[])
{
    for (int i = 1; i <= n; i++)
        d[i] = -INF;

    // 终点本身,当然什么时候到都算成功
    d[target] = INF;

    q.push(make_pair(-d[target], target));

    while (!q.empty())
    {
        pair<int, int> p = q.top();
        q.pop();

        int u = p.second;

        // 旧状态,跳过
        if (-p.first != d[u])
            continue;

        // 枚举u出发的所有线路
        for (int i = h[u]; i; i = nx[i])
        {
            int v = to[i];

            // 计算前一个站最晚什么时候出发
            int w = min(d[u] - t[i], l[i]);

            // 已经无法赶上
            if (w < 0)
                continue;

            // 如果更优,就更新
            if (w > d[v])
            {
                d[v] = w;

                q.push(make_pair(-d[v], v));
            }
        }
    }
}


int main()
{
    int q;

    scanf("%d%d%d", &n, &m, &q);

    // 读入所有地铁线路
    for (int i = 1; i <= m; i++)
    {
        int u, v;

        scanf("%d%d%d%d",
              &u, &v, &l[i], &t[i]);

        // 建立有向边 u -> v
        to[i] = v;
        nx[i] = h[u];
        h[u] = i;
    }

    // 每一个车站都当一次终点
    for (int i = 1; i <= n; i++)
    {
        calc(i, d[i]);
    }

    // 回答所有询问
    while (q--)
    {
        int u, v, s;

        scanf("%d%d%d", &u, &v, &s);

        // s分钟从u出发,
        // 如果不超过"最晚可出发时间",就可以到v
        if (s <= d[v][u])
            printf("Yes\n");
        else
            printf("No\n");
    }

    return 0;
}

三十二、这里有一个非常重要的"数组理解"

看到:

复制代码
d[v][u]

初学者很容易糊涂。

它的含义:

d[v][u] = "如果我要去 v 号站,那么从 u 号站最晚几点出发还来得及?"

所以:

复制代码
d[3][1] = 2

就是:

目标是3号站,从1号站最晚2分钟出发。

查询:

复制代码
1 3 2

就看:

复制代码
s <= d[3][1]

也就是:

复制代码
2 <= 2

所以:

复制代码
Yes

三十四、这道题最值得孩子掌握的4个算法思想

⭐ 思想1:不要每个询问重新跑

看到:

q=5×10^5

就要想到:

查询太多,要预处理。


⭐ 思想2:正着想很麻烦,就倒着想

正向:

"我现在出发,什么时候能到?"

反向:

"我必须什么时候之前到这里,才能赶上后面的车?"

后者更容易计算。


⭐ 思想3:把"最短路"换成"最晚时间"

普通Dijkstra:

复制代码
最短距离

本题:

复制代码
最晚出发时间

核心仍然是:

不断松弛、不断更新最优答案。


⭐ 思想4:优先队列帮助我们快速找到当前最优状态

复制代码
priority_queue

就像一个:

🏆 "时间排行榜"

谁的"最晚可出发时间"最值得优先处理,就先处理谁。


🎯 一张"算法藏宝图"

整道题可以压缩成:

复制代码
                 🚇 末班车
                    │
                    ↓
              有很多询问
                    │
                    ↓
            每个询问都跑算法?
                    │
                    ↓
                   ❌
                    │
                    ↓
              先做预处理
                    │
                    ↓
          枚举每一个"终点"
                    │
                    ↓
       从终点向前反推最晚时间
                    │
                    ↓
        d[v][u] = 从u到v的
              最晚出发时间
                    │
                    ↓
        u → v,最晚发车 l
        行驶时间 t
                    │
                    ↓
       w=min(l,d[v]-t)
                    │
                    ↓
          优先队列不断松弛
                    │
                    ↓
             全部计算完成
                    │
                    ↓
          每个询问只比较一次
                    │
                    ↓
       s <= d[v][u] → Yes
       s >  d[v][u] → No

🧠 最后一句口诀

末班车,不问"最早到",改问"最晚走";
终点开始向前推,优先队列找最优;
车次时间取最小,所有终点先预处理;
询问来了查数组,一次比较就搞定!

这道题真正的难点不是优先队列本身,而是发现这个非常漂亮的转换:

"能不能到达?"

变成

"最晚什么时候出发还来得及?"

一旦这个思维转换想明白,后面的 d[v][u]、min()、优先队列和50万次询问,就全部串起来了。


相关推荐
傲世仙尊2 小时前
C++起步-命名空间解决命名冲突与cin-cout缺省参数
开发语言·c++
(Charon)2 小时前
【C++面试】四种cast转换:static_cast、dynamic_cast、const_cast与reinterpret_cast
开发语言·c++·面试
蒸蒸yyyyzwd3 小时前
后端学习笔记 day58
c++·八股
道尔柯南3 小时前
【QT】从零认识 Qt:跨平台 C++ GUI 开发入门
开发语言·c++·qt
MayZork3 小时前
std::filesystem 文件操作详解
开发语言·c++·qt
NeilYuen15 小时前
【C++】STL源码仿写(二):vector
开发语言·c++
郝学胜_神的一滴16 小时前
游戏引擎原理与实践 04:拆解引擎基础系统与内存管理
c++·游戏
All for pursuit.16 小时前
【设计-1】208.实现Trie (前缀树)
数据结构·c++·算法·leetcode
weixin_3077791316 小时前
C++代码实现MATLAB中的crossval函数功能
开发语言·c++·算法·matlab