C#を用いたアプリケーション開発において、データの集合を特定の順序で効率的に処理したい場面は多々あります。

通常の Queue<T> は先入れ先出し (FIFO) の原則に基づきますが、業務システムやゲーム開発、アルゴリズムの実装においては、要素が追加された順序ではなく「優先度」の高いものから順に取り出したいケースが少なくありません。

従来、.NET ではこのようなデータ構造が標準ライブラリに含まれておらず、開発者が自作するか、他のコレクションを代用する必要がありました。

しかし、.NET 6 以降、待望の PriorityQueue<TElement, TPriority> クラス が導入され、標準機能として高いパフォーマンスを享受できるようになりました。

本記事では、この優先度付きキューの基本的な概念から、具体的な実装例、応用的な使い方までを詳しく解説します。

優先度付きキュー(PriorityQueue)とは

優先度付きキューとは、各要素が特定の「優先度」を持ち、常に最も優先度の高い要素から順番に取り出される抽象データ型です。

通常のキューがレジの行列のように並んだ順に処理するのに対し、優先度付きキューは「緊急度の高いタスクを最優先で処理する」といった状況に適しています。

C# の PriorityQueue<TElement, TPriority> は、内部的に「ヒープ (Heap)」というデータ構造を利用しています。

一般的には二分ヒープ (Binary Heap) が採用されており、要素の追加や最小値(または最大値)の取り出しを非常に効率的に行うことができます。

このクラスの最大の特徴は、要素本体 (TElement) と優先度 (TPriority) を分離して管理できる点にあります。

これにより、既存のクラスを書き換えることなく、外部から優先度を指定して管理することが可能になりました。

なお、デフォルトの状態では 「優先度の値が小さいものほど優先度が高い」 と見なされる「最小優先度付きキュー」として動作します。

PriorityQueueの基本的な操作メソッド

C# の PriorityQueue クラスで主に使用されるメソッドは非常にシンプルです。

まずは、基本的な操作を行うための主要なメソッドを確認しておきましょう。

  1. Enqueue(TElement element, TPriority priority): 指定した要素を、指定した優先度でキューに追加します。
  2. Dequeue(): キューの中で最も優先度が高い(値が小さい)要素を取り除き、その要素を返します。
  3. Peek(): 最も優先度が高い要素を削除せずに取得します。
  4. TryDequeue(out TElement element, out TPriority priority): 安全に要素を取り出し、成功したかどうかを真偽値で返します。
  5. Clear(): キュー内のすべての要素を削除します。

これらのメソッドは、要素数 $N$ に対して、追加および削除が $O(\log N)$ という高速な時間計算量で実行されます。

これは、単なるリストをソートし続ける手法 ($O(N \log N)$) や、毎回最小値を探す手法 ($O(N)$) よりも圧倒的に効率的です。

基本的な実装コード例

まずは、数値の優先度を使用して文字列のタスクを管理する、最もシンプルな例を見てみましょう。

C#
using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // TElementがstring、TPriorityがintの優先度付きキューを作成
        PriorityQueue<string, int> taskQueue = new PriorityQueue<string, int>();

        // 要素の追加 (値が小さいほど優先度が高い)
        taskQueue.Enqueue("メールの返信", 3);
        taskQueue.Enqueue("緊急のシステム障害対応", 1);
        taskQueue.Enqueue("週報の作成", 5);
        taskQueue.Enqueue("会議資料の準備", 2);

        Console.WriteLine("--- タスクの処理を開始します ---");

        // キューが空になるまでループ
        while (taskQueue.Count > 0)
        {
            // 最も優先度の高い要素を取得して削除
            string task = taskQueue.Dequeue();
            Console.WriteLine($"処理中: {task}");
        }
    }
}
実行結果
--- タスクの処理を開始します ---
処理中: 緊急のシステム障害対応
処理中: 会議資料の準備
処理中: メールの返信
処理中: 週報の作成

この例では、優先度として指定された 1, 2, 3, 5 の順、つまり 数値が小さい順 にタスクが取り出されていることが分かります。

優先順位のカスタマイズとIComparerの活用

デフォルトでは昇順(小さい値が優先)で処理されますが、実務では「数値が大きい方を優先したい(降順)」場合や、「独自のオブジェクトで複雑な優先順位を判定したい」場合があります。

このようなケースでは、IComparer<T> インターフェースを実装したクラスをコンストラクタに渡すことで、優先順位のロジックを自由にカスタマイズできます。

数値が大きい方を優先させる例

以下のコードは、数値を降順に評価する比較器を使用して、スコアが高い順にプレイヤーを処理する例です。

C#
using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // 降順の比較器を指定してインスタンス化
        // Comparer<int>.Createを使用してラムダ式で定義
        var comparer = Comparer<int>.Create((x, y) => y.CompareTo(x));
        PriorityQueue<string, int> highScorerQueue = new PriorityQueue<string, int>(comparer);

        highScorerQueue.Enqueue("Player A", 1500);
        highScorerQueue.Enqueue("Player B", 3200);
        highScorerQueue.Enqueue("Player C", 2100);

        Console.WriteLine("--- ハイスコア順のリスト ---");
        while (highScorerQueue.TryDequeue(out string player, out int score))
        {
            Console.WriteLine($"{player}: {score} pts");
        }
    }
}
実行結果
--- ハイスコア順のリスト ---
Player B: 3200 pts
Player C: 2100 pts
Player A: 1500 pts

複合的な優先度の定義

優先度が単一の数値ではなく、複数の条件(例:会員ランクと申し込み日時など)で決まる場合は、タプルやカスタム構造体を TPriority に指定します。

C#
using System;
using System.Collections.Generic;

// 優先度を判定するためのカスタム構造体
public struct OrderPriority : IComparable<OrderPriority>
{
    public int Rank { get; }      // 1: VIP, 2: Regular
    public DateTime OrderedAt { get; }

    public OrderPriority(int rank, DateTime orderedAt)
    {
        Rank = rank;
        OrderedAt = orderedAt;
    }

    // IComparableを実装して比較ロジックを定義
    public int CompareTo(OrderPriority other)
    {
        // まずランクを比較
        int result = Rank.CompareTo(other.Rank);
        if (result != 0) return result;

        // ランクが同じなら注文日時が早い順
        return OrderedAt.CompareTo(other.OrderedAt);
    }
}

class Program
{
    static void Main()
    {
        var queue = new PriorityQueue<string, OrderPriority>();

        // データの追加
        queue.Enqueue("注文A (Regular, 後)", new OrderPriority(2, DateTime.Now.AddMinutes(10)));
        queue.Enqueue("注文B (VIP, 後)", new OrderPriority(1, DateTime.Now.AddMinutes(5)));
        queue.Enqueue("注文C (VIP, 前)", new OrderPriority(1, DateTime.Now));
        queue.Enqueue("注文D (Regular, 前)", new OrderPriority(2, DateTime.Now.AddMinutes(-5)));

        Console.WriteLine("--- 注文処理順序 ---");
        while (queue.TryDequeue(out string order, out OrderPriority p))
        {
            string rankLabel = p.Rank == 1 ? "VIP" : "Regular";
            Console.WriteLine($"処理: {order} [Rank: {rankLabel}, Time: {p.OrderedAt:HH:mm:ss}]");
        }
    }
}
実行結果
--- 注文処理順序 ---
処理: 注文C (VIP, 前) [Rank: VIP, Time: 12:00:00]
処理: 注文B (VIP, 後) [Rank: VIP, Time: 12:05:00]
処理: 注文D (Regular, 前) [Rank: Regular, Time: 11:55:00]
処理: 注文A (Regular, 後) [Rank: Regular, Time: 12:10:00]

このように、IComparable を実装した型を優先度に使うことで、ビジネスロジックに即した柔軟な並び替えが実現可能です。

実践的なアルゴリズムへの応用:ダイクストラ法

優先度付きキューが最も真価を発揮する場面の一つが、グラフ理論における最短経路探索アルゴリズム「ダイクストラ法」です。

未訪問のノードの中から、現在の出発点より「最も距離が短いノード」を常に選択する必要があるため、PriorityQueue を使用することで計算量を大幅に削減できます。

以下に、簡易的なダイクストラ法の実装例を示します。

C#
using System;
using System.Collections.Generic;

class DijkstraExample
{
    // 隣接リスト形式のグラフ (Node, Distance)
    private static Dictionary<char, List<(char Node, int Weight)>> graph = new()
    {
        { 'A', new List<(char, int)> { ('B', 4), ('C', 2) } },
        { 'B', new List<(char, int)> { ('C', 5), ('D', 10) } },
        { 'C', new List<(char, int)> { ('D', 3) } },
        { 'D', new List<(char, int)> { } }
    };

    static void Main()
    {
        char startNode = 'A';
        var distances = SolveDijkstra(startNode);

        Console.WriteLine($"起点 {startNode} からの最短距離:");
        foreach (var kvp in distances)
        {
            Console.WriteLine($"ノード {kvp.Key}: {kvp.Value}");
        }
    }

    static Dictionary<char, int> SolveDijkstra(char start)
    {
        var distances = new Dictionary<char, int>();
        // 優先度付きキュー (Node名, 暫定の最短距離)
        var pq = new PriorityQueue<char, int>();

        pq.Enqueue(start, 0);
        distances[start] = 0;

        while (pq.Count > 0)
        {
            if (!pq.TryDequeue(out char current, out int currentDist)) continue;

            // 既に確定している距離より長い場合はスキップ
            if (distances.ContainsKey(current) && currentDist > distances[current]) continue;

            foreach (var edge in graph[current])
            {
                int newDist = currentDist + edge.Weight;
                if (!distances.ContainsKey(edge.Node) || newDist < distances[edge.Node])
                {
                    distances[edge.Node] = newDist;
                    pq.Enqueue(edge.Node, newDist);
                }
            }
        }
        return distances;
    }
}
実行結果
起点 A からの最短距離:
ノード A: 0
ノード B: 4
ノード C: 2
ノード D: 5

もし PriorityQueue を使用せずに毎回リストをソートしていた場合、ノード数やエッジ数が膨大になるとパフォーマンスが劇的に低下します。

C# 標準の PriorityQueue を使うことで、アルゴリズムの簡潔さと実行効率を両立させることができます。

既存のコレクションとの違いと使い分け

C# には他にも並び替えを伴うコレクションが存在します。

それぞれの違いを理解し、適切な場面で PriorityQueue を選択することが重要です。

SortedSet<T> や SortedList<TKey, TValue> との違い

特徴PriorityQueueSortedSet / SortedList
主な用途常に最小/最大値だけを効率よく取り出す常に全要素をソートされた状態で保持し、検索も行う
計算量 (追加)$O(\log N)$$O(\log N)$ (ただし再配置コストが高い場合あり)
計算量 (削除)$O(\log N)$ (最小/最大のみ)$O(\log N)$ (任意要素)
要素の重複許可される基本的に許可されない (SortedSet)
ランダムアクセス不可 (先頭のみ)可能(インデックスまたはキーによる検索)

PriorityQueue は「すべてをソートしておく必要はないが、一番重要なものだけは常に把握しておきたい」という場面に特化しています。

そのため、全要素を列挙(イテレーション)しても、昇順に並んでいることは保証されません。

あくまで「取り出すときに最小であること」のみが保証されるデータ構造です。

パフォーマンス特性と使用上の注意点

PriorityQueue を扱う上で、知っておくべき技術的な制約や注意点がいくつかあります。

1. スレッドセーフではない

標準の PriorityQueue<TElement, TPriority> はスレッドセーフではありません。

複数のスレッドから同時に要素を追加・削除する場合、データが破損したり例外が発生したりするリスクがあります。

マルチスレッド環境で使用する場合は、lock 文を用いた排他制御を行うか、自作の同期ラッパーを作成する必要があります。

C#
private readonly PriorityQueue<string, int> _safeQueue = new();
private readonly object _lockObj = new();

public void SafeEnqueue(string item, int priority)
{
    lock (_lockObj)
    {
        _safeQueue.Enqueue(item, priority);
    }
}

2. 優先度の更新(Update Priority)が困難

ヒープ構造の特性上、一度追加した要素の優先度を途中で変更する(UpdatePriority)操作は標準メソッドとして用意されていません。

もし優先度を変更したい場合は、古い要素を無視するか、キューを再構築する必要があります。

ダイクストラ法の実装例で「既に確定している距離より長い場合はスキップ」という処理を入れたのは、この制約への対策です。

3. 列挙時の順序

foreach などで PriorityQueue の中身をループした場合、優先度順には表示されません

内部のヒープ配列の物理的な並び順で取得されるためです。

必ず Dequeue() を通じて要素を取り出すようにしてください。

まとめ

C# の PriorityQueue<TElement, TPriority> は、.NET 6 における非常に強力な追加機能の一つです。

要素と優先度を明確に分離して扱える設計は、コードの可読性を高めるだけでなく、メモリ効率や実行速度の面でも大きなメリットをもたらします。

  • 基本的な使い方は Enqueue と Dequeue だけと非常にシンプル。
  • IComparer を活用することで、降順や複雑なオブジェクトの比較にも柔軟に対応可能。
  • ダイクストラ法などのアルゴリズムにおいて、標準機能だけで高速な実装ができる。
  • ただし、スレッドセーフではない点や列挙順序の仕様には注意が必要。

タスクスケジューリング、イベント駆動型システム、ゲームのAI思考ルーチンなど、優先度に基づいたデータ処理が必要なシーンでは、ぜひこの PriorityQueue を第一の選択肢として検討してみてください。

適切に活用することで、アプリケーションのパフォーマンスとメンテナンス性を一段上のレベルへと引き上げることができるでしょう。