title: Checking Existence of Edge Length Limited Paths
slug: checking-existence-of-edge-length-limited-paths
difficulty: hard
leetcode_id: 1697
leetcode_url: https://leetcode.com/problems/checking-existence-of-edge-length-limited-paths/
categories:
  - graphs
  - sorting
  - arrays
patterns:
  - union-find
  - two-pointers

description: |
  An undirected graph of `n` nodes is defined by `edgeList`, where `edgeList[i] = [u_i, v_i, dis_i]` denotes an edge between nodes `u_i` and `v_i` with distance `dis_i`. Note that there may be **multiple** edges between two nodes.

  Given an array `queries`, where `queries[j] = [p_j, q_j, limit_j]`, your task is to determine for each `queries[j]` whether there is a path between `p_j` and `q_j` such that each edge on the path has a distance **strictly less than** `limit_j`.

  Return *a boolean array* `answer`, *where* `answer.length == queries.length` *and the* `j`<sup>th</sup> *value of* `answer` *is* `true` *if there is a path for* `queries[j]`, *and* `false` *otherwise*.

constraints: |
  - `2 <= n <= 10^5`
  - `1 <= edgeList.length, queries.length <= 10^5`
  - `edgeList[i].length == 3`
  - `queries[j].length == 3`
  - `0 <= u_i, v_i, p_j, q_j <= n - 1`
  - `u_i != v_i`
  - `p_j != q_j`
  - `1 <= dis_i, limit_j <= 10^9`
  - There may be **multiple** edges between two nodes.

examples:
  - input: "n = 3, edgeList = [[0,1,2],[1,2,4],[2,0,8],[1,0,16]], queries = [[0,1,2],[0,2,5]]"
    output: "[false, true]"
    explanation: "For the first query, between 0 and 1 there is no path where each distance is less than 2 (the smallest edge is exactly 2, not strictly less). For the second query, there is a path (0 → 1 → 2) using edges with distances 2 and 4, both less than 5."
  - input: "n = 5, edgeList = [[0,1,10],[1,2,5],[2,3,9],[3,4,13]], queries = [[0,4,14],[1,4,13]]"
    output: "[true, false]"
    explanation: "For the first query, the path 0 → 1 → 2 → 3 → 4 uses edges with distances 10, 5, 9, 13 — all strictly less than 14. For the second query, node 4 requires the edge with distance 13, which is not strictly less than the limit 13."

explanation:
  intuition: |
    Imagine you're building a road network incrementally. You start with no roads, then add them one by one, starting with the shortest roads first. As you add each road, previously disconnected cities become connected.

    Now consider a query: "Can I travel from city A to city B using only roads shorter than X kilometres?" If you've already added all roads shorter than X, the answer depends entirely on whether A and B are in the same connected component at that moment.

    This insight transforms the problem: instead of checking each query independently (which would be expensive), we can **process queries in order of their limits**. For a query with limit `L`, we first add all edges with distance `< L`, then check if the two nodes are connected.

    Think of it like this: we're "growing" the graph by adding edges in sorted order, and each query asks "at what point in this growth are these two nodes connected?" By sorting both edges and queries by their distance/limit, we can answer all queries efficiently using a single pass through both.

  approach: |
    We use **Offline Query Processing with Union-Find**:

    **Step 1: Prepare the data structures**

    - Create a Union-Find (Disjoint Set Union) structure for `n` nodes
    - Sort `edgeList` by distance in ascending order
    - Create an indexed copy of queries: `[(limit, p, q, original_index), ...]`
    - Sort queries by limit in ascending order
    - Initialise result array of size `len(queries)`

    &nbsp;

    **Step 2: Process queries in order of increasing limit**

    - Maintain an edge pointer `edge_idx` starting at `0`
    - For each query `(limit, p, q, idx)` in sorted order:
      - Add all edges with `distance < limit` to the Union-Find structure
      - This is done by unioning edges while `edge_idx < len(edgeList)` and `edgeList[edge_idx].distance < limit`
      - Check if `p` and `q` are in the same connected component using `find(p) == find(q)`
      - Store the result at position `idx` in the result array

    &nbsp;

    **Step 3: Return the result array**

    - The result array now contains answers in the original query order

    &nbsp;

    The key insight is that because both edges and queries are sorted, each edge is added to the Union-Find exactly once. When we process a query with limit `L`, all edges with distance `< L` have already been added, so we just need to check connectivity.

  common_pitfalls:
    - title: Processing Queries Independently
      description: |
        A naive approach might process each query separately: for each query, filter edges with distance `< limit`, build a graph, and run BFS/DFS to check connectivity.

        This results in **O(Q × (E + N))** time where Q is queries, E is edges, and N is nodes. With `Q = E = 10^5`, this means up to 10^10 operations — far too slow.

        The offline approach processes all queries together in **O((E + Q) log(E + Q) + E × α(N))** time, where α is the inverse Ackermann function (effectively constant).
      wrong_approach: "BFS/DFS for each query independently"
      correct_approach: "Sort queries by limit and use Union-Find incrementally"

    - title: Forgetting Strictly Less Than
      description: |
        The problem requires edges with distance **strictly less than** the limit, not less than or equal. When processing a query with `limit = 5`, you must only add edges with distance `< 5`, not `<= 5`.

        For example, with `limit = 2` and an edge of distance `2`, that edge should NOT be included. This is easy to miss and causes wrong answers.
      wrong_approach: "Using `distance <= limit` when adding edges"
      correct_approach: "Using `distance < limit` when adding edges"

    - title: Not Preserving Query Order
      description: |
        Since we process queries in sorted order by limit, we must track the original index of each query to place results in the correct position.

        If you forget to track original indices, you'll return answers in the wrong order, which fails the test cases even if the connectivity logic is correct.
      wrong_approach: "Returning results in sorted order"
      correct_approach: "Storing original index and placing results at that position"

    - title: Inefficient Union-Find Implementation
      description: |
        A basic Union-Find without optimisations can degrade to O(N) per operation. With `10^5` edges, this becomes too slow.

        Use **path compression** in `find()` and **union by rank/size** in `union()` to achieve near-constant time per operation.
      wrong_approach: "Union-Find without path compression"
      correct_approach: "Union-Find with path compression and union by rank"

  key_takeaways:
    - "**Offline query processing**: When queries can be answered in any order, sorting them can enable efficient batch processing"
    - "**Union-Find for dynamic connectivity**: Perfect for incrementally adding edges and checking if nodes are connected"
    - "**Two-pointer technique on sorted data**: Sorting both edges and queries by distance allows single-pass processing"
    - "**Pattern recognition**: Problems asking 'is there a path with constraint X' often benefit from processing in order of that constraint"

  time_complexity: "O((E + Q) log(E + Q) + E × α(N)). Sorting edges and queries dominates at O((E + Q) log(E + Q)). Each union/find operation takes O(α(N)) amortised time, where α is the inverse Ackermann function (effectively constant for all practical inputs)."
  space_complexity: "O(N + Q). We store the Union-Find parent and rank arrays (O(N)) and the indexed queries array (O(Q))."

solutions:
  - approach_name: Offline Query Processing with Union-Find
    is_optimal: true
    code: |
      def distance_limited_paths_exist(
          n: int, edge_list: list[list[int]], queries: list[list[int]]
      ) -> list[bool]:
          # Union-Find with path compression and union by rank
          parent = list(range(n))
          rank = [0] * n

          def find(x: int) -> int:
              # Path compression: make every node point directly to root
              if parent[x] != x:
                  parent[x] = find(parent[x])
              return parent[x]

          def union(x: int, y: int) -> None:
              # Union by rank: attach smaller tree under larger tree
              px, py = find(x), find(y)
              if px == py:
                  return
              if rank[px] < rank[py]:
                  px, py = py, px
              parent[py] = px
              if rank[px] == rank[py]:
                  rank[px] += 1

          # Sort edges by distance
          edge_list.sort(key=lambda e: e[2])

          # Create indexed queries and sort by limit
          # Format: (limit, p, q, original_index)
          indexed_queries = [(q[2], q[0], q[1], i) for i, q in enumerate(queries)]
          indexed_queries.sort()

          result = [False] * len(queries)
          edge_idx = 0

          # Process queries in order of increasing limit
          for limit, p, q, idx in indexed_queries:
              # Add all edges with distance strictly less than limit
              while edge_idx < len(edge_list) and edge_list[edge_idx][2] < limit:
                  u, v, _ = edge_list[edge_idx]
                  union(u, v)
                  edge_idx += 1

              # Check if p and q are connected
              result[idx] = find(p) == find(q)

          return result
    explanation: |
      **Time Complexity:** O((E + Q) log(E + Q) + E × α(N)) — Sorting dominates; Union-Find operations are nearly constant.

      **Space Complexity:** O(N + Q) — Union-Find arrays plus indexed queries.

      By sorting both edges and queries by distance/limit, we process everything in a single pass. Each edge is added exactly once to the Union-Find. When answering a query with limit L, all edges with distance < L have been added, so connectivity check is just comparing roots.

  - approach_name: BFS/DFS Per Query (Brute Force)
    is_optimal: false
    code: |
      from collections import defaultdict, deque

      def distance_limited_paths_exist(
          n: int, edge_list: list[list[int]], queries: list[list[int]]
      ) -> list[bool]:
          result = []

          for p, q, limit in queries:
              # Build adjacency list with only valid edges
              graph = defaultdict(list)
              for u, v, dist in edge_list:
                  if dist < limit:
                      graph[u].append(v)
                      graph[v].append(u)

              # BFS to check if p and q are connected
              visited = set([p])
              queue = deque([p])
              found = False

              while queue and not found:
                  node = queue.popleft()
                  if node == q:
                      found = True
                      break
                  for neighbor in graph[node]:
                      if neighbor not in visited:
                          visited.add(neighbor)
                          queue.append(neighbor)

              result.append(found)

          return result
    explanation: |
      **Time Complexity:** O(Q × (E + N)) — For each query, we filter edges and run BFS.

      **Space Complexity:** O(E + N) — Adjacency list and visited set per query.

      This approach processes each query independently: filter edges below the limit, build a graph, run BFS to check connectivity. While correct, it's far too slow for the constraints (`Q = E = 10^5` means up to 10^10 operations). Included to illustrate why offline processing with Union-Find is essential.
