Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 22.220 phút đọc

Ngăn xếp bằng danh sách liên kết

Sau bài này bạn sẽ làm được

  • Cài ngăn xếp liên kết với top là con trỏ
  • So sánh hai cách cài trên bốn tiêu chí
  • Xử lý hết bộ nhớ trong push
  • Hủy ngăn xếp không rò rỉ

Ngăn xếp bằng danh sách liên kết không có giới hạn kích thước và không bao giờ phải cấp phát lại cả khối. Đổi lại mỗi lần push là một lần malloc, và duyệt thì mất hết lợi thế bộ nhớ đệm.

#Cài đặt

stack-lk.h
#ifndef STACK_LK_H
#define STACK_LK_H

#include <stddef.h>

typedef struct SNode {
    int           data;
    struct SNode *next;
} SNode;

/* Bất biến:
     1. top == NULL  khi và chỉ khi  size == 0
     2. size == số nút đi được từ top theo next
   Không cần con trỏ đáy: mọi thao tác chỉ đụng vào đỉnh.        */
typedef struct {
    SNode *top;
    size_t size;
} StackLK;

void   stack_khoi_tao(StackLK *s);
void   stack_huy(StackLK *s);
int    stack_rong(const StackLK *s);
size_t stack_so_phan_tu(const StackLK *s);
int    stack_push(StackLK *s, int v);
int    stack_pop(StackLK *s, int *out);
int    stack_peek(const StackLK *s, int *out);

#endif
stack-lk.c
#include <stdlib.h>

#include "stack-lk.h"

void stack_khoi_tao(StackLK *s)
{
    s->top  = NULL;
    s->size = 0;
}

int stack_rong(const StackLK *s)
{
    return s->top == NULL;
}

size_t stack_so_phan_tu(const StackLK *s)
{
    return s->size;
}

/* Trả về 0 nếu ổn, -1 nếu hết bộ nhớ. Không bao giờ đầy vì lý do khác. */
int stack_push(StackLK *s, int v)
{
    SNode *n = malloc(sizeof *n);

    if (n == NULL) return -1;

    n->data = v;
    n->next = s->top;      /* nút mới trỏ vào đỉnh cũ */
    s->top  = n;           /* nút mới thành đỉnh */

    ++s->size;

    return 0;
}

int stack_pop(StackLK *s, int *out)
{
    if (s->top == NULL) return -1;

    SNode *n = s->top;

    if (out != NULL) *out = n->data;

    s->top = n->next;      /* đọc next TRƯỚC khi free */

    free(n);
    --s->size;

    return 0;
}

int stack_peek(const StackLK *s, int *out)
{
    if (s->top == NULL) return -1;

    if (out != NULL) *out = s->top->data;

    return 0;
}

void stack_huy(StackLK *s)
{
    while (stack_pop(s, NULL) == 0) { }
}

#Vì sao mã gọn hơn hẳn

So với Bài 21.3 và 21.4, mã ở đây không có nhánh nào cho trường hợp danh sách rỗng. Lý do là ngăn xếp chỉ đụng vào một đầu, nên không có tail nào phải giữ đồng bộ.

Danh sách đầy đủ (Bài 21)Ngăn xếp liên kết
Con trỏ trong structhead và tailChỉ top
Số bất biến42
Nhánh xử lý danh sách rỗng trong pushCóKhông
Nhánh xử lý danh sách rỗng trong popCóKhông
Số dòng của push108

#So sánh hai cách cài

Tiêu chíMảngDanh sách liên kết
Tốc độ push và popNhanh hơn nhiềuChậm hơn, mỗi lần một malloc
Giới hạn kích thướcCố định, hoặc phải reallocChỉ giới hạn bởi bộ nhớ hệ thống
Bộ nhớ mỗi phần tử4 byte32 byte thực tế
Cấp phátMột lần, trướcMỗi phần tử một lần
Chi phí xấu nhất của một pushO(n) khi phải nhân đôiO(1) nhưng malloc có thể chậm
Con trỏ tới phần tử có bền khôngKhông, realloc làm treoCó, nút không bao giờ dịch
Thân thiện bộ nhớ đệmRấtKém

#Đo trên máy thật

do-hai-cach.c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define N 5000000

/* --- Bản mảng động --- */
typedef struct { int *d; size_t n, cap; } SA;

static int sa_push(SA *s, int v)
{
    if (s->n == s->cap) {
        size_t moi = s->cap ? s->cap * 2 : 8;
        int   *t   = realloc(s->d, moi * sizeof *t);

        if (t == NULL) return -1;

        s->d = t; s->cap = moi;
    }

    s->d[s->n++] = v;

    return 0;
}

/* --- Bản liên kết --- */
typedef struct SNode { int data; struct SNode *next; } SNode;
typedef struct { SNode *top; } SL;

static int sl_push(SL *s, int v)
{
    SNode *n = malloc(sizeof *n);

    if (n == NULL) return -1;

    n->data = v; n->next = s->top; s->top = n;

    return 0;
}

int main(void)
{
    SA a = { NULL, 0, 0 };
    SL l = { NULL };

    clock_t t0 = clock();

    for (int i = 0; i < N; ++i) if (sa_push(&a, i) != 0) return 1;

    double g_a = (double)(clock() - t0) / CLOCKS_PER_SEC;

    t0 = clock();

    for (int i = 0; i < N; ++i) if (sl_push(&l, i) != 0) return 1;

    double g_l = (double)(clock() - t0) / CLOCKS_PER_SEC;

    /* Đo cả pop, tức duyệt ngược */
    long long tong = 0;

    t0 = clock();

    while (a.n > 0) tong += a.d[--a.n];

    double p_a = (double)(clock() - t0) / CLOCKS_PER_SEC;

    t0 = clock();

    while (l.top != NULL) {
        SNode *t = l.top->next;

        tong += l.top->data;
        free(l.top);
        l.top = t;
    }

    double p_l = (double)(clock() - t0) / CLOCKS_PER_SEC;

    printf("push mang      : %.3f s\n", g_a);
    printf("push danh sach : %.3f s  (%.1f lan cham hon)\n", g_l, g_l / g_a);
    printf("pop  mang      : %.3f s\n", p_a);
    printf("pop  danh sach : %.3f s  (%.1f lan cham hon)\n", p_l, p_l / p_a);
    printf("bo nho mang    : %zu byte\n", a.cap * sizeof *a.d);
    printf("bo nho danh sach: %zu byte\n", (size_t)N * 32);
    printf("(tong %lld)\n", tong);

    free(a.d);

    return 0;
}
terminal
gcc -std=c17 -O2 do-hai-cach.c -o t && ./t
push mang      : 0.024 s
push danh sach : 0.187 s  (7.8 lan cham hon)
pop  mang      : 0.005 s
pop  danh sach : 0.042 s  (8.4 lan cham hon)
bo nho mang    : 33554432 byte
bo nho danh sach: 160000000 byte
(tong 24999995000000)

Chương trình thử tính đúng

main.c
#include <stdio.h>

#include "stack-lk.h"

int main(void)
{
    StackLK s;
    int     v;

    stack_khoi_tao(&s);

    printf("pop tren rong: %d\n", stack_pop(&s, &v));

    for (int i = 1; i <= 5; ++i)
        if (stack_push(&s, i * 10) != 0) { stack_huy(&s); return 1; }

    printf("so phan tu   : %zu\n", stack_so_phan_tu(&s));

    stack_peek(&s, &v);
    printf("dinh         : %d\n", v);

    printf("lay ra       : ");

    while (stack_pop(&s, &v) == 0) printf("%d ", v);

    printf("\nso phan tu   : %zu\n", stack_so_phan_tu(&s));

    /* Đẩy lại rồi hủy giữa chừng để kiểm rò rỉ */
    for (int i = 0; i < 1000; ++i)
        if (stack_push(&s, i) != 0) { stack_huy(&s); return 1; }

    stack_huy(&s);
    stack_huy(&s);      /* gọi lần hai vẫn an toàn */

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra -g stack-lk.c main.c -o t && ./t
pop tren rong: -1
so phan tu   : 5
dinh         : 50
lay ra       : 50 40 30 20 10 
so phan tu   : 0
# Valgrind chạy trên bản không bật sanitizer
valgrind --leak-check=full ./t
All heap blocks were freed -- no leaks are possible
ERROR SUMMARY: 0 errors from 0 contexts

#Chọn cách nào

Phần tử nhỏ, dùng liên kết
/* Ngăn xếp int, dùng danh sách liên kết
   32 byte hạ tầng cho 4 byte dữ liệu, và mỗi push một malloc */
typedef struct SNode { int data; struct SNode *next; } SNode;
Phần tử nhỏ, dùng mảng
/* Ngăn xếp int, dùng mảng động
   Bộ nhớ liên tiếp, một lần cấp phát cho mỗi mốc nhân đôi */
typedef struct { int *d; size_t n, cap; } StackD;

Tự làm thử

  1. Cài StackLK đầy đủ và chạy dưới valgrind cho tới khi không còn rò rỉ.
  2. Viết stack_pop theo thứ tự sai, tức free trước rồi đọc next, và xem -fsanitize=address báo gì.
  3. Chạy do-hai-cach.c trên máy bạn với N bằng 1000, 100000 và 5 triệu. Giải thích vì sao tỷ lệ thay đổi.
  4. Sửa cả hai bản để phần tử là struct 256 byte, rồi đo lại. Xem kết luận có đổi không.
  5. Cài stack_chuyen(StackLK *tu, StackLK *toi, size_t k) chuyển k phần tử đỉnh sang ngăn xếp khác mà không cấp phát gì thêm.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Ngăn xếp liên kết chỉ cần một con trỏ top, và không có nhánh nào cho trường hợp rỗng vì n->next = top tự đúng.
  • Mọi thao tác vẫn là O(1), nhưng chậm hơn bản mảng khoảng tám lần và tốn bộ nhớ gấp năm.
  • Bản mảng có lần push cực chậm ở mỗi mốc nhân đôi. Bản liên kết không, nên nó thắng khi cần độ trễ tối đa thấp.
  • Nút của bản liên kết không bao giờ dịch chỗ, nên con trỏ tới phần tử vẫn dùng được sau khi ngăn xếp lớn lên.
  • Mặc định hãy dùng mảng động. Chỉ chuyển sang liên kết khi có một trong bốn lý do cụ thể ở cuối bài.