cv.cm

Lập trình · Cấu trúc dữ liệu

Danh sách liên kết (linked list): cách hoạt động, code và độ phức tạp

7 phút đọc

Trả lời nhanh

Danh sách liên kết là một chuỗi các node, trong đó mỗi node lưu một giá trị và một tham chiếu (con trỏ) tới node kế tiếp. Khác với mảng, các node không nằm cạnh nhau trong bộ nhớ, nên chèn hoặc xóa một node tại vị trí đã biết chỉ tốn O(1), nhưng tìm phần tử thứ k tốn O(n) vì phải đi từ đầu danh sách. Danh sách liên kết đôi còn lưu thêm con trỏ tới node đứng trước.

Danh sách liên kết giải quyết vấn đề gì

Mảng (một list trong Python) lưu các phần tử trong một khối bộ nhớ liên tục. Đọc a[i] là tức thì, nhưng chèn vào đầu mảng buộc mọi phần tử khác dịch sang phải một ô, tốn O(n). Danh sách liên kết tránh việc dịch này: mỗi phần tử nằm trong node riêng, các node nối với nhau bằng tham chiếu. Khi chèn, bạn chỉ cần đổi vài tham chiếu.

Danh sách liên kết là viên gạch nền móng của nhiều cấu trúc khác: ngăn xếp, hàng đợi, bucket của bảng băm (nối chuỗi - separate chaining), bộ nhớ đệm LRU và danh sách kề trong đồ thị.

Ý tưởng giải thích đơn giản

Hãy hình dung trò chơi tìm kho báu: mỗi manh mối chỉ cho bạn manh mối tiếp theo ở đâu. Bạn không thể nhảy thẳng tới manh mối số 7, mà phải đi qua manh mối 1 đến 6 trước. Bản thân danh sách chỉ nhớ node đầu tiên, gọi là head. Node cuối trỏ tới không có gì (None).

LoạiMỗi node lưuỨng dụng điển hình
Liên kết đơngiá trị, nextngăn xếp, hàng đợi đơn giản, bucket của bảng băm
Liên kết đôigiá trị, next, prevdeque, bộ nhớ đệm LRU, bộ đệm của trình soạn thảo văn bản
Vònggiống liên kết đơn hoặc đôi, nhưng node đuôi trỏ ngược về headlập lịch round-robin, bộ đệm vòng (ring buffer)

Ví dụ minh họa: chèn và xóa

Bắt đầu với danh sách 3 -> 7 -> 9. Ta chèn 5 sau 3, rồi xóa 7.

  1. Tạo một node mới có giá trị 5.
  2. Gán new.next bằng node đứng sau 3 (node 7).
  3. Gán node3.next bằng node mới. Danh sách lúc này là 3 -> 5 -> 7 -> 9.
  4. Để xóa 7, tìm node đứng trước nó (5) bằng cách đi từ head.
  5. Gán node5.next = node5.next.next, thao tác này bỏ qua 7. Danh sách lúc này là 3 -> 5 -> 9. Bộ thu gom rác của Python sẽ giải phóng node không còn truy cập được.

Cài đặt danh sách liên kết đơn bằng Python

Lớp này giữ tham chiếu tới head và một biến đếm kích thước. Nó hỗ trợ thêm vào đầu và cuối, xóa giá trị khớp đầu tiên, và duyệt (iteration).

class Node:
    def __init__(self, value, next=None):
        self.value = value
        self.next = next
class LinkedList:
    def __init__(self):
        self.head = None
        self.size = 0
    def push_front(self, value):
        self.head = Node(value, self.head)
        self.size += 1
    def push_back(self, value):
        node = Node(value)
        if self.head is None:
            self.head = node
        else:
            cur = self.head
            while cur.next:
                cur = cur.next
            cur.next = node
        self.size += 1
    def remove(self, value):
        prev, cur = None, self.head
        while cur and cur.value != value:
            prev, cur = cur, cur.next
        if cur is None:
            return False          # not found
        if prev is None:
            self.head = cur.next  # removing the head
        else:
            prev.next = cur.next
        self.size -= 1
        return True
    def __iter__(self):
        cur = self.head
        while cur:
            yield cur.value
            cur = cur.next
items = LinkedList()
for x in [3, 7, 9]:
    items.push_back(x)
items.push_front(1)
items.remove(7)
print(list(items), items.size)  # [1, 3, 9] 3

Ở đây push_back đi qua toàn bộ danh sách nên tốn O(n). Giữ thêm một tham chiếu tail sẽ đưa nó về O(1); các cài đặt hàng đợi thực tế đều làm như vậy.

Thao tác kinh điển: đảo ngược và phát hiện chu trình

Đảo ngược danh sách tại chỗ là câu hỏi phỏng vấn về danh sách liên kết phổ biến nhất. Duyệt danh sách một lần và quay ngược từng con trỏ next.

def reverse(head):
    prev = None
    cur = head
    while cur:
        nxt = cur.next     # remember the rest
        cur.next = prev    # turn the pointer around
        prev, cur = cur, nxt
    return prev            # new head
def has_cycle(head):
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow is fast:
            return True
    return False
head = Node(1, Node(2, Node(3)))
head = reverse(head)
print(head.value, head.next.value, head.next.next.value)  # 3 2 1
print(has_cycle(head))  # False

has_cycle là thuật toán rùa và thỏ của Floyd: con trỏ chậm đi một bước, con trỏ nhanh đi hai bước. Nếu có vòng lặp, con trỏ nhanh cuối cùng sẽ gặp con trỏ chậm; nếu không, nó sẽ tới None. Thuật toán chỉ dùng O(1) bộ nhớ phụ.

Độ phức tạp thời gian và không gian

Thao tácDanh sách liên kết đơnlist Python (mảng động)Lý do
Truy cập phần tử thứ kO(n)O(1)danh sách liên kết phải đi qua k node
Chèn hoặc xóa ở đầuO(1)O(n)mảng phải dịch mọi phần tử
Chèn hoặc xóa sau một node đã biếtO(1)O(n)chỉ đổi các tham chiếu
Thêm vào cuốiO(1) nếu có con trỏ tailO(1) khấu haocả hai đều rẻ
Tìm kiếm một giá trịO(n)O(n)cả hai quét từng phần tử một
Bộ nhớ phụmột hoặc hai tham chiếu cho mỗi nodemột ít dung lượng dự phòngcác node là những đối tượng riêng biệt

Trên thực tế, mảng thường nhanh hơn ngay cả ở những thao tác mà danh sách liên kết có Big O tốt hơn, vì các phần tử mảng nằm cạnh nhau trong bộ nhớ và tận dụng tốt cache của CPU. Các node của danh sách liên kết nằm rải rác.

Các biến thể và lỗi thường gặp

  • Quên trường hợp danh sách rỗng: luôn kiểm tra head is None trước khi đọc head.next.
  • Mất phần còn lại của danh sách do ghi đè next trước khi lưu nó lại (xem nxt trong reverse).
  • Không cập nhật head khi xóa node đầu tiên, hoặc tail khi xóa node cuối.
  • Dùng đệ quy trên danh sách rất dài: giới hạn đệ quy mặc định của Python khoảng 1000 khung, nên code lặp an toàn hơn.
  • Một node đầu giả (sentinel) loại bỏ hầu hết các trường hợp đặc biệt: khi đó mọi node thật đều có node đứng trước.
  • Trong danh sách liên kết đôi, mỗi lần chèn và xóa phải cập nhật bốn tham chiếu; sót một cái sẽ làm hỏng danh sách mà không báo lỗi.

Khi nào dùng danh sách liên kết, và nên dùng gì trong Python

Hãy dùng danh sách liên kết khi bạn chèn và xóa ở hai đầu hoặc ở giữa tại những vị trí bạn đã nắm sẵn tham chiếu, và hiếm khi cần truy cập ngẫu nhiên. Ví dụ: bộ nhớ đệm LRU (danh sách liên kết đôi cộng bảng băm), free list của bộ cấp phát bộ nhớ, lịch sử hoàn tác (undo), hoặc các bucket của bảng băm.

Trong code Python hằng ngày bạn hiếm khi tự viết. Với hàng đợi hoặc deque, hãy dùng collections.deque, cho phép thêm và lấy ở cả hai đầu với O(1). Với các dãy thông thường, dùng list. Chỉ tự viết danh sách liên kết để học, để phỏng vấn, và khi cần ghép node trong O(1), như trong bộ nhớ đệm LRU.

from collections import deque
q = deque([1, 2, 3])
q.appendleft(0)
q.append(4)
print(q.popleft(), q.pop(), list(q))  # 0 4 [1, 2, 3]

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

List trong Python có phải là danh sách liên kết không?

Không. list của Python là một mảng động chứa các tham chiếu nằm liên tục, nên truy cập theo chỉ số là O(1) còn chèn vào đầu là O(n). collections.deque là cấu trúc dựng sẵn gần với danh sách liên kết nhất khi dùng làm hàng đợi.

Tại sao truy cập trong danh sách liên kết là O(n)?

Các node không nằm ở địa chỉ có thể đoán trước. Cách duy nhất để tới node thứ k là bắt đầu từ head và đi theo k tham chiếu next.

Khi nào danh sách liên kết đôi tốt hơn danh sách liên kết đơn?

Khi bạn cần xóa một node chỉ với tham chiếu tới nó, hoặc cần đi lùi. Có con trỏ prev, việc xóa là O(1) mà không cần tìm node đứng trước, đổi lại mỗi node tốn thêm một tham chiếu.

Làm sao tìm node giữa của danh sách liên kết chỉ với một lần duyệt?

Dùng hai con trỏ: một con đi mỗi lần một bước, con kia đi hai bước. Khi con trỏ nhanh tới cuối, con trỏ chậm đang ở giữa.

Node sentinel là gì?

Là một node giả đặt trước node thật đầu tiên (đôi khi cả sau node cuối). Nó không chứa dữ liệu, nhưng nhờ nó mọi node thật đều có node lân cận, nên code chèn và xóa cần ít trường hợp đặc biệt hơn cho danh sách rỗng và head.

Tất cả hướng dẫn lập trình