Dynamic Data Structure + Pointers

🔗 Linked List – Danh sách liên kết

Linked List gồm các node được nối bằng con trỏ. Bài học tập trung vào cách tổ chức node, thay đổi liên kết khi chèn/xóa, duyệt danh sách, đảo danh sách và kỹ thuật slow/fast pointer.

Nodedata + next
Insert đầuO(1)
Duyệt/tìmO(n)
Slow/FastGiữa và chu kỳ

🎯 Mục tiêu bài học

Cần hiểu

  • Node, con trỏ next, headtail.
  • Vì sao Linked List không hỗ trợ truy cập ngẫu nhiên O(1).
  • Cách đổi liên kết khi chèn, xóa và đảo danh sách.
  • Vai trò của node trước khi xóa.
  • Kỹ thuật slow/fast pointer.

Cần làm được

  • Chèn đầu, chèn cuối, chèn theo vị trí.
  • Xóa đầu, xóa giá trị đầu tiên, xóa theo vị trí.
  • Tìm kiếm và duyệt danh sách.
  • Đảo danh sách liên kết đơn.
  • Tìm node giữa và phát hiện chu kỳ.

1. Đặt vấn đề

Bài toán mở đầu.
Ta quản lý một dãy dữ liệu thường xuyên chèn và xóa ở đầu hoặc giữa. Nếu dùng mảng/vector, các phần tử phía sau có thể phải dịch chuyển.
Thao tácVector/mảngLinked ListĐiều kiện
Truy cập phần tử thứ iO(1)O(i)Linked List phải đi từ head.
Chèn đầuO(n)O(1)Chỉ đổi một vài con trỏ.
Xóa đầuO(n)O(1)Cập nhật head.
Chèn/xóa sau node đã biếtO(n)O(1)Đã có con trỏ tới node cần thao tác.
Tìm một giá trịO(n)O(n)Đều phải duyệt trong trường hợp tổng quát.

1.1. Ý tưởng thay vì dịch chuyển phần tử

Mỗi phần tử được đặt trong một node. Node lưu dữ liệu và địa chỉ node tiếp theo. Chèn/xóa được thực hiện bằng cách thay đổi liên kết.

Node hiện tại ──next──▶ Node tiếp theo
Không phải Linked List luôn nhanh hơn vector. Linked List mạnh khi đã biết vị trí node cần chèn/xóa; việc tìm vị trí vẫn có thể mất O(n).

2. Cấu tạo node và danh sách

data
giá trị
next
địa chỉ node sau
struct Node {
    int data;
    Node* next;

    Node(int value)
        : data(value), next(nullptr) {}
};

head

Trỏ tới node đầu tiên. Nếu danh sách rỗng, head == nullptr.

tail

Trỏ tới node cuối. Giúp chèn cuối O(1), nhưng phải cập nhật đúng khi danh sách thay đổi.

next

Node cuối có next == nullptr. Đây là điều kiện dừng khi duyệt.

3. Mô phỏng Linked List tương tác

Node* node = new Node(x);
node->next = head;
head = node;
tail->next = node;
tail = node;
while (cur && cur->data != x) cur=cur->next;
node->next = prev->next; prev->next = node;
prev->next = cur->next; delete cur;
Node* old=head; head=head->next; delete old;

Danh sách hiện tại

Mô phỏng dùng mảng để thể hiện giá trị, nhưng phần giải thích và code mẫu dùng đúng logic con trỏ của Linked List.

4. Các thao tác quan trọng

4.1. Chèn đầu

  1. Tạo node mới.
  2. Cho node mới trỏ tới head cũ.
  3. Cập nhật head thành node mới.
  4. Nếu danh sách ban đầu rỗng, cập nhật cả tail.

4.2. Chèn cuối

  1. Tạo node mới có next = nullptr.
  2. Nếu rỗng, head = tail = node mới.
  3. Nếu không rỗng, nối tail cũ tới node mới.
  4. Cập nhật tail.

4.3. Xóa một node

  1. Tìm node cần xóa và node đứng trước.
  2. Nối node trước tới node sau node bị xóa.
  3. Cập nhật head/tail nếu cần.
  4. Giải phóng bộ nhớ bằng delete.

4.4. Tìm kiếm

  1. Bắt đầu từ head.
  2. So sánh dữ liệu node hiện tại.
  3. Nếu chưa thấy, đi theo next.
  4. Dừng khi tìm thấy hoặc cur == nullptr.
Không được viết head->data khi head có thể là nullptr. Luôn kiểm tra danh sách rỗng trước khi truy cập node đầu.

5. Đảo Linked List bằng ba con trỏ

Ta cần giữ node tiếp theo trước khi đổi liên kết, nếu không sẽ mất phần còn lại của danh sách.

nextNode = current→next; current→next = previous; previous = current; current = nextNode
Lỗi thường gặp: đổi current->next = previous trước khi lưu nextNode, làm mất đường đi tới phần còn lại.

6. Slow/Fast Pointer

6.1. Tìm node giữa

slow đi một bước, fast đi hai bước. Khi fast tới cuối, slow ở giữa.

6.2. Phát hiện chu kỳ

Nếu danh sách có chu kỳ, slow và fast cuối cùng sẽ gặp nhau. Nếu fast đạt nullptr, danh sách không có chu kỳ.

while (fast && fast->next) {
    slow = slow->next;
    fast = fast->next->next;

    if (slow == fast) return true;
}
Độ phức tạp: O(n) thời gian và O(1) bộ nhớ phụ.

7. So sánh Linked List và vector

Tiêu chívectorLinked List
Bộ nhớLiên tiếp, tốt cho cache.Rời rạc, mỗi node tốn thêm con trỏ.
Truy cập chỉ sốO(1).O(n).
Chèn/xóa đầuO(n).O(1).
Chèn/xóa giữaO(n) do dịch chuyển.O(1) nếu đã có node trước; O(n) nếu phải tìm.
DuyệtNhanh do cache locality.Thường chậm hơn do truy cập bộ nhớ rời rạc.
STL tương ứngvector.forward_list, list.
Trong lập trình thi đấu, vector thường là lựa chọn mặc định. Chỉ chọn Linked List khi cấu trúc con trỏ hoặc thao tác chèn/xóa theo node thực sự cần thiết.

8. Phân tích thuật toán

Thao tácKhông có tailCó tail / có node trướcGhi chú
Chèn đầuO(1)O(1)Cập nhật head.
Chèn cuốiO(n)O(1) với tailPhải cập nhật tail.
Tìm giá trịO(n)O(n)Không có truy cập ngẫu nhiên.
Xóa theo giá trịO(n)O(n)Phải tìm node trước.
Xóa sau node đã biếtO(1)O(1)Không áp dụng nếu node cần xóa là head.
Đảo danh sáchO(n)O(n)O(1) bộ nhớ phụ.
Tìm giữa / chu kỳO(n)O(n)Slow/Fast, O(1) bộ nhớ.

9. Code mẫu

#include <bits/stdc++.h>
using namespace std;

struct Node {
    int data;
    Node* next;

    Node(int value)
        : data(value), next(nullptr) {}
};

struct LinkedList {
    Node* head = nullptr;
    Node* tail = nullptr;

    bool empty() const {
        return head == nullptr;
    }

    void print() const {
        for (Node* current = head;
             current != nullptr;
             current = current->next) {
            cout << current->data << ' ';
        }
    }

    ~LinkedList() {
        while (head != nullptr) {
            Node* old = head;
            head = head->next;
            delete old;
        }
    }
};
void pushFront(int value) {
    Node* node = new Node(value);

    node->next = head;
    head = node;

    if (tail == nullptr) {
        tail = node;
    }
}

void pushBack(int value) {
    Node* node = new Node(value);

    if (tail == nullptr) {
        head = tail = node;
        return;
    }

    tail->next = node;
    tail = node;
}

bool insertAt(int position, int value) {
    if (position < 0) return false;

    if (position == 0) {
        pushFront(value);
        return true;
    }

    Node* previous = head;

    for (int index = 1;
         index < position && previous != nullptr;
         index++) {
        previous = previous->next;
    }

    if (previous == nullptr) return false;

    if (previous == tail) {
        pushBack(value);
        return true;
    }

    Node* node = new Node(value);
    node->next = previous->next;
    previous->next = node;

    return true;
}
bool popFront() {
    if (head == nullptr) return false;

    Node* old = head;
    head = head->next;

    if (head == nullptr) {
        tail = nullptr;
    }

    delete old;
    return true;
}

bool eraseFirst(int value) {
    if (head == nullptr) return false;

    if (head->data == value) {
        return popFront();
    }

    Node* previous = head;
    Node* current = head->next;

    while (current != nullptr
           && current->data != value) {
        previous = current;
        current = current->next;
    }

    if (current == nullptr) return false;

    previous->next = current->next;

    if (current == tail) {
        tail = previous;
    }

    delete current;
    return true;
}
void reverseList() {
    Node* previous = nullptr;
    Node* current = head;

    tail = head;

    while (current != nullptr) {
        Node* nextNode = current->next;

        current->next = previous;
        previous = current;
        current = nextNode;
    }

    head = previous;
}
Node* middleNode(Node* head) {
    Node* slow = head;
    Node* fast = head;

    while (fast != nullptr
           && fast->next != nullptr) {
        slow = slow->next;
        fast = fast->next->next;
    }

    return slow;
}

bool hasCycle(Node* head) {
    Node* slow = head;
    Node* fast = head;

    while (fast != nullptr
           && fast->next != nullptr) {
        slow = slow->next;
        fast = fast->next->next;

        if (slow == fast) {
            return true;
        }
    }

    return false;
}
#include <bits/stdc++.h>
using namespace std;

int main() {
    list<int> values = {10, 20, 30};

    values.push_front(5);
    values.push_back(40);

    auto iterator = values.begin();
    advance(iterator, 2);

    values.insert(iterator, 15);

    for (int value : values) {
        cout << value << ' ';
    }
}

10. Giải thích code

10.1. Vì sao constructor gán next = nullptr?

Node mới chưa nối tới node nào. Khởi tạo rõ ràng tránh con trỏ rác và giúp node có thể trở thành node cuối.

10.2. Vì sao pushFront phải cập nhật tail khi danh sách rỗng?

Node mới đồng thời là node đầu và node cuối. Nếu chỉ cập nhật head, tail sẽ sai.

10.3. Vì sao xóa phải giữ node previous?

Danh sách liên kết đơn không có con trỏ lùi. Muốn bỏ current khỏi chuỗi, phải thay đổi previous->next.

10.4. Vì sao phải delete node bị xóa?

Node được tạo bằng new. Nếu bỏ liên kết mà không delete, vùng nhớ vẫn tồn tại nhưng không còn truy cập được, gây memory leak.

10.5. Vì sao reverse cần nextNode?

Sau khi đổi current->next, liên kết cũ bị mất. nextNode giữ đường tới phần còn lại.

10.6. Danh sách chẵn có node giữa nào?

Với cách slow đi 1 và fast đi 2 từ head, danh sách chẵn trả về node giữa thứ hai. Có thể thay khởi tạo hoặc điều kiện nếu đề yêu cầu node giữa thứ nhất.

10.7. Vì sao destructor cần duyệt và delete toàn bộ?

Mỗi node được cấp phát động. Destructor giải phóng từng node để tránh rò rỉ bộ nhớ khi đối tượng LinkedList bị hủy.

11. Lỗi thường gặp

  • Truy cập head->data khi head là nullptr.
  • Chèn vào danh sách rỗng nhưng quên cập nhật tail.
  • Xóa node cuối nhưng không cập nhật tail.
  • Thay đổi liên kết trước khi lưu node tiếp theo.
  • Xóa liên kết nhưng quên delete, gây memory leak.
  • delete node rồi tiếp tục dùng con trỏ tới node đó.
  • Cho rằng chèn/xóa theo chỉ số là O(1); thực tế phải tìm vị trí O(n).
  • Không xử lý riêng vị trí 0 khi chèn/xóa.
  • Slow/Fast nhưng không kiểm tra fastfast->next.
  • Sao chép LinkedList chứa con trỏ thô mà không cài Rule of Three/Five, dẫn tới double delete.

12. Quiz và bài tập

Câu 1. Node cuối của danh sách liên kết đơn có next bằng gì?
Câu 2. Chèn tại vị trí i của Linked List có chắc chắn O(1) không?
Câu 3. Khi đảo danh sách, cần lưu gì trước khi đổi current->next?
Câu 4. Slow/Fast tìm giữa có độ phức tạp nào?

📘 Cơ bản

  1. Chèn đầu/cuối.
  2. In danh sách.
  3. Đếm số node.
  4. Tìm giá trị x.

📗 Trung bình

  1. Chèn/xóa theo vị trí.
  2. Xóa mọi node bằng x.
  3. Tìm node thứ k từ cuối.
  4. Tìm node giữa.

📙 Nâng cao

  1. Đảo toàn bộ danh sách.
  2. Đảo đoạn [L,R].
  3. Gộp hai danh sách tăng.
  4. Kiểm tra palindrome.

🐉 HSG

  1. Floyd Cycle Detection.
  2. Tìm điểm bắt đầu chu kỳ.
  3. LRU Cache định hướng.
  4. Danh sách liên kết hai chiều.