Giải thuật và lập trình - C: II. Ngăn xếp (Stack)


Đăng ký nhận thông báo về những video mới nhất

II. NGĂN XẾP (STACK)

1. Định nghĩa ngăn xếp

Ngăn xếp (Stack) là một danh sách mà ta giới hạn việc thêm vào hoặc loại bỏ một phần tử chỉ thực hiện tại một đầu của danh sách, đầu này gọi là đỉnh (TOP) của ngăn xếp.

Ta có thể xem hình ảnh trực quan của ngăn xếp bằng một chồng đĩa đặt trên bàn. Muốn thêm vào chồng đó 1 đĩa ta để đĩa mới trên đỉnh chồng, muốn lấy các đĩa ra khỏi chồng ta cũng phải lấy đĩa trên trước. Như vậy ngăn xếp là một cấu trúc có tính chất “vào sau - ra trước” hay “vào trước – ra sau“ (LIFO (last in - first out ) hay FILO (first in – last out)).

2. Các phép toán trên ngăn xếp

  • MAKENULL_STACK(S): tạo một ngăn xếp rỗng.
  • TOP(S) xem như một hàm trả về phần tử tại đỉnh ngăn xếp. Nếu ngăn xếp rỗng thì hàm không xác định. Lưu ý rằng ở đây ta dùng từ "hàm" để ngụ ý là TOP(S) có trả kết quả ra. Nó có thể không đồng nhất với khái niệm hàm trong ngôn ngữ lập trình như C chẳng hạn, vì có thể kiểu phần tử không thể là kiểu kết quả ra của hàm trong C.
  • POP(S) chương trình con xoá một phần tử tại đỉnh ngăn xếp.
  • PUSH(x,S) chương trình con thêm một phần tử x vào đầu ngăn xếp.
  • EMPTY_STACK(S) kiểm tra ngăn xếp rỗng. Hàm cho kết quả 1 (true) nếu ngăn xếp rỗng và 0 (false) trong trường hợp ngược lại.

Như đã nói từ trước, khi thiết kế giải thuật ta có thể dùng các phép toán trừu tượng như là các "nguyên thủy" mà không cần phải định nghĩa lại hay giải thích thêm. Tuy nhiên để giải thuật đó thành chương trình chạy được thì ta phải chọn một cấu trúc dữ liệu hợp lí để cài đặt các "nguyên thủy" này.

Ví dụ: Viết chương trình con Edit nhận một chuỗi kí tự từ bàn phím cho đến khi gặp kí tự @ thì kết thúc việc nhập và in kết quả theo thứ tự ngược lại.

void Edit(){

    Stack S; char c;

    MakeNull_Stack(&S);

    do{// Lưu từng ký tự vào ngăn xếp

        c=getche();

        Push(c,&S);

    }while (c!='@');

    printf("\nChuoi theo thu tu nguoc lai\n");

    // In ngan xep

    while (!Empty_Stack(S)){

        printf("%c\n",Top(S));

        Pop(&S);

    }

}

Ta có thể truy xuất trực tiếp phần tử tại vị trí bất kỳ trong ngăn xếp được không?

3. Cài đặt ngăn xếp:

a. Cài đặt ngăn xếp bằng danh sách:

Do ngăn xếp là một danh sách đặc biệt nên ta có thể sử dụng kiểu dữ liệu trừu tượng danh sách để biểu diễn cách cài đặt nó (như đã đề cập trong mục III chương 1). Như vậy, ta có thể khai báo ngăn xếp như sau:

typedef List Stack;

Khi chúng ta đã dùng danh sách để biểu diễn cho ngăn xếp thì ta nên sử dụng các phép toán trên danh sách để cài đặt các phép toán trên ngăn xếp. Sau đây là phần cài đặt ngăn xếp bằng danh sách.

Tạo ngăn xếp rỗng:

void MakeNull_Stack(Stack *S){

    MakeNull_List(S);

}

Kiểm tra ngăn xếp rỗng:

int Empty_Stack(Stack S){

    return Empty_List(S);

}

Thêm phần tử vào ngăn xếp

void Push(Elementtype X, Stack *S){

    Insert_List (x, First (*S), S);

}

Xóa phần tử ra khỏi ngăn xếp

void Pop (Stack *S){

    Delete_List (First (*S), S);

}

Tuy nhiên để tăng tính hiệu quả của ngăn xếp ta có thể cài đặt ngăn xếp trực tiếp từ các cấu trúc dữ liệu như các phần sau.

b. Cài đặt bằng mảng

Dùng một mảng để lưu trữ liên tiếp các phần tử của ngăn xếp. Các phần tử đưa vào ngăn xếp bắt đầu từ vị trí có chỉ số cao nhất của mảng, xem hình II.9. Ta còn phải dùng một biến số nguyên (TOP_IDX) giữ chỉ số của phần tử tại đỉnh ngăn xếp.

Ngăn xếp

Hình II.9 Ngăn xếp

Khai báo ngăn xếp

#define MaxLength ... //độ dài của mảng

typedef  ... ElementType; //kiểu các phần tử trong ngăn xếp

typedef struct {

    ElementType Elements[MaxLength];

    //Lưu nội dung của các phần tử

    int Top_idx; //giữ vị trí đỉnh ngăn xếp

} Stack;

Tạo ngăn xếp rỗng

Ngăn xếp rỗng là ngăn xếp không chứa bất kỳ một phần tử nào, do đó đỉnh của ngăn xếp không được phép chỉ đến bất kỳ vị trí nào trong mảng. Để tiện cho quá trình thêm và xóa phần tử ra khỏi ngăn xếp, khi tạo ngăn xếp rỗng ta cho đỉnh ngăn xếp nằm ở vị trí maxlength.

void MakeNull_Stack(Stack *S){

    S->Top_idx=MaxLength;

}

Kiểm tra ngăn xếp rỗng

int Empty_Stack(Stack S){

    return S.Top_idx==MaxLength;

}

Kiểm tra ngăn xếp đầy

int Full_Stack(Stack S){

    return S.Top_idx==0;
}

Lấy nội dung phần tử tại đỉnh của ngăn xếp :

Hàm trả về nội dung phần tử tại đỉnh của ngăn xếp khi ngăn xếp không rỗng. Nếu ngăn xếp rỗng thì hàm hiển thị câu thông báo lỗi.

ElementType Top(Stack S){

    if (!Empty_Stack(S))

        return S.Elements[S.Top_idx];

    else printf("Loi! Ngan xep rong");

}

Chú ý: Nếu ElementType không thể là kiểu kết quả trả về của một hàm thì ta có thể viết Hàm Top như sau:

void TOP(Stack S, Elementtype *X){

    //Trong đó x sẽ lưu trữ nội dung phần tử tại đỉnh của ngăn xếp

    if (!Empty_Stack(S))

        *X = S.Elements[S.Top_idx];

    else printf(“Loi: Ngan xep rong “);

}

Chương trình con xóa phần tử ra khỏi ngăn xếp

Phần tử được xóa ra khỏi ngăn xếp là tại đỉnh của ngăn xếp. Do đó, khi xóa ta chỉ cần dịch chuyển đỉnh của ngăn xếp xuống 1 vị trí (top_idx tăng 1 đơn vị )

void Pop(Stack *S){

    if (!Empty_Stack(*S))

        S->Top_idx=S->Top_idx+1;

     else printf("Loi! Ngan xep rong!");

}

Chương trình con thêm phần tử vào ngăn xếp:

Khi thêm phần tử có nội dung x (kiểu ElementType) vào ngăn xếp S (kiểu Stack), trước tiên ta phải kiểm tra xem ngăn xếp có còn chỗ trống để lưu trữ phần tử mới không. Nếu không còn chỗ trống (ngăn xếp đầy) thì báo lỗi; Ngược lại, dịch chuyển Top_idx lên trên 1 vị trí và đặt x vào tại vị trí đỉnh mới.

void Push(ElementType X, Stack *S){

    if (Full_Stack(*S))

        printf("Loi! Ngan xep day!");

    else{

        S->Top_idx=S->Top_idx-1;

        S->Elements[S->Top_idx]=X;

    }

}

Tất nhiên ta cũng có thể cài đặt ngăn xếp bằng con trỏ, trường hợp này xin dành cho bạn đọc xem như một bài tập nhỏ.

4. Ứng dụng ngăn xếp để loại bỏ đệ qui của chương trình

Nếu một chương trình con đệ qui P(x) được gọi từ chương trình chính ta nói chương trình con được thực hiện ở mức 1. Chương trình con này gọi chính nó, ta nói nó đi sâu vào mức 2... cho đến một mức k. Rõ ràng mức k phải thực hiện xong thì mức k-1 mới được thực hiện tiếp tục, hay ta còn nói là chương trình con quay về mức k-1.

Trong khi một chương trình con từ mức i đi vào mức i+1 thì các biến cục bộ của mức i và địa chỉ của mã lệnh còn dang dở phải được lưu trữ, địa chỉ này gọi là địa chỉ trở về. Khi từ mức i+1 quay về mức i các giá trị đó được sử dụng. Như vậy những biến cục bộ và địa chỉ lưu sau được dùng trước. Tính chất này gợi ý cho ta dùng một ngăn xếp để lưu giữ các giá trị cần thiết của mỗi lần gọi tới chương trình con. Mỗi khi lùi về một mức thì các giá trị này được lấy ra để tiếp tục thực hiện mức này. Ta có thể tóm tắt quá trình như sau:

Bước 1: Lưu các biến cục bộ và địa chỉ trở về.

Bước 2: Nếu thoả điều kiện ngừng đệ qui thì chuyển sang bước 3. Nếu không thì tính toán từng phần và quay lại bước 1 (đệ qui tiếp).

Bước 3: Khôi phục lại các biến cục bộ và địa chỉ trở về.

Ví dụ sau đây minh hoạ việc dùng ngăn xếp để loại bỏ chương trình đệ qui của bài toán "tháp Hà Nội" (tower of Hanoi).

Bài toán "tháp Hà Nội" được phát biểu như sau:

Có ba cọc A,B,C. Khởi đầu cọc A có một số đĩa xếp theo thứ tự nhỏ dần lên trên đỉnh. Bài toán đặt ra là phải chuyển toàn bộ chồng đĩa từ A sang B. Mỗi lần thực hiện chuyển một đĩa từ một cọc sang một cọc khác và không được đặt đĩa lớn nằm trên đĩa nhỏ (hình II.10).

Tháp Hà Nội

Hình II.10: Bài toán tháp Hà Nội

Chương trình con đệ qui để giải bài toán tháp Hà Nội như sau:

void Move(int N, int A, int B, int C)

    //n: số đĩa, A,B,C: cọc nguồn , đích và trung gian

    {

    if (n==1)

        printf("Chuyen 1 dia tu %c sang %c\n",Temp.A,Temp.B);

    else {

        Move(n-1, A,C,B); //chuyển n-1 đĩa từ cọc nguồn sang cọc trung gian

        Move(1,A,B,C); //chuyển 1 đĩa từ cọc nguồn sang cọc đích

        Move(n-1,C,B,A); //chuyển n-1 đĩa từ cọc trung gian sang cọc đích

    }

}

Quá trình thực hiện chương trình con được minh hoạ với ba đĩa (n=3) như sau:

Chương trình con minh họa:

Để khử đệ qui ta phải nắm nguyên tắc sau đây:

Mỗi khi chương trình con đệ qui được gọi, ứng với việc đi từ mức i vào mức i+1, ta phải lưu trữ các biến cục bộ của chương trình con ở bước i vào ngăn xếp. Ta cũng phải lưu "địa chỉ mã lệnh" chưa được thi hành của chương trình con ở mức i. Tuy nhiên khi lập trình bằng ngôn ngữ cấp cao thì đây không phải là địa chỉ ô nhớ chứa mã lệnh của máy mà ta sẽ tổ chức sao cho khi mức i+1 hoàn thành thì lệnh tiếp theo sẽ được thực hiện là lệnh đầu tiên chưa được thi hành trong mức i.

Tập hợp các biến cục bộ của mỗi lần gọi chương trình con xem như là một mẩu tin hoạt động (activation record).

Mỗi lần thực hiện chương trình con tại mức i thì phải xoá mẩu tin lưu các biến cục bộ ở mức này trong ngăn xếp.

Như vậy nếu ta tổ chức ngăn xếp hợp lí thì các giá trị trong ngăn xếp chẳng những lưu trữ được các biến cục bộ cho mỗi lần gọi đệ qui, mà còn "điều khiển được thứ tự trở về" của các chương trình con. Ý tưởng này thể hiện trong cài đặt khử đệ qui cho bài toán tháp Hà Nội là: mẩu tin lưu trữ các biến cục bộ của chương trình con thực hiện sau thì được đưa vào ngăn xếp trước để nó được lấy ra dùng sau.

//Kiểu cấu trúc lưu trữ biến cục bộ

typedef struct{

     int N;

    int A, B, C;

} ElementType;

// Chương trình con MOVE không đệ qui

void Move(ElementType X){

    ElementType Temp, Temp1;

    Stack S; MakeNull_Stack(&S); Push(X,&S);

    do

    {

        Temp=Top(S); //Lay phan tu dau

        Pop(&S); //Xoa phan tu dau

        if (Temp.N==1)

            printf("Chuyen 1 dia tu %c sang %c\n",Temp.A,Temp.B);

        else

        {

            // Luu cho loi goi Move(n-1,C,B,A)

            Temp1.N=Temp.N-1;

            Temp1.A=Temp.C;

            Temp1.B=Temp.B;

            Temp1.C=Temp.A;

            Push(Temp1,&S);

            // Luu cho loi goi Move(1,A,B,C)

            Temp1.N=1;

            Temp1.A=Temp.A;

            Temp1.B=Temp.B;

            Temp1.C=Temp.C;

            Push(Temp1,&S);

            //Luu cho loi goi Move(n-1,A,C,B)

            Temp1.N=Temp.N-1;

            Temp1.A=Temp.A;

            Temp1.B=Temp.C;

            Temp1.C=Temp.B;

            Push(Temp1,&S);

        }

    } while (!Empty_Stack(S));

}

Minh họa cho lời gọi Move(x) với 3 đĩa, tức là x.N=3.

Ngăn xếp khởi đầu:

 

3,A,B,C

Ngăn xếp sau lần lặp thứ nhất:

 

2,A,C,B

1,A,B,C

2,C,B,A

Ngăn xếp sau lần lặp thứ hai:

 

1,A,B,C

1,A,C,B

1,B,C,A

1,A,B,C

2,C,B,A

Các lần lặp 3,4,5,6 thì chương trình con xử lý trường hợp chuyển 1 đĩa (ứng với trường hợp không gọi đệ qui), vì vậy không có mẩu tin nào được thêm vào ngăn xếp. Mỗi lần xử lý, phần tử đầu ngăn xếp bị xoá. Ta sẽ có ngăn xếp như sau.

 

2,C,B,A

Tiếp tục lặp bước 7 ta có ngăn xếp như sau:

 

1,C,A,B

1,C,B,A

1,A,B,C

Các lần lặp tiếp tục chỉ xử lý việc chuyển 1 đĩa (ứng với trường hợp không gọi đệ quy).

Chương trình con in ra các phép chuyển và dẫn đến ngăn xếp rỗng.


Nếu bạn có điều thắc mắc, bạn hãy comment cho V1Study để được giải đáp.
Bài viết này được chia sẻ bởi LongDT. Nếu bạn muốn chia sẻ bài viết, bạn hãy Đăng ký làm thành viên!
« Prev
Next »
Copied !!!