Queue<T> hoạt động như một hàng người xếp trước quầy: phần tử được thêm vào đầu tiên là phần tử được lấy ra đầu tiên (FIFO, first in, first out, vào trước ra trước). Bạn thêm vào cuối hàng bằng Enqueue và lấy ra từ đầu hàng bằng Dequeue, cả hai đều trong thời gian hằng số.
Enqueue, Dequeue và Peek
Đầu ra:
Waiting: 3
Next up: invoice.pdf
Printing invoice.pdf
Next up: photo.png
Printing photo.png
Printing report.docx
Waiting: 0
Peek dùng để quyết định làm gì với phần tử kế tiếp trước khi thật sự lấy nó; Dequeue thì lấy luôn. Vòng lặp while (queue.Count > 0) lấy ra cho đến khi rỗng là cách chuẩn để xử lý một queue, và nó vẫn chạy đúng nếu thân vòng lặp thêm phần tử mới vào, điều mà foreach không cho phép.
Ngoại lệ khi queue rỗng và TryDequeue
Dequeue và Peek trên một queue rỗng sẽ ném InvalidOperationException. Không có kiểu hành vi "trả về null khi rỗng", kể cả với kiểu tham chiếu.
Đầu ra:
Caught InvalidOperationException
Serving ticket 41
Serving ticket 42
False
TryDequeue và TryPeek trả về true và gán biến out khi có phần tử, và trả về false khi queue rỗng. Chúng xuất hiện từ .NET Core 2.0; với code .NET Framework cũ hơn, hãy kiểm tra Count > 0 trước khi gọi Dequeue.
Xem bên trong một queue
Bạn có thể duyệt một queue mà không lấy phần tử nào ra. foreach, ToArray và Contains đều thấy các phần tử theo thứ tự từ đầu đến cuối hàng.
Đầu ra:
Ana Ben Chloe
True
Ana is first, 3 in line
3
Queue không có chỉ số: line[1] không biên dịch được, và bạn không thể xóa một phần tử ở giữa. Nếu cần một trong hai điều đó, dữ liệu của bạn thật ra không phải là một hàng đợi; hãy dùng List<T> hoặc LinkedList<T>. Giống các collection khác, thêm hoặc lấy phần tử bên trong một foreach trên chính queue đó sẽ ném InvalidOperationException.
Tìm kiếm theo chiều rộng với queue
Thứ tự của queue chính là thứ mà tìm kiếm theo chiều rộng (BFS) cần: đi qua mọi thứ cách một bước, rồi mọi thứ cách hai bước, và cứ thế tiếp tục. Ở đây nó tìm xem Ana cách mọi người khác bao nhiêu mối quan hệ trong một mạng lưới nhỏ:
Đầu ra:
Ana: 0 step(s) from Ana
Ben: 1 step(s) from Ana
Chloe: 1 step(s) from Ana
Dev: 2 step(s) from Ana
Eli: 2 step(s) from Ana
Fay: 3 step(s) from Ana
Mỗi người chỉ được đưa vào queue một lần, ở lần đầu tiên được chạm tới, và vì queue trả họ ra theo đúng thứ tự được tìm thấy, lần đầu tiên đó luôn nằm trên một đường đi ngắn nhất. Đổi queue thành Stack thì cùng vòng lặp đó trở thành tìm kiếm theo chiều sâu, và không còn tìm được đường đi ngắn nhất nữa.
Cùng cấu trúc này xử lý được việc duyệt cây theo từng tầng, tô loang (flood fill) trên lưới và thu thập liên kết: bắt đầu với một phần tử trong queue, và chừng nào queue chưa rỗng thì lấy ra một phần tử rồi đưa các hàng xóm chưa được thăm của nó vào.
Queue và List cho công việc FIFO
Bạn có thể dùng List<T> làm hàng đợi với Add và RemoveAt(0), nhưng RemoveAt(0) dịch mọi phần tử còn lại lùi xuống một vị trí. Làm rỗng một danh sách 100.000 phần tử theo cách đó cần khoảng năm tỷ lần di chuyển phần tử; Queue<T> chỉ cần 100.000 bước với thời gian hằng số. Bên trong, queue là một bộ đệm vòng (circular buffer): nó theo dõi chỉ số đầu và chỉ số cuối trong một mảng và chỉ cấp phát lại khi mảng đầy.
| Thao tác | Queue<T> | List<T> dùng làm hàng đợi |
|---|---|---|
| Thêm vào cuối | Enqueue, O(1) | Add, O(1) |
| Lấy ra từ đầu | Dequeue, O(1) | RemoveAt(0), O(n) |
| Xem phần tử đầu | Peek | list[0] |
| Truy cập theo chỉ số | Không có | list[i] |
ConcurrentQueue cho nhiều luồng
Queue<T> không an toàn đa luồng. Khi nhiều luồng cùng thêm hoặc lấy phần tử, hãy dùng ConcurrentQueue<T> trong System.Collections.Concurrent. Nó có Enqueue, TryDequeue và TryPeek nhưng không có Dequeue, vì khi có các luồng khác, "kiểm tra Count rồi Dequeue" có thể thất bại ngay giữa hai lời gọi.
Đầu ra:
4000
4000 processed
Nếu dùng Queue<T> thông thường thay cho ConcurrentQueue<T>, số lượng sẽ ra sai hoặc chương trình sẽ ném ngoại lệ, tùy vào thời điểm chạy. Với các luồng producer và consumer cần chờ việc thay vì quay vòng liên tục, BlockingCollection<T> (mặc định bọc một ConcurrentQueue<T>) hoặc System.Threading.Channels bổ sung khả năng chờ và báo hoàn tất. Xem lock để biết cách tự bảo vệ một collection thông thường.
PriorityQueue
Khi phần tử cần rời hàng theo độ ưu tiên thay vì thứ tự đến (phiếu khẩn cấp nhất trước, đường đi ngắn nhất hiện tại trong thuật toán Dijkstra), .NET 6 trở lên cung cấp PriorityQueue<TElement, TPriority>. Giá trị ưu tiên thấp nhất được lấy ra trước:
var triage = new PriorityQueue<string, int>();
triage.Enqueue("sprained ankle", 3);
triage.Enqueue("chest pain", 1);
triage.Enqueue("headache", 5);
while (triage.TryDequeue(out string patient, out int priority))
{
Console.WriteLine($"{priority}: {patient}");
}
// 1: chest pain
// 3: sprained ankle
// 5: headache
Các phần tử có cùng độ ưu tiên có thể ra theo thứ tự bất kỳ; hãy thêm một số thứ tự vào độ ưu tiên nếu cần thứ tự đến để phân định.
Lỗi thường gặp
- Dequeue mà không kiểm tra. Một queue rỗng sẽ ném
InvalidOperationException; hãy lặp theoCount > 0hoặc dùngTryDequeue. - Gọi
Peekvà nghĩ rằng phần tử đã bị lấy ra. ChỉDequeuemới lấy ra. - Enqueue bên trong
foreachtrên chính queue đó. Sẽ ném ngoại lệ; hãy xử lý bằng vòng lặpwhile. - Dùng chung một
Queue<T>giữa nhiều luồng. Hãy dùngConcurrentQueue<T>hoặc một lock. - Dùng
List.RemoveAt(0)làm hàng đợi với dữ liệu lớn. Mỗi lần gọi là O(n).
Câu hỏi thường gặp
Queue trong C# là gì?
Queue<T> trong System.Collections.Generic là một collection vào trước ra trước (FIFO, first in, first out). Enqueue thêm một phần tử vào cuối hàng, Dequeue lấy ra và trả về phần tử ở đầu hàng, còn Peek trả về phần tử đầu hàng mà không lấy nó ra. Cả ba đều chạy trong thời gian hằng số.
Chuyện gì xảy ra khi gọi Dequeue trên một queue rỗng trong C#?
Dequeue và Peek trên một queue rỗng sẽ ném InvalidOperationException. Hãy kiểm tra queue.Count > 0 trước, hoặc dùng TryDequeue(out var item) và TryPeek(out var item), hai phương thức trả về false thay vì ném ngoại lệ (có từ .NET Core 2.0).
Peek và Dequeue khác nhau thế nào?
Peek trả về phần tử ở đầu hàng và để nguyên nó trong queue, nên gọi hai lần sẽ được cùng một phần tử. Dequeue trả về phần tử đầu hàng và lấy nó ra, nên lần gọi tiếp theo trả về phần tử đứng sau nó.
Queue<T> trong C# có an toàn đa luồng không?
Không. Hai luồng cùng lúc gọi Enqueue hoặc Dequeue trên cùng một Queue<T> có thể làm hỏng nó. Hãy dùng ConcurrentQueue<T> trong System.Collections.Concurrent, có Enqueue và TryDequeue an toàn khi gọi từ nhiều luồng, hoặc bọc mọi lần truy cập queue thông thường trong một lock.
Vì sao nên dùng Queue thay vì List?
Xóa phần tử đầu tiên của một List<T> bằng RemoveAt(0) sẽ dịch chuyển mọi phần tử còn lại, nên càng chậm khi danh sách càng lớn. Queue<T> lấy phần tử ở đầu ra trong thời gian hằng số, và API của nó nói rõ ý định: các phần tử được xử lý theo thứ tự đến.