GIỚI T H IỆ U
VAI TRÒ CỦA CÁU TRÚC D Ữ LIỆU TRONG M Ộ T ĐỀ ÁN TIN HỌC 11 Mối quan hệ giữa cấu trúc dữ liệu và giải th u ậ t
1.1 Mối quan hệ giữa cấu trúc dữ liệu và giải thuật
1.1.1 Xây dựng cấu trúc dữ liệu Đe giải quyết một bài toán tin học thì việc xây dựng cấu trúc dữ liệu cho bài toán là không thể thiếu Do vậy, việc tổ chức để lưu trữ dữ liệu phục vụ cho chương trình có ý nghĩa rất quan trọng trong toàn bộ hệ thống chương trình Việc xây dựng cấu trúc dữ liệu quyết định rất lớn đến chất lượng cũng như công sức của người lập trình trong việc thiết kế, cài đặt chương trình.
Việc tìm kiếm cấu trúc dữ liệu cho bài toán thực chất là xác định các biến và kiểu dữ liệu phù hợp để lưu trữ và xử lý thông tin một cách hiệu quả.
Giải thuật là phương pháp để giải quyết vấn đề, có thể được biểu diễn bằng ngôn ngữ tự nhiên, sơ đồ hoặc mã giả Trong thực tế, giải thuật thường được thể hiện bằng mã giả dựa trên một hoặc nhiều ngôn ngữ lập trình, thường là ngôn ngữ mà lập trình viên chọn để cài đặt thuật toán, như C, Pascal, và các ngôn ngữ khác.
Khi đã chọn được cấu trúc dữ liệu phù hợp, lập trình viên sẽ bắt đầu xây dựng thuật toán dựa trên cấu trúc đó để giải quyết bài toán Có nhiều phương pháp để giải quyết một vấn đề, do đó việc lựa chọn phương pháp thích hợp là rất quan trọng và cần được cân nhắc kỹ lưỡng Sự lựa chọn này có thể giúp giảm bớt khối lượng công việc khi cài đặt thuật toán trên một ngôn ngữ lập trình cụ thể.
1.1.3 Mối quan hệ giữa cấu trúc dữ liệu và giải thuật
Mối quan hệ giữa cấu trúc dữ liệu và giải thuật có thể minh họa bằng đẳng thức:
Cấu trúc dữ liệu + Giải thuật = Chương trình
Khi đã xác định được cấu trúc dữ liệu hợp lý và nắm vững thuật giải, việc triển khai chương trình bằng ngôn ngữ lập trình chỉ còn là vấn đề thời gian Cấu trúc dữ liệu và thuật giải có mối quan hệ chặt chẽ: không thể có chương trình nếu thiếu một trong hai yếu tố này Một chương trình máy tính chỉ hoàn thiện khi có đủ cả cấu trúc dữ liệu để lưu trữ thông tin và thuật giải để xử lý dữ liệu theo yêu cầu của bài toán.
1.2 n ê u chí đánh giá cấu trúc dữ liệu Để đánh giá một cấu trúc dữ liệu chúng ta thường dựa vào một số tiêu chí sau:
❖ Cấu trúc dữ liệu phải tiết kiệm tài nguyên hệ thống (bộ nhớ trong).
❖ Cẩu trúc dữ liệu phải phản ảnh đúng thực tế của bài toán.
♦> Cấu trúc dữ liệu phải dễ dàng trong việc thao tác dữ liệu.
1.3 Các bước cơ bản khi giải quyết bài toán
Bước 1: Nghiên cứu bài toán, dữ liệu vào/ra của bài toán, phác thảo cách giải để tìm ra thuật toán.
Bước 2: Biểu diễn thuật toán.
Bước 4: Sửa lồi cú pháp.
Bước 6: Tiến hành chạy thử chương trình với các bộ dữ liệu cơ bản và các bộ dữ liệu cá biệt Để phát hiện lỗi, hãy cô lập những dòng lệnh nghi ngờ và bổ sung các lệnh in dữ liệu sau những dòng lệnh đó để kiểm tra.
Sử dụng các công cụ dò lồi logic (Debug) của các chương trình dịch.
TRỪU TƯỢNG HÓA DỮ L IỆ U
Khi thiết kế giải thuật cho một vấn đề, việc sử dụng sự trừu tượng hóa dữ liệu là rất quan trọng Sự trừu tượng hóa này cho phép chúng ta chỉ tập trung vào những dữ liệu cần thiết cho bài toán, bỏ qua các chi tiết không quan trọng Ví dụ, khi mô tả đối tượng Sinh viên, chúng ta chỉ cần lưu trữ thông tin như mã sinh viên, họ tên, năm sinh, quê quán, điểm trung bình và lớp chủ nhiệm, trong khi các thông tin như cân nặng hay chiều cao không cần thiết cho ứng dụng quản lý sinh viên Để thực hiện sự trừu tượng hóa dữ liệu, chúng ta có thể tạo ra các kiểu dữ liệu trừu tượng, sử dụng từ khóa struct trong lập trình hướng thủ tục hoặc từ khóa class trong lập trình hướng đối tượng trong ngôn ngữ lập trình C++.
THUẬT TOÁN - GIẢI THUẬT
Thuật toán là một chuỗi các bước hướng dẫn để giải quyết một bài toán cụ thể, trong đó máy tính thực hiện các thao tác từ dữ liệu đầu vào (input) để tạo ra dữ liệu đầu ra (output) Khác với thuật toán, giải thuật mở rộng tính xác định và tính đúng đắn của thuật toán, với các ví dụ như giải thuật đệ quy và ngẫu nhiên Tính đúng của thuật toán không còn là yêu cầu bắt buộc cho mọi cách giải, đặc biệt là đối với các giải pháp gần đúng Trong thực tế, nhiều giải pháp thường được chấp nhận vì chúng mang lại kết quả tốt hơn, mặc dù không phải lúc nào cũng hoàn hảo, nhưng lại ít phức tạp và hiệu quả hơn Khi giải quyết một bài toán có thể mất nhiều năm với thuật toán tối ưu, người ta thường chấp nhận giải pháp gần tối ưu trong thời gian ngắn hơn Các giải pháp này, mặc dù không hoàn toàn đáp ứng tiêu chuẩn của thuật toán, được gọi là thuật giải hay giải thuật Khái niệm này đã mở ra nhiều phương pháp mới để giải quyết các bài toán, và trong tài liệu này, các bài toán được đưa ra không phức tạp, cho phép sử dụng thuật toán hoặc giải thuật để tìm ra cách giải quyết.
3.2 Các tính chất của thuật toán
Thuật toán phải kết thúc sau một số hữu hạn lần thực hiện các thao tác
Ví dụ: Thuật toán sau đây vi phạm tính dừng
Bước 5: Đưa ra s và kết thúc thuật toán
Thuật toán này tính tổng bình phương của các số 1, 2, 3, 4, mà không có điểm dừng Do đó, nếu áp dụng thuật toán này, nó sẽ dẫn đến việc lặp vô tận và không thể dừng lại, vi phạm nguyên tắc tính dừng của thuật toán.
Sửa lại thuật toán như sau:
Bước 3 : Nếu i > N thì chuyển đến Bước 7;
Bước 5: Đưa ra s vả kết thúc thuật toán 3.2.2 Tính xác định
Thuật toán cần đảm bảo rằng sau khi hoàn thành một thao tác, sẽ có hai khả năng: hoặc thuật toán kết thúc, hoặc có một thao tác tiếp theo được xác định rõ ràng để thực hiện.
Ví dụ: thuật toán sau đây vi phạm tính xác định:
Bước 2: Tính diện tích hình chữ nhật kích thước a, b hoặc tính thể tính hình nón đ ròng cao a và bán kính hình tròn đáy là b Tức là: s máy tính thực hiện 4 phép nhân và 3 phép cộng đại sổ
Giả sử phép nhân mất 1 giây, phép cộng mất 0.01 giây và phép gán mất 0.005 giây, thì thời gian thực hiện phép nhân hai số phức là 4*1 + 3*0.01 + 0.005 = 4.035 giây Để tối ưu hóa thời gian tính toán, việc giảm số phép nhân là cần thiết.
Với ad + bc = (a + b)*(c + d) - ac - bd Đặt p := ac; q := bd;
->Tính z gồm3 phép nhân, 6 phép cộng và 3 phép gán; mất khoảng thời gian là 3*1 + 6*0.01 + 3*0.005 = 3.075 giây, giảm được 4.04 - 3.09 = 0.96 giây
Vậy chủng ta nên phân phân tích và đánh giá thuật toán để trả lời các câu hỏi sau:
- Với phần lớn các bài toán, thường có nhiều giải thuật khác nhau Làm cách nào để chọn giải thuật tốt nhất để giải một bài toán?
- Làm cách nào để so sánh các giải thuật cùng giải được một bài toán?
- Một thuật toán đúng chưa chắc cho kết quả mong muốn vì các lí do:
+ Thòi gian thực hiện quá lâu
1.2 Tính hiệu quả của thuật toán
Người ta xem xét thuật toán, lựa chọn thuật toán để áp dụng vào từng bài toán cụ thể sẽ dựa vào các tiêu chí sau:
1 Thuật toán đơn giản, dễ hiểu
2 Thuật toán dễ cài đặt (dễ viết chương trình)
3 Thuật toán cần ít bộ nhớ
Khi cài đặt thuật toán cho những lần sử dụng hạn chế, người ta thường dựa vào tiêu chí 1 và 2 Tuy nhiên, với những thuật toán được sử dụng thường xuyên như thuật toán sắp xếp, tìm kiếm hay đồ thị, tiêu chí 3 và 4 trở nên quan trọng hơn Hai tiêu chí này phản ánh tính hiệu quả của thuật toán, bao gồm dung lượng bộ nhớ cần thiết và thời gian thực hiện Dung lượng bộ nhớ bao gồm không gian lưu trữ dữ liệu đầu vào, đầu ra và các kết quả trung gian, được gọi là độ phức tạp không gian Thời gian thực hiện thuật toán, hay độ phức tạp thời gian, là yếu tố chính mà chúng ta sẽ tập trung đánh giá trong tương lai.
ƯỚC LƯỢNG VÀ TÍNH THỜI GIAN T H ự C HIỆN CHƯƠNG TRÌNH
2.1 Phân tích thời gian thực hiện thuật toán Đánh giá thời gian chạy của thuật toán bằng cách nào? Với cách tiếp cận thực nghiệm chúng ta có thể cài đặt thuật toán và cho chạy chương trinh trên một máy tính nào đó với một số dữ liệu vào Thời gian chạy mà ta thu được sẽ phụ thuộc vào nhiều nhân tố:
• Kỳ năng của người lập trình
• Tốc độ thực hiện các phép toán của máy tính
Trong cách tiếp cận thực nghiệm, không thể xác định thời gian chạy của thuật toán bằng một đơn vị cụ thể, ví dụ như "thời gian chạy của thuật toán là 30 giây" là không chính xác Khi so sánh hai thuật toán A và B giải quyết cùng một vấn đề, việc chỉ chạy chương trình với một tập dữ liệu đầu vào duy nhất không đủ để kết luận thuật toán nào hoạt động nhanh hơn.
Một cách tiếp cận để đánh giá thời gian chạy của thuật toán là sử dụng phân tích toán học, nhằm đưa ra kết luận không phụ thuộc vào cài đặt hay máy tính thực hiện thuật toán Để phân tích, chúng ta cần xem xét khái niệm cỡ (size) của dữ liệu đầu vào, mà cỡ này phụ thuộc vào từng thuật toán cụ thể Ví dụ, với thuật toán sắp xếp mảng kích thước n, cỡ dữ liệu đầu vào chính là n Thời gian chạy của thuật toán thường tỉ lệ thuận với cỡ dữ liệu; tính tổng mảng 10 phần tử sẽ nhanh hơn so với mảng 1000 phần tử Nhìn chung, cỡ dữ liệu càng lớn thì thời gian thực hiện thuật toán càng kéo dài, nhưng thời gian thực hiện cũng bị ảnh hưởng bởi chính bản thân dữ liệu đầu vào.
Thời gian chạy của thuật toán tìm kiếm phụ thuộc vào kích thước dữ liệu đầu vào Ví dụ, khi tìm phần tử a trong danh sách, thuật toán tìm kiếm tuần tự sẽ kiểm tra từng phần tử cho đến khi tìm thấy a hoặc hết danh sách Nếu a là phần tử đầu tiên, chỉ cần một lần so sánh, đây là trường hợp tốt nhất Ngược lại, nếu a nằm ở cuối danh sách hoặc không có trong danh sách, cần n lần so sánh, đây là trường hợp xấu nhất Do đó, cần xem xét thời gian chạy trong các trường hợp tốt nhất, xấu nhất và trung bình.
Thời gian chạy trong trường hợp tốt nhất (best-case running time) của một thuật toán là thời gian chạy ngắn nhất mà thuật toán đó có thể đạt được trên tất cả các dữ liệu đầu vào có cùng kích cỡ Chúng ta ký hiệu thời gian chạy trong trường hợp tốt nhất là T(n), với n đại diện cho kích thước của dữ liệu đầu vào.
Thời gian chạy trong trường hợp xấu nhất (worst-case running time) của thuật toán là thời gian tối đa mà thuật toán đó cần để xử lý tất cả dữ liệu đầu vào có cùng kích cỡ, được ký hiệu là T(n), với n là kích thước dữ liệu Khi nói đến thời gian chạy của thuật toán, chúng ta thường ám chỉ đến thời gian chạy trong trường hợp xấu nhất Việc sử dụng thời gian chạy trong trường hợp xấu nhất để đánh giá hiệu suất của thuật toán mang lại nhiều lợi ích, vì nó đảm bảo rằng thuật toán sẽ không bao giờ tiêu tốn nhiều thời gian hơn mức đã xác định Hơn nữa, trong thực tế, trường hợp xấu nhất thường xảy ra, làm cho việc phân tích này trở nên quan trọng.
Thời gian chạy trung bình (average running time) của thuật toán được xác định là trung bình cộng của thời gian chạy trên tất cả các dữ liệu đầu vào có kích thước n, ký hiệu là Tn(n) Tuy nhiên, việc đánh giá thời gian chạy trung bình là một thách thức lớn, đòi hỏi sử dụng các công cụ xác suất và thống kê, cùng với việc hiểu rõ phân phối xác suất của dữ liệu đầu vào Do sự khó khăn trong việc xác định phân phối xác suất của dữ liệu, các phân tích thường dựa trên giả thiết rằng dữ liệu có phân phối xác suất đều, dẫn đến việc ít khi đánh giá thời gian chạy trung bình trong thực tế.
Thời gian thực hiện thuật toán không được đo bằng các đơn vị thời gian chuẩn như giờ, phút hay giây, mà được xác định bởi số phép toán sơ cấp cần thực hiện Các phép toán sơ cấp bao gồm phép toán số học, logic và so sánh, và thời gian thực hiện chúng là cố định, phụ thuộc vào tốc độ của máy tính Thời gian chạy T(n) được định nghĩa là số phép toán sơ cấp mà thuật toán yêu cầu cho dữ liệu đầu vào cỡ n Việc xác định biểu thức mô tả hàm T(n) có thể phức tạp, do đó chúng ta chỉ quan tâm đến tốc độ tăng của hàm này khi cỡ dữ liệu đầu vào thay đổi Ví dụ, nếu thời gian chạy là T(n) = 3n² + 7n + 5, thì hạng thức 3n² sẽ quyết định tốc độ tăng, cho phép chúng ta kết luận rằng thời gian chạy tỉ lệ với bình phương cỡ dữ liệu đầu vào Trong phần tiếp theo, chúng ta sẽ định nghĩa ký hiệu ô lớn để biểu diễn thời gian chạy của thuật toán.
2.2 Phân tích một số ví dụ minh họa để tính thời gian thực hiện thuật toán
Vỉ dụ 1: Tính trung bình cộng của n sô nhậ D từ bàn phím
Phân tích và đánh giá: Các lệnh 1, 2, 3 và 7 được thực hiện một lần Thân vòng lặp gồm các lệnh 4, 5, 6 được thực hiện n lần Vậy T(n) = 3n + 4.
Ví dụ 2: Tim kiếm tuần tự
Cho dãy số (a) gồm n phần tử aJ, a2, , a„ Hãy tìm vị trí của phần tử có giá trị bang X cho trước trong dãy
2 bool found = false; 1 lần while (i NI
Khi hàm thời gian tính T(n) của thuật toán được biểu diễn qua kí hiệu b ig -0 thì T(n) được gọi là độ phức tạp thuật toán (Complexity o f Algorithms)
Ví dụ 1: Biểu diễn hàm thời gian theo các kí pháp big -0
Tổng quát nếu f(n) là một đa thức bậc k của n: f(n) = a ^ + ak 1nk' 1+ + ajn + a° thì f(n) = 0 (n k)
Các hàm đánh giá thông dụng: _
STT Hàm Tên gọi: độ phức tạp Đánh giá
1 O(C), 0 (1 ) Hằng sổ Chấp nhận được
8 0 (a n) Hàm mũ Không chấp nhận được
Bảng theo dõi độ tăng của hàm: n nlogn n2 n3 2"
Hình 2-1 So sánh cấp độ tăng của các hàm đánh giá giải thuật
3.2 Các quy tắc xác định độ phức tạp của giải thuật
- Nguyên tắc bỏ hằng số
Nếu một thuật toán T có thời gian thực hiệnT(n)=0(C.f(n)) với c là hằng số dương thì có thể coi thuật toán T có độ phức tạp tính toán là 0(f(n)).
Giả sử một thuật toán T bao gồm hai phần TI và T2, với TI có thời gian thực hiện Tl(n) = 0(f(n)) và T2 có thời gian thực hiện T2(n) = 0(g(n)) Khi đó, thời gian thực hiện tổng thể của thuật toán T sẽ được tính bằng công thức T(n) = TI(n) + T2(n) = 0(f(n) + g(n)).
Nếu thuật toán T có thời gian thực hiện T(n)= 0(f(n)+ g(n)) thì có thể coi thời gian thực hiện thuật toán T có độ phức tạp là T(n)=0(max(f(n), g(n)).
Ví dụ: Minh họa qui tắc max
Vậy thuật toán có độ phức tạp tuyến tính.
Nếu đoạn thuật toán T có thời gian thực hiện T(n) = Θ(f(n)), thì khi thực hiện k(n) lần đoạn thuật toán T với k(n) = Θ(g(n)), độ phức tạp tính toán của quá trình lặp này sẽ là T(n) = Θ(f(n) * g(n)).
- Đoạn (2-3) có độ phức tạp là n
- Đoạn (2-3) được lặp lại n-1 lần
Lưu ý: T(2.3)(n) không phụ thuộc vào T] (n).
3.3 Các kỹ thuật cơ bản khi đánh giá của độ phức tạp của thuật toán
Câu lệnh đơn là những lệnh thực hiện thao tác cụ thể, bao gồm các lệnh gán đơn giản, lệnh vào/ra cơ bản và các lệnh chuyển điều khiển như break, goto, continue và return.
Thời gian thực hiện một câu lệnh đơn không phụ thuộc vào kích thước dữ liệu nên sẽ là 0 (1 ).
Thời gian thực hiện một câu lệnh họp thành sẽ được tính theo qui tắc cộng và qui tắc max.
3.3.3 Câu lệnh lặp vói số lần lặp biết trước for-do for( int i = 0; i i; j — ) if (a[j] = aỊ2*i] và afi] >= af2*i+i] (i= 0 n/2) (Điều kiện này là cho sắp xếp tăng dần).
Gốc của heap luôn là phần tử lớn nhất, và khi ta lần lượt rút trích phần tử gốc và tái xây dựng heap, chúng ta sẽ có được một mảng được sắp xếp theo thứ tự tăng dần.
Bước 1: Xây dựng heap từ mảng ban đầu.
Bước 2: Hoán vị phần tử đầu và cuối mảng, rồi giảm dần số phần tử của mảng xuống thành n-1 (bỏ phần tử cuối).
Bước 3: Tiếp tục thực hiện quá trình xây dựng và hoán vị cho đến khi hoàn thành, chúng ta sẽ có dãy số được sắp xếp theo thứ tự tăng dần Để sắp xếp theo thứ tự giảm dần, chỉ cần thay đổi phần tử gốc của heap thành phần tử nhỏ nhất.
Sắp xếp dãy số trên tăng dần với các bước của thuật toán được thể hiện như sau: : Giai đoan 1: hiệu chỉnh dãy ban đầu thành heap Ạ k= _ J
{ = Ị Phấn tử Hên đói í = 1 ĩ = 0 Phẩn tử liên đòì
Giai đoan 2 : sắp xếp dãy số dựa trên heap:
Thực hiện tương tự cho r = 4, 3, 2 ,1 ta được:
Hình 4-1 M inh họa thuật toán HeapSort
Chi phí để xây dựng heap khi thêm một phần tử mới vào heap là log2n, tương ứng với chiều cao của heap trong quá trình hạ phần tử xuống vị trí thích hợp Trong quá trình sắp xếp, từ bước 2 đến bước 3, mỗi phần tử sẽ được xây dựng lại heap một lần, và với n phần tử, ta ước tính chi phí cho thuật toán HeapSort là O(nlogn) cho mọi trường hợp Thực nghiệm cài đặt cho thấy kết quả gần bằng 41og->n.
❖ Giai đoạn hiệu chỉnh ãị, Ũ\+U-;^T thành Heap void shift(int a[],int l,int r) { int x,i,j; i = 1; j = 2 * i + 1;
//tim phan tu lon nhat a[j] va a[j+l] if(a[j] < a[j + 1]) j++;//luu chi so cua phan tu nho nhat trong hai phan tu if (a[j] 0) { swap(a[0],a[r]) ;//a[0] la nút gốc r ; if (r > 0) shift (a,0 , r) ;
Quicksort là một thuật toán sắp xếp hiệu quả, chia mảng thành hai danh sách bằng cách so sánh từng phần tử với một phần tử chốt Các phần tử nhỏ hơn hoặc bằng phần tử chốt được đưa vào danh sách con thứ nhất, trong khi các phần tử lớn hơn sẽ được đưa vào danh sách con thứ hai Quá trình này tiếp tục cho đến khi tất cả các danh sách con chỉ còn một phần tử.
♦> Sau 1 lượt phân hoạch ta có: o ao ạ, < X, phân hoạch tiếp ao aj. o 3j+] 2i-l = X o ãị an.j > X, phân hoạch tiếp ãị an.Ị
Bước 1: Neu left >= right thì ngừng thuật toán
Ngược lại thực hiện các bước sau:
Bước 2 : Chọn tùy ý một phần tử a[k] trong dãy là phần tử chốt ( 0 < k < r): x = a[k]; ỉ = left; j = right;
(Thường người ta hay chọn k = (1+r) / 2 )
Bước 3 : Phát hiện và hiệu chỉnh cặp phần tử a[i], a[j] nằm sai chỗ :
Bước 4 : Nếu i < j: Lặp lại Bước 3.
Gọi đệ quy thuật toán cho đoạn mới (a, left, j); //thực hiện từ bước 1
Gọi đệ quy thuật toán cho đoạn mới (a, i, riglit); // thực hiện từ bước 1
Sắp xểp dãy số trên tăng dần với các bước của thuật toán được thể hiện như sau:
Hình 4-2.M inh họa thuật toán Q uicksort
Cài đặt: void quicksort(int a[], int left, int right) { int i , j , X ; if (left >= right) return;
X = a [(left+right) / 2]; i = left; j = right; while (i < j) { while (a[i] < x) i++; while (a[j] > x) j— ; if (i Trường hợp tot nhất: 0 (n ỉo g 2(n))
♦> Trưcmg hợp trung bình: 0 (n lo g 2(n))
Cho dãy ban đầu a1, a2, , an, ta có thể xem nó như một tập hợp liên tiếp của các dãy có thứ tự Những dãy có thứ tự này được gọi là các dãy con.
Phương pháp Merge Sort là một kỹ thuật sắp xếp hiệu quả, trong đó dãy ban đầu được phân hoạch thành các dãy con Sau khi phân chia, các dãy con sẽ được tách thành hai dãy phụ theo nguyên tắc phân phối luân phiên Tiếp theo, từng cặp dãy con của hai dãy phụ được trộn lại để tạo thành một dãy con của dãy ban đầu Quá trình này giảm số lượng dãy con của dãy ban đầu xuống ít nhất một nửa Qua nhiều bước lặp lại, cuối cùng ta sẽ thu được dãy ban đầu được sắp xếp hoàn chỉnh với chỉ một dãy con.
Có hai phương pháp trộn: Trộn tự nhiên và trộn trực tiếp:
Trộn tự nhiên là phương pháp sử dụng các dãy con tự nhiên có sẵn từ dãy ban đầu Ví dụ, với dãy 1 3 5 4 8 9 1 0, chúng ta có ba dãy con tăng là 1 3 5 4 8 9 10 Phương pháp này phân phối đều các dãy con vào hai dãy phụ, và khi hoàn tất, ta sẽ có một dãy mới với số lượng dãy con ít hơn Quy trình tách và trộn này tiếp tục cho đến khi chỉ còn một dãy con duy nhất.
Trộn trực tiếp là phương pháp trộn đơn giản nhất, trong đó dãy ban đầu với n phân tử sẽ được phân hoạch thành n dãy con, mỗi dãy con chỉ chứa 1 phần tử, tạo thành dãy có thứ tự Qua mỗi lần tách - trộn, chiều dài của dãy con sẽ được nhân đôi Ví dụ, với dãy 1 3 5 4 8 9 10, ban đầu sẽ có 7 dãy con, mỗi dãy chỉ chứa 1 phần tử.
CẤU TRÚC D Ữ LIỆU Đ Ộ N G
KIỂU D Ữ LIỆU CÓ CÁU T R Ú C
2.1 Định nghĩa kiểu dữ liệu có cấu trúc
Cấu trúc (struct) là một kiểu dữ liệu do người dùng định nghĩa, cho phép gom nhóm các kiểu dữ liệu cơ bản trong C/C++ thành một kiểu dữ liệu phức hợp với nhiều thành phần.
Củ pháp struct < tên cấu trúc >
Các kiểu dữ liệu thành phần ; };
Ngoài ra ta có thể dùng từ khoá typedef để định nghĩa một tên mới cho kiểu dữ liệu đã có.
Củ pháp typedef struct < tên cấu trúc > < tên mới >;
Khi định nghĩa một kiểu dữ liệu mới, chúng ta cần khai báo biến để sử dụng kiểu dữ liệu đó Cú pháp khai báo kiểu dữ liệu mới tương tự như cách khai báo các kiểu dữ liệu chuẩn: `struct ;` Để truy xuất thành phần dữ liệu bên trong cấu trúc, có hai cách truy xuất khác nhau.
- Biển X là một biển cấu trúc thông thường, ta dùng toán tử dấu chấm
< Tên cấu trúc >.< B iến thành phần >;
- Biển X là một biến con trỏ, ta dùng toán tử mũi tên
< Tên cấu trúc > -> < B iến thành phần >;
Cấu trúc tự trỏ X là cấu trúc mà trong đó có một trường dữ liệu là một con trỏ kiểu X Cú pháp khai báo: struct
Ví dụ giả sử có một danh sách các số nguyên được tổ chức như mô hình sau:
Vậy mồi phần tử trong tập hợp các phần tử trên đều có cấu trúc sau:
Giả sử dùng cấu trúc tên là Node để mô tả một phần tử struct node
Mô hình trên đầu bao gồm hai thành phần chính: đầu tiên là thành phần mô tả dữ liệu, và thứ hai là một con trỏ dùng để trỏ tới một phần tử tương tự khác, cụ thể là một phần tử kiểu node.
2.3 Biến con trỏ kiểu cấu trúc
Sau khi định nghĩa cấu trúc (node), chúng ta có thể sử dụng nó như một kiểu dữ liệu để khai báo biến Đây là kiểu dữ liệu do người dùng tự tạo ra.
- Đối với biến thông thường: node x;
- Đối với biến con trỏ node* y;
Khai báo trên thì biến y là biến con trỏ kiểu cấu trúc Khi đó để làm việc với biến y chúng ta sử dụng toán tử
Ví dụ để nhập dữ liệu cho biến y chúng ta thực hiện: y->data = 1; y ->next = NULL;
3 NHU CẦU XÂY D ựN G CẤU TRÚC DỮ LIỆU ĐỘNG
Trước hết chúng ta cùng tìm hiểu hạn chế của biển tĩnh:
Các biến tĩnh là những biến có kích thước và kiểu dữ liệu xác định, được sử dụng trong lập trình Khi khai báo biến tĩnh, bộ nhớ sẽ được cấp phát cho chúng và duy trì suốt thời gian thực thi chương trình, ngay cả khi một số biến chỉ được sử dụng một lần và sau đó không còn cần thiết.
Một số hạn chế có thể gặp phải khi sử dụng các biến tĩnh:
- Cấp phát ô nhớ dư, gây ra lãng phí ô nhớ.
Ngôn ngữ C cung cấp một loại biến đặc biệt gọi là biến động, giúp tạo ra các ô nhớ động và các kiểu dữ liệu động Điều này giúp khắc phục những hạn chế của việc cấp phát ô nhớ tĩnh và chương trình thực thi bị lồi Dữ liệu động có những đặc điểm nổi bật, mang lại tính linh hoạt và hiệu quả trong quản lý bộ nhớ.
- Có thế hủy bỏ vùng nhớ của biến khi không còn sử dụng mà không cần chờ vào trình biên dịch hủy giống như biến tĩnh
- Có thể thay đổi được kích thước của biến động
- Tận dựng tối đa lượng ô nhớ đã khai báo và không gây ra lãng phí ô nhớ
Khai báo mảng cố định với số phần tử tối đa như int M[1000]; có thể dẫn đến thiếu hụt bộ nhớ nếu số lượng phần tử thực tế vượt quá 1000 Ngược lại, nếu chỉ sử dụng khoảng 100 phần tử, điều này sẽ gây lãng phí bộ nhớ.
Dựa trên những đặc điểm trên thì chúng ta thấy cấu trúc dữ liệu động sẽ ưu việt hơn tùy vào nhu cầu thực tế.
4 KDÈU DỮ LIỆU DANH SÁCH
4.1 Định nghĩa, phân loại, so sánh
Danh sách là tập hợp hữu hạn các phần tử cùng kiểu, được biểu diễn dưới dạng chuỗi các phần tử như a1, a2, , an với n > 0 Khi n = 0, danh sách được gọi là danh sách rỗng Nếu n > 0, phần tử đầu tiên là a1 và phần tử cuối cùng là an, trong đó độ dài của danh sách là số lượng phần tử có trong nó.
Một đặc điểm quan trọng của danh sách là các phần tử được sắp xếp theo thứ tự tuyến tính dựa trên vị trí xuất hiện của chúng Cụ thể, phần tử a đứng trước phần tử ai+1 với i từ 1 đến n-1, và phần tử ãị là phần tử đứng sau a với i từ 2 đến n.
Chủng ta cũng nói aj là phần tử tại vị trí thứ i, hay phần tử thứ i của danh sách.
Ví dụ thực tế thường gặp những danh sách sau:
■ Danh mục sách trong thư viện
■ Danh sách các nhân viên trong công ty
■ Danh sách các cuộc gọi video đang chờ được xử lý
Có 2 loại danh sách đó là danh sách đặc (mảng các phần tử) và danh sách liên kết.
Danh sách đặc: Các phần tử được bố trí nằm liên tục với nhau, là dãy các ô nhớ liên tiếp nên được gọi là danh sách đặc.
- Danh sách nhau, phần ô nhớ) của
•ằỊA n B Ị > liên kết tử thứ phần tủ
Các phần tử trong danh sách liên kết tự quản lý sẽ bao gồm một con trỏ để kết nối với phần tử tiếp theo trong danh sách.
Dù là danh sách đặc hay danh sách liên kết, mục đích chính vẫn là lưu trữ các phần tử cùng kiểu Tuy nhiên, giữa hai loại danh sách này có những điểm khác biệt quan trọng.
Danh sách đặc Danh sách liên kết
Liên kết ngầm Liên kết tường minh
Vỉệc thêm, xóa các phần tử trong danh sách rất khó khăn đặc biệt là khi danh sách có số lượng phần tử rất lớn
Việc thêm, xóa các phần tử trong danh sách dễ dàng
Bắt buộc các ô nhớ của các phần tử trong danh sách phải nằm liên tiếp nhau
Các ô nhớ này không nhất phải nằm cạnh nhau, chi cần c chống thì được cấp phát thiết là lỗ nào
Các phần tử có thể là tĩnh (giống như khai báo mảng thông thường) và có thể là động (cấp phát một dãy ô nhớ)
Hoàn toàn là những ô nhớ động
Các phần tử truy xuất thông qua chỉ số (truy xuất ngẫu nhiên)
Các phần tử truy xuất tuần tự thao tác truy xuất sẽ chậm hom danh sách đặc
Cỏ có thể dẫn đến tình trạng lãng phí hoặc thiếu hụt ô nhớ, vì vậy cần xác định chính xác số lượng phần tử cần thiết để cấp phát, nhằm tránh tình trạng dư thừa ô nhớ.
Tùy vào thực tể mà chủng ta sẽ xem xét dừng loại nào dựa trên ưu và nhược điểm của chúng.
4.2 Các thao tác trên danh sách
Tùy thuộc vào đặc điểm và tính chất của từng loại danh sách, mỗi loại sẽ yêu cầu một số thao tác nhất định Nói chung, các thao tác cơ bản trên danh sách bao gồm việc thêm, sửa đổi và xóa các mục.
4.2.1 Tạo mới một danh sách
Trong thao tác này, chúng ta sẽ đưa nội dung của các phần tử vào danh sách, từ đó xác định chiều dài của danh sách Trong một số trường hợp, chúng ta chỉ cần khởi tạo giá trị và trạng thái ban đầu cho danh sách.
4.2.2 Thêm một phần tử vào danh sách
Thao tác thêm phần tử vào danh sách sẽ làm tăng chiều dài của danh sách lên 1 nếu thành công Việc thêm phần tử có thể diễn ra ở đầu, cuối hoặc giữa danh sách, tùy thuộc vào loại danh sách và từng trường hợp cụ thể.
4.2.3 Tim kiếm một phần tử trong danh sách
KIỂU D Ữ LIỆU DANH SÁ C H
Việc sử dụng ô nhớ có thể dẫn đến thiếu hụt nếu số lượng ô nhớ vượt quá 1000 Ngược lại, nếu chỉ sử dụng khoảng 100 phần tử, sẽ gây lãng phí tài nguyên.
Dựa trên những đặc điểm trên thì chúng ta thấy cấu trúc dữ liệu động sẽ ưu việt hơn tùy vào nhu cầu thực tế.
4 KDÈU DỮ LIỆU DANH SÁCH
4.1 Định nghĩa, phân loại, so sánh
Danh sách là một tập hợp hữu hạn các phần tử cùng kiểu, được biểu diễn dưới dạng chuỗi các phần tử như a1, a2, , an với n > 0 Nếu n = 0, danh sách được gọi là danh sách rỗng Khi n > 0, phần tử đầu tiên là a1 và phần tử cuối cùng là an, trong đó độ dài của danh sách được xác định bởi số lượng phần tử trong nó.
Một đặc điểm quan trọng của danh sách là các phần tử được sắp xếp theo thứ tự tuyến tính dựa trên vị trí xuất hiện Cụ thể, phần tử a đứng trước phần tử ai+1 với i từ 1 đến n-1, và phần tử ai đứng sau a với i từ 2 đến n.
Chủng ta cũng nói aj là phần tử tại vị trí thứ i, hay phần tử thứ i của danh sách.
Ví dụ thực tế thường gặp những danh sách sau:
■ Danh mục sách trong thư viện
■ Danh sách các nhân viên trong công ty
■ Danh sách các cuộc gọi video đang chờ được xử lý
Có 2 loại danh sách đó là danh sách đặc (mảng các phần tử) và danh sách liên kết.
Danh sách đặc: Các phần tử được bố trí nằm liên tục với nhau, là dãy các ô nhớ liên tiếp nên được gọi là danh sách đặc.
- Danh sách nhau, phần ô nhớ) của
•ằỊA n B Ị > liên kết tử thứ phần tủ
Các phần tử trong danh sách liên kết tự quản lý sẽ chứa một con trỏ để liên kết với phần tử tiếp theo trong danh sách.
Danh sách đặc và danh sách liên kết đều nhằm mục đích lưu trữ các phần tử cùng kiểu, nhưng chúng có những điểm khác biệt quan trọng.
Danh sách đặc Danh sách liên kết
Liên kết ngầm Liên kết tường minh
Vỉệc thêm, xóa các phần tử trong danh sách rất khó khăn đặc biệt là khi danh sách có số lượng phần tử rất lớn
Việc thêm, xóa các phần tử trong danh sách dễ dàng
Bắt buộc các ô nhớ của các phần tử trong danh sách phải nằm liên tiếp nhau
Các ô nhớ này không nhất phải nằm cạnh nhau, chi cần c chống thì được cấp phát thiết là lỗ nào
Các phần tử có thể là tĩnh (giống như khai báo mảng thông thường) và có thể là động (cấp phát một dãy ô nhớ)
Hoàn toàn là những ô nhớ động
Các phần tử truy xuất thông qua chỉ số (truy xuất ngẫu nhiên)
Các phần tử truy xuất tuần tự thao tác truy xuất sẽ chậm hom danh sách đặc
Cần đảm bảo rằng việc cấp phát bộ nhớ được thực hiện một cách hợp lý để tránh tình trạng lãng phí hoặc thiếu hụt ô nhớ Điều này có nghĩa là cần xác định chính xác số lượng phần tử cần thiết, từ đó cấp phát đúng số ô nhớ mà không gây ra sự dư thừa.
Tùy vào thực tể mà chủng ta sẽ xem xét dừng loại nào dựa trên ưu và nhược điểm của chúng.
4.2 Các thao tác trên danh sách
Tùy thuộc vào đặc điểm và tính chất của từng loại danh sách, mỗi loại có thể yêu cầu những thao tác cụ thể Nói chung, các thao tác thường gặp trên danh sách bao gồm thêm, sửa đổi và xóa mục.
4.2.1 Tạo mới một danh sách
Trong thao tác này, chúng ta sẽ đưa các phần tử vào danh sách nội dung, từ đó xác định chiều dài của danh sách Trong một số trường hợp, chúng ta chỉ cần khởi tạo giá trị và trạng thái ban đầu cho danh sách.
4.2.2 Thêm một phần tử vào danh sách
Thao tác thêm phần tử vào danh sách sẽ làm tăng chiều dài của danh sách lên 1 nếu thành công Tùy thuộc vào loại danh sách và từng trường hợp cụ thể, việc thêm phần tử có thể diễn ra ở đầu, cuối hoặc giữa danh sách.
4.2.3 Tim kiếm một phần tử trong danh sách
Thao tác này sử dụng các thuật toán tìm kiếm để xác định một phần tử trong danh sách đáp ứng một tiêu chí nhất định, thường là tiêu chí về giá trị, được gọi là khóa tìm kiếm.
4.2.4 Loại bỏ bót một phần tử ra khỏi danh sách
Ngược lại với việc thêm phần tử, thao tác loại bỏ sẽ giảm số lượng phần tử trong danh sách Khi việc loại bỏ thành công, chiều dài của danh sách sẽ giảm xuống 1 Trước khi thực hiện thao tác này, thường cần phải tìm kiếm phần tử cần loại bỏ.
4.2.5 Cập nhật (sửa đổi) giá trị cho một phần tử trong danh sách
Thao tác này được thực hiện để sửa đổi nội dung của một phần tử trong danh sách Trước khi thực hiện việc thay đổi, chúng ta cần phải tìm kiếm phần tử cần sửa đổi, tương tự như khi thực hiện thao tác loại bỏ.
4.2.6 Sắp xếp thứ tự các phần tử trong danh sách
Trong thao tác này chúng ta sẽ vận dụng các thuật toán sắp xếp các phần tử trên danh sách theo một trật tự xác định
Danh sách liên kết là tập hợp các phần tử mà giữa chúng có một sự kết nối với nhau thông qua vùng liên kết của chúng.
Sự kết nối giữa các phần tử trong danh sách liên kết thể hiện sự quản lý và ràng buộc nội dung giữa chúng, đồng thời xác định địa chỉ của từng phần tử Tùy thuộc vào mức độ và cách thức kết nối, danh sách liên kết có thể được phân loại thành nhiều loại khác nhau.
- Danh sách liên kểt đon;
- Danh sách liên kết đôi/kép;
- Danh sách liên kết vòng (vòng đon, vòng đôi).
Danh sách liên kết đơn là một cấu trúc dữ liệu đặc biệt, trong đó các phần tử được tổ chức theo cách mà mỗi phần tử chứa một giá trị và một liên kết đến phần tử tiếp theo Trong tài liệu này, chúng ta sẽ khám phá chi tiết về cách thức hoạt động, cấu trúc và các thao tác cơ bản trên danh sách liên kết đơn.
4.3.2 Xây dựng cấu trúc dữ liệu
Một phần tử trong danh sách liên kết, hay còn gọi là nút, bao gồm hai vùng: vùng dữ liệu và vùng liên kết Cấu trúc dữ liệu của nó được định nghĩa như sau: struct node.
Ngăn xểp ( Stac k)
Hình 5-11 Hình mô phỏng một ngăn xếp
5.1 Khái niệm s Ngăn xếp là một đối tượng dùng để chứa các phần tử dữ liệu (item) một cách có thứ tự. s Khi đưa một phần tử vào hoặc lấy một phần tử ra khỏi ngăn xếp thì ta đều thực hiện ở cùng một phía (từ phía đỉnh của ngăn xếp).
Phần tử được đưa vào sau sẽ được lấy ra trước, trong khi phần tử đưa vào trước sẽ được lấy ra sau Cách thức này được gọi là LIFO (Last In First Out).
Tóm lại ngăn xếp chẳng qua cũng là một danh sách (gồm các phần tử cùng kiểu) nhưng nó phải hoạt động tuân thủ theo quy tắc LIFO.
Hình 5-12 Mô phỏng thao tác chính trên ngăn xếp Các thao tác cơ bản trên ngăn xếp: s initStack: khởi tạo một ngăn xếp rồng
■S isEmpty: kiểm tra ngăn xếp có rồng hay chưa.
•S isFull: kiểm tra ngăn xếp đầy hay chưa.
■S push: đưa một phần tử (item) dữ liệu vào ngăn xếp, việc đưa vào này có thể làm ngăn xếp bị đầy.
■S pop: lấy một phần tử (item) dữ liệu ra khỏi ngăn xếp, việc này có thể làm ngăn xếp bị rỗng.
Khi viết code hiện thực một ngăn xếp, ta có thể dùng:
• Danh sách liên kết đơn.
5.2 Cài đặt ngăn xếp bằng mảng ũ t ? ĩ 4 5 6 7 s 1 10 5 8 9
Hình 5-13 Mô hình cài đặt ngăn xếp bằng mảng
Sau đây chúng ta sẽ minh họa cài đặt ngăn xếp chứa các sổ nguyên:
Khai báo cấu trúc dữ liệu: struct STACK
{ int *a; //mảng một chiều chưa các item của ngăn xếp int n; //cho biết kích thước của ngăn xếp int top; //quản lý đỉnh của ngăn xếp
Các thao tác: a) Hàm khởi tạo ngăn xếp: void initstack (STACK &s, int spt) { s.a = new int[spt]; if (s.a == NULL) { cout ô "Khụng dủ bộ nhớ để cấp phỏt!"; exit(0);
} b) Hàm kiểm tra ngăn xếp rồng hay chưa: bool isEmpty(STACK s) { if (s.top = -1) return true; return false;
} c) Hàm kiểm tra ngăn xếp bị đầy hay chưa: bool isFull (STACK s) { if (s top = s n-1) return true; return false;
Hàm `push` cho phép thêm một phần tử vào ngăn xếp Nếu ngăn xếp đã đầy, hàm sẽ trả về false Ngược lại, phần tử sẽ được thêm vào vị trí đỉnh của ngăn xếp và hàm trả về true sau khi thực hiện thành công.
} e) Hàm lấy một phần tử ra khỏi ngăn xếp: bool pop(STACK &s, int &x) { if (isEmpty(s) = true) return false; // lấy ra không thành công vì ngăn xếp đã rỗng
X = s a [s.top ]; return true; //lấy ra thành công
} f) Hàm kiểm tra phần tử ở đỉnh ngăn xếp có giả trị bàng bao nhiêu: bool checkTop(STACK s, int &x){ if (isEmpty(s) = true) return false;
The function `outputstack(STACK s)` is designed to display the elements present in a stack It first checks if the stack is empty using `isEmpty(s)`, and if so, it exits the function If the stack contains elements, it iterates from the top of the stack down to zero, printing each element Finally, it outputs the position of the stack's top element.
} h) Một ví dụ về hàm main cho bài lập trình ngăn xếp bằng mảng một chiều: void main() { bool over, empty;
STACK s; int n, X , i; cout ô "Hóy cho biết kich thước của ngăn x ế p :"; ein ằ n; //số phần tử của ngăn xếp initstack(s,n); cout ô "Thi nghiệm hàm Push:";
//cố tình push vượt kich thước ngăn xếp for (i = 0; i < s.n + 1; i++) { cout ô "Nhập giỏ trị dể dưa vào ngăn xếp, X = "; ein ằ x; over = push(s,x); if (over == false) cout ô "Ngăn xếp bị tràn!";
} cout ô "Thi nghiệm hàm Pop:";
//cố tình pop ra vượt kích thước ngăn xếp for (i = 0; i < s.n + 3; i++) { empty = pop(s,x); cout ô "Giỏ trị X = " ô x; if (empty = false) cout ô "Ngăn xếp dó rỗng!";
5.3 Cài đặt Ngăn xếp bằng danh sách liên kết đơn
✓ Sử dụng một danh sách liên kết đơn để hiện thực ngăn xếp với đỉnh ngăn xếp (biến top) chỉ vào phần tử đầu tiên của danh sách.
■S push một phần tử (item) vào ngăn xếp thì tương đương với hàm addHead trong bài danh sách liên kết đơn.
•S pop một phần tử (item) ra khỏi ngăn xếp thỉ tương đương với hàm removeHead trong bài danh sách liên kết đơn. rtem-i
Hình 5-14 Mô hình cài đặt ngăn xếp bằng danh sách liên kết đơn
Khai báo cấu trúc dữ liệu: struct ITEM
{ int key; struct titem *pDown;
Các thao tác: a) Hàm tạo một phần tử để chuẩn bị đưa vào ngăn xếp
(Hàm này giống với hàm tạo node trong bài danh sách liên kết đom)
ITEM *t = new ITEM; if (t == NULL) { cout ô "Stack is full!" ô endl; return NULL;
} b) Hàm khởi động ngăn xếp: void initstack(STACK &s) í s.Top = NULL; s numOfItem = 0;
} c) Hàm kiểm tra ngăn xếp đã rồng hay chưa bool isEmpty(STACK s) { if (s.Top = NULL && s.numOfltem == 0) return true; return false;
} d) Hàm kiểm tra ngăn xếp đã đầy hay chưa:
Nếu không còn đủ bộ nhớ để cấp phát thì Ngăn xếp bị đầy
ITEM *t = new ITEM; if (t == NULL) return true; delete t; return false;
} e) Hàm thêm một phần tử vào đỉnh của ngăn xếp:
Hàm addTop tương đương với hàm addHead trong danh sách liên kết đơn, được sử dụng để thêm một phần tử vào đầu ngăn xếp Cú pháp của hàm là void addTop(STACK &s, ITEM *item) và trong trường hợp ngăn xếp rỗng (s.Top = NULL), hàm sẽ gán phần tử mới cho Top của ngăn xếp và kết thúc.
} item -> pDown = s.Top; s.Top = item;
> f) Hàm đưa một phần tử vào ngăn xếp bool push (STACK &s, int x) { if (isFullO == true) return false;
ITEM *item = makeltem(x); addTop(s, item); s numOfItem++; return true;
} g) Hàm xóa một phần tử tại đinh ngăn xếp:
(Hàm này tương đương với hàm deleteHead trong bài danh sách liên kết đơn Hàm deleteTop để phục vụ cho hàm pop) void deleteTop(STACK &s) { if (s.Top = NULL) return;
ITEM *item = s.Top; s Top = s Top -> pDown; delete item;
} h) Hàm lấy một phần tử ra khỏi đỉnh ngăn xếp: bool pop(STACK &s, int &x) { if (isEmpty(s) == true) return false;
X = s.Top->key; deleteTop(s) ; s numOfItem ; return true;
} i) Hàm kiểm tra phần tử ở đỉnh ngăn xếp có giá trị bằng bao nhiêu: bool checkTop(STACK s, int &x) { if (isEmpty(s) = NULL) return false;
} j) Hàm xuất các phần tử có trong ngăn xếp: void outputstack (STACK s) {
ITEM *i; if (isEmpty(s) = true) { cout ô "Stack is empty!" ô e n d u ư re turn;
} for (i = s.Top; i != NULL; i = i -> pDown) cout ô i->key ô " "; cout ô endl ô "Number of items is " ô s.numOfltem ô endl ; cout ô endl;
} k) M ột ví dụ về hàm main cho bài lập trình ngăn xếp bằng danh sách liên kết đon: void main() {
STACK s; int i, x; bool check; initstack(s) ; for ( i = 0; i < 7; i++) { cout ô "Input a value = "; cin ằ x; check = push(s,x) ; if (check = false) { cout ô "Stack is full!" ô endl; break;
After the input, the content of the stack is displayed The process begins with the first pop operation, where the number of items in the stack is determined A loop is initiated to pop items from the stack, and for each successful pop, the value of X is outputted If the stack is empty during the process, a message indicating that the stack is empty is printed, and the loop is terminated.
After the initial pop operation, the contents of the stack are displayed The process then begins to pop items from the stack a second time The number of items in the stack is determined, and a loop is initiated to pop items If an item is successfully popped, a message indicating the value of the popped item is printed Conversely, if the stack is empty, a message stating "Stack is empty!" is displayed.
} cout ô "After (the second) pop out, content of the stack is:" ô endl; outputstack(s) ; cout ô endl;
Bài toán chuyển đổi số thập phân sang hệ cơ số bất kỳ được giải quyết bằng cách thực hiện phép chia và lưu trữ các số dư vào ngăn xếp Sau khi hoàn tất các phép chia, ta sẽ lấy các phần tử từ ngăn xếp theo thứ tự từ đỉnh xuống Kết quả cuối cùng là dãy số đã được chuyển đổi thành hệ cơ số mong muốn.
#define FALSE 0 typedef unsigned int Data; typedef struct! int top;
} Stack; void push(Stack & st, Data x) ;
Data pop (Stack &st) ; void initstack(Stack &st); int isEmpty(Stack St); int isFull (Stack st) ;
Data top(Stack st); void push(Stack & St, Data x) { if (isFull (st) ) p r i n t f ("\nStack is full!"); else st.S[++st.top] = x;
Data pop (Stack &st) { if (isEmpty(st)) p r i n t f ("\nStack is empty!"); else return (st S [st top ] ) ;
} void initStack(Stack &st) { st.top = -1;
} int isEmpty(Stack st) { if (st.top == -1) return TRUE; else return FALSE;
} int isFull (Stack st) { if (st.top >= MAX) return TRUE; else return FALSE;
Data d; if (isEmpty(st)) printf("\n Stack is empty! else d = st.S[st.top]; return d;
In the given C program, the user is prompted to input a base for conversion and a number The program initializes a stack and repeatedly divides the number by the base, pushing the remainder onto the stack until the number becomes zero This process effectively converts the number from its original base to the specified base using stack data structure operations.
} printf("\n Co so : "); while (!isEmpty(st)) printf ("%3X" , pop(st)); getch 0 ;
Bài toán tính giá trị biểu thức hậu tố:
Vào những năm đầu thập niên 1950, nhà logic học Ba Lan Jan Lukasiewicz đã chứng minh rằng biểu thức hậu tố có thể được tính toán mà không cần dấu ngoặc Điều này có thể thực hiện được bằng cách đọc biểu thức từ trái sang phải và sử dụng một ngăn xếp để lưu trữ các kết quả trung gian.
Các bước thực hiện như sau:
Bước 1: Khời tạo một ngăn xếp = {0}
Bước 2: Đọc lần lượt các phần tử của biểu thức hậu tố từ trái sang phải, với mồi phần tử đó thực hiện kiểm tra:
- Neu phần tử này là toán hạng thì đẩy nó vào ngăn xếp
Neu phần tử là toán tử, ta sẽ lấy hai toán hạng từ ngăn xếp và thực hiện phép toán giữa chúng Kết quả của phép toán sẽ được lưu trữ lại trong ngăn xếp.
Sau khi hoàn thành bước hai, biểu thức đã được đọc xong và trong ngăn xếp chỉ còn lại một phần tử, đó chính là giá trị cuối cùng của biểu thức.
Ví dụ: tính giá trị biểu thức 1 0 2 / 3 + 7 4 - * , có biểu thức trung tố là: (10/2 +
3)*(7-4), ta có các bước thực hiện như sau: Đọc Xử l ý S t a c k Output
L ấ y hai p h ầ n từ đầu s t a c k là 2 , 10 và th ự c hiện ph ép to án 10/2 = 5 S a u đó lưu kết q u ả 5 v à o sta c k
L ấ y hai giá trị 3, 5 ra khỏi sta ck , th ự c hiện phép cộ n g c ủ a hai s ố đó, kểt q u ả lồ 8 đ ư ợ c đ ư a v à o lai sta c k
L ấ y hai g iá trị 4, 7 ra khỏi sta ck , th ự c hiện phép tính 7 - 4 = 3, kết q u ả 3 đ ư ợ c đ ư a v à o lại s ta c k 8 3
* L ấ y hai g iá trị 3, 8 ra khỏi sta c k , th ự c hiện p h ép tính 8 * 3 = 2 4 , lưu kết q u ả v à o sta ck
L ấ y kểt q u ả từ sta ck =3 đ â y chỉnh là kết q u à c ủ a biểu th ứ c 2 4 về phần cài đặt, sinh viên tự cài đặt sau khi đọc xong thuật toán.
Hàng đợi (Q u eu e)
Hình 5-15 Mô phỏng hàng đọi
Hàng đợi là một đối tượng dùng để lưu trữ các phần tử dữ liệu (item) một cách có thứ tự.
Thêm một item thì thêm vào cuối hàng đợi (đầu rear), lấy một item ra thì lấy từ đầu hàng đợi (đầu front).
Các phần tử được lưu vào và lấy ra theo cơ chế FIFO (First In First Out).
Hình 5-16 Thao tác chính trên hàng đợi
Hàng đợi là một cấu trúc dữ liệu bao gồm các phần tử cùng kiểu, trong đó việc thêm và lấy ra các phần tử phải tuân theo nguyên tắc FIFO (First In, First Out).
Có thể dừng mảng một chiều hoặc danh sách liên kết đơn để xây dựng hàng đợi (queue)
Các thao tác cơ bản trên hàng đợi:
- Kiểm tra hàng đợi rồng hay chưa.
- Kiểm tra hàng đợi đầy hay chưa.
- Thêm một phần tử vào hàng đợi (enQueue).
- Lấy một phần tử ra khỏi hàng đợi (deQueue).
- Khởi tạo hàng đợi rỗng.
- Front: cho biết vị trí trên hàng đợi, nơi mà một phần tử sẽ bị lấy ra khỏi hàng đợi (chỉ phần tử ở đầu hàng đợi)
Rear: cho biết vị trí trên hàng đợi, nơi mà một phần tử mới sẽ được thêm vào tại đó (chỉ phần tử ở cuối hàng đợi)
Tóm lại: ra tại Front, vào tại Rear.
6.2 Cài đặt Hàng đọi bằng mảng vói phương pháp tịnh tiến:
Sau đây là hình mô tả hàng đợi trong các tình huống: rồng, có một phần tử, có nhiều phần tử và khi hàng đợi đầy: n=6 phẩn tử
-1 thêm giá trị 10 vào n=ó phần tữ n=6 phẩn tú
-1 -1 thêm 15 rồi thcm 7 lấv 10 ra
Giả sừ hàng đợi có nội dung như hình sau: n=6 phần tử
Để thêm 105 vào hàng đợi, bạn cần thực hiện phương pháp tịnh tiến, vì trạng thái hiện tại đã đầy và không thể thêm giá trị mới Cách thực hiện là dịch chuyển các giá trị trong hàng đợi xuống phía dưới để tạo không gian cho giá trị mới.
•1Hàng đợi sau khi tịnh tiến n=6 phẩn tử
•I Hàng đợi sau khi thêm 105 n=6 phần từ
Thêm tiếp 37 vào thì hàng đợi đầy thật.
Khai báo cẩu trúc dữ liệu cho hàng đợi: struct tqueue
{ int * queue Array; int n; int front,rear;
}; typedef struct tqueue QUEUE; a) Hàm khởi tạo hàng đợi: void queuelnitialize(QUEUE &q, int spt) { q.queueArray = new int[spt]; q n = spt; q.front = -1; q.rear = -1;
} b) Hàm kiểm tra hàng đợi rỗng hay chưa: bool isEmpty(QUEUE q) { if (q.front == -1) return true; return false;
} c) Hàm kiểm tra hàng đợi đầy hay chưa:
Vì hàng đợi có thể bị đầy giả (tức là chưa đầy thật), cho nên nếu chỉ viết: if (q.rear == q.n - 1) là không đúng? n -ó phần tử n=6 phần tử
Trạng thái đầy giả Trạng thái đầy thật. bool isFull(QUEUE q) { if (q.rear - q.front == q.n - 1) return true; return false;
To add an element to a queue (enQueue), the function checks if the queue is full; if it is, it returns false If the queue is empty, it initializes the front index to zero When the rear index reaches the maximum size of the queue, the function shifts elements forward to make space for the new item This process updates the rear and front indices accordingly, ensuring that the queue maintains its structure while accommodating the new element.
} q.queueArray[++q.rear] = in_item; return true;
} e) Hàm lấy một phần tử ra khỏi hàng đợi (deQueue):
Neu hàng đợi chỉ còn một phần tử Ta xét các trường hợp sau: n=6 phần tử
Hàng đợi trước khi lấy ra 50 n=6 phẩn từ
After removing an item from the queue, it becomes empty, necessitating an immediate update of Front to -1 and Rear to -1 The function `removeItem` checks if the queue is empty; if it is not, it retrieves the front item and increments the front index If the front index exceeds the rear index, both are reset to -1, indicating the queue is now empty.
} void outputToTestQueue(QUEUE q) { int i ; if (isEmpty(q) ) { cout ô "Queue is empty!"; cout ô "\nq.front = " ô q.front; cout ô " q.rear = " ô q.rear; cout ô " q.n = " ô q.n; return;
} for (int i = q.front; i pNext` to point to the new node and then update `q.rear` to the new node.
} d Lấy phần tử ra khỏi cuối hàng đợi
DataType deQueue (Queue &q) { if (isEmpty(q) ) { cout ô "Queue is empty"; exit(1);
Node *p = q.front; q.front = q.front -> pNext; if (q.front = NULL) q.rear = NULL;
DataType X = p - > data; p -> pNext = NULL; delete p; return x;
} e Xem thông tin của phần tử ở đầu Hàng đợi:
DataType viewFront (Queue q) { if (isEmpty(q)) { cout ô "Queue is empty"; exit (1) ;
Trong lĩnh vực tin học, cấu trúc dữ liệu hàng đợi có nhiều ứng dụng quan trọng như khử đệ quy, tổ chức lưu vết các quá trình tìm kiếm theo chiều rộng, quay lui và vét cạn Nó cũng được sử dụng để quản lý và phân phối tiến trình trong các hệ điều hành, cũng như tổ chức bộ đệm bàn phím.
Cơ chế “vào trước ra trước” thường liên quan đến cấu trúc dữ liệu Hàng đợi, được áp dụng trong nhiều lĩnh vực như sản xuất và tiêu thụ, nơi hàng hóa sản xuất trước sẽ được xuất ra trước Hàng đợi cũng đóng vai trò quan trọng trong các ứng dụng như đặt vé tàu, máy bay và hệ thống rút tiền Ngoài ra, trong hệ điều hành, hàng đợi được sử dụng để quản lý bộ đệm ứng dụng, xử lý sự kiện, xử lý phím nhấn và tiến trình.
Bài toán quản lý kho hàng là một vấn đề quan trọng trong sản xuất và tiêu dùng, trong đó hàng hóa được sản xuất sẽ được lưu trữ trong kho và sau đó xuất ra cho nhà phân phối Đặc điểm nổi bật của hệ thống này là nguyên tắc FIFO (First In, First Out), nghĩa là những mặt hàng được đưa vào kho trước sẽ được xuất kho trước Để minh họa cho quy trình này, chúng ta có thể sử dụng cấu trúc dữ liệu Hàng đợi, giúp quản lý và theo dõi hàng hóa một cách hiệu quả.
Cài đặt cho sinh viên được thực hiện tự động, tương tự như cách cài đặt Hàng đợi với các số nguyên Sự khác biệt nằm ở kiểu dữ liệu, không phải là kiểu in, mà là kiểu Data được khai báo bằng cấu trúc typedef struct.
{ char maSP[10]; // Ma san pham char tenSP[50]; // Ten san pham
Sau khi học xong chương này chúng ta cần nhớ:
Cấu trúc dữ liệu động và tĩnh có sự khác biệt chính ở khả năng thay đổi: "động" cho phép linh hoạt trong việc thay đổi, trong khi "tĩnh" không thể thay đổi Mỗi loại đều có ưu và nhược điểm riêng, và việc lựa chọn giữa chúng phụ thuộc vào ứng dụng thực tế để đạt hiệu quả tối đa về lưu trữ, thao tác và thời gian thực hiện.
Con trỏ là một công cụ quan trọng trong lập trình, có nhiều ứng dụng thực tiễn Hiểu rõ bản chất của biến con trỏ giúp chúng ta quản lý hiệu quả vùng nhớ động, từ đó tối ưu hóa hiệu suất chương trình.
Trong chương này, chúng ta đã phân biệt hai loại danh sách: danh sách đặc và danh sách liên kết Đặc biệt, chúng ta đã tìm hiểu sâu về danh sách liên kết đơn, bao gồm các thao tác cơ bản như thêm, xóa và sửa trong danh sách động.
Có hai loại danh sách đặc biệt thường được sử dụng trong thực tế là ngăn xếp (Stack) và hàng đợi (Queue) Ngăn xếp hoạt động theo cơ chế LIFO (vào sau ra trước), trong khi hàng đợi hoạt động theo cơ chế FIFO (vào trước ra trước) Khi gặp các bài toán yêu cầu áp dụng cơ chế của ngăn xếp hoặc hàng đợi, việc sử dụng chúng là cần thiết để giải quyết hiệu quả.
CÂU HỎI VẢ BẢI TÁP
1 Biến con trỏ là gì? Nêu các thao tác trên biển con trỏ
2 Nêu sự khác nhau giữa biến tĩnh và biến động
3 Cho biết kết quả xuất ra màn hình của đoạn code sau: int *p = new int; int X = 10; int *q = &x;
CÁU TRÚC C Â Y
CÂY N H Ị P H Â N
Cây nhị phân là loại cây có tối đa hai nút con cho mỗi nút, trong đó các nút con được phân biệt rõ ràng thành nút con trái và nút con phải Theo quy ước, nút con trái được vẽ bên trái nút cha, trong khi nút con phải được đặt bên phải Mỗi nút con được kết nối với nút cha thông qua một đoạn thẳng.
Hình 6-4 Cấu trúc cây nhị phân
Trong cây nhị phân, mỗi nút con chỉ có thể là nút con trái hoặc nút con phải, điều này dẫn đến việc có thể tồn tại những cây có thứ tự giống nhau nhưng lại là hai cây nhị phân khác nhau.
Hinh 6-1: Hai cây có thứ tự giống nhau nhưng là hai cây nhị phân khác nhau
Cây nhị phân có thể ứng dụng trong nhiều bài toán thông dụng Ví dụ dưới đây cho ta hình ảnh của một biểu thức toán học: ôCC3 X (i + (1 + r>m + Í2 + 8)1 X s> + ớ i X 0 + ĩ)))
Hình 6-5 Biểu thức toán học được biểu diễn bằng cây nhị phân
2.2 Tính chất của cây nhị phân
- Số nút lá < 2h' \ với h là chiều cao của cây.
- Chiều cao của cây h > log2(số nút trong cây).
Hình 6-6 Tính chất của cây nhị phân
2.3 Bỉờu diờn cõy nhị phõn ^ Tầ • ? 1ằ x A 1ô 1 A
Ta chọn cấu trúc dữ liệu động để biểu diễn cây nhị phân, ứ n g với một nút, ta dùng một biến động lưu trừ các thông tin:
- Thông tin lưu trữ tại nút.
- Địa chỉ nút gốc của cây con trái trong bộ nhớ.
- Địa chỉ nút gốc của cây con phải trong bộ nhớ
Lchild và Rchild là các con trỏ đại diện cho nút con bên trái và bên phải trong cấu trúc dữ liệu Nếu không tồn tại nút con nào, giá trị của chúng sẽ là rỗng.
Khai báo tưorng ứng trong ngôn ngừ c có thể như sau: t ỵ p e d e f s t r u c t t a g T N O D E
Do tính chất linh hoạt của cách biểu diễn bằng cấp phát liên kết, phương pháp này chủ yếu được áp dụng trong việc biểu diễn cây nhị phân Từ bây giờ, khi đề cập đến cây nhị phân, chúng ta sẽ sử dụng phương pháp biểu diễn này.
Có ba cách duyệt cây nhị phân thường dung:
Duyệt theo thứ tự trước (Node-Left-Right) là phương pháp duyệt cây nhị phân, trong đó ưu tiên xử lý nút hiện tại trước, sau đó là con trái và cuối cùng là con phải Thủ tục duyệt có thể được trình bày đơn giản như sau: void N L R (TREE Root) { if (Root != NULL) {
; //Xử lý tương ứng theo nhu cầu
Duyệt theo tứ tự giữa (Left-Node-Right) là phương pháp duyệt cây trong đó ưu tiên xử lý nút con bên trái trước, sau đó là nút hiện tại, và cuối cùng là nút con bên phải.
Thủ tục duyệt có thể trình bày đem giản như sau:
Hình 6-7 Duyệt Node - Left - Right void LNR(TREE Root) { if (Root != NULL) {
< X ử lý Root>; //Xử lý tương ứng theo nhu cầu
Duyệt theo thứ tự Left-Right-Node (LRN) trong cây nhị phân, ưu tiên xử lý nút hiện tại trước, sau đó là con phải và cuối cùng là con trái Thủ tục duyệt có thể được mô tả đơn giản như sau: void L R N (TREE Root) { if (Root != NULL) {
< X ử lý Root>; //Xử lý tương ứng theo nhu cầu
Một ứng dụng phổ biến trong tin học liên quan đến việc duyệt theo thứ tự là xác định tổng kích thước của một thư mục trên đĩa.
Hình 6-8 ứ n g dụng duyệt theo Left-Right-Node tính tổng kích thước thư mục
Một ứng dụng quan trọng của phép duyệt cây theo thứ tự là tính toán giá trị của biểu thức dựa trên cây biểu thức.
Hình 6-9 ử n g dụng duyệt theo Leữ-Right-Node tính giá trị biểu thức
Ví dụ tông hợp khi duyệt bằng 3 phương pháp trên:
Hình 6-10 Ví dụ cây nhị phân
Các danh sách duyệt cây nhị phân:
2.5 Biểu diễn cây tồng quát bằng cây nhị phân
Nhược điểm của các cấu trúc cây tổng quát là sự biến động lớn của bậc các nút trên cây, dẫn đến khó khăn trong việc biểu diễn và gây lãng phí tài nguyên.
Việc xây dựng các thao tác trên cây tổng quát phức tạp hơn so với cây nhị phân Do đó, trong nhiều trường hợp, người ta thường chuyển đổi cây tổng quát thành cây nhị phân nếu không cần thiết phải sử dụng cây tổng quát.
Ta có thể biến đổi một cây bất kỳ thành một cây nhị phân theo qui tắc sau:
- Giữ lại nút con trái nhất làm nút con trái.
- Các nút con còn lại chuyển thành nút con phải.
Trong cây nhị phân mới, nút con trái biểu thị mối quan hệ cha con, trong khi nút con phải thể hiện mối quan hệ anh em so với cây tổng quát ban đầu.
Ta có thể xem ví dụ dưới đây để thấy rõ hơn qui trình.
Giả sử có cây tổng quát như hình bên dưới:
Cây nhị phân tương ứng sẽ như sau:
Hình 6-12 Cây nhị phân được chuyển đổi từ cây tổng quát
2.6 Một cách khác để biểu diễn cây nhị phân Đôi khi, khi định nghĩa cây nhị phân, người ta quan tâm đến cả quan hệ 2 chiều cha con chứ không chỉ một chiều như định nghĩa ở phần trên Lúc đó, cẩu trúc cây nhị phân có thể định nghĩa lại như sau: typedef struct tagTNode
DataType Key; struct taglNode* pParent; struct tagINode* pLeft; struct taglNode* pRight;
Hình 6-13 Cách biểu diễn khác của cây nhị phân
CÂY NHỊ PHÂN TÌM K IẾ M
Cây nhị phân tìm kiếm (CNPTK) là một cấu trúc dữ liệu đặc biệt, trong đó mỗi nút có khóa lớn hơn tất cả các nút trong cây con bên trái và nhỏ hơn tất cả các nút trong cây con bên phải.
Dưới đây là một ví dụ về cây nhị phân tìm kiểm:
Hình 6-14 Ví dụ về cây nhị phân tìm kiếm
Nhờ vào ràng buộc khóa trong cấu trúc cây nhị phân tìm kiếm (CNPTK), quá trình tìm kiếm trở nên có định hướng và hiệu quả hơn Cấu trúc cây giúp tăng tốc độ tìm kiếm, với chi phí trung bình chỉ khoảng log2N khi số nút trên cây là N.
Trong thực tế, khi xét đến cây nhị phân chủ yếu người ta xét CNPTK
3.2 Các thao tác trên cây nhị phân tìm kiếm
Việc duyệt cây trên cây nhị phân tìm kiếm tương tự như trên cây nhị phân thông thường Tuy nhiên, khi thực hiện duyệt theo thứ tự giữa, các nút được duyệt sẽ được sắp xếp theo thứ tự tăng dần của khóa.
3.2.2 Tìm một phần tử X trong cây
TNODE* searchNode(TREE T, Data X) { if(T) { if (T->Key == X) return T; if (T->Key > X) return searchNode(T->pLeft, X ) ; else return searchNode(T->pRight, X) ;
Ta có thể xây dựng một hàm tìm kiếm tương đương không đệ qui như sau:
TNODE * searchNode(TREE Root, Data x) {
NODE *p = Root; while (p != NULL) { if (x == p->Key) return p; else if (x < p->Key) p = p->pLeft; else p = p->pRight;
Số lần so sánh tối đa cần thực hiện để tìm kiếm phần tử X trong cây nhị phân tìm kiếm (CNPTK) là h, với h là chiều cao của cây Do đó, thao tác tìm kiếm trên CNPTK với n nút có chi phí trung bình khoảng O(log2 n).
Ví dụ: Tìm phần tử 55
Hình 6-15 Tỉm một phần tử trên cây nhị phân tìm kiếm
3.2.3 Thêm một phần tử X vào cây
Để thêm một phần tử X vào cây, cần đảm bảo tuân thủ các điều kiện của cây nhị phân tìm kiếm Việc thêm có thể thực hiện ở nhiều vị trí khác nhau, nhưng việc thêm vào một nút lá là thuận tiện nhất, vì nó cho phép áp dụng quy trình tương tự như khi tìm kiếm Khi kết thúc quá trình tìm kiếm, chúng ta sẽ xác định được vị trí cần thêm phần tử.
The insertNode function in a binary tree returns values of -1, 0, or 1 based on memory availability, duplicate nodes, or successful insertion The function checks if the tree node exists; if the key matches the input data, it returns 0, indicating a duplicate If the input data is less than the current node's key, the function recursively attempts to insert into the left subtree, while if it's greater, it proceeds to the right subtree.
T = new TNode; i f (T = NULL) return -1; //thiếu bộ nhó
T->pLeft =T->pRight = NULL; return 1; //thêm vào thành công
Ví dụ: Thêm phần tử
Hình 6-16 Minh họa thêm một phần tử vào trong cây nhị phân tìm kiếm
3.2.4 Hủy một phần tử có khóa X
Việc hủy một phần tử X ra khỏi cây phải bảo đảm điều kiện ràng buộc của CNPIK.
Có 3 trường hợp khi hủy nút X có thể xảy ra:
- X chỉ cỏ 1 con (trái hoặc phải).
Trường họp thứ nhất: chỉ đon giản hủy X vì nó không móc nối đán phần tử nào khác.
Hình 6-17 Hủy nút lá trên cây nhị phân tìm lđếm
Trường họp thứ hai: trước khi hủy X ta móc nối cha của X với con duy nhất của nó.
Trong trường hợp cuối cùng khi nút X trong cây nhị phân tìm kiếm có đủ hai con, ta không thể hủy trực tiếp Thay vào đó, ta sẽ thực hiện việc hủy gián tiếp bằng cách tìm một phần tử thế mạng Y, phần tử này chỉ có tối đa một con Thông tin từ Y sẽ được chuyển lên lưu tại X, và sau đó, nút Y sẽ được hủy, tương tự như hai trường hợp trước đó.
Vẩn đề là phải chọn Y sao cho khi lưu Y vào vị trí của X, cây vẫn là CNPTK.
Có 2 phần tử thỏa mãn yêu cầu:
- Phần tử nhỏ nhất (trái nhất) trên cây con phải.
- Phần tử lớn nhất (phải nhất) trên cây con trái.
Việc lựa chọn phần tử thế mạng hoàn toàn phụ thuộc vào sở thích của lập trình viên Trong bài viết này, tôi sẽ chọn phần tử nằm ở vị trí phải nhất trên cây con trái làm phần tử thế mạng.
Hãy xem ví dụ dưới đây để hiểu rõ hon:
Hình 6-19 Hủy nút có đủ 2 con trên cây nhị phân tìm kiếm
Sau khi hủy phần tử x ra khỏi cây tình trạng của cây sẽ như trong hình dưới đây (phần tử 23 là phần tử thế mạng):
The delNode function returns a value of 1 or 0, indicating a successful deletion or the absence of the specified data X in the tree If the tree is empty (T == NULL), the function returns 0 If the key of the current node (T->Key) is greater than X, the function recursively calls itself on the left subtree Conversely, if T->Key is less than X, it calls itself on the right subtree When T->Key equals X, the deletion process is initiated.
T = T->pRight; else if (T->pRight == NULL)
Trong đó, hàm searchStandFor được viết như sau:
//Tim phần tử thế mạng cho nút p void searchStandFor(TREE &p, TREE &q) { if (q->pLeft) searchStandFor(p, q->pLeft); else { p->Key = q->Key; p = q; q = q->pRight;
Ta có thể tạo một cây nhị phân tìm kiếm bàng cách lặp lại quá trình thêm 1 phần tử vào một cây rồng.
The entire tree can be deleted by performing a tree traversal in the following order: first, remove the left subtree, then the right subtree, and finally delete the root node The function to accomplish this is defined as follows: `void removeTree(TREE &T) { if (T) { removeTree(T->pLeft); removeTree(T->pRight); delete(T); } }`.
Tất cả các thao tác searchNode, insertNode, delNode trên CNPTK đều có độ phức tạp trung bình O(h), với h là chiều cao của cây
Trong trường hợp tối ưu, cây nhị phân tìm kiếm (CNPTK) với n nút sẽ đạt độ cao h = log2(n) Khi đó, chi phí tìm kiếm sẽ tương đương với việc thực hiện tìm kiếm nhị phân trên một mảng đã được sắp xếp.
Trong trường hợp xấu nhất, cây có thể suy biến thành một danh sách liên kết (DSLK) khi mà mọi nút đều chỉ có một con trừ nút lả, dẫn đến độ phức tạp thao tác đạt 0(n) Do đó, cần cải tiến cấu trúc của cây nhị phân tìm kiếm (CNPTK) để giảm chi phí thao tác xuống còn log(n).
CÂY NHỊ PHÂN CÂN B Ằ N G
Cây nhị phân tìm kiếm cân bằng là một cấu trúc dữ liệu đặc biệt, trong đó tại mỗi nút, độ cao của cây con bên trái và cây con bên phải không được chênh lệch quá một Điều này giúp duy trì tính cân bằng của cây, từ đó cải thiện hiệu suất tìm kiếm, chèn và xóa các nút trong cây.
Dưới đây là ví dụ cây cân bằng (lưu ý, cây này không phải là cây cân bằng hoàn toàn):
Hình 6-20 Cây nhị phân cân bằng
4.2 Lịch sử cây cân bằng (AVL Tree)
Cây AVL, được đặt theo tên các tác giả người Nga Adelson-Velskii và Landis (1962), là một loại cây nhị phân cân bằng Từ nay, thuật ngữ "cây AVL" sẽ được sử dụng để chỉ cây cân bằng này.
Kể từ khi ra mắt, cây AVL đã nhanh chóng được ứng dụng trong nhiều bài toán, dẫn đến sự phổ biến và thu hút nhiều nghiên cứu Từ cây AVL, nhiều cấu trúc dữ liệu hữu ích khác đã được phát triển, bao gồm cây đỏ-đen (Red-Black Tree) và B-Tree.
4.3 Cấu trúc dữ liệu cho cây AVL
Chỉ số cân bằng của một nút được định nghĩa là hiệu của chiều cao cây con bên phải và cây con bên trái của nó Trong một cây cân bằng, chỉ số cân bằng (CSCB) của mỗi nút chỉ có thể nhận một trong ba giá trị cụ thể.
CSCB(p) = 0 Độ cao cây trái (p) = Độ cao cây phải (p)
CSCB(p) = 1 Độ cao cây trái (p) < Độ cao cây phải (p)
Để xác định độ cân bằng của cây, ta có công thức CSCB(p) = -l, tương đương với việc độ cao của cây trái (hL) lớn hơn độ cao của cây phải (hR) Để dễ dàng trình bày, ta ký hiệu p->balFactor = CSCB(p) Để khảo sát cây cân bằng, cần lưu trữ thông tin về chỉ số cân bằng tại mỗi nút Do đó, cây cân bằng có thể được khai báo với cấu trúc như sau: typedef struct tagAVLNode { char balFactor; // Chỉ số cân bằng.
D a t a k e y ; struct tagAVLNode* pLeft; s t r u c t tagAVLNode* p R i g h t ; }AVLNode; t y p e d e f AVLNode *AVLTr ee; Đe tiện cho việc trình bày, ta định nghĩa một số hàng số sau:
♦ d e f i n e LH - 1 //Cây con trái cao hcm t d e f i n e EH - 0 //Hai cây con bằng nhau t d e f i n e RH 1 //Cây con phải cao hon
4.4 Cân bằng lại cây AVL
Chúng ta sẽ không khảo sát tính cân bằng của một cây nhị phân bất kỳ, mà chỉ tập trung vào các khả năng mất cân bằng xảy ra khi thêm hoặc xóa một nút trên cây AVL Khi xảy ra mất cân bằng, độ lệch chiều cao giữa hai cây con sẽ là 2 Có tổng cộng 6 khả năng mất cân bằng cần được xem xét.
> Trường hợp 1: cây T lệch về bên trái (có 3 khả năng)
Hình 6-21 Cây bị lệch trái
> Trường hợp 2: cây T lệch về bên phải
Ta có các khả năng sau:
Các trường hợp lệch về bên phải hoàn toàn đối xứng với các trường hợp lệch về bên trái, do đó chỉ cần khảo sát trường hợp lệch bên trái Trong ba trường hợp lệch bên trái, trường hợp TI lệch phải là phức tạp nhất, trong khi các trường hợp còn lại được giải quyết một cách đơn giản.
Sau đây, ta sẽ khảo sát và giải quyết từng trường hợp nêu trên.
T/h 1.1: cây TT lệch về bên trái Ta thực hiện phép quay đơn Left-Left
Hình 6-23 Cân bằng lại cây trường họp 1T/h 1.2: cây TI không lệch Ta thực hiện phép quay đơn Left-Left
Hình 6-24 Cân bằng lại cây trường họp 2 T/h 1.3: cây TI lệch về bên phải Ta thực hiện phép quay kép Left-Right
Do TI lệch về bên phải ta không thể áp dụng phép quay đon đã áp dụng trong
Khi cây T chuyển từ trạng thái mất cân bằng do lệch trái sang mất cân bằng do lệch phải, cần áp dụng phương pháp khác để khôi phục sự cân bằng cho cây.
Hình vẽ dưới đây minh họa phép quay kép áp dụng cho trường hợp này:
Hình 6-25 Cân bằng lại cây trường họp 3
Trước khi tiến hành cân bằng cây T có chiều cao h+2 trong các trường hợp 1.1, 1.2 và 1.3, cần lưu ý rằng sau khi cân bằng, cây trong trường hợp 1.1 và 1.3 sẽ có chiều cao h+1, trong khi cây ở trường hợp 1.2 vẫn giữ chiều cao h+2 Đặc biệt, trường hợp 1.2 là trường hợp duy nhất mà sau khi cân bằng, nút T cũ có chỉ số cân bằng là 5*0.
Thao tác cân bằng lại trong tất cả các trường hợp đều cóù độ phức tạp 0 (1 ).
Với những xem xét trên, xét tưcmg tự cho trường hợp cây T lệch về bên phải, ta có thể xây dựng 2 hàm quay đơn và 2 hàm quay kép sau:
//quay đơn Right-Right v o i d r o t a t e R R ( A V L T r e e &T) { AVLNode* TI = T - > p R i g h t ;
} //quay kép Left-Right v o i d r o t a t e L R ( A V L T r e e &T) { AVLNode* T1 = T - > p L e f t ; AVLNode* T2 = T l - > p R i g h t ;
} //quay kép Right-Leíì v o i d r o t a t e R L ( A V L T r e e &T) { AVLNode* T I = T - > p R i g h t ; AVLNode* T2 = T l - > p L e f t ;
T = T2; Đe thuận tiện, ta xây dựng 2 hàm cân bàng lại khi cây bị lệch trái hay lệch phải như sau:
//Cân băng khi cây bị lêch về bên trái i n t b a l a n c e L e f t ( A V L T r e e &T) { AVLNo de* T I = T - > p L e f t ; s w i t c h ( T l - > b a l F a c t o r ) { c a s e L H : r o t a t e L L (T ) ; r e t u r n 2; c a s e E H : r o t a t e L L (T ) ; r e t u r n 1; c a s e R H : r o t a t e L R ( T ) ; r e t u r n 2;
//Càn băng khi cây bị lêch về bên phải i n t b a l a n c e R i g h t ( A V L T r e e &T) { AVLNo de* T I = T - > p R i g h t ; s w i t c h ( T l - > b a l F a c t o r ) { c a s e LH: r o t a t e R L ( T ) ; r e t u r n 2 ; c a s e EH: r o t a t e R R ( T ) ; r e t u r n 1 ; c a s e RH: r o t a t e R R ( T ) ; r e t u r n 2 ; } r e t u r n 0 ;
Sau khi học xong chưcmg này chúng ta cần nhớ:
Một số mô hình thực tế thường được tổ chức dưới dạng cây, bao gồm mô hình mô tả cơ cấu tổ chức trong công ty, cấu trúc thư mục tập tin và cấu trúc thư viện sách Những mô hình này giúp dễ dàng hình dung và quản lý các thành phần trong hệ thống.
Để cài đặt các mô hình cây vào máy tính, cần chuyển đổi chúng về dạng cây nhị phân Cây nhị phân có cấu trúc cố định và nhất quán, điều này giúp dễ dàng quản lý và thao tác so với cây tổng quát.
Cây nhị phân là cấu trúc dữ liệu trong đó mỗi nút cha chỉ có tối đa hai nút con Cây nhị phân tìm kiếm có quy tắc rằng nút con bên trái luôn nhỏ hơn nút cha, trong khi nút con bên phải lớn hơn hoặc bằng nút cha Đặc biệt, cây nhị phân tìm kiếm cân bằng là loại cây mà trong đó mọi nút đều có độ lệch giữa nhánh con trái và phải không vượt quá 1, giúp duy trì hiệu suất tìm kiếm tối ưu.
- Đổ duyệt cây người ta sử dụng ba cách duyệt: Left -N ode -Right, Node - Left - Right, Left - Right -Node.
Khi cần thêm hoặc xóa nút trên cây nhị phân tìm kiếm cân bằng, cây có thể trở nên mất cân bằng Để khôi phục sự cân bằng cho cây, chúng ta áp dụng các quy tắc quay đơn và quay kép cho từng trường hợp.
CẢU HỎI VẢ BẢI TÁP
1 Trình bày khái niệm về cây, cây nhị phân, cây nhị phân tìm kiếm và cây nhị phân cân bằng.
2 Trình bày cấu trúc dữ liệu của một node trong cây nhị phân
3 Trình bày cấu trúc dữ liệu của một node trong cây nhị phân tìm kiểm căng bằng
4 Cho cây tổng quát biểu diễn sơ đồ tổ chức trong công ty như sau:
Hãy biểu diễn cây này thành cây nhị phân để có thể cài đặt được trong máy tính.
5 Cho cây nhị phân tìm kiếm sau:
Cho biết kết quả của các phép duyệt cây theo thứ tự NLR, LRN, LNR
6 Cho biết cây kết quả sau khi thêm nút có key = 55 vào cây
7 Cho cây nhị phân tìm kiếm sau:
Hãy vẽ cây trong các trường hợp sau: a Sau khi xóa nút có key = 108 b Sau khi xóa nút có key = 71 c Sau khi xóa nút có key = 37
Để xây dựng cây nhị phân tìm kiếm cân bằng, thực hiện các bước sau: Thêm nút với khóa key = 25 vào cây Tiếp theo, thêm nút với khóa key = 80 và kiểm tra xem cây có bị mất cân bằng hay không Nếu có, tiến hành cân bằng lại cây Cuối cùng, thêm nút với khóa key = 52 và xác định xem cây có bị mất cân bằng không Nếu có, hãy thực hiện các bước cần thiết để cân bằng lại cây.
1 Nguyễn Đình Hóa, c ấ u trúc dữ liệu và giải thuật, Nhà xuất bản Đại học Quốc gia Hà Nội 2008
2 Lê Hoài Bắc và Nguyễn Thanh Nghị, Kỹ năng lập trình, NXB KHKT 2005.
3 Trần Hoàng Thọ, Giáo trình Kỹ thuật Lập trình Nâng cao, ĐH Đà Lạt 2002.
4 Dương Anh Đức và Trần Hạnh Nhi, Nhập môn cấu trúc dừ liệu và thuật toán, ĐH KHTN 2000.
5 Lê Minh Hoàng, Giải thuật và lập trình, NXB ĐH Sư Phạm HN, 1999- 2002
6 Đinh Mạnh Tường, c ấu trúc dữ liệu và thuật toán, Nhà xuất bản Khoa học và kỹ thuật 2000