Taro Logo

Operations on Tree

Medium
Google logo
Google
2 views
Topics:
Trees

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.

The data structure should support the following functions:

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

Implement the LockingTree class:

  • LockingTree(int[] parent) initializes the data structure with the parent array.
  • lock(int num, int user) returns true if it is possible for the user with id user to lock the node num, or false otherwise. If it is possible, the node num will become locked by the user with id user.
  • unlock(int num, int user) returns true if it is possible for the user with id user to unlock the node num, or false otherwise. If it is possible, the node num will become unlocked.
  • upgrade(int num, int user) returns true if it is possible for the user with id user to upgrade the node num, or false otherwise. If it is possible, the node num will be upgraded.

For example, consider the following sequence of operations:

LockingTree lockingTree = new LockingTree([-1, 0, 0, 1, 1, 2, 2]);
lockingTree.lock(2, 2);    // return true
lockingTree.unlock(2, 3);  // return false
lockingTree.unlock(2, 2);  // return true
lockingTree.lock(4, 5);    // return true
lockingTree.upgrade(0, 1); // return true
lockingTree.lock(0, 1);    // return false

How would you implement this LockingTree class, and what would be the time and space complexity of each operation?

Solution


Naive Solution

A naive approach to solving this problem would be to directly implement the given functions lock, unlock, and upgrade as described. This would involve:

  1. Storing the parent array as given.
  2. Maintaining a separate array or hash map to track which nodes are locked and by which user.
  3. Implementing the lock and unlock functions by simply checking the lock status and updating it if the conditions are met.
  4. Implementing the upgrade function by checking the specified conditions (unlocked node, at least one locked descendant, no locked ancestors) and then performing the lock and unlock operations.

Code (Python)

class LockingTree:
    def __init__(self, parent: List[int]):
        self.parent = parent
        self.locked = [0] * len(parent)  # 0: unlocked, user_id: locked by user_id
        self.children = [[] for _ in range(len(parent))]
        for i in range(1, len(parent)):
            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
        curr = self.parent[num]
        while curr != -1:
            if self.locked[curr] != 0:
                return False
            curr = self.parent[curr]

        # Check for locked descendants and unlock them
        locked_descendants = []
        def find_locked_descendants(node):
            if self.locked[node] != 0:
                locked_descendants.append(node)
            for child in self.children[node]:
                find_locked_descendants(child)

        find_locked_descendants(num)

        if not locked_descendants:
            return False

        # Unlock all locked descendants
        for node in locked_descendants:
            self.locked[node] = 0

        # Lock the current node
        self.locked[num] = user
        return True

Time Complexity

  • lock(): O(1)
  • unlock(): O(1)
  • upgrade(): O(N) in the worst case, where N is the number of nodes (due to ancestor and descendant checks).

Space Complexity

  • O(N) to store the parent array, locked array, and children list.

Optimal Solution

The optimal solution focuses on improving the upgrade function's efficiency. The key improvement involves pre-calculating and storing the descendants of each node to avoid repeatedly traversing the tree.

  1. Precompute Children: Create an adjacency list representing the tree's structure, where each index i contains a list of its children.
  2. Precompute Ancestors: While we have the parent array, directly accessing it is efficient, and we don't necessarily need to precompute all ancestors.
  3. lock and unlock functions: Remain the same, with O(1) complexity.
  4. upgrade Function: The primary optimization will be in checking descendants quickly using the precomputed children.

Optimized Code (Python)

class LockingTree:
    def __init__(self, parent: List[int]):
        self.parent = parent
        self.locked = [0] * len(parent)
        self.children = [[] for _ in range(len(parent))]
        for i in range(1, len(parent)):
            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
        curr = self.parent[num]
        while curr != -1:
            if self.locked[curr] != 0:
                return False
            curr = self.parent[curr]

        # Check for locked descendants and unlock them
        locked_descendants = []
        def find_locked_descendants(node):
            if self.locked[node] != 0:
                locked_descendants.append(node)
            for child in self.children[node]:
                find_locked_descendants(child)

        find_locked_descendants(num)

        if not locked_descendants:
            return False

        # Unlock all locked descendants
        for node in locked_descendants:
            self.locked[node] = 0

        # Lock the current node
        self.locked[num] = user
        return True

Time Complexity

  • lock(): O(1)
  • unlock(): O(1)
  • upgrade(): O(N) in the worst case, where N is the number of nodes. While precomputing children helps, we still need to traverse potential descendants and ancestors.

Space Complexity

  • O(N) to store the parent array, locked array, and children list.

Edge Cases

  • Root Node: The root node (node 0) has no parent (parent[0] == -1). This needs to be handled correctly when checking for locked ancestors in the upgrade function.
  • No Locked Descendants: The upgrade function should return false if there are no locked descendants.
  • Node Already Locked: The lock function should return false if the node is already locked.
  • Incorrect User: The unlock function should return false if the node is locked by a different user.
  • Invalid Node Number: The problem statement specifies constraints for num, but in a real-world scenario, you might want to add checks to ensure the node number is within the valid range.