1993. Operations On Tree - Explanation

Problem Link

Description

Implement LockingTree for a rooted tree described by parent. Node 0 is the root and has parent -1; every other entry identifies that node's parent. All nodes start unlocked.

  • lock(num, user) locks num for user if that node is currently unlocked. It does not check ancestors or descendants.
  • unlock(num, user) unlocks num only if that same user currently owns its lock.
  • upgrade(num, user) succeeds only when num is unlocked, every ancestor of num is unlocked, and at least one proper descendant is locked. On success, unlock every descendant regardless of its owner and lock num for user.

Each operation returns true when it succeeds and false otherwise. A failed operation leaves every lock unchanged. A proper descendant excludes the node itself.

Example 1:

Operations on Tree, example 1: illustration of the example

Input: ["LockingTree","lock","unlock","upgrade","unlock","unlock"]
[[[-1,0,0]],[1,7],[1,8],[0,4],[1,7],[0,4]]

Output: [null,true,false,true,false,true]

Explanation: User 7 locks node 1; user 8 cannot unlock it. Upgrading root 0 succeeds because it has a locked descendant and no locked ancestor. This clears node 1 and locks node 0 for user 4, so only the final unlock succeeds.

Example 2:

Operations on Tree, example 2: illustration of the example

Input: ["LockingTree","lock","lock","upgrade","unlock","upgrade"]
[[[-1,0,1]],[0,1],[2,2],[1,3],[0,1],[1,3]]

Output: [null,true,true,false,true,true]

Explanation: Both ordinary lock calls succeed, even though node 0 is an ancestor of node 2. Upgrading node 1 initially fails because node 0 is locked. After unlocking node 0, the upgrade succeeds and clears node 2.

Example 3:

Operations on Tree, example 3: illustration of the example

Input: ["LockingTree","upgrade","lock","upgrade","unlock","lock","upgrade","unlock"]
[[[-1,0,0]],[0,1],[0,1],[0,2],[0,1],[2,99],[1,3],[2,99]]

Output: [null,false,true,false,true,true,false,true]

Explanation: An upgrade with no locked descendant fails. An already locked node also cannot be upgraded. Finally, locked node 2 is a sibling of node 1, so upgrading node 1 fails and leaves node 2 locked; its owner can still unlock it.

Constraints:

  • 2 <= parent.length <= 2000
  • parent[0] == -1
  • Every other parent is a valid node index, and parent describes a valid tree rooted at node 0.
  • 0 <= num < parent.length
  • 1 <= user <= 10000
  • At most 2000 calls are made to lock, unlock, and upgrade combined.


Topics

Company Tags

Please upgrade to NeetCode Pro to view company tags.



Prerequisites

Before attempting this problem, you should be comfortable with:

  • Tree Data Structure - Understanding parent-child relationships and tree traversal patterns
  • Depth-First Search (DFS) - Recursive and iterative traversal of tree nodes to visit all descendants
  • Breadth-First Search (BFS) - Level-by-level tree traversal using a queue
  • Building Adjacency Lists - Converting parent array representation to child adjacency lists for efficient traversal

Intuition

We need to simulate a locking system on a tree structure. The key insight is that lock and unlock are simple O(1) operations since they only check and modify a single node. The upgrade operation is more complex: it requires checking all ancestors (no locks allowed) and all descendants (at least one lock required, then unlock all).

For the ancestor check, we traverse up the parent chain. For the descendant check and unlock, we use dfs to visit all nodes in the subtree, counting and clearing any locks.

Algorithm

  1. Initialization: Build a child adjacency list from the parent array and create a locked array to track which user (if any) has locked each node.
  2. lock(num, user): If the node is unlocked (locked[num] == 0), set locked[num] = user and return true. Otherwise return false.
  3. unlock(num, user): If locked[num] equals user, set it to 0 and return true. Otherwise return false.
  4. upgrade(num, user):
    • Walk up the parent chain from num. If any ancestor is locked, return false.
    • Run dfs on the subtree rooted at num: count locked descendants and unlock them.
    • If at least one descendant was locked, lock num with user and return true. Otherwise return false.
class LockingTree:

    def __init__(self, parent: List[int]):
        self.parent = parent
        self.child = [[] for _ in range(len(parent))]
        self.locked = [0] * len(parent)
        for node in range(1, len(parent)):
            self.child[parent[node]].append(node)

    def lock(self, num: int, user: int) -> bool:
        if self.locked[num]:
            return False
        self.locked[num] = user
        return True

    def unlock(self, num: int, user: int) -> bool:
        if self.locked[num] != user:
            return False
        self.locked[num] = 0
        return True

    def upgrade(self, num: int, user: int) -> bool:
        node = num
        while node != -1:
            if self.locked[node]:
                return False
            node = self.parent[node]

        def dfs(node):
            lockedCount = 0
            if self.locked[node]:
                lockedCount += 1
                self.locked[node] = 0

            for nei in self.child[node]:
                lockedCount += dfs(nei)
            return lockedCount

        if dfs(num) > 0:
            self.locked[num] = user
            return True
        return False

Time & Space Complexity

  • Time complexity:
    • O(n)O(n) time for initialization.
    • O(1)O(1) time for each lock()lock() and unlock()unlock() function call.
    • O(n)O(n) time for each upgrade()upgrade() function call.
  • Space complexity: O(n)O(n)

Intuition

This solution replaces the recursive dfs for the descendant traversal with an iterative BFS using a queue. The logic remains identical: lock and unlock are O(1) operations, while upgrade requires ancestor and descendant checks.

BFS processes nodes level by level, which can be more cache-friendly in some cases and avoids potential stack overflow issues with very deep trees.

Algorithm

  1. Initialization: Same as dfs, build child lists and initialize the locked array.
  2. lock and unlock: Same O(1) checks and updates.
  3. upgrade(num, user):
    • Check ancestors by walking up the parent chain.
    • Use a queue to traverse all descendants, counting and unlocking any locked nodes.
    • If lockedCount > 0, lock num for user and return true.
class LockingTree:

    def __init__(self, parent: List[int]):
        self.parent = parent
        self.child = [[] for _ in range(len(parent))]
        self.locked = [0] * len(parent)
        for node in range(1, len(parent)):
            self.child[parent[node]].append(node)

    def lock(self, num: int, user: int) -> bool:
        if self.locked[num]:
            return False
        self.locked[num] = user
        return True

    def unlock(self, num: int, user: int) -> bool:
        if self.locked[num] != user:
            return False
        self.locked[num] = 0
        return True

    def upgrade(self, num: int, user: int) -> bool:
        node = num
        while node != -1:
            if self.locked[node]:
                return False
            node = self.parent[node]

        lockedCount, q = 0, deque([num])
        while q:
            node = q.popleft()
            if self.locked[node]:
                self.locked[node] = 0
                lockedCount += 1
            q.extend(self.child[node])

        if lockedCount:
            self.locked[num] = user
            return True
        return False

Time & Space Complexity

  • Time complexity:
    • O(n)O(n) time for initialization.
    • O(1)O(1) time for each lock()lock() and unlock()unlock() function call.
    • O(n)O(n) time for each upgrade()upgrade() function call.
  • Space complexity: O(n)O(n)

3. Iterative DFS

Intuition

This approach converts the recursive dfs into an iterative version using an explicit stack. This is useful when you want to avoid recursion overhead or potential stack overflow on very large trees, while maintaining the depth-first traversal order.

The core logic for all three operations remains unchanged from the recursive dfs solution.

Algorithm

  1. Initialization: Build child adjacency lists and initialize the locked array.
  2. lock and unlock: Same O(1) checks.
  3. upgrade(num, user):
    • Traverse ancestors to ensure none are locked.
    • Use a stack to perform iterative dfs on descendants.
    • Pop nodes, check if locked, unlock if so, and push children.
    • If any descendants were locked, lock num and return true.
class LockingTree:

    def __init__(self, parent: List[int]):
        self.parent = parent
        self.child = [[] for _ in range(len(parent))]
        self.locked = [0] * len(parent)
        for node in range(1, len(parent)):
            self.child[parent[node]].append(node)

    def lock(self, num: int, user: int) -> bool:
        if self.locked[num]:
            return False
        self.locked[num] = user
        return True

    def unlock(self, num: int, user: int) -> bool:
        if self.locked[num] != user:
            return False
        self.locked[num] = 0
        return True

    def upgrade(self, num: int, user: int) -> bool:
        node = num
        while node != -1:
            if self.locked[node]:
                return False
            node = self.parent[node]

        lockedCount, stack = 0, [num]
        while stack:
            node = stack.pop()
            if self.locked[node]:
                self.locked[node] = 0
                lockedCount += 1
            stack.extend(self.child[node])

        if lockedCount:
            self.locked[num] = user
            return True
        return False

Time & Space Complexity

  • Time complexity:
    • O(n)O(n) time for initialization.
    • O(1)O(1) time for each lock()lock() and unlock()unlock() function call.
    • O(n)O(n) time for each upgrade()upgrade() function call.
  • Space complexity: O(n)O(n)

Common Pitfalls

Not Checking All Ancestors for Upgrade

The upgrade operation requires that no ancestor of the node is locked. Checking only the immediate parent is insufficient. You must traverse the entire path from the node to the root, verifying each ancestor is unlocked before proceeding.

Forgetting to Unlock Descendants Before Locking

When upgrade succeeds, all locked descendants must be unlocked before locking the current node. Some solutions lock the node first, which means when traversing descendants, the node itself gets incorrectly counted or unlocked. Process descendants completely before modifying the current node.

Returning True When No Descendants Are Locked

The upgrade operation requires at least one locked descendant. Even if all other conditions are met (node unlocked, ancestors unlocked), if no descendant is locked, the operation must return false. Always verify the locked descendant count is greater than zero before returning success.