Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 65

Segment Tree for Min-Max Range Query

Recommended resources

Hello! Welcome back to our project.

In our last lesson, we successfully implemented the build algorithm for our MinMaxTree. We now have a mechanism to take a raw array of data and construct a complete min-max tree in memory, stored efficiently in a std::vector. The groundwork has been laid.

Today, we get to the core purpose of this data structure. We will implement the function that queries the tree to find the min-max pair for any given data range. This is where we unlock the O(log n) performance that makes this structure so powerful for our LOD plotter.

This lesson is dedicated to the learning outcome: Implement a function to query the min-max range for an arbitrary interval in logarithmic time.

By the end of this lesson, you will have a fully functional MinMaxTree class that can be both built and queried efficiently.

The Query Algorithm: Divide, Conquer, and Combine

Just like building the tree, querying it is a recursive process. The goal is to find the minimum and maximum values in a specific query range, let's say [l, r]. We start at the root of the tree and traverse downwards. At each node, which represents its own range [tl, tr], we face one of three scenarios:

  1. No Overlap: The node's range is completely outside our query range [l, r]. This branch of the tree is irrelevant to our query, so we can ignore it.
  2. Total Overlap: The node's range is completely inside our query range [l, r]. Since this node already has the pre-computed min and max for its entire range, we can just return its value and stop traversing down this path. This is the key to the algorithm's efficiency.
  3. Partial Overlap: The node's range partially intersects with our query range. We can't use this node's value directly, nor can we ignore it. We must continue our search by making two recursive calls: one to the left child and one to the right child, and then combine their results.

To see a great visual explanation of these three rules, the following video segment is invaluable.

Segment Tree Range Minimum Query

This part of the video by Tushar Roy explains the three crucial overlap rules for a range query. It uses a clear diagram to show how a query traverses the tree, pruning branches (No Overlap), using pre-computed nodes (Total Overlap), and recursing when necessary (Partial Overlap).

Watch from 05:04 to 10:12. Focus on understanding the logic behind the 'Partial Overlap', 'Total Overlap', and 'No Overlap' cases. This conceptual understanding is the foundation for the code we are about to write.

The reason this algorithm is so fast (O(log n)) is that any query range [l, r] can be perfectly covered by at most 2 * log(n) nodes in the tree. At each level of the tree, we only ever explore at most two "partial overlap" nodesβ€”the ones on the edges of our query range. Any nodes in between will be fully contained.

Segment Tree for Range Minimum Query
This image illustrates a query on a segment tree. A query for a range like [0, 3] would be decomposed into queries on the nodes for [0, 2] and [3, 3]. The algorithm intelligently combines these pre-computed results.

Implementing the Query in C++

Let's add the query functionality to our MinMaxTree class. We'll follow a standard pattern: a public-facing method that calls a private recursive helper function.

First, how do we handle the "No Overlap" case? We need to return a value that, when combined with other results, doesn't alter them. This is the identity element for our combine operation. For a min/max query, this would be:

  • min_val: Positive infinity
  • max_val: Negative infinity

Let's update our MinMaxNode and combine function from the last lesson to handle this.

#include <limits> // Required for std::numeric_limits

// Inside the MinMaxTree class
private:
    struct MinMaxNode {
        T min_val = std::numeric_limits<T>::max(); // Identity for min
        T max_val = std::numeric_limits<T>::lowest(); // Identity for max
    };

    MinMaxNode combine(const MinMaxNode& left, const MinMaxNode& right) {
        return {
            std::min(left.min_val, right.min_val),
            std::max(left.max_val, right.max_val)
        };
    }

Note: lowest() is used for floating-point types to get the most negative finite value, which is safer than -infinity in some contexts. For integers, it's equivalent to min().

Now we can write the recursive query function. It needs to know the index of the current node (node_idx), the range that node covers (range_start, range_end), and the range we are querying for (query_l, query_r).

// Private recursive query function
MinMaxNode query(size_t node_idx, size_t range_start, size_t range_end, size_t query_l, size_t query_r) {
    // 1. No Overlap: The node's range is completely outside the query range.
    // Return the identity node.
    if (range_start > query_r || range_end < query_l) {
        return MinMaxNode{}; // Returns the identity {max, lowest}
    }

    // 2. Total Overlap: The node's range is completely inside the query range.
    // Return the pre-computed value for this node.
    if (query_l <= range_start && range_end <= query_r) {
        return m_tree[node_idx];
    }

    // 3. Partial Overlap: Recurse on children and combine results.
    size_t mid = range_start + (range_end - range_start) / 2;
    size_t left_child_idx = 2 * node_idx + 1;
    size_t right_child_idx = 2 * node_idx + 2;

    MinMaxNode left_result = query(left_child_idx, range_start, mid, query_l, query_r);
    MinMaxNode right_result = query(right_child_idx, mid + 1, range_end, query_l, query_r);

    return combine(left_result, right_result);
}

Finally, the public method provides a clean interface. It performs some basic validation and starts the recursion from the root node (index 0), which covers the entire original data range (0 to m_num_leaves - 1).

// Public query function
public:
    MinMaxNode getMinMaxInRange(size_t l, size_t r) {
        if (m_tree.empty() || l > r) {
            return MinMaxNode{};
        }
        return query(0, 0, m_num_leaves - 1, l, r);
    }

The resource cp-algorithms.com provides an excellent, compact implementation for a sum query. The logic is identical to our min-max query, just with a different combine operation. Notice its clever way of splitting the query range in the recursive calls. While our implementation above works perfectly, the cp-algorithms version is a robust alternative worth studying.

Segment Tree Implementation

This resource provides a complete C++ implementation of a Segment Tree. Pay close attention to the sum function, which is the query logic.

First, read the 'Sum queries' section for another explanation of the three overlap cases. Then, examine the C++ sum function in the 'Implementation' section. Finally, see the 'Finding the maximum' and 'Finding the maximum and the number of times it appears' sections, which confirm that this exact structure works for min/max by just changing the merge logic.

Test your understanding!

Let's use the same data as last lesson: [5, 8, 2, 7]. The root node (range [0..3]) stores {min:2, max:8}. Its left child (range [0..1]) stores {min:5, max:8} and its right child (range [2..3]) stores {min:2, max:7}.

If you call getMinMaxInRange(1, 2), can you trace the query calls?

  1. What happens at the root node (range [0..3])?
  2. What happens at its left child (range [0..1])?
  3. What happens at its right child (range [2..3])?
  4. What is the final combined result?
Show answer
  1. Root (range [0..3]): The query [1..2] is a partial overlap. The function recurses on both children.
  2. Left Child (range [0..1]): The query [1..2] is a partial overlap here too. It recurses further.
    • Its left child (for value 5, range [0..0]) has no overlap with [1..2] and returns an identity node.
    • Its right child (for value 8, range [1..1]) has total overlap with [1..2] and returns {min:8, max:8}.
    • The combination of these two gives {min:8, max:8} back to the call from the root.
  3. Right Child (range [2..3]): The query [1..2] is also a partial overlap. It recurses further.
    • Its left child (for value 2, range [2..2]) has total overlap with [1..2] and returns {min:2, max:2}.
    • Its right child (for value 7, range [3..3]) has no overlap with [1..2] and returns an identity node.
    • The combination of these two gives {min:2, max:2} back to the call from the root.
  4. Final Result: The root combines the results from its left child ({min:8, max:8}) and its right child ({min:2, max:2}). The final combined result is {min:2, max:8}. This is correct, as the min and max in the range [1..2] (values 8, 2) are indeed 2 and 8.

To solidify this, let's watch the final segment of Tushar Roy's video, where he does a detailed code walkthrough of the query function, tracing the recursive calls and return values just like we did in the exercise.

Segment Tree Range Minimum Query

This is a detailed code trace of the rangeMinQuery function. It shows exactly how the recursive calls navigate the tree array and how the results from different branches are combined to produce the final answer.

Watch from 21:39 to 27:24. Follow along as the call stack deepens and unwinds. This will connect the conceptual rules of overlap to concrete C++ code execution.


Conclusion

Excellent work. We have now completed the MinMaxTree data structure. It can be constructed from an array of data points and can efficiently query the minimum and maximum values over any arbitrary sub-range.

Key Takeaways:

  • The query algorithm works by recursively traversing the tree and applying one of three rules at each node: No Overlap, Total Overlap, or Partial Overlap.
  • The O(log n) complexity is achieved because "Total Overlap" cases prune entire subtrees from the search, and any query range can be covered by a logarithmic number of such nodes.
  • The implementation uses a private recursive helper that handles the tree traversal, while a public method provides a simple, clean API.
  • An identity element ({min: infinity, max: -infinity}) is crucial for handling "No Overlap" cases correctly during the combine step.

Our data structure is now ready for use in the LOD plotter.


What's Next?

We have a powerful tool, but how do we use it for visualization? A user doesn't specify data indices l and r; they interact with the plot by zooming and panning.

In the next lesson, we will bridge the gap between user interaction and our data structure. We will implement the logic to calculate the visible data range based on the current zoom and pan state. This will tell us which range of indices we need to pass to our getMinMaxInRange function to draw the correct view of the data.