Professional Documents
Culture Documents
Đệ quy tuyến tính là hàm đệ quy chỉ gọi chính nó một lần trong thân hàm. Hiểu
đơn giản là trong một hàm, nếu có duy nhất một câu lệnh gọi chính hàm đó thì
được gọi là hàm đệ quy tuyến tính.
Cứ sau mỗi lần gọi hàm factorial(), kết quả sẽ được đẩy vào Stack cho đến khi gặp
điều kiện dừng hàm sẽ kết thúc và trả về kết quả 1. Sau đó gọi Stack để tính kết
quả, cơ chế Stack sẽ lấy từ trên xuống vì vậy kết quả sẽ là : 1 * 1 * 2 * 3 * 4 * 5 =
120.
2. Đệ quy đuôi
Đệ quy đuôi là tất cả phép tính được thực hiện trước rồi mới gọi đệ quy sau cùng.
Đệ quy đuôi là một trường hợp đặc biệt của đệ quy tuyến tính. Giống như tên của
nó, đệ quy đuôi là hàm thực hiện gọi đệ quy ở sau cùng.
Đệ quy nhị phân là dạng đệ quy gọi hai lần chính nó. Hiểu đơn giản là trong một
hàm đệ quy, mà có dòng lệnh gọi chính hàm đó hai lần.
4. Đệ quy đa tuyến
Một hàm được gọi là đệ quy đa tuyến nếu mỗi lần gọi đệ quy nó phát sinh ra
khoảng n lần gọi đệ quy khác. Thông thường câu lệnh gọi đệ quy được đặt trong
các vòng lặp.
5. Đệ quy lồng
Đệ quy lồng là loại đệ quy gọi đối số của nó là một đệ quy. Hiểu đơn giản là tham
số truyền vào của hàm đệ quy là một đệ quy.
6. Đệ quy tương hỗ
Đệ quy tương hỗ là loại đệ quy không gọi đệ quy trực tiếp chính nó, mà gọi một
hàm khác. Trong hàm khác lại gọi lại nó.
Sắp xếp nổi bọt hay bubble sort là thuật toán sắp xếp đầu tiên mà mình giới thiệu
đến các bạn và cũng là thuật toán đơn giản nhất trong các thuật toán mà mình sẽ
giới thiệu, ý tưởng của thuật toán này như sau:
Duyệt qua danh sách, làm cho các phần tử lớn nhất hoặc nhỏ nhất dịch chuyển về
phía cuối danh sách, tiếp tục lại làm phần tử lớn nhất hoặc nhỏ nhất kế đó dịch
chuyển về cuối hay chính là làm cho phần tử nhỏ nhất (hoặc lớn nhất) nổi lên, cứ
như vậy cho đến hết danh sách Cụ thể các bước thực hiện của giải thuật này như
sau:
Gán i = 0
Gán j = 0
Nếu i < n – 1:
Thật đơn giản đúng không nào, chúng ta hãy cùng cài đặt thuật toán này trong C++
nha.
Sắp xếp nổi bọt là một thuật toán sắp xếp ổn định. Về độ phức tạp, do dùng hai
vòng lặp lồng vào nhau nên độ phức tạp thời gian trung bình của thuật toán này là
O(n2).
Các bạn có thể xem mình trình bày ý tưởng của giải thuật này trong bên dưới:
Cho mảng A có n phần tử chưa được sắp xếp. Cụ thể các bước của giải thuật này
áp dụng trên mảng A như sau:
Gán i = 0
Nếu j < n:
j=j+1
Nếu i < n – 1:
Ý tưởng và từng bước giải cụ thể đã có, bây giờ mình sẽ sử dụng thuật toán này
trong C++:
int min;
for (int i = 0; i < n - 1; i++)
if (A[j] < A[min]) // A[j] mà nhỏ hơn A[min] thì A[j] là nhỏ nhất
if (min != i) // nếu như A[min] không phải là A[i] ban đầu thì đổi chỗ
swap(A[i], A[min]);
Đối với thuật toán sắp xếp chọn, do sử dụng 2 vòng lặp lồng vào nhau, độ phức tạp
thời gian trung bình của thuật toán này là O(n2). Thuật toán sắp xếp chọn mình cài
đặt là thuật toán sắp xếp không ổn định, nó còn có một phiên bản khác cải tiến là
thuật toán sắp xếp chọn ổn định.
Sắp xếp chèn hay insertion sort là thuật toán tiếp theo mà mình giới thiệu, ý tưởng
của thuật toán này như sau: ta có mảng ban đầu gồm phần tử A[0] xem như đã sắp
xếp, ta sẽ duyệt từ phần tử 1 đến n – 1, tìm cách chèn những phần tử đó vào vị trí
thích hợp trong mảng ban đầu đã được sắp xếp.
Giả sử cho mảng A có n phần tử chưa được sắp xếp. Các bước thực hiện của thuật
toán áp dụng trên mảng A như sau:
Gán i = 1
A[pos + 1] = A[pos]
pos = pos – 1
A[pos + 1] = x
Nếu i < n:
int pos, x;
x = A[i]; // lưu lại giá trị của x tránh bị ghi đè khi dịch chuyển các phần tử
pos = i - 1;
// kết hợp với dịch chuyển phần tử sang phải để chừa chỗ cho x
A[pos + 1] = A[pos];
pos--;
A[pos + 1] = x;
Cũng tương tự như sắp xếp chọn, thuật toán sắp xếp chèn cũng có độ phức tạp thời
gian trung bình là O(n2) do có hai vòng lặp lồng vào nhau.
Sắp xếp trộn (merge sort) là một thuật toán dựa trên kỹ thuật chia để trị, ý tưởng
của thuật toán này như sau: chia đôi mảng thành hai mảng con, sắp xếp hai mảng
con đó và trộn lại theo đúng thứ tự, mảng con được sắp xếp bằng cách tương tự.
Giả sử left là vị trí đầu và right là cuối mảng đang xét, cụ thể các bước của thuật
toán như sau:
Nếu mảng còn có thể chia đôi được (tức left < right)
Sắp xếp mảng thứ nhất (từ vị trí left đến mid)
Bây giờ mình sẽ cài đặt thuật toán cụ thể trong C++ như sau:
// Hàm trộn hai mảng con vào nhau theo đúng thứ tự
A[current++] = LeftArr[i++];
else
A[current++] = RightArr[j++];
// Nếu mảng thứ nhất còn phần tử thì copy nó vào mảng A
A[current++] = LeftArr[i++];
// Nếu mảng thứ hai còn phần tử thì copy nó vào mảng A
A[current++] = RightArr[j++];
}
// Hàm chia đôi mảng và gọi hàm trộn
// việc này giúp tránh bị tràn số với left, right quá lớn
}
// Hàm sắp xếp chính, được gọi khi dùng merge sort
_MergeSort(A, 0, n - 1);
Về độ phức tạp, thuật toán Merge Sort có độ phức tạp thời gian trung bình là
O(nlog(n)), về không gian, do sử dụng mảng phụ để lưu trữ, và 2 mảng phụ dài
nhất là hai mảng phụ ở lần chia đầu tiên có tổng số phần tử bằng đúng số phần tử
của mảng nên độ phức tạp sẽ là O(n). Sắp xếp trộn là thuật toán sắp xếp ổn định.
Sắp xếp nhanh (quick sort) hay sắp xếp phân đoạn (Partition) là là thuật toán sắp
xếp dựa trên kỹ thuật chia để trị, cụ thể ý tưởng là: chọn một điểm làm chốt (gọi là
pivot), sắp xếp mọi phần tử bên trái chốt đều nhỏ hơn chốt và mọi phần tử bên phải
đều lớn hơn chốt, sau khi xong ta được 2 dãy con bên trái và bên phải, áp dụng
tương tự cách sắp xếp này cho 2 dãy con vừa tìm được cho đến khi dãy con chỉ còn
1 phần tử.
Sắp xếp hai mảng con bên trái và bên phải pivot
Phần tử được chọn làm chốt rất quan trọng, nó quyết định thời gian thực thi của
thuật toán. Phần tử được chọn làm chốt tối ưu nhất là phần tử trung vị, phần tử này
làm cho số phần tử nhỏ hơn trong dãy bằng hoặc sấp xỉ số phần tử lớn hơn trong
dãy. Tuy nhiên, việc tìm phần tử này rất tốn kém, phải có thuật toán tìm riêng, từ
đó làm giảm hiệu suất của thuật toán tìm kiếm nhanh, do đó, để đơn giản, người ta
thường sử dụng phần tử chính giữa làm chốt.
Trong bài viết này, mình cũng sẽ sử dụng phần tử chính giữa làm chốt, thuật toán
cài đặt trong C++ như sau:
// Kiểm tra xem nếu mảng có 1 phần tử thì không cần sắp xếp
return;
int pivot = A[(left + right) / 2]; // Chọn phần tử chính giữa dãy làm chốt
while (i < j)
while (A[i] < pivot) // Nếu phần tử bên trái nhỏ hơn pivot thì ok, bỏ qua
i++;
while (A[j] > pivot) // Nếu phần tử bên phải nhỏ hơn pivot thì ok, bỏ qua
j--;
// Sau khi kết thúc hai vòng while ở trên thì chắc chắn
// vị trí A[i] phải lớn hơn pivot và A[j] phải nhỏ hơn pivot
// nếu i < j
if (i <= j)
if (i < j) // nếu i != j (tức không trùng thì mới cần hoán đổi)
swap(A[i], A[j]); // Thực hiện đổi chổ ta được A[i] < pivot và A[j] >
pivot
i++;
j--;
Partition(A, i, right);
}
Partition(A, 0, n - 1);
Thuật toán sắp xếp nhanh không phải là thuật toán sắp xếp ổn định, tuy nhiên vẫn
có thể cải tiến nó thành thuật toán sắp xếp ổn định. Độ phức tạp thời gian trung
bình của thuật toán này là O(nlog(n)).
Cây nhị phân (tiếng Anh: binary tree) là một cấu trúc dữ liệu cây mà mỗi nút có
nhiều nhất hai nút con, được gọi là con trái (left child) và con phải (right child).
Một định nghĩa đệ quy chỉ sử dụng các khái niệm lý thuyết tập hợp là cây nhị phân
không trống là một tuple (L, S, R), với L và R là các cây nhị phân hay tập hợp
rỗng và S là tập đơn (singleton set).[1] Một số tác giả cho phép cây nhị phân cũng
có thể là tập hợp trống.[2]
Cây nhị phân là một trường hợp đặc biệt của cấu trúc cây và nó cũng phổ biến
nhất. Đúng như tên gọi của nó, cây nhị phân có bậc là 2 và mỗi nút trong cây nhị
phân đều có bậc không quá 2.
Có một số khái niệm khác về cây nhị phân các bạn cần nắm như sau:
Cây nhị phân đúng: là cây nhị phân mà mỗi nút của nó đều có bậc 2. Ví dụ như
hình trên, hoặc hình trên bỏ đi nút H và I cũng là cây nhị phân đúng.
Cây nhị phân đầy đủ là cây nhị phân có mức của các nút lá đều bằng nhau. Ví dụ
hình trên, tất cả các nút lá đều có mức 3.
Cây nhị phân tìm kiếm (sẽ tìm hiểu bên dưới)
Cây nhị phân cân bằng: số phần tử của cây con bên trái chênh lệch không quá 1 so
với cây con bên phải.
Nhìn vào hình, ta có thể dễ dàng phân tích được rằng, mỗi nút trong cây nhị phân
sẽ gồm 3 thành phần như sau:
Thành phần liên kết trái: lưu trữ địa chỉ của nút gốc của cây con bên trái. Kiểu dữ
liệu là con trỏ trỏ vào node.
Thành phân liên kết phải: lưu trữ địa chỉ của nút gốc của cây con bên phải. Kiểu dữ
liệu là con trỏ trỏ vào node.
Chúng ta sẽ có struct lưu trữ một node như sau – ở đây để đơn giản mình sử dụng
kiểu dữ liệu int cho thành phần dữ liệu của node:
struct Node
int data;
Node *left;
Node *right;
};
Khi tạo một nút node mới, chúng ta cần phải gán lại các thành phần của node để nó
không nhận giá trị rác, tránh lỗi không mong muốn. Chúng ta sẽ tạo một biến động
cho node và trả về địa chỉ của node đó, mình sẽ có đoạn code tạo node như sau:
p->data = init;
p->left = NULL;
p->right = NULL;
return p;
Để quản lý một cái cây, bạn chỉ cần quản lý được nút gốc, bạn có thể đi được đến
các nhánh và lá của nó từ đó. Trên thực tế bạn không cần phải định nghĩa một kiểu
dữ liệu nào để quản lý cả, tuy nhiên, để cho code rõ ràng hơn, bạn nên định nghĩa
một kiểu dữ liệu cây nữa.
Lúc này, khi tạo một cây, bản chất là nó sẽ tạo cho bạn một con trỏ có thể trỏ vào
một node.
Tree myTree;
Vì nó là con trỏ nên các bạn gán nó bằng NULL để tránh lỗi, nhưng để mọi thứ rõ
ràng hơn, mình sẽ dùng hàm tạo cây đơn giản gán nó bằng NULL.
root = NULL;
CreateTree(myTree);
Duyệt tiền tự (NLR): duyệt nút gốc, duyệt tiền tự cây con trái, duyệt tiền tự cây
con phải.
Duyệt trung tự (LNR): duyệt trung tự cây con trái, duyệt nút gốc, duyệt trung tự
cây con phải.
Duyệt hậu tự (LRN): duyệt hậu tự cây con trái, duyệt hậu tự cây con phải, duyệt
nút gốc.
Để bạn hiểu rõ hơn ba cách duyệt này, chúng ta sẽ sử dụng lại hình ảnh cây nhị
phân trên:
Ứng với từng cách duyệt đó, chúng ta sẽ có các hàm duyệt cây như sau:
Duyệt tiền tự
if (root)
NLR(root->left);
NLR(root->right);
Duyệt trung tự
if (root)
LNR(root->left);
// Xử lý nút gốc (root)
LNR(root->right);
Duyệt hậu tự
if (root)
LRN(root->left);
LRN(root->right);
Để hủy đi cây nhị phân, các bạn cũng thực hiện duyệt và xóa đi các nút của cây,
tuy nhiên, các bạn dễ thấy rằng, nếu ta duyệt tiền tự và trung tự, khi xóa nút nhánh
thì sẽ bị mất luôn địa chỉ của các nút con. Do đó, việc hủy cây nhị phân bắt buộc
phải duyệt hậu tự. Hay nói cách khác, bạn phải xóa các phần tử là nút lá xóa dần
lên đến nút gốc.
if (root)
DestroyTree(root->left);
DestroyTree(root->right);
delete root;
Như vậy là chúng ta đã tìm hiểu về cách tạo một nút, kết nối chúng lại thành một
cây nhị phân, duyệt cây và hủy cây. Tiếp theo chúng ta sẽ tìm hiểu về cây nhị phân
đặc biệt khác là cây nhị phân tìm kiếm.
Tham khảo thêm các vị trí tuyển lập trình viên C++ mới nhất.
Cây nhị phân tìm kiếm là cây nhị phân mà trong đó, các phần tử của cây con bên
trái đều nhỏ hơn phần tử hiện hành và các phần tử của cây con bên phải đều lớn
hơn phần tử hiện hành. Do tính chất này, cây nhị phân tìm kiếm không được có
phần tử cùng giá trị.
Nhờ vào tính chất đặc biệt này, cây nhị phân tìm kiếm được sử dụng để tìm kiếm
phần tử nhanh hơn (tương tự với tìm kiếm nhị phân). Khi duyệt cây nhị phân theo
cách duyệt trung tự, bạn sẽ thu được một mảng có thứ tự. Chúng ta sẽ lần lượt tìm
hiểu qua chúng.
Thêm phần tử vào cây nhị phân tìm kiếm
Để thêm phần tử vào cây nhị phân tìm kiếm, ta phải thêm vào cây nhưng vẫn đảm
bảo được cây đó vẫn là cây nhị phân tìm kiếm. Ví dụ thêm phần tử 12 vào cây
trong hình trên, mình sẽ cần chèn vào vị trí bên trái 13. Hàm duyệt tìm vị trí thích
hợp và chèn của mình như sau:
if (root)
return;
if (node->data < root->data) // Thêm vào cây con bên trái (nhỏ hơn nút hiện
tại)
AddNode(root->left, node);
else
AddNode(root->right, node); // Thêm vào cây con bên phải (lớn hơn nút
hiện tại)
else
root = node; // Đã tìm thấy vị trí thích hợp, thêm node vào
}
Như đã giới thiệu ở trên, để tìm một phần tử trong cây nhị phân tìm kiếm, chúng ta
sẽ thực hiện tương tự việc tìm kiếm nhị phân. Nếu như nút cần tìm nhỏ hơn nút
đang xét, chúng ta sẽ tìm cây con bên trái, ngược lại chúng ta sẽ tìm trong cây con
bên phải, nếu đúng nút cần tìm thì mình sẽ trả về địa chỉ của nút đó. Mình sẽ có
thuật toán sau:
if (root)
return root;
if (x < root->data)
Nút X là nút lá, ta xóa đi mà không làm ảnh hưởng đến các nút khác. Ví dụ xóa nút
15 đi không ảnh hưởng gì đến các nút khác.
Nút X có 1 cây con, chúng ta chỉ cần nối nút cha của X với nút con của X. Ví dụ
xóa nút 13 đi, ta chỉ cần nối nút 18 và 15 lại, sau đó xóa nút 13 đi.
Nút X có đầy đủ 2 cây con: vì X có đầy đủ 2 nút nên nếu ta xóa đi, ta sẽ bị mất
toàn bộ cây con. Do đó chúng ta cần tìm phần tử thế mạng cho X mà vẫn đảm bảo
được cây nhị phân tìm kiếm, sau đó mới xóa X đi.
Đối với hai trường hợp đầu thì dễ, tuy nhiên, với trường hợp thứ 3, chúng ta cần
phải giải quyết vấn đề tìm phần tử thế mạng cho x, chúng ta sẽ có hai cách thực
hiện như sau:
Nút thế mạng là nút có khóa nhỏ nhất (trái nhất) của cây con bên phải x.
Nút thế mạng là nút có khóa lớn nhất (phải nhất) của cây con bên trái x.
Lấy ví dụ cho các bạn dễ hiểu hơn, hình phía trên, xóa đi phần tử 18 theo cách 1,
phần tử lớn nhất của cây con bên trái là 15, vậy thì thay 18 bằng 15 rồi xóa đi nút
15 cuối. Cách 2, phần tử nhỏ nhất của cây con bên phải là 23, vậy 18 sẽ thay bằng
23 và xóa nút 23 đó đi.
Đối với hai trường hợp đầu tiên khá đơn giản, nên mình sẽ lồng nó vào code luôn ở
phần dưới, mình sẽ giải quyết cách tìm phần tử thế mạng ở trường hợp 3 trước và
theo cả hai cách. Theo cách 1, mình sẽ làm như sau:
Trường hợp 1
// nút p là nút cần thay thế, tree là cây đang xét (cây bên phải)
void FindAndReplace1(Tree &p, Tree &tree)
p = tree; // trỏ nút p vào nút tree sẽ làm thế mạng bị xóa
tree = tree->right; // nút trái không còn tuy nhiên nút phải có thể còn nên ta
phải nối chúng lại
Đối với trường hợp này, các bạn phải gọi hàm FindAndReplace1(p, root->right)
trong hàm DeleteNode ở phía trên. Trường hợp thứ 2 thì ngược lại.
Trường hợp 2
// nút p là nút cần thay thế, tree là cây đang xét (cây bên trái)
p = tree; // trỏ nút p vào nút tree sẽ làm thế mạng bị xóa
tree = tree->left; // nút phải không còn tuy nhiên nút trái có thể còn nên ta
phải nối chúng lại
Và trong hàm DeleteNode, các bạn sẽ gọi hàm FindAndReplace(p, root->left). Bây
giờ, tổng hợp lại, chúng ta đã có thể dể dàng xóa một nút khỏi cây nhị phân tìm
kiếm, mình sẽ code như sau:
if (root)
if (x > root->data)
DeleteNode(root->right, x);
DeleteNode(root->left, x);
else if (!root->right)
else
else
cout << "Not found!\n"; // Không tìm thấy phần tử cần xóa