Giới thiệu

Cấu trúc dữ liệu và giải thuật là cách lập trình để lưu trữ dữ liệu để dữ liệu có thể sử dụng một cách hiệu quả. Hầu hết các ứng dụng chuyên nghiệp đều sử dụng nhiều loại cấu trúc dữ liệu theo cách này hay cách khác. Dưới đây mình cung cấp những hướng dẫn cần thiết nhất để bạn có thể hiểu cơ bản về cấu trúc dữ liệu, tầm quan trọng cũng như những ứng dụng của nó trong việc lập trình.

Yêu cầu

Để có thể hiểu và vận dụng tốt những kiến thức liên quan đến cấu trúc dữ liệu bạn phải có kiến thức căn bản về lập trình C/C++ nếu không dễ bị ngộp lắm.

 

Tổng quan

  • Ở bài này mình sẽ cung cấp cho bạn những kiến thức nền tảng trong việc học Cấu trúc dữ liệu và giải thuật.

  • Cấu trúc dữ liệu và giải thuật là cách tổ chức dữ liệu một cách có hệ thống để có thể sử dụng một cách hiệu quả. Chúng ta cùng tìm hiểu qua một số khái niệm nền tảng trong Cấu trúc dữ liệu sau đây:

  • Interface: Mỗi cấu trúc dữ liệu có một Interface. Interface đại diện cho một tập các phép tính mà một cấu trúc dữ liệu hỗ trợ. Một Interface chỉ cung cấp danh sách danh sách những phép tính được hỗ trợ, cá loại tham số mà chúng có thể chấp nhận và kiểu trả về của các phép tính đó.

  • Implementation: Implementation biểu diễn nội bộ của cấu trúc dữ liệu. Implementation cũng cung cấp định nghĩa của giải thuật được sử dụng trong các phép tính của cấu trúc dữ liệu.

  • Những đặc tính của cấu trúc dữ liệu

  • Chính xác: Sự triển khai của cấu trúc dữ liệu nên triển khai chính xác:

  • Độ phức tạp về thời gian: Thời gian chạy hay thời gian thực thi của các phép toán cấu trúc dữ liệu phải càng nhỏ càng tốt.

  • Độ phức tạp về không gian: Bộ nhớ sử dụng phép toán của cấu trúc dữ liệu càng ít càng tốt.

  • Sự cần thiết của cấu trúc dữ liệu

    Ngày nay, các ứng dụng trở nên phức tạp và dữ liệu ngày càng nhiều. Có 3 vấn đề thường gặp phải đối mặt với ứng dụng ngày nay là:

  • Tìm kiếm dữ liệu: Giả sử bạn cần tìm một thông tin trong một triệu (10^6) thông tin lưu trữ trong cơ sở dữ liệu. Chúng ta có 1 ứng dụng để tìm kiếm một thông tin trong cơ sở dữ liệu đó. Và mỗi lần tìm kiếm như thế ứng dụng sẽ mất 10^6 lần tìm kiếm. Nếu dữ liệu ngày càng nhiều thì việc tìm kiếm sẽ trở nên chậm chạp hơn.

  • Tốc độ xử lý: Mặc dù những bộ vi xử lý máy tính ngày nay rất nhanh tuy nhiên nó cũng sẽ bị trở nên chậm chạp lượng dữ liệu ngày càng nhiều với hàng tỷ bảng ghi trong cơ sở dữ liệu

  • Đa yêu cầu: Trong một lúc có hàng ngàn người cùng thực hiện một phép tính tìm kiếm trên một máy chủ web thì cho dù máy chủ web đó có nhanh đến mấy thì việc xử lý hàng ngàn phép tính cùng một lúc cũng trở nên khó khăn.

  • Để xử lý các vấn đề trên, cấu trúc dữ liệu và giải thuật là một giải phép tuyệt vời. Dữ liệu có thể được tổ chức theo một cách này đó sao cho việc tìm kiếm một phần tử nào đó thì dữ liệu sẽ tìm thấy ngay lập tức.

    Thời gian thực thi của các trường hợp

    Có 3 khái niệm thường được dùng để so sánh giữa các cấu trúc dữ liệu với nhau một cách tương đối

  • Trường hợp xấu nhất: Đúng với tên gọi đây là trường hợp mà một cấu trúc dữ liệu mất thời gian ta tối đa để nó thực hiện xong.

  • Trường hợp trung bình: Đây là trường hợp mà một cấu trúc dữ liệu mất một thời gian trung bình để thực hiện xong.

  • Trường hợp tốt nhất: Đây là trường hợp mà một cấu trúc dữ liệu mất thời gian ít nhất hay tối ưu nhất để thực hiện xong.

  • Những khái niệm cơ bản:

  • Dữ liệu: Là các giá trị hay các tập hợp.

  • Phần từ dữ liệu: Là đơn vị lẻ của giá trị.

  • Các phần tử nhóm: Là phần tử dữ liệu được chia thành các phần tử con.

  • Các phần tử cơ bản: Là phần tử dữ liệu không thể chia nhỏ thành các phần tử con.

  • Thuộc tính và thực thể: Một thực thể là cái mà chứa một vài thuộc tính và nó có thể được khởi tạo.

  • Tập hợp thực thể: Là tập hợp các thực thể có những điểm chung về thuộc tính.

  • Trường: Là một đơn vị thông tin cơ bản biểu diễn một thuộc tính của thực thể đó.

  • Bản ghi: Là một tập các giá trị trường của một thực thể.

  • Tệp tin: Là một tập hợp các bản ghi của các thực thể trong một tập các thực thể đã cho.

Cài đặt môi trường

Ngôn ngữ C và C++ là ngôn ngữ mà hầu như mọi trường đại học sử dụng để giảng dạy, cho nên trong chương này mình sẽ hướng dẫn các bạn cài đặt C và C++ để làm môi trường chạy các ví dụ trong loạt bài Cấu trúc dữ liệu và giải thuật.

Cài đặt IDE để biên dịch và thực thi C

Có một số IDE có sẵn và miễn phí để biên dịch và thực thi các chương trình C. Bạn có thể chọn Dev-C++, Code:: Blocks, hoặc Turbo C. Tuy nhiên, lựa chọn phổ biến nhất và hay được sử dụng nhất là Dev-C++ và các chương trình C trong loạt bài này cũng được biên dịch và thực thi trong Dev-C++.

Bạn truy cập theo link sau để tải Dev-C++: Tải Dev-C++. Trên trang này cũng bao gồm cả Code:: Blocks. Sau khi bạn tải xong, để cài đặt IDE này, bạn chỉ cần vào Google và gõ "cài đặt dev-c++" là có rất nhiều video hướng dẫn chi tiết, cho nên mình không cần trình bày thêm nữa.

Sau khi đã cài đặt xong, để biên dịch và thực thi một chương trình C, bạn: (a) vào File -> New -> Project -> Console Application -> C project, sau đó nhập tên vào hoặc (b) File -> New -> Source File. Cuối cùng, sao chép và dán chương trình C vào file bạn vừa tạo. Để biên dịch và thực thi, chọn Execute -> Compile & Run.

Cài đặt để chạy trên Command Prompt

Nếu bạn muốn cài đặt để biên dịch và chạy trên Command Prompt, thì bạn nên đọc phần sau đây.
Nếu bạn đang muốn cài đặt chương trình C, bạn cần phải sử dụng 2 phần mềm trên máy tính của bạn: (a) Chương trình soạn văn bản - Text Editor và (b) Bộ biên dịch C.

Text Editor

Được sử dụng để soạn thảo các chương trình. Ví dụ về một vài trình editor như Window Notepad, Notepad ++, vim hay vi…
Tên và các phiên bản của các trình editor có thể thay đổi theo các hệ điều hành. Ví dụ, Notepad được sử dụng trên Windows, hoặc vim hay vi được sử dụng trên Linux hoặc UNIX.
Các file bạn tạo trong trình editor được gọi là source file (file nguồn) và chứa các chương trình code. Các file trong chương trình C thường được đặt tên với phần mở rộng ".c".
Trước khi bắt đầu chương trình của bạn, hãy chắc chắn bạn có một trình editor trên máy tính và bạn có đủ kinh nghiệm để viết các chương trình máy tính, lưu trữ trong file và thực thi nó.

Bộ biên dịch C

Mã nguồn được viết trong file nguồn dưới dạng có thể đọc được. Nó sẽ được biên dịch thành mã máy, để cho CPU có thể thực hiện các chương trình này dựa trên các lệnh được viết.
Bộ biên dịch được sử dụng để biên dịch mã nguồn (source code) của bạn đến chương trình có thể thực thi. Tôi giả sử bạn có kiến thức cơ bản về một bộ biên dịch ngôn ngữ lập trình.
Bộ biên dịch thông dụng nhất là bộ biên dịch GNU C/C++, mặt khác bạn có thể có các bộ biên dịch khác như HP hoặc Solaris với Hệ điều hành tương ứng.
Dưới đây là phần hướng dẫn giúp bạn cách cài đặt bộ biên dich GNU C/C++ trên các hệ điều hành khác nhau. Tôi đang đề cập đến C/C++ bởi vì bộ biên dịch GNU gcc hoạt động cho cả ngôn ngữ C và C++.

Cài đặt trên môi trường UNIX/Linux

Nếu bạn đang sử dụng Linux hoặc UNIX, bạn có thể kiểm tra bộ GCC đã được cài đặt trên môi trường của bạn chưa bằng lệnh sau đây:

$ gcc -v

Nếu bạn có bộ cài đặt GNU trên máy tính của bạn, sau đó nó sẽ phản hồi một thông báo sau:

Using built-in specs.
Target: i386-redhat-linux
Configured with: ../configure --prefix=/usr .......
Thread model: posix
gcc version 4.1.2 20080704 (Red Hat 4.1.2-46)

Nếu bộ GCC chưa được cài đặt, bạn có thể cài đặt nó với hướng dẫn trên đường link dưới đây: http://gcc.gnu.org/install/
Bài hướng dẫn này được viết dựa trên Linux và tất cả các ví dụ dược biên dịch trên Cent OS của hệ thống Linux.
 

Cài đặt trên môi trường Mac OS
Nếu bạn đang sử dụng phiên bản hệ điều hành này thì bạn hãy cài đặt môi trường phát triển Xcode theo link sau: http://developer.apple.com/technologies/tools/
Cài đặt trên Windows

  • Để cài đặt GNU C/C++ trên Windows bạn cần phải cài đặt MinGW trong link sau: http://www.mingw.org/
  • Khi cài đặt bạn tối thiểu phải cài đặt cc-core, gcc-g++, binutils, and the MinGW runtime.
  • Để có thể sử dụng trên Command line một cách thuận tiện bạn nên cài đặt biến môi trường trỏ tới thư mục con bin trong nơi cài đặt của MinGW
  • Khi cài xong bạn sẽ có thể sử dụng các công cụ gcc, g++, ar, ranlib, dlltool và các công cụ khác trên Command line

Giải thuật là gì ?

Giải thuật (hay còn gọi là thuật toán - tiếng Anh là Algorithms) là một tập hợp hữu hạn các chỉ thị để được thực thi theo một thứ tự nào đó để thu được kết quả mong muốn. Nói chung thì giải thuật là độc lập với các ngôn ngữ lập trình, tức là một giải thuật có thể được triển khai trong nhiều ngôn ngữ lập trình khác nhau.

Xuất phát từ quan điểm của cấu trúc dữ liệu, dưới đây là một số giải thuật quan trọng:

  • Giải thuật Tìm kiếm: Giải thuật để tìm kiếm một phần tử trong một cấu trúc dữ liệu.

  • Giải thuật Sắp xếp: Giải thuật để sắp xếp các phần tử theo thứ tự nào đó.

  • Giải thuật Chèn: Giải thuật để chèn phần từ vào trong một cấu trúc dữ liệu.

  • Giải thuật Cập nhật: Giải thuật để cập nhật (hay update) một phần tử đã tồn tại trong một cấu trúc dữ liệu.

  • Giải thuật Xóa: Giải thuật để xóa một phần tử đang tồn tại từ một cấu trúc dữ liệu.

Đặc điểm của giải thuật

Không phải tất cả các thủ tục có thể được gọi là một giải thuật. Một giải thuật nên có các đặc điểm sau:

  • Tính xác định: Giải thuật nên rõ ràng và không mơ hồ. Mỗi một giai đoạn (hay mỗi bước) nên rõ ràng và chỉ mang một mục đích nhất định.

  • Dữ liệu đầu vào xác định: Một giải thuật nên có 0 hoặc nhiều hơn dữ liệu đầu vào đã xác định.

  • Kết quả đầu ra: Một giải thuật nên có một hoặc nhiều dữ liệu đầu ra đã xác định, và nên kết nối với kiểu kết quả bạn mong muốn.

  • Tính dừng: Các giải thuật phải kết thúc sau một số hữu hạn các bước.

  • Tính hiệu quả: Một giải thuật nên là có thể thi hành được với các nguồn có sẵn, tức là có khả năng giải quyết hiệu quả vấn đề trong điều kiện thời gian và tài nguyên cho phép.

  • Tính phổ biến: Một giải thuật có tính phổ biến nếu giải thuật này có thể giải quyết được một lớp các vấn đề tương tự.

  • Độc lập: Một giải thuật nên có các chỉ thị độc lập với bất kỳ phần code lập trình nào.

Cách viết một giải thuật ?

Bạn đừng tìm, bởi vì sẽ không có bất kỳ tiêu chuẩn nào cho trước để viết các giải thuật. Như bạn đã biết, các ngôn ngữ lập trình đều có các vòng lặp (do, for, while) và các lệnh điều khiển luồng (if-else), … Bạn có thể sử dụng những lệnh này để viết một giải thuật.

Chúng ta viết các giải thuật theo cách thức là theo từng bước một. Viết giải thuật là một tiến trình và được thực thi sau khi bạn đã định vị rõ ràng vấn đề cần giải quyết. Từ việc định vị vấn đề, chúng ta sẽ thiết kế ra giải pháp để giải quyết vấn đề đó và sau đó là viết giải thuật.

Ví dụ viết giải thuật

Bạn theo dõi ví dụ minh họa dưới đây để thấy rõ các bước và cách viết một giải thuật. Tất nhiên là ví dụ dưới đây là khá đơn giản vì đây chỉ là ví dụ minh họa mở đầu cho cách viết giải thuật thôi, nên mình nghĩ càng đơn giản sẽ càng tốt.

Bài toán: Thiết kế một giải thuật để cộng hai số và hiển thị kết quả.

Bước 1: Bắt đầu
Bước 2: Khai báo ba số a, b & c
Bước 3: Định nghĩa các giá trị của a & b
Bước 4: Cộng các giá trị của a & b
Bước 5: Lưu trữ kết quả của Bước 4 vào biến c
Bước 6: In biến c
Bước 7: Kết thúc

 Các giải thuật nói cho lập trình viên cách để viết code. Ngoài ra, bạn cũng có thể viết một giải thuật cho bài toán trên như sau:

Bước 1: Bắt đầu
Bước 2: Lấy giá trị của a & b
Bước 3: c ← a + b
Bước 4: Hiển thị c
Bước 5: Kết thúc

rong khi thiết kế và phân tích các giải thuật, thường thì phương thức thứ hai được sử dụng để miêu tả một giải thuật. Cách thứ hai này giúp dễ dàng phân tích giải thuật khi đã bỏ qua các phần định nghĩa không cần thiết. Nhìn vào cách thứ hai này, người ta có thể biết các phép tính nào đang được sử dụng và cách tiến trình thực thi.

Tất nhiên, việc viết tên các bước là tùy ý.

Chúng ta viết một giải thuật để tìm giải pháp để xử lý một bài toán nào đó. Một bài toán có thể được giải theo nhiều cách khác nhau.

Do đó, một bài toán có thể sẽ có nhiều lời giải. Vậy lời giải nào sẽ là thích hợp nhất cho bài toán đó. Mời bạn tiếp tục theo dõi.

Phân tích giải thuật

Hiệu quả của một giải thuật có thể được phân tích dựa trên 2 góc độ: trước khi triển khai và sau khi triển khai:

  • Phân tích lý thuyết: Có thể coi đây là phân tích chỉ dựa trên lý thuyết. Hiệu quả của giải thuật được đánh giá bằng việc giả sử rằng tất cả các yếu tố khác (ví dụ: tốc độ vi xử lý, …) là hằng số và không ảnh hưởng tới sự triển khai giải thuật.

  • Phân tích tiệm cận: Việc phân tích giải thuật này được tiến hành sau khi đã tiến hành trên một ngôn ngữ lập trình nào đó. Sau khi chạy và kiểm tra đo lường các thông số liên quan thì hiệu quả của giải thuật dựa trên các thông số như thời gian chạy, thời gian thực thi, lượng bộ nhớ cần dùng, …

Chương này chúng ta sẽ tìm hiểu phân tích lý thuyết. Còn phân tích tiệm cận chúng ta sẽ cùng tìm hiểu ở chương tiếp theo.

Độ phức tạp giải thuật (Algorithm Complexity)

Về bản chất, độ phức tạp giải thuật là một hàm ước lượng (có thể không chính xác) số phép tính mà giải thuật cần thực hiện (từ đó dễ dàng suy ra thời gian thực hiện của giải thuật) đối với bộ dữ liệu đầu vào (Input) có kích thước n. Trong đó, n có thể là số phần tử của mảng trong trường hợp bài toán sắp xếp hoặc tìm kiếm, hoặc có thể là độ lớn của số trong bài toán kiểm tra số nguyên tố, …

Giả sử X là một giải thuật và n là kích cỡ của dữ liệu đầu vào. Thời gian và lượng bộ nhớ được sử dụng bởi giải thuật X là hai nhân tố chính quyết định hiệu quả của giải thuật X:

  • Nhân tố thời gian: Thời gian được đánh giá bằng việc tính số phép tính chính (chẳng hạn như các phép so sánh trong thuật toán sắp xếp).

  • Nhân tố bộ nhớ: Lượng bộ nhớ được đánh giá bằng việc tính lượng bộ nhớ tối đa mà giải thuật cần sử dụng.

Độ phức tạp của một giải thuật (một hàm f(n)) cung cấp mối quan hệ giữa thời gian chạy và/hoặc lượng bộ nhớ cần được sử dụng bởi giải thuật.

Độ phức tạp bộ nhớ (Space complexity) trong phân tích giải thuật

Nhân tố bộ nhớ của một giải thuật biểu diễn lượng bộ nhớ mà một giải thuật cần dùng trong vòng đời của giải thuật. Lượng bộ nhớ (giả sử gọi là S(P)) mà một giải thuật cần sử dụng là tổng của hai thành phần sau:

  • Phần cố định (giả sử gọi là C) là lượng bộ nhớ cần thiết để lưu giữ dữ liệu và các biến nào đó (phần này độc lập với kích cỡ của vấn đề). Ví dụ: các biến và các hằng đơn giản, …

  • Phần biến đổi (giả sử gọi là SP(I)) là lượng bộ nhớ cần thiết bởi các biến, có kích cỡ phụ thuộc vào kích cỡ của vấn đề. Ví dụ: cấp phát bộ nhớ động, cấp phát bộ nhớ đệ qui, …

Từ trên, ta sẽ có S(P) = C + SP(I). Bạn theo dõi ví dụ đơn giản sau:

Giải thuật: tìm tổng hai số A, B
Step 1 -  Bắt đầu
Step 2 -  C ← A + B + 10
Step 3 -  Kết thúc

Ở đây chúng ta có ba biến A, B và C và một hằng số. Do đó: S(P) = 1+3.

Bây giờ, lượng bộ nhớ sẽ phụ thuộc vào kiểu dữ liệu của các biến và hằng đã cho và sẽ bằng tích của tổng trên với bộ nhớ cho kiểu dữ liệu tương ứng.

Độ phức tạp thời gian (Time Complexity) trong phân tích giải thuật

Nhân tố thời gian của một giải thuật biểu diễn lượng thời gian chạy cần thiết từ khi bắt đầu cho đến khi kết thúc một giải thuật. Thời gian yêu cầu có thể được biểu diễn bởi một hàm T(n), trong đó T(n) có thể được đánh giá như là số các bước.

Ví dụ, phép cộng hai số nguyên n-bit sẽ có n bước. Do đó, tổng thời gian tính toán sẽ là T(n) = c*n, trong đó c là thời gian để thực hiện phép cộng hai bit. Ở đây, chúng ta xem xét hàm T(n) tăng tuyến tính khi kích cỡ dữ liệu đầu vào tăng lên.

Phân tích tiệm cận là gì ?

Phân tích tiệm cận của một giải thuật là khái niệm giúp chúng ta ước lượng được thời gian chạy (Running Time) của một giải thuật. Sử dụng phân tích tiệm cận, chúng ta có thể đưa ra kết luận tốt nhất về các tình huống trường hợp tốt nhất, trường hợp trung bình, trường hợp xấu nhất của một giải thuật. Để tham khảo về các trường hợp này, bạn có thể tìm hiểu chương Cấu trúc dữ liệu là gì ?.

Phân tích tiệm cận tức là tiệm cận dữ liệu đầu vào (Input), tức là nếu giải thuật không có Input thì kết luận cuỗi cùng sẽ là giải thuật sẽ chạy trong một lượng thời gian cụ thể và là hằng số. Ngoài nhân tố Input, các nhân tố khác được xem như là không đổi.

Phân tích tiệm cận nói đến việc ước lượng thời gian chạy của bất kỳ phép tính nào trong các bước tính toán. Ví dụ, thời gian chạy của một phép tính nào đó được ước lượng là một hàm f(n) và với một phép tính khác là hàm g(n2). Điều này có nghĩa là thời gian chạy của phép tính đầu tiên sẽ tăng tuyến tính với sự tăng lên của n và thời gian chạy của phép tính thứ hai sẽ tăng theo hàm mũ khi n tăng lên. Tương tự, khi n là khá nhỏ thì thời gian chạy của hai phép tính là gần như nhau.

Thường thì thời gian cần thiết bởi một giải thuật được chia thành 3 loại:

  • Trường hợp tốt nhất: là thời gian nhỏ nhất cần thiết để thực thi chương trình.

  • Trường hợp trung bình: là thời gian trung bình cần thiết để thực thi chương trình.

  • Trường hợp xấu nhất: là thời gian tối đa cần thiết để thực thi chương trình.

Asymptotic Notation trong Cấu trúc dữ liệu và giải thuật

Dưới đây là các Asymptotic Notation được sử dụng phổ biến trong việc ước lượng độ phức tạp thời gian chạy của một giải thuật:

  • Ο Notation

  • Ω Notation

  • θ Notation

Big Oh Notation, Ο trong Cấu trúc dữ liệu và giải thuật

Ο(n) là một cách để biểu diễn tiệm cận trên của thời gian chạy của một thuật toán. Nó ước lượng độ phức tạp thời gian trường hợp xấu nhất hay chính là lượng thời gian dài nhất cần thiết bởi một giải thuật (thực thi từ bắt đầu cho đến khi kết thúc). Đồ thị biểu diễn như sau:

Ví dụ, gọi f(n) và g(n) là các hàm không giảm định nghĩa trên các số nguyên dương (tất cả các hàm thời gian đều thỏa mãn các điều kiện này):

Ο(f(n)) = { g(n) : nếu tồn tại c > 0 và n0 sao cho g(n) ≤ c.f(n) với mọi n > n0. }

 Omega Notation, Ω trong Cấu trúc dữ liệu và giải thuật

The Ω(n) là một cách để biểu diễn tiệm cận dưới của thời gian chạy của một giải thuật. Nó ước lượng độ phức tạp thời gian trường hợp tốt nhất hay chính là lượng thời gian ngắn nhất cần thiết bởi một giải thuật. Đồ thị biểu diễn như sau:

Ví dụ, với một hàm f(n):

Ω(f(n)) ≥ { g(n) : nếu tồn tại c > 0 và n0 sao cho g(n) ≤ c.f(n) với mọi n > n0. }

Theta Notation, θ trong Cấu trúc dữ liệu và giải thuật

The θ(n) là cách để biểu diễn cả tiệm cận trên và tiệm cận dưới của thời gian chạy của một giải thuật. Bạn nhìn vào đồ thì sau:

​​​​​​​θ(f(n)) = { g(n) nếu và chỉ nếu g(n) = Ο(f(n)) và g(n) = Ω(f(n)) với mọi n > n0. }

Một số Asymptotic Notation phổ biến trong cấu trúc dữ liệu và giải thuật

hằng số Ο(1)
logarit Ο(log n)
Tuyến tính (Linear) Ο(n)
n log n Ο(n log n)
Bậc hai (Quadratic) Ο(n2)
Bậc 3 (cubic) Ο(n3)
Đa thức (polynomial) nΟ(1)
Hàm mũ (exponential) 2Ο(n)

Giải thuật tham lam là gì ?

Tham lam (hay tham ăn) là một trong những phương pháp phổ biến nhất để thiết kế giải thuật. Nếu bạn đã đọc truyện dân gian thì sẽ có câu chuyện như thế này: trên một mâm cỗ có nhiều món ăn, món nào ngon nhất ta sẽ ăn trước, ăn hết món đó ta sẽ chuyển sang món ngon thứ hai, và chuyển tiếp sang món thứ ba, …

Rất nhiều giải thuật nổi tiếng được thiết kế dựa trên ý tưởng tham lam, ví dụ như giải thuật cây khung nhỏ nhất của Dijkstra, giải thuật cây khung nhỏ nhất của Kruskal, …

Giải thuật tham lam (Greedy Algorithm) là giải thuật tối ưu hóa tổ hợp. Giải thuật tìm kiếm, lựa chọn giải pháp tối ưu địa phương ở mỗi bước với hi vọng tìm được giải pháp tối ưu toàn cục.

Giải thuật tham lam lựa chọn giải pháp nào được cho là tốt nhất ở thời điểm hiện tại và sau đó giải bài toán con nảy sinh từ việc thực hiện lựa chọn đó. Lựa chọn của giải thuật tham lam có thể phụ thuộc vào lựa chọn trước đó. Việc quyết định sớm và thay đổi hướng đi của giải thuật cùng với việc không bao giờ xét lại các quyết định cũ sẽ dẫn đến kết quả là giải thuật này không tối ưu để tìm giải pháp toàn cục.

Bạn theo dõi một bài toán đơn giản dưới đây để thấy cách thực hiện giải thuật tham lam và vì sao lại có thể nói rằng giải thuật này là không tối ưu.

Bài toán đếm số đồng tiền

Yêu cầu là hãy lựa chọn số lượng đồng tiền nhỏ nhất có thể sao cho tổng mệnh giá của các đồng tiền này bằng với một lượng tiền cho trước.

Nếu tiền đồng có các mệnh giá lần lượt là 1, 2, 5, và 10 xu và lượng tiền cho trước là 18 xu thì giải thuật tham lam thực hiện như sau:

  • Bước 1: Chọn đồng 10 xu, do đó sẽ còn 18 – 10 = 8 xu.

  • Bước 2: Chọn đồng 5 xu, do đó sẽ còn là 3 xu.

  • Bước 3: Chọn đồng 2 xu, còn lại là 1 xu.

  • Bước 4: Cuối cùng chọn đồng 1 xu và giải xong bài toán.

Bạn thấy rằng cách làm trên là khá ổn, và số lượng đồng tiền cần phải lựa chọn là 4 đồng tiền. Nhưng nếu chúng ta thay đổi bài toán trên một chút thì cũng hướng tiếp cận như trên có thể sẽ không đem lại cùng kết quả tối ưu.

Chẳng hạn, một hệ thống tiền tệ khác có các đồng tiền có mệnh giá lần lượt là 1, 7 và 10 xu và lượng tiền cho trước ở đây thay đổi thành 15 xu thì theo giải thuật tham lam thì số đồng tiền cần chọn sẽ nhiều hơn 4. Với giải thuật tham lam thì: 10 + 1 + 1 +1 + 1 + 1, vậy tổng cộng là 6 đồng tiền. Trong khi cùng bài toán như trên có thể được xử lý bằng việc chỉ chọn 3 đồng tiền (7 + 7 +1).

Do đó chúng ta có thể kết luận rằng, giải thuật tham lam tìm kiếm giải pháp tôi ưu ở mỗi bước nhưng lại có thể thất bại trong việc tìm ra giải pháp tối ưu toàn cục.

Ví dụ áp dụng giải thuật tham lam

Có khá nhiều giải thuật nổi tiếng được thiết kế dựa trên tư tưởng của giải thuật tham lam. Dưới đây là một trong số các giải thuật này:

  • Bài toán hành trình người bán hàng
  • Giải thuật cây khung nhỏ nhất của Prim
  • Giải thuật cây khung nhỏ nhất của Kruskal
  • Giải thuật cây khung nhỏ nhất của Dijkstra
  • Bài toán xếp lịch công việc
  • Bài toán xếp ba lô

Giải thuật chia để trị (Divide and Conquer)là gì ?

Phương pháp chia để trị (Divide and Conquer) là một phương pháp quan trọng trong việc thiết kế các giải thuật. Ý tưởng của phương pháp này khá đơn giản và rất dễ hiểu: Khi cần giải quyết một bài toán, ta sẽ tiến hành chia bài toán đó thành các bài toán con nhỏ hơn. Tiếp tục chia cho đến khi các bài toán nhỏ này không thể chia thêm nữa, khi đó ta sẽ giải quyết các bài toán nhỏ nhất này và cuối cùng kết hợp giải pháp của tất cả các bài toán nhỏ để tìm ra giải pháp của bài toán ban đầu.

 

Tiến trình 1: Chia nhỏ (Divide/Break)

Trong bước này, chúng ta chia bài toán ban đầu thành các bài toán con. Mỗi bài toán con nên là một phần của bài toán ban đầu. Nói chung, bước này sử dụng phương pháp đệ qui để chia nhỏ các bài toán cho đến khi không thể chia thêm nữa. Khi đó, các bài toán con được gọi là "atomic – nguyên tử", nhưng chúng vẫn biểu diễn một phần nào đó của bài toán ban đầu.

Tiến trình 2: Giải bài toán con (Conquer/Solve)

Trong bước này, các bài toán con được giải.

Tiến trình 3: Kết hợp lời giải (Merge/Combine)
Sau khi các bài toán con đã được giải, trong bước này chúng ta sẽ kết hợp chúng một cách đệ qui để tìm ra giải pháp cho bài toán ban đầu.

Hạn chế của giải thuật chia để trị (Devide and Conquer)
Giải thuật chia để trị tồn tại hai hạn chế, đó là:

Làm thế nào để chia tách bài toán một cách hợp lý thành các bài toán con, bởi vì nếu các bài toán con được giải quyết bằng các thuật toán khác nhau thì sẽ rất phức tạp.

Việc kết hợp lời giải các bài toán con được thực hiện như thế nào.

Ví dụ giải thuật chia để trị

Dưới đây là một số giải thuật được xây dựng dựa trên phương pháp chia để trị (Divide and Conquer):

  • Giải thuật sắp xếp trộn (Merge Sort)
  • Giải thuật sắp xếp nhanh (Quick Sort)
  • Giải thuật tìm kiếm nhị phân (Binary Search)
  • Nhân ma trận của Strassen

Giải thuật Qui hoạch động (Dynamic Programming) là gì ?

Giải thuật Qui hoạch động (Dynamic Programming) giống như giải thuật chia để trị (Divide and Conquer) trong việc chia nhỏ bài toán thành các bài toán con nhỏ hơn và sau đó thành các bài toán con nhỏ hơn nữa có thể. Nhưng không giống chia để trị, các bài toán con này không được giải một cách độc lập. Thay vào đó, kết quả của các bài toán con này được lưu lại và được sử dụng cho các bài toán con tương tự hoặc các bài toán con gối nhau (Overlapping Sub-problems).

Chúng ta sử dụng Qui hoạch động (Dynamic Programming) khi chúng ta có các bài toán mà có thể được chia thành các bài toán con tương tự nhau, để mà các kết quả của chúng có thể được tái sử dụng. Thường thì các giải thuật này được sử dụng cho tối ưu hóa. Trước khi giải bài toán con, giải thuật Qui hoạch động sẽ cố gắng kiểm tra kết quả của các bài toán con đã được giải trước đó. Các lời giải của các bài toán con sẽ được kết hợp lại để thu được lời giải tối ưu.

Do đó, chúng ta có thể nói rằng:

  • Bài toán ban đầu nên có thể được phân chia thành các bài toán con gối nhau nhỏ hơn.

  • Lời giải tối ưu của bài toán có thể thu được bởi sử dụng lời giải tối ưu của các bài toán con.

  • Giải thuật Qui hoạch động sử dụng phương pháp lưu trữ (Memoization) – tức là chúng ta lưu trữ lời giải của các bài toán con đã giải, và nếu sau này chúng ta cần giải lại chính bài toán đó thì chúng ta có thể lấy và sử dụng kết quả đã được tính toán.

So sánh

Giải thuật tham lam và giải thuật qui hoạch động

  • Giải thuật tham lam (Greedy Algorithms) là giải thuật tìm kiếm, lựa chọn giải pháp tối ưu địa phương ở mỗi bước với hi vọng tìm được giải pháp tối ưu toàn cục.

  • Giải thuật Qui hoạch động tối ưu hóa các bài toán con gối nhau.

Giải thuật chia để trị và giải thuật Qui hoạch động:

  • Giải thuật chia để trị (Divide and Conquer) là kết hợp lời giải của các bài toán con để tìm ra lời giải của bài toán ban đầu.

  • Giải thuật Qui hoạch động sử dụng kết quả của bài toán con và sau đó cố gắng tối ưu bài toán lớn hơn. Giải thuật Qui hoạch động sử dụng phương pháp lưu trữ (Memoization) để ghi nhớ kết quả của các bài toán con đã được giải.

 

Ví dụ giải thuật Qui hoạch động

Dưới đây là một số bài toán có thể được giải bởi sử dụng giải thuật Qui hoạch động:

  • Dãy Fibonacci
  • Bài toán tháp Hà Nội (Tower of Hanoi)
  • Bài toán ba lô
  • ...

Giải thuật Qui hoạch động có thể được sử dụng trong cả hai phương pháp Phân tích (Top-down) và Qui nạp (Bottom-up). Và tất nhiên là nếu dựa vào vòng đời làm việc của CPU thì việc tham chiếu tới kết quả của lời giải trước đó là ít tốn kém hơn việc giải lại bài toán.

Giải thuật Định lý thợ (Master Theorem) là gì ?

Chúng ta sử dụng Định lý thợ (Master Theorem) để giải các công thức đệ quy dạng sau một cách hiệu quả :

T(n) =aT(n/b) + c.n^k trong đó a>=1 , b>1

  • Bài toán ban đầu được chia thành a bài toán con có kích thước mỗi bài là n/b, chi phí để tổng hợp các bài toán con là f(n).

  • Ví dụ : Thuật toán sắp xếp trộn chia thành 2 bài toán con , kích thước n/2. Chi phí tổng hợp 2 bài toán con là O(n).

Định lý thợ

a>=1, b>1, c, k là các hằng số. T(n) định nghĩa đệ quy trên các tham số không âm

T(n) = aT(n/b) + c.n^k + Nếu a> b^k thì T(n) =O(n^ (logab)) + Nếu a= b^k thì T(n)=O(n^k.lgn) + Nếu a< b^k thì T(n) = O(n^k)

Chú ý: Không phải trường hợp nào cũng áp dụng được định lý thợ

VD : T(n) = 2T(n/2) +nlogn a =2, b =2, nhưng không xác định được số nguyên k

 

Cấu trúc dữ liệu mảng là gì ?

Mảng (Array) là một trong các cấu trúc dữ liệu cũ và quan trọng nhất. Mảng có thể lưu giữ một số phần tử cố định và các phần tử này nền có cùng kiểu. Hầu hết các cấu trúc dữ liệu đều sử dụng mảng để triển khai giải thuật. Dưới đây là các khái niệm quan trọng liên quan tới Mảng.

  • Phần tử: Mỗi mục được lưu giữ trong một mảng được gọi là một phần tử.

  • Chỉ mục (Index): Mỗi vị trí của một phần tử trong một mảng có một chỉ mục số được sử dụng để nhận diện phần tử.

Mảng gồm các bản ghi có kiểu giống nhau, có kích thước cố định, mỗi phần tử được xác định bởi chỉ số

Mảng là cấu trúc dữ liệu được cấp phát lien tục cơ bản

Ưu điểm của mảng :

Truy câp phàn tử vơi thời gian hằng số O(1)

Sử dụng bộ nhớ hiệu quả

Tính cục bộ về bộ nhớ

Nhược điểm

Không thể thay đổi kích thước của mảng khi chương trình dang thực hiện

Mảng động

Mảng động (dynamic aray) : cấp phát bộ nhớ cho mảng một cách động trong quá trình chạy chương trình trong C là malloc và calloc, trong C++ là new

Sử dụng mảng động ta bắt đầu với mảng có 1 phàn tử, khi số lượng phàn tử vượt qua khả năng của ảng thì ta gấp đôi kích thước mảng cuc và copy phàn tử mảng cũ vào nửa đầu của mảng mới

Ưu điểm : tránh lãng phí bộ nhớ khi phải khai báo mảng có kích thước lớn ngay từ đầu

Nhược điểm: + phải thực hiện them thao tác copy phần tử mỗi khi thay đổi kích thước. + một số thời gian thực hiện thao tác không còn là hằng số nữa

Biểu diễn Cấu trúc dữ liệu mảng

Mảng có thể được khai báo theo nhiều cách đa dạng trong các ngôn ngữ lập trình. Để minh họa, chúng ta sử dụng phép khai báo mảng trong ngôn ngữ C:

Hình minh họa phần tử và chỉ mục:

Dưới đây là một số điểm cần ghi nhớ về cấu trúc dữ liệu mảng:

  • Chỉ mục bắt đầu với 0.

  • Độ dài mảng là 10, nghĩa là mảng có thể lưu giữ 10 phần tử.

  • Mỗi phần tử đều có thể được truy cập thông qua chỉ mục của phần tử đó. Ví dụ, chúng ta có thể lấy giá trị của phần tử tại chỉ mục 6 là 27.

Phép toán cơ bản được hỗ trợ bởi mảng

Dưới đây là các hoạt động cơ bản được hỗ trợ bởi một mảng:

  • Duyệt: In tất cả các phần tử mảng theo cách in từng phần tử một.

  • Chèn: Thêm một phần tử vào mảng tại chỉ mục đã cho.

  • Xóa: Xóa một phần tử từ mảng tại chỉ mục đã cho.

  • Tìm kiếm: Tìm kiếm một phần tử bởi sử dụng chỉ mục hay bởi giá trị.

  • Cập nhật: Cập nhật giá trị một phần tử tại chỉ mục nào đó.

Trong ngôn ngữ C, khi một mảng được khởi tạo với kích cỡ ban đầu, thì nó gán các giá trị mặc định cho các phần tử của mảng theo thứ tự sau:

Kiểu dữ liệu Giá trị mặc định
bool false
char 0
int 0
float 0.0
double 0.0f
void  
wchar_t 0

Chèn phần tử vào mảng

Hoạt động chèn là để chèn một hoặc nhiều phần tử dữ liệu vào trong một mảng. Tùy theo yêu cầu, phần tử mới có thể được chèn vào vị trí đầu, vị trí cuối hoặc bất kỳ vị trí chỉ mục đã cho nào của mảng.
Phần tiếp theo chúng ta sẽ cùng triển khai hoạt động chèn trong một ví dụ thực. Trong ví dụ này, chúng ta sẽ chèn dữ liệu vào cuối mảng.
Ví dụ

Giả sử LA là một mảng tuyến tính không có thứ tự có N phần tử và K là một số nguyên dương thỏa mãn K <= N. Dưới đây là giải thuật chèn phần tử A vào vị trí thứ K của mảng LA.

Giải thuật

1. Bắt đầu
2. Gán J=N
3. Gán N = N+1
4. Lặp lại bước 5 và 6 khi J >= K
5. Gán LA[J+1] = LA[J]
6. Gán J = J-1
7. Gán LA[K] = ITEM
8. Kết thúc

Sau đây là code đầy đủ của giải thuật trên trong ngôn ngữ C:
 

#include <stdio.h>
main() {
   int LA[] = {1,3,5,7,8};
   int item = 10, k = 3, n = 5;
   int i = 0, j = n;
   
   printf("Danh sach phan tu trong mang ban dau:\n");
	
   for(i = 0; i<n; i++) {
      printf("LA[%d] = %d \n", i, LA[i]);
   }
    
   n = n + 1;
	
   while( j >= k){
      LA[j+1] = LA[j];
      j = j - 1;
   }
	
   LA[k] = item;
   
   printf("Danh sach phan tu cua mang sau hoat dong chen:\n");
	
   for(i = 0; i<n; i++) {
      printf("LA[%d] = %d \n", i, LA[i]);
   }
}

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Xóa phần tử từ mảng

Hoạt động xóa là xóa một phần tử đang tồn tại từ một mảng và tổ chức lại các phần tử còn lại trong mảng đó.

Ví dụ

Giả sử LA là một mảng tuyến tính có N phần tử và K là số nguyên dương thỏa mãn K <= N. Dưới đây là thuật toán để xóa một phần tử có trong mảng LA tại vị trí K.

Giải thuật

1. Bắt đầu
2. Gán J=K
3. Lặp lại bước 4 và 5 trong khi J < N
4. Gán LA[J-1] = LA[J]
5. Gán J = J+1
6. Gán N = N-1
7. Kết thúc

Sau đây là code đầy đủ của giải thuật trên trong ngôn ngữ C:

#include <stdio.h>
main() {
   int LA[] = {1,3,5,7,8};
   int k = 3, n = 5;
   int i, j;
   
   printf("Danh sach phan tu trong mang ban dau:\n");
	
   for(i = 0; i<n; i++) {
      printf("LA[%d] = %d \n", i, LA[i]);
   }
    
   j = k;
	
   while( j < n){
      LA[j-1] = LA[j];
      j = j + 1;
   }
	
   n = n -1;
   
   printf("Danh sach phan tu trong mang sau hoat dong xoa:\n");
	
   for(i = 0; i<n; i++) {
      printf("LA[%d] = %d \n", i, LA[i]);
   }
}

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Tìm kiếm phần tử trong mảng

Bạn có thể thực hiện hoạt động tìm kiếm phần tử trong mảng dựa vào giá trị hay chỉ mục của phần tử đó.

Ví dụ

Giả sử LA là một mảng tuyến tính có N phần tử và K là số nguyên dương thỏa mãn K <= N. Dưới đây là giải thuật để tìm một phần tử ITEM bởi sử dụng phương pháp tìm kiếm tuần tự (hay tìm kiếm tuyến tính).

Giải thuật

1. Bắt đầu
2. Gán J=0
3. Lặp lại bước 4 và 5 khi J < N
4. Nếu LA[J] là bằng ITEM THÌ TỚI BƯỚC 6
5. Gán J = J +1
6. In giá trị J, ITEM
7. Kết thúc

Sau đây là code đầy đủ của giải thuật trên trong ngôn ngữ C:

#include <stdio.h>
main() {
   int LA[] = {1,3,5,7,8};
   int item = 5, n = 5;
   int i = 0, j = 0;
   
   printf("Danh sach phan tu trong mang ban dau:\n");
	
   for(i = 0; i<n; i++) {
      printf("LA[%d] = %d \n", i, LA[i]);
   }
    
   while( j < n){
	
      if( LA[j] == item ){
         break;
      }
		
      j = j + 1;
   }
	
   printf("Tim thay phan tu %d tai vi tri %d\n", item, j+1);
}

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Cập nhật trong mảng

Hoạt động cập nhật là update giá trị của phần tử đang tồn tại trong mảng tại chỉ mục đã cho.

Giải thuật

Giả sử LA là một mảng tuyến tính có N phần tử và K là số nguyên dương thỏa mãn K <= N. Dưới đây là giải thuật để update giá trị phần tử tại vị trí K của mảng LA.

1. Bắt đầu
2. Thiết lập LA[K-1] = ITEM
3. Kết thúc

Sau đây là code đầy đủ của giải thuật trên trong ngôn ngữ C:
 

#include <stdio.h>
main() {
   int LA[] = {1,3,5,7,8};
   int k = 3, n = 5, item = 10;
   int i, j;
   
   printf("Danh sach phan tu trong mang ban dau:\n");
	
   for(i = 0; i<n; i++) {
      printf("LA[%d] = %d \n", i, LA[i]);
   }
    
   LA[k-1] = item;

   printf("Danh sach phan tu trong mang sau hoat dong update:\n");
	
   for(i = 0; i<n; i++) {
      printf("LA[%d] = %d \n", i, LA[i]);
   }
}

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Danh sách liên kết (Linked List) là gì ?

Một Danh sách liên kết (Linked List) là một dãy các cấu trúc dữ liệu được kết nối với nhau thông qua các liên kết (link). Hiểu một cách đơn giản thì Danh sách liên kết là một cấu trúc dữ liệu bao gồm một nhóm các nút (node) tạo thành một chuỗi. Mỗi nút gồm dữ liệu ở nút đó và tham chiếu đến nút kế tiếp trong chuỗi.

Danh sách liên kết là cấu trúc dữ liệu được sử dụng phổ biến thứ hai sau mảng. Dưới đây là các khái niệm cơ bản liên quan tới Danh sách liên kết:

  • Link (liên kết): mỗi link của một Danh sách liên kết có thể lưu giữ một dữ liệu được gọi là một phần tử.

  • Next: Mỗi liên kết của một Danh sách liên kết chứa một link tới next link được gọi là Next.

  • First: một Danh sách liên kết bao gồm các link kết nối tới first link được gọi là First.

Biểu diễn Danh sách liên kết (Linked List)

Danh sách liên kết có thể được biểu diễn như là một chuỗi các nút (node). Mỗi nút sẽ trỏ tới nút kế tiếp.
 

Biểu diễn Danh sách liên kết (Linked List)

Danh sách liên kết có thể được biểu diễn như là một chuỗi các nút (node). Mỗi nút sẽ trỏ tới nút kế tiếp.

Cấu trúc dữ liệu Danh sách liên kết (Linked List)

Dưới đây là một số điểm cần nhớ về Danh sách liên kết:

  • Danh sách liên kết chứa một phần tử link thì được gọi là First.
  • Mỗi link mang một trường dữ liệu và một trường link được gọi là Next.
  • Mỗi link được liên kết với link kế tiếp bởi sử dụng link kế tiếp của nó.
  • Link cuối cùng mang một link là null để đánh dấu điểm cuối của danh sách.

Các loại Danh sách liên kết (Linked List)

Dưới đây là các loại Danh sách liên kết (Linked List) đa dạng:

  • Danh sách liên kết đơn (Simple Linked List): chỉ duyệt các phần tử theo chiều về trước.

  • Danh sách liên kết đôi (Doubly Linked List): các phần tử có thể được duyệt theo chiều về trước hoặc về sau.

  • Danh sách liên kết vòng (Circular Linked List): phần tử cuối cùng chứa link của phần tử đầu tiên như là next và phần tử đầu tiên có link tới phần tử cuối cùng như là prev.

Các hoạt động cơ bản trên Danh sách liên kết

Dưới đây là một số hoạt động cơ bản có thể được thực hiện bởi một danh sách liên kết:

  • Hoạt động chèn: thêm một phần tử vào đầu danh sách liên kết.

  • Hoạt động xóa (phần tử đầu): xóa một phần tử tại đầu danh sách liên kết.

  • Hiển thị: hiển thị toàn bộ danh sách.

  • Hoạt động tìm kiếm: tìm kiếm phần tử bởi sử dụng khóa (key) đã cung cấp.

  • Hoạt động xóa (bởi sử dụng khóa): xóa một phần tử bởi sử dụng khóa (key) đã cung cấp.

 

Chèn trong Danh sách liên kết

Việc thêm một nút mới vào trong danh sách liên kết không chỉ là một hoạt động thêm đơn giản như trong các cấu trúc dữ liệu khác (bởi vì chúng ta có dữ liệu và có link). Chúng ta sẽ tìm hiểu thông qua sơ đồ dưới đây. Đầu tiên, tạo một nút bởi sử dụng cùng cấu trúc và tìm vị trí để chèn nút này.

Hoạt động chèn trong Danh sách liên kết

Giả sử chúng ta cần chèn một nút B vào giữa nút A (nút trái) và C (nút phải). Do đó: B.next trỏ tới C.

NewNode.next −> RightNode;

 

Hình minh họa như sau:

Hoạt động chèn trong Danh sách liên kết

Bây giờ, next của nút bên trái sẽ trở tới nút mới.

LeftNode.next −> NewNode;

Hoạt động chèn trong Danh sách liên kết

Quá trình trên sẽ đặt nút mới vào giữa hai nút. Khi đó danh sách mới sẽ trông như sau:

Hoạt động chèn trong Danh sách liên kết

Các bước tương tự sẽ được thực hiện nếu chèn nút vào đầu danh sách liên kết. Trong khi đặt một nút vào vị trí cuối của danh sách, thìnút thứ hai tính từ nút cuối cùng của danh sách sẽ trỏ tới nút mới và nút mới sẽ trỏ tới NULL.

Chương trình minh họa Danh sách liên kết (Linked List) trong C
 

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>

struct node  
{
   int data;
   int key;
   struct node *next;
};

struct node *head = NULL;
struct node *current = NULL;

//hien thi danh sach
void printList()
{
   struct node *ptr = head;
   printf("\n[ ");
	
   //bat dau tu phan dau danh sach
   while(ptr != NULL)
	{        
      printf("(%d,%d) ",ptr->key,ptr->data);
      ptr = ptr->next;
   }
	
   printf(" ]");
}

//chen link tai vi tri dau tien
void insertFirst(int key, int data)
{
   //tao mot link
   struct node *link = (struct node*) malloc(sizeof(struct node));
	
   link->key = key;
   link->data = data;
	
   //tro link nay toi first node cu
   link->next = head;
	
   //tro first toi first node moi
   head = link;
}

//xoa phan tu dau tien
struct node* deleteFirst()
{

   //luu tham chieu toi first link
   struct node *tempLink = head;
	
   //danh dau next toi first link la first 
   head = head->next;
	
   //tra ve link bi xoa
   return tempLink;
}

//kiem tra list co trong hay khong
bool isEmpty()
{
   return head == NULL;
}

int length()
{
   int length = 0;
   struct node *current;
	
   for(current = head; current != NULL; current = current->next)
	{
      length++;
   }
	
   return length;
}

//tim mot link voi key da cho
struct node* find(int key){

   //bat dau tim tu first link
   struct node* current = head;

   //neu list la trong
   if(head == NULL)
	{
      return NULL;
   }

   //duyet qua list
   while(current->key != key){
	
      //neu day la last node
      if(current->next == NULL){
         return NULL;
      }else {
         //di chuyen toi next link
         current = current->next;
      }
   }      
	
   //neu tim thay du lieu, tra ve link hien tai
   return current;
}

//xoa mot link voi key da cho
struct node* deleteKey(int key){

   //bat dau tu first link
   struct node* current = head;
   struct node* previous = NULL;
	
   //neu list la trong
   if(head == NULL){
      return NULL;
   }

   //duyet qua list
   while(current->key != key){
	
      //neu day la last node
      if(current->next == NULL){
         return NULL;
      }else {
         //luu tham chieu toi link hien tai
         previous = current;
         //di chuyen toi next link
         current = current->next;             
      }
		
   }

   //cap nhat link
   if(current == head) {
      //thay doi first de tro toi next link
      head = head->next;
   }else {
      //bo qua link hien tai
      previous->next = current->next;
   }    
	
   return current;
}

// ham sap xep
void sort(){

   int i, j, k, tempKey, tempData ;
   struct node *current;
   struct node *next;
	
   int size = length();
   k = size ;
	
   for ( i = 0 ; i < size - 1 ; i++, k-- ) {
      current = head ;
      next = head->next ;
		
      for ( j = 1 ; j < k ; j++ ) {   
		
         if ( current->data > next->data ) {
            tempData = current->data ;
            current->data = next->data;
            next->data = tempData ;

            tempKey = current->key;
            current->key = next->key;
            next->key = tempKey;
         }
			
         current = current->next;
         next = next->next;                        
      }
   }   
}

// ham dao nguoc list
void reverse(struct node** head_ref) {
   struct node* prev   = NULL;
   struct node* current = *head_ref;
   struct node* next;
	
   while (current != NULL) {
      next  = current->next;  
      current->next = prev;   
      prev = current;
      current = next;
   }
	
   *head_ref = prev;
}

main() {

   insertFirst(1,10);
   insertFirst(2,20);
   insertFirst(3,30);
   insertFirst(4,1);
   insertFirst(5,40);
   insertFirst(6,56); 

   printf("Danh sach ban dau: "); 
	
   //in danh sach
   printList();

   while(!isEmpty()){            
      struct node *temp = deleteFirst();
      printf("\nGia tri bi xoa:");  
      printf("(%d,%d) ",temp->key,temp->data);        
   }  
	
   printf("\nDanh sach sau khi da xoa gia tri: ");          
   printList();
   insertFirst(1,10);
   insertFirst(2,20);
   insertFirst(3,30);
   insertFirst(4,1);
   insertFirst(5,40);
   insertFirst(6,56); 
   printf("\nPhuc hoi danh sach: ");  
   printList();
   printf("\n");  

   struct node *foundLink = find(4);
	
   if(foundLink != NULL){
      printf("Tim thay phan tu: ");  
      printf("(%d,%d) ",foundLink->key,foundLink->data);  
      printf("\n");  
   }else {
      printf("Khong tim thay phan tu.");  
   }

   deleteKey(4);
   printf("Danh sach, sau khi xoa mot phan tu: ");  
   printList();
   printf("\n");
   foundLink = find(4);
	
   if(foundLink != NULL){
      printf("Tim thay phan tu: ");  
      printf("(%d,%d) ",foundLink->key,foundLink->data);  
      printf("\n");  
   }else {
      printf("Khong tim thay phan tu.");  
   }
	
   printf("\n");  
   sort();
	
   printf("Danh sach sau khi duoc sap xep: ");  
   printList();
	
   reverse(&head);
   printf("\nDanh sach sau khi bi dao nguoc: ");  
   printList();
}

 

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Xóa trong Danh sách liên kết

Hoạt động xóa trong Danh sách liên kết cũng phức tạp hơn trong cấu trúc dữ liệu khác. Đầu tiên chúng ta cần định vị nút cần xóa bởi sử dụng các giải thuật tìm kiếm.

Hoạt động xóa trong Danh sách liên kết

Bây giờ, nút bên trái (prev) của nút cần xóa nên trỏ tới nút kế tiếp (next) của nút cần xóa.

LeftNode.next −> TargetNode.next;

Hoạt động xóa trong Danh sách liên kết

Quá trình này sẽ xóa link trỏ tới nút cần xóa. Bây giờ chúng ta sẽ xóa những gì mà nút cần xóa đang trỏ tới.

TargetNode.next −> NULL;

Hoạt động xóa trong Danh sách liên kết

Nếu bạn cần sử dụng nút đã bị xóa này thì bạn có thể giữ chúng trong bộ nhớ, nếu không bạn có thể xóa hoàn toàn hẳn nó khỏi bộ nhớ.

Hoạt động xóa trong Danh sách liên kết

Chương trình minh họa Danh sách liên kết (Linked List) trong C
 

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>

struct node  
{
   int data;
   int key;
   struct node *next;
};

struct node *head = NULL;
struct node *current = NULL;

//hien thi danh sach
void printList()
{
   struct node *ptr = head;
   printf("\n[ ");
	
   //bat dau tu phan dau danh sach
   while(ptr != NULL)
	{        
      printf("(%d,%d) ",ptr->key,ptr->data);
      ptr = ptr->next;
   }
	
   printf(" ]");
}

//chen link tai vi tri dau tien
void insertFirst(int key, int data)
{
   //tao mot link
   struct node *link = (struct node*) malloc(sizeof(struct node));
	
   link->key = key;
   link->data = data;
	
   //tro link nay toi first node cu
   link->next = head;
	
   //tro first toi first node moi
   head = link;
}

//xoa phan tu dau tien
struct node* deleteFirst()
{

   //luu tham chieu toi first link
   struct node *tempLink = head;
	
   //danh dau next toi first link la first 
   head = head->next;
	
   //tra ve link bi xoa
   return tempLink;
}

//kiem tra list co trong hay khong
bool isEmpty()
{
   return head == NULL;
}

int length()
{
   int length = 0;
   struct node *current;
	
   for(current = head; current != NULL; current = current->next)
	{
      length++;
   }
	
   return length;
}

//tim mot link voi key da cho
struct node* find(int key){

   //bat dau tim tu first link
   struct node* current = head;

   //neu list la trong
   if(head == NULL)
	{
      return NULL;
   }

   //duyet qua list
   while(current->key != key){
	
      //neu day la last node
      if(current->next == NULL){
         return NULL;
      }else {
         //di chuyen toi next link
         current = current->next;
      }
   }      
	
   //neu tim thay du lieu, tra ve link hien tai
   return current;
}

//xoa mot link voi key da cho
struct node* deleteKey(int key){

   //bat dau tu first link
   struct node* current = head;
   struct node* previous = NULL;
	
   //neu list la trong
   if(head == NULL){
      return NULL;
   }

   //duyet qua list
   while(current->key != key){
	
      //neu day la last node
      if(current->next == NULL){
         return NULL;
      }else {
         //luu tham chieu toi link hien tai
         previous = current;
         //di chuyen toi next link
         current = current->next;             
      }
		
   }

   //cap nhat link
   if(current == head) {
      //thay doi first de tro toi next link
      head = head->next;
   }else {
      //bo qua link hien tai
      previous->next = current->next;
   }    
	
   return current;
}

// ham sap xep
void sort(){

   int i, j, k, tempKey, tempData ;
   struct node *current;
   struct node *next;
	
   int size = length();
   k = size ;
	
   for ( i = 0 ; i < size - 1 ; i++, k-- ) {
      current = head ;
      next = head->next ;
		
      for ( j = 1 ; j < k ; j++ ) {   
		
         if ( current->data > next->data ) {
            tempData = current->data ;
            current->data = next->data;
            next->data = tempData ;

            tempKey = current->key;
            current->key = next->key;
            next->key = tempKey;
         }
			
         current = current->next;
         next = next->next;                        
      }
   }   
}

// ham dao nguoc list
void reverse(struct node** head_ref) {
   struct node* prev   = NULL;
   struct node* current = *head_ref;
   struct node* next;
	
   while (current != NULL) {
      next  = current->next;  
      current->next = prev;   
      prev = current;
      current = next;
   }
	
   *head_ref = prev;
}

main() {

   insertFirst(1,10);
   insertFirst(2,20);
   insertFirst(3,30);
   insertFirst(4,1);
   insertFirst(5,40);
   insertFirst(6,56); 

   printf("Danh sach ban dau: "); 
	
   //in danh sach
   printList();

   while(!isEmpty()){            
      struct node *temp = deleteFirst();
      printf("\nGia tri bi xoa:");  
      printf("(%d,%d) ",temp->key,temp->data);        
   }  
	
   printf("\nDanh sach sau khi da xoa gia tri: ");          
   printList();
   insertFirst(1,10);
   insertFirst(2,20);
   insertFirst(3,30);
   insertFirst(4,1);
   insertFirst(5,40);
   insertFirst(6,56); 
   printf("\nPhuc hoi danh sach: ");  
   printList();
   printf("\n");  

   struct node *foundLink = find(4);
	
   if(foundLink != NULL){
      printf("Tim thay phan tu: ");  
      printf("(%d,%d) ",foundLink->key,foundLink->data);  
      printf("\n");  
   }else {
      printf("Khong tim thay phan tu.");  
   }

   deleteKey(4);
   printf("Danh sach, sau khi xoa mot phan tu: ");  
   printList();
   printf("\n");
   foundLink = find(4);
	
   if(foundLink != NULL){
      printf("Tim thay phan tu: ");  
      printf("(%d,%d) ",foundLink->key,foundLink->data);  
      printf("\n");  
   }else {
      printf("Khong tim thay phan tu.");  
   }
	
   printf("\n");  
   sort();
	
   printf("Danh sach sau khi duoc sap xep: ");  
   printList();
	
   reverse(&head);
   printf("\nDanh sach sau khi bi dao nguoc: ");  
   printList();
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Đảo ngược Danh sách liên kết

Với hoạt động này, bạn cần phải cẩn thận. Chúng ta cần làm cho nút đầu (head) trỏ tới nút cuối cùng và đảo ngược toàn bộ danh sách liên kết.

Hoạt động đảo ngược Danh sách liên kết

Đầu tiên, chúng ta duyệt tới phần cuối của danh sách. Nút này sẽ trỏ tới NULL. Bây giờ điều cần làm là làm cho nút cuối này trỏ tới nút phía trước của nó.

Hoạt động đảo ngược Danh sách liên kết

Chúng ta phải đảm bảo rằng nút cuối cùng này sẽ không bị thất lạc, do đó chúng ta sẽ sử dụng một số nút tạm (temp node – giống như các biến tạm trung gian để lưu giữ giá trị). Tiếp theo, chúng ta sẽ làm cho từng nút bên trái sẽ trỏ tới nút trái của chúng.

Hoạt động đảo ngược Danh sách liên kết

Sau đó, nút đầu tiên sau nút head sẽ trỏ tới NULL.

Hoạt động đảo ngược Danh sách liên kết

Chúng ta sẽ làm cho nút head trỏ tới nút đầu tiên mới bởi sử dụng các nút tạm.

Hoạt động đảo ngược Danh sách liên kết

Bây giờ Danh sách liên kết đã bị đảo ngược.

Chương trình minh họa Danh sách liên kết (Linked List) trong C
 

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>

struct node  
{
   int data;
   int key;
   struct node *next;
};

struct node *head = NULL;
struct node *current = NULL;

//hien thi danh sach
void printList()
{
   struct node *ptr = head;
   printf("\n[ ");
	
   //bat dau tu phan dau danh sach
   while(ptr != NULL)
	{        
      printf("(%d,%d) ",ptr->key,ptr->data);
      ptr = ptr->next;
   }
	
   printf(" ]");
}

//chen link tai vi tri dau tien
void insertFirst(int key, int data)
{
   //tao mot link
   struct node *link = (struct node*) malloc(sizeof(struct node));
	
   link->key = key;
   link->data = data;
	
   //tro link nay toi first node cu
   link->next = head;
	
   //tro first toi first node moi
   head = link;
}

//xoa phan tu dau tien
struct node* deleteFirst()
{

   //luu tham chieu toi first link
   struct node *tempLink = head;
	
   //danh dau next toi first link la first 
   head = head->next;
	
   //tra ve link bi xoa
   return tempLink;
}

//kiem tra list co trong hay khong
bool isEmpty()
{
   return head == NULL;
}

int length()
{
   int length = 0;
   struct node *current;
	
   for(current = head; current != NULL; current = current->next)
	{
      length++;
   }
	
   return length;
}

//tim mot link voi key da cho
struct node* find(int key){

   //bat dau tim tu first link
   struct node* current = head;

   //neu list la trong
   if(head == NULL)
	{
      return NULL;
   }

   //duyet qua list
   while(current->key != key){
	
      //neu day la last node
      if(current->next == NULL){
         return NULL;
      }else {
         //di chuyen toi next link
         current = current->next;
      }
   }      
	
   //neu tim thay du lieu, tra ve link hien tai
   return current;
}

//xoa mot link voi key da cho
struct node* deleteKey(int key){

   //bat dau tu first link
   struct node* current = head;
   struct node* previous = NULL;
	
   //neu list la trong
   if(head == NULL){
      return NULL;
   }

   //duyet qua list
   while(current->key != key){
	
      //neu day la last node
      if(current->next == NULL){
         return NULL;
      }else {
         //luu tham chieu toi link hien tai
         previous = current;
         //di chuyen toi next link
         current = current->next;             
      }
		
   }

   //cap nhat link
   if(current == head) {
      //thay doi first de tro toi next link
      head = head->next;
   }else {
      //bo qua link hien tai
      previous->next = current->next;
   }    
	
   return current;
}

// ham sap xep
void sort(){

   int i, j, k, tempKey, tempData ;
   struct node *current;
   struct node *next;
	
   int size = length();
   k = size ;
	
   for ( i = 0 ; i < size - 1 ; i++, k-- ) {
      current = head ;
      next = head->next ;
		
      for ( j = 1 ; j < k ; j++ ) {   
		
         if ( current->data > next->data ) {
            tempData = current->data ;
            current->data = next->data;
            next->data = tempData ;

            tempKey = current->key;
            current->key = next->key;
            next->key = tempKey;
         }
			
         current = current->next;
         next = next->next;                        
      }
   }   
}

// ham dao nguoc list
void reverse(struct node** head_ref) {
   struct node* prev   = NULL;
   struct node* current = *head_ref;
   struct node* next;
	
   while (current != NULL) {
      next  = current->next;  
      current->next = prev;   
      prev = current;
      current = next;
   }
	
   *head_ref = prev;
}

main() {

   insertFirst(1,10);
   insertFirst(2,20);
   insertFirst(3,30);
   insertFirst(4,1);
   insertFirst(5,40);
   insertFirst(6,56); 

   printf("Danh sach ban dau: "); 
	
   //in danh sach
   printList();

   while(!isEmpty()){            
      struct node *temp = deleteFirst();
      printf("\nGia tri bi xoa:");  
      printf("(%d,%d) ",temp->key,temp->data);        
   }  
	
   printf("\nDanh sach sau khi da xoa gia tri: ");          
   printList();
   insertFirst(1,10);
   insertFirst(2,20);
   insertFirst(3,30);
   insertFirst(4,1);
   insertFirst(5,40);
   insertFirst(6,56); 
   printf("\nPhuc hoi danh sach: ");  
   printList();
   printf("\n");  

   struct node *foundLink = find(4);
	
   if(foundLink != NULL){
      printf("Tim thay phan tu: ");  
      printf("(%d,%d) ",foundLink->key,foundLink->data);  
      printf("\n");  
   }else {
      printf("Khong tim thay phan tu.");  
   }

   deleteKey(4);
   printf("Danh sach, sau khi xoa mot phan tu: ");  
   printList();
   printf("\n");
   foundLink = find(4);
	
   if(foundLink != NULL){
      printf("Tim thay phan tu: ");  
      printf("(%d,%d) ",foundLink->key,foundLink->data);  
      printf("\n");  
   }else {
      printf("Khong tim thay phan tu.");  
   }
	
   printf("\n");  
   sort();
	
   printf("Danh sach sau khi duoc sap xep: ");  
   printList();
	
   reverse(&head);
   printf("\nDanh sach sau khi bi dao nguoc: ");  
   printList();
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Danh sách liên kết đôi (Doubly Linked List) là gì ?

Danh sách liên kết đôi (Doubly Linked List) là một biến thể của Danh sách liên kết (Linked List), trong đó hoạt động duyệt qua các nút có thể được thực hiện theo hai chiều: về trước và về sau một cách dễ dàng khi so sánh với Danh sách liên kết đơn. Dưới đây là một số khái niệm quan trọng cần ghi nhớ về Danh sách liên kết đôi.

  • Link: mỗi link của một Danh sách liên kết có thể lưu giữ một dữ liệu và được gọi là một phần tử.

  • Next: mỗi link của một Danh sách liên kết có thể chứa một link tới next link và được gọi là Next.

  • Prev: mỗi link của một Danh sách liên kết có thể chứa một link tới previous link và được gọi là Prev.

  • First và Last: một Danh sách liên kết chứa link kết nối tới first link được gọi là First và tới last link được gọi là Last.

Biểu diễn Danh sách liên kết đôi

Danh sách liên kết đôi (Doubly Linked List)

Như hình minh họa trên, bạn cần ghi nhớ:

  • Danh sách liên kết đôi chứa một phần tử link và được gọi là First và Last.
  • Mỗi link mang một trường dữ liệu và một trường link được gọi là Next.
  • Mỗi link được liên kết với phần tử kế tiếp bởi sử dụng Next Link.
  • Mỗi link được liên kết với phần tử phía trước bởi sử dụng Prev Link.
  • Last Link mang một link trỏ tới NULL để đánh dầu phần cuối của Danh sách liên kết.

Các hoạt động cơ bản trên Danh sách liên kết đôi

  • Hoạt động chèn: thêm một phần tử vào vị trí đầu của Danh sách liên kết.

  • Hoạt động xóa: xóa một phần tử tại vị trí đầu của Danh sách liên kết.

  • Hoạt động chèn vào cuối: thêm một phần tử vào vị trí cuối của Danh sách liên kết.

  • Hoạt động xóa phần tử cuối: xóa một phần tử tại vị trí cuối của Danh sách liên kết.

  • Hoạt động chèn vào sau: thêm một phần tử vào sau một phần tử của Danh sách liên kết.

  • Hoạt động xóa (bởi key): xóa một phần tử từ Danh sách liên kết bởi sử dụng khóa đã cung cấp.

  • Hiển thị danh sách về phía trước: hiển thị toàn bộ Danh sách liên kết theo chiều về phía trước.

  • Hiển thị danh sách về phía sau: hiển thị toàn bộ Danh sách liên kết theo chiều về phía sau. 


Hoạt động chèn trong Danh sách liên kết đôi

Phần dưới đây là giải thuật minh họa cho hoạt động chèn tại vị trí đầu của một Danh sách liên kết đôi.

//Chèn link tại vị trí đầu tiên
void insertFirst(int key, int data) {

   //tạo một link
   struct node *link = (struct node*) malloc(sizeof(struct node));
   link->key = key;
   link->data = data;
	
   if(isEmpty()) {
      //Biến nó thành last link
      last = link;
   }else {
      //Cập nhật prev link đầu tiên
      head->prev = link;
   }

   //Trỏ nó tới first link cũ
   link->next = head;
	
   //Trỏ first tới first link mới
   head = link;
}

 Chương trình danh sách liên kết đôi trong C 


Hoạt động xóa trong Danh sách liên kết đôi

Phần dưới đây là giải thuật minh họa cho hoạt động xóa phần tử tại vị trí đầu của một Danh sách liên kết đôi.
 

//xóa phần tử đầu tiên
struct node* deleteFirst() {

   //Lưu tham chiếu tới first link
   struct node *tempLink = head;
	
   //Nếu chỉ có link
   if(head->next == NULL) {
      last = NULL;
   }else {
      head->next->prev = NULL;
   }
	
   head = head->next;
	
   //Trả về link đã bị xóa
   return tempLink;
}

 Chương trình danh sách liên kết đôi trong C 


Hoạt động chèn tại vị trí cuối trong Danh sách liên kết đôi

Phần dưới đây là giải thuật minh họa cho hoạt động chèn tại vị trí cuối của một Danh sách liên kết đôi.

//Chèn link vào vị trí cuối cùng
void insertLast(int key, int data) {

   //tạo một link
   struct node *link = (struct node*) malloc(sizeof(struct node));
   link->key = key;
   link->data = data;
	
   if(isEmpty()) {
      //biến nó thành last link
      last = link;
   }else {
      //làm cho link trở thành last link mới
      last->next = link;     
      //Đánh dấu last node là prev của new link
      link->prev = last;
   }

   //Trỏ last tới new last node
   last = link;
}

 Chương trình danh sách liên kết đôi trong C 

Danh sách liên kết vòng (Circular Linked List) là gì ?

Danh sách liên kết vòng (Circular Linked List) là một biến thể của Danh sách liên kết (Linked List), trong đó phần tử đầu tiên trỏ tới phần tử cuối cùng và phần tử cuối cùng trỏ tới phần tử đầu tiên.

Cả hai loại Danh sách liên kết đơn (Singly Linked List) và Danh sách liên kết đôi (Doubly Linked List) đều có thể được tạo thành dạng Danh sách liên kết vòng. Phần dưới chúng ta sẽ tìm hiểu từng cách tạo một.

Tạo Danh sách liên kết vòng từ Danh sách liên kết đơn

Trong Danh sách liên kết đơn, điểm trỏ tới kế tiếp của nút cuối sẽ trỏ tới nút đầu tiên, thay vì sẽ trỏ tới NULL.

Tạo Danh sách liên kết vòng từ Danh sách liên kết đơn

Tạo Danh sách liên kết vòng từ Danh sách liên kết đôi

Trong Danh sách liên kết đôi, điểm trỏ tới kế tiếp của nút cuối trỏ tới nút đầu tiên và điểm trỏ tới phía trước của nút trước sẽ trỏ tới nút cuối cùng. Quá trình này sẽ tạo thành vòng ở cả hai hướng.

Tạo Danh sách liên kết vòng từ Danh sách liên kết đôi

Nhìn vào hai hình minh họa trên, bạn cần ghi nhớ:

  • Next của Last Link trỏ tới First Link trong cả hai trường hợp với Danh sách liên kết đơn cũng như Danh sách liên kết đôi.

  • Prev của First Link trỏ tới phần tử cuối của Danh sách liên kết với trường hợp Danh sách liên kết đôi.

Các hoạt động cơ bản trên Danh sách liên kết vòng

Dưới đây là một số hoạt động cơ bản được hỗ trợ bởi Danh sách liên kết vòng:

  • Hoạt động chèn: chèn một phần tử vào vị trí bắt đầu của Danh sách liên kết vòng.

  • Hoạt động xóa: xóa một phần tử của Danh sách liên kết vòng.

  • Hiển thị: hiển thị toàn bộ Danh sách liên kết vòng.

 

Chèn trong Danh sách liên kết vòng

Dưới đây là giải thuật minh họa hoạt động chèn trong Danh sách liên kết vòng dựa trên Danh sách liên kết đơn.

/Chèn link tại vị trí đầu tiên
void insertFirst(int key, int data) {
   //tạo một link
   struct node *link = (struct node*) malloc(sizeof(struct node));
   link->key = key;
   link->data= data;
	
   if (isEmpty()) {
      head = link;
      head->next = head;
   }else {
      //trỏ nó tới first node cũ
      link->next = head;
		
      //trỏ first tới first node mới
      head = link;
   }   
   
}

  Chương trình danh sách liên kết vòng trong C


Xóa trong Danh sách liên kết vòng

Dưới đây là giải thuật minh họa hoạt động xóa trong Danh sách liên kết vòng dựa trên Danh sách liên kết đơn.

//Xóa phần tử đầu tiên
struct node * deleteFirst() {
   //Lưu tham chiếu tới first link
   struct node *tempLink = head;
	
   if(head->next == head){  
      head = NULL;
      return tempLink;
   }     

   //Đánh dấu next tới first link là first 
   head = head->next;
	
   //trả về link đã bị xóa
   return tempLink;
}

  Chương trình danh sách liên kết vòng trong C


Hiển thị Danh sách liên kết vòng

Dưới đây là giải thuật minh họa hoạt động hiển thị toàn bộ Danh sách liên kết vòng.

//Hiển thị danh sách liên kết vòng
void printList() {
   struct node *ptr = head;
   printf("\n[ ");
	
   //Bắt đầu từ vị trí đầu tiên
   if(head != NULL) {
      while(ptr->next != ptr) {     
         printf("(%d,%d) ",ptr->key,ptr->data);
         ptr = ptr->next;
      }
   }
	
   printf(" ]");
}

  Chương trình danh sách liên kết vòng trong C

Ngăn xếp (Stack) là gì ?

Một ngăn xếp là một cấu trúc dữ liệu trừu tượng (Abstract Data Type – viết tắt là ADT), hầu như được sử dụng trong hầu hết mọi ngôn ngữ lập trình. Đặt tên là ngăn xếp bởi vì nó hoạt động như một ngăn xếp trong đời sống thực, ví dụ như một cỗ bài hay một chồng đĩa, …

Cấu trúc dữ liệu ngăn xếp (Stack)

Trong đời sống thực, ngăn xếp chỉ cho phép các hoạt động tại vị trí trên cùng của ngăn xếp. Ví dụ, chúng ta chỉ có thể đặt hoặc thêm một lá bài hay một cái đĩa vào trên cùng của ngăn xếp. Do đó, cấu trúc dữ liệu trừu tượng ngăn xếp chỉ cho phép các thao tác dữ liệu tại vị trí trên cùng. Tại bất cứ thời điểm nào, chúng ta chỉ có thể truy cập phần tử trên cùng của ngăn xếp.

Đặc điểm này làm cho ngăn xếp trở thành cấu trúc dữ liệu dạng LIFO. LIFO là viết tắt của Last-In-First-Out. Ở đây, phần tử được đặt vào (được chèn, được thêm vào) cuối cùng sẽ được truy cập đầu tiên. Trong thuật ngữ ngăn xếp, hoạt động chèn được gọi là hoạt động PUSH và hoạt động xóa được gọi là hoạt động POP.

Biểu diễn cấu trúc dữ liệu ngăn xếp (Stack)

Dưới đây là sơ đồ minh họa một ngăn xếp và các hoạt động diễn ra trên ngăn xếp.

Biểu diễn ngăn xếp (Stack)

Một ngăn xếp có thể được triển khai theo phương thức của Mảng (Array), Cấu trúc (Struct), Con trỏ (Pointer) và Danh sách liên kết (Linked List). Ngăn xếp có thể là ở dạng kích cỡ cố định hoặc ngăn xếp có thể thay đổi kích cỡ. Phần dưới chúng ta sẽ triển khai ngăn xếp bởi sử dụng các mảng với việc triển khai các ngăn xếp cố định.

Các hoạt động cơ bản trên cấu trúc dữ liệu ngăn xếp

Các hoạt động cơ bản trên ngăn xếp có thể liên quan tới việc khởi tạo ngăn xếp, sử dụng nó và sau đó xóa nó. Ngoài các hoạt động cơ bản này, một ngăn xếp có hai hoạt động nguyên sơ liên quan tới khái niệm, đó là:

  • Hoạt động push(): lưu giữ một phần tử trên ngăn xếp.

  • Hoạt động pop(): xóa một phần tử từ ngăn xếp.

Khi dữ liệu đã được PUSH lên trên ngăn xếp:

Để sử dụng ngăn xếp một cách hiệu quả, chúng ta cũng cần kiểm tra trạng thái của ngăn xếp. Để phục vụ cho mục đích này, dưới đây là một số tính năng hỗ trợ khác của ngăn xếp:

  • Hoạt động peek(): lấy phần tử dữ liệu ở trên cùng của ngăn xếp, mà không xóa phần tử này.

  • Hoạt động isFull(): kiểm tra xem ngăn xếp đã đầy hay chưa.

  • Hoạt động isEmpty(): kiểm tra xem ngăn xếp là trống hay không.

Tại mọi thời điểm, chúng ta duy trì một con trỏ tới phần tử dữ liệu vừa được PUSH cuối cùng vào trên ngăn xếp. Vì con trỏ này luôn biểu diễn vị trí trên cùng của ngăn xếp vì thế được đặt tên là topCon trỏ top cung cấp cho chúng ta giá trị của phần tử trên cùng của ngăn xếp mà không cần phải thực hiện hoạt động xóa ở trên (hoạt động pop).
Phần tiếp theo chúng ta sẽ tìm hiểu về các phương thức để hỗ trợ các tính năng của ngăn xếp.
Phương thức peek() của cấu trúc dữ liệu ngăn xếp
Giải thuật của hàm peek():

Bắt đầu hàm peek

   return stack[top]
   
kết thúc hàm

Hàm peek() trong ngôn ngữ C:

int peek() {
   return stack[top];
}

Phương thức isFull() của cấu trúc dữ liệu ngăn xếp

Giải thuật của hàm isFull():

Bắt đầu hàm isfull

   if top bằng MAXSIZE
      return true
   else
      return false
   kết thúc if
   
kết thúc hàm

Hàm isFull() trong ngôn ngữ C:

bool isfull() {
   if(top == MAXSIZE)
      return true;
   else
      return false;
}

Phương thức isEmpty() của cấu trúc dữ liệu ngăn xếp
Giải thuật của hàm isEmpty():

bắt đầu hàm isempty

   if top nhỏ hơn 1
      return true
   else
      return false
   kết thúc if
   
kết thúc hàm

Hàm isEmpty() trong ngôn ngữ C khác hơn một chút. Chúng ta khởi tạo top tại -1, giống như chỉ mục của mảng bắt đầu từ 0. Vì thế chúng ta kiểm tra nếu top là dưới 0 hoặc -1 thì ngăn xếp là trống. Dưới đây là phần code:

bool isempty() {
   if(top == -1)
      return true;
   else
      return false;
}

 



Hoạt động PUSH trong cấu trúc dữ liệu ngăn xếp

Tiến trình đặt (thêm) một phần tử dữ liệu mới vào trên ngăn xếp còn được biết đến với tên Hoạt động PUSH. Hoạt động push bao gồm các bước sau:

  • Bước 1: kiểm tra xem ngăn xếp đã đầy hay chưa.

  • Bước 2: nếu ngăn xếp là đầy, tiến trình bị lỗi và thoát ra.

  • Bước 3: nếu ngăn xếp chưa đầy, tăng top để trỏ tới phần bộ nhớ trống tiếp theo.

  • Bước 4: thêm phần tử dữ liệu vào vị trí nơi mà top đang trỏ đến trên ngăn xếp.

  • Bước 5: trả về success.

Hoạt động push trong ngăn xếp

Nếu Danh sách liên kết được sử dụng để triển khai ngăn xếp, thì ở bước 3 chúng ta cần cấp phát một không gian động.
Giải thuật cho hoạt động PUSH của cấu trúc dữ liệu ngăn xếp
Từ trên có thể suy ra một giải thuật đơn giản cho hoạt động PUSH trong cấu trúc dữ liệu ngăn xếp như sau:

bắt đầu hoạt động push: stack, data

   if stack là đầy
      return null
   kết thúc if
   
   top ← top + 1
   
   stack[top] ← data

kết thúc hàm

Sự triển khai của giải thuật này trong ngôn ngữ C là:

void push(int data) {
   if(!isFull()) {
      top = top + 1;   
      stack[top] = data;
   }else {
      printf("Khong the chen them du lieu vi Stack da day.\n");
   }
}

Để tìm hiểu chương trình C minh họa đầy đủ các hoạt động trên của ngăn xếp, mời bạn click chuột vào chương: Ngăn xếp (Stack) trong C.


Hoạt động POP của cấu trúc dữ liệu ngăn xếp

Việc truy cập nội dung phần tử trong khi xóa nó từ ngăn xếp còn được gọi là Hoạt động POP. Trong sự triển khai Mảng của hoạt động pop(), phần tử dữ liệu không thực sự bị xóa, thay vào đó top sẽ bị giảm về vị trí thấp hơn trong ngăn xếp để trỏ tới giá trị tiếp theo. Nhưng trong sự triển khai Danh sách liên kết, hoạt động pop() thực sụ xóa phần tử xữ liệu và xóa nó khỏi không gian bộ nhớ.

Hoạt động POP có thể bao gồm các bước sau:

  • Bước 1: kiểm tra xem ngăn xếp là trống hay không.

  • Bước 2: nếu ngăn xếp là trống, tiến trình bị lỗi và thoát ra.

  • Bước 3: nếu ngăn xếp là không trống, truy cập phần tử dữ liệu tại top đang trỏ tới.

  • Bước 4: giảm giá trị của top đi 1.

  • Bước 5: trả về success.

Hoạt động pop trong cấu trúc dữ liệu ngăn xếp

Giải thuật cho hoạt động POP

Từ trên ta có thể suy ra giải thuật cho hoạt động POP trên cấu trúc dữ liệu ngăn xếp như sau:

bắt đầu hàm pop: stack

   if stack là trống
      return null
   kết thúc if
   
   data ← stack[top]
   
   top ← top - 1
   
   return data

kết thúc hàm

Triển khai giải thuật trong ngôn ngữ C như sau:

int pop(int data) {

   if(!isempty()) {
      data = stack[top];
      top = top - 1;   
      return data;
   }else {
      printf("Khong the lay du lieu, Stack la trong.\n");
   }
}

Để tìm hiểu chương trình C minh họa đầy đủ các hoạt động trên của ngăn xếp, mời bạn click chuột vào chương: Ngăn xếp (Stack) trong C.


 

Cấu trúc dữ liệu hàng đợi (Queue) là gì ?

Hàng đợi (Queue) là một cấu trúc dữ liệu trừu tượng, là một cái gì đó tương tự như hàng đợi trong đời sống hàng ngày (xếp hàng).

Khác với ngăn xếp, hàng đợi là mở ở cả hai đầu. Một đầu luôn luôn được sử dụng để chèn dữ liệu vào (hay còn gọi là sắp vào hàng) và đầu kia được sử dụng để xóa dữ liệu (rời hàng). Cấu trúc dữ liệu hàng đợi tuân theo phương pháp First-In-First-Out, tức là dữ liệu được nhập vào đầu tiên sẽ được truy cập đầu tiên.

Trong đời sống thực chúng ta có rất nhiều ví dụ về hàng đợi, chẳng hạn như hàng xe ô tô trên đường một chiều (đặc biệt là khi tắc xe), trong đó xe nào vào đầu tiên sẽ thoát ra đầu tiên. Một vài ví dụ khác là xếp hàng học sinh, xếp hàng mua vé, …

Biểu diễn cấu trúc dữ liệu hàng đợi (Queue)

Giờ thì có lẽ bạn đã tưởng tượng ra hàng đợi là gì rồi. Chúng ta có thể truy cập cả hai đầu của hàng đợi. Dưới đây là biểu diễn hàng đợi dưới dạng cấu trúc dữ liệu:

Cấu trúc dữ liệu hàng đợi (Queue)

Tương tự như cấu trúc dữ liệu ngăn xếp, thì cấu trúc dữ liệu hàng đợi cũng có thể được triển khai bởi sử dụng Mảng (Array), Danh sách liên kết (Linked List), Con trỏ (Pointer) và Cấu trúc (Struct). Để đơn giản, phần tiếp theo chúng ta sẽ tìm hiểu tiếp về hàng đợi được triển khai bởi sử dụng mảng một chiều.

Các hoạt động cơ bản trên cấu trúc dữ liệu hàng đợi

Các hoạt động trên cấu trúc dữ liệu hàng đợi có thể liên quan tới việc khởi tạo hàng đợi, sử dụng dữ liệu trên hàng đợi và sau đó là xóa dữ liệu khỏi bộ nhớ. Danh sách dưới đây là một số hoạt động cơ bản có thể thực hiện trên cấu trúc dữ liệu hàng đợi:

  • Hoạt động enqueue(): thêm (hay lưu trữ) một phần tử vào trong hàng đợi.

  • Hoạt động dequeue(): xóa một phần tử từ hàng đợi.

Để sử dụng hàng đợi một cách hiệu quả, chúng ta cũng cần kiểm tra trạng thái của hàng đợi. Để phục vụ cho mục đích này, dưới đây là một số tính năng hỗ trợ khác của hàng đợi:

  • Phương thức peek(): lấy phần tử ở đầu hàng đợi, mà không xóa phần tử này.

  • Phương thức isFull(): kiểm tra xem hàng đợi là đầy hay không.

  • Phương thức isEmpty(): kiểm tra xem hàng đợi là trống hay hay không.

Trong cấu trúc dữ liệu hàng đợi, chúng ta luôn luôn: (1) dequeue (xóa) dữ liệu được trỏ bởi con trỏ front và (2) enqueue (nhập) dữ liệu vào trong hàng đợi bởi sự giúp đỡ của con trỏ rear.

Trong phần tiếp chúng ta sẽ tìm hiểu về các tính năng hỗ trợ của cấu trúc dữ liệu hàng đợi:

Phương thức peek() của cấu trúc dữ liệu hàng đợi

Giống như trong cấu trúc dữ liệu ngăn xếp, hàm này giúp chúng ta quan sát dữ liệu tại đầu hàng đợi. Giải thuật của hàm peek() là:

bắt đầu hàm peek

   return queue[front]
   
kết thúc hàm

Hàm peek() trong ngôn ngữ C:

int peek() {
   return queue[front];
}

Phương thức isFull() trong cấu trúc dữ liệu hàng đợi

Nếu khi chúng ta đang sử dụng mảng một chiều để triển khai hàng đợi, chúng ta chỉ cần kiểm tra con trỏ rear có tiến đến giá trị MAXSIZE hay không để xác định hàng đợi là đầy hay không. Trong trường hợp triển khai hàng đợi bởi sử dụng Danh sách liên kết vòng (Circular Linked List), giải thuật cho hàm isFull() sẽ khác.

Phần dưới đây là giải thuật của hàm isFull():

bắt đầu hàm isfull

   if rear equals to MAXSIZE
      return true
   else
      return false
   endif
   
kết thúc hàm

Hàm isFull() trong ngôn ngữ C:

bool isfull() {
   if(rear == MAXSIZE - 1)
      return true;
   else
      return false;
}

Phương thức isEmpty() trong cấu trúc dữ liệu hàng đợi

Giải thuật của hàm isEmpty():

bắt đầu hàm isempty

   if front là nhỏ hơn MIN  OR front là lớn hơn rear
      return true
   else
      return false
   kết thúc if
   
kết thúc hàm

Nếu giá trị của front là nhỏ hơn MIN hoặc 0 thì tức là hàng đợi vẫn chưa được khởi tạo, vì thế hàng đợi là trống.

Dưới đây là sự triển khai code trong ngôn ngữ C:

bool isempty() {
   if(front < 0 || front > rear) 
      return true;
   else
      return false;
}

Hoạt động enqueue trong cấu trúc dữ liệu hàng đợi

Bởi vì cấu trúc dữ liệu hàng đợi duy trì hai con trỏ dữ liệu: front và rear, do đó các hoạt động của loại cấu trúc dữ liệu này là khá phức tạp khi so sánh với cấu trúc dữ liệu ngăn xếp.

Dưới đây là các bước để enqueue (chèn) dữ liệu vào trong hàng đợi:

  • Bước 1: kiểm tra xem hàng đợi là có đầy không.

  • Bước 2: nếu hàng đợi là đầy, tiến trình bị lỗi và bị thoát.

  • Bước 3: nếu hàng đợi không đầy, tăng con trỏ rear để trỏ tới vị trí bộ nhớ trống tiếp theo.

  • Bước 4: thêm phần tử dữ liệu vào vị trí con trỏ rear đang trỏ tới trong hàng đợi.

  • Bước 5: trả về success.

Hoạt động chèn trong cấu trúc dữ liệu hàng đợi

Đôi khi chúng ta cũng cần kiểm tra xem hàng đợi đã được khởi tạo hay chưa để xử lý các tình huống không mong đợi.

Giải thuật cho hoạt động enqueue trong cấu trúc dữ liệu hàng đợi 

bắt đầu enqueue(data)      
   if queue là đầy
      return overflow
   endif
   
   rear ← rear + 1
   
   queue[rear] ← data
   
   return true
   
kết thúc hàm

Sự triển khai giải thuật của hoạt động enqueue() trong ngôn ngữ C:

int enqueue(int data)      
   if(isfull())
      return 0;
   
   rear = rear + 1;
   queue[rear] = data;
   
   return 1;
kết thúc hàm

Để theo dõi sự triển khai code đầy đủ của các hoạt động trên trong ngôn ngữ C, mời bạn click chuột vào chương: Hàng đợi trong C.


Hoạt động dequeue trong cấu trúc dữ liệu hàng đợi

Việc truy cập dữ liệu từ hàng đợi là một tiến trình gồm hai tác vụ: truy cập dữ liệu tại nơi con trỏ front đang trỏ tới và xóa dữ liệu sau khi đã truy cập đó. Dưới đây là các bước để thực hiện hoạt động dequeue:

  • Bước 1: kiểm tra xem hàng đợi là trống hay không.

  • Bước 2: nếu hàng đợi là trống, tiến trình bị lỗi và bị thoát.

  • Bước 3: nếu hàng đợi không trống, truy cập dữ liệu tại nơi con trỏ front đang trỏ.

  • Bước 4: tăng con trỏ front để trỏ tới vị trí chứa phần tử tiếp theo.

  • Bước 5: trả về success.

Hoạt động xóa trong cấu trúc dữ liệu hàng đợi

Giải thuật cho hoạt động dequeue

bắt đầu hàm dequeue
   if queue là trống
      return underflow
   end if

   data = queue[front]
   front ← front + 1
   
   return true
kết thúc hàm

Sự triển khai hoạt động dequeue() trong ngôn ngữ C:

int dequeue() {

   if(isempty())
      return 0;

   int data = queue[front];
   front = front + 1;

   return data;
}

Để theo dõi sự triển khai code đầy đủ của các hoạt động trên trong ngôn ngữ C, mời bạn click chuột vào chương: Hàng đợi trong C.


 

Một ngăn xếp là một cấu trúc dữ liệu trừu tượng (Abstract Data Type – viết tắt là ADT), hầu như được sử dụng trong hầu hết mọi ngôn ngữ lập trình. Đặt tên là ngăn xếp bởi vì nó hoạt động như một ngăn xếp trong đời sống thực, ví dụ như một cỗ bài hay một chồng đĩa, …

Chương trình minh họa Ngăn xếp (Stack) trong C
 

#include <stdio.h>

int MAXSIZE = 8;       
int stack[8];     
int top = -1;            

int isempty() {

   if(top == -1)
      return 1;
   else
      return 0;
}
   
int isfull() {

   if(top == MAXSIZE)
      return 1;
   else
      return 0;
}

int peek() {
   return stack[top];
}


int pop() {
   int data;
	
   if(!isempty()) {
      data = stack[top];
      top = top - 1;   
      return data;
   }else {
      printf("Khong the thu thap du lieu, ngan xep (Stack) la trong.\n");
   }
}

int push(int data) {

   if(!isfull()) {
      top = top + 1;   
      stack[top] = data;
   }else {
      printf("Khong the chen du lieu, ngan xep (Stack) da day.\n");
   }
}

int main() {
   // chen cac phan tu vao ngan xep
   push(3);
   push(5);
   push(9);
   push(1);
   push(12);
   push(15);

   printf("Phan tu tai vi tri tren cung cua ngan xep: %d\n" ,peek());
   printf("Cac phan tu: \n");

   // in cac phan tu trong ngan xep
   while(!isempty()) {
      int data = pop();
      printf("%d\n",data);
   }

   printf("Ngan xep da day: %s\n" , isfull()?"true":"false");
   printf("Ngan xep la trong: %s\n" , isempty()?"true":"false");
   
   return 0;
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Ngăn xếp (Stack) trong C
 


 

Hàng đợi (Queue) là một cấu trúc dữ liệu trừu tượng, là một cái gì đó tương tự như hàng đợi trong đời sống hàng ngày (xếp hàng).

Chương trình minh họa Hàng đợi (Queue) trong C
 

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>

#define MAX 6

int intArray[MAX];
int front = 0;
int rear = -1;
int itemCount = 0;

int peek(){
   return intArray[front];
}

bool isEmpty(){
   return itemCount == 0;
}

bool isFull(){
   return itemCount == MAX;
}

int size(){
   return itemCount;
}  

void insert(int data){

   if(!isFull()){
	
      if(rear == MAX-1){
         rear = -1;            
      }       

      intArray[++rear] = data;
      itemCount++;
   }
}

int removeData(){
   int data = intArray[front++];
	
   if(front == MAX){
      front = 0;
   }
	
   itemCount--;
   return data;  
}

int main() {
   /* chen 5 phan tu */
   insert(3);
   insert(5);
   insert(9);
   insert(1);
   insert(12);

   // front : 0
   // rear  : 4
   // ------------------
   // index : 0 1 2 3 4 
   // ------------------
   // queue : 3 5 9 1 12
   insert(15);

   // front : 0
   // rear  : 5
   // ---------------------
   // index : 0 1 2 3 4  5 
   // ---------------------
   // queue : 3 5 9 1 12 15
	
   if(isFull()){
      printf("Hang doi (Queue) da day!\n");   
   }

   // xoa mot phan tu 
   int num = removeData();
	
   printf("Phan tu bi xoa: %d\n",num);
   // front : 1
   // rear  : 5
   // -------------------
   // index : 1 2 3 4  5
   // -------------------
   // queue : 5 9 1 12 15

   // Chen them mot phan tu
   insert(16);

   // front : 1
   // rear  : -1
   // ----------------------
   // index : 0  1 2 3 4  5
   // ----------------------
   // queue : 16 5 9 1 12 15

   // neu hang doi la day thi phan tu se khong duoc chen. 
   insert(17);
   insert(18);

   // ----------------------
   // index : 0  1 2 3 4  5
   // ----------------------
   // queue : 16 5 9 1 12 15
   printf("Phan tu tai vi tri front: %d\n",peek());

   printf("----------------------\n");
   printf("Gia tri chi muc : 5 4 3 2  1  0\n");
   printf("----------------------\n");
   printf("Hang doi (Queue):  ");
	
   while(!isEmpty()){
      int n = removeData();           
      printf("%d ",n);
   }   
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Hàng đợi (Queue) trong C


 

Tìm kiếm tuyến tính (Linear Search) là gì ?

Linear Search là một giải thuật tìm kiếm rất cơ bản. Trong kiểu tìm kiếm này, một hoạt động tìm kiếm liên tiếp được diễn ra qua tất cả từng phần tử. Mỗi phần tử đều được kiểm tra và nếu tìm thấy bất kỳ kết nối nào thì phần tử cụ thể đó được trả về; nếu không tìm thấy thì quá trình tìm kiếm tiếp tục diễn ra cho tới khi tìm kiếm hết dữ liệu.
Giải thuật tìm kiếm tuyến tính (Linear Search)

Giải thuật tìm kiếm tuyến tính

Giải thuật tìm kiếm tuyến tính ( Mảng A, Giá trị x)

Bước 1: Thiết lập i thành 1
Bước 2: Nếu i > n thì chuyển tới bước 7
Bước 3: Nếu A[i] = x thì chuyển tới bước 6
Bước 4: Thiết lập i thành i + 1
Bước 5: Tới bước 2
Bước 6: In phần tử x được tìm thấy tại chỉ mục i và tới bước 8
Bước 7: In phần tử không được tìm thấy
Bước 8: Thoát

Giải thuật mẫu cho tìm kiếm tuyến tính

Bắt đầu hàm linear_search (list, value)

   for mỗi phần tử trong danh sách

      if match item == value

         return vị trí của phần tử

      kết thúc if

   kết thúc for

kết thúc hàm

Chương trình minh họa Tìm kiếm tuyến tính (Linear Search) trong C
 

#include <stdio.h>

#define MAX 20

// khai bao mang du lieu
int intArray[MAX] = {1,2,3,4,6,7,9,11,12,14,15,16,17,19,33,34,43,45,55,66};

void printline(int count){
   int i;
	
   for(i = 0;i <count-1;i++){
      printf("=");
   }
	
   printf("=\n");
}

// phuong thuc tim kiem tuyen tinh 
int find(int data){

   int comparisons = 0;
   int index = -1;
   int i;

   // duyet qua tat ca phan tu
   for(i = 0;i<MAX;i++){
	
      // dem so phep tinh so sanh da thuc hien 
      comparisons++;
		
      // neu tim thay du lieu, thoat khoi vong lap
		
      if(data == intArray[i]){
         index = i;
         break;
      }
		
   }   
	
   printf("Tong so phep so sanh da thuc hien: %d", comparisons);
   return index;
}

void display(){
   int i;
   printf("[");
	
   // duyet qua tat ca phan tu 
   for(i = 0;i<MAX;i++){
      printf("%d ",intArray[i]);
   }
	
   printf("]\n");
}

main(){

   printf("Mang du lieu dau vao: ");
   display();
   printline(50);
	
   //Tim kiem vi tri cua 55
   int location = find(55);

   // neu tim thay phan tu
   if(location != -1)
      printf("\nTim thay phan tu tai vi tri: %d" ,(location+1));
   else
      printf("Khong tim thay phan tu.");
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Giải thuật tìm kiếm tuyến tính (Linear Search)


 

Giải thuật tìm kiếm nhị phân (Binary Search) là gì ?

Binany Search (Tìm kiếm nhị phân) là một giải thuật tìm kiếm nhanh với độ phức tạp thời gian chạy là Ο(log n). Giải thuật tìm kiếm nhị phân làm việc dựa trên nguyên tắc chia để trị (Divide and Conquer). Để giải thuật này có thể làm việc một cách chính xác thì tập dữ liệu nên ở trong dạng đã được sắp xếp.

Binary Search tìm kiếm một phần tử cụ thể bằng cách so sánh phần tử tại vị trí giữa nhất của tập dữ liệu. Nếu tìm thấy kết nối thì chỉ mục của phần tử được trả về. Nếu phần tử cần tìm là lớn hơn giá trị phần tử giữa thì phần tử cần tìm được tìm trong mảng con nằm ở bên phải phần tử giữa; nếu không thì sẽ tìm ở trong mảng con nằm ở bên trái phần tử giữa. Tiến trình sẽ tiếp tục như vậy trên mảng con cho tới khi tìm hết mọi phần tử trên mảng con này.

Cách Binary Search làm việc

Để Binary Search làm việc thì mảng phải cần được sắp xếp. Để tiện cho việc theo dõi, mình sẽ cung cấp thêm các hình minh họa tương ứng với mỗi bước.

Giả sử chúng ta cần tìm vị trí của giá trị 31 trong một mảng bao gồm các giá trị như hình dưới đây bởi sử dụng Binary Search:

Giải thuật tìm kiếm nhị phân (Binary Search)

Đầu tiên, chúng ta chia mảng thành hai nửa theo phép toán sau:

chỉ-mục-giữa = ban-đầu + (cuối + ban-đầu)/ 2

Với ví dụ trên là 0 + (9 – 0)/ 2 = 4 (giá trị là 4.5). Do đó 4 là chỉ mục giữa của mảng.

Giải thuật tìm kiếm nhị phân (Binary Search)

Bây giờ chúng ta so sánh giá trị phần tử giữa với phần tử cần tìm. Giá trị phần tử giữa là 27 và phần tử cần tìm là 31, do đó là không kết nối. Bởi vì giá trị cần tìm là lớn hơn nên phần tử cần tìm sẽ nằm ở mảng con bên phải phần tử giữa.

Giải thuật tìm kiếm nhị phân (Binary Search)

Chúng ta thay đổi giá trị ban-đầu thành chỉ-mục-giữa + 1 và lại tiếp tục tìm kiếm giá trị chỉ-mục-giữa.

ban-đầu = chỉ-mục-giữa + 1
chỉ-mục-giữa = ban-đầu + (cuối + ban-đầu)/ 2

Bây giờ chỉ mục giữa của chúng ta là 7. Chúng ta so sánh giá trị tại chỉ mục này với giá trị cần tìm.

Giải thuật tìm kiếm nhị phân (Binary Search)

Giá trị tại chỉ mục 7 là không kết nối, và ngoài ra giá trị cần tìm là nhỏ hơn giá trị tại chỉ mục 7 do đó chúng ta cần tìm trong mảng con bên trái của chỉ mục giữa này.

Giải thuật tìm kiếm nhị phân (Binary Search)

Tiếp tục tìm chỉ-mục-giữa lần nữa. Lần này nó có giá trị là 5.

Giải thuật tìm kiếm nhị phân (Binary Search)

So sánh giá trị tại chỉ mục 5 với giá trị cần tìm và thấy rằng nó kết nối.

Giải thuật tìm kiếm nhị phân (Binary Search)

Do đó chúng ta kết luận rằng giá trị cần tìm 31 được lưu giữ tại vị trí chỉ mục 5.

Binary Search chia đôi lượng phần tử cần tìm và do đó giảm số lượng phép so sánh cần thực hiện nên giải thuật tìm kiếm này được thực hiện khá nhanh.

Giải thuật mẫu cho Binary Search

Dưới đây là code mẫu cho giải thuật tìm kiếm nhị phân:

Giải thuật tìm kiếm nhị phân (Binary Search)
   A ← một mảng đã được sắp xếp
   n ← kích cỡ mảng
   x ← giá trị để tìm kiếm trong mảng

   gán lowerBound = 1
   gán upperBound = n 

   while x not found
   
      if upperBound < lowerBound 
         EXIT: x không tồn tại.
   
      gán midPoint = lowerBound + ( upperBound - lowerBound ) / 2
      
      if A[midPoint] < x
         gán lowerBound = midPoint + 1
         
      if A[midPoint] > x
         gán upperBound = midPoint - 1 

      if A[midPoint] = x 
         EXIT: x được tìm thấy tại midPoint

   kết thúc while
   
kết thúc giải thuật

 


Chương trình minh họa Tìm kiếm nhị phân
 

#include <stdio.h>

#define MAX 20

// khai bao mang 
int intArray[MAX] = {1,2,3,4,6,7,9,11,12,14,15,16,17,19,33,34,43,45,55,66};

void printline(int count){
   int i;
	
   for(i = 0;i <count-1;i++){
      printf("=");
   }
	
   printf("=\n");
}

int find(int data){
   int lowerBound = 0; // chi muc ban dau
   int upperBound = MAX -1; // chi muc cuoi
   int midPoint = -1;
   int comparisons = 0;      
   int index = -1;
	
   while(lowerBound <= upperBound){
      printf("So sanh lan thu %d\n" , (comparisons +1) ) ;
      printf("Chi muc ban dau : %d, intArray[%d] = %d\n", 
		lowerBound,lowerBound,intArray[lowerBound]);
      printf("Chi muc cuoi : %d, intArray[%d] = %d\n", upperBound,upperBound,intArray[upperBound]);
      comparisons++;
		
      // uoc luong gia tri tai vi tri trung vi 
      // midPoint = (lowerBound + upperBound) / 2;
      midPoint = lowerBound + (upperBound - lowerBound) / 2;	
		
      // tim thay du lieu
      if(intArray[midPoint] == data){
         index = midPoint;
         break;
      }else {
         // neu du lieu la lon hon 
         if(intArray[midPoint] < data){
            // tim kiem du lieu phan lon hon
            lowerBound = midPoint + 1;
         }
         // neu du lieu la nho hon 
         else{           
            // tim kiem du lieu phan nho hon
            upperBound = midPoint -1;
         }
      }               
   }
   printf("So phep tinh so sanh thuc hien: %d" , comparisons);
   return index;
}

void display(){
   int i;
   printf("[");
	
   // duyet qua tat ca phan tu 
   for(i = 0;i<MAX;i++){
      printf("%d ",intArray[i]);
   }
	
   printf("]\n");
}

main(){
   printf("Mang du lieu nhap vao: ");
   display();
   printline(50);
	
   //Tim vi tri cua 55
   int location = find(55);

   // neu phan tu duoc tim thay 
   if(location != -1)
      printf("\nTim thay phan tu tai vi tri: %d" ,(location+1));
   else
      printf("\nKhong tim thay phan tu.");
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Tìm kiếm nhị phân (Binary Search) trong C

Giải thuật Tìm kiếm nội suy (Interpolation Search) là gì ?

Tìm kiếm nội suy (Interpolation Search) là biến thể cải tiến của Tìm kiếm nhị phân (Binary Search). Để giải thuật tìm kiếm này làm việc chính xác thì tập dữ liệu phải được sắp xếp.

Binary Search có lợi thế lớn về độ phức tạp thời gian khi so sánh với Linear Search. Linear Search có độ phức tạp trường hợp xấu nhất là Ο(n) trong khi Binary Search là Ο(log n).

Có một số tình huống mà vị trí của dữ liệu cần tìm có thể đã được biết trước. Ví dụ, trong trường hợp danh bạ điện thoại, nếu chúng ta muốn tìm số điện thoại của Hương chẳng hạn. Trong trường hợp này, Linear Search và cả Binary Search có thể là chậm khi thực hiện tìm kiếm, khi mà chúng ta có thể trực tiếp nhảy tới phần không gian bộ nhớ có tên bắt đầu với H được lưu giữ.

Xác định vị trí trong Binary Search

Trong Binary Search, nếu dữ liệu cần tìm không được tìm thấy thì phần còn lại của danh sách được phân chia thành hai phần: phần bên trái (chứa giá trị nhỏ hơn) và phần bên phải (chứa giá trị lớn hơn). Sau đó tiến trình tìm kiếm được thực hiện trên một trong hai phần này.



 

Dò vị trí trong Tìm kiếm nội suy (Interpolation Search)

Tìm kiếm nội suy tìm kiếm một phần tử cụ thể bằng việc tính toán vị trí dò (Probe Position). Ban đầu thì vị trí dò là vị trí của phần tử nằm ở giữa nhất của tập dữ liệu.

Giải thuật Tìm kiếm nội suy (Interpolation Search)

Nếu tìm thấy kết nối thì chỉ mục của phần tử được trả về. Để chia danh sách thành hai phần, chúng ta sử dụng phương thức sau:

mid = Lo + ((Hi - Lo) / (A[Hi] - A[Lo])) * (X - A[Lo])

Trong đó:
   A    = danh sách
   Lo   = chỉ mục thấp nhất của danh sách
   Hi   = chỉ mục cao nhất của danh sách
   A[n] = giá trị được lưu giữ tại chỉ mục n trong danh sách

Nếu phần tử cần tìm có giá trị lớn hơn phần tử ở giữa thì phần tử cần tìm sẽ ở mảng con bên phải phần tử ở giữa và chúng ta lại tiếp tục tính vị trí dò; nếu không phần tử cần tìm sẽ ở mảng con bên trái phần tử ở giữa. Tiến trình này tiến tụp diễn ra trên các mảng con cho tới khi kích cỡ của mảng con giảm về 0.

Độ phức tạp thời gian chạy của Interpolation Search là Ο(log (log n)), trong khi của Binary Search là Ο(log n).

Giải thuật Tìm kiếm nội suy

Bởi vì đây là sự cải tiến của giải thuật Binary Search nên chúng ta sẽ chỉ đề cập tới các bước để tìm kiếm chỉ mục của giá trị cần tìm bởi sử dụng vị trí dò.

Bước 1 : Bắt đầu tìm kiếm dữ liệu từ phần giữa của danh sách
Bước 2 : Nếu đây là một so khớp (một kết nối), thì trả về chỉ mục của phần tử, và thoát.
Bước 3 : Nếu không phải là một so khớp, thì là vị trí dò.
Bước 4 : Chia danh sách bởi sử dụng phép tính tìm vị trí dò và tìm vị trí giữa mới.
Bước 5 : Nếu dữ liệu cần tìm lớn hơn giá trị tại vị trí giữa, thì tìm kiếm trong mảng con bên phải.
Bước 6 : Nếu dữ liệu cần tìm nhỏ hơn giá trị tại vị trí giữa, thì tìm kiếm trong mảng con bên trái
Bước 7 : Lặp lại cho tới khi tìm thấy so khớp

Code mẫu cho giải thuật Tìm kiếm nội suy

A → Mảng
N → Kích cỡ của A
X → Giá trị cần tìm

hàm tìm kiếm nội suy Interpolation_Search()

   Gán Lo  →  0
   Gán Mid → -1
   Gán Hi  →  N-1

   While X không so khớp
   
      if Lo bằng Hi OR A[Lo] bằng A[Hi]
         EXIT: Thất bại, không tìm thấy X
      kết thúc if
      
      Gán Mid = Lo + ((Hi - Lo) / (A[Hi] - A[Lo])) * (X - A[Lo]) 

      if A[Mid] = X
         EXIT: Thành công, tìm thấy tại Mid
      else 
         if A[Mid] < X
            Thiết lập Lo thành Mid+1
         else if A[Mid] > X
            Thiết lập Hi thành Mid-1
         kết thúc if
      kết thúc if
 
   Kết thúc While

Kết thúc hàm


Chương trình minh họa tìm kiếm nội suy trong C

#include<stdio.h>

#define MAX 10

// khai bao mot mang 
int list[MAX] = { 10, 14, 19, 26, 27, 31, 33, 35, 42, 44 };

int find(int data) {
   int lo = 0;
   int hi = MAX - 1;
   int mid = -1;
   int comparisons = 1;      
   int index = -1;

   while(lo <= hi) {
      printf("\nSo sanh lan thu %d  \n" , comparisons ) ;
      printf("lo : %d, list[%d] = %d\n", lo, lo, list[lo]);
      printf("hi : %d, list[%d] = %d\n", hi, hi, list[hi]);
      
      comparisons++;

      // phan tu chot (probe) tai vi tri trung vi
      mid = lo + (((double)(hi - lo) / (list[hi] - list[lo])) * (data - list[lo]));
      printf("Vi tri trung vi = %d\n",mid);

      // tim thay du lieu
      if(list[mid] == data) {
         index = mid;
         break;
      }else {
         if(list[mid] < data) {
            // neu du lieu co gia tri lon hon, tim du lieu trong phan lon hon 
            lo = mid + 1;
         }else {
            // neu du lieu co gia tri nho hon, tim du lieu trong phan nho hon 
            hi = mid - 1;
         }
      }               
   }
   
   printf("\nTong so phep so sanh da thuc hien: %d", --comparisons);
   return index;
}

int main() {
   //Tim vi tri cua phan tu 33
   int location = find(33);

   // Neu tim thay phan tu 
   if(location != -1)
      printf("\nTim thay phan tu tai vi tri: %d" ,(location+1));
   else
      printf("Khong tim thay phan tu.");
   
   return 0;
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Tìm kiếm nội suy trong C


 

Hash Table là gì?

Cấu trúc dữ liệu Hash Table là một cấu trúc dữ liệu lưu giữ dữ liệu theo cách thức liên hợp. Trong Hash Table, dữ liệu được lưu giữ trong định dạng mảng, trong đó các giá trị dữ liệu có giá trị chỉ mục riêng. Việc truy cập dữ liệu trở nên nhanh hơn nếu chúng ta biết chỉ mục của dữ liệu cần tìm.

Do đó, với loại cấu trúc dữ liệu Hash Table này thì các hoạt động chèn và hoạt động tìm kiếm sẽ diễn ra rất nhanh, bất chấp kích cỡ của dữ liệu là bao nhiêu. Hash Table sử dụng mảng như là một kho lưu giữ trung gian và sử dụng kỹ thuật Hash để tạo chỉ mục tại nơi phần tử được chèn vào.

Kỹ thuật Hashing

Hashing là một kỹ thuật để chuyển đổi một dãy các giá trị khóa (key) vào trong một dãy các giá trị chỉ mục (index) của một mảng. Chúng ta đang sử dụng toán tử lấy phần dư để thu được một dãy các giá trị khóa. Giả sử có một HashTable có kích cỡ là 20, và dưới đây là các phần tử cần được lưu giữ. Phần tử trong định dạng (key, value).

Cấu trúc dữ liệu hash table

  • (1,20)
  • (2,70)
  • (42,80)
  • (4,25)
  • (12,44)
  • (14,32)
  • (17,11)
  • (13,78)
  • (37,98)

 

Stt Key Hash Chỉ mục mảng
1 1 1 % 20 = 1 1
2 2 2 % 20 = 2 2
3 42 42 % 20 = 2 2
4 4 4 % 20 = 4 4
5 12 12 % 20 = 12 12
6 14 14 % 20 = 14 14
7 17 17 % 20 = 17 17
8 13 13 % 20 = 13 13
9 37 37 % 20 = 17 17

Kỹ thuật Dò tuyến tính (Linear Probing)

Chúng ta thấy rằng có thể xảy ra trường hợp mà kỹ thuật Hashing được sử dụng để tạo chỉ mục đã tồn tại trong mảng. Trong tình huống này, chúng ta cần tìm kiếm vị trí trống kế tiếp trong mảng bằng việc nhìn vào trong ô tiếp theo cho tới khi chúng ta tìm thấy một ô trống. Kỹ thuật này được gọi là Dò tuyến tính (Linear Probing).

Stt Key Hash Chỉ mục mảng Sau kỹ thuật Linear Probing, chỉ mục mảng
1 1 1 % 20 = 1 1 1
2 2 2 % 20 = 2 2 2
3 42 42 % 20 = 2 2 3
4 4 4 % 20 = 4 4 4
5 12 12 % 20 = 12 12 12
6 14 14 % 20 = 14 14 14
7 17 17 % 20 = 17 17 17
8 13 13 % 20 = 13 13 13
9 37 37 % 20 = 17 17 18

Các hoạt động cơ bản trên Hash Table

Dưới đây là một số hoạt động cơ bản có thể được thực hiện trên cấu trúc dữ liệu Hash Table.

  • Hoạt động tìm kiếm: tìm kiếm một phần tử trong cấu trúc dữ liệu HashTable.

  • Hoạt động chèn: chèn một phần tử vào trong cấu trúc dữ liệu HashTable.

  • Hoạt động xóa: xóa một phần tử từ cấu trúc dữ liệu HashTable.

Phần tử dữ liệu (DataItem) trong HashTable

Phần tử dữ liệu bao gồm: data và key. Dựa vào key này chúng ta có thể thực hiện các hoạt động tìm kiếm trong cấu trúc dữ liệu HashTable.

struct DataItem {
   int data;   
   int key;
};

Phương thức của cấu trúc dữ liệu Hash Table

Xác định một phương thức để ước lượng Hash Code của key của phần tử dữ liệu.

int hashCode(int key){
   return key % SIZE;
}

Hoạt động tìm kiếm trong Hash Table

Mỗi khi một phần tử được tìm kiếm: ước lượng giá trị hash code của key đã truyền vào và sau đó xác định vị trí của phần tử bởi sử dụng giá trị hash code đó giống như là chỉ mục trong mảng. Sử dụng kỹ thuật Dò tuyến tính (Linear Probing) để lấy phần tử nếu như không tìm thấy phần tử với giá trị hash code đã ước lượng.

struct DataItem *search(int key){               
   //lấy giá trị hash 
   int hashIndex = hashCode(key);   
	
   //di chuyển trong mảng cho tới khi gặp ô trống 
   while(hashArray[hashIndex] != NULL){
	
      if(hashArray[hashIndex]->key == key)
         return hashArray[hashIndex];
			
      //tới ô tiếp theo
      ++hashIndex;
		
      //bao quanh hash table
      hashIndex %= SIZE;
   }
	
   return NULL;        
}

Để theo dõi code đầy đủ các hoạt động trong Hash Table, mời bạn click chuột vào chương: Hash Table trong C.

Hoạt động chèn trong Hash Table

Mỗi khi một phần tử được chèn: ước lượng giá trị hash code của key đã truyền và xác định vị trí của phần tử bởi sử dụng giá trị hash code đó giống như là chỉ mục trong mảng. Sử dụng Dò tuyến tính (Linear Probing) cho vị trí trống nếu phần tử được tìm thấy với giá trị hash code đã ước lượng.

void insert(int key,int data){
   struct DataItem *item = (struct DataItem*) malloc(sizeof(struct DataItem));
   item->data = data;  
   item->key = key;     

   //Lấy giá trị hash
   int hashIndex = hashCode(key);

   //di chuyển trong mảng cho tới khi gặp ô trống hoặc bị xóa
   while(hashArray[hashIndex] != NULL && hashArray[hashIndex]->key != -1){
      //tới ô tiếp theo
      ++hashIndex;
		
      //bao quanh hash table
      hashIndex %= SIZE;
   }
	
   hashArray[hashIndex] = item;        
}

Để theo dõi code đầy đủ các hoạt động trong Hash Table, mời bạn click chuột vào chương: Hash Table trong C.

Hoạt động xóa trong Hash Table

Mỗi khi một phần tử cần được xóa: ước lượng giá trị hash code của key đã truyền vào và sau đó xác định vị trí của phần tử bởi sử dụng giá trị hash code đó giống như là chỉ mục trong mảng. Sử dụng Dò tuyến tính (Linear Probing) để lấy phần tử nếu như không tìm thấy phần tử với giá trị hash code đã ước lượng. Khi tìm thấy, lưu trữ một phần tử giả tại đây để giữ hiệu suất của hash table.

struct DataItem* delete(struct DataItem* item){
   int key = item->key;

   //lấy giá trị hash 
   int hashIndex = hashCode(key);

   //di chuyển trong mảng cho tới khi gặp ô trống
   while(hashArray[hashIndex] !=NULL){
	
      if(hashArray[hashIndex]->key == key){
         struct DataItem* temp = hashArray[hashIndex]; 
			
         //gán một phần tử giả tại vị trí bị xóa
         hashArray[hashIndex] = dummyItem; 
         return temp;
      } 
		
      //tới ô tiếp theo
      ++hashIndex;
		
      //bao quanh hash table
      hashIndex %= SIZE;
   }  
	
   return NULL;        
}

Để theo dõi code đầy đủ các hoạt động trong Hash Table, mời bạn click chuột vào chương: Hash Table trong C

Giải thuật sắp xếp trong cấu trúc dữ liệu & giải thuật

Sắp xếp là sắp xếp dữ liệu theo một định dạng cụ thể. Trong khoa học máy tính, giải thuật sắp xếp xác định cách để sắp xếp dữ liệu theo một thứ tự nào đó. Sắp xếp theo thứ tự ở đây là sắp xếp theo thứ tự dạng số hoặc thứ tự dạng chữ cái như trong từ điển.

Tính quan trọng của việc sắp xếp dữ liệu nằm ở chỗ: việc tìm kiếm dữ liệu có thể được tối ưu nếu dữ liệu được sắp xếp theo một thứ tự nào đó (tăng hoặc giảm). Sắp xếp cũng được sử dụng để biểu diễn dữ liệu trong một định dạng dễ đọc hơn.

Giải thuật sắp xếp In-place và Not-in-place

Các giải thuật sắp xếp có thể cần thêm một số bộ nhớ phụ để so sánh và bộ nhớ tạm để lưu giữ một số phần tử dữ liệu.

Những giải thuật mà không yêu cầu thêm bất kỳ bộ nhớ phụ và việc sắp xếp được tiến hành trong chính phần bộ nhớ đã khai báo trước đó (ví dụ trong một mảng chẳng hạn) thì được gọi là in-place sorting. Ví dụ cho loại giải thuật sắp xếp này là giải thuật sắp xếp nổi bọt (bubble sorting).

Nhưng trong một số giải thuật sắp xếp, chương trình cần thêm lượng bộ nhớ mà có thể lớn hơn hoặc bằng với số phần tử đang được sắp xếp. Các giải thuật này được gọi là not-in-place sorting. Ví dụ cho loại giải thuật này là sắp xếp trộn (merge sort).

Giải thuật sắp xếp cố định và sắp xếp so sánh

Một giải thuật sắp xếp được gọi là sắp xếp cố định nếu sau khi tiến hành sắp xếp thì vị trí tương đối giữa các phần tử bằng nhau không bị thay đổi.

Giải thuật Sắp xếp cố định

Một giải thuật được gọi là sắp xếp so sánh nếu trong quá trình thực hiện giải thuật chúng ta tiến hành so sánh các khóa và đổi chỗ các phần tử cho nhau. Tức là khi đó vị trí tương đối của các phần tử bằng nhau bị thay đổi.

Giải thuật sắp xếp so sánh

Giải thuật sắp xếp Adaptive và Non-Adaptive

Một giải thuật được xem như là adaptive, nếu nó tận dụng các phần tử đã được sắp xếp trong danh sách mà đã được sắp xếp. Đó là, trong khi sắp xếp nếu danh sách ban đầu có một số phần tử đã được sắp xếp, thì giải thuật dạng adaptive sẽ ghi nhận các phần tử này và sẽ cố gắng không thay đổi thứ tự của chúng.

Trái ngược với loại giải thuật trên, giải thuật dạng non-adaptive sẽ không ghi nhận các phần tử đã được sắp xếp trước đó. Giải thuật loại này sẽ vấn cố gắng sắp xếp lại từng phần tử trong danh sách ban đầu.

Các khái niệm quan trọng trong giải thuật sắp xếp
Dưới đây là phần giới thiệu ngắn gọn cho một số khái niệm xuất hiện trong khi thảo luận về các giải thuật sắp xếp:

Thứ tự tăng
Một dãy giá trị được xem như trong thứ tự tăng dần nếu phần tử đứng sau lớn hơn phần tử đứng trước. Ví dụ: 1, 3, 5, 6, 9.

Thứ tự giảm
Một dãy giá trị được xem như trong thứ tự giảm dần nếu phần tử đứng sau nhỏ hơn phần tử đứng trước. Ví dụ: 9, 6, 5, 3, 1.

Thứ tự không tăng
Một dãy giá trị được xem như trong thứ tự không tăng nếu phần tử đứng sau nhỏ hơn hoặc bằng phần tử đứng trước. Ví dụ: 9, 6, 5, 5, 1. Loại thứ tự này xuất hiện khi trong một dãy có chứa các giá trị giống nhau (trong ví dụ là 5).
Thứ tự không giảm

Một dãy giá trị được xem như trong thứ tự không giảm nếu phần tử đứng sau lớn hơn hoặc bằng phần tử đứng trước. Ví dụ: 1, 5, 5, 6, 9. Loại thứ tự này xuất hiện khi trong một dãy có chứa các giá trị giống nhau (trong ví dụ là 5).


Sắp xếp nổi bọt (Bubble Sort) là gì ?

Sắp xếp nổi bọt là một giải thuật sắp xếp đơn giản. Giải thuật sắp xếp này được tiến hành dựa trên việc so sánh cặp phần tử liền kề nhau và tráo đổi thứ tự nếu chúng không theo thứ tự.
Giải thuật này không thích hợp sử dụng với các tập dữ liệu lớn khi mà độ phức tạp trường hợp xấu nhất và trường hợp trung bình là Ο(n2) với n là số phần tử.
Giải thuật sắp xếp nổi bọt là giải thuật chậm nhất trong số các giải thuật sắp xếp cơ bản. Giải thuật này còn chậm hơn giải thuật đổi chỗ trực tiếp mặc dù số lần so sánh bằng nhau, nhưng do đổi chỗ hai phần tử kề nhau nên số lần đổi chỗ nhiều hơn.

Cách giải thuật sắp xếp nổi bọt làm việc?

Giả sử chúng ta có một mảng không có thứ tự gồm các phần tử như dưới đây. Bây giờ chúng ta sử dụng giải thuật sắp xếp nổi bọt để sắp xếp mảng này.

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

Giải thuật sắp xếp nổi bọt bắt đầu với hai phần tử đầu tiên, so sánh chúng để kiểm tra xem phần tử nào lớn hơn.

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

Trong trường hợp này, 33 lớn hơn 14, do đó hai phần tử này đã theo thứ tự. Tiếp đó chúng ta so sánh 3327.

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

Chúng ta thấy rằng 33 lớn hơn 27, do đó hai giá trị này cần được tráo đổi thứ tự.

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

Mảng mới thu được sẽ như sau:

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

Tiếp đó chúng ta so sánh 33 và 35. Hai giá trị này đã theo thứ tự.

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

Sau đó chúng ta so sánh hai giá trị kế tiếp là 3510.

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

Vì 10 nhỏ hơn 35 nên hai giá trị này chưa theo thứ tự.

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

Tráo đổi thứ tự hai giá trị. Chúng ta đã tiến tới cuối mảng. Vậy là sau một vòng lặp, mảng sẽ trông như sau:

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

Để đơn giản, tiếp theo mình sẽ hiển thị hình ảnh của mảng sau từng vòng lặp. Sau lần lặp thứ hai, mảng sẽ trông giống như:

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

Sau mỗi vòng lặp, ít nhất một giá trị sẽ di chuyển tới vị trí cuối. Sau vòng lặp thứ 3, mảng sẽ trông giống như:

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

Và khi không cần tráo đổi thứ tự phần tử nào nữa, giải thuật sắp xếp nổi bọt thấy rằng mảng đã được sắp xếp xong.

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

Tiếp theo, chúng ta tìm hiểu thêm một số khía cạnh thực tế của giải thuật sắp xếp.

Giải thuật cho sắp xếp nổi bọt (Bubble Sort)

Giả sử list là một mảng có n phần tử. Tiếp đó giả sử hàm swap để tráo đổi giá trị của các phần tử trong mảng (đây là giả sử, tất nhiên là bạn có thể viết code riêng cho hàm swap này).

Bắt đầu giải thuật BubbleSort(list)

   for tất cả phần tử trong list
      if list[i] > list[i+1]
         swap(list[i], list[i+1])
      kết thúc if
   kết thúc for
   
   return list
   
Kết thúc BubbleSort

Giải thuật mẫu cho sắp xếp nổi bọt (Bubble Sort)

Chúng ta thấy rằng giải thuật sắp xếp nổi bọt so sánh mỗi cặp phần tử trong mảng trừ khi cả toàn bộ mảng đó đã hoàn toàn được sắp xếp theo thứ tự tăng dần. Điều này có thể làm tăng độ phức tạp, tức là tăng các thao tác so sánh và tráo đổi không cần thiết nếu như mảng này không cần sự tráo đổi nào nữa khi tất cả các phần tử đã được sắp xếp theo thứ tự tăng dần rồi.

Để tránh việc này xảy ra, chúng ta có thể sử dụng một biến swapped chẳng hạn để giúp chúng ta biết có cần thực hiện thao tác tráo đổi thứ tự hay không. Nếu không cần thiết thì thoát khỏi vòng lặp.

Bạn theo dõi phần giải thuật mẫu minh họa sau:

Bắt đầu hàm bubbleSort( list : mảng các phần tử )

   loop = list.count;
   
   for i = 0 tới loop-1 thực hiện:
      swapped = false
		
      for j = 0 tới loop-1 thực hiện:
      
         /* so sánh các phần tử cạnh nhau */   
         if list[j] > list[j+1] then
            /* tráo đổi chúng */
            swap( list[j], list[j+1] )		 
            swapped = true
         kết thúc if
         
      kết thúc for
      
      /*Nếu không cần tráo đổi phần tử nào nữa thì 
      tức là mảng đã được sắp xếp. Thoát khỏi vòng lặp.*/
      
      if(not swapped) then
         break
      kết thúc if
      
   kết thúc for
   
Kết thúc hàm return list

Triển khai giải thuật sắp xếp nổi bọt trong C

Một điều nữa mà chúng ta chưa nói tới trong 2 phần thiết kế giải thuật đó là cứ sau mỗi vòng lặp thì các giá trị lớn nhất sẽ xuất hiện ở vị trí cuối mảng (như trong hình minh họa: sau vòng lặp 1 là 35; sau vòng lặp 2 là 33 và 35; …). Do đó, vòng lặp tiếp theo sẽ không cần bao gồm cả các phần tử đã được sắp xếp này. Để thực hiện điều này, trong phần code chúng ta giới hạn vòng lặp lặp bên để tránh phải lặp lại các giá trị đã qua sắp xếp này.

Để theo dõi code đầy đủ của giải thuật sắp xếp nổi bọt trong ngôn ngữ C, mời bạn click chuột vào chương: Sắp xếp nổi bọt (Bubble Sort) trong C.

Sắp xếp chèn (Insertion Sort) là gì ?

Sắp xếp chèn là một giải thuật sắp xếp dựa trên so sánh in-place. Ở đây, một danh sách con luôn luôn được duy trì dưới dạng đã qua sắp xếp. Sắp xếp chèn là chèn thêm một phần tử vào danh sách con đã qua sắp xếp. Phần tử được chèn vào vị trí thích hợp sao cho vẫn đảm bảo rằng danh sách con đó vẫn sắp theo thứ tự.

Với cấu trúc dữ liệu mảng, chúng ta tưởng tượng là: mảng gồm hai phần: một danh sách con đã được sắp xếp và phần khác là các phần tử không có thứ tự. Giải thuật sắp xếp chèn sẽ thực hiện việc tìm kiếm liên tiếp qua mảng đó, và các phần tử không có thứ tự sẽ được di chuyển và được chèn vào vị trí thích hợp trong danh sách con (của cùng mảng đó).

Giải thuật này không thích hợp sử dụng với các tập dữ liệu lớn khi độ phức tạp trường hợp xấu nhất và trường hợp trung bình là Ο(n2) với n là số phần tử.

Cách giải thuật sắp xếp chèn thực hiện?

Ví dụ chúng ta có một mảng gồm các phần tử không có thứ tự:

Sắp xếp chèn (Insertion Sort)

Giải thuật sắp xếp chèn so sánh hai phần tử đầu tiên:

Sắp xếp chèn (Insertion Sort)

Giải thuật tìm ra rằng cả 14 33 đều đã trong thứ tự tăng dần. Bây giờ, 14 là trong danh sách con đã qua sắp xếp.

Sắp xếp chèn (Insertion Sort)

Giải thuật sắp xếp chèn tiếp tục di chuyển tới phần tử kế tiếp và so sánh 33 27.

Sắp xếp chèn (Insertion Sort)

Và thấy rằng 33 không nằm ở vị trí đúng.

Sắp xếp chèn (Insertion Sort)

Giải thuật sắp xếp chèn tráo đổi vị trí của 33 27. Đồng thời cũng kiểm tra tất cả phần tử trong danh sách con đã sắp xếp. Tại đây, chúng ta thấy rằng trong danh sách con này chỉ có một phần tử 1427 là lớn hơn 14. Do vậy danh sách con vẫn giữ nguyên sau khi đã tráo đổi.

Sắp xếp chèn (Insertion Sort)

Bây giờ trong danh sách con chúng ta có hai giá trị 14 27. Tiếp tục so sánh 33 với 10.

Sắp xếp chèn (Insertion Sort)

Hai giá trị này không theo thứ tự.

Sắp xếp chèn (Insertion Sort)

Vì thế chúng ta tráo đổi chúng.

Sắp xếp chèn (Insertion Sort)

Việc tráo đổi dẫn đến 27 10 không theo thứ tự.

Sắp xếp chèn (Insertion Sort)

Vì thế chúng ta cũng tráo đổi chúng.

Sắp xếp chèn (Insertion Sort)

Chúng ta lại thấy rằng 14 10 không theo thứ tự.

Sắp xếp chèn (Insertion Sort)

Và chúng ta tiếp tục tráo đổi hai số này. Cuối cùng, sau vòng lặp thứ 3 chúng ta có 4 phần tử.

Sắp xếp chèn (Insertion Sort)

Tiến trình trên sẽ tiếp tục diễn ra cho tới khi tất cả giá trị chưa được sắp xếp được sắp xếp hết vào trong danh sách con đã qua sắp xếp.

Tiếp theo chúng ta cùng tìm hiểu khía cạnh lập trình của giải thuật sắp xếp chèn.

Giải thuật sắp xếp chèn (Insertion Sort)

Từ minh họa trên chúng ta đã có bức tranh tổng quát về giải thuật sắp xếp chèn, từ đó chúng ta sẽ có các bước cơ bản trong giải thuật như sau:

Bước 1: Kiểm tra nếu phần tử đầu tiên đã được sắp xếp. trả về 1
Bước 2: Lấy phần tử kế tiếp
Bước 3: So sánh với tất cả phần tử trong danh sách con đã qua sắp xếp
Bước 4: Dịch chuyển tất cả phần tử trong danh sách con mà lớn hơn giá trị để được sắp xếp
Bước 5: Chèn giá trị đó
Bước 6: Lặp lại cho tới khi danh sách được sắp xếp

Giải thuật mẫu cho sắp xếp nổi bọt

Bắt đầu hàm insertionSort( A : mảng phần tử )
   int holePosition
   int valueToInsert
	
   for i = 1 tới length(A) thực hiện:
	
      /* chọn một giá trị để chèn */
      valueToInsert = A[i]
      holePosition = i
      
      /*xác định vị trí cho phần tử được chèn */
		
      while holePosition > 0 và A[holePosition-1] > valueToInsert thực hiện:
         A[holePosition] = A[holePosition-1]
         holePosition = holePosition -1
      kết thúc while
		
      /* chèn giá trị tại vị trí trên */
      A[holePosition] = valueToInsert
      
   kết thúc for
	
Kết thúc hàm

Để theo dõi code đầy đủ của giải thuật sắp xếp chèn trong ngôn ngữ C:

#include <stdio.h>
#include <stdbool.h>
#define MAX 7

int intArray[MAX] = {4,6,3,2,1,9,7};

void printline(int count){
   int i;
	
   for(i = 0;i <count-1;i++){
      printf("=");
   }
	
   printf("=\n");
}

void display(){
   int i;
   printf("[");
	
   // duyet qua tat ca phan tu
   for(i = 0;i<MAX;i++){
      printf("%d ",intArray[i]);
   }
	
   printf("]\n");
}

void insertionSort(){

   int valueToInsert;
   int holePosition;
   int i;
  
   // lap qua tat ca cac so
   for(i = 1; i < MAX; i++){ 
	
      // chon mot gia tri de chen
      valueToInsert = intArray[i];
		
      // lua chon vi tri de chen
      holePosition = i;
		
      // kiem tra xem so lien truoc co lon hon gia tri duoc chen khong
      while (holePosition > 0 && intArray[holePosition-1] > valueToInsert){
         intArray[holePosition] = intArray[holePosition-1];
         holePosition--;
         printf(" Di chuyen phan tu : %d\n" , intArray[holePosition]);
      }

      if(holePosition != i){
         printf(" Chen phan tu : %d, tai vi tri : %d\n" , valueToInsert,holePosition);
         // chen phan tu tai vi tri chen 
         intArray[holePosition] = valueToInsert;   
      }

      printf("Vong lap thu %d#:",i);
      display();
		
   }  
}

main(){
   printf("Mang du lieu dau vao: ");
   display();
   printline(50);
   insertionSort();
   printf("Mang sau khi da sap xep: ");
   display();
   printline(50);
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Sắp xếp chèn (Insertion Sort) trong C


 

Giải thuật sắp xếp chọn (Selection Sort) là gì ?

Giải thuật sắp xếp chọn (Selection Sort) là một giải thuật đơn giản. Giải thuật sắp xếp này là một giải thuật dựa trên việc so sánh in-place, trong đó danh sách được chia thành hai phần, phần được sắp xếp (sorted list) ở bên trái và phần chưa được sắp xếp (unsorted list) ở bên phải. Ban đầu, phần được sắp xếp là trống và phần chưa được sắp xếp là toàn bộ danh sách ban đầu.

Phần tử nhỏ nhất được lựa chọn từ mảng chưa được sắp xếp và được tráo đổi với phần bên trái nhất và phần tử đó trở thành phần tử của mảng được sắp xếp. Tiến trình này tiếp tục cho tới khi toàn bộ từng phần tử trong mảng chưa được sắp xếp đều được di chuyển sang mảng đã được sắp xếp.

Giải thuật này không phù hợp với tập dữ liệu lớn khi mà độ phức tạp trường hợp xấu nhất và trường hợp trung bình là O(n2) với n là số phần tử.

Bạn tìm hiểu khái niệm in-place trong chương: Một số khái niệm cơ bản về giải thuật sắp xếp.

Cách giải thuật sắp xếp chọn (Selection Sort) làm việc

Dưới đây là các hình minh họa cho cách giải thuật sắp xếp chọn làm việc. Giả sử chúng ta có một mảng như sau:

Giải thuật sắp xếp chọn (Selection Sort)

Từ vị trí đầu tiên trong danh sách đã được sắp xếp, toàn bộ danh sách được duyệt một cách liên tục. Vị trí đầu tiên có giá trị 14, chúng ta tìm toàn bộ danh sách và thấy rằng 10 là giá trị nhỏ nhất.

Giải thuật sắp xếp chọn (Selection Sort)

Do đó, chúng ta thay thế 14 với 10. Sau một vòng lặp, giá trị 10 thay thế cho giá trị 14 tại vị trí đầu tiên trong danh sách đã được sắp xếp. Chúng ta tráo đổi hai giá trị này.

Giải thuật sắp xếp chọn (Selection Sort)

Tại vị trí thứ hai, giá trị 33, chúng ta tiếp tục quét phần còn lại của danh sách theo thứ tự từng phần tử.

Giải thuật sắp xếp chọn (Selection Sort)

Chúng ta thấy rằng 14 là giá trị nhỏ nhất thứ hai trong danh sách và nó nên xuất hiện ở vị trí thứ hai. Chúng ta tráo đổi hai giá trị này.

Giải thuật sắp xếp chọn (Selection Sort)

 

Sau hai vòng lặp, hai giá trị nhỏ nhất đã được đặt tại phần đầu của danh sách đã được sắp xếp.

Giải thuật sắp xếp chọn (Selection Sort)

Tiến trình tương tự sẽ được áp dụng cho phần còn lại của danh sách. Các hình dưới minh họa cho các tiến trình này.

Giải thuật sắp xếp chọn (Selection Sort)

Tiếp theo chúng ta sẽ theo dõi một số khía cạnh khác của giải thuật sắp xếp chọn.

Giải thuật cho sắp xếp chọn (Selection Sort)

Bước 1: Thiết lập MIN về vị trí 0
Bước 2: Tìm kiếm phần tử nhỏ nhất trong danh sách
Bước 3: Tráo đổi với giá trị tại vị trí MIN
Bước 4: Tăng MIN để trỏ tới phần tử tiếp theo
Bước 5: Lặp lại cho tới khi toàn bộ danh sách đã được sắp xếp

Giải thuật mẫu cho sắp xếp chọn

Bắt đầu giải thuật sắp xếp chọn (Selection Sort) 
   list  : mảng các phần tử
   n     : kích cỡ mảng

   for i = 1 tới n - 1
   /* thiết lập phần tử hiện tại là min*/
      min = i    
  
      /* kiểm tra phần tử có là nhỏ nhất không */

      for j = i+1 tới n 
         if list[j] < list[min] thì
            min = j;
         kết thúc if
      kết thúc for

      /* tráo đổi phần tử nhỏ nhất với phần tử hiện tại*/
      if indexMin != i  then
         tráo đổi list[min] và list[i]
      kết thúc if

   kết thúc for
	
Kết thúc giải thuật

 


Chương trình minh họa sắp xếp chọn (Selection Sort) trong C

#include <stdio.h>
#include <stdbool.h>
#define MAX 7

int intArray[MAX] = {4,6,3,2,1,9,7};

void printline(int count){
   int i;
	
   for(i = 0;i <count-1;i++){
      printf("=");
   }
	
   printf("=\n");
}

void display(){
   int i;
   printf("[");
	
   // duyet qua tat ca phan tu 
   for(i = 0;i<MAX;i++){
      printf("%d ", intArray[i]);
   }
	
   printf("]\n");
}

void selectionSort(){

   int indexMin,i,j; 
	
   // lap qua ta ca cac so
   for(i = 0; i < MAX-1; i++){ 
	
      // thiet lap phan tu hien tai la min 
      indexMin = i;
		
      // kiem tra phan tu hien tai co phai la nho nhat khong 
      for(j = i+1;j<MAX;j++){
         if(intArray[j] < intArray[indexMin]){
            indexMin = j;
         }
      }

      if(indexMin != i){
         printf("Trao doi phan tu: [ %d, %d ]\n" , intArray[i], intArray[indexMin]); 
			
         // Trao doi cac so 
         int temp = intArray[indexMin];
         intArray[indexMin] = intArray[i];
         intArray[i] = temp;
      }          

      printf("Vong lap thu %d#:",(i+1));
      display();
   }
}  

main(){
   printf("Mang du lieu dau vao: ");
   display();
   printline(50);
   selectionSort();
   printf("Mang sau khi da sap xep: ");
   display();
   printline(50);
}

 

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:


 

Giải thuật sắp xếp trộn (Merge Sort) là gì ?

Sắp xếp trộn (Merge Sort) là một giải thuật sắp xếp dựa trên giải thuật Chia để trị (Divide and Conquer). Với độ phức tạp thời gian trường hợp xấu nhất là Ο(n log n) thì đây là một trong các giải thuật đáng được quan tâm nhất.

Đầu tiên, giải thuật sắp xếp trộn chia mảng thành hai nửa và sau đó kết hợp chúng lại với nhau thành một mảng đã được sắp xếp.

Cách giải thuật sắp xếp trộn (Merge Sort) làm việc

Dưới đây là các hình minh họa cách giải thuật sắp xếp trộn làm việc. Giả sử chúng ta có mảng sau:

Giải thuật sắp xếp trộn (Merge Sort)

Đầu tiên, giải thuật sắp xếp trộn chia toàn bộ mảng thành hai nửa. Tiến trình chia này tiếp tục diễn ra cho đến khi không còn chia được nữa và chúng ta thu được các giá trị tương ứng biểu diễn các phần tử trong mảng. Trong hình dưới, đầu tiên chúng ta chia mảng kích cỡ 8 thành hai mảng kích cỡ 4.

Giải thuật sắp xếp trộn (Merge Sort)

Tiến trình chia này không làm thay đổi thứ tự các phần tử trong mảng ban đầu. Bây giờ chúng ta tiếp tục chia các mảng này thành 2 nửa.

Giải thuật sắp xếp trộn (Merge Sort)

Tiến hành chia tiếp cho tới khi không còn chia được nữa.

Giải thuật sắp xếp trộn (Merge Sort)

Bây giờ chúng ta tổ hợp chúng theo như đúng cách thức mà chúng được chia ra.

Đầu tiên chúng ta so sánh hai phần tử trong mỗi list và sau đó tổ hợp chúng vào trong một list khác theo cách thức đã được sắp xếp. Ví dụ, 14 và 33 là trong các vị trí đã được sắp xếp. Chúng ta so sánh 27 và 10 và trong list khác chúng ta đặt 10 ở đầu và sau đó là 27. Tương tự, chúng ta thay đổi vị trí của 19 và 35. 42 và 44 được đặt tương ứng.

Giải thuật sắp xếp trộn (Merge Sort)

Vòng lặp tiếp theo là để kết hợp từng cặp list một ở trên. Chúng ta so sánh các giá trị và sau đó hợp nhất chúng lại vào trong một list chứa 4 giá trị, và 4 giá trị này đều đã được sắp thứ tự.

Giải thuật sắp xếp trộn (Merge Sort)

Sau bước kết hợp cuối cùng, danh sách sẽ trông giống như sau:

Giải thuật sắp xếp trộn (Merge Sort)

Phần tiếp theo chúng ta tìm hiểu một số khía cạnh khác của giải thuật sắp xếp trộn.

Giải thuật cho Sắp xếp trộn (Merge Sort)

Giải thuật sắp xếp trộn tiếp tục tiến trình chia danh sách thành hai nửa cho tới khi không thể chia được nữa. Theo định nghĩa, một list mà chỉ có một phần tử thì list này coi như là đã được sắp xếp. Sau đó, giải thuật sắp xếp trộn kết hợp các sorted list lại với nhau để tạo thành một list mới mà cũng đã được sắp xếp.

Bước 1: Nếu chỉ có một phần tử trong list thì list này được xem như là đã được 
sắp xếp. Trả về list hay giá trị nào đó.
Bước 2: Chia list một cách đệ qui thành hai nửa cho tới khi không thể chia được nữa.
Bước 3: Kết hợp các list nhỏ hơn (đã qua sắp xếp) thành list mới (cũng đã được sắp xếp).

Giải thuật mẫu cho Sắp xếp trộn (Merge Sort)

Có thể nói rằng với giải thuật sắp xếp trộn, bạn cần chú ý hai điểm chính: chia và hợp.

Bởi vì giải thuật sắp xếp trộn làm việc theo phương thức đệ qui nên phần triển khai giải thuật chúng ta cũng nên sử dụng đệ qui để biểu diễn.

Bắt đầu giải thuật sắp xếp trộn mergesort( biến a là một mảng )
   if ( n == 1 ) return a

   khai báo biến l1 là một mảng = a[0] ... a[n/2]
   khai báo biến l2 là một mảng = a[n/2+1] ... a[n]

   l1 = mergesort( l1 )
   l2 = mergesort( l2 )

   return merge( l1, l2 ) // gọi hàm merge()
Kết thúc giải thuật


Bắt đầu hàm merge( Mảng a, mảng b )

   khai báo biến c là một mảng

   while ( a và b có phần tử )
      if ( a[0] > b[0] )
         Thêm b[0] vào cuối mảng c
         Xóa b[0] từ b
      else
         Thêm a[0] vào cuối mảng c
         Xóa a[0] từ a
      kết thúc if
   kết thúc while
   
   while ( a có phần tử )
      Thêm a[0] vào cuối mảng c
      Xóa a[0] từ a
   kết thúc while
   
   while ( b có phần tử )
      Thêm b[0] vào cuối mảng c
      Xóa b[0] từ b
   kết thúc while
   
   return c
	
Kết thúc hàm

Chương trình minh họa sắp xếp trộn (Merge Sort) trong C:

#include <stdio.h>
#define max 10

int a[10] = { 10, 14, 19, 26, 27, 31, 33, 35, 42, 44 };
int b[10];


void merging(int low, int mid, int high) {
   int l1, l2, i;

   for(l1 = low, l2 = mid + 1, i = low; l1 <= mid && l2 <= high; i++) {
      if(a[l1] <= a[l2])
         b[i] = a[l1++];
      else
         b[i] = a[l2++];
   }

   while(l1 <= mid)    
      b[i++] = a[l1++];

   while(l2 <= high)   
      b[i++] = a[l2++];

   for(i = low; i <= high; i++)
      a[i] = b[i];
}

void sort(int low, int high) {
   int mid;
   
   if(low < high) {
      mid = (low + high) / 2;
      sort(low, mid);
      sort(mid+1, high);
      merging(low, mid, high);
   }else { 
      return;
   }   
}

int main() { 
   int i;

   printf("Danh sach truoc khi duoc sap xep\n");
   
   for(i = 0; i <= max; i++)
      printf("%d ", a[i]);

   sort(0, max);

   printf("\nDanh sach sau khi duoc sap xep\n");
   
   for(i = 0; i <= max; i++)
      printf("%d ", a[i]);
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Sắp xếp trộn (Merge Sort) trong C


 

Shell Sort là gì ?

Shell Sort là một giải thuật sắp xếp mang lại hiệu quả cao dựa trên giải thuật sắp xếp chèn (Insertion Sort). Giải thuật này tránh các trường hợp phải tráo đổi vị trí của hai phần tử xa nhau trong giải thuật sắp xếp chọn (nếu như phần tử nhỏ hơn ở vị trí bên phải khá xa so với phần tử lớn hơn bên trái).

Đầu tiên, giải thuật này sử dụng giải thuật sắp xếp chọn trên các phần tử có khoảng cách xa nhau, sau đó sắp xếp các phần tử có khoảng cách hẹp hơn. Khoảng cách này còn được gọi là khoảng (interval) – là số vị trí từ phần tử này tới phần tử khác. Khoảng này được tính dựa vào công thức Knuth như sau:

h = h * 3 + 1

trong đó:
   h là Khoảng (interval) với giá trị ban đâu là 1

Giải thuật này khá hiệu quả với các tập dữ liệu có kích cỡ trung bình khi mà độ phức tạp trường hợp xấu nhất và trường hợp trung bình là O(n), với n là số phần tử.

Cách Shell Sort làm việc

Để dễ tìm hiểu hơn, dưới đây mình cung cấp các hình minh họa cho cách Shell Sort làm việc. Chúng ta sử dụng một mảng gồm các giá trị như dưới đây. Giả sử ban đầu giá trị Khoảng (interval) là 4. Ví dụ, với phần tử 35 thì với khoảng là 4 thì phần tử còn lại sẽ là 14. Do đó ta sẽ có các cặp giá trị {35, 14}, {33, 19}, {42, 27}, và {10, 14}.

Shell Sort trong cấu trúc dữ liệu và giải thuật

So sánh các giá trị này với nhau trong các danh sách con và tráo đổi chúng (nếu cần) trong mảng ban đầu. Sau bước này, mảng mới sẽ trống như sau:

Shell Sort trong cấu trúc dữ liệu và giải thuật

Sau đó, lấy giá trị Khoảng (interval) là 2 và với khoảng cách này sẽ cho hai danh sách con: {14, 27, 35, 42}, {19, 10, 33, 44}.

Shell Sort trong cấu trúc dữ liệu và giải thuật

Tiếp tục so sánh và tráo đổi các giá trị (nếu cần) trong mảng ban đầu. Sau bước này, mảng sẽ trông như sau:

Shell Sort trong cấu trúc dữ liệu và giải thuật

Cuối cùng, chúng ta sắp xếp phần mảng còn lại này với Khoảng (interval) bằng 1. Shell Sort sử dụng giải thuật sắp xếp chèn để sắp xếp mảng. Dưới đây là hình minh họa cho từng bước.

Shell Sort trong cấu trúc dữ liệu và giải thuật

Như trên các hình trên, bạn thấy rằng chúng ta chỉ cần 4 lần tráo đổi để sắp xếp phần mảng còn lại này.
Giải thuật cho Shell Sort

Bây giờ chúng ta sẽ theo dõi giải thuật cho Shell Sort:

Bước 1: Khởi tạo giá trị h
Bước 2: Chia list thành các sublist nhỏ hơn tương ứng với h
Bước 3: Sắp xếp các sublist này bởi sử dụng sắp xếp chèn (Insertion Sort)
Bước 4: Lặp lại cho tới khi list đã được sắp xếp

Giải thuật mẫu cho Shell Sort

Từ các bước trên chúng ta có thể thiết kế một giải thuật mẫu cho Shell Sort như sau:

Bắt đầu hàm shellSort()
    A : mảng các phần tử 
	
   /* Tính toán giá trị Khoảng (interval)*/
   while interval < A.length /3 thực hiện:
      interval = interval * 3 + 1	    
   kết thúc while
   
   while interval > 0 thực hiện:

      for outer = interval; outer < A.length; outer ++ thực hiện:

      /* chọn giá trị để chèn */
      valueToInsert = A[outer]
      inner = outer;

         /*dịch chuyển phần tử sang phải*/
         while inner > interval -1 && A[inner - interval] >= valueToInsert do:
            A[inner] = A[inner - interval]
            inner = inner - interval
         kết thúc while

      /* chèn giá trị vào vị trí trên */
      A[inner] = valueToInsert

      kết thúc for

   /* Tính toán giá trị Khoảng (interval)*/
   interval = (interval -1) /3;	  

   kết thúc while
   
Kết thúc hàm

Để theo dõi code đầy đủ của giải thuật Shell Sort trong ngôn ngữ C:

#include <stdio.h>
#include <stdbool.h>
#define MAX 7

int intArray[MAX] = {4,6,3,2,1,9,7};

void printline(int count){
   int i;
	
   for(i = 0;i <count-1;i++){
      printf("=");
   }
	
   printf("=\n");
}

//ham hien thi cac phan tu
void display(){
   int i;
   printf("[");
	
   // duyet qua tat ca phan tu 
   for(i = 0;i<MAX;i++){
      printf("%d ",intArray[i]);
   }
	
   printf("]\n");
}

//ham sap xep
void shellSort(){
   int inner, outer;
   int valueToInsert;
   int interval = 1;   
   int elements = MAX;
   int i = 0;
   
   while(interval <= elements/3) {
      interval = interval*3 +1;                   
   }

   while(interval > 0) {
      printf("Vong lap thu %d#:",i); 
      display();
      
      for(outer = interval; outer < elements; outer++) {
         valueToInsert = intArray[outer];
         inner = outer;
			
         while(inner > interval -1 && intArray[inner - interval] 
            >= valueToInsert) {
            intArray[inner] = intArray[inner - interval];
            inner -=interval;
            printf(" Di chuyen phan tu :%d\n",intArray[inner]);
         }
         
         intArray[inner] = valueToInsert;
         printf(" Chen phan tu :%d, tai vi tri :%d\n",valueToInsert,inner);
      }
		
      interval = (interval -1) /3;           
      i++;
   }          
}

int main() {
   printf("Mang du lieu dau vao: ");
   display();
   printline(50);
   shellSort();
   printf("Mang ket qua: ");
   display();
   printline(50);
   return 1;
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Shell Sort trong C


 

Sắp xếp nhanh (Quick Sort) là gì ?

Giải thuật sắp xếp nhanh (Quick Sort) là một giải thuật hiệu quả cao và dựa trên việc chia mảng dữa liệu thành các mảng nhỏ hơn. Giải thuật sắp xếp nhanh chia mảng thành hai phần bằng cách so sánh từng phần tử của mảng với một phần tử được chọn gọi là phần tử chốt (Pivot): một mảng bao gồm các phần tử nhỏ hơn hoặc bằng phần tử chốt và mảng còn lại bao gồm các phần tử lớn hơn hoặc bằng phần tử chốt.

Tiến trình chia này diễn ra tiếp tục cho tới khi độ dài của các mảng con đều bằng 1. Giải thuật sắp xếp nhanh tỏ ra khá hiệu quả với các tập dữ liệu lớn khi mà độ phức tạp trường hợp trung bình và trường hợp xấu nhất là O(nlogn) với n là số phần tử.

Kỹ thuật chọn phần tử chốt trong giải thuật sắp xếp nhanh (Quick Sort)

Kỹ thuật chọn phần tử chốt ảnh hưởng khá nhiều đến khả năng rơi vào các vòng lặp vô hạn đối với các trường hợp đặc biệt. Tốt nhất là chọn phần tử chốt (pivot) nằm ở trung vị của danh sách. Khi đó, sau log2(n) lần chia chúng ta sẽ đạt tới kích thước các mảng con bằng 1.

Dưới đây là các cách chọn phần tử chốt:

  • Chọn phần tử đứng đầu hoặc đứng cuối làm phần tử chốt.

  • Chọn phần tử đứng giữa danh sách làm phần tử chốt.

  • Chọn phần tử trung vị trong ba phần tử đứng đầu, đứng giữa và đứng cuối làm phần tử chốt.

  • Chọn phần tử ngẫu nhiên làm phần tử chốt. Tuy nhiên cách này rất dễ dẫn đến khả năng rơi vào các trường hợp đặc biệt.

 

Minh họa cách chia trong giải thuật sắp xếp nhanh (Quick Sort)

Hình minh họa dưới đây minh họa cách tìm phần tử chốt trong mảng. Ở đây, chúng ta chọn phần tử chốt đứng ở cuối danh sách.

Giải thuật sắp xếp nhanh (Quick Sort)

Phần tử chốt chia danh sách thành hai phần. Và sử dụng đệ qui, chúng ta tìm phần tử chốt cho các mảng con cho tới khi danh sách chỉ còn một phần tử.

Giải thuật phần tử chốt trong sắp xếp nhanh (Quick Sort)

Dựa vào cách chia danh sách trong giải thuật sắp xếp nhanh ở trên, chúng ta có thể viết một giải thuật như dưới đây.

Bước 1: Chọn phần tử chốt là phần tử có chỉ mục cao nhất (phần tử ở cuối danh sách)
Bước 2: Khai báo hai biến để trỏ tới bên trái và bên phải của danh sách, ngoại trừ phần tử chốt
Bước 3: Biến bên trái trỏ tới mảng con bên trái
Bước 4: Biến bên phải trỏ tới mảng con bên phải 
Bước 5: Khi giá trị tại biến bên trái là nhỏ hơn phần tử chốt thì di chuyển sang phải
Bước 6: Khi giá trị tại biến bên phải là lớn hơn phần tử chốt thì di chuyển sang trái 
Bước 7: Nếu không trong trường hợp cả bước 5 và bước 6 thì tráo đổi giá trị biến trái và phải
Bước 8: Nếu left ≥ right, thì đây chính là giá trị chốt mới

Giải thuật phần tử chốt mẫu trong sắp xếp nhanh (Quick Sort)

Từ các bước trên, chúng ta có thể suy ra code mẫu cho giải thuật sắp xếp nhanh (Quick Sort) như sau:

Bắt đầu hàm partitionFunc(left, right, pivot)
   leftPointer = left -1
   rightPointer = right

   while True thực hiện
      while A[++leftPointer] < pivot thực hiện
         //không làm điều gì            
      kết thúc while
		
      while rightPointer > 0 && A[--rightPointer] > pivot thực hiện
         //không làm điều gì     
      kết thúc while
		
      if leftPointer >= rightPointer
         break
      else                
         Tráo đổi leftPointer,rightPointer
      kết thúc if
		
   kết thúc while 
	
   Tráo đổi leftPointer,right
   return leftPointer
	
Kết thúc hàm

Giải thuật sắp xếp nhanh (Quick Sort)

Sử dụng giải thuật phần tử chốt một cách đệ qui, chúng ta có thể kết thúc với các mảng con nhỏ hơn. Sau đó mỗi mảng con này có thể được xử lý với sắp xếp nhanh. Dưới đây, mình sử dụng giải thuật đệ qui cho sắp xếp nhanh:

Bước 1: Lấy phần tử chốt là phần tử ở cuối danh sách
Bước 2: Chia mảng bởi sử dụng phần tử chốt
Bước 3: Sử dụng sắp xếp nhanh một cách đệ qui với mảng con bên trái 
Bước 4: Sử dụng sắp xếp nhanh một cách đệ qui với mảng con bên phải

Giải thuật mẫu cho Sắp xếp nhanh (Quick Sort)

Từ phần giải thuật trên, chúng ta có thể suy ra code mẫu cho giải thuật sử dụng đệ qui cho sắp xếp nhanh như sau:

Bắt đầu hàm quickSort(left, right)

   if right-left <= 0
      return
   else     
      pivot = A[right]
      partition = partitionFunc(left, right, pivot)
      quickSort(left,partition-1)
      quickSort(partition+1,right)    
   kết thúc if		
   
Kết thúc hàm

Code đầy đủ của giải thuật sắp xếp nhanh trong ngôn ngữ C:

#include <stdio.h>
#include <stdbool.h>
#define MAX 7

int intArray[MAX] = {4,6,3,2,1,9,7};

void printline(int count){
   int i;
	
   for(i = 0;i <count-1;i++){
      printf("=");
   }
	
   printf("=\n");
}

// ham hien thi cac phan tu
void display(){
   int i;
   printf("[");
	
   // duyet qua moi phan tu 
   for(i = 0;i<MAX;i++){
      printf("%d ",intArray[i]);
   }
	
   printf("]\n");
}

// ham de trao doi gia tri
void swap(int num1, int num2){
   int temp = intArray[num1];
   intArray[num1] = intArray[num2];
   intArray[num2] = temp;
}

// ham de chia mang thanh 2 phan, su dung phan tu chot (pivot)
int partition(int left, int right, int pivot){
   int leftPointer = left -1;
   int rightPointer = right;

   while(true){
	
      while(intArray[++leftPointer] < pivot){
         //khong lam gi
      }
		
      while(rightPointer > 0 && intArray[--rightPointer] > pivot){
         //khong lam gi
      }

      if(leftPointer >= rightPointer){
         break;
      }else{
         printf(" Phan tu duoc trao doi :%d,%d\n", 
         intArray[leftPointer],intArray[rightPointer]);
         swap(leftPointer,rightPointer);
      }
		
   }
	
   printf(" Phan tu chot duoc trao doi :%d,%d\n", intArray[leftPointer],intArray[right]);
   swap(leftPointer,right);
   printf("Hien thi mang sau moi lan trao doi: "); 
   display();
   return leftPointer;
}

// ham sap xep
void quickSort(int left, int right){        
   if(right-left <= 0){
      return;   
   }else {
      int pivot = intArray[right];
      int partitionPoint = partition(left, right, pivot);
      quickSort(left,partitionPoint-1);
      quickSort(partitionPoint+1,right);
   }        
}   

main(){
   printf("Mang du lieu dau vao: ");
   display();
   printline(50);
   quickSort(0,MAX-1);
   printf("Mang sau khi da sap xep: ");
   display();
   printline(50);
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Sắp xếp nhanh (Quick Sort) trong C


 

Cấu trúc dữ liệu cây là gì ?

Cấu trúc dữ liệu cây biểu diễn các nút (node) được kết nối bởi các cạnh. Chúng ta sẽ tìm hiểu về Cây nhị phân (Binary Tree) và Cây tìm kiếm nhị phân (Binary Search Tree) trong phần này.

Cây nhị phân là một cấu trúc dữ liệu đặc biệt được sử dụng cho mục đích lưu trữ dữ liệu. Một cây nhị phân có một điều kiện đặc biệt là mỗi nút có thể có tối đa hai nút con. Một cây nhị phân tận dụng lợi thế của hai kiểu cấu trúc dữ liệu: một mảng đã sắp thứ tự và một danh sách liên kết (Linked List), do đó việc tìm kiếm sẽ nhanh như trong mảng đã sắp thứ tự và các thao tác chèn và xóa cũng sẽ nhanh bằng trong Linked List.

Cây nhị phân (Binary Tree)

Các khái niệm cơ bản về cây nhị phân

Dưới đây là một số khái niệm quan trọng liên quan tới cây nhị phân:

  • Đường: là một dãy các nút cùng với các cạnh của một cây.

  • Nút gốc (Root): nút trên cùng của cây được gọi là nút gốc. Một cây sẽ chỉ có một nút gốc và một đường xuất phát từ nút gốc tới bất kỳ nút nào khác. Nút gốc là nút duy nhất không có bất kỳ nút cha nào.

  • Nút cha: bất kỳ nút nào ngoại trừ nút gốc mà có một cạnh hướng lên một nút khác thì được gọi là nút cha.

  • Nút con: nút ở dưới một nút đã cho được kết nối bởi cạnh dưới của nó được gọi là nút con của nút đó.

  • Nút lá: nút mà không có bất kỳ nút con nào thì được gọi là nút lá.

  • Cây con: cây con biểu diễn các con của một nút.

  • Truy cập: kiểm tra giá trị của một nút khi điều khiển là đang trên một nút đó.

  • Duyệt: duyệt qua các nút theo một thứ tự nào đó.

  • Bậc: bậc của một nút biểu diễn số con của một nút. Nếu nút gốc có bậc là 0, thì nút con tiếp theo sẽ có bậc là 1, và nút cháu của nó sẽ có bậc là 2, …

  • Khóa (Key): biểu diễn một giá trị của một nút dựa trên những gì mà một thao tác tìm kiếm thực hiện trên nút.

Biểu diễn cây tìm kiếm nhị phân

Cây tìm kiếm nhị phân biểu diễn một hành vi đặc biệt. Con bên trái của một nút phải có giá trị nhỏ hơn giá trị của nút cha (của nút con này) và con bên phải của nút phải có giá trị lớn hơn giá trị của nút cha (của nút con này). Hình minh họa:

Cây tìm kiếm nhị phân (Binary Search Tree)

Chúng ta đang triển khai cây bởi sử dụng đối tượng nút và kết nối chúng thông qua các tham chiếu.

Nút (Node) trong cây tìm kiếm nhị phân

Một nút sẽ có cấu trúc như dưới đây. Nút có phần dữ liệu và phần tham chiếu tới các nút con bên trái và nút con bên phải.

struct node {
   int data;   
   struct node *leftChild;
   struct node *rightChild;
};

Trong một cây, tất cả các nút chia sẻ cùng một cấu trúc.

Hoạt động cơ bản trên cây tìm kiếm nhị phân

Dưới đây liệt kê các hoạt động cơ bản có thể được thực hiện trên cấu trúc dữ liệu cây tìm kiếm nhị phân:

  • Chèn: chèn một phần tử vào trong một cây/ tạo một cây.

  • Tìm kiếm: tìm kiếm một phần tử trong một cây.

  • Duyệt tiền thứ tự: duyệt một cây theo cách thức duyệt tiền thứ tự (tham khảo chương sau).

  • Duyệt trung thứ tự: duyệt một cây theo cách thức duyệt trung thứ tự (tham khảo chương sau).

  • Duyệt hậu thứ tự: duyệt một cây theo cách thức duyệt hậu thứ tự (tham khảo chương sau).

Trong chương này, chúng ta sẽ tìm hiểu chi tiết cách tạo (chèn) cấu trúc cây và cách tìm kiếm một phần tử dữ liệu trên một cây. Chương sau chúng ta sẽ tìm hiểu chi tiết về các cách duyệt cây.

Hoạt động chèn trong cây tìm kiếm nhị phân

Bước chèn đầu tiên sẽ tạo thành cây. Tiếp đó là sẽ chèn từng phần tử vào trong cây. Đầu tiên chúng ta cần xác định vị trí chính xác của nó. Bắt đầu tìm kiếm từ nút gốc, sau đó nếu dữ liệu là nhỏ hơn giá trị khóa, thì tìm kiếm vị trí rỗng trong cây con bên trái và chèn dữ liệu. Nếu không nhỏ hơn, tìm vị trí rỗng trong cây con bên phải và chèn dữ liệu. (Nếu bạn chưa hiểu, bạn có thể đọc lại phần Biểu diễn cây tìm kiếm nhị phân ở trên để biết tại sao lại chèn như vậy và xem hình minh họa)

Giải thuật cho hoạt động chèn

If root là NULL 
   thì tạo nút gốc (root node)
return

If root đã tồn tại thì sau đó
   so sánh dữ liệu với node.data
   
   while tới vị trí chèn đã xác định

      If dữ liệu là lớn hơn node.data
         tới cây con bên phải
      else
         tới cây con bên trái

   kết thúc while 
   
   chèn dữ liệu
	
Kết thúc If      

Giải thuật mẫu cho hoạt động chèn

Từ trên ta có thể suy ra giải thuật mẫu cho hoạt động chèn trong cây tìm kiếm nhị phân như sau:

void insert(int data) {
   struct node *tempNode = (struct node*) malloc(sizeof(struct node));
   struct node *current;
   struct node *parent;

   tempNode->data = data;
   tempNode->leftChild = NULL;
   tempNode->rightChild = NULL;

   //Nếu cây là trống, chúng ta tạo root node
   if(root == NULL) {
      root = tempNode;
   }else {
      current = root;
      parent  = NULL;

      while(1) {                
         parent = current;

         //tới cây con bên trái
         if(data < parent->data) {
            current = current->leftChild;                
            
            //chèn dữ liệu vào bên trái
            if(current == NULL) {
               parent->leftChild = tempNode;
               return;
            }
         }
			
         //tới cây con bên phải
         else {
            current = current->rightChild;
            
            //chèn dữ liệu vào bên phải
            if(current == NULL) {
               parent->rightChild = tempNode;
               return;
            }
         }
      }            
   }
}

Để tìm hiểu code đầy đủ của cấu trúc dữ liệu cây trong ngôn ngữ C, mời bạn click chuột vào chương: Duyệt cây trong C.

Hoạt động tìm kiếm trong cây nhị phân

Mỗi khi một phần tử cần tìm kiếm: bắt đầu tìm kiếm từ nút gốc, sau đó nếu dữ liệu là nhỏ hơn giá trị khóa, thì tìm kiếm phần tử trong cây con bên trái; nếu không nhỏ hơn thì tìm kiếm phần tử trong cây con bên phải. (Nếu bạn chưa hiểu, bạn có thể đọc lại phần Biểu diễn cây tìm kiếm nhị phân ở trên để biết tại sao lại tìm kiếm như vậy và xem hình minh họa)
Giải thuật cho hoạt động tìm kiếm

If root.data là bằng với search.data
   return root
else
   while không tìm thấy dữ liệu

      If data là lớn hơn node.data
         tới cây con bên phải
      else
         tới cây con bên trái
         
      If data được tìm thấy
         return node

   kết thúc while 
   
   return không tìm thấy data
   
Kết thúc if      

Giải thuật mẫu cho hoạt động tìm kiếm

Từ trên ta có thể suy ra giải thuật mẫu cho hoạt động tìm kiếm trong cây tìm kiếm nhị phân như sau:

struct node* search(int data) {
   struct node *current = root;
   printf("Truy cap phan tu: ");

   while(current->data != data) {
      if(current != NULL)
      printf("%d ",current->data); 
      
      //tới cây con bên trái

      if(current->data > data) {
         current = current->leftChild;
      }
      //else  tới cây con bên phải
      else {                
         current = current->rightChild;
      }

      //không tìm thấy
      if(current == NULL) {
         return NULL;
      }

      return current;
   }  
}

Để tìm hiểu code đầy đủ của cấu trúc dữ liệu cây trong ngôn ngữ C, mời bạn click chuột vào chương: Duyệt cây trong C

Duyệt cây trong cấu trúc dữ liệu và giải thuật

Duyệt cây là một tiến trình để truy cập tất cả các nút của một cây và cũng có thể in các giá trị của các nút này. Bởi vì tất cả các nút được kết nối thông qua các cạnh (hoặc các link), nên chúng ta luôn luôn bắt đầu truy cập từ nút gốc. Do đó, chúng ta không thể truy cập ngẫu nhiên bất kỳ nút nào trong cây. Có ba phương thức mà chúng ta có thể sử dụng để duyệt một cây:

  • Duyệt tiền thứ tự (Pre-order Traversal)
  • Duyệt trung thứ tự (In-order Traversal)
  • Duyệt hậu thứ tự (Post-order Traversal)

Nói chung, chúng ta duyệt một cây để tìm kiếm hay là để xác định vị trí phần tử hoặc khóa đã cho trong cây hoặc là để in tất cả giá trị mà cây đó chứa.

Duyệt trung thứ tự trong cây nhị phân

Trong cách duyệt này, cây con bên trái được truy cập đầu tiên, sau đó là nút gốc và sau đó là cây con bên phải. Bạn nên luôn luôn ghi nhớ rằng mỗi nút đều có thể biểu diễn một cây con.

Nếu một cây nhị phân được duyệt trung thứ tự, kết quả tạo ra sẽ là các giá trị khóa được sắp xếp theo thứ tự tăng dần.

Duyệt trung thứ tự trong cây nhị phân

Ở hình ví dụ minh họa trên, A là nút gốc. Với phương thức duyệt trung thứ tự, chúng ta bắt đầu từ nút gốc A, di chuyển tới cây con bên trái Bcủa nút gốc. Tại đây, B cũng được duyệt theo cách thức duyệt trung thứ tự. Và tiến trình tiếp tục cho đến khi tất cả các nút đã được truy cập. Kết quả của cách thức duyệt trung thứ tự cho cây trên sẽ là:

D → B → E → A → F → C → G

Giải thuật cho cách duyệt trung thứ tự

Duyệt cho tới khi tất cả các nút đều được duyệt:
Bước 1: Duyệt các cây con bên trái một cách đệ qui
Bước 2: Truy cập nút gốc
Bước 3: Duyệt các cây con bên phải một cách đệ qui

Để tìm hiểu code đầy đủ của cách Duyệt cây trong ngôn ngữ C, mời bạn click chuột vào chương: Duyệt cây trong C.

Duyệt tiền thứ tự trong cây nhị phân

Trong cách thức duyệt tiền thứ tự trong cây nhị phân, nút gốc được duyệt đầu tiên, sau đó sẽ duyệt cây con bên trái và cuối cùng sẽ duyệt cây con bên phải.

Duyệt tiền thứ tự trong cây nhị phân

Ở hình ví dụ minh họa trên, A là nút gốc. Chúng ta bắt đầu từ A, và theo cách thức duyệt tiền thứ tự, đầu tiên chúng ta truy cập chính nút gốc Anày và sau đó di chuyển tới nút con bên trái B của nó. B cũng được duyệt theo cách thức duyệt tiền thứ tự. Và tiến trình tiếp tục cho tới khi tất cả các nút đều đã được truy cập. Kết quả của cách thức duyệt tiền thứ tự cây này sẽ là:

A → B → D → E → C → F → G

Giải thuật cho cách duyệt tiền thứ tự

Duyệt cho tới khi tất cả các nút đều được duyệt:
Bước 1: Truy cập nút gốc
Bước 2: Duyệt các cây con bên trái một cách đệ qui
Bước 3: Duyệt các cây con bên phải một cách đệ qui

Để tìm hiểu code đầy đủ của cách Duyệt cây trong ngôn ngữ C, mời bạn click chuột vào chương: Duyệt cây trong C.

Duyệt hậu thứ tự trong cây nhị phân

Trong cách thức duyệt hậu thứ tự trong cây nhị phân, nút gốc của cây sẽ được truy cập cuối cùng, do đó bạn cần chú ý. Đầu tiên, chúng ta duyệt cây con bên trái, sau đó sẽ duyệt cây con bên phải và cuối cùng là duyệt nút gốc.

Duyệt hậu thứ tự trong cây nhị phân

Ở hình ví dụ minh họa trên, A là nút gốc. Chúng ta bắt đầu từ A, và theo cách duyệt hậu thứ tự, đầu tiên chúng ta truy cập cây con bên trái Bcũng được duyệt theo cách thứ duyệt hậu thứ tự. Và tiến trình sẽ tiếp tục tới khi tất cả các nút đã được truy cập. Kết quả của cách thức duyệt hậu thứ tự của cây con trên sẽ là:

D → E → B → F → G → C → A

Giải thuật cho cách duyệt hậu thứ tự

Duyệt cho tới khi tất cả các nút đều được duyệt:
Bước 1: Duyệt các cây con bên trái một cách đệ qui
Bước 2: Duyệt các cây con bên phải một cách đệ qui
Bước 3: Truy cập nút gốc.

Để tìm hiểu code đầy đủ của cách Duyệt cây trong ngôn ngữ C, mời bạn click chuột vào chương: Duyệt cây trong C.
 


 

Cây tìm kiếm nhị phân là gì ?

Một cây tìm kiếm nhị phân (Binary Search Tree – viết tắt là BST) là một cây mà trong đó tất cả các nút đều có các đặc điểm sau:

  • Cây con bên trái của một nút có khóa (key) nhỏ hơn hoặc bằng giá trị khóa của nút cha (của cây con này).

  • Cây con bên phải của một nút có khóa lớn hơn hoặc bằng giá trị khóa của nút cha (của cây con này).

Vì thế có thể nói rằng, một cây tìm kiếm nhị phân (BST) phân chia tất cả các cây con của nó thành hai phần: cây con bên trái và cây con bên phải và có thể được định nghĩa như sau:

left_subtree (keys)  ≤  node (key)  ≤  right_subtree (keys)

Biểu diễn cây tìm kiếm nhị phân (BST)

Cây tìm kiếm nhị phân (BST) là một tập hợp bao gồm các nút được sắp xếp theo cách để chúng có thể duy trì hoặc tuân theo các đặc điểm của cây tìm kiếm nhị phân. Mỗi một nút thì đều có một khóa và giá trị liên kết với nó. Trong khi tìm kiếm, khóa cần tìm được so sánh với các khóa trong cây tìm kiếm nhị phân (BST) và nếu tìm thấy, giá trị liên kết sẽ được thu nhận.

Ví dụ một cây tìm kiếm nhị phân (BST):

Cây tìm kiếm nhị phân (Binary Search Tree)

Từ hình ví dụ minh họa trên ta thấy rằng, khóa của nút gốc có giá trị 27 và tất cả khóa bên trái của cây con bên trái đều có giá trị nhỏ hơn 27 này và tất cả các khóa bên phải của cây con bên phải đều có giá trị lớn hơn 27.


 


Hoạt động cơ bản trên cây tìm kiếm nhị phân

Dưới đây là một số hoạt động cơ bản có thể được thực hiện trên cây tìm kiếm nhị phân:

  • Hoạt động tìm kiếm: tìm kiếm một phần tử trong một cây.

  • Hoạt động chèn: chèn một phần tử vào trong một cây.

  • Hoạt động duyệt tiền thứ tự: duyệt một cây theo cách thức duyệt tiền thứ tự.

  • Hoạt động duyệt trung thứ tự: duyệt một cây theo cách thứ duyệt trung thứ tự.

  • Hoạt động duyệt hậu thứ tự: duyệt một cây theo cách thức duyệt hậu thứ tự.

Nút (Node) trong cây tìm kiếm nhị phân

Một nút có một vài dữ liệu, tham chiếu tới các nút con bên trái và nút con bên phải của nó.

struct node {
   int data;   
   struct node *leftChild;
   struct node *rightChild;
};

Hoạt động tìm kiếm trong cây tìm kiếm nhị phân

Mỗi khi một phần tử được tìm kiếm: bắt đầu tìm kiếm từ nút gốc, sau đó nếu dữ liệu là nhỏ hơn giá trị khóa (key), thì sẽ tìm phần tử ở cây con bên trái; nếu lớn hơn thì sẽ tìm phần tử ở cây con bên phải. Dưới đây là giải thuật cho mỗi nút:

struct node* search(int data){
   struct node *current = root;
   printf("Truy cap cac phan tu: ");
	
   while(current->data != data){
	
      if(current != NULL) {
         printf("%d ",current->data);
			
         //tới cây con bên trái
         if(current->data > data){
            current = current->leftChild;
         }//else  tới cây con bên phải
         else {                
            current = current->rightChild;
         }
			
         //không tìm thấy
         if(current == NULL){
            return NULL;
         }
      }			
   }
   return current;
}

 


Hoạt động chèn trong cây tìm kiếm nhị phân

Mỗi khi một phần tử được chèn: đầu tiên chúng ta cần xác định vị trí chính xác của phần tử này. Bắt đầu tìm kiếm từ nút gốc, sau đó nếu dữ liệu là nhỏ hơn giá trị khóa (key), thì tìm kiếm vị trí còn trống ở cây con bên trái và chèn dữ liệu vào đó; nếu dữ liệu là nhỏ hơn thì tìm kiếm vị trí còn sống ở cây con bên phải và chèn dữ liệu vào đó.

void insert(int data){
   struct node *tempNode = (struct node*) malloc(sizeof(struct node));
   struct node *current;
   struct node *parent;

   tempNode->data = data;
   tempNode->leftChild = NULL;
   tempNode->rightChild = NULL;

   //Nếu cây là trống
   if(root == NULL){
      root = tempNode;
   }else {
      current = root;
      parent = NULL;

      while(1){                
         parent = current;
			
         //tới cây con bên trái
         if(data < parent->data){
            current = current->leftChild;                
            //chèn dữ liệu vào cây con bên trái
				
            if(current == NULL){
               parent->leftChild = tempNode;
               return;
            }
         }//tới cây con bên phải
         else{
            current = current->rightChild;
            //chèn dữ liệu vào cây con bên phải
            if(current == NULL){
               parent->rightChild = tempNode;
               return;
            }
         }
      }            
   }
}        

 

Cây AVL là gì ?

Điều gì xảy ra nếu dữ liệu nhập vào cây tìm kiếm nhị phân (BST) là ở dạng đã được sắp thứ tự (tăng dần hoặc giảm dần). Nó sẽ trông giống như sau:

Cây AVL

Nói chung, hiệu suất trường hợp xấu nhất của cây tìm kiếm nhị phân (BST) gần với các giải thuật tìm kiếm tuyến tính, tức là Ο(n). Với dữ liệu thời gian thực, chúng ta không thể dự đoán mẫu dữ liệu và các tần số của nó. Vì thế, điều cần thiết phát sinh ở đây là để cân bằng cây tìm kiếm nhị phân đang tồn tại.

Cây AVL (viết tắt của tên các nhà phát minh Adelson, Velski và Landis) là cây tìm kiếm nhị phân có độ cân bằng cao. Cây AVL kiểm tra độ cao của các cây con bên trái và cây con bên phải và bảo đảm rằng hiệu số giữa chúng là không lớn hơn 1. Hiệu số này được gọi là Balance Factor (Nhân tố cân bằng).

Dưới đây là hình ví dụ minh họa ba cây, trong đó cây đầu tiên là cân bằng, cây thứ hai và thứ ba là không cân bằng.

Cây AVL

Trong cây thứ hai, cây con bên trái của C có độ cao là 2 và cây con bên phải có độ cao là 0, do đó hiệu số là 2. Trong cây thứ ba, cây con bên phải của A có độ cao là 2 và cây con bên trái có độ cao là 0, do đó hiệu số cũng là 2. Trong khi cây AVL chỉ chấp nhận hiệu số (hay Nhân tố cân bằng) là 1.

BalanceFactor = height(left-sutree) − height(right-sutree)

Nếu hiệu số giữa độ cao của các cây con bên trái và cây con bên phải là lớn hơn 1 thì cây được cân bằng bởi sử dụng một số kỹ thuật quay AVL được trình bày dưới đây.
Kỹ thuật quay cây AVL
Để làm cho cây tự cân bằng, một cây AVL có thể thực hiện 4 loại kỹ thuật quay sau:

  • Kỹ thuật quay trái
  • Kỹ thuật quay phải
  • Kỹ thuật quay trái-phải
  • Kỹ thuật quay phải-trái

Hai kỹ thuật quay đầu tiên là các kỹ thuật quay đơn và hai kỹ thuật quay còn lại là các kỹ thuật quay ghép.
Phần tiếp theo chúng ta sẽ tìm hiểu chi tiết từng kỹ thuật quay với hình minh họa đơn giản và dễ hiểu.

Kỹ thuật quay trái cây AVL

Nếu một cây trở nên không cân bằng khi một nút được chèn vào trong cây con bên phải của cây con bên phải thì chúng ta có thể thực hiện kỹ thuật quay trái đơn như sau:

Quay trái cây AVL

Trong hình minh họa trên, nút A trở nên không cân bằng khi một nút (nút C) được chèn vào cây con bên phải của cây con bên phải của nút A. Chúng ta thực hiện kỹ thuật quay trái để làm A trở thành cây con bên trái của B.

Kỹ thuật quay phải cây AVL

Cây AVL trở nên không cân bằng nếu một nút được chèn vào cây con bên trái của cây con bên trái. Để cây cân bằng, chúng ta thực hiện kỹ thuật quay phải như sau:

Quay phải cây AVL

Như hình minh họa, nút không cân bằng sẽ trở thành cây con bên phải của cây con bên trái của nó bằng kỹ thuật quay phải.

Kỹ thuật quay trái-phải cây AVL

Kỹ thuật quay ghép là khá phức tạp so với hai kỹ thuật quay đơn vừa giới thiệu trên. Để hiểu kỹ thuật quay này nhanh hơn, bạn cần phải ghi chú từng hành động được thực hiện trong khi quay. Một kỹ thuật quay trái-phải là sự kết hợp của kỹ thuật quay trái được theo sau bởi kỹ thuật quay phải.

Trạng thái Hành động
Quay phải cây AVL Một nút đã được chèn vào trong cây con bên phải của cây con bên trái. Điều này làm nút C trở nên không cân bằng. Với tình huống này, cây AVL có thể thực hiện kỹ thuật quay trái-phải.
   
Quay trái cây AVL Đầu tiên, thực hiện phép quay trái trên cây con bên trái của C. Điều này làm cho A trở thành cây con bên trái của B.
   
Quay trái cây AVL Bây giờ nút C vẫn không cân bằng, đó là do xuất hiện cây con bên trái của cây con bên trái.
   
Quay phải cây AVL Bây giờ chúng ta sẽ thực hiện kỹ thuật quay phải để làm B trở thành nút gốc mới của cây này. Nút Cbây giờ trở thành cây con bên phải của chính cây con bên trái của nó.
   
Cây AVL cân bằng Bây giờ cây đã cân bằng.

Kỹ thuật quay phải-trái cây AVL

Một loại kỹ thuật quay ghép khác là kỹ thuật quay phải-trái. Kỹ thuật này là sự kết hợp của kỹ thuật quay phải được theo sau bởi kỹ thuật quay trái.

Trạng thái Hành động
Cây con trái của cây con phải Một nút đã được chèn vào trong cây con bên trái của cây con bên phải. Điều này làm nút A trở nên không cân bằng bởi vì hiệu số (Balance Factor) là 2.
   
Quay cây con phải Đầu tiên, chúng ta thực hiện kỹ thuật quay phải với nút C, làm cho C trở thành cây con bên phải của chính cây con bên trái B. Bây giờ, nút B trở thành cây con bên phải của nút A.
   
Cây không cân bằng Bây giờ nút A vẫn không cân bằng bởi vì xuất hiện cây con bên phải của cây con bên phải của nó. Do đó cần phải thực hiện một kỹ thuật quay trái.
   
Quay trái cây AVL Một kỹ thuật quay trái được thực hiện làm cho Btrở thành nút gốc mới của cây con. Nút A trở thành cây con bên trái của cây con B bên phải của nó.
   
Cây AVL cân bằng Bây giờ cây đã cân bằng.

Cây khung (Spanning Tree) là gì ?

Một cây khung là một tập con của Grahp G mà có tất cả các đỉnh được bao bởi số cạnh tối thiểu nhất. Vì thế, một cây khung sẽ không hình thành một vòng tuần hoàn và nó cũng không thể bị ngắt giữa chừng.
Từ định nghĩa trên ta có thể kết luận rằng mỗi Graph G tuần hoàn sẽ có ít nhất một cây khung. Một Graph G bị ngắt giữa chừng sẽ không có bất kỳ cây khung nào.
Dưới đây là hình ví dụ minh họa cho một Grahp G và các cây khung của nó:

Cây khung (Spanning Tree)

Ở trên chúng ta có 3 cây khung của một đồ thị tuần hoàn. Một đồ thị tuần hoàn có thể có tối đa nn-2 cây khung, trong đó n là số nút. Trong ví dụ trên, n là 3 do đó 33−2 = 3 cây khung.

Thuộc tính chung của cây khung (Spanning Tree)

Bây giờ chúng ta hiểu rằng một Graph có thể có nhiều hơn một cây khung. Phần dưới đây là một số thuộc tính của cây khung của Graph G tuần hoàn đã cho:

  • Một Grahp G tuần hoàn có thể có nhiều hơn một cây khung.

  • Tất cả các cây khung của một Graph G đều có cùng số cạnh và số đỉnh.

  • Cây khung không có bất kỳ vòng tuần hoàn nào.

  • Việc xóa một cạnh từ cây khung sẽ làm cho Grahp là không tuần hoàn.

  • Thêm một cạnh vào cây khung sẽ tạo nên một vòng tuần hoàn.

Thuộc tính toán học của cây khung (Spanning Tree)

  • Cây khung có n-1 cạnh, trong đó n là số nút (đỉnh).

  • Từ một Graph tuần hoàn, bằng việc xóa đi tối đa e-n+1 cạnh, chúng ta có thể xây dựng một cây khung.

  • Một Grahp tuần hoàn có thể có tối đa nn-2 cây khung.

Ứng dụng của cây khung (Spanning Tree)

Về cơ bản cây khung được sử dụng để tìm các đường ngắn nhất để kết nối tất cả các nút trong một Graph. Các ứng dụng phổ biến của cây khung là:

  • Lập kế hoạch mạng dân sự

  • Giao thức định tuyến mạng máy tính

  • Cluster Analysis

Chúng ta tìm hiểu ví dụ đơn giản sau để hiểu các ứng dụng này. Bạn thử tưởng tượng một mạng internet trong thành phố là một hình Graph lớn và bây giờ kế hoạch đặt ra là triển khai các đường dây mạng sao cho với độ dài dây là ngắn nhất mà vẫn có thể kết nối được tất cả các nút trong thành phố. Đó là một ví dụ giải thích cho ứng dụng của cây khung.

Cây khung nhỏ nhất (Minimum Spanning Tree – MST)

Trong một đồ thị có trọng số (Weighted Graph), một cây khung nhỏ nhất là cây khung mà có trọng số (weight) nhỏ nhất trong tất cả các cây khung của Grahp này. Trong đời sống thực, weight (trọng số) ở đây có thể là khoảng cách (distance), độ nghẽn (congestion), độ tải (traffic load) hoặc bất kỳ giá trị nào có thể được biểu diễn thành các cạnh.

Cấu trúc dữ liệu Heap là gì ?

Cấu trúc dữ liệu Heap là một trường hợp đặc biệt của cấu trúc dữ liệu cây nhị phân cân bằng, trong đó khóa của nút gốc được so sánh với các con của nó và được sắp xếp một cách phù hợp. Nếu α có nút con β thì:

key(α) ≥ key(β)

Khi giá trị của nút cha lớn hơn giá trị của nút con, thì thuộc tính này tạo ra một Max Heap. Dựa trên tiêu chí này, một Heap có thể là một trong hai kiểu sau:

Với dữ liệu đầy vào → 35 33 42 10 14 19 27 44 26 31
  • Min-Heap: ở đây giá trị của nút gốc là nhỏ hơn hoặc bằng các giá trị của các nút con.

Cấu trúc dữ liệu heap

  • Max-Heap: ở đây giá trị của nút gốc là lớn hơn hoặc bằng giá trị của các nút con.

Cấu trúc dữ liệu heap

Hai cây ví dụ trên đều được xây dựng dựa trên cùng một dữ liệu đầu vào và cùng thứ tự.

Giải thuật xây dựng Max Heap

Chúng ta sẽ sử dụng cùng ví dụ trên để minh họa cách tạo một Max Heap. Phương thức để xây dựng Min Heap là tương tự.

Chúng ta sẽ suy ra một giải thuật cho Max Heap bằng việc chèn một phần tử tại một thời điểm. Tại bất cứ thời điểm nào, Heap đều phải duy trì (tuân theo) thuộc tính của nó. Trong quá trình chèn, chúng ta cũng giả sử rằng chúng ta đang chèn một nút vào trong HEAPIFIED Tree.

Bước 1: Tạo một nút mới tại vị trí cuối cùng của Heap.
Bước 2: Gán giá trị mới cho nút này.
Bước 3: So sánh giá trị của nút con với giá trị cha.
Bước 4: Nếu giá trị của cha là nhỏ hơn con thì tráo đổi chúng.
Bước 5: Lặp lại bước 3 và 4 cho tới khi vẫn duy trì thuộc tính của Heap.

Ghi chú: Trong giải thuật xây dựng Min Heap, giá trị của nút cha sẽ là nhỏ hơn giá trị của các nút con.

Để rõ hơn về giải thuật xây dựng Max Heap, chúng ta hãy nhìn vào hình minh họa động dưới đây.

Cấu trúc dữ liệu heap

Giải thuật xóa trong Max Heap

Hoạt động xóa trong Max (hoặc Min) Heap luôn luôn diễn ra tại nút gốc và để xóa giá trị Lớn nhất (hoặc Nhỏ nhất). Bạn theo dõi giải thuật và hình minh họa động dưới đây để hiểu thêm về giải thuật này.

Bước 1: Xóa nút gốc.
Bước 2: Di chuyển phần tử cuối cùng có bậc thấp nhất lên nút gốc.
Bước 3: So sánh giá trị của nút con này với giá trị của cha.
Bước 4: Nếu giá trị của cha là nhỏ hơn của con thì tráo đổi chúng.
Bước 5: Lặp lại bước 3 và 4 cho tới khi vẫn duy trì thuộc tính của Heap.

Cấu trúc dữ liệu heap

Đệ qui (Recursion) là gì ?

Một số ngôn ngữ lập trình cho phép việc một module hoặc một hàm được gọi tới chính nó. Kỹ thuật này được gọi là Đệ qui (Recursion). Trong đệ qui, một hàm a có thể: gọi trực tiếp chính hàm a này hoặc gọi một hàm b mà trả về lời gọi tới hàm a ban đầu. Hàm a được gọi là hàm đệ qui.

Ví dụ: một hàm gọi chính nó

int function(int value) {
   if(value < 1)
      return;
   function(value - 1);
	
   printf("%d ",value);   
}

Ví dụ: một hàm mà gọi tới hàm khác mà trả về lời gọi tới hàm ban đầu

int function(int value) {
   if(value < 1)
      return;
   function(value - 1);
	
   printf("%d ",value);   
}

Đặc điểm của hàm đệ qui

Một hàm đệ qui có thể tiếp tục diễn ra vô số lần giống như một vòng lặp vô hạn. Để tránh điều này, bạn phải ghi nhớ hai thuộc tính sau của hàm đệ qui:

  • Điều kiện cơ bản: phải có ít nhất một điều kiện để khi mà gặp điều kiện này thì việc gọi chính hàm đó (gọi đệ qui) sẽ dừng lại.

  • Tiệm cận: mỗi khi hàm đệ qui được gọi thì nó càng tiệm cận tới điều kiện cơ bản.

Sự triển khai hàm đệ qui

Nhiều ngôn ngữ lập trình triển khai sự đệ qui theo cách thức của các ngăn xếp (stack). Nói chung, mỗi khi một hàm (hàm gọi – caller) gọi hàm khác (hàm được gọi – callee) hoặc gọi chính nó (callee), thì hàm caller truyền điều khiển thực thi tới callee. Tiến trình truyền này cũng có thể bao gồm một số dữ liệu từ caller tới callee.

So sánh đệ qui và vòng lặp

Ai đó có thể nói rằng tại sao lại sử dụng đệ qui trong khi sử dụng vòng lặp cũng có thể làm được các tác vụ tương tự. Lý do đầu tiên là đệ qui làm cho chương trình dễ đọc hơn và với các hệ thống CPU cải tiến ngày nay thì đệ qui là hiệu quả hơn rất nhiều khi so với các vòng lặp.

Độ phức tạp thời gian (Time complexity) của hàm đệ qui

Với vòng lặp, chúng ta lấy số vòng lặp để tính độ phức tạp thời gian. Tương tự với đệ qui, giả sử mọi thứ là hằng số, chúng ta tính thời gian một lời gọi đệ qui được tạo ra. Một lời gọi được tạo ra tới một hàm sẽ là Ο(1), Do đó với n là thời gian một lời gọi đệ qui được tạo ra thì độ phức tạp thời gian hàm đệ qui sẽ là Ο(n).

Độ phức tạp bộ nhớ (Space complexity) của hàm đệ qui

Độ phức tạp bộ nhớ được ước lượng dựa vào lượng bộ nhớ cần thêm cho một module được thực thi. Với vòng lặp, trình biên dịch hầu như không cần thêm bộ nhớ. Trình biên dịch sẽ tự cập nhật giá trị của biến được sử dụng ngay trong vòng lặp. Nhưng với đệ qui, hệ thống cần lưu giữ các bản ghi động mỗi khi một lời gọi đệ qui được tạo. Do đó có thể nói rằng, độ phức tạp bộ nhớ của hàm đệ qui là cao hơn so với vòng lặp.

Tháp Hà Nội (Tower of Hanoi) là gì ?

Bài toán Tháp Hà Nội (Tower of Hanoi) là một trò chơi toán học bao gồm 3 cột và với số đĩa nhiều hơn 1.

Dưới đây là hình minh họa bài toán Tháp Hà Nội (Tower of Hanoi) với trường hợp có 3 đĩa.

Tháp Hà Nội (Tower of Hanoi)

Các đĩa có kích cỡ khác nhau và xếp theo tự tự tăng dần về kích cỡ từ trên xuống: đĩa nhỏ hơn ở trên đĩa lớn hơn. Với số đĩa khác nhau thì ta có các bài toán Tháp Hà Nội (Tower of Hanoi) khác nhau, tuy nhiên lời giải cho các bài toán này là tương tự nhau. Lời giải tối ưu cho bài toán Tháp Hà Nội (Tower of Hanoi) là khi trò chơi chỉ có 3 cọc. Với số cọc lớn hơn thì lời giải bài toán vẫn chưa được khẳng định.

Qui tắc trò chơi toán học Tháp Hà Nội (Tower of Hanoi)

Nhiệm vụ của trò chơi là di chuyển các đĩa có kích cỡ khác nhau sang cột khác sao cho vẫn đảm bảo thứ tự ban đầu của các đĩa: đĩa nhỏ nằm trên đĩa lớn. Dưới đây là một số qui tắc cho trò chơi toán học Tháp Hà Nội (Tower of Hanoi):

  • Mỗi lần chỉ có thể di chuyển một đĩa từ cột này sang cột khác.
  • Chỉ được di chuyển đĩa nằm trên cùng (không được di chuyển các đĩa nằm giữa).
  • Đĩa có kích thước lớn hơn không thể được đặt trên đĩa có kích thước nhỏ hơn.

Dưới đây là hình minh họa cách giải bài toán Tháp Hà Nội (Tower of Hanoi) với trường hợp có 3 đĩa.

Tháp Hà Nội (Tower of Hanoi)

Bài toán Tháp Hà Nội (Tower of Hanoi) với số đĩa là n có thể được giải với số bước tối thiểu là 2n−1. Do đó, với trường hợp 3 đĩa, bài toán Tháp Hà Nội (Tower of Hanoi) có thể được giải sau 23−1 = 7 bước.

Giải thuật cho bài toán Tháp Hà Nội (Tower of Hanoi)

Để viết giải thuật cho trò chơi toán học Tháp Hà Nội (Tower of Hanoi), đầu tiên chúng ta cần tìm hiểu cách giải bài toán với số đĩa là 1 và 2. Chúng ta gán 3 cột với các tên là:

  • cotNguon: cột ban đầu chứa các đĩa

  • cotDich: cột cần di chuyển các đĩa tới

  • cotTrungGian: cột trung gian có mục đích làm trung gian trong quá trình di chuyển đĩa

Nếu chỉ có 1 đĩa, chúng ta chỉ cần di chuyển từ cotNguon tới cotDich.

Nếu có 2 đĩa:

  • Đầu tiên chúng ta di chuyển đĩa trên cùng (đĩa nhỏ nhất) tới cotTrungGian.
  • Sau đó chúng ta di chuyển đĩa ở dưới cùng (đĩa to hơn) tới cotDich.
  • Và cuối cùng di chuyển đĩa nhỏ nhất từ cotTrungGian về cotDich.

Tháp Hà Nội (Tower of Hanoi)

Từ hai giải thuật phần giải thuật trên chúng ta sẽ có giải thuật cho bài toán Tháp Hà Nội (Tower of Hanoi) cho 3 đĩa trở lên. Chúng ta chia ngăn xếp các đĩa thành hai phần: đĩa thứ lớn nhất (đĩa thứ n) là phần thứ nhất và (n-1) đĩa còn lại là phần thứ hai.

Mục đích của chúng ta là di chuyển đĩa thứ n từ cotNguon tới cotDich và sau đó đặt tất cả (n-1) đĩa còn lại lên trên nó. Bây giờ chúng ta có thể tưởng tượng ra cách giải bài toán trên dựa vào đệ qui theo các bước sau:

Bước 1: Di chuyển n-1 đĩa từ cotNguon tới cotTrungGian
Bước 2: Di chuyển đĩa thứ n từ cotNguon tới cotDich
Bước 3: Di chuyển n-1 đĩa từ cotTrungGian về cotDich

Giải thuật đệ qui cho bài toán Tháp Hà Nội (Tower of Hanoi) là:

Bắt đầu giải thuật Tháp Hà nội Hanoi(disk, cotNguon, cotDich, cotTrungGian)

   IF disk == 0, thì
      di chuyển đĩa từ cotNguon tới cotDich             
   ELSE
      Hanoi(disk - 1, cotNguon, cotTrungGian, cotDich)     // Bước 1
      di chuyển đĩa từ cotNguon tới cotDich           // Bước 2
      Hanoi(disk - 1, cotTrungGian, cotDich, cotNguon)     // Bước 3
   Kết thúc IF
   
Kết thúc giải thuật

Giải bài toán Tháp Hà Nội (Tower of Hanoi) trong C:

/* Bai toan thap HN */
#include
#include
 
void DICH_CHUYEN(int n, int c1, int c2,int c3)
{
 if(n==1) printf("\n\t%d ->%d",c1,c2);
 else
    {
     DICH_CHUYEN(n-1,c1,c3,c2);
     DICH_CHUYEN(1,c1,c2,c3);
     DICH_CHUYEN(n-1,c3,c2,c1);
    }
}
 
void main()
  {
   int n,c1=1,c2=2,c3=3;
   clrscr();
   printf("\ncho so dia can chuyen:");scanf("%d",&n);
   DICH_CHUYEN(n,c1,c2,c3);
   getch();
  }

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Bài toán Tháp Hà Nội (Tower of Hanoi) trong C

Dãy Fibonacci là gì ?

Dãy Fibonacci tạo dãy các số bằng cách cộng hai số đằng trước. Dãy Fibonacci bắt đầu từ hai số: F0 & F1. Giá trị ban đầu của F0 & F1 có thể tương ứng là 0, 1 hoặc 1, 1.

Điều kiện của dãy Fibonacci là:

Fn = Fn-1 + Fn-2

Ví dụ một dãy Fibonacci:

F8 = 0 1 1 2 3 5 8 13

Ví dụ một dãy Fibonacci khác:

F8 = 1 1 2 3 5 8 13 21

Dưới đây là phần minh họa cho dãy Fibonacci trên:

Dãy Fibonacci trong cấu trúc dữ liệu và giải thuật

Giải thuật sử dụng vòng lặp cho dãy Fibonacci

Đầu tiên, giải thuật của chúng ta sẽ sử dụng vòng lặp để tạo dãy Fibonacci:

Bắt đầu giải thuật Fibonacci(n)
   khai báo f0, f1, fib, loop 
   
   Thiết lập f0 là 0
   Thiết lập f1 là 1
   
   hiển thị f0, f1
   
   for loop ← 1 tới n
   
      fib ← f0 + f1   
      f0 ← f1
      f1 ← fib

      hiển thị dãy fib
   kết thúc for
	
Kết thúc giải thuật

Dãy Fibonacci sử dụng đệ qui trong C

#include<stdio.h>  
#include<conio.h>  

// khai bao ham indayFibonacci
void indayFibonacci(int n){  
    static int n1=0,n2=1,n3;  
    if(n>0){  
         n3 = n1 + n2;  
         n1 = n2;  
         n2 = n3;  
         printf("%d ",n3);  
         indayFibonacci(n-1);  
    }  
}  

// ham main de in day Fibonacci
int main(){  
    int n;  
    
    printf("Ban hay nhap so phan tu trong day Fibonacci: ");  
    scanf("%d",&n);  
  
    printf("Hien thi day Fibonacci tren man hinh\n\n");  
    printf("%d %d ",0,1);  
    indayFibonacci(n-2);  //n-2 boi vi 2 phan tu dau tien da duoc in 
    
    printf("\n\n===========================\n");
    printf("VietJack chuc cac ban hoc tot!");
  
    getch();  
}  

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Dãy Fibonacci trong C

Danh sách liên kết đôi (Doubly Linked List) là một biến thể của Danh sách liên kết (Linked List), trong đó hoạt động duyệt qua các nút có thể được thực hiện theo hai chiều: về trước và về sau một cách dễ dàng khi so sánh với Danh sách liên kết đơn. Dưới đây là một số khái niệm quan trọng cần ghi nhớ về Danh sách liên kết đôi.

Chương trình minh họa Danh sách liên kết đôi (Doubly Linked List) trong C

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>

struct node {
   int data;
   int key;
	
   struct node *next;
   struct node *prev;
};

//link nay luon luon tro toi first Link 
struct node *head = NULL;

//link nay luon luon tro toi last Link 
struct node *last = NULL;

struct node *current = NULL;

//kiem tra xem list co trong khong
bool isEmpty(){
   return head == NULL;
}

int length(){
   int length = 0;
   struct node *current;
	
   for(current = head; current != NULL; current = current->next){
      length++;
   }
	
   return length;
}

//hien thi list bat dau tu first toi last
void displayForward(){

   //bat dau tu phan dau list
   struct node *ptr = head;
	
   //duyet toi cuoi list
   printf("\n[ ");
	
   while(ptr != NULL){        
      printf("(%d,%d) ",ptr->key,ptr->data);
      ptr = ptr->next;
   }
	
   printf(" ]");
}

//hien thi list bat dau tu last toi first
void displayBackward(){

   //bat dau tu cuoi list
   struct node *ptr = last;
	
   //duyet toi phan dau list
   printf("\n[ ");
	
   while(ptr != NULL){    
	
      //in du lieu
      printf("(%d,%d) ",ptr->key,ptr->data);
		
      //di chuyen toi phan tu tiep theo
      ptr = ptr ->prev;
      printf(" ");
   }
	
   printf(" ]");
}


//chen link tai vi tri dau tien
void insertFirst(int key, int data){

   //tao mot link
   struct node *link = (struct node*) malloc(sizeof(struct node));
   link->key = key;
   link->data = data;
	
   if(isEmpty()){
      //lam cho no thanh last link
      last = link;
   }else {
      //cap nhat first prev link
      head->prev = link;
   }

   //tro no toi first link cu
   link->next = head;
	
   //tro first toi first link moi
   head = link;
}

//chen link tai vi tri cuoi cung
void insertLast(int key, int data){

   //tao mot link
   struct node *link = (struct node*) malloc(sizeof(struct node));
   link->key = key;
   link->data = data;
	
   if(isEmpty()){
      //lam cho no thanh last link
      last = link;
   }else {
      //lam cho no thanh last link moi
      last->next = link;     
      //danh dau last node la prev cua link moi
      link->prev = last;
   }

   //tro last toi last node moi
   last = link;
}

//xoa phan tu dau tien
struct node* deleteFirst(){

   //luu tham chieu toi first link
   struct node *tempLink = head;
	
   //neu chi co link
   if(head->next == NULL){
      last = NULL;
   }else {
      head->next->prev = NULL;
   }
	
   head = head->next;
   //tra ve link da bi xoa
   return tempLink;
}

//xoa link tai vi tri cuoi cung

struct node* deleteLast(){
   //luu tham chieu toi last link
   struct node *tempLink = last;
	
   //neu chi co link
   if(head->next == NULL){
      head = NULL;
   }else {
      last->prev->next = NULL;
   }
	
   last = last->prev;
	
   //tra ve link bi xoa
   return tempLink;
}

//xoa mot link voi key da cho

struct node* deleteKey(int key){

   //bat dau tu link dau tien
   struct node* current = head;
   struct node* previous = NULL;
	
   //neu list la trong
   if(head == NULL){
      return NULL;
   }

   //duyet qua list
   while(current->key != key){
      //neu no la last node
		
      if(current->next == NULL){
         return NULL;
      }else {
         //luu tham chieu toi link hien tai
         previous = current;
			
         //di chuyen next link
         current = current->next;             
      }
   }

   //cap nhat link
   if(current == head) {
      //thay doi first de tro toi next link
      head = head->next;
   }else {
      //bo qua link hien tai
      current->prev->next = current->next;
   }    

   if(current == last){
      //thay doi last de tro toi prev link
      last = current->prev;
   }else {
      current->next->prev = current->prev;
   }
	
   return current;
}

bool insertAfter(int key, int newKey, int data){
   //bat dau tu first link
   struct node *current = head; 
	
   //neu list la trong
   if(head == NULL){
      return false;
   }

   //duyet qua list
   while(current->key != key){
	
      //neu day la last node
      if(current->next == NULL){
         return false;
      }else {           
         //di chuyen next link
         current = current->next;             
      }
   }
	
   //tao mot link
   struct node *newLink = (struct node*) malloc(sizeof(struct node));
   newLink->key = key;
   newLink->data = data;

   if(current == last) {
      newLink->next = NULL; 
      last = newLink; 
   }else {
      newLink->next = current->next;         
      current->next->prev = newLink;
   }
	
   newLink->prev = current; 
   current->next = newLink; 
   return true; 
}

main() {

   insertFirst(1,10);
   insertFirst(2,20);
   insertFirst(3,30);
   insertFirst(4,1);
   insertFirst(5,40);
   insertFirst(6,56); 

   printf("\nIn danh sach (First ---> Last): ");  
   displayForward();
	
   printf("\n");
   printf("\In danh sach (Last ---> first): "); 
   displayBackward();

   printf("\nDanh sach, sau khi xoa ban ghi dau tien: ");
   deleteFirst();        
   displayForward();

   printf("\nDanh sach, sau khi xoa ban ghi cuoi cung: ");  
   deleteLast();
   displayForward();

   printf("\nDanh sach, chen them phan tu sau key(4): ");  
   insertAfter(4,7, 13);
   displayForward();

   printf("\nDanh sach, sau khi xoa key(4) : ");  
   deleteKey(4);
   displayForward();
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Danh sách liên kết đôi (Doubly Linked List) trong C

Danh sách liên kết vòng (Circular Linked List) là một biến thể của Danh sách liên kết (Linked List), trong đó phần tử đầu tiên trỏ tới phần tử cuối cùng và phần tử cuối cùng trỏ tới phần tử đầu tiên.

Danh sách liên kết vòng (Circular Linked List) trong C
 

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>

struct node {
   int data;
   int key;
	
   struct node *next;
};

struct node *head = NULL;
struct node *current = NULL;

bool isEmpty(){
   return head == NULL;
}

int length(){
   int length = 0;

   //neu danh sach la trong
   if(head == NULL){
      return 0;
   }

   current = head->next;

   while(current != head){
      length++;
      current = current->next;   
   }
	
   return length;
}

//chen link tai vi tri dau tien
void insertFirst(int key, int data){

   //tao mot link
   struct node *link = (struct node*) malloc(sizeof(struct node));
   link->key = key;
   link->data = data;
	
   if (isEmpty()) {
      head = link;
      head->next = head;
   }else {
      //tro no toi first node cu
      link->next = head;
		
      //tro first toi first node moi
      head = link;
   }    
	
}

//xoa phan tu dau tien
struct node * deleteFirst(){

   //luu tham chieu toi first link
   struct node *tempLink = head;
	
   if(head->next == head){  
      head = NULL;
      return tempLink;
   }     

   //danh dau next toi first link la first 
   head = head->next;
	
   //tra ve link da bi xoa
   return tempLink;
}

//hien thi danh sach
void printList(){

   struct node *ptr = head;
   printf("\n[ ");
	
   //bat dau tu phan dau cua danh sach
   if(head != NULL){
	
      while(ptr->next != ptr){     
         printf("(%d,%d) ",ptr->key,ptr->data);
         ptr = ptr->next;
      }
		
   }
	
   printf(" ]");
}

main() {

   insertFirst(1,10);
   insertFirst(2,20);
   insertFirst(3,30);
   insertFirst(4,1);
   insertFirst(5,40);
   insertFirst(6,56); 

   printf("Danh sach ban dau: "); 
	
   //In danh sach
   printList();

   while(!isEmpty()){            
      struct node *temp = deleteFirst();
      printf("\nGia tri bi xoa:");  
      printf("(%d,%d) ",temp->key,temp->data);        
   }   
	
   printf("\nDanh sach sau khi da xoa tat ca phan tu: ");          
   printList();   
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Danh sách liên kết vòng (Circular Linked List) trong C

Chương trình Sắp xếp nổi bọt (Bubble Sort) trong C

Sắp xếp nổi bọt là một giải thuật sắp xếp đơn giản. Giải thuật sắp xếp này được tiến hành dựa trên việc so sánh cặp phần tử liền kề nhau và tráo đổi thứ tự nếu chúng không theo thứ tự.

Giải thuật này không thích hợp sử dụng với các tập dữ liệu lớn khi mà độ phức tạp trường hợp xấu nhất và trường hợp trung bình là Ο(n2) với n là số phần tử.

Giải thuật sắp xếp nổi bọt là giải thuật chậm nhất trong số các giải thuật sắp xếp cơ bản. Giải thuật này còn chậm hơn giải thuật đổi chỗ trực tiếp mặc dù số lần so sánh bằng nhau, nhưng do đổi chỗ hai phần tử kề nhau nên số lần đổi chỗ nhiều hơn.

Chương trình minh họa sắp xếp nổi bọt (Bubble Sort) trong C

#include <stdio.h>
#include <stdbool.h>

#define MAX 10

int list[MAX] = {1,8,4,6,0,3,5,2,7,9};

void display(){
   int i;
   printf("[");
	
   // Duyet qua tat ca phan tu
   for(i = 0; i < MAX; i++){
      printf("%d ",list[i]);
   }
	
   printf("]\n");
}

void bubbleSort() {
   int temp;
   int i,j;
	
   bool swapped = false;       
   
   // lap qua tat ca cac so
   for(i = 0; i < MAX-1; i++) { 
      swapped = false;
		
      // vong lap thu hai
      for(j = 0; j < MAX-1-i; j++) {
         printf("     So sanh cac phan tu: [ %d, %d ] ", list[j],list[j+1]);

         // kiem xa xem so ke tiep co nho hon so hien tai hay khong
         //   trao doi cac so. 
         //  (Muc dich: lam noi bot (bubble) so lon nhat) 
			
         if(list[j] > list[j+1]) {
            temp = list[j];
            list[j] = list[j+1];
            list[j+1] = temp;

            swapped = true;
            printf(" => trao doi [%d, %d]\n",list[j],list[j+1]);
         }else {
            printf(" => khong can trao doi\n");
         }
			
      }

      // neu khong can trao doi nua, tuc la 
      //   mang da duoc sap xep va thoat khoi vong lap. 
      if(!swapped) {
         break;
      }
      
      printf("Vong lap thu %d#: ",(i+1)); 
      display();                     
   }
	
}

main(){
   printf("Mang du lieu dau vao: ");
   display();
   printf("\n");
	
   bubbleSort();
   printf("\nMang sau khi da sap xep: ");
   display();
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Sắp xếp nổi bọt (Bubble Sort) trong C


 

Tìm kiếm bằng bảng băm

Cấu trúc dữ liệu Bảng Băm là một cấu trúc dữ liệu lưu giữ dữ liệu theo cách thức liên hợp. Trong Bảng Băm, dữ liệu được lưu giữ trong định dạng mảng, trong đó các giá trị dữ liệu có giá trị chỉ mục riêng. Việc truy cập dữ liệu trở nên nhanh hơn nếu chúng ta biết chỉ mục của dữ liệu cần tìm.

Do đó, với loại cấu trúc dữ liệu Bảng Băm này thì các hoạt động chèn và hoạt động tìm kiếm sẽ diễn ra rất nhanh, bất chấp kích cỡ của dữ liệu là bao nhiêu. Bảng Băm sử dụng mảng như là một kho lưu giữ trung gian và sử dụng kỹ thuật Hash để tạo chỉ mục tại nơi phần tử được chèn vào.

Chương trình minh họa Bảng Băm trong C

 

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>

#define SIZE 20

struct DataItem {
   int data;   
   int key;
};

struct DataItem* hashArray[SIZE]; 
struct DataItem* dummyItem;
struct DataItem* item;

int hashCode(int key){
   return key % SIZE;
}

struct DataItem *search(int key){               
   //lay gia tri hash 
   int hashIndex = hashCode(key);  
	
   //di chuyen trong mang cho toi khi gap mot o trong (empty cell)
   while(hashArray[hashIndex] != NULL){
	
      if(hashArray[hashIndex]->key == key)
         return hashArray[hashIndex]; 
			
      //di chuyen toi o tiep theo
      ++hashIndex;
		
      //bao quanh hash table
      hashIndex %= SIZE;
   }        
	
   return NULL;        
}

void insert(int key,int data){

   struct DataItem *item = (struct DataItem*) malloc(sizeof(struct DataItem));
   item->data = data;  
   item->key = key;     

   //lay gia tri hash 
   int hashIndex = hashCode(key);

   //di chuyen trong mang cho toi khi gap mot o trong (empty cell) hoac o bi xoa
   while(hashArray[hashIndex] != NULL && hashArray[hashIndex]->key != -1){
      //di chuyen toi o tiep theo
      ++hashIndex;
		
      //bao quanh hash table
      hashIndex %= SIZE;
   }
	
   hashArray[hashIndex] = item;        
}

struct DataItem* deleteItem(struct DataItem* item){
   int key = item->key;

   //lay gia tri hash 
   int hashIndex = hashCode(key);

   //di chuyen trong mang cho toi khi gap mot o trong (empty cell) 
   while(hashArray[hashIndex] != NULL){
	
      if(hashArray[hashIndex]->key == key){
         struct DataItem* temp = hashArray[hashIndex]; 
			
         //gan mot phan tu gia tai vi tri bi xoa
         hashArray[hashIndex] = dummyItem; 
         return temp;
      }
		
      //di chuyen toi o tiep theo
      ++hashIndex;
		
      //bao quanh hash table
      hashIndex %= SIZE;
   }      
	
   return NULL;        
}

void display(){
   int i = 0;
	
   for(i = 0; i<SIZE; i++) {
	
      if(hashArray[i] != NULL)
         printf(" (%d,%d)",hashArray[i]->key,hashArray[i]->data);
      else
         printf(" ~~ ");
   }
	
   printf("\n");
}

int main(){
   
   dummyItem = (struct DataItem*) malloc(sizeof(struct DataItem));
   dummyItem->data = -1;  
   dummyItem->key = -1; 

   insert(1, 20);
   insert(2, 70);
   insert(42, 80);
   insert(4, 25);
   insert(12, 44);
   insert(14, 32);
   insert(17, 11);
   insert(13, 78);
   insert(37, 97);

   display();

   item = search(37);

   if(item != NULL){
      printf("Tim thay phan tu: %d\n", item->data);
   }else {
      printf("Khong tim thay phan tu\n");
   }

   deleteItem(item);

   item = search(37);

   if(item != NULL){
      printf("Tim thay phan tu: %d\n", item->data);
   }else {
      printf("Khong tim thay phan tu\n");
   }
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Bảng Băm trong C

Duyệt cây là một tiến trình để truy cập tất cả các nút của một cây và cũng có thể in các giá trị của các nút này. Bởi vì tất cả các nút được kết nối thông qua các cạnh (hoặc các link), nên chúng ta luôn luôn bắt đầu truy cập từ nút gốc. Do đó, chúng ta không thể truy cập ngẫu nhiên bất kỳ nút nào trong cây. Có ba phương thức mà chúng ta có thể sử dụng để duyệt một cây:

  • Duyệt tiền thứ tự (Pre-order Traversal)
  • Duyệt trung thứ tự (In-order Traversal)
  • Duyệt hậu thứ tự (Post-order Traversal)

Chúng ta sẽ tìm hiểu các cách duyệt cây trên qua chương trình C dưới đây:

Cây tìm kiếm nhị phân (Binary Search Tree)

Cách duyệt cây trong C

#include <stdio.h>
#include <stdlib.h>

struct node {
   int data; 
	
   struct node *leftChild;
   struct node *rightChild;
};

struct node *root = NULL;

void insert(int data) {
   struct node *tempNode = (struct node*) malloc(sizeof(struct node));
   struct node *current;
   struct node *parent;

   tempNode->data = data;
   tempNode->leftChild = NULL;
   tempNode->rightChild = NULL;

   //kiem tra neu cay la trong
   if(root == NULL) {
      root = tempNode;
   }else {
      current = root;
      parent = NULL;

      while(1) { 
         parent = current;
         
         //chuyen toi cay con ben trai
         if(data < parent->data) {
            current = current->leftChild;                
            
            //chen du lieu vao cay con ben trai
            if(current == NULL) {
               parent->leftChild = tempNode;
               return;
            }
         }//chuyen toi cay con ben phai
         else {
            current = current->rightChild;

            //chen du lieu vao cay con ben phai
            if(current == NULL) {
               parent->rightChild = tempNode;
               return;
            }
         }
      }            
   }
}

struct node* search(int data) {
   struct node *current = root;
   printf("Truy cap cac phan tu cua cay: ");

   while(current->data != data) {
      if(current != NULL)
         printf("%d ",current->data); 

      //chuyen toi cay con ben trai
      if(current->data > data) {
         current = current->leftChild;
      }
      //chuyen toi cay con ben phai
      else {                
         current = current->rightChild;
      }

      //khong tim thay
      if(current == NULL) {
         return NULL;
      }
   }
   
   return current;
}

void pre_order_traversal(struct node* root) {
   if(root != NULL) {
      printf("%d ",root->data);
      pre_order_traversal(root->leftChild);
      pre_order_traversal(root->rightChild);
   }
}

void inorder_traversal(struct node* root) {
   if(root != NULL) {
      inorder_traversal(root->leftChild);
      printf("%d ",root->data);          
      inorder_traversal(root->rightChild);
   }
}

void post_order_traversal(struct node* root) {
   if(root != NULL) {
      post_order_traversal(root->leftChild);
      post_order_traversal(root->rightChild);
      printf("%d ", root->data);
   }
}

int main() {
   int i;
   int array[7] = { 27, 14, 35, 10, 19, 31, 42 };

   for(i = 0; i < 7; i++)
      insert(array[i]);

   i = 31;
   struct node * temp = search(i);

   if(temp != NULL) {
      printf("[%d] Da tim thay phan tu.", temp->data);
      printf("\n");
   }else {
      printf("[ x ] Khong tim thay phan tu (%d).\n", i);
   }

   i = 15;
   temp = search(i);

   if(temp != NULL) {
      printf("[%d] Da tim thay phan tu.", temp->data);
      printf("\n");
   }else {
      printf("[ x ] Khong tim thay phan tu (%d).\n", i);
   }            

   printf("\nCach duyet tien thu tu: ");
   pre_order_traversal(root);

   printf("\nCach duyet trung thu tu: ");
   inorder_traversal(root);

   printf("\nCach duyet hau thu tu: ");
   post_order_traversal(root);       

   return 0;
}

Kết quả

Biên dịch và chạy chương trình C trên sẽ cho kết quả:

Cách thức duyệt cây trong C