Operations on Tree

Medium
9 days ago

You are given a tree with n nodes numbered from 0 to n - 1 in the form of a parent array parent where parent[i] is the parent of the i<sup>th</sup> node. The root of the tree is node 0, so parent[0] = -1 since it has no parent. You want to design a data structure that allows users to lock, unlock, and upgrade nodes in the tree. Implement the LockingTree class with lock, unlock, and upgrade methods. Here's how they should function:

  • Lock(int num, int user): Locks the given node for the given user and prevents other users from locking the same node. You may only lock a node if it's unlocked.
  • Unlock(int num, int user): Unlocks the given node for the given user. You may only unlock a node if it's currently locked by the same user.
  • Upgrade(int num, int user): Locks the given node for the given user and unlocks all of its descendants, regardless of who locked them. You may only upgrade a node if all three of these conditions are met:
    • The node is unlocked.
    • It has at least one locked descendant (by any user).
    • It does not have any locked ancestors.

For example, given parent = [-1, 0, 0, 1, 1, 2, 2], demonstrate how the LockingTree class would function:

LockingTree lockingTree = new LockingTree([-1, 0, 0, 1, 1, 2, 2]);
lockingTree.lock(2, 2);    // return true, node 2 locked by user 2
lockingTree.unlock(2, 3);  // return false, user 3 can't unlock node 2
lockingTree.unlock(2, 2);  // return true, node 2 unlocked
lockingTree.lock(4, 5);    // return true, node 4 locked by user 5
lockingTree.upgrade(0, 1); // return true, node 0 locked by user 1, node 4 unlocked
lockingTree.lock(0, 1);    // return false, node 0 already locked
Sample Answer
class LockingTree:

    def __init__(self, parent: List[int]):
        self.parent = parent
        self.n = len(parent)
        self.locked = [0] * self.n  # 0: unlocked, user_id: locked by user_id
        self.children = [[] for _ in range(self.n)]
        for i in range(1, self.n):
            self.children[parent[i]].append(i)

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

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

    def upgrade(self, num: int, user: int) -> bool:
        if self.locked[num] != 0:
            return False
        
        # Check for locked ancestors
        p = self.parent[num]
        while p != -1:
            if self.locked[p] != 0:
                return False
            p = self.parent[p]
        
        # Check for locked descendants and unlock them
        has_locked_descendant = False
        locked_descendants = []

        def dfs(node):
            nonlocal has_locked_descendant
            if self.locked[node] != 0:
                has_locked_descendant = True
                locked_descendants.append(node)
            for child in self.children[node]:
                dfs(child)

        dfs(num)

        if not has_locked_descendant:
            return False
        
        # Unlock descendants
        for node in locked_descendants:
            self.locked[node] = 0
        
        # Lock the current node
        self.locked[num] = user
        return True


# Your LockingTree object will be instantiated and called as such:
# obj = LockingTree(parent)
# param_1 = obj.lock(num,user)
# param_2 = obj.unlock(num,user)
# param_3 = obj.upgrade(num,user)

Naive Solution

A naive solution would involve directly implementing the given functions lock, unlock, and upgrade as described in the problem statement. For the upgrade function, one could perform a depth-first search (DFS) to identify and unlock all descendants. The time complexity of the naive solution could be significant, especially for the upgrade operation, as it might involve traversing a large portion of the tree.

Optimal Solution

The provided Python code represents an optimized solution for the problem. It uses the following strategies:

  1. __init__(self, parent): Initializes the tree structure using the parent array. It also precomputes the children for each node to facilitate efficient traversal during the upgrade operation. A locked array is used to keep track of the lock status of each node.
  2. lock(self, num, user): Checks if the node num is unlocked. If it is, it locks the node for the given user and returns True. Otherwise, it returns False.
  3. unlock(self, num, user): Checks if the node num is locked by the given user. If it is, it unlocks the node and returns True. Otherwise, it returns False.
  4. upgrade(self, num, user): This is the most complex operation. It first checks if the node num is unlocked and has no locked ancestors. Then, it performs a DFS to find all locked descendants. If such descendants exist, it unlocks them and locks the node num for the given user. The use of DFS ensures that all descendants are visited to check for locked nodes.

Big(O) Run-time Analysis

  • __init__(self, parent): O(n), where n is the number of nodes, because it iterates through the parent array to build the children list.
  • lock(self, num, user): O(1), as it only involves checking and updating the locked array at the given index.
  • unlock(self, num, user): O(1), similar to the lock function, it only involves checking and updating the locked array.
  • upgrade(self, num, user): O(n) in the worst case, where n is the number of nodes. This is because, in the worst-case scenario, the DFS might have to visit all the nodes in the tree to find locked descendants. The ancestor check also takes O(h) time where h is the height of the tree, which can be O(n) in worst case.

Big(O) Space Usage Analysis

  • __init__(self, parent): O(n), where n is the number of nodes. This is because the locked array and the children list both store information for each node in the tree.
  • lock(self, num, user): O(1), as it doesn't use any additional space.
  • unlock(self, num, user): O(1), similar to the lock function, it doesn't use any additional space.
  • upgrade(self, num, user): O(n) in the worst case due to the recursion stack of the DFS and the locked_descendants list. In the worst case, the recursion stack can grow up to the height of the tree, and the locked_descendants list can contain all nodes.

Edge Cases

  1. Invalid Node Numbers: The code assumes that the node numbers are valid and within the range [0, n-1]. Adding checks for invalid node numbers can prevent potential errors.
  2. Invalid User IDs: The code assumes that user IDs are valid. Additional validation can be added if needed.
  3. Concurrent Access: The code is not thread-safe. If multiple threads access the tree simultaneously, race conditions can occur. Synchronization mechanisms like locks should be used to ensure thread safety.
  4. Cyclic Parent-Child Relationship: The code assumes that the parent array represents a valid tree structure without cycles. If there are cycles, the DFS in the upgrade function might enter an infinite loop. Input validation should be performed to detect cycles.
  5. Empty Tree: Although the constraints specify n >= 2, handling an empty tree (n = 0) in the constructor would make the class more robust.
  6. Root Locking/Unlocking: While the problem statement specifies that parent[0] = -1, one could add a check in the lock/unlock functions to ensure the root cannot be directly locked or unlocked (if the problem requires this).