24/05/2018, 22:47

Cấu trúc dữ liệu động

Với các cấu trúc dữ liệu được xây dựng từ các kiểu cơ sở như: kiểu thực, kiểu nguyên, kiểu ký tự ... hoặc từ các cấu trúc đơn giản như mẩu tin, tập hợp, mảng ... lập trình viên có thể giải quyết hầu hết các bài toán đặt ra. Các đối tượng dữ ...

Với các cấu trúc dữ liệu được xây dựng từ các kiểu cơ sở như: kiểu thực, kiểu nguyên, kiểu ký tự ... hoặc từ các cấu trúc đơn giản như mẩu tin, tập hợp, mảng ... lập trình viên có thể giải quyết hầu hết các bài toán đặt ra. Các đối tượng dữ liệu được xác định thuộc những kiểu dữ liệu này có đặc điểm chung là không thay đổi được kích thước, cấu trúc trong quá trình sống, do vậy thường cứng ngắt, gò bó khiến đôi khi khó diễn tả được thực tế vốn  sinh động, phong phú. Các kiểu dữ liệu kể trên được gọi là các kiểu dữ liệu tĩnh.

Ví dụ

1.       Trong thực tế, một số đối tượng có thể được định nghĩa đệ qui, ví dụ để mô tả đối tượng "con người" cần thể hiện các thông tin tối thiểu như :

     Họ tên

     Số CMND

     Thông tin về cha, mẹ

Ðể biễu diễn một đối tượng có nhiều thành phần thông tin như trên có thể sử dụng kiểu bản ghi. Tuy nhiên, cần lưu ý cha, mẹ của một người cũng là các đối tượng kiểu NGƯỜI, do vậy về nguyên tắc cần phải có định nghĩa như sau:

typedef  struct NGUOI{

char  Hoten[30];

int   So_CMND ;

NGUOI Cha,Me;

};

Nhưng với khai báo trên, các ngôn ngữ lập trình gặp khó khăn trong việc cài đặt không vượt qua được như xác định kích thước của đối tượng kiểu NGUOI.

2.       Một số đối tượng dữ liệu trong chu kỳ sống của nó có thể thay đổi về cấu trúc, độ lớn, như danh sách các học viên trong một lớp học có thể tăng thêm, giảm đi ... Khi đó nếu cố tình dùng những cấu trúc dữ liệu tĩnh đã biết như mảng để biểu diễn những đối tượng đó lập trình viên phải sử dụng những thao tác phức tạp, kém tự nhiên khiến chương trình trở nên khó đọc, do đó khó bảo trì và nhất là khó có thể sử dụng bộ nhớ một cách có hiệu quả.

3.      Một lý do nữa làm cho các kiểu dữ liệu tĩnh không thể đáp ứng được nhu cầu của thực tế là tổng kích thước vùng nhớ dành cho tất cả các biến tĩnh chỉ là 64Kb (1 Segment bộ nhớ). Khi có nhu cầu dùng nhiều bộ nhớ hơn ta phải sử dụng các cấu trúc dữ liệu động.

4.       Cuối cùng, do bản chất của các dữ liệu tĩnh, chúng sẽ chiếm vùng nhớ đã dành cho chúng suốt quá trình hoạt động của chương trình. Tuy nhiên, trong thực tế, có thể xảy ra trường hợp một dữ liệu nào đó chỉ tồn tại nhất thời hay không thường xuyên trong quá trình hoạt động của chương trình. Vì vậy việc dùng các CTDL tĩnh sẽ không cho phép sử dụng hiệu quả bộ nhớ.

Do vậy, nhằm đáp ứng nhu cầu thể hiện sát thực bản chất của dữ liệu cũng như ?ây dựng các thao tác hiệu quả trên dữ liệu, cần phải tìm cách tổ chức kết hợp dữ liệu với những hình thức mới linh động hơn, có thể thay đổi kích thước, cấu trúc trong suốt thời gian sống. Các hình thức tổ chức dữ liệu như vậy được gọi là cấu trúc dữ liệu động. Chương này sẽ giới thiệu về các cấu trúc dữ liệu động và tập trung khảo sát cấu trúc đơn giản nhất thuộc loại này là danh sách liên kết.

Biến không động (biến tĩnh, biến nửa tĩnh):

Khi xây dựng chương trình, lập trình viên có thể xác định được ngay những đối tượng dữ liệu luôn cần được sử dụng, không có nhu cầu thay đổi về số lượng kích thước .... do đó có thể xác định cách thức lưu trữ chúng ngay từ đầu. Các đối tượng dữ liệu này sẽ được khai báo như các biến không động. Biến không động là những biến thỏa:

+Ðược khai báo tường minh,

+Tồn tại khi vào phạm vi khai báo và chỉ mất khi ra khỏi phạm vi này,

+Ðược cấp phát vùng nhớ trong vùng dữ liệu (Data segment) hoặc là  Stack (đối với biến nửa tĩnh - các biến cục bộ).

+Kích thước không thay đổi trong suốt quá trình sống.

Do được khai báo tường minh, các biến không động có một định danh đã được kết nối với địa chỉ vùng nhớ lưu trữ biến và được truy xuất trực tiếp thông qua định danh đó.

Ví dụ  :  a, b là các biến không động

int       a;                   

          char     b[10];

Kiểu con trỏ

  •   Cho trước kiểu T = <V,O>. Kiểu con trỏ - ký hiệu "Tp"- chỉ đến các phần tử có kiểu "T" được định nghĩa:

Tp = <Vp, Op>

trong đó

-         Vp = {{các điạ chỉ có thể lưu trữ  những  đối tượng có kiểu T}, NULL} (với NULL là một giá trị đặc biệt tượng trưng cho một giá trị không biết hoặc không quan tâm)

-         Op = {các thao tác định địa chỉ của một đối tượng thuộc kiểu T khi biết con trỏ chỉ đến đối tượng đó} (thường gồm các thao tác tạo một con trỏ chỉ đến một đối tượng thuộc kiểu T;  hủy một đối tượng dữ liệu thuộc kiểu T khi biết con trỏ chỉ đến đối tượng đó}

  •  Nói một cách dễ hiểu, kiểu con trỏ là kiểu cơ sở dùng lưu địa chỉ của một đối tượng dữ liệu khác.
  •  Biến thuộc kiểu con trỏ Tp là biến mà giá trị của nó là địa chỉ cuả một vùng nhớ ứng với một biến kiểu T, hoặc là  giá trị NULL.

LƯU Ý :

Kích thước của biến con trỏ tùy thuộc vào quy ước số byte địa chỉ trong từng mô hình bộ nhớ của từng ngôn ngữ lập trình cụ thể..

Ví dụ:

-          biến con trỏ trong Pascal có kích thước 4 bytes (2 bytes địa chỉ segment + 2 byte địa chỉ offset)

-          biến con trỏ trong C có kích thước 2 hoặc 4 bytes tùy vào con trỏ near (chỉ lưu địa chỉ offset) hay far (lưu cả segment lẫn offset)

Cú pháp định nghĩa một kiểu con trỏ trong ngôn ngữ C :

typedef  <kiểu con trỏ>  *<kiểu cơ sở>;

Ví dụ :             

typedef     int      *intpointer;

intpointer  p;

hoặc

int      *p;

là những khai báo hợp lệ.

Các thao tác cơ bản trên kiểu con trỏ:(minh họa bằng C)

  •        Khi 1 biến con trỏ p lưu địa chỉ của đối tượng x, ta nói ?p trỏ đến x?
  •  Gán địa chỉ của một vùng nhớ con trỏ p:  

p = <địa chỉ>;

p = <địa chỉ> + <giá trị nguyên>;

  •  Truy xuất nội dung của đối tượng do p trỏ đến (*p)

Biến động

  •    Trong nhiều trường hợp, tại thời điểm biên dịch không thể xác định trước kích thước chính xác của một số đối tượng dữ liệu do sự tồn tại và tăng trưởng của chúng phụ thuộc vào ngữ cảnh của việc thực hiện chương trình. Các đối tượng dữ liệu có đặc điểm kể trên nên được khai báo như biến động.

 Biến động là những biến thỏa:

    •    Biến không được khai báo tường minh.
    •   Có thể được cấp phát hoặc giải phóng bộ nhớ khi người sử dụng yêu cầu.               
    •   Các biến này không theo qui tắc phạm vi (tĩnh).
    • Vùng nhớ của biến được cấp phát trong Heap.
    • Kích thước có thể thay đổi trong quá trình sống.
  •          Do không được khai báo tường minh nên các biến động không có một định danh được kết buộc với địa chỉ vùng nhớ cấp phát cho nó, do đó gặp khó khăn khi truy xuất đến một biến động. Ðể giải quyết vấn đề, biến con trỏ (là biến không động) được sử dụng để trỏ đến biến động. Khi tạo ra một biến động, phải dùng một con trỏ để lưu địa chỉ của biến này và sau đó, truy xuất đến biến động thông qua biến con trỏ đã biết định danh.
  •          Hai thao tác cơ bản trên biến động là tạo và hủy một biến động do biến con trỏ ?p? trỏ đến:
  • Tạo ra một biến động và cho con trỏ ?p? chỉ đến nó:

Hầu hết các ngôn ngữ lập trình cấp cao đều cung cấp những thủ tục cấp phát vùng nhớ cho một biến động và cho một con trỏ giữ địa chỉ vùng nhớ đó.

Một số hàm cấp phát bộ nhớ của C :

void* malloc(size);  // trả về con trỏ chỉ đến một vùng

                                                      // nhớ size byte vừa được cấp phát.

void* calloc(n,size);// trả về con trỏ chỉ đến một vùng

                                                        // nhớ vừa được cấp phát gồm n

                                                        //phần tử,   mỗi phần tử có kích

                                           //thước size byte

new                   // hàm cấp phát bộ nhớ trong C++

  • Hủy một biến động do p chỉ đến :

Hàm free(p) huỷ vùng nhớ cấp phát bởi hàm malloc hoặc calloc do p trỏ tới

Hàm delete p huỷ vùng nhớ cấp phát bởi hàm new do p trỏ tới

  • Ví dụ :

int*    p1, p2;

// cấp phát vùng nhớ cho 1 biến động kiểu int

p1 = (int*)malloc(sizeof(int));

p1* = 5;       // đặt giá trị 5 cho biến động p1

// cấp phát biến động kiểu mảng gồm 10 phần tử kiểu int

p2 = (int*)calloc(10, sizeof(int));

(p2+3)* = 0; // đặt giá trị 0 cho phần tử thứ 4                                   // của mảng p2

free(p1); free(p2);

Ðịnh nghĩa:

Cho T là một kiểu được định nghiã trước, kiểu danh sách Tx gồm các phần tử thuộc kiểu T được định nghĩa là:

Tx = <Vx, Ox>

trong đó:

Vx = {tập hợp có thứ tự các phần tử kiểu T được móc nối với nhau theo trình tự tuyến tính};

Ox = {Tạo danh sách; Tìm 1 phần tử trong danh sách; Chèn một phần tử vào danh sách; Huỷ một phần tử khỏi danh sách ; Liệt kê danh sách, Sắp xếp danh sách ...}

Ví du: Hồ sơ các học sinh của một trường được tổ chức thành danh sách gồm nhiều hồ sơ của từng học sinh; số lượng học sinh trong trường có thể thay đổi do vậy cần có các thao tác thêm, hủy một hồ sơ; để phục vụ công tác giáo vụ cần thực hiện các thao tác tìm hồ sơ của một học sinh, in danh sách hồ sơ ...

Các hình thức tổ chức danh sách

Có nhiều hình thức tổ chức mối liên hệ tuần tự giữa các phần tử trong cùng một danh sách:

  •   Mối liên hệ giữa các phần tử được thể hiện ngầm: mỗi phần tử trong danh sách được đặc trưng bằng chỉ số. Cặp phần tử  xi, xi+1 được xác định là kế cận trong danh sách nhờ vào quan hệ giữa cặp chỉ số i và (i+1). Với hình thức tổ chức này, các phần tử của danh sách thường bắt buộc phải lưu trữ liên tiếp trong bộ nhớ để có thể xây dựng công thức xác định địa chỉ phần tử thứ i:
1 2 3 4 5
9 4 5 3 8

address(i) = address(1) + (i-1)*sizeof(T)

Có thể xem mảng và tập tin là những danh sách đặc biệt được tổ chức theo hình thức liên kết "ngầm" giữa các phần tử. Tuy nhiên mảng có một đặc trưng giới hạn là số phần tử mảng cố định, do vậy không có thao tác thêm, hủy trên mảng; trường hợp tập tin thì các phần tử được lưu trữ trên bộ nhớ phụ có những đặc tính lưu trữ riêng sẽ được trình bày chi tiết ở giáo trình Cấu trúc dữ liệu 2.

Cách biểu diễn này cho phép truy xuất ngẫu nhiên, đơn giản và nhanh chóng đến một phần tử bất kỳ trong danh sách, nhưng lại hạn chế về mặt sử dụng bộ nhớ. Ðối với mảng, số phần tử được xác định trong thời gian biên dịch và cần cấp phát vùng nhớ liên tục. Trong trường hợp tổng kích thước bộ nhớ trống còn đủ để chứa toàn bộ mảng nhưng các ô nhớ trống lại không nằm kế cận nhau thì cũng không cấp phát  vùng nhớ cho mảng được. Ngoài ra do kích thước mảng cố định mà số phần tử của danh sách lại khó dự trù chính xác nên có thể gây ra tình trạng thiếu hụt hay lãng phí bộ nhớ. Hơn nữa các thao tác thêm, hủy một phần tử vào danh sách được thực hiện không tự nhiên trong hình thức tổ chức này.

  •  Mối liên hệ giữa các phần tử được thể hiện tường minh: mỗi phần tử ngoài các thông tin về bản thân còn chứa một liên kết (địa chỉ) đến phần tử kế trong danh sách nên còn được gọi là danh sách móc nối. Do liên kết tường minh, với hình thức này các phần tử trong danh sách không cần phải lưu trữ kế cận trong bộ nhớ nên khắc phục được các khuyết điểm của hình thức tổ chức mảng, nhưng việc truy xuất đến một phần tử đòi hỏi phải thực hiện truy xuất qua một số phần tử khác. Có nhiều kiểu tổ chức liên kết giữa các phần tử trong danh sách như  :

   Danh sách liên kết đơn: mỗi phần tử liên kết với phần tử đứng sau nó trong danh sách:

  Danh sách liên kết kép: mỗi phần tử liên kết với các phần tử đứng trước và sau nó trong danh sách:

Danh sách liên kết vòng : phần tử cuối danh sách liên kết với phần tử đầu danh sách:         

Hình thức liên kết này cho phép các thao tác thêm, hủy trên danh sách được thực hiện dễ dàng, phản ánh được bản chất linh động của danh sách.

0