出租车的最大盈利 图解题解
这道题到底在问什么
- 输入
- n=5, rides=[[2,5,4],[1,5,1]]
- 输出
- 7 (接第 0 单,5 减 2 加 4 等于 7)
- 输入
- n=20, rides=[[1,6,1],[3,10,2],[10,12,3],[11,12,2],[12,15,2],[13,18,1]]
- 输出
- 20 (接 3 单)
最优解:为什么这么做
一句话答案:LeetCode 2008 出租车的最大盈利:订单按起点排序,f[i] 记从第 i 单往后最多赚多少,每单在不接沿用与接单赚 end−start+tip 再二分跳去下一单之间取大,即带权区间调度,时间 O(m log m)、空间 O(m)。
一条单行道上挑单,到底在赚什么钱
一条路上有 n 个地点,出租车只能往编号大的方向开,不能回头。每位乘客是一单 rides[i]=[start,end,tip],接了能赚 end−start+tip;同一时刻只能载一位,可以在同一地点放下一位、马上接上另一位。要挑一批互不冲突的订单,让总盈利最大。
见单就接为什么会亏,全枚举为什么试不完
m 个订单每单接或不接,2 的 m 次方种组合,几万条试不完。贪心(每步只拿眼前最赚的)靠不住:题面 n=20 那组,值 9 那单最赚先接,车到 10,再接只剩值 6 的末单,9+6=15;先接 1 到 6 的头单,车占到 6、值 9 错过,连头单最多 6+5+6=17(加 10 到 12、13 到 18),都不到最优的 20。
f[i] 定成从第 i 单往后,两条路才有得比
先把订单按起点从小到大排序,『往后』才有方向。这题用动态规划(简称 DP,把『从某单往后最多赚多少』这类会反复被问的小问题各算一次、存表复用)。状态(表里每格记的数)定成 f[i]:从排序后第 i 单起往后随便挑,最多能赚多少。答案是 f[0]。
接了这单,为什么要用二分找下一单
填 f[i] 摆两条路取大。不接:这单当不存在,沿用 f[i+1]。接:落袋 end−start+tip,车到 end 才空出来,下一个能接的是起点不小于 end 的第一单,记作第 j 单,再加 f[j]。订单已按起点排好序,第 j 单用二分(每次砍一半范围去定位)就能找到,参考代码里 bisect_left 干的就是这事。『不小于』含等号:同一地点放人再接人不算冲突。
转移式(由后面的格子推出当前格的式子)就是 f[i]=max(f[i+1], end−start+tip+f[j])。做过 LeetCode 1235 规划兼职工作会眼熟,这题是它搬上数轴的出租车版。
六单从后往前,f 是怎么涨到 20 的
题面第二组:n=20,rides=[[1,6,1],[3,10,2],[10,12,3],[11,12,2],[12,15,2],[13,18,1]],按起点排好记作单0 到单5。各单收益是终点减起点加小费,依次 6、9、5、3、5、6;接完能跳到的下一单依次是单2、单2、单4、单4、无、无。
倒着填:单5 接得 6,不接是 0,f 记 6;单4 接只有 5,不如沿用单5 的 6;单3 接是 3+6=9,胜过 6;单2 接是 5+6=11,胜过 9;单1 接是 9+11=20,胜过不接的 11;单0 接是 6+11=17,输给不接的 20。f[0]=20 就是答案:接单1、单2、单5,9+5+6=20。
二分把等号写丢,20 为什么缩成 18
排序 O(m log m)(大 O 衡量规模对计算量的放大倍数),每单一次二分 log m,总时间 O(m log m);存 f 的表是 O(m)。找下一单写成严格大于终点,10 下客、10 上客这种首尾相接就被误判冲突,上面那组的 20 当场缩成 18。总盈利能累到几十亿,Java 和 C++ 得用长整型接住,int 存不下。排序也省不得,乱序上二分出的『下一单』是错位的。
▶ 动画逐步走查(共 24 步)——想跟着动画一帧帧对照就展开
- 3记住这套框架:排好序后,每一单都做一次「接还是不接」的取舍。不接就顺延,接就把本单收益加上从下一个不冲突订单往后的最优。下面把这张表一列一列、再一行一行填出来。
- 4排序完成先把 6 个订单按起点从小到大排好,正好这组本来就是有序的。表格每一行是一个订单,左边两列是它的起点和终点。收益、下一单、还有最关键的 f 值三列先空着,等会儿一列一列补上。排序是后面二分找下一单的前提,一定要先做。
- 5收益列填好补上收益这一列。每单收益等于终点减起点,再加它自己的小费。比如单0从 1 到 6 小费 1,收益就是 6 减 1 加 1 等于 6;单1从 3 到 10 小费 2,收益是 10 减 3 加 2 等于 9。六个订单的收益依次是 6、9、5、3、5、6。注意单1一个人就值 9,是这里最肥的一单。
- 6下一单列填好再补下一单这一列。接了某单,车就开到了它的终点,下一个能接的必须是起点不早于这个终点的订单。因为已经按起点排好序,用二分就能很快找到第一个满足的。比如单0在 6 下客,起点不小于 6 的第一单是单2;单4在 15 下客,后面再没有起点到 15 的了,标一个无。允许下一单起点正好等于本单终点,因为同一地点可以先放下再接上。
- 7f 值从后往前填开始填最右边的 f 值列。约定一个虚拟的末尾:如果一单都不接,盈利就是 0,记成 f 的第 6 格等于 0。填表从最后一单往前推,因为每一单的 f 都依赖它后面的格子,后面先算好,前面才有得取。下面从单5 开始,一单填一遍。
- 8算单5 的不接轮到单5,起点 13,终点 18。先看第一种选择:不接它。不接就等于从下一单往后随便挑,盈利直接沿用 f[末尾] = 0,也就是 0。这一项不花本单的收益,先把它记在心里。
- 9算单5 的接再看第二种:接单5。先拿到它的收益 6,车开到终点 18,下一个能接的是 无,从那往后的最优是 f[末尾] = 0。两个加起来是 6 加 0 等于 6。它后面没有能接的单了,所以只加末尾的 0。
- 10f[单5] = 6两种选择摆在一起,取较大的那个:不接是 0,接是 6,接更划算,所以单5 的 f 记成 6,这一步我们倾向接它。 把 6 填进 f 值列,继续往前推。
- 11算单4 的不接轮到单4,起点 12,终点 15。先看第一种选择:不接它。不接就等于从下一单往后随便挑,盈利直接沿用 f[单5] = 6,也就是 6。这一项不花本单的收益,先把它记在心里。
- 12算单4 的接再看第二种:接单4。先拿到它的收益 5,车开到终点 15,下一个能接的是 无,从那往后的最优是 f[末尾] = 0。两个加起来是 5 加 0 等于 5。它后面没有能接的单了,所以只加末尾的 0。
- 13f[单4] = 6两种选择摆在一起,取较大的那个:不接是 6,接是 5,不接反而更高,所以单4 的 f 记成 6,这一单在最优里跳过。 把 6 填进 f 值列,继续往前推。
- 14算单3 的不接轮到单3,起点 11,终点 12。先看第一种选择:不接它。不接就等于从下一单往后随便挑,盈利直接沿用 f[单4] = 6,也就是 6。这一项不花本单的收益,先把它记在心里。
- 15算单3 的接再看第二种:接单3。先拿到它的收益 3,车开到终点 12,下一个能接的是 单4,从那往后的最优是 f[单4] = 6。两个加起来是 3 加 6 等于 9。注意接了本单只能跳到 单4,中间被占住的订单就接不了了。
- 16f[单3] = 9两种选择摆在一起,取较大的那个:不接是 6,接是 9,接更划算,所以单3 的 f 记成 9,这一步我们倾向接它。 把 9 填进 f 值列,继续往前推。
- 17算单2 的不接轮到单2,起点 10,终点 12。先看第一种选择:不接它。不接就等于从下一单往后随便挑,盈利直接沿用 f[单3] = 9,也就是 9。这一项不花本单的收益,先把它记在心里。
- 18算单2 的接再看第二种:接单2。先拿到它的收益 5,车开到终点 12,下一个能接的是 单4,从那往后的最优是 f[单4] = 6。两个加起来是 5 加 6 等于 11。注意接了本单只能跳到 单4,中间被占住的订单就接不了了。
- 19f[单2] = 11两种选择摆在一起,取较大的那个:不接是 9,接是 11,接更划算,所以单2 的 f 记成 11,这一步我们倾向接它。 把 11 填进 f 值列,继续往前推。
- 20算单1 的不接轮到单1,起点 3,终点 10。先看第一种选择:不接它。不接就等于从下一单往后随便挑,盈利直接沿用 f[单2] = 11,也就是 11。这一项不花本单的收益,先把它记在心里。
- 21算单1 的接再看第二种:接单1。先拿到它的收益 9,车开到终点 10,下一个能接的是 单2,从那往后的最优是 f[单2] = 11。两个加起来是 9 加 11 等于 20。注意接了本单只能跳到 单2,中间被占住的订单就接不了了。
- 22f[单1] = 20两种选择摆在一起,取较大的那个:不接是 11,接是 20,接更划算,所以单1 的 f 记成 20,这一步我们倾向接它。 把 20 填进 f 值列,继续往前推。
- 23算单0 的不接轮到单0,起点 1,终点 6。先看第一种选择:不接它。不接就等于从下一单往后随便挑,盈利直接沿用 f[单1] = 20,也就是 20。这一项不花本单的收益,先把它记在心里。
- 24算单0 的接再看第二种:接单0。先拿到它的收益 6,车开到终点 6,下一个能接的是 单2,从那往后的最优是 f[单2] = 11。两个加起来是 6 加 11 等于 17。注意接了本单只能跳到 单2,中间被占住的订单就接不了了。
- 25f[单0] = 20两种选择摆在一起,取较大的那个:不接是 20,接是 17,不接反而更高,所以单0 的 f 记成 20,这一单在最优里跳过。 把 20 填进 f 值列,继续往前推。
- 26答案 = 20f 值列填满,f[单0] 等于 20,就是答案。顺着每一步的取舍回放:最优方案是接 单1、单2、单5,收益 9 加 5 加 6 合起来正好 20。这三单首尾相接又不冲突:单1到 10 下客,单2从 10 上客,单2到 12,再等到单5从 13 上客。见单就接反而会被便宜的短单占住路段,取 max 才躲开了这个坑。
⚠️ 容易写错的地方
✗ 错:不排序直接二分找下一单
✓ 对:先按起点升序排序再二分
二分的前提是数组按起点有序,不排序二分找到的下一单是错的,整套递推全崩
✗ 错:用 int 累加答案
✓ 对:答案用 long 或 long long
订单最多约 3 万,单笔收益上限约 20 万,累加最坏能到几十亿,超出 int 范围会溢出成负数
✗ 错:把下一单找成「起点严格大于本单终点」
✓ 对:起点不小于本单终点即可,取等号
题目允许在同一地点先放下再接上,所以下一单起点正好等于本单终点是合法的,用严格大于会漏接
✗ 错:把「同时只载一人」理解成「总共只接一单」
✓ 对:可以依次接很多单,只是不能同时载两人
同一时刻只能有一位乘客,但放下之后就能再接下一位,最优往往是接好几单
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def maxTaxiEarnings(self, n: int, rides: List[List[int]]) -> int:
@cache
def dfs(i: int) -> int:
if i >= len(rides):
return 0
st, ed, tip = rides[i]
j = bisect_left(rides, ed, lo=i + 1, key=lambda x: x[0])
return max(dfs(i + 1), dfs(j) + ed - st + tip)
rides.sort()
return dfs(0)C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
long long maxTaxiEarnings(int n, vector<vector<int>>& rides) {
sort(rides.begin(), rides.end());
int m = rides.size();
long long f[m];
memset(f, -1, sizeof(f));
function<long long(int)> dfs = [&](int i) -> long long {
if (i >= m) {
return 0;
}
if (f[i] != -1) {
return f[i];
}
auto& r = rides[i];
int st = r[0], ed = r[1], tip = r[2];
int j = lower_bound(rides.begin() + i + 1, rides.end(), ed, [](auto& a, int val) { return a[0] < val; }) - rides.begin();
return f[i] = max(dfs(i + 1), dfs(j) + ed - st + tip);
};
return dfs(0);
}
};Java
import java.util.*;
class Solution {
private int m;
private int[][] rides;
private Long[] f;
public long maxTaxiEarnings(int n, int[][] rides) {
Arrays.sort(rides, (a, b) -> a[0] - b[0]);
m = rides.length;
f = new Long[m];
this.rides = rides;
return dfs(0);
}
private long dfs(int i) {
if (i >= m) {
return 0;
}
if (f[i] != null) {
return f[i];
}
int[] r = rides[i];
int st = r[0], ed = r[1], tip = r[2];
int j = search(ed, i + 1);
return f[i] = Math.max(dfs(i + 1), dfs(j) + ed - st + tip);
}
private int search(int x, int l) {
int r = m;
while (l < r) {
int mid = (l + r) >> 1;
if (rides[mid][0] >= x) {
r = mid;
} else {
l = mid + 1;
}
}
return l;
}
}复杂度
时间
O(m log m)
m 是订单数。排序是 m log m;之后每个订单状态只算一次,算的时候用二分找下一单是 log m,合起来还是 m log m 级别。用前缀式的位置 DP 也能做到接近线性,这里按参考解的排序加二分算
空间
O(m)
按峰值算。备忘录数组 f 是 m 个格子;自顶向下递归时调用栈最深也是订单个数量级。两者都是 O(m),不额外开与地点数 n 相关的大表
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 出租车的最大盈利 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这题和 LeetCode 1235 规划兼职工作是什么关系?+
同一个模型,叫带权区间调度:一堆带收益的区间,两两不能重叠,选收益和最大的一批。1235 直接给出每份工作的起止时间和报酬,本题的区间是数轴上的行程,收益要自己算成 end−start+tip。排序、二分找下一个不冲突区间、接或不接取大,这套骨架两题一字不差,会一题另一题就是换皮。
同一时刻只能载一位乘客,这个限制在解法里落在哪一步?+
落在接单后的跳转上。接了第 i 单,车要一直开到它的终点,途中起点更早的订单全部错过,所以只能跳到二分找出的第 j 单,也就是起点不小于本单终点的第一单。单量本身没有限制:只要前一单的终点不晚于后一单的起点,接多少单都合法,题面最优解就一口气接了三单。
不排序加二分,还有别的递推办法吗?+
有,按地点推:把订单按终点分桶,dp[x] 表示车开到地点 x 时的最大盈利,每一步要么空驶(dp[x] 沿用 dp[x−1]),要么让某个终点是 x 的订单收尾(取 dp[start]+end−start+tip 的最大)。地点和订单各扫一遍,时间 O(n+m),不用排序;但 n 远大于订单数时,这张表大多数格子在空驶,反而不如排序二分版省。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 出租车的最大盈利 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。