LeetCode 3530 - Maximum Profit from Valid Topological Order in DAG
The problem requires computing the maximum possible profit achievable by processing nodes of a Directed Acyclic Graph (DAG) in a valid topological order.
Difficulty: 🔴 Hard
Topics: Array, Dynamic Programming, Bit Manipulation, Graph Theory, Topological Sort, Bitmask
Solution
Problem Understanding
The problem requires computing the maximum possible profit achievable by processing nodes of a Directed Acyclic Graph (DAG) in a valid topological order. Each node has a score, and the profit contribution of a node is its score multiplied by its position in the processing order (1-based). The goal is to determine an ordering of the nodes that respects all DAG dependencies and maximizes the total profit.
The input consists of three parts: n, the number of nodes; edges, a list of directed edges defining the DAG; and score, an array of integers representing each node's score. The output is a single integer, the maximum achievable profit.
Constraints reveal that n can be at most 22, which is small enough to allow bitmask dynamic programming over subsets of nodes. The DAG guarantee ensures there are no cycles, which simplifies topological ordering. The score range is large (up to 10^5), so correctness requires careful ordering to maximize the weighted sum.
Important edge cases include a single-node DAG, a DAG with multiple independent nodes, and DAGs where many nodes depend on a single node or vice versa. The graph is guaranteed acyclic, so topological sorting is always possible.
Approaches
A brute-force approach would enumerate all valid topological orders, compute their profits, and return the maximum. This involves generating permutations constrained by edges, which is factorial in n and infeasible for n = 22.
The key insight for an optimal solution is to use dynamic programming over subsets of nodes (bitmask DP). For each subset of nodes, we track the maximum profit achievable by processing exactly those nodes. At each step, we can only add nodes whose dependencies are already in the subset. By iterating over all valid subsets and updating profits incrementally, we can efficiently compute the maximum profit.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n!) | O(n) | Enumerates all valid topological orders; infeasible for n = 22 |
| Bitmask DP | O(n * 2^n) | O(2^n) | Uses DP over subsets to choose valid next nodes and maximize profit |
Algorithm Walkthrough
- Construct an adjacency list from the
edgesarray for easy dependency checking. Also, compute a bitmaskpre[i]for each nodeirepresenting the set of nodes that must appear beforei. - Initialize a DP array
dpof size2^nwith all entries as-1exceptdp[0] = 0. Here,dp[mask]will store the maximum profit achievable by processing nodes in the subset represented bymask. - Iterate over all possible subsets of nodes represented by
maskfrom0to2^n - 1. - For each node
inot inmask, check if all its prerequisites are inmaskusing the bitmaskpre[i]. If true, calculate the next maskmask | (1 << i)and updatedp[next_mask]as the maximum of its current value ordp[mask] + score[i] * (number_of_set_bits(mask) + 1). - After processing all subsets,
dp[(1 << n) - 1]contains the maximum profit achievable by processing all nodes.
Why it works: The DP ensures that at every subset, we consider all valid nodes that can be appended to the current order, respecting the DAG dependencies. The bitmask tracks exactly which nodes are used, ensuring no invalid topological order is counted. This problem asks us to construct a valid topological ordering of a Directed Acyclic Graph (DAG) in a way that maximizes a weighted sum of node scores based on their positions in the ordering.
We are given a DAG with n nodes labeled from 0 to n - 1. Each directed edge u → v enforces a constraint that node u must appear before node v in any valid ordering. Each node i has a numeric value score[i]. Once we choose a valid topological order, each node is assigned a 1-based position in that order. The total profit is computed as the sum over all nodes of:
score[i] * position[i]
The task is to find the maximum possible profit among all valid topological orderings.
The constraints are critical: n ≤ 22, which strongly indicates an exponential algorithm with bitmask dynamic programming is intended. A naive factorial exploration of all topological orders is impossible, but 2^n state DP is feasible.
The input guarantees the graph is a DAG, meaning at least one valid topological ordering always exists. Edges may be sparse or dense.
Important edge cases include graphs with no edges (any permutation is valid), chains (fully constrained ordering), and nodes with shared dependencies that restrict ordering heavily.
Approaches
The brute-force approach is to enumerate all valid topological orders of the DAG and compute the profit for each ordering. This can be done via backtracking with indegree tracking or DFS with visited states. While correct, this approach explores all permutations consistent with constraints, leading to factorial complexity in the worst case.
The optimal solution uses bitmask dynamic programming. The key insight is that the only thing that matters for future decisions is which nodes have already been placed, not the exact order itself. Once we know the subset of selected nodes, we also know the next position index implicitly. For a node to be eligible next, all its prerequisites must already be included in the current subset. This transforms the problem into a subset DP where each state represents a partial topological ordering.
We precompute prerequisite masks for each node and then build a DP over subsets, trying to place valid next nodes and accumulating profit based on the current position.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n!) | O(n) | Enumerates all valid topological orders via backtracking |
| Optimal (Bitmask DP) | O(n · 2^n) | O(2^n + n^2) | DP over subsets with prerequisite constraints |
Algorithm Walkthrough
We define a bitmask DP where each state represents which nodes have already been placed in the ordering.
- We represent each subset of nodes using a bitmask of size
n. If bitiis set, nodeiis already placed in the ordering. This compact representation allows us to efficiently track all partial solutions. - For each node, we compute a prerequisite bitmask. If there is an edge
u → v, thenuis added toprereq[v]. This allows us to quickly check whether a node is eligible to be placed at any point. - We define a DP array
dp[mask], wheremaskrepresents the set of already chosen nodes. The value stored is the maximum profit achievable after placing exactly those nodes in some valid order. - The transition is to try adding any node
inot inmask. However, nodeican only be placed if all its prerequisites are already included inmask. This is checked using bit operations. - If node
iis valid, we compute the next masknew_mask = mask | (1 << i). The position of nodeiispopcount(mask) + 1, since we are building the ordering incrementally. - We update:
dp[new_mask] = max(dp[new_mask], dp[mask] + score[i] * position)
- We iterate masks in increasing order so that all smaller subsets are processed before larger ones, ensuring transitions are valid.
- The answer is
dp[(1 << n) - 1], where all nodes are placed.
Why it works
The DP invariant is that dp[mask] always stores the maximum profit achievable by any valid topological ordering of exactly the nodes in mask. Since every transition respects dependency constraints, we never violate the DAG ordering rules. Because every valid topological ordering corresponds to exactly one path through this DP state space, and we explore all valid transitions, we guarantee optimality.
Python Solution
from typing import List
class Solution:
def maxProfit(self, n: int, edges: List[List[int]], score: List[int]) -> int:
pre = [0] * n
for u, v in edges:
pre[v] |= 1 << u
dp = [-1] * (1 << n)
dp[0] = 0
for mask in range(1 << n):
if dp[mask] == -1:
continue
cnt = bin(mask).count('1')
for i in range(n):
if not (mask & (1 << i)) and (mask & pre[i]) == pre[i]:
next_mask = mask | (1 << i)
dp[next_mask] = max(dp[next_mask], dp[mask] + score[i] * (cnt + 1))
return dp[(1 << n) - 1]
The Python implementation first encodes dependencies as bitmasks. The DP array dp tracks maximum profit for each subset. We iterate through all subsets, adding eligible nodes to update profits. The bin(mask).count('1') gives the current number of nodes processed, used to compute the 1-based multiplier.
prereq = [0] * n
for u, v in edges:
prereq[v] |= (1 << u)
size = 1 << n
dp = [-10**18] * size
dp[0] = 0
for mask in range(size):
if dp[mask] < 0:
continue
pos = bin(mask).count("1") + 1
for i in range(n):
if mask & (1 << i):
continue
# check prerequisites
if (prereq[i] & mask) != prereq[i]:
continue
new_mask = mask | (1 << i)
new_score = dp[mask] + score[i] * pos
if new_score > dp[new_mask]:
dp[new_mask] = new_score
return dp[size - 1]
### Code Explanation
We first build a prerequisite bitmask for each node, encoding all nodes that must appear before it. Then we initialize a DP array over all subsets of nodes, setting the empty set to zero profit.
For each state `mask`, we compute the current position based on how many nodes have already been selected. We then try to add each node that is not yet included. The prerequisite check ensures that we only place valid nodes according to the DAG constraints.
Each valid transition updates the DP state for the new subset. Finally, we return the full mask state representing all nodes placed.
## Go Solution
```go
func maxProfit(n int, edges [][]int, score []int) int {
pre := make([]int, n)
for _, e := range edges {
u, v := e[0], e[1]
pre[v] |= 1 << u
}
size := 1 << n
dp := make([]int, size)
for i := range dp {
dp[i] = -1
}
dp[0] = 0
for mask := 0; mask < size; mask++ {
if dp[mask] == -1 {
continue
}
cnt := 0
temp := mask
for temp > 0 {
cnt += temp & 1
temp >>= 1
}
for i := 0; i < n; i++ {
if mask&(1<<i) == 0 && mask&pre[i] == pre[i] {
nextMask := mask | (1 << i)
profit := dp[mask] + score[i]*(cnt+1)
if profit > dp[nextMask] {
dp[nextMask] = profit
}
}
}
}
return dp[size-1]
}
In Go, we handle bit counting manually instead of bin(mask).count('1'). The logic is otherwise identical to Python, iterating over masks and updating DP for valid next nodes.
Worked Examples
Example 1: n = 2, edges = [[0,1]], score = [2,3]
| mask | processed nodes | eligible next node | cnt | dp update |
|---|---|---|---|---|
| 0b00 | none | 0 | 0 | dp[0b01] = 2*1 = 2 |
| 0b01 | 0 | 1 | 1 | dp[0b11] = 2 + 3*2 = 8 |
Final dp[0b11] = 8
Example 2: n = 3, edges = [[0,1],[0,2]], score = [1,6,3]
| mask | processed nodes | eligible next node | cnt | dp update |
|---|---|---|---|---|
| 0b000 | none | 0 | 0 | dp[0b001] = 1*1 = 1 |
| 0b001 | 0 | 1,2 | 1 | dp[0b011] = 1 + 6_2 = 13, dp[0b101] = 1 + 3_2 = 7 |
| 0b011 | 0,1 | 2 | 2 | dp[0b111] = 13 + 3*3 = 22 |
| 0b101 | 0,2 | 1 | 2 | dp[0b111] = max(22, 7 + 6*3 = 25) = 25 |
Final dp[0b111] = 25 prereq := make([]int, n)
for _, e := range edges {
u, v := e[0], e[1]
prereq[v] |= 1 << u
}
size := 1 << n
dp := make([]int, size)
for i := 1; i < size; i++ {
dp[i] = -1 << 60
}
dp[0] = 0
for mask := 0; mask < size; mask++ {
if dp[mask] < 0 {
continue
}
pos := 0
for x := mask; x > 0; x >>= 1 {
pos += x & 1
}
pos++
for i := 0; i < n; i++ {
if mask&(1<<i) != 0 {
continue
}
if prereq[i]&mask != prereq[i] {
continue
}
newMask := mask | (1 << i)
newVal := dp[mask] + score[i]*pos
if newVal > dp[newMask] {
dp[newMask] = newVal
}
}
}
return dp[size-1]
}
### Go-specific Notes
In Go, we manually compute the population count using bit shifts instead of a built-in function. We also initialize the DP array with a very small integer value to represent negative infinity. Slice-based DP is used since Go does not have native dynamic arrays like Python lists.
## Worked Examples
### Example 1
Input:
n = 2 edges = [[0,1]] score = [2,3]
Prerequisites:
- `prereq[1] = {0}`
- `prereq[0] = {}`
DP evolution:
| mask | nodes placed | valid next nodes | dp value |
| --- | --- | --- | --- |
| 00 | {} | 0 | 0 |
| 01 | {0} | 1 | 2 |
| 11 | {0,1} | none | 8 |
Step details:
1. Start with `mask = 00`, position = 1
2. Place node 0 → profit = 2 * 1 = 2
3. From mask `01`, position = 2
4. Place node 1 → profit = 3 * 2 = 6
5. Total = 8
Final answer = 8
### Example 2
Input:
n = 3 edges = [[0,1],[0,2]] score = [1,6,3]
Prerequisites:
- `prereq[1] = {0}`
- `prereq[2] = {0}`
DP highlights:
| mask | valid nodes | best action |
| --- | --- | --- |
| 000 | 0 | start |
| 001 | 1,2 invalid (need 0) | place 0 |
| 001 (after 0) | 1,2 | choose 2 first |
| 011 | 1 | then 1 |
Optimal sequence: `[0,2,1]`
Profit:
- 0 at pos 1 → 1
- 2 at pos 2 → 6
- 1 at pos 3 → 18
Total = 25
## Complexity Analysis
| Measure | Complexity | Explanation |
| --- | --- | --- |
| Time | O(n * 2^n) | Iterates over 2^n subsets and for each subset checks n nodes for eligibility |
| Space | O(2^n) | Stores DP for each subset mask |
The DP approach is feasible because `n <= 22`, making `2^n ≈ 4 million`, which is acceptable for modern computation.
| Time | O(n · 2^n) | Each subset is processed, and for each subset we try up to n nodes |
| Space | O(2^n + n) | DP array of size 2^n and prerequisite masks |
The exponential complexity is acceptable because `n ≤ 22`, making `2^n ≈ 4 million`, which is feasible with efficient transitions.
## Test Cases
sol = Solution()
Example 1
assert sol.maxProfit(2, [[0,1]], [2,3]) == 8 # simple two-node DAG
Example 2
assert sol.maxProfit(3, [[0,1],[0,2]], [1,6,3]) == 25 # forked DAG
Single node
assert sol.maxProfit(1, [], [10]) == 10
Linear DAG
assert sol.maxProfit(4, [[0,1],[1,2],[2,3]], [1,2,3,4]) == 30 # 11 + 22 + 33 + 44 = 30
Independent nodes
assert sol.maxProfit(3, [], [5,10,1]) == 31 # order nodes descending by score: 11+102+5*3 = 31
Complex DAG
assert sol.maxProfit(4, [[0,1],[0,2],[1,3],[2,3]], [3,2,1,4]) == 29
| Test | Why |
assert Solution().maxProfit(2, [[0,1]], [2,3]) == 8 # basic chain dependency
assert Solution().maxProfit(3, [[0,1],[0,2]], [1,6,3]) == 25 # branching dependency optimal ordering
assert Solution().maxProfit(3, [], [1,2,3]) == 14 # no edges, best descending order
assert Solution().maxProfit(1, [], [10]) == 10 # single node
assert Solution().maxProfit(4, [[0,1],[1,2],[2,3]], [1,1,1,1]) == 10 # strict chain
assert Solution().maxProfit(3, [[1,2]], [5,1,10]) == 35 # independent + dependency mix
| Test | Why |
|---|---|
| single edge chain | validates basic dependency handling |
| branching DAG | tests optimal ordering choice |
| no edges | checks permutation optimization |
| single node | minimal boundary case |
| long chain | ensures strict ordering propagation |
| mixed constraints | validates partial dependency structure |
Edge Cases
One important edge case is when the graph has no edges. In this case, every permutation is valid, and the algorithm should automatically choose descending score values first since earlier positions have smaller multipliers. The DP naturally handles this because it will always pick high-value nodes earlier to maximize contribution.
Another edge case is a fully linear chain of dependencies. Here, there is exactly one valid topological order. The DP should behave like a simple accumulation, and any deviation would indicate incorrect prerequisite handling.
A third edge case involves multiple independent subgraphs. In such cases, the optimal solution interleaves nodes from different components based on score magnitude. The DP correctly handles this because independence means prerequisite masks are empty across components, allowing flexible ordering decisions.