Priority Queue in C++

by | C++, Programming, Tips

Welcome to our comprehensive guide on Priority Queues in C++! In this tutorial, we’ll explore how to implement and use priority queues effectively, covering everything from basic usage to advanced features introduced in modern C++ versions. Whether you’re building a task scheduler or implementing Dijkstra’s algorithm, understanding priority queues is essential for many programming scenarios.

📚 Priority Queue Quick Reference
std::priority_queue
A container adapter that provides constant time lookup of the largest element, at the cost of logarithmic insertion and extraction.
Heap Property
The underlying principle where parent nodes are always greater (max-heap) or smaller (min-heap) than their children.
Container Adaptors
Classes that provide a specific interface relying on an object of one of the container classes as its underlying container.
Custom Comparators
Functions or functors used to define custom ordering for elements in a priority queue.
Min-Heap
A heap where the smallest element is always at the top. Achieved in std::priority_queue by using std::greater.
Max-Heap
A heap where the largest element is always at the top. This is the default behavior of std::priority_queue.
Time Complexity
Insertion and extraction in a priority queue have logarithmic time complexity, while lookup of the top element is constant time.

Basic Priority Queue Usage

Let’s start with the fundamental operations of a priority queue in C++. A priority queue is a container adapter that allows elements to be accessed based on their priority, with the highest priority element being served first. By default, std::priority_queue in C++ implements a max-heap (largest element first). You can also configure it as a min-heap using custom comparators.

Basic Priority Queue Operations
// Required headers
#include <iostream>   // For input and output
#include <queue>      // For std::priority_queue
#include <vector>     // For using custom containers if needed

int main() {
    // Create a max heap (default behavior of std::priority_queue)
    std::priority_queue<int> max_pq;

    // Insert elements into the priority queue
    max_pq.push(10); // Add 10; internally arranged to maintain heap property
    max_pq.push(30); // Add 30; becomes the top element (largest so far)
    max_pq.push(20); // Add 20; heap reorganizes to keep the largest element at the top

    // Output the elements in order of priority
    std::cout << "Max Priority Queue elements:\n";
    while (!max_pq.empty()) {
        // Access the top element (largest in the max-heap)
        std::cout << max_pq.top() << " ";
        max_pq.pop(); // Remove the top element and reorganize the heap
    }
    std::cout << "\n";

    // Create a min heap using greater comparator
    std::priority_queue<int, std::vector<int>, std::greater<>> min_pq;

    // Insert elements into the min heap
    min_pq.push(10); // Add 10; smallest so far
    min_pq.push(30); // Add 30; heap maintains smallest (10) at the top
    min_pq.push(20); // Add 20; heap adjusts to keep 10 at the top

    // Output the elements in ascending order of priority
    std::cout << "Min Priority Queue elements:\n";
    while (!min_pq.empty()) {
        std::cout << min_pq.top() << " "; // Access the smallest element
        min_pq.pop(); // Remove the smallest element and reorganize the heap
    }

    return 0;
}

The above code demonstrates how to create and manipulate a priority queue in C++. By default, the std::priority_queue is a max-heap, which means the largest element is always at the top. However, you can configure it as a min-heap by specifying a custom comparator (std::greater<T>) along with a std::vector container.

Expected Output:

Max Priority Queue elements:
30 20 10
Min Priority Queue elements:
10 20 30

Here’s a detailed breakdown of the operations:

  • Creating a Max Heap: The std::priority_queue<int> creates a max heap where the largest element is always at the top. This is useful when you want to process elements in descending order of priority.
  • Creating a Min Heap: Using std::greater<int>, you can configure the priority queue as a min heap. This ensures the smallest element is always at the top, which is helpful for ascending order processing.
  • Heap Property: The priority queue ensures the heap property is maintained after every insertion or deletion. This means that insertion and removal operations are performed in logarithmic time complexity.

Explaining Custom Comparators in Priority Queues

Custom comparators allow developers to define specific rules for how elements are ordered in a priority queue. This is especially useful when working with complex data types where the default ordering (based on the < operator) does not meet the requirements. Let’s break down the provided example step by step.

Priority Queue with Custom Comparators
#include <iostream>   // For standard input and output
#include <queue>      // For std::priority_queue
#include <string>     // For std::string
#include <vector>     // For the custom container

// Custom task structure
struct Task {
    std::string name;  // Name of the task
    int priority;      // Priority of the task

    // Custom comparison operator
    bool operator<(const Task& other) const {
        // Compare by priority first, then lexicographically by name
        if (priority == other.priority) {
            return name < other.name; // Name comparison if priorities are equal
        }
        return priority < other.priority; // Compare by priority otherwise
    }
};

// Custom comparator for the priority queue
struct TaskCompare {
    bool operator()(const Task& a, const Task& b) const {
        return a.priority < b.priority; // Higher priority value = higher precedence
    }
};

int main() {
    // Create a priority queue for Task objects
    std::priority_queue<Task, std::vector<Task>, TaskCompare> task_queue;

    // Add some tasks to the priority queue
    task_queue.push({"Write docs", 2}); // Task with priority 2
    task_queue.push({"Fix bug", 3});   // Task with priority 3
    task_queue.push({"Add feature", 1}); // Task with priority 1

    std::cout << "Processing tasks by priority:\n";

    // Process and print tasks in descending order of priority
    while (!task_queue.empty()) {
        const Task& top = task_queue.top(); // Get the task with the highest priority
        std::cout << "Task: " << top.name
                  << ", Priority: " << top.priority << "\n";
        task_queue.pop(); // Remove the processed task
    }

    return 0;
}

Expected Output

The output reflects tasks being processed in descending order of priority:

Processing tasks by priority:
Task: Fix bug, Priority: 3
Task: Write docs, Priority: 2
Task: Add feature, Priority: 1

Key Takeaways

  • Custom comparators provide flexibility in defining the order of elements in a priority queue.
  • Use a functor for efficient and reusable comparison logic.
  • Combine custom structures with custom comparators for more advanced use cases.

By understanding and leveraging custom comparators, you can tailor priority queues to handle even the most complex ordering requirements in your applications.

Modern C++ Features (C++17 and Beyond)

Modern C++ provides multiple approaches to implementing priority queues with custom comparison logic. Let's explore two common implementations: using lambda functions and using comparison structures.

Approach 1: Using Lambda Functions

Lambda-based Priority Queue Implementation
#include <iostream>
#include <queue>
#include <string_view>
#include <vector>

struct Event {
    std::string_view name;
    int timestamp;
    int priority;
};

// Using a custom comparator with explicit template arguments
int main() {
    // Define the comparator as a lambda
    auto compare = [](const Event& a, const Event& b) {
        if (a.priority != b.priority) {
            return a.priority < b.priority; // Higher priority = higher precedence
        }
        return a.timestamp > b.timestamp;  // Earlier timestamp = higher precedence
    };

    // Explicitly specify all template arguments and pass the comparator
    std::priority_queue<Event,
                       std::vector<Event>,
                       decltype(compare)> event_queue(compare);

    // Add events using list initialization
    event_queue.push({"User Login", 1000, 1});      // Priority 1
    event_queue.push({"Critical Error", 1001, 3});  // Priority 3
    event_queue.push({"Warning", 1002, 2});         // Priority 2

    std::cout << "Processing events by priority:\n";
    while (!event_queue.empty()) {
        const auto& top_event = event_queue.top();
        std::cout << "Event: " << top_event.name
                  << ", Time: " << top_event.timestamp
                  << ", Priority: " << top_event.priority << "\n";
        event_queue.pop();
    }

    return 0;
}

Key Points for Lambda Approach:

  • Uses explicit template arguments with decltype(compare) to specify the comparator type
  • Lambda function must be created before the priority queue declaration
  • Requires passing the lambda to the priority queue constructor
  • Suitable for one-off comparison logic

Approach 2: Using Comparison Structures

Struct-based Priority Queue Implementation
#include <iostream>
#include <queue>
#include <string_view>
#include <vector>

struct Event {
    std::string_view name;
    int timestamp;
    int priority;
};

struct EventCompare {
    bool operator()(const Event& a, const Event& b) const {
        if (a.priority != b.priority) {
            return a.priority < b.priority;
        }
        return a.timestamp > b.timestamp;
    }
};

int main() {
    std::priority_queue<Event,
                       std::vector<Event>,
                       EventCompare> event_queue;

    event_queue.push({"User Login", 1000, 1});
    event_queue.push({"Critical Error", 1001, 3});
    event_queue.push({"Warning", 1002, 2});

    std::cout << "Processing events by priority:\n";
    while (!event_queue.empty()) {
        const auto& top_event = event_queue.top();
        std::cout << "Event: " << top_event.name
                  << ", Time: " << top_event.timestamp
                  << ", Priority: " << top_event.priority << "\n";
        event_queue.pop();
    }

    return 0;
}

Key Points for Struct Approach:

  • More traditional C++ approach using a comparison functor
  • Reusable comparison logic that can be used across multiple priority queues
  • No need to store or pass a comparator instance
  • Better for production code and larger systems

Expected Output (same for both approaches):

Processing events by priority:
Event: Critical Error, Time: 1001, Priority: 3
Event: Warning, Time: 1002, Priority: 2
Event: User Login, Time: 1000, Priority: 1

Choosing Between Approaches

Consider these factors when deciding which approach to use:

  • Code Organization: The struct approach is better for organizing comparison logic that will be reused
  • Performance: Both approaches have similar performance characteristics when properly implemented
  • Maintainability: The struct approach is generally easier to maintain and test
  • Flexibility: The lambda approach is more flexible for quick implementations where the comparison logic is used only once

Additional Insights: Modern Priority Queue Approaches

  • decltype(compare): In the lambda approach, decltype(compare) is required to specify the comparator type explicitly, as lambdas do not have a named type.
  • Stateless Lambdas: The lambda comparator in the example is stateless, meaning it does not store any data. This makes it lightweight and efficient for one-off use cases.
  • Performance: Both lambdas and functors have comparable performance when implemented properly. The choice depends on reusability and code organization.
  • Functors and State: Unlike lambdas, functors can maintain state, making them more versatile for complex scenarios.
  • Use Case: Lambdas are ideal for quick, local usage, while functors are better for production systems requiring reusable logic.

Memory Management in Priority Queues

When working with priority queues in C++, especially with large objects, efficient memory management is crucial. Improper handling of memory can lead to performance bottlenecks or unnecessary overhead. This section covers key considerations and best practices for managing memory in priority queues, including examples of leveraging move semantics for optimal performance.

Key Considerations

  • Object Size: If the elements stored in the priority queue are large, consider storing pointers or smart pointers (e.g., std::unique_ptr) instead of the objects themselves. This reduces the cost of copying and ensures more efficient memory usage.
  • Move Semantics: Use move semantics to avoid unnecessary copying of objects when inserting or extracting elements from the priority queue. Remember to implement both move constructor and move assignment operator for proper heap operations.
  • Heap Operations: Priority queues perform internal heap operations that require moving elements around. Ensure your objects properly support these operations through move semantics.
Using Move Semantics with Priority Queue
#include <iostream>
#include <queue>
#include <vector>
#include <string>

struct LargeObject {
    std::string data;

    // Constructor
    LargeObject(std::string d) : data(std::move(d)) {
        std::cout << "Constructing: " << data << "\n";
    }

    // Move Constructor
    LargeObject(LargeObject&& other) noexcept : data(std::move(other.data)) {
        std::cout << "Moving: " << data << "\n";
    }

    // Move Assignment Operator
    LargeObject& operator=(LargeObject&& other) noexcept {
        std::cout << "Move assigning: " << other.data << "\n";
        if (this != &other) {
            data = std::move(other.data);
        }
        return *this;
    }

    // Deleted Copy Constructor
    LargeObject(const LargeObject&) = delete;

    // Deleted Copy Assignment
    LargeObject& operator=(const LargeObject&) = delete;

    // Destructor
    ~LargeObject() {
        if (data.empty()) {
            std::cout << "Destroying moved-from object\n";
        } else {
            std::cout << "Destroying: " << data << "\n";
        }
    }

    // Custom Comparator - reversed for priority queue
    bool operator<(const LargeObject& other) const {
        return data.size() > other.data.size(); // Reversed comparison for max heap
    }
};

int main() {
    // Create a priority queue with proper type specification
    std::priority_queue<LargeObject> pq;

    // Use emplace to construct objects directly in the queue
    pq.emplace("Short");
    pq.emplace("A much longer string");
    pq.emplace("Medium length");

    // Process elements
    while (!pq.empty()) {
        // Access the top element (largest based on custom comparator)
        std::cout << "Top: " << pq.top().data << "\n";
        pq.pop(); // Remove the element
    }

    return 0;
}

Priority Queue with Move Semantics - Key Points

  • Creates a priority queue that efficiently handles large objects by using move operations instead of copies
  • LargeObject class:
    • Wraps a string
    • Can only be moved, not copied
    • Prints messages to show when it's being constructed, moved, or destroyed
  • Move operations required:
    • Move constructor: for creating new positions in the queue
    • Move assignment operator: for shuffling elements during heap operations
  • Queue organization:
    • Automatically sorts by string length
    • Uses reversed comparison so shorter strings come first
    • Internally reorganizes elements using move operations

Constructing: Short

First element is created in the queue

Constructing: A much longer string

Second element is created in the queue

Moving: Short

Destroying moved-from object

Queue reorganizes and cleans up first element during heapification

Constructing: Medium length

Third element is created in the queue

Moving: A much longer string

Moving: Short

Destroying moved-from object

Destroying moved-from object

Queue reorganizes elements to maintain heap property

Top: Short

Queue returns the top element (shortest string)

Moving: Short

Move assigning: Medium length

Move assigning: Short

Destroying moved-from object

Destroying: Short

Heap maintenance and cleanup after removing top element

Top: Medium length

Queue returns new top element

Moving: Medium length

Move assigning: A much longer string

Move assigning: Medium length

Destroying moved-from object

Destroying: Medium length

Heap maintenance and cleanup after removing second element

Top: A much longer string

Destroying: A much longer string

Final element retrieved and queue cleaned up

Key Implementation Details

  • Move Constructor: Handles the transfer of resources during construction of new objects:
    LargeObject(LargeObject&& other) noexcept : data(std::move(other.data))
  • Move Assignment Operator: Essential for heap operations in the priority queue:
    LargeObject& operator=(LargeObject&& other) noexcept
  • Deleted Copy Operations: Prevent accidental copying by explicitly deleting copy constructor and assignment:
    LargeObject(const LargeObject&) = delete;
    LargeObject& operator=(const LargeObject&) = delete;

Important Notes

  • Always implement both move constructor and move assignment operator when working with move-only types in priority queues
  • The move assignment operator is crucial for internal heap operations
  • Mark move operations as noexcept to enable optimizations
  • Consider using std::greater if you want to maintain natural ordering without reversing comparisons

By properly implementing move semantics and understanding the internal operations of priority queues, you can ensure efficient memory usage while maintaining clear and maintainable code. The priority queue will handle large objects effectively, with minimal overhead from moving data during heap operations.

Best Practices for Priority Queues

  • Use the default max heap unless you specifically need a min heap.
  • Consider using references or smart pointers when working with large objects to avoid unnecessary copying.
  • Implement custom comparators as functors for better reusability and maintainability.
  • Use structured bindings (C++17) for cleaner and more readable code when working with complex types.
  • Leverage emplace methods for constructing objects directly in the priority queue to avoid extra copies or moves.
  • For performance-critical applications, profile your custom comparator to ensure it does not introduce unnecessary overhead.
  • Use `std::greater` for min-heaps to simplify code when custom comparators are not needed.

Conclusion

Throughout this guide, we've explored the versatility and power of priority queues in C++. From basic operations to advanced implementations using modern C++ features, priority queues prove to be an invaluable tool in many programming scenarios.

Remember that while the standard priority queue implementation serves most use cases well, you can always customize it to fit your specific needs using custom comparators and modern C++ features. The key is choosing the right approach based on your requirements while keeping performance and maintainability in mind.

Congratulations on reading to the end of this tutorial! For further exploration of priority queues in C++, check out the resources below.

Have fun and happy coding!

Further Reading

  • Online C++ Compiler

    Try out the code examples from this guide in our free, interactive C++ environment. This compiler supports all modern C++ features including C++17 and beyond, making it perfect for experimenting with priority queue implementations and custom comparators. No installation required - just copy, paste, and run!

  • C++ Priority Queue Reference

    Official documentation for std::priority_queue

  • Heap Operations in C++

    Understanding the underlying heap operations

  • The C++ Standard Website

    The official home of C++ standards and guidelines, offering valuable resources and updates for developers.

Attribution and Citation

If you found this guide helpful, feel free to link back to this page or cite it in your work!

Profile Picture
Senior Advisor, Data Science | [email protected] |  + posts

Suf is a senior advisor in data science with deep expertise in Natural Language Processing, Complex Networks, and Anomaly Detection. Formerly a postdoctoral research fellow, he applied advanced physics techniques to tackle real-world, data-heavy industry challenges. Before that, he was a particle physicist at the ATLAS Experiment of the Large Hadron Collider. Now, he’s focused on bringing more fun and curiosity to the world of science and research online.

Buy Me a Coffee ✨