← Cấu trúc dữ liệu & Giải thuật

Bài 2 · Vận dụng · 22 phút

Mảng & danh sách liên kết

Biên soạn bởi Nguyễn Anh Tuấn

Hai cách lưu một dãy phần tử: mảng (array) truy cập O(1) theo chỉ số, danh sách liên kết (linked list) chèn/xoá O(1) ở đầu - đánh đổi mỗi loại.

Bài trước, ta học cách đo chi phí bằng Độ phức tạp (Big-O). Giờ ta gặp cấu trúc dữ liệu đầu tiên - và sẽ thấy ngay vì sao cùng một việc lại có chi phí khác nhau tuỳ cách dữ liệu được xếp trong bộ nhớ.

Cần lưu một dãy phần tử (một hàng mèo con), có hai cách xếp. Cách 1 - dãy ghế liền nhau, đánh số: mèo con ngồi vào ghế 0, 1, 2… Biết số ghế là tới thẳng, nhưng chen một bạn vào giữa thì cả hàng phải xích ra. Cách 2 - mỗi mèo nắm tay bạn kế tiếp: không cần ghế cố định, ai cũng chỉ nhớ “bạn sau mình là ai”. Đó chính là mảngdanh sách liên kết.

  • Mảng (array): các phần tử nằm trong ô nhớ LIỀN KỀ, đánh chỉ số 0, 1, 2…
  • Danh sách liên kết (linked list): mỗi phần tử là một node, GIỮ con trỏ tới node kế.
  • Cùng lưu một dãy, nhưng chi phí mỗi thao tác lại khác nhau - đó là bài học chính.

Trong mảng, các phần tử nằm khít nhau trong bộ nhớ. Vì biết ô nhớ gốc và mỗi phần tử to bằng nhau, máy tính tính ngay địa chỉ phần tử thứ i: địa chỉ = gốc + i × kích thước. Không cần dò - nhảy thẳng tới nơi. Đó là lý do truy cập theo chỉ số là O(1). (Muốn nhìn ô nhớ và địa chỉ vận hành ở mức phần cứng, ghé bài Bộ nhớ & RAM ở khoá Máy tính hoạt động thế nào.)

Cái giá nằm ở chỗ khác: muốn chèn vào đầu, mọi phần tử cũ phải dịch sang phải một ô để chừa chỗ - tốn O(n).

mang.ts · truy cập thẳng O(1), chèn đầu phải dời cả dãy O(n)

const a = [10, 20, 30, 40, 50];

console.log(a[3]);  // truy cap theo chi so: nhay thang, O(1)

a.unshift(5);       // chen vao dau: doi ca day sang phai, O(n)
console.log(a);

Kết quả khi chạy

40
[ 5, 10, 20, 30, 40, 50 ]
  • Truy cập a[i] theo chỉ số: O(1) - tính địa chỉ rồi nhảy thẳng.
  • Chèn / xoá ở đầu (hay giữa): O(n) - phải dời các phần tử khác.
  • Mảng hợp khi bạn đọc theo vị trí nhiều, ít chèn/xoá ở đầu.

Một node gồm hai phần: value (giá trị) và next (con trỏ tới node kế, hoặc null nếu là node cuối). Ta chỉ nắm head - con trỏ tới node đầu; muốn đi đâu thì lần theo next.

Nhờ vậy chèn vào đầu cực rẻ: tạo một node mới trỏ vào head cũ là xong, O(1), không ai phải dịch chỗ. Nhưng truy cập phần tử thứ i lại phải đi từ head qua từng node - O(n), vì không có chỉ số để nhảy thẳng:

danh-sach.ts · chèn đầu O(1), truy cập theo vị trí O(n)

type Node = { value: number; next: Node | null };

// Chen vao dau: O(1) - chi tao 1 node tro vao head cu
function prepend(head: Node | null, value: number): Node {
  return { value, next: head };
}

// Truy cap phan tu thu i: phai di qua i+1 node - O(n)
function getAt(head: Node | null, i: number): { value: number | null; hops: number } {
  let hops = 0;
  for (let node = head; node; node = node.next) {
    hops++;
    if (hops === i + 1) return { value: node.value, hops };
  }
  return { value: null, hops };
}

let ds: Node | null = null;
for (const v of [30, 20, 10]) ds = prepend(ds, v); // chen dau -> [10, 20, 30]

console.log(getAt(ds, 0)); // phan tu dau
console.log(getAt(ds, 2)); // phan tu cuoi: phai di het

Kết quả khi chạy

{ value: 10, hops: 1 }
{ value: 30, hops: 3 }

Phần tử đầu lấy được sau 1 bước; phần tử cuối phải đi hết 3 node (hops: 3). Danh sách càng dài, lấy phần tử cuối càng lâu - đúng tinh thần O(n).

  • Node = value + con trỏ next; head trỏ tới node đầu, node cuối trỏ null.
  • Chèn / xoá ở đầu: O(1) - chỉ đổi vài con trỏ.
  • Truy cập phần tử thứ i: O(n) - phải đi lần lượt từ head.

Chọn một thao tác và kéo kích thước n để xem mỗi cấu trúc tốn bao nhiêu bước. Ô tô màu cho thấy thao tác phải “đụng” tới đâu - chính là nơi sinh ra chi phí:

Sơ đồ mảng gồm các ô liền kề đánh chỉ số 0-4, và danh sách liên kết gồm các node value+next nối nhau từ head tới null.
Mảng (ô liền kề, có chỉ số) so với danh sách liên kết (node nắm tay nhau qua con trỏ next).

Mảng - ô nhớ liền kề, có chỉ số

0
10
1
20
2
30
3
40
4
50
5
60

Danh sách liên kết - node + con trỏ next

head
10
20
30
40
50
60
null
Mảng O(n) 1.000
Danh sách O(1) 1

→ Danh sách ít bước hơn hẳn

Ô tô màu = nơi thao tác phải đụng tới (hình dùng 6 phần tử). Cột "bước" tính ở n của thanh trượt - đó là số ô nhớ / node việc làm phải chạm.

  • Truy cập theo chỉ số: Mảng O(1) · Danh sách O(n).
  • Chèn / xoá ở đầu: Mảng O(n) · Danh sách O(1) - ngược hẳn lại.
  • Tìm một giá trị (chưa sắp): cả hai O(n), hoà.
  • Không có cấu trúc “tốt nhất” - chỉ có cái HỢP với thao tác bạn làm nhiều.

Trung thực

Bảng trên đơn giản hoá một chút. “Chèn cuối mảng” thật ra là O(1) khấu hao (amortized): mảng động thỉnh thoảng phải phình gấp đôi và chép lại, nhưng tính trung bình vẫn rẻ. “Chèn cuối danh sách” là O(n) vì danh sách đơn không nhớ node cuối - thêm một con trỏ tail là xuống O(1) ngay.

Cùng một Big-O chưa chắc cùng tốc độ thật. Hai điều khiến mảng thường thắng trong thực tế dù trên giấy ngang nhau:

  • Tốn bộ nhớ phụ: mỗi node danh sách phải mang thêm một con trỏ next (8 byte trên máy 64-bit); mảng thì không.
  • Thân thiện cache: mảng nằm liền kề nên CPU nạp một “mẻ” là có sẵn nhiều phần tử; node danh sách rải rác khắp bộ nhớ, đọc tới đâu nhảy tới đó.
  • Vì vậy duyệt tuần tự một mảng thường nhanh hơn duyệt danh sách, dù cùng O(n).

Trung thực

Đừng vội kết luận “mảng luôn tốt hơn”. Khi bạn chèn/xoá ở đầu liên tục, hoặc cần ghép/cắt các đoạn mà không chép dữ liệu, danh sách liên kết (và họ hàng của nó) vẫn là lựa chọn đúng. Quy tắc: chọn theo thao tác bạn làm nhiều nhất, rồi mới tính tới cache.

Mèo con vừa có hai “viên gạch” nền: mảng (truy cập theo chỉ số nhanh) và danh sách liên kết (thêm/bớt ở đầu nhanh). Mọi cấu trúc phức tạp hơn đều xây từ hai viên gạch này, cộng với cái la bàn Big-O của bài trước để cân đo.

Bước tiếp theo

Nếu ta CỐ TÌNH chỉ cho phép thêm/bớt ở một đầu, hai viên gạch này biến thành những công cụ cực kỳ hữu dụng: ngăn xếp và hàng đợi. Hẹn gặp ở bài Ngăn xếp & hàng đợi. Còn khi cần tìm một giá trị thật nhanh, ta sẽ phải sắp xếp trước - chuyện của bài Sắp xếp & tìm kiếm.

Câu hỏi thường gặp

Không hẳn. Array của JS là mảng động (dynamic array), bên dưới engine vẫn cấp một vùng ô nhớ liền kề và tự phình ra khi đầy. Nhờ vậy truy cập theo chỉ số vẫn nhanh gần như O(1). Mảng tĩnh “thuần” (cố định kích thước, ô nhớ liền kề khít) là khái niệm rõ nhất ở C, nhưng ý tưởng chi phí thì giống nhau.

Chỉ ở vài thao tác: chèn/xoá ở ĐẦU là O(1). Còn truy cập theo vị trí thì danh sách CHẬM hơn (O(n) vì phải đi từng node). Trong thực tế mảng còn thắng nhờ ô nhớ liền kề (CPU đọc theo cache rất nhanh), nên đừng mặc định danh sách “xịn hơn”.

head là con trỏ tới node đầu tiên của danh sách. Bạn chỉ nắm mỗi head; muốn tới node sau phải đi theo con trỏ next. Nếu lỡ mất head (không còn biến nào trỏ tới node đầu) thì mất luôn cả danh sách - không có chỉ số để tìm lại như mảng.

Tính cả việc tìm chỗ thì cả hai đều O(n): mảng phải dời các phần tử sau, danh sách phải đi từ head tới đúng vị trí. Nhưng nếu bạn ĐÃ đứng ngay node đó (đã có con trỏ), nối lại danh sách chỉ tốn O(1), còn mảng vẫn phải dời.

Vì node-trỏ-node là nền tảng của hàng loạt cấu trúc sau này: ngăn xếp, hàng đợi, cây, đồ thị đều dựng từ node + con trỏ. Hiểu danh sách liên kết là hiểu cách dữ liệu “móc” vào nhau trong bộ nhớ.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Truy cập phần tử thứ 500 trong MẢNG 1000 phần tử có độ phức tạp là gì?

  1. 1

    Vẽ một danh sách

    Vẽ tay danh sách liên kết chứa [5, 10, 15]: ba node có ô value và mũi tên next, kèm nhãn head ở đầu và null ở cuối.

    Hoàn thành khi: Hình có đúng 3 node nối nhau bằng mũi tên; head trỏ vào node 5; node 15 trỏ tới null.

  2. 2

    Điền bảng Big-O

    Lập bảng 2 cột (Mảng, Danh sách) cho 4 thao tác: truy cập theo chỉ số, chèn đầu, chèn cuối, xoá đầu.

    Hoàn thành khi: Mảng: O(1), O(n), O(1), O(n). Danh sách: O(n), O(1), O(n), O(1) (danh sách đơn không có con trỏ tail).

  3. 3

    Chọn cấu trúc

    Với mỗi việc, chọn mảng hay danh sách và nói 1 câu lý do: (1) lấy phần tử thứ 1000 thật nhanh; (2) liên tục thêm/bớt ở đầu; (3) duyệt lần lượt toàn bộ.

    Hoàn thành khi: (1) Mảng (truy cập chỉ số O(1)); (2) Danh sách (chèn/xoá đầu O(1)); (3) Cả hai đều O(n), mảng thường nhanh hơn nhờ cache.

  4. 4

    Vì sao là O(n)?

    Giải thích bằng lời mèo con vì sao chèn một phần tử vào ĐẦU mảng lại tốn O(n).

    Hoàn thành khi: Nêu được: mọi phần tử cũ phải dịch sang phải một ô để chừa chỗ trống ở đầu, nên số thao tác tỉ lệ với n.

  5. 5

    Thêm con trỏ tail

    Nếu danh sách giữ thêm một con trỏ tail trỏ tới node cuối, thì chèn vào cuối còn tốn O(n) nữa không? Vì sao?

    Hoàn thành khi: Không - chỉ còn O(1): có sẵn tail nên nối node mới vào cuối ngay, khỏi đi từ head dò tới cuối.

  6. 6

    Tự code độ dài

    Viết hàm TypeScript dem(head) đếm số node của một danh sách liên kết, và cho biết Big-O của nó.

    Hoàn thành khi: Hàm đi từ head theo next, mỗi node tăng biến đếm, trả về số đếm; độ phức tạp O(n).