leetcode3286 DAG 阈值猎杀:二分特工的探针三连击

🎮DAG 阈值猎杀:二分特工的探针三连击!

你是一名网络特工,代号“二分”——不是因为你长得二分,而是你解决问题时总喜欢把答案劈成两半,逼它自己招供。今天你接到的任务比以往更离谱:从总部 0 号节点出发,穿越一张由单向光缆编织的有向无环图(DAG),抵达最后一处未被黑客攻破的数据中心 n-1。沿途的中转服务器,有些还在线(online[i]=true),有些已经挂了。节点之间由单向光缆连接,每条光缆上都贴着一张维修价签 cost,明码标价。

上司甩给你一笔可怜的预算 k,要你挑一条路,把沿途经过的光缆通通维护一遍。规矩很明确:总花费不许超支,沿途服务器必须在线——你总不能对着一台冒烟的机柜敲 ping 吧。

你决定,要在所有合法路径中,找出那个最小边成本最大的路径。为什么盯着最小边?因为短板决定体验,瓶颈决定命运——哪怕路上有一根 1 块钱的劣质网线,它就能把整条万兆光纤拖成PPT播放器。让这条路上最烂的那段,也比其他所有合法路径的最烂段要强——这是属于特工的尊严,也是属于打工人的倔强。

当然,如果连一条合法路径都不存在……那就带着 -1 回去挨批。


二分阈值猎杀

这个问题说白了是个“双约束”修罗场——既要让路径上的最小边成本尽可能大,又要让总成本不超过 k。就像老板既要马儿跑得快,又要马儿不吃草一样。

但你丝毫不慌——"最大化最小值",这五个字就是二分查找的接头暗号

如果阈值 x 可行(即存在一条路径,所有边成本 ≥ x,且总成本 ≤ k),那么任何比 x 小的阈值也一定可行(因为放宽了边限制,路径只会更多)。反过来,如果 x 不可行,那么比 x 大的阈值更不可能可行。

单调性成立,猎杀开始。你像切西瓜一样,把答案范围 [l, r] 一分为二,反复猜、反复验证,直到精确命中那个最优阈值。

你给这套"猜答案 → 验答案"的流程起了个中二代号:阈值探针。

def findMaxPathScore(n: int, edges: List[List[int]], online: List[bool], k: int) -> int:
    n = len(online)
    g = [[] for _ in range(n)]
    l, r = float('inf'), 0
	
    # 建图(只走在线节点)和确定边界
    for u, v, cost in edges:
        if not online[u] or not online[v]:
            continue
		g[u].append((v, cost))
        r = max(r, cost)
        l = min(l, cost)

    # 阈值探针!
	def check(limit: int) -> bool:
        # todo: 检查阈值 limit 是否可行    
		pass
    
	# 二分答案
	if not check(l):
        return -1
    
    while l <= r:
        mid = (l + r) >> 1
        if check(mid):
            l = mid + 1
        else:
            r = mid - 1
            
	return r

现在,你必须给这个空壳探针注入灵魂。

check(limit) 要干的事很明确:在只允许走成本 ≥ limit 的边这个前提下,判断能否从 0 走到 n-1,且总花费 ≤ k


Dijkstra 最短路 + 二分(最稳健的探针)

你脑子里第一个蹦出来的,是图论祖师爷 Dijkstra 老爷子。他老人家的算法精髓就四个字:贪心 + 单调——用一个最小堆永远优先处理当前花费最小的节点,保证第一次抵达就是最优。

现在你把 Dijkstra 改装成一台"阈值检查器":

  1. 维护一个 dist 数组,记录从起点到每个节点的最小花费,初始时只有 dist[0] = 0,其余为正无穷。
  2. 堆里存 (累计花费, 节点),每次弹出花费最小的节点。如果当前花费已经超过 k——继续走就是贴钱打工,直接return False
  3. 如果弹出的恰好是终点n-1,恭喜,Dijkstra 保证第一次弹出终点就是全局最小总花费,它不超预算,意味着limit 肯定可行,return True
  4. 如果当前弹出的花费比 dist[u] 还大?说明这是个过时记录——之前已经有更便宜的路到过u了,果断扔掉。
  5. 遍历 u 的所有出边,cost < limit 的直接无视(太次,配不上你的追求),只对cost >= limit 的“硬货”做松弛:如果 dist[u] + cost < dist[v],更新 dist[v] 并入堆。

这个检查器有多稳?一次 Dijkstra 是 O(m log n),套上二分 O(log C),总复杂度 O(m log n log C),对于题目规模来说,就像用 4090 跑扫雷,绝对稳如老狗。

# 解法一:Dijkstra 最短路
def check(limit: int) -> bool:
    dis = [float('inf')] * n
    dis[0] = 0
    pq = [(0, 0)]  # (累计花费, 节点)
    while pq:
        cur, u = heapq.heappop(pq)
        if cur > k:
            return False	# 超预算,直接死刑
        if u == n-1:
            return True  	# 第一次抵达终点 = 最小花费,预算内通关
        if cur > dis[u]:
            continue  		# 过时的记录,跳过
        for v, cost in g[u]:
            if cost < limit:
                continue
            if dis[v] > dis[u] + cost:
                dis[v] = dis[u] + cost
                heapq.heappush(pq, (dis[v], v))

	return False

你刚觉得Dijkstra这把重剑稳如老狗,但转念一想——这图可是DAG啊!有向无环,意味着路径天生有序,根本不需要堆去维护什么优先级。于是你决定换一把更轻巧的匕首。


记忆化搜索 + 二分(灵巧的回溯探针)

既然 DAG 无环,那递归天然就是安全的——不用担心无限套娃,因为没有环可以绕回去。

路径天生有序,递归回溯就能把每条边捋一遍。而且 n 的范围不算太大,记忆化搜索正好派上用场:从终点倒推,一路回溯,把中间结果缓存下来,既省堆又省脑细胞。

思路也很直球:定义 dfs(u) 表示从节点 u 出发,在只允许边成本 ≥ limit 的条件下,到达终点 n-1 所需的最小总花费。

你从 0 开始递归,对于当前节点 u

  • 如果 u 恰好是终点,那就不用再花一分钱,返回 0
  • 否则,遍历 u 的所有出边 (v, cost),只挑 cost >= limit 的硬边走,递归计算 dfs(v),加上当前边的成本 cost,取所有结果中的最小值;
  • @cache 装饰器自动缓存每个 u 的计算结果——这样每个节点最多算一次,后面的递归直接复用。

最后,如果 dfs(0) <= k,说明阈值 limit 可行。

整个过程就像从终点往回铺路标:你只关心"从当前位置到终点最少花多少钱",而每个子问题结构相同,递归套娃加上缓存,把"反向推导"玩成了自动化流水线。时间复杂度O(m)(每条边最多被访问一次),乘上二分的O(log C),总复杂度O(m log C)——比 Dijkstra 省掉了整个堆操作的log n,轻快得像卸了甲的轻骑兵。

# 解法二:记忆化搜索
def check(limit: int) -> bool:
    @cache
    def dfs(u: int) -> int:
        if u == n-1:
            return 0
        ans = float('inf')
        for v, cost in g[u]:
            if cost >= limit:
                ans = min(ans, dfs(v) + cost)
        return ans
    
	return dfs(0) <= k

你舞着这把递归匕首,咧开的嘴角还没来得及合上,脑子里突然闪过一个声音:“递归深度告警。” 没错,万一哪天数据规模翻倍,你的递归栈就可能当场爆掉,留下一地 RecursionError 的碎片。

作为一名成熟的特工,你习惯把命运攥在自己手里——与其依赖黑盒子的调用栈,不如自己动手排兵布阵,勤劳迭代。于是,你收起匕首,抽出第三把武器——拓扑排序 + DP


拓扑排序 DP + 二分(终极流水线探针)

拓扑排序是 DAG 的天选搭档。它能把所有节点排成一条流水线,保证每个节点轮到它的时候,所有前驱都已经处理完毕。这意味着你可以顺着流水线一路往前递推,不需要堆来排序,不需要递归来回溯,一个普通队列就搞定。

它和记忆化搜索,本质上是同一道递推公式的两面:

  • 记忆化是 “拉”:dfs(u) 向后继节点要答案,一层层往下问,答案再一层层往回传;
  • 拓扑排序是 “推”:dp[u] 算完直接塞给后继节点,按拓扑序从前往后走,接力棒一样往下传。

区别在于——“谁先算谁后算"这件事,记忆化靠递归栈隐式决定,拓扑排序靠入度归零机制显式决定——入度归零的先上,处理完它的后继,后继入度减一,新的归零节点跟上。整个顺序由 DAG 的结构天然锁定,比递归栈靠谱得多,也比堆排序轻快得多。

不过开工前有个小坑要填:图中可能存在一些不可达的"孤岛节点”——入度为 0 但不是起点,没有任何边指向它们,从起点绝对到不了。这些孤岛如果留着,它们的后继会一直等不到入度归零,整条链路直接卡死。所以先做一轮预处理,把这些坏死组织清理干净:

def findMaxPathScore(edges: List[List[int]], online: List[bool], k: int) -> int:
	n = len(online)
    g = [[] for _ in range(n)]
    l, r = float('inf'), 0
    deg = [0] * n  		# 记录节点入度
    
    for u, v, cost in edges:
        if not online[u] or not online[v]:
            continue
        g[u].append((v, cost))
        r = max(r, cost)
        l = min(l, cost)
        deg[v] += 1   	# 更新节点入度
    
    # 阈值探针!
    # 先预处理:剥离从起点不可达的"孤岛节点"
    q = deque([i for i in range(1, n) if deg[i] == 0])   # 入度为 0 且不是起点的节点
    while q:
        u = q.popleft() 								 # 弹出一个孤岛
        for v, _ in g[u]:
            deg[v] -= 1                                  # 孤岛的后继入度减 1
            if v and deg[v] == 0:                        # 后继也变成孤岛了(且不是起点)
                q.append(v)                              # 加入剥离队列
                
	def check(limit: int) -> bool:
        # todo: 检查阈值 limit 是否可行    
		pass
    
    # 二分答案
    ......

剥完之后,剩下的 deg 就是一份干净的、只保留从起点可达结构的入度表。

接下来 check(limit) 就清爽了,三步走:

  1. 初始化:复制入度表 cdeg = deg.copy()dp数组全填无穷大,初始化 dp[0]=0,队列只放起点 0

    为什么要 deg.copy()?因为 check 里的拓扑排序是破坏性的——每处理一个节点就 cdeg[v] -= 1,跑完一次 check 后整个入度表就全被减成 0 了。但二分要调几十次 check,每次都需要一份完整的、初始状态的入度表。所以每次都得 copy 一份新的来"消耗"。

  2. 流水线推进:弹出节点 u,如果是终点n-1,直接返回 dp[u] <= k——此时所有前驱都已处理完毕;

  3. 松弛 + 传递:遍历u的出边,只走cost >= limit的硬边做松弛,更新dp[v]。注意:不管边够不够硬,cdeg[v] 都要减一——边权过滤管的是 dp 值,入度递减管的是拓扑序,两条线互不干扰。入度归零的节点入队,轮到它上场。

时间复杂度 O(m) 一次检查,套上二分O(log C),总复杂度 O(m log C)。无递归、无堆栈、遇终点提前宣判——DAG 上的性能天花板。

def findMaxPathScore(edges: List[List[int]], online: List[bool], k: int) -> int:
	n = len(online)
    g = [[] for _ in range(n)]
    l, r = float('inf'), 0
    deg = [0] * n  # 记录节点入度
    
    for u, v, cost in edges:
        if not online[u] or not online[v]:
            continue
        g[u].append((v, cost))
        r = max(r, cost)
        l = min(l, cost)
        deg[v] += 1  
    
    # 阈值探针!
    # 预处理:剥离从起点不可达的"孤岛节点"
    q = deque([i for i in range(1, n) if deg[i] == 0])   
    while q:
        u = q.popleft() 								 
        for v, _ in g[u]:
            deg[v] -= 1                                  
            if v and deg[v] == 0:                        
                q.append(v)                              

	def check(limit: int) -> bool:
        dp = [float('inf')] * n		# dp[i]: 从起点到 i 的最小总花费
    	cdeg = deg.copy()       	# 拷贝入度——拓扑排序会消耗它,不能动原件
        dp[0] = 0        
        q = deque([0])
        while q:
            u = q.popleft()
            if u == n - 1:
                return dp[u] <= k	# 到达终点,提前宣判!
            for v, cost in g[u]:
                if cost >= limit:
                    dp[v] = min(dp[v], dp[u] + cost)  	# 松弛:只走够硬的边
                cdeg[v] -= 1							# 入度必减——跟边权无关,这是拓扑序的事
                if cdeg[v] == 0:						# 前驱全部处理完毕,轮到 v 上场
                    q.append(v)
		return False
        
	# 二分答案
    ......

尾声

探针归位,二分收敛——这是你第无数次看着二分框架优雅地合拢,像狙击镜里十字准星缓缓锁住目标。

Dijkstra 重剑无锋,记忆化匕首如风,拓扑排序流水线降维打击——三连击打完收工,最终撑起整个猎杀框架的,还是那招最朴素的祖传手艺——二分:把一个"既要又要"的双约束难题,劈成一道道简单的判断题:“够不够?行不行?能还是不能?”

知识共享许可协议
本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。