大厂真题 / 拼多多

拼多多算法工程师 2026-10-18 笔试真题

本场考试概述

考试时间:2026 年 10 月 18 日

考试岗位:算法工程师

难度评级:中等

考点分析:

  • 第一题:按中转城市拆分最优费用,哈希表聚合。
  • 第二题:整数闭区间、离散事件与扫描线。
  • 第三题:带结尾颜色状态的最长公共子序列。
  • 第四题:迭代 DFS、子树区间与树状数组。

建议策略:先完成覆盖计数,再处理航线方向;动态规划先写清状态含义,树上询问先转化为子树求和。注意负权、闭区间右端点和链状树的递归深度。


第 1 题:至多一次中转的最低运费

题目描述

将一件包裹从城市 $S$ 运到城市 $T$。共有 $N$ 家物流公司,每家公司提供一条有方向的航线,航线中的城市按经过顺序给出,同一航线内城市不会重复。

使用一条航线时,可以从其中任意城市上货,在严格排在它后面的城市下货;不论运输距离多远,都支付该航线的固定费用 $C$。同一家公司的航线最多使用一次。包裹全程最多使用两条航线,即可以直达,也可以在某个城市换乘一次。求最低总费用,无可行方案则输出 -1。

输入描述

第一行输入 $S,T,N$。随后每条航线用两行表示:第一行输入固定费用 $C$ 和城市数 $M$,第二行输入按航行顺序排列的 $M$ 个城市编号。

数据范围:

  • $1\le S,T\le 10000$,$1\le N\le 1000$。
  • $1\le C,M\le 10000$,所有城市编号介于 $1$ 和 $10000$。
  • 航线内城市互不相同;城市编号的数值大小不代表航行方向。

输出描述

输出最低运费,无法到达时输出 -1。

样例

输入

5 3 3
4 5
5 9 8 3 2
2 3
5 7 2
1 3
2 9 3

输出

3

第一条航线直达需要 4。先用第二条航线从 5 到 2,再用第三条从 2 到 3,费用为 $2+1=3$。

思路分析

第一步:用中转城市代替航线对。

枚举两家公司还需要检查它们是否存在方向正确的公共城市,重复工作很多。固定中转城市 $x$ 后,前半程只关心从 $S$ 到 $x$ 的最低单航线费用,后半程只关心从 $x$ 到 $T$ 的最低单航线费用。

分别用 from_s[x]、to_t[x] 保存这两个值。不存在的路径视为无穷大。另用 best 保存最低直达费用。

第二步:逐条航线更新。

找出 $S,T$ 在当前航线中的位置 $p_s,p_t$:

  1. 若二者存在且 $p_s<p_t$,当前航线可以直达。
  2. 若 $S$ 存在,$S$ 后面的每个城市都可作为第一程终点。
  3. 若 $T$ 存在,$T$ 前面的每个城市都可作为第二程起点。

每次用当前固定费用更新对应最小值。最后枚举两个字典共有的城市,比较两段费用之和。整个过程不需要保存全部航线。

第三步:说明为什么不用记录公司编号。

两个最小值可能来自同一家公司。此时该航线上必然依次出现 $S,x,T$,直接使用这条航线只需 $C$,而重复组合需要 $2C$。由于费用为正,直达方案严格更便宜,因此非法的重复组合不可能降低最终答案。

正确性证明

任意合法运输方案要么只用一条航线,要么经过某个中转城市。前者会在直达检查中被考虑;后者在对应城市处,两段分别取最小费用不会比该方案更贵。反过来,每个被记录的单程都满足城市顺序;两段属于不同公司时可以直接拼接,属于同一公司时有费用更低的合法直达方案替代。因此最终最小值恰为合法方案的最优值。

题解代码

import sys

input = sys.stdin.readline


def cheapest(s, t, routes):
    inf = float('inf')
    from_s = {}
    to_t = {}
    best = inf
    for cost, cities in routes:
        pos_s = pos_t = -1
        for i, city in enumerate(cities):
            if city == s:
                pos_s = i
            if city == t:
                pos_t = i
        if 0 <= pos_s < pos_t:
            best = min(best, cost)
        if pos_s >= 0:
            for i in range(pos_s + 1, len(cities)):
                city = cities[i]
                from_s[city] = min(from_s.get(city, inf), cost)
        if pos_t >= 0:
            for i in range(pos_t):
                city = cities[i]
                to_t[city] = min(to_t.get(city, inf), cost)
    for city, cost in from_s.items():
        best = min(best, cost + to_t.get(city, inf))
    return -1 if best == inf else best


def solve():
    s, t, n = map(int, input().split())

    def read_routes():
        for _ in range(n):
            cost, m = map(int, input().split())
            cities = list(map(int, input().split()))
            yield cost, cities

    print(cheapest(s, t, read_routes()))


if __name__ == '__main__':
    solve()

复杂度分析

时间复杂度:期望 $O(H+V)$,其中 $H=\sum M_i$,$V$ 为出现过的城市数。每条航线只扫描常数次,字典操作按均摊常数时间计。

空间复杂度:$O(V+M_{\max})$,保存两个费用字典和当前航线;生成器不会缓存所有航线。

易错点

  • 必须按航线下标判断方向,不能仅判断两个城市是否同时出现。
  • 费用按整条航线收取,不乘经过的边数。
  • 本题按实际运输航段建模;若另行允许起终点相同且零次运输,应将该扩展情形的答案定义为 0,不要与至少经过一个有向航段混淆。

第 2 题:至少被申请 K 次的整数位置

题目描述

有 $N$ 个部门申请走廊区域。第 $i$ 个申请覆盖整数闭区间 $[L_i,R_i]$,包含左右端点。求被至少 $K$ 个申请同时覆盖的整数位置总数。统计的是整数坐标数量,而不是连续线段的几何长度。

输入描述

第一行输入 $N,K$;接下来 $N$ 行,每行输入一个申请的 $L_i,R_i$。

约束为 $1\le N\le 100000$,$1\le K\le 10^9$,$1\le L_i\le R_i\le 10^9$。

输出描述

输出满足覆盖次数要求的整数位置数量。

样例 1

输入

3 2
1 5
2 6
3 7

输出

5

整数位置 2 到 6 均被至少两个申请覆盖,共 5 个。

样例 2

输入

3 1
1 3
4 6
7 9

输出

9

三个申请覆盖了 1 到 9 的所有整数位置。

样例 3

输入

2 3
1 10
1 10

输出

0

只有两个申请,覆盖数不可能达到 3。

思路分析

第一步:把闭区间改成两个事件。

若对每个位置逐一计数,坐标达到 $10^9$ 时不可行。但覆盖数只在区间端点变化:在 $L_i$ 处加一,在 $R_i+1$ 处减一。减一必须放在右端点的下一格,否则会漏掉 $R_i$ 本身。

第二步:只扫描事件间的整段。

把全部事件按坐标排序。处理坐标 $x$ 前,变量 cover 表示从上一个事件坐标 previous 到 $x-1$ 的覆盖数。若它不少于 $K$,一次性将 $x-previous$ 加入答案,然后处理当前事件的增量。

同一坐标可能出现多个事件,逐个处理也没有问题:同坐标事件之间的长度为零;开始下一段时,该坐标全部增量已累加完成。

正确性证明

每个申请的加一事件恰好使它在 $L_i$ 开始生效,减一事件恰好使它在 $R_i+1$ 失效,所以事件增量的前缀和就是实际覆盖数。相邻不同事件坐标之间不存在变化,每段或者全部满足要求,或者全部不满足。算法统计所有达标段的整数长度,各段互不重叠且覆盖全部可能被申请的位置,因此结果正确。

题解代码

import sys

input = sys.stdin.readline


def count_hot(segments, k):
    events = []
    for left, right in segments:
        events.append((left, 1))
        events.append((right + 1, -1))
    events.sort()
    cover = answer = 0
    previous = events[0][0]
    for position, delta in events:
        if cover >= k:
            answer += position - previous
        cover += delta
        previous = position
    return answer


def solve():
    n, k = map(int, input().split())
    segments = (tuple(map(int, input().split())) for _ in range(n))
    print(count_hot(segments, k))


if __name__ == '__main__':
    solve()

复杂度分析

时间复杂度:$O(N\log N)$,排序 $2N$ 个事件后线性扫描。

空间复杂度:$O(N)$,存储事件及排序辅助空间。

易错点

$K>N$ 时答案自然为 0;单点区间 $L_i=R_i$ 仍贡献一个整数位置。不能把答案写成所有达标闭区间端点差的简单相加。


第 3 题:将两条彩灯带删成相同交替序列

题目描述

两条彩灯带分别由字符串 $S,T$ 表示,每个字符为 R、G、B 中的一种。一次操作可以从任意一条灯带删除一盏灯,剩余灯按原顺序重新连接。

要求最终两条灯带完全相同,并且相邻灯的颜色不同。求两条灯带合计最少删除多少盏灯。可以保留空序列;“交替”仅要求相邻不同,不要求严格重复 RGB 或仅使用两种颜色。

输入描述

第一行输入 $S$,第二行输入 $T$。两串仅含大写字母 R、G、B,且 $1\le\lvert S\rvert,\lvert T\rvert\le 2000$。

输出描述

输出最少删除总数。

样例 1

输入

RRGG
RGRG

输出

4

两串均可留下 RG,各删除两盏。第一串无法形成长度大于 2 的相邻异色子序列。

样例 2

输入

RGB
RGB

输出

0

思路分析

第一步:把最少删除转成最长保留。

若最终公共序列长度为 $L$,删除数为

\[\lvert S\rvert+\lvert T\rvert-2L.\]

因此要找相邻颜色不同的最长公共子序列。普通 LCS 只记长度,不知道能否继续接某种颜色,需要增加“末尾颜色”状态。

第二步:定义状态并转移。

令 $f[i][j][c]$ 表示从两串长度分别为 $i,j$ 的前缀中,能得到的以颜色 $c$ 结尾的合法公共子序列最大长度;没有这种非空序列时记为 0,作为启动新序列的哨兵。

跳过一边末尾字符时:

\[f[i][j][c]=\max(f[i-1][j][c],f[i][j-1][c]).\]

如果 $S_{i-1}=T_{j-1}=c$,还可以选择这对字符:

\[f[i][j][c]=\max\left(f[i][j][c],1+\max_{d\ne c}f[i-1][j-1][d]\right).\]

最后一项为 1 时表示从单个字符开始,并不意味着空串真的具有末尾颜色。最终 $L$ 是三种颜色状态的最大值。

第三步:只更新当前字符对应的颜色行。

处理 $S$ 的一个新字符 ch 时,若一个公共子序列不以 ch 结尾,就不可能使用这个新位置,因而其他两种颜色的整行保持不变。只需为 ch 新建一行,同时从旧行、当前新行左侧、另外两种颜色旧行的左上角转移。不能提前覆盖另外两行,也不能从当前新行接同色。

把较短的字符串作为列方向,可将空间缩至较短串长度。

正确性证明

考虑任意最优合法公共子序列。若它未同时使用两个前缀的末尾位置,则至少有一边的末尾可以跳过,由上方或左方状态覆盖。若它使用了这两个位置,则字符必须相同,去掉末尾后得到一个末尾颜色不同的合法公共子序列,属于左上角的另外两种颜色状态。两类情况覆盖所有可能;每条转移也只跳过字符或接上异色的相同字符,因此不会产生非法序列。按前缀长度归纳,所有状态与最终答案正确。

题解代码

import sys

input = sys.stdin.readline


def min_deletions(s, t):
    if len(s) < len(t):
        s, t = t, s
    m = len(t)
    rows = {color: [0] * (m + 1) for color in 'RGB'}
    for color in s:
        other_colors = [c for c in 'RGB' if c != color]
        other_a = rows[other_colors[0]]
        other_b = rows[other_colors[1]]
        old = rows[color]
        new = [0] * (m + 1)
        for j in range(1, m + 1):
            best = max(old[j], new[j - 1])
            if t[j - 1] == color:
                best = max(best, 1 + max(other_a[j - 1], other_b[j - 1]))
            new[j] = best
        rows[color] = new
    length = max(row[m] for row in rows.values())
    return len(s) + len(t) - 2 * length


def solve():
    s = input().strip()
    t = input().strip()
    print(min_deletions(s, t))


if __name__ == '__main__':
    solve()

复杂度分析

时间复杂度:$O(\lvert S\rvert\cdot\lvert T\rvert)$,每对前缀位置只进行常数次比较。

空间复杂度:辅助空间 $O(\min(\lvert S\rvert,\lvert T\rvert))$;若计入两个输入字符串,总空间为 $O(\lvert S\rvert+\lvert T\rvert)$。

易错点

公共子序列不要求连续;不能先求普通 LCS 再删除重复颜色,因为某一条普通 LCS 不一定包含最优的交替公共子序列。两串没有公共颜色时 $L=0$,需全部删除。


第 4 题:动态点权树的断边乘积询问

题目描述

给定一棵有 $N$ 个节点、$N-1$ 条边的树,节点编号为 $1$ 到 $N$,初始点权为 $a_i$。依次处理 $Q$ 次操作:

  • 1 u x:将节点 $u$ 的点权增加 $x$,允许负增量。
  • 2 u v:对树中已有边 $(u,v)$ 进行假想断开,求两部分点权和的乘积。含节点 1 的部分记为 $A$,另一部分记为 $B$。

询问不会真的删除边,也不会影响后续操作。每个乘积对 $P=10^9+7$ 取模,输出 $[0,P-1]$ 内的非负余数。

输入描述

第一行输入 $N,Q$;第二行输入 $N$ 个初始点权;接下来 $N-1$ 行输入边的两个端点;最后 $Q$ 行输入上述操作。

约束为:

  • $2\le N,Q\le 2\times10^5$。
  • $\lvert a_i\rvert\le10^6$,修改量 $\lvert x\rvert\le10^5$。
  • 节点编号在 $[1,N]$ 内,询问的两个端点构成树中的一条边。

输出描述

每次类型 2 操作输出一行乘积的非负余数。

样例 1

输入

5 3
1 1 1 1 1
1 2
2 3
3 4
4 5
2 2 3
2 3 4
2 4 5

输出

6
6
4

三次断边分别得到大小为 2 与 3、3 与 2、4 与 1 的两部分,所有点权均为 1。

样例 2

输入

3 3
1 2 3
1 2
2 3
2 2 3
1 2 100
2 2 3

输出

9
309

第一次两部分点权和均为 3。节点 2 增加 100 后,两部分点权和为 103 和 3,乘积为 309。

思路分析

第一步:将断边对应到一个子树。

以节点 1 为根。边的两个端点必定是父子关系,设子节点为 $w$。断开后,不含根的一侧恰为 $w$ 的整棵子树。若其权值和为 $b$,全树权值和为 total,答案就是

\[b\cdot(\mathrm{total}-b)\bmod P.\]

逐次遍历子树最坏需要 $O(NQ)$;每次修改向所有祖先更新子树和也会在链状树上退化。

第二步:用 DFS 序把子树压成区间。

首次访问节点时分配编号 tin[u]。DFS 会连续处理完一个节点的全部后代,因此子树大小为 size[u] 时,其区间为

\[[\mathrm{tin}[u],\mathrm{tin}[u]+\mathrm{size}[u]-1].\]

实现采用显式栈得到 DFS 先序,避免长链导致递归栈溢出;再逆序遍历,把每个节点的大小累加到父节点。注意先序必须来自深度优先遍历,不能换成 BFS 序。

第三步:树状数组维护点加与区间和。

将点权放到 DFS 编号位置,树状数组支持单点增加和前缀求和。子树和等于两个前缀和之差,同时维护全树总和。初始树状数组可直接由 DFS 序权值数组线性构建。

正确性证明

树中每条边连接一个节点及其父节点,因此断边隔离的恰是子节点的全部后代。DFS 先序的连续性保证算法查询的区间与该子树节点集合完全相同。树状数组初始保存准确点权,每次修改只改变目标位置并同步总和,故归纳可知每次询问时子树和与全树和均准确。将两部分权值和相乘再取模,即得到题意要求的结果。

题解代码

import sys

input = sys.stdin.readline


def process_tree(weights, edges, operations):
    n = len(weights) - 1
    graph = [[] for _ in range(n + 1)]
    for u, v in edges:
        graph[u].append(v)
        graph[v].append(u)
    parent = [0] * (n + 1)
    tin = [0] * (n + 1)
    order = []
    stack = [1]
    while stack:
        u = stack.pop()
        tin[u] = len(order) + 1
        order.append(u)
        for v in graph[u]:
            if v != parent[u]:
                parent[v] = u
                stack.append(v)
    size = [1] * (n + 1)
    for u in reversed(order):
        if parent[u]:
            size[parent[u]] += size[u]
    bit = [0] + [weights[u] for u in order]
    for i in range(1, n + 1):
        j = i + (i & -i)
        if j <= n:
            bit[j] += bit[i]

    def add(i, delta):
        while i <= n:
            bit[i] += delta
            i += i & -i

    def prefix(i):
        result = 0
        while i:
            result += bit[i]
            i -= i & -i
        return result

    total = sum(weights)
    mod = 10**9 + 7
    for kind, u, value in operations:
        if kind == 1:
            add(tin[u], value)
            total += value
        else:
            v = value
            child = u if parent[u] == v else v
            left = tin[child]
            right = left + size[child] - 1
            subtree = prefix(right) - prefix(left - 1)
            yield (subtree % mod) * ((total - subtree) % mod) % mod


def solve():
    n, q = map(int, input().split())
    weights = [0] + list(map(int, input().split()))
    edges = [tuple(map(int, input().split())) for _ in range(n - 1)]
    operations = (tuple(map(int, input().split())) for _ in range(q))
    for answer in process_tree(weights, edges, operations):
        print(answer)


if __name__ == '__main__':
    solve()

复杂度分析

时间复杂度:$O(N+Q\log N)$。DFS 与线性建树为 $O(N)$,每次操作使用常数次树状数组操作。

空间复杂度:$O(N)$。邻接表、DFS 序、父节点、子树大小、栈和树状数组均为线性大小,操作与答案逐条处理。

易错点

更新是增加而不是赋值;边的输入顺序不代表父子关系;负点权可能使乘积为负,但 Python 对正模数的 % 已返回非负余数。询问不改变树的结构。


小结

  • 航线问题用中转点连接两组局部最优,避免枚举公司对。
  • 大坐标覆盖问题只处理发生变化的事件,不按坐标开数组。
  • 相邻约束可以通过记录序列末尾状态嵌入 LCS。
  • 动态子树统计先建立 DFS 区间,再复用序列数据结构。