need to consider test cases: (1) n=1 edges: [] (2) root point is not always start with 0 (but usually it returns False) DFS solution with Hashmap + stack (iterative)

class Solution:
    def validTree(self, n: int, edges: List[List[int]]) -> bool:
        """  time O(E+V) space O(E+V)  
        1.build graph (adjacency list)
        2. iterative dfs (stack + map ) traversal => stack + parent hash map with {0:-1} initialization
        3. for each node, push its nei into stack and add into hash map, meanwhile remove node as neighbor's neighbor in graph 
        """
        if len(edges) != n-1:
            return False
        # build gragh 
        g = {}
        stack = [0]
        for n1,n2 in edges:
            if n1 not in g:
                g[n1] = []
            if n2 not in g:
                g[n2] = []
            g[n1].append(n2)
            g[n2].append(n1)
        parent = {0:-1} # root node has no parent 
        while stack:
            node = stack.pop()
            for nei in g.get(node,[]):  # need to consider 0 not in graph, to skip, otherwise it caused KeyError: 0
                # ignore cases where current node's parent node is the neighbor node
                if nei == parent[node]:
                    continue
                # there is cycle 
                if nei in parent:
                    return False 
                stack.append(nei)
                parent[nei] = node
        return n == len(parent) 

DFS using graph property (Adjacency list + set )

class Solution:
    def validTree(self, n: int, edges: List[List[int]]) -> bool:
        """ time O(N) space O(N)
        1.build graph (adjacency list)
        2. iterative dfs (stack + set ) traversal
        3. for each node, push its nei into stack and add into set, remove 
        """
        if len(edges) != n-1:
            return False
        # build gragh 
        g = [[]for _ in range(n)]
        stack = [0]
        seen = {0}
        for n1,n2 in edges:
            g[n1].append(n2)
            g[n2].append(n1)
        
        while stack:
            node = stack.pop()
            for nei in g[node]:
                if nei in seen:
                    continue 
                stack.append(nei)
                seen.add(nei)
        return n == len(seen) 

UF solution

class UF:
    def __init__(self,n):
        self.parents = [i for i in range(n)]
        self.ranks = [1] * n
        self.cnt = n
    def find(self, u):
        if u != self.parents[u]:
            self.parents[u] = self.find(self.parents[u])
        return self.parents[u]

    def union(self,u,v):
        pu = self.find(u)
        pv = self.find(v)
        
        if pv == pu:
            return False
        self.cnt -= 1 
        if self.ranks[pu] < self.ranks[pv]: # note here is to update ranks of parent node insted of current node 
            self.ranks[v] += self.ranks[u]
            self.parents[u] = pv
            
        else:
            self.ranks[pu] += self.ranks[pv]
            self.parents[v] = pu
        return True 
    
class Solution:
    def validTree(self, n: int, edges: List[List[int]]) -> bool:
        """ time O(N * alpha(N)) space O(V) ~O(N)
        0. initialize each vertice has itself as root 
        1. for each V pairs, find its root 
        2. if  root not same, union them; otherwise, there is a circle.
        3. after loop through all vertices, if size of uf > 1, tree is not valid 
        """
        if len(edges) != n-1:
            return False
        uf = UF(n)
        for e1,e2 in edges:
            if not uf.union(e1,e2):
                return False
        return uf.cnt == 1