You are on page 1of 20

Các thuật toán sắp xếp với C#:

sắp xếp chọn, chèn, nổi bọt,


nhanh
NỘI DUNG CỦA BÀI  Ẩn 
1. Thuật toán Sắp xếp chọn (Selection sort)
1.1. Ví dụ minh họa hoạt động của thuật toán sắp xếp chọn
1.2. Thực thi thuật toán sắp xếp chọn trong C#
2. Thuật toán sắp xếp chèn (Insertion sort)
2.1. Minh họa hoạt động của thuật toán sắp xếp chèn
2.2. Thực thi thuật toán sắp xếp chèn trong C#
3. Sắp xếp nổi bọt (Bubble sort)
3.1. Thực thi thuật toán sắp xếp nổi bọt trong C#
3.2. Cải tiến thuật toán sắp xếp nổi bọt
4. Thuật toán sắp xếp nhanh (Quicksort)
4.1. Thuật toán
4.2. Minh họa hoạt động của thuật toán Quicksort
4.3. Thực thi thuật toán Quicksort trong C#
5. Kết luận
5.1. Tải mã nguồn solution cho Các thuật toán sắp xếp

Thuật toán Sắp xếp chọn (Selection sort)


Sắp xếp chọn là thuật toán sắp xếp đơn giản nhất. Nếu bạn chưa học bài bản
về cấu trúc dữ liệu và thuật toán, khi được yêu cầu sắp xếp mảng, tôi tin rằng
hầu hết các bạn sẽ tự xây dựng ra được một thuật toán rất gần với sắp xếp
chọn.

Giả sử chúng ta cần sắp xếp một mảng (một chiều) theo thứ tự tăng dần.

Thuật toán sắp xếp chọn hoạt động trên các mảng một chiều. Nó phân chia
mảng thành hai phần: (1) phần đã sắp xếp, và (2) phần chưa sắp xếp. Ở giai
đoạn khởi đầu, phần đã sắp xếp không có phần tử nào. Phần chưa sắp xếp chứa
toàn bộ các phần tử.

Trong mỗi lượt hoạt động, thuật toán này tìm phần tử nhỏ nhất trong phần
chưa sắp xếp và đổi chỗ nó với phần tử đầu tiên của phần chưa sắp xếp.
Trong lượt đầu tiên, thuật toán dĩ nhiên sẽ tìm ra phần tử nhỏ nhất mảng và đổi
chỗ nó với phần tử đầu tiên của mảng (cũng là của phần chưa sắp xếp). Do đó,
phần tử đầu tiên của mảng sau lượt 1 sẽ là phần tử nhỏ nhất.

Cũng để ý rằng, với logic hoạt động như trên, phần tử nhỏ nhất của phần chưa
sắp xếp sẽ luôn luôn lớn hơn phần tử lớn nhất của phần đã sắp xếp. Khi này, ta
có thể đưa phần tử này vào phần đã sắp xếp. Hiểu theo cách khác, là ta đã mở
rộng phần đã sắp xếp đến phần tử đầu tiên của phần chưa sắp xếp (tức là mở
thêm một ô về phía chưa sắp xếp). Qua mỗi lượt, phần đã sắp xếp sẽ mở rộng
dần, phần chưa sắp xếp sẽ thu hẹp dần.

Quá trình sẽ tiếp diễn như vậy cho đến khi phần đã sắp xếp mở rộng ra chiếm
hết số phần tử của mảng.

Thuật toán này rất gần với cách thức suy nghĩ thông thường của chúng ta.

Ví dụ minh họa hoạt động của thuật toán sắp xếp chọn
Để dễ dàng hình dung hoạt động của thuật toán này, chúng ta cùng thực hiện
(bằng tay) một số ví dụ. Code minh họa trên C# sẽ trình bày ở mục tiếp theo.

Giả sử chúng ta có mảng {10, 3, 1, 7, 9, 2, 0}. Giờ chúng ta cần sắp xếp các
phần tử của mảng này theo thứ tự tăng dần. Hình dưới đây minh họa các bước
thực hiện.
Minh họa hoạt động
của thuật toán sắp xếp chọn
Ở lượt 1, thuật toán sẽ tìm phần tử nhỏ nhất ở vùng chưa sắp xếp (cũng có
nghĩa là phần tử nhỏ nhất mảng, giá trị min = 0). Chỉ số của phần tử này
là m=6 (phần tử giá trị 0 có chỉ số là 6). Phần tử đầu tiên của vùng chưa
sắp xếp hiện tại cũng là phần tử đầu tiên của mảng, có chỉ số i=0. Phần tử m
và i giờ sẽ đổi chỗ cho nhau. Mở rộng ranh giới phần đã sắp xếp ra trước phần
tử đầu tiên của vùng chưa sắp xếp. Như vậy, phần đã sắp xếp giờ có một phần
tử (giá trị 0). Chuyển tiếp sang lượt 2.

Khi bắt đầu lượt 2, ranh giới đã mở rộng ra sau phần tử đầu tiên của mảng
(chứa phần tử giá trị 0). Tiếp tục tìm phần tử nhỏ nhất của vùng chưa sắp xếp
(min = 1, m = 2) và đổi chỗ với phần tử đầu tiên của vùng chưa sắp xếp (i=1).

Khi bắt đầu lượt 3, ranh giới giờ đã mở rộng đến sau phần tử thứ hai của mảng.
Cũng có nghĩa là phần đã sắp xếp giờ có hai phần tử (giá trị 0 và 1). Lại tìm
phần tử nhỏ nhất của vùng chưa sắp xếp (min = 2, m = 5) và đổi chỗ với phần
tử đầu tiên của vùng chưa sắp xếp (i = 2).

Quá trình này cứ lặp lại như vậy cho đến lượt 6, khi vùng chưa sắp xếp chỉ còn
hai phần tử (9 và 10). Dễ dàng tìm được m = 5, min = 9, và i = 5. Đổi chỗ cho
nhau xong là thuật toán hoàn thành nhiệm vụ.

Thực thi thuật toán sắp xếp chọn trong C#


Khi các bạn đã hiểu được hoạt động của thuật toán này, việc thực thi nó trong
C# khá đơn giản. Hãy cùng thực hiện lại chương trình dưới đây.

using System;
namespace P01_SelectionSort
{
class Program
{
static void Main(string[] args)
{
Console.Title = "Selection Sort";
var numbers = new[] {10, 3, 1, 7, 9, 2, 0};
Sort(numbers);
Console.ReadKey();
}
static void Swap<T>(T[] array, int i, int m)
{
T temp = array[i];
array[i] = array[m];
array[m] = temp;
}
static void Print<T>(T[] array)
{
Console.WriteLine(string.Join("\t", array));
}
static void Sort<T>(T[] array) where T : IComparable
{
for (int i = 0; i < array.Length - 1; i++)
{
int m = i;
T minValue = array[i];
for (int j = i + 1; j < array.Length; j++)
{
if (array[j].CompareTo(minValue) < 0)
{
m = j;
minValue = array[j];
}
}
Swap(array, i, m);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"Step {i+1}: i = {i}, m = {m}, min = {minValue}");
Console.ResetColor();
Print(array);
Console.WriteLine();
}
}
}
}
Kết
quả chạy chương trình với thuật toán sắp xếp chọn
So sánh với ví dụ minh họa đã thực hiện ở mục trước.

Các thuật toán trong bài này đều sử dụng kỹ thuật lập trình generic. Kỹ thuật này giúp
các phương thức có thể làm việc với bất kỳ kiểu dữ liệu nào có thể so sánh được (thực
thi giao diện IComparable). Hãy đọc kỹ bài viết trên nếu bạn chưa nắm chắc kỹ thuật này.

Tính toán theo lý thuyết, thuật toán sắp xếp chọn là một thuật toán không hiệu
quả lắm với độ phức tạp là O(n2). Thực tế qua code chúng ta dễ dàng nhận thấy
nó có hai vòng lặp lồng nhau. Một vòng lặp để di chuyển ranh giới vùng, một
vòng lặp để tìm giá trị nhỏ nhất trong vùng chưa sắp xếp.

Link tải mã nguồn solution để ở phần cuối của bài học.

Thuật toán sắp xếp chèn (Insertion sort)


Tương tự như sắp xếp chọn, sắp xếp chèn cũng hoạt động trên mảng một chiều
và là một thuật toán khá đơn giản. Thuật toán này cũng chia mảng thành hai
phần: (1) phần đã sắp xếp, (2) phần chưa sắp xếp. Sự khác biệt với thuật toán
sắp xếp chọn ở chỗ, phần đã sắp xếp ngay từ đầu đã chứa một phần tử (là
phần tử chỉ số 0 của mảng gốc).

Trong mỗi lượt, thuật toán này sẽ mở rộng phần sắp xếp ra phần tử đầu tiên
của phần chưa sắp xếp và tìm cách chèn phần tử mới này vào đúng thứ
tự nó cần có trong phần đã sắp xếp. Khi được chèn vào đúng thứ tự về giá
trị, vùng đã sắp xếp luôn duy trì được đúng trật tự của các phần tử.
Việc chèn phần tử mới vào đúng thứ tự được thực hiện bằng cách lần lượt so
sánh nó với các phần tử của vùng đã sắp xếp. Nếu phần tử mới nhỏ hơn thì sẽ
đổi vị trí với phần tử đang so sánh.

Quá trình tiếp diễn cho đến khi vùng đã sắp xếp mở rộng ra chiếm hết vùng
chưa sắp xếp.

Minh họa hoạt động của thuật toán sắp xếp chèn
Chúng ta cùng xem minh họa hoạt động của thuật toán sắp xếp chèn trên mảng
một chiều {9, 1, 5, 2, 4, 6, 3}. Quá trình được thể hiện qua hình dưới đây.
Minh họa hoạt động của thuật toán
sắp xếp chèn
Có thể dễ dàng nhận thấy rằng, ở mỗi lượt, thuật toán luôn chọn phần tử đầu
tiên của phần chưa sắp xếp, sau đó tìm cách “nhét” phần tử này vào đúng vị trí
mà nó nên có trong phần đã sắp xếp.

Vấn đề nằm ở chỗ làm thế nào để chèn phần tử vào đúng vị trí của nó để giữ
được thứ tự của phần đã sắp xếp. Hãy cùng xem ví dụ ở bước 6 khi chúng ta
cần chèn phần tử giá trị 3 vào giữa phần tử giá trị 2 và 4.

Minh họa cách chèn


phần tử vào vị trí phù hợp trong thuật toán sắp xếp chèn
Như vậy, cách thực hiện khá đơn giản. Đem phần tử đó lần lượt so sánh với các
phần tử của phần đã sắp xếp theo thứ tự. Nếu phần tử cần chèn nhỏ hơn thì đổi
chỗ hai anh này. Tiếp tục thực hiện cho đến khi không còn tìm được anh nào
nhỏ hơn nó nữa.

Thực thi thuật toán sắp xếp chèn trong C#


using System;
namespace P02_InsertionSort
{
class Program
{
static void Main(string[] args)
{
Console.Title = "Insertion Sort";
var numbers = new[] { 9, 1, 5, 2, 4, 6, 3 };
Sort(numbers);
Console.ReadKey();
}
static void Swap<T>(T[] array, int i, int j)
{
T temp = array[i];
array[i] = array[j];
array[j] = temp;
}
static void Print<T>(T[] array)
{
Console.WriteLine(string.Join("\t", array));
}
static void Sort<T>(T[] array) where T : IComparable
{
for(var i = 1; i < array.Length; i++)
{
var j = i;
while( j > 0 && array[j].CompareTo(array[j-1]) < 0)
{
Swap(array, j, j - 1);
j--;
}
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write($"Step {i}:\t");
Console.ResetColor();
Print(array);
Console.WriteLine();
}
}
}
}

Kết
quả chạy chương trình cho thuật toán sắp xếp chèn
Các kỹ thuật thực thi thuật toán sắp xếp chèn không có gì khác biệt so với sắp
xếp chọn ở phần trước.
Hãy tự so sánh phần thực thi với phần minh họa ở trên để hiểu rõ hơn về thuật
toán này.

Thuật toán này cũng có độ phức tạp là O(n2).

Link tải mã nguồn solution để ở phần cuối của bài học.

Sắp xếp nổi bọt (Bubble sort)


Sắp xếp nổi bọt (Bubble sort) cũng là một thuật toán sắp xếp rất đơn giản. Ý
tưởng của thuật toán này là duyệt qua tất cả các cặp phần tử liền kề. Nếu phát
hiện cặp phần tử nào sắp xếp sai trật tự thì sẽ đổi chỗ chúng. Với cách thức đó,
qua mỗi lượt, phần tử có giá trị lớn nhất trong mảng sẽ được đẩy vào đúng vị trí
chúng cần có. Qua đó có thể tưởng tượng là các phần tử, theo giá trị, sẽ lần lượt
được đẩy dần lên đầu mảng vào đúng vị trí nó cần có.

Hãy cùng xem xét ví dụ sau:

Giả sử cần sắp xếp mảng {9, 1, 5, 2, 4, 6, 3} theo thứ tự tăng dần. Trình tự
thực hiện thể hiện trong bảng dưới đây:

Lượt 1
9 1 5 2 4 6 3 Tráo 9 và 1 => 1 9 5 2 4 6 3
1 9 5 2 4 6 3 Tráo 9 và 5 => 1 5 9 2 4 6 3
1 5 9 2 4 6 3 Tráo 9 và 2 => 1 5 2 9 4 6 3
1 5 2 9 4 6 3 Tráo 9 và 4 => 1 5 2 4 9 6 3
1 5 2 4 9 6 3 Tráo 9 và 6 => 1 5 2 4 6 9 3
1 5 2 4 6 9 3 Tráo 9 và 3 => 1 5 2 4 6 3 9
1 5 2 4 6 3 9 => không còn gì để tráo nữa
Kết quả lượt 1: giá trị lớn nhất (9) được nổi lên đầu danh sách
1 5 2 4 6 3 9

Lượt 2
1 5 2 4 6 3 9
1 5 2 4 6 3 9
1 2 5 4 6 3 9
1 2 4 5 6 3 9
1 2 4 5 6 3 9
1 2 4 5 3 6 9
Kết quả lượt 2: giá trị lớn thứ hai (6) nổi lên vào vị trí thứ hai trong danh sách
1 2 4 5 3 6 9
Lượt 3
1 2 4 5 3 6 9
1 2 4 5 3 6 9
1 2 4 5 3 6 9
1 2 4 5 3 6 9
1 2 4 3 5  6 9
Kết quả lượt 3
1 2 4 3 5 6 9

Lượt 4
1 2 4 3 5 6 9
1 2 4 3 5 6 9
1 2 4 3 5 6 9
1 2 3 4 5 6 9
Kết quả lượt 4
1 2 3 4 5 6 9

Lượt 5: để ý là từ lượt này không có sự xáo trộn nào nữa nhưng thuật toán vẫn
tiếp tục chạy
1 2 3 4 5 6 9
1 2 3 4 5 6 9
1 2 3 4 5 6 9
Kết quả lượt 5
1 2 3 4 5 6 9

Lượt 6
1 2 3 4 5 6 9
1 2 3 4 5 6 9
Kết quả lượt 6
1 2 3 4 5 6 9

Thực thi thuật toán sắp xếp nổi bọt trong C#


using System;
namespace P03_BubbleSort
{
class Program
{
static void Main(string[] args)
{
Console.Title = "Bubble Sort";
var numbers = new[] { 9, 1, 5, 2, 4, 6, 3 };
Sort(numbers);
Console.ReadKey();
}
static void Swap<T>(T[] array, int i, int m)
{
T temp = array[i];
array[i] = array[m];
array[m] = temp;
}
static void Print<T>(T[] array)
{
Console.WriteLine(string.Join("\t", array));
}
static void Sort<T>(T[] array) where T : IComparable
{
for (var i = 0; i < array.Length-1; i++)
{
for (var j = 0; j < array.Length - i - 1; j++)
{
if (array[j].CompareTo(array[j + 1]) > 0)
{
Swap(array, j, j + 1);
}
}
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write($"Step {i+1}:\t");
Console.ResetColor();
Print(array);
Console.WriteLine();
}
}
}
}

Kết quả chạy chương trình như dưới đây


Kết
quả chạy chương trình sắp xếp nổi bọt
Tương tự như hai thuật toán trên, sắp xếp nổi bọt có độ phức tạp trong cả
trường hợp xấu nhất và trung bình đều là O(n2).

Cải tiến thuật toán sắp xếp nổi bọt


Thuật toán sắp xếp nổi bọt nguyên gốc có một nhược điểm nhỏ: thuật toán sẽ
cố gắng chạy hết các vòng lặp, ngay cả khi mảng đã được sắp xếp hoàn chỉnh.

Vấn đề này thể hiện rất rõ trong ví dụ minh họa ở trên. Chúng ta có thể để ý,
đến lượt 4 thì mảng đã hoàn thành sắp xếp. Tuy nhiên, thuật toán vẫn cố chạy
thêm lượt 5 và lượt 6. Trong khi hai lượt này không thực hiện thêm bất kỳ việc
xáo trộn vị trí nào nữa.

Chúng ta đưa vào một cải tiến nhỏ để khi các phần tử đã được sắp xếp đúng thứ
tự thì thuật toán sẽ dừng lại. Qua đó tiết kiệm được một số lượt tính toán.

using System;
namespace P03_BubbleSort
{
class Program
{
static void Main(string[] args)
{
Console.Title = "Bubble Sort";
var numbers = new[] { 9, 1, 5, 2, 4, 6, 3 };
Sort(numbers);
Console.ReadKey();
}
static void Swap<T>(T[] array, int i, int m)
{
T temp = array[i];
array[i] = array[m];
array[m] = temp;
}
static void Print<T>(T[] array)
{
Console.WriteLine(string.Join("\t", array));
}
static void Sort<T>(T[] array) where T : IComparable
{
for (var i = 0; i < array.Length-1; i++)
{
var hasChanged = false;
for (var j = 0; j < array.Length - i - 1; j++)
{
if (array[j].CompareTo(array[j + 1]) > 0)
{
hasChanged = true;
Swap(array, j, j + 1);
}
}
if (!hasChanged)
{
break;
}
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write($"Step {i+1}:\t");
Console.ResetColor();
Print(array);
Console.WriteLine();
}
}
}
}
Thuật
toán sắp xếp nổi bọt “cải tiến”
Khi so sánh với kết quả trên có thể thấy chúng ta đã giảm bớt được hai lượt
thực hiện.

Thuật toán sắp xếp nhanh (Quicksort)


Thuật toán sắp xếp nhanh (quicksort) là một trong những thuật toán sắp xếp
phổ biến và hiệu quả nhất. Nếu so sánh với 3 thuật toán sắp xếp đã trình bày
thì quicksort có hiệu suất cao hơn cả. Khác biệt với các thuật toán trên,
quicksort hoạt động theo nguyên tắc “chia để trị”. Về mặt thực thi, quicksort
cũng phức tạp hơn và phải dùng đến đệ quy.

Thuật toán
Bước 1. Chọn một giá trị trục (pivot) trên mảng.

Giá trị trục (pivot) đóng vai trò là một giá trị trung gian để phân vùng mảng
(trong bước tiếp theo). Pivot có thể chọn là giá trị của phần tử bất kỳ trong
mảng. Tuy nhiên, để đơn giản, pivot thường được chọn là giá trị của phần tử
đầu tiên, phần tử cuối cùng, hoặc phần tử nằm giữa mảng. Vì mảng sẽ được
phân vùng nên việc chọn vị trí ban đầu của pivot không có gì khác biệt nhau.

Bước 2. Sử dụng pivot để phân vùng mảng

Phân vùng (partition) là một thuật toán đơn giản có nhiệm vụ phân chia mảng
thành hai vùng: một vùng chỉ chứa các giá trị nhỏ hơn pivot, gọi là vùng lower;
một vùng chỉ chứa các giá trị lớn hơn pivot, gọi là vùng upper. Bản thân giá trị
pivot cũng được đẩy về vị trí giao nhau giữa hai vùng này (mặc dù ban đầu
pivot có thể ở đầu, cuối, hoặc ở giữa mảng).

Thuật toán phân vùng hoạt động theo logic sau: (1) duyệt xuôi từ đầu mảng để
tìm giá trị lớn hơn pivot; (2) duyệt ngược từ cuối mảng để tìm giá trị nhỏ hơn
pivot; (3) nếu tìm thấy cả hai thì đổi chỗ hai phần tử này với nhau; (4) lặp lại
(1)-(2)-(3) cho đến khi chiều duyệt xuôi và chiều duyệt ngược gặp nhau.

Vị trí giao nhau đồng thời sẽ là vị trí mới của giá trị pivot, là nơi phân tách ra
phần lower và phần upper của mảng.

Bước 3. Xem vùng lower là một mảng riêng, áp dụng lại bước 1, 2 và 3 cho
mảng này; Xem vùng upper là một mảng riêng, áp dụng lại bước 1, 2 và 3 cho
mảng này.

Lưu ý rằng, bước 3 này là bước gọi đệ quy (tự nó gọi chính nó). Quá trình gọi
này tiếp tục cho đến khi không thể phân chia ra các mảng con được nữa.

Minh họa hoạt động của thuật toán Quicksort


Để minh họa, hãy cùng xem cách Quicksort thực hiện trên mảng {-11, 12, -42,
0, 1, 90, 68, 6, -9}. Trong ví dụ này, pivot được chọn là giá trị của phần tử đầu
tiên.

Minh họa hoạt động của thuật toán Quicksort


Dưới đây là một cách khác để sắp xếp mảng trên. Pivot giờ sẽ là phần tử ở giữa
(median) của mảng, thay vì phần tử đầu tiên.
Thực thi thuật toán Quicksort trong C#
Dưới đây là full mã nguồn của project thực thi thuật toán Quicksort trong C#.

using System;
namespace P04_QuickSort
{
class Program
{
static void Main(string[] args)
{
Console.Title = "Quick Sort";
var numbers = new[] { -11, 12, -42, 0, 1, 90, 68, 6, -9 };
Console.WriteLine("Original");
Print(numbers);
Sort(numbers);
Console.WriteLine("Sorted:");
Print(numbers);
Console.ReadKey();
}
static void Swap<T>(T[] array, int i, int m)
{
T temp = array[i];
array[i] = array[m];
array[m] = temp;
}
static void Print<T>(T[] array)
{
Console.WriteLine(string.Join("\t", array));
}
static int Partition<T>(T[] array, int lower, int upper) where T : IComparable
{
var i = lower;
var j = upper;
T pivot = array[lower];
do
{
while (array[i].CompareTo(pivot) < 0) i++;
while (array[j].CompareTo(pivot) > 0) j--;
if (i >= j) break;
Swap(array, i, j);
} while (i <= j);
return j;
}
static void SubSort<T>(T[] array, int lower, int upper) where T : IComparable
{
if (lower < upper)
{
var p = Partition(array, lower, upper);
SubSort(array, lower, p);
SubSort(array, p + 1, upper);
}
}
static void Sort<T>(T[] array) where T : IComparable
{
SubSort(array, 0, array.Length - 1);
}
}
}

Trong project này chúng ta vẫn tiếp tục sử dụng lại hai phương thức Swap và
Print đã thực hiện trong các project trước đây. Chúng ta bổ sung thêm hai
phương thức mới: Partition và SubSort.

Phương thức Partition thực thi thuật toán phân vùng như đã mô tả ở phần trên.
Phương thức SubSort thực hiện theo kiểu đệ quy quá trình (1) phân vùng mảng
đầu vào, (2) gọi lại chính nó để thực hiện cho vùng lower, (3) gọi lại chính nó
để thực hiện cho vùng upper. Quá trình đệ quy chỉ kết thúc khi không thể phân
chia thành lower và upper nữa, tức là khi mỗi mảng con chỉ còn lại một phần tử.

Phương thức Sort lúc này chỉ còn có nhiệm vụ cung cấp mảng gốc cho SubSort.

Thuật toán Quicksort có độ phức tạp trung bình là O(n log(n)) và O(n2) cho
trường hợp xấu nhất.

Kết luận
Trong bài học rất dài này chúng ta đã cùng xem xét chi tiết các thuật toán sắp
xếp cơ bản trên mảng một chiều và cách thực thi của chúng trên C#.

Cũng xin nói luôn, việc xem xét các thuật toán này và cách thực thi trên C#
mang tính học thuật nhiều hơn là tính ứng dụng. Lý do là bản thân C# và .NET
Framework đã hỗ trợ sẵn việc sắp xếp trên dữ liệu tập hợp thông qua thư viện
LINQ.

Trong bài học tiếp theo chúng ta sẽ tiếp tục làm quen với danh sách trong C#.

Chúc bạn học tốt!

You might also like