1.5 - Mảng và danh sách
Tóm tắt: Bốn cấu trúc bạn sẽ dùng hằng ngày:
array(cố định, nhanh nhất),List<T>(mảng biết tự lớn lên),Dictionary<K,V>(tra theo khoá, trung bình O(1)) vàHashSet<T>(kiểm tra tồn tại, trung bình O(1)). Chọn sai một cái không làm code sai — nó chỉ làm code chậm dần theo lượng dữ liệu. Cái bẫy kinh điển: gọilist.Contains()bên trong một vòng lặp, biến việc đối chiếu hai danh sách 10.000 phần tử từ tức thời thành 100 triệu phép so sánh. ĐổiListthànhHashSetlà xong, và đó là một dòng code.
Mục tiêu bài học
Sau bài này bạn có thể:
- Chọn giữa
array,List<T>,Dictionary<K,V>,HashSet<T>theo thao tác chính bạn cần làm. - Giải thích
List<T>lớn lên như thế nào, và vì sao đặt trước sức chứa lại có ích. - Nêu điều kiện để một kiểu dùng được làm khoá
Dictionary. - Nhận ra và sửa bẫy
Containstrong vòng lặp. - Chọn kiểu trả về đúng cho một phương thức công khai.
Cần biết trước: bài 1.3 (vòng lặp, lambda), bài 1.4 (hàm, out).
Ký hiệu O(1), O(n). Bảng dưới dùng ký hiệu Big-O để nói một thao tác chậm đi thế nào khi dữ liệu lớn lên: O(1) là gần như không đổi dù có 10 hay 10 triệu phần tử, O(n) là tăng tỉ lệ với số phần tử n. Bài 1.7 dạy kỹ.
Nội dung bài học
1.5.1 — Bốn lựa chọn và thao tác mạnh nhất của mỗi cái
| Cấu trúc | Truy cập theo chỉ số | Tìm theo giá trị | Thêm vào cuối | Dùng khi |
|---|---|---|---|---|
T[] | O(1) | O(n) | không thêm được | Số lượng biết trước và không đổi |
List<T> | O(1) | O(n) | O(1) trung bình | Mặc định cho danh sách |
Dictionary<K,V> | — | O(1) theo khoá | O(1) trung bình | Tra cứu theo mã, theo Id |
HashSet<T> | — | O(1) | O(1) trung bình | Kiểm tra tồn tại, khử trùng lặp |
1.5.2 — array: cố định và liền mạch
int[] scores = new int[5]; // 5 phần tử, mặc định bằng 0
string[] tiers = { "Thường", "Bạc", "VIP" };
Console.WriteLine(tiers.Length); // 3 — Length, không phải Count
Console.WriteLine(tiers[^1]); // "VIP"Mảng nằm liền mạch trong bộ nhớ, nên truy cập theo chỉ số là một phép cộng địa chỉ — nhanh nhất có thể. Đổi lại kích thước cố định: muốn thêm phần tử phải cấp phát mảng mới và chép toàn bộ sang.
Trong code nghiệp vụ, bạn hiếm khi cần array trực tiếp. Nó đáng dùng khi số phần tử biết trước, hoặc trong đoạn code nhạy hiệu năng.
1.5.3 — List<T>: mảng biết tự lớn lên
// Customer.cs
class Customer
{
public string Name { get; set; } = "";
public bool IsInactive { get; set; }
}// Program.cs
var customers = new List<Customer>();
customers.Add(new Customer { Name = "A" });
customers.Insert(0, new Customer { Name = "B", IsInactive = true }); // O(n) — phải dịch mọi phần tử
customers.RemoveAll(c => c.IsInactive);
Console.WriteLine(customers.Count); // 1Dán vào một file Program.cs thì khối Program.cs đặt lên đầu, khối khai báo đặt xuống cuối (C# bắt buộc, nếu không sẽ báo lỗi CS8803).
Bên trong, List<T> là một array. Khi đầy, nó cấp phát mảng mới gấp đôi rồi chép sang. Vì thế:
Addở cuối là O(1) khấu hao — thỉnh thoảng có một lần chép tốn kém, nhưng chia đều ra thì rẻ.Insert(0, ...)vàRemoveAt(0)là O(n), vì mọi phần tử phải dịch chỗ.- Nếu biết trước số lượng, hãy đặt sức chứa để tránh chuỗi cấp phát và sao chép:
var items = new List<Order>(capacity: 10_000); // cấp phát đúng một lầnCount là số phần tử đang có; Capacity là sức chứa hiện tại. Hai con số khác nhau, và chỉ Count mới là thứ bạn quan tâm trong logic.
1.5.4 — Dictionary<K,V>: tra theo khoá
var customer = new Customer { Name = "An" };
var customersById = new Dictionary<int, Customer>();
customersById[42] = customer;
// Cách đọc AN TOÀN — không ném nếu thiếu khoá
if (customersById.TryGetValue(42, out var found))
Console.WriteLine(found.Name);
// Cách đọc ném KeyNotFoundException nếu thiếu
var risky = customersById[999];Dùng TryGetValue làm mặc định. Nó tra cứu một lần duy nhất, trong khi ContainsKey rồi [key] là tra hai lần.
Điều kiện của khoá: kiểu làm khoá phải cài đặt GetHashCode() và Equals() nhất quán với nhau. Các kiểu dựng sẵn (int, string, Guid) đã có sẵn. Nếu dùng class của bạn làm khoá mà không override hai thứ đó, Dictionary sẽ so sánh theo tham chiếu — hai object có nội dung giống hệt nhau vẫn là hai khoá khác nhau.
GetHashCode() trả về một số nguyên (mã băm) tính từ giá trị, dùng để biết phải tìm ở "ngăn" nào; Equals() so hai giá trị có bằng nhau không. Cách đơn giản nhất để có cả hai là khai báo khóa bằng record, một dạng khai báo kiểu chỉ chứa dữ liệu (bài 4.7 dạy kỹ):
// record tự sinh GetHashCode và Equals theo giá trị -> dùng làm khoá được ngay
record CustomerKey(int TenantId, int CustomerId);Một cái bẫy hay gặp với LINQ. Hai cách dưới đây thay thế nhau:
// SAI khi một khách có nhiều đơn: ném ArgumentException
// "An item with the same key has already been added. Key: 7"
var orderByCustomer = orders.ToDictionary(o => o.CustomerId);
// ĐÚNG: gom nhóm trước, mỗi khóa ứng với danh sách đơn của khách đó
var ordersByCustomer = orders.GroupBy(o => o.CustomerId)
.ToDictionary(g => g.Key, g => g.ToList());1.5.5 — HashSet<T> và bẫy Contains trong vòng lặp
Đây là phần đáng giá nhất của cả bài. Hai đoạn code dưới là phác thảo (GetActiveIds, Process là hàm giả định); Bài tập 1 cho bạn chạy và đo thật.
// CHẬM — O(n × m)
var activeIds = GetActiveIds(); // List<int>, 10.000 phần tử
foreach (var order in orders) // 10.000 đơn
{
if (activeIds.Contains(order.CustomerId)) // quét tuyến tính MỖI LẦN
Process(order);
}
// Tổng: tới 100.000.000 phép so sánhList<T>.Contains phải quét từ đầu tới khi tìm thấy. Đặt nó trong vòng lặp là nhân hai kích thước với nhau.
// NHANH — O(n + m)
var activeIds = GetActiveIds().ToHashSet(); // dựng một lần, O(m)
foreach (var order in orders)
{
if (activeIds.Contains(order.CustomerId)) // O(1) trung bình
Process(order);
}
// Tổng: khoảng 20.000 thao tácMột dòng .ToHashSet() đổi 100 triệu phép so sánh lấy 20 nghìn. Đây chính là Big-O có mặt trong đời thật, và bài 1.7 — Cấu trúc dữ liệu và Big-O sẽ nói kỹ vì sao.
HashSet còn tự khử trùng lặp và có các phép toán tập hợp:
var a = new HashSet<int> { 1, 2, 3 };
a.UnionWith(new[] { 3, 4 }); // {1,2,3,4}
a.IntersectWith(new[] { 2, 3, 9 }); // {2,3}
a.ExceptWith(new[] { 3 }); // {2}1.5.6 — Trả kiểu gì ra khỏi API?
Mục này nói về hàm nằm trong một class, nơi _orders là danh sách mà class giữ bên trong (bài 4.2 dạy kỹ). Ba phiên bản dưới thay thế nhau:
// Rò rỉ khả năng sửa — người gọi Add/Remove thẳng vào trạng thái nội bộ của bạn
public List<Order> GetOrders() => _orders;
// Chỉ đọc, nhưng vẫn cho biết số lượng và truy cập theo chỉ số
public IReadOnlyList<Order> GetOrders() => _orders;
// Lười nhất — người gọi không biết đã vật chất hoá chưa
public IEnumerable<Order> GetOrders() => _orders;Hướng dẫn thực dụng:
- Trả
IReadOnlyList<T>cho dữ liệu đã có sẵn trong bộ nhớ. Người gọi biếtCountmà không sợ chạy lại truy vấn. - Trả
IEnumerable<T>khi kết quả thật sự được sinh dần (yield return, luồng dữ liệu lớn). - Nhận
IEnumerable<T>làm tham số — càng rộng càng dễ gọi.
Nguyên tắc chung: nhận rộng, trả hẹp.
1.5.7 — Rà lại code của bạn
Danh sách rà soát collection
- Không có Contains trên List nào nằm trong vòng lặp; đã đổi sang HashSet.
- Dùng TryGetValue thay cho cặp ContainsKey + indexer.
- Kiểu tự viết dùng làm khoá Dictionary đều là record, hoặc đã override GetHashCode và Equals.
- ToDictionary chỉ dùng khi khoá chắc chắn duy nhất; còn lại dùng GroupBy.
- Danh sách biết trước số lượng lớn thì khởi tạo kèm capacity.
- Phương thức công khai trả IReadOnlyList thay vì List.
- Không dùng Insert(0) hay RemoveAt(0) trong vòng lặp trên danh sách lớn.
Bài tập áp dụng
Ba bài đều có kết quả đo được. Chạy ở chế độ Release (dotnet run -c Release), vì bản Debug tắt nhiều tối ưu và cho số liệu sai lệch. Đo thời gian bằng Stopwatch, nhớ thêm using System.Diagnostics; ở dòng đầu Program.cs: thiếu dòng này sẽ gặp lỗi CS0103: The name 'Stopwatch' does not exist in the current context.
Bài 1 — Đo bẫy Contains trong vòng lặp
Dựng hai danh sách 20.000 số nguyên có phần giao nhau. Đối chiếu bằng List.Contains trong vòng lặp, đo bằng Stopwatch. Làm lại với HashSet. Ghi lại tỉ lệ chênh lệch.
Tiêu chí hoàn thành: bạn giải thích được tỉ lệ đó bằng độ phức tạp, không chỉ đọc con số.
Gợi ý và lời giải — Bài 1
Gợi ý. Đếm số phép so sánh thay vì nhìn đồng hồ. Vòng lặp ngoài chạy 20.000 lần. Với mỗi lần, List.Contains làm gì? Nó không biết dữ liệu được sắp xếp hay chưa, nên phải duyệt tuần tự từ đầu.
Lời giải.
using System.Diagnostics;
int n = 20_000;
var a = Enumerable.Range(0, n).ToList(); // 0 .. 19.999
var b = Enumerable.Range(n / 2, n).ToList(); // 10.000 .. 29.999, giao với a ở 10.000 số
var sw = Stopwatch.StartNew();
int c1 = 0;
foreach (var x in a) if (b.Contains(x)) c1++; // O(n²)
sw.Stop();
double listMs = sw.Elapsed.TotalMilliseconds;
Console.WriteLine($"List.Contains : {listMs,8:F2} ms (khớp {c1})");
var hs = new HashSet<int>(b);
sw.Restart();
int c2 = 0;
foreach (var x in a) if (hs.Contains(x)) c2++; // O(n)
sw.Stop();
double setMs = sw.Elapsed.TotalMilliseconds;
Console.WriteLine($"HashSet : {setMs,8:F2} ms (khớp {c2})");
Console.WriteLine($"nhanh gấp : {listMs / setMs,8:F0} lần");Kết quả đo thật trên .NET 10, chế độ Release, máy tác giả:
List.Contains : 23,67 ms (khớp 10000)
HashSet : 0,03 ms (khớp 10000)
nhanh gấp : 722 lầnChạy vài lần, tỉ lệ dao động trong khoảng 680 tới 780 lần. Máy bạn sẽ ra số khác; điều cần thấy là khoảng cách hàng trăm lần.
Giải thích bằng độ phức tạp. List.Contains duyệt tuần tự từ đầu. Một nửa số phần tử của a (từ 0 tới 9.999) không có trong b, nên mỗi lần phải quét đủ 20.000 phần tử: 10.000 × 20.000 = 200 triệu phép so sánh. Nửa còn lại có trong b, trung bình tìm thấy sau khoảng 5.000 phép so sánh: thêm 50 triệu. Tổng khoảng 250 triệu phép so sánh — đó chính là O(n²).
HashSet.Contains tính mã băm của giá trị rồi nhảy thẳng tới ô tương ứng, mất thời gian gần như không đổi bất kể tập hợp lớn cỡ nào. Tổng cộng chỉ 20.000 lần tra, tức O(n).
Điều quan trọng hơn con số 722 lần. Tỉ lệ này không cố định — nó tăng theo kích thước dữ liệu. Bản List tốn khoảng n² bước, bản HashSet khoảng n bước, nên tỉ lệ giữa hai bên tỉ lệ thuận với n: dữ liệu gấp 10 thì lý thuyết dự đoán tỉ lệ gấp khoảng 10, tức vài nghìn lần. Đổi n thành 200_000 rồi chạy lại, máy tác giả đo được bản List mất khoảng 1.250 ms, bản HashSet khoảng 0,37 ms, chênh khoảng 3.400 lần. Tỉ lệ chỉ tăng gấp 5 chứ không đủ 10 vì bộ nhớ đệm (cache) của CPU và lệnh so sánh nhiều số cùng lúc (SIMD) làm phép quét tuyến tính nhanh hơn lý thuyết thuần túy. Big-O dự đoán xu hướng, không dự đoán con số chính xác. Đây là lý do một tính năng chạy tốt suốt giai đoạn thử nghiệm lại sập khi dữ liệu thật đổ vào.
Cách nhận ra trong code review. Dấu hiệu là một lời gọi Contains, Any, First, hoặc Where trên một tập hợp bên trong một vòng lặp duyệt tập hợp khác. Gặp mẫu này thì chuyển tập hợp bên trong sang HashSet hoặc Dictionary trước khi vào vòng lặp. Cùng một lỗi này khi tập hợp bên trong là database thì mang tên N+1, đã gặp ở bài 1.3.
Bài 2 — Khoá Dictionary sai kiểu
Tạo class CustomerKey thường (không phải record) với hai thuộc tính, dùng làm khoá Dictionary. Thêm một phần tử, rồi TryGetValue bằng một đối tượng mới có cùng giá trị. Giải thích vì sao không tìm thấy, rồi sửa bằng record.
Tiêu chí hoàn thành: bạn nêu đúng hai phương thức mà Dictionary dựa vào, và vì sao bản mặc định của class không đáp ứng.
Gợi ý và lời giải — Bài 2
Gợi ý. Dictionary tìm khoá theo hai bước: trước hết tính mã băm để biết nhìn vào ô nào, sau đó so sánh bằng để xác nhận đúng khoá. Vậy có hai phương thức tham gia. Với class thường, cả hai đều dựa trên cái gì?
Lời giải.
// CustomerKeys.cs
class CustomerKeyCls { public string Region = ""; public int Code; }
record CustomerKeyRec(string Region, int Code);// Program.cs
var k1 = new CustomerKeyCls { Region = "HN", Code = 1 };
var k2 = new CustomerKeyCls { Region = "HN", Code = 1 };
var dc = new Dictionary<CustomerKeyCls, string> { [k1] = "x" };
var r1 = new CustomerKeyRec("HN", 1);
var r2 = new CustomerKeyRec("HN", 1);
var dr = new Dictionary<CustomerKeyRec, string> { [r1] = "x" };
Console.WriteLine($"class tìm lại: {dc.TryGetValue(k2, out _)}");
Console.WriteLine($"record tìm lại: {dr.TryGetValue(r2, out _)}");
Console.WriteLine($"class mã băm bằng nhau: {k1.GetHashCode() == k2.GetHashCode()}");
Console.WriteLine($"record mã băm bằng nhau: {r1.GetHashCode() == r2.GetHashCode()}");Kết quả chạy thật trên .NET 10:
class tìm lại: False
record tìm lại: True
class mã băm bằng nhau: False
record mã băm bằng nhau: TrueVì sao. Dictionary dùng GetHashCode() để chọn ô chứa và Equals() để xác nhận. Bản mặc định của hai phương thức này ở class dựa trên danh tính của đối tượng trong bộ nhớ, không dựa trên giá trị các thuộc tính. Hai đối tượng khác nhau nên mã băm khác nhau, nên Dictionary tìm ở một ô hoàn toàn khác và kết luận không có.
record tự sinh cả GetHashCode() lẫn Equals() dựa trên toàn bộ thuộc tính, nên hai giá trị giống nhau cho cùng mã băm và được coi là bằng nhau.
Vì sao lỗi này nguy hiểm. Nó không ném ngoại lệ. Dictionary chỉ lặng lẽ báo không tìm thấy, và code thường xử lý trường hợp đó bằng cách thêm mới — nên bạn kết thúc với hai mục trùng nội dung, hoặc một bộ đệm không bao giờ trúng. Trong một hệ thống đang chạy, triệu chứng là "cache không hiệu quả" hoặc "dữ liệu bị nhân đôi", và rất ít người nghĩ ngay tới GetHashCode.
Ba cách sửa, theo thứ tự nên dùng:
- Dùng
recordhoặcrecord struct— tự sinh cả hai phương thức, không viết thêm dòng nào. - Tự ghi đè
EqualsvàGetHashCodekhi buộc phải giữclass, và phải ghi đè cả hai, vì ghi đè một cái thôi là tạo ra lỗi còn khó tìm hơn. - Truyền một
IEqualityComparer<T>riêng vào hàm khởi tạoDictionary, khi bạn không sở hữu kiểu khoá hoặc cần quy tắc so sánh khác nhau ở từng chỗ.
Bài 4.7 — Records và Value Objects đi sâu vào tiêu chí chọn giữa record và class.
Bài 3 — Đặt trước sức chứa cho List
Thêm 1.000.000 phần tử vào một List<int> không đặt sức chứa và một List<int> có đặt. So sánh thời gian và số lần cấp phát lại.
Tiêu chí hoàn thành: bạn tính được số lần cấp phát lại bằng lý thuyết, rồi đối chiếu với thực tế.
Gợi ý và lời giải — Bài 3
Gợi ý. List<T> bọc bên trong một mảng có kích thước cố định. new List<int>() chưa có mảng nào (Capacity bằng 0); lần Add đầu tiên cấp phát mảng 4 ô. Khi mảng đầy, nó cấp phát mảng mới gấp đôi rồi sao chép toàn bộ nội dung sang. Vậy để chứa một triệu phần tử, dãy sức chứa đi qua những giá trị nào, và có bao nhiêu bước? Đếm thực tế bằng cách theo dõi Capacity sau mỗi lần Add.
Một bẫy khi đo: lần đầu chạy một đoạn code, .NET còn phải biên dịch nó sang mã máy (JIT), nên đoạn nào chạy trước sẽ bị thiệt. Chạy mỗi cách nhiều lần và lấy lần nhanh nhất.
Lời giải.
using System.Diagnostics;
// Chạy mỗi cách 10 lần, lấy lần nhanh nhất
double best1 = double.MaxValue, best2 = double.MaxValue;
for (int run = 0; run < 10; run++)
{
var sw = Stopwatch.StartNew();
var l1 = new List<int>();
for (int i = 0; i < 1_000_000; i++) l1.Add(i);
best1 = Math.Min(best1, sw.Elapsed.TotalMilliseconds);
sw.Restart();
var l2 = new List<int>(1_000_000);
for (int i = 0; i < 1_000_000; i++) l2.Add(i);
best2 = Math.Min(best2, sw.Elapsed.TotalMilliseconds);
}
Console.WriteLine($"không capacity: {best1:F1} ms");
Console.WriteLine($"có capacity : {best2:F1} ms");
// Đếm số lần cấp phát: mỗi lần Capacity đổi là một lần cấp phát mảng mới
var l3 = new List<int>();
int growCount = 0;
int lastCapacity = l3.Capacity; // 0: chưa có mảng nào
for (int i = 0; i < 1_000_000; i++)
{
l3.Add(i);
if (l3.Capacity != lastCapacity)
{
growCount++;
lastCapacity = l3.Capacity;
}
}
Console.WriteLine($"số lần cấp phát: {growCount}, sức chứa cuối: {l3.Capacity}");Kết quả đo thật trên .NET 10, chế độ Release, máy tác giả:
không capacity: 1.2 ms
có capacity : 1.1 ms
số lần cấp phát: 19, sức chứa cuối: 1048576Nếu bỏ vòng chạy 10 lần và chỉ đo một lần, bạn sẽ thấy cách nào chạy trước chậm hơn hẳn (trên máy tác giả khoảng 8 tới 11 ms so với 2 tới 4 ms), bất kể có đặt sức chứa hay không. Đó là chi phí JIT, không phải chi phí cấp phát.
Tính bằng lý thuyết. Sức chứa đi theo dãy 4, 8, 16, 32, … mỗi bước gấp đôi, tới 1.048.576 = 2²⁰ là số đầu tiên vượt một triệu. Từ 4 = 2² tới 2²⁰ có 19 giá trị: một lần cấp phát đầu tiên và 18 lần cấp phát lại, khớp với con số 19 đếm được. Mỗi lần cấp phát lại đều phải sao chép toàn bộ phần tử hiện có sang mảng mới, và tổng số phần tử phải sao chép cộng dồn xấp xỉ bằng kích thước cuối (4 + 8 + … + 524.288 ≈ 1 triệu).
Vì sao chênh lệch nhỏ như vậy. Vì phép nhân đôi khiến chi phí cấp phát lại phân bổ đều ra thành gần như không đổi trên mỗi lần Add — thuật ngữ gọi là chi phí khấu hao. Đây cũng là lý do List<T> vẫn nhanh dù không đặt sức chứa, và là lý do không nên đặt sức chứa theo phản xạ.
Khi nào việc này đáng làm:
| Nên đặt sức chứa | Không cần |
|---|---|
Biết trước số phần tử, ví dụ đã có rows.Count | Không biết trước số lượng |
| Danh sách rất lớn, từ hàng trăm nghìn phần tử | Danh sách vài chục phần tử |
| Vòng lặp nóng chạy liên tục | Code khởi tạo chạy một lần |
Một chi tiết ít người biết. Bộ nhớ lãng phí còn đáng kể hơn thời gian. Một List chứa 1.000.001 phần tử sẽ có sức chứa 2.097.152 — tức chiếm gấp đôi bộ nhớ cần thiết. Với đối tượng lớn thay vì số nguyên, phần thừa đó đủ gây áp lực lên bộ thu gom rác. Khi đã điền xong và không thêm nữa, gọi TrimExcess() để trả lại phần thừa, hoặc dùng thẳng ToArray().
Tự kiểm tra
List<T> lớn lên bằng cách nào?
Bên trong List<T> là một array. Khi đầy, nó cấp phát một array mới gấp đôi sức chứa rồi chép toàn bộ phần tử sang. Vì thế Add ở cuối là O(1) khấu hao — thỉnh thoảng tốn một lần chép, nhưng chia đều ra thì rẻ. Nếu biết trước số lượng, đặt capacity lúc khởi tạo để chỉ cấp phát một lần.
Vì sao Contains trong vòng lặp lại nguy hiểm?
Vì List.Contains phải quét tuyến tính, nên đặt nó trong vòng lặp là nhân hai kích thước với nhau. Hai tập 10.000 phần tử thành tối đa 100 triệu phép so sánh. Đổi danh sách tra cứu thành HashSet làm mỗi lần kiểm tra còn O(1) trung bình, tổng chi phí về khoảng 20.000 thao tác.
Kiểu nào dùng được làm khoá Dictionary?
Kiểu có GetHashCode() và Equals() nhất quán với nhau. Các kiểu dựng sẵn như int, string, Guid đã có sẵn. Nếu dùng class tự viết mà không override hai thứ đó, Dictionary so sánh theo tham chiếu, nên hai object cùng nội dung vẫn là hai khoá khác nhau. Dùng record là cách đơn giản nhất vì nó tự sinh cả hai theo giá trị.
TryGetValue tốt hơn ContainsKey rồi lấy indexer ở chỗ nào?
ContainsKey rồi dùng indexer là tra cứu hai lần trên cùng một khoá. TryGetValue làm đúng một lần và trả về cả kết quả lẫn cờ tìm thấy, code cũng gọn hơn. Lưu ý: Dictionary không an toàn khi có nhiều luồng cùng ghi, dù bạn dùng API nào. Khi đó dùng ConcurrentDictionary (TryGetValue, GetOrAdd).
Nên trả IEnumerable hay IReadOnlyList ra khỏi một phương thức?
Trả IReadOnlyList cho dữ liệu đã có trong bộ nhớ, vì người gọi biết được Count và truy cập theo chỉ số mà không sợ chạy lại truy vấn. Trả IEnumerable khi kết quả thật sự sinh dần, ví dụ hàm dùng yield return. Với tham số đầu vào thì ngược lại: nhận IEnumerable cho rộng đường gọi.
ToDictionary bị ném ArgumentException là vì sao?
Vì có hai phần tử sinh ra cùng một khoá, mà Dictionary không cho phép khoá trùng. Nếu dữ liệu có thể trùng khoá, hãy GroupBy trước rồi ToDictionary với giá trị là danh sách của từng nhóm.
Kết luận
Ba điều đáng nhớ nhất:
Containstrong vòng lặp là lỗi hiệu năng phổ biến nhất với collection. Và nó sửa bằng đúng một dòng.ToHashSet().TryGetValuelà cách đọcDictionarymặc định. Một lần tra cứu, không ném ngoại lệ, không cần kiểm tra trước.- Nhận rộng, trả hẹp. Nhận
IEnumerable<T>, trảIReadOnlyList<T>— để người gọi dễ gọi mà không sửa được trạng thái nội bộ của bạn.
Tham khảo
- Arrays — C# programming guide
List<T>Class — chú ý phần CapacityDictionary<TKey,TValue>ClassHashSet<T>Class- Commonly used collection types — bảng so sánh độ phức tạp chính thức
- Collections and Data Structures — tổng quan
Điều hướng
- Bài trước: 1.4 — Hàm
- Bài tiếp theo: 1.6 — Tư duy giải bài toán
- Về module: Trang mục lục