CTDL & giải thuật

Danh sách liên kết: nút, con trỏ, xóa và đảo danh sách

Danh sách liên kết (linked list) lưu dữ liệu trong các nút nối nhau bằng tham chiếu hoặc con trỏ. Bạn đi từ nút đầu qua từng liên kết next; muốn sửa danh sách, cần sửa đúng các mối nối thay vì dịch toàn bộ mảng.

Ý chính
  • Node chứa giá trị và liên kết tới nút tiếp theo; nút cuối có next rỗng.
  • Biết vị trí cần sửa khác với biết chỉ số: tìm nút thứ i vẫn tốn O(i).
  • Khi đảo, lưu next cũ trước khi ghi đè liên kết, nếu không sẽ mất phần đuôi.

Node, head và next là gì?

Mỗi nút của danh sách liên kết đơn gồm một giá trị và một đường dẫn tới nút kế tiếp. Trong C++, đường dẫn thường biểu diễn bằng Node*. Trong Python, nó là tham chiếu tới đối tượng Node hoặc None. Biến head giữ nút đầu tiên; danh sách rỗng có head bằng nullptr hoặc None.

Khác với vector của C++ và list có sẵn của Python, các nút không cần nằm cạnh nhau trong bộ nhớ. Khi đọc phần tử tiếp theo, chương trình phải theo liên kết next. Bạn không thể từ head nhảy thẳng tới nút thứ một triệu chỉ bằng phép tính địa chỉ. Hãy phân biệt list có sẵn của Python, vốn là mảng động, với danh sách Node tự cài trong bài.

Danh sách gốc 1 → 2 → 3 → 2 → 4 head trỏ tới 1 Xóa giá trị 2 1 → 3 → 4 nối qua nút bị xóa Đảo các liên kết 4 → 3 → 1 head mới là 4
Danh sách gốc 1 → 2 → 3 → 2 → 4 head trỏ tới 1 Xóa giá trị 2 1 → 3 → 4 nối qua nút bị xóa Đảo các liên kết 4 → 3 → 1 head mới là 4
Xóa mọi nút có giá trị 2 khỏi 1 → 2 → 3 → 2 → 4, rồi đảo các liên kết để thu được 4 → 3 → 1.

Đọc độ phức tạp cho đúng điều kiện

Thêm một nút ngay sau nút p đã biết chỉ cần đổi vài liên kết, có chi phí O(1). Nhưng tìm p theo chỉ số hoặc giá trị có thể tốn O(n). Nếu gộp cả hai bước vào một hàm “chèn tại vị trí i”, không được quảng cáo toàn hàm O(1) chỉ vì thao tác nối ở cuối ngắn.

Với danh sách đơn, xóa một nút bất kỳ thường cần biết nút đứng trước nó. Chỉ có con trỏ tới nút cần xóa chưa đủ để xử lý mọi trường hợp, đặc biệt nút cuối. Danh sách liên kết đôi thêm prev để đi ngược, đổi lại tốn thêm bộ nhớ liên kết và phải cập nhật cả hai chiều.

Công việc Điều kiện Chi phí
Đọc nút đầu Head đã có O(1)
Tìm theo giá trị Chưa biết vị trí O(n)
Chèn sau p Đã có p O(1)
Xóa sau p Đã có p, nút sau tồn tại O(1)
Đọc nút thứ i Chỉ có head O(i), xấu nhất O(n)

Những ưu điểm này không có nghĩa linked list luôn nhanh hơn mảng. Mỗi nút cần cấp phát và lưu con trỏ, trong khi duyệt mảng thường thuận lợi hơn cho bộ nhớ đệm. Chọn theo thao tác thật sự cần dùng, không chỉ theo tên cấu trúc.

Xóa mọi nút có giá trị cho trước bằng nút giả

Giả sử cần xóa giá trị 2 khỏi [1, 2, 3, 2, 4]. Nếu chỉ sửa liên kết từ nút trước, trường hợp nút đầu cũng bằng 2 sẽ cần nhánh riêng. Một nút giả dummy đặt trước head giúp mọi nút thật đều có một nút đứng trước để xử lý thống nhất.

Giữ prev ở nút đứng trước vị trí đang xét. Nếu prev.next có giá trị cần xóa, nối prev.next tới nút tiếp nữa. Trong C++, giải phóng nút vừa bỏ bằng delete sau khi đã giữ các liên kết cần thiết. Không tăng prev ở nhánh này, vì nút mới đứng sau prev có thể cũng cần xóa. Nếu không xóa, mới tiến prev sang nút tiếp theo.

Sau vòng lặp trả dummy.next, vì head cũ có thể đã bị bỏ. Nút giả chỉ hỗ trợ xử lý biên, không phải phần tử kết quả. Bản C++ dùng dummy là biến cục bộ nhưng chỉ trả con trỏ tới nút thật, không trả địa chỉ của chính dummy.

Học cùng giảng viên

Chủ đề này nằm trong Lecture 12: Singly Linked List (Danh sách liên kết đơn) của Khóa Green. Xem đề cương

Đảo danh sách với ba tham chiếu

Muốn đảo 1 → 3 → 4, ta cần biến next của 1 thành rỗng, next của 3 thành 1 và next của 4 thành 3. Dùng prev đại diện phần đã đảo, head đại diện phần chưa xử lý và sau giữ nút kế tiếp trước khi sửa.

Mỗi lượt: lưu sau = head.next, đổi head.next = prev, rồi chuyển prev tới head và head tới sau. Khi head rỗng, prev là đầu mới. Bất biến này giúp bạn không bị mất phần đuôi: phần chưa xử lý luôn còn được giữ trong sau hoặc head trước khi đường nối cũ bị ghi đè.

#include <iostream>
#include <vector>
using namespace std;
struct Node {int val;Node* next;Node(int v,Node* p=nullptr):val(v),next(p){}};
Node* tao(const vector<int>& a) {
    Node* head=nullptr;
    for(auto it=a.rbegin();it!=a.rend();++it)head=new Node(*it,head);
    return head;
}
Node* xoa_gia_tri(Node* head,int val) {
    Node dummy(0,head);Node* prev=&dummy;
    while(prev->next) {
        Node* cur=prev->next;
        if(cur->val==val){prev->next=cur->next;delete cur;}
        else prev=cur;
    }
    return dummy.next;
}
Node* dao(Node* head) {
    Node* prev=nullptr;
    while(head){Node* sau=head->next;head->next=prev;prev=head;head=sau;}
    return prev;
}
int main() {
    int n,val=2;vector<int>a={1,2,3,2,4};
    if(cin>>n>>val){a.resize(n);for(int&x:a)cin>>x;}
    Node* head=dao(xoa_gia_tri(tao(a),val));
    while(head){cout<<head->val<<' ';Node* sau=head->next;delete head;head=sau;}
    cout<<'\n';
}

Không nhập dữ liệu, tệp chạy ví dụ và in 4 3 1. Để thử khác, nhập n, giá trị cần xóa, rồi n số. Chương trình dựng danh sách, xóa mọi nút mang giá trị đó và đảo phần còn lại. Cuối cùng bản C++ giải phóng các nút kết quả khi in. Hàm xử lý liên kết chạy O(n) thời gian và O(1) bộ nhớ phụ; phần dựng dữ liệu và các nút vẫn cần O(n) bộ nhớ.

Các ca biên và lỗi mất liên kết

Cẩn thận
  • Ghi head.next = prev trước khi lưu next cũ sẽ làm mất đường tới các nút phía sau.
  • Sau khi xóa một nút, tăng prev ngay có thể bỏ sót hai nút cần xóa đứng liên tiếp.
  • Trả head cũ sau khi đảo là sai: nó đã trở thành nút cuối.
  • Trong C++, đọc lại nút sau delete là truy cập đối tượng đã hết vòng đời.

Thử danh sách rỗng, một nút, mọi nút cùng giá trị cần xóa, chỉ nút đầu bị xóa và hai nút cần xóa đứng liền nhau. Để đối chiếu nhanh trên dữ liệu nhỏ, bạn có thể lọc bỏ giá trị và đảo ngược trên một mảng thường rồi so sánh kết quả với danh sách liên kết. Kiểm tra thêm rằng đảo ngược hai lần liên tiếp sẽ khôi phục đúng thứ tự ban đầu.

Chu trình và con trỏ nhanh chậm

Các hàm trong bài giả định danh sách hữu hạn, không có chu trình. Nếu nút cuối trỏ lại một nút trước, vòng duyệt tới nullptr sẽ không dừng. Thuật toán Floyd dùng một con trỏ đi một bước và một con trỏ đi hai bước; nếu gặp nhau thì có chu trình. Đây là bài mở rộng, không phải cách sửa tùy tiện hàm đảo đang dùng.

Khi làm phỏng vấn, nên nói rõ đầu vào có thể có chu trình hay không. Tương tự, nếu nhiều head cùng trỏ tới chung một đoạn đuôi, việc sửa liên kết từ một head có thể ảnh hưởng head khác. Các ví dụ tạo danh sách độc lập, nên không gặp chia sẻ nút như vậy.

Luyện ngay trên Big-O Coding

Bài xóa nút miễn phí áp dụng trực tiếp dummy. Tiếp theo luyện đảo danh sách và phát hiện chu trình để hiểu khác biệt giữa đổi liên kết và chỉ đổi thứ tự các giá trị.

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

Có nên dùng Node tự cài cho mọi bài Python không?

Không. Nếu đề chỉ cần mảng hoặc hàng đợi, list/deque thường phù hợp hơn. Tự cài Node khi bài yêu cầu cấu trúc liên kết hoặc để học thao tác con trỏ.

Đảo danh sách bằng cách sao chép sang mảng có sai không?

Có thể cho cùng dãy giá trị, nhưng dùng thêm O(n) bộ nhớ và không nhất thiết giữ đúng danh tính các nút. Hãy xem đề yêu cầu đổi liên kết hay chỉ cần xuất ngược.

Danh sách đôi có xóa nút biết trước nhanh hơn không?

Khi đã có nút và quản lý đúng hai liên kết prev/next, xóa tại vị trí đó tốn O(1). Chi phí tìm nút nếu chưa biết vẫn phải tính riêng.

Muốn học bài bản cùng giảng viên?

Khóa Green cho người mới, sinh viên năm 1–2: xây nền tảng lập trình và thuật toán vững chắc, biết dùng AI hỗ trợ. 16 buổi online, học thử buổi 1 miễn phí.

Xem Khóa Green

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483