Trong thế giới khoa học máy tính và lập trình, cấu trúc dữ liệu đóng vai trò nền tảng, quyết định hiệu quả của việc lưu trữ và truy xuất thông tin. Sau khi tìm hiểu về Danh sách liên kết đơn (Singly Linked List), chúng ta sẽ cùng khám phá một biến thể mạnh mẽ và linh hoạt hơn: Danh Sách Liên Kết đôi (Doubly Linked List). Cấu trúc này khắc phục một số hạn chế của danh sách liên kết đơn, cho phép duyệt dữ liệu theo cả hai chiều và thực hiện một số thao tác xóa hiệu quả hơn. Mặc dù yêu cầu thêm một chút bộ nhớ cho con trỏ ngược, sự linh hoạt mà danh sách liên kết đôi mang lại khiến nó trở thành lựa chọn tối ưu trong nhiều ứng dụng thực tế, từ việc quản lý lịch sử duyệt web đến các chức năng hoàn tác (undo/redo) trong phần mềm. Bài viết này sẽ đi sâu vào khái niệm, ưu nhược điểm và hướng dẫn chi tiết cách cài đặt các thao tác cơ bản trên danh sách liên kết đôi, giúp bạn nắm vững và ứng dụng cấu trúc dữ liệu quan trọng này.
Danh sách liên kết đôi là gì?
Nội dung
Danh sách liên kết đôi (Doubly Linked List) là một cấu trúc dữ liệu tuyến tính, tương tự như danh sách liên kết đơn, nhưng với một điểm khác biệt quan trọng: mỗi nút (node) trong danh sách không chỉ chứa dữ liệu và một con trỏ (next
) đến nút kế tiếp, mà còn chứa thêm một con trỏ (prev
– previous) trỏ đến nút đứng trước nó.
Cụ thể, mỗi nút trong danh sách liên kết đôi bao gồm ba thành phần:
- Data (Dữ liệu): Lưu trữ thông tin của nút.
- Next Pointer (Con trỏ tới): Trỏ đến địa chỉ bộ nhớ của nút tiếp theo trong danh sách. Nếu nút là nút cuối cùng (tail), con trỏ
next
sẽ trỏ tớiNULL
. - Previous Pointer (Con trỏ lui): Trỏ đến địa chỉ bộ nhớ của nút đứng trước nó trong danh sách. Nếu nút là nút đầu tiên (head), con trỏ
prev
sẽ trỏ tớiNULL
.
Mô tả cấu trúc node và liên kết trong danh sách liên kết đôi
Sự tồn tại của con trỏ prev
mang lại khả năng duyệt danh sách theo cả hai chiều: từ đầu đến cuối (forward traversal) và từ cuối về đầu (backward traversal). Điều này tạo ra sự khác biệt lớn so với danh sách liên kết đơn, vốn chỉ cho phép duyệt theo một chiều.
Ưu điểm và Nhược điểm của Danh sách liên kết đôi
So với danh sách liên kết đơn và mảng (Array), danh sách liên kết đôi có những ưu và nhược điểm riêng:
Ưu điểm:
- Duyệt hai chiều: Có thể duyệt danh sách từ đầu đến cuối và ngược lại một cách dễ dàng nhờ con trỏ
prev
vànext
. - Xóa nút hiệu quả hơn: Việc xóa một nút trở nên đơn giản hơn vì có thể dễ dàng truy cập nút đứng trước (
prev
) và nút đứng sau (next
) của nút cần xóa để cập nhật lại các liên kết. Trong danh sách liên kết đơn, để xóa một nút, bạn thường cần tham chiếu đến nút đứng trước nó, việc này đôi khi phức tạp hơn. Với danh sách liên kết đôi, bạn có thể xóa một nút chỉ cần biết chính nút đó (hoặc nút sau nó). - Chèn linh hoạt: Việc chèn một nút vào trước hoặc sau một nút đã biết cũng thuận tiện hơn.
Nhược điểm:
- Tốn bộ nhớ hơn: Mỗi nút cần thêm không gian để lưu trữ con trỏ
prev
, làm tăng tổng dung lượng bộ nhớ yêu cầu so với danh sách liên kết đơn. - Thao tác phức tạp hơn: Các thao tác chèn và xóa đòi hỏi phải cập nhật nhiều con trỏ hơn (cả
next
vàprev
), làm cho việc triển khai mã nguồn phức tạp hơn một chút và có thể tốn thêm một chút thời gian xử lý so với danh sách liên kết đơn.
Cài đặt Danh sách liên kết đôi
Chúng ta sẽ tiến hành cài đặt danh sách liên kết đôi bằng cách định nghĩa lớp Node
và lớp DoublyLinkedList
.
Thiết lập ban đầu
- Lớp Node: Đại diện cho một phần tử trong danh sách. Mỗi node chứa giá trị (
value
), con trỏ tới nút phía trước (prev
), và con trỏ tới nút phía sau (next
).
class Node {
constructor(value, prev = null, next = null) { // Thêm giá trị mặc định là null
this.value = value;
this.prev = prev;
this.next = next;
}
}
- Lớp DoublyLinkedList: Quản lý toàn bộ danh sách. Nó chứa con trỏ đến nút đầu tiên (
head
), con trỏ đến nút cuối cùng (tail
– để tối ưu hóa thao tác cuối danh sách), và độ dài (length
) của danh sách.
class DoublyLinkedList {
constructor() {
this.head = null;
this.tail = null; // Thêm con trỏ tail
this.length = 0;
}
}
- Phương thức phụ trợ
getNodeAtIndex
: Lấy nút ở một vị trí (index) cụ thể. Phương thức này hữu ích cho các thao tác chèn/xóa tại vị trí bất kỳ.
// Thêm phương thức này vào trong class DoublyLinkedList
getNodeAtIndex(index) {
// Kiểm tra chỉ số không hợp lệ
if (index < 0 || index >= this.length) {
return null; // Hoặc ném lỗi tùy theo thiết kế
}
// Tối ưu: Duyệt từ đầu nếu index gần đầu, duyệt từ cuối nếu index gần cuối
let currentNode;
if (index < this.length / 2) {
currentNode = this.head;
for (let i = 0; i < index; i++) {
currentNode = currentNode.next;
}
} else {
currentNode = this.tail;
for (let i = this.length - 1; i > index; i--) {
currentNode = currentNode.prev;
}
}
return currentNode;
}
Các thao tác chèn (Insertion Operations)
Giống như danh sách liên kết đơn, chúng ta có ba trường hợp chèn chính:
1. Chèn vào đầu danh sách (insertAtHead
)
-
Ý tưởng: Tạo nút mới.
prev
của nút mới lànull
.next
của nút mới trỏ tớihead
cũ.prev
củahead
cũ (nếu có) trỏ tới nút mới. Cập nhậthead
thành nút mới. Nếu danh sách rỗng, cập nhật cảtail
. -
Các bước:
- Tạo một
Node
mới vớivalue
được cung cấp,prev
lànull
, vànext
trỏ tớithis.head
hiện tại. - Nếu danh sách không rỗng (
this.head
tồn tại), cập nhật con trỏprev
củahead
cũ để trỏ tới nút mới. - Cập nhật
this.head
để trỏ tới nút mới. - Nếu danh sách ban đầu rỗng (
this.length === 0
), cập nhậtthis.tail
cũng trỏ tới nút mới. - Tăng
this.length
.
- Tạo một
-
Mã nguồn:
// Thêm phương thức này vào trong class DoublyLinkedList
insertAtHead(data) {
const newNode = new Node(data, null, this.head);
if (this.head) {
// Nếu danh sách không rỗng, cập nhật prev của head cũ
this.head.prev = newNode;
} else {
// Nếu danh sách rỗng, tail cũng là node mới
this.tail = newNode;
}
this.head = newNode; // Cập nhật head
this.length++;
// return this; // Có thể trả về danh sách để chaining
}
2. Chèn vào cuối danh sách (insertAtTail
)
-
Ý tưởng: Tạo nút mới.
next
của nút mới lànull
.prev
của nút mới trỏ tớitail
cũ.next
củatail
cũ (nếu có) trỏ tới nút mới. Cập nhậttail
thành nút mới. Nếu danh sách rỗng, gọiinsertAtHead
. -
Các bước:
- Nếu danh sách rỗng (
!this.head
), gọiinsertAtHead(data)
và kết thúc. - Tạo một
Node
mới vớivalue
được cung cấp,prev
trỏ tớithis.tail
hiện tại, vànext
lànull
. - Cập nhật con trỏ
next
củatail
cũ (this.tail
) để trỏ tới nút mới. - Cập nhật
this.tail
để trỏ tới nút mới. - Tăng
this.length
.
- Nếu danh sách rỗng (
-
Mã nguồn:
// Thêm phương thức này vào trong class DoublyLinkedList
insertAtTail(data) {
if (!this.head) {
return this.insertAtHead(data); // Xử lý trường hợp danh sách rỗng
}
const newNode = new Node(data, this.tail, null);
this.tail.next = newNode; // Cập nhật next của tail cũ
this.tail = newNode; // Cập nhật tail
this.length++;
// return this;
}
3. Chèn vào vị trí bất kỳ (insertAtIndex
)
Chèn node vào vị trí bất kỳ trong danh sách liên kết đôi
-
Ý tưởng: Tìm nút đứng trước (
prevNode
) và nút đứng sau (nextNode
) vị trí cần chèn. Tạo nút mới. Cập nhật các con trỏnext
vàprev
củaprevNode
,nextNode
, và nút mới để liên kết chúng lại với nhau. Xử lý các trường hợp đặc biệt (chèn vào đầu, cuối, hoặc chỉ số không hợp lệ). -
Các bước:
- Kiểm tra chỉ số
index
:- Nếu
index < 0
hoặcindex > this.length
: Không hợp lệ (có thể ném lỗi hoặc không làm gì). - Nếu
index === 0
: GọiinsertAtHead(data)
. - Nếu
index === this.length
: GọiinsertAtTail(data)
.
- Nếu
- Lấy nút tại vị trí
index - 1
(gọi làprevNode
) bằnggetNodeAtIndex(index - 1)
. - Lấy nút tại vị trí
index
(gọi lànextNode
, đây là nútprevNode.next
). - Tạo một
Node
mới (newNode
) vớivalue
làdata
,prev
trỏ tớiprevNode
,next
trỏ tớinextNode
. - Cập nhật
prevNode.next
để trỏ tớinewNode
. - Cập nhật
nextNode.prev
để trỏ tớinewNode
. - Tăng
this.length
.
- Kiểm tra chỉ số
-
Mã nguồn:
// Thêm phương thức này vào trong class DoublyLinkedList
insertAtIndex(data, index) {
if (index < 0 || index > this.length) {
// Xử lý chỉ số không hợp lệ (ví dụ: return null hoặc throw Error)
console.error("Index out of bounds");
return false; // Hoặc return null
}
if (index === 0) {
return this.insertAtHead(data);
}
if (index === this.length) {
return this.insertAtTail(data);
}
// Tìm nút đứng trước vị trí chèn (index - 1)
const prevNode = this.getNodeAtIndex(index - 1);
if (!prevNode) return false; // Không tìm thấy node trước đó (lỗi logic nếu index hợp lệ)
const nextNode = prevNode.next; // Nút sẽ đứng sau nút mới
const newNode = new Node(data, prevNode, nextNode);
prevNode.next = newNode; // Cập nhật next của node trước
if (nextNode) { // Cần kiểm tra vì nextNode có thể là null nếu đang chèn cuối (đã xử lý ở trên)
nextNode.prev = newNode; // Cập nhật prev của node sau
}
this.length++;
return true; // Chèn thành công
}
Các thao tác xóa (Deletion Operations)
Tương tự như chèn, việc xóa cũng có ba trường hợp chính:
1. Xóa ở vị trí đầu danh sách (deleteAtHead
)
-
Ý tưởng: Cập nhật
head
thành nút thứ hai.prev
củahead
mới (nếu có) thànhnull
. Xử lý trường hợp danh sách rỗng hoặc chỉ có một phần tử. -
Các bước:
- Kiểm tra danh sách rỗng (
!this.head
), nếu rỗng thì không làm gì. - Lưu lại
head
cũ để có thể trả về (tùy chọn). - Nếu danh sách chỉ có một phần tử (
this.length === 1
), đặtthis.head
vàthis.tail
thànhnull
. - Nếu có nhiều hơn một phần tử:
- Cập nhật
this.head
thànhthis.head.next
(nút thứ hai). - Đặt
this.head.prev
thànhnull
.
- Cập nhật
- Giảm
this.length
. - Trả về giá trị của nút đã xóa (tùy chọn).
- Kiểm tra danh sách rỗng (
-
Mã nguồn:
// Thêm phương thức này vào trong class DoublyLinkedList
deleteAtHead() {
if (!this.head) {
return null; // Danh sách rỗng
}
const deletedNode = this.head; // Lưu lại node bị xóa
if (this.length === 1) {
// Trường hợp chỉ có 1 node
this.head = null;
this.tail = null;
} else {
// Trường hợp có nhiều node
this.head = this.head.next; // head mới là node thứ 2
this.head.prev = null; // prev của head mới là null
deletedNode.next = null; // Ngắt kết nối của node bị xóa (quan trọng)
}
this.length--;
return deletedNode.value; // Trả về giá trị của node đã xóa
}
2. Xóa ở vị trí cuối danh sách (deleteAtTail
)
Xóa node cuối cùng trong danh sách liên kết đôi
-
Ý tưởng: Cập nhật
tail
thành nút kế cuối (tail.prev
).next
củatail
mới thànhnull
. Xử lý trường hợp danh sách rỗng hoặc chỉ có một phần tử. -
Các bước:
- Kiểm tra danh sách rỗng (
!this.tail
hoặc!this.head
). - Nếu danh sách chỉ có một phần tử (
this.length === 1
), gọideleteAtHead()
và kết thúc. - Lưu lại
tail
cũ (deletedNode
). - Cập nhật
this.tail
thànhthis.tail.prev
(nút kế cuối). - Đặt
this.tail.next
thànhnull
. - Ngắt kết nối
prev
của nút đã xóa (deletedNode.prev = null
). - Giảm
this.length
. - Trả về giá trị của nút đã xóa (tùy chọn).
- Kiểm tra danh sách rỗng (
-
Mã nguồn:
// Thêm phương thức này vào trong class DoublyLinkedList
deleteAtTail() {
if (!this.tail) { // Hoặc !this.head
return null; // Danh sách rỗng
}
if (this.length === 1) {
// Nếu chỉ có 1 node, xử lý như xóa head
return this.deleteAtHead();
}
const deletedNode = this.tail; // Lưu lại tail cũ
this.tail = this.tail.prev; // Tail mới là node kế cuối
this.tail.next = null; // Next của tail mới là null
deletedNode.prev = null; // Ngắt kết nối prev của node bị xóa
this.length--;
return deletedNode.value; // Trả về giá trị
}
3. Xóa ở vị trí bất kỳ (deleteAtIndex
)
-
Ý tưởng: Tìm nút cần xóa (
nodeToBeDeleted
). Lấy nút đứng trước (prevNode
) và nút đứng sau (nextNode
). Cập nhậtprevNode.next
trỏ tớinextNode
. Cập nhậtnextNode.prev
trỏ tớiprevNode
. Xử lý các trường hợp đặc biệt (xóa đầu, cuối, chỉ số không hợp lệ). -
Các bước:
- Kiểm tra chỉ số
index
:- Nếu
index < 0
hoặcindex >= this.length
: Không hợp lệ. - Nếu
index === 0
: GọideleteAtHead()
. - Nếu
index === this.length - 1
: GọideleteAtTail()
.
- Nếu
- Lấy nút tại vị trí
index
cần xóa (nodeToBeDeleted
) bằnggetNodeAtIndex(index)
. - Lấy nút đứng trước nó:
prevNode = nodeToBeDeleted.prev
. - Lấy nút đứng sau nó:
nextNode = nodeToBeDeleted.next
. - Cập nhật con trỏ
next
củaprevNode
để trỏ tớinextNode
. - Cập nhật con trỏ
prev
củanextNode
để trỏ tớiprevNode
. - Ngắt kết nối của
nodeToBeDeleted
(nodeToBeDeleted.prev = null; nodeToBeDeleted.next = null;
). - Giảm
this.length
. - Trả về giá trị của nút đã xóa (tùy chọn).
- Kiểm tra chỉ số
-
Mã nguồn:
// Thêm phương thức này vào trong class DoublyLinkedList
deleteAtIndex(index) {
if (index < 0 || index >= this.length) {
// Chỉ số không hợp lệ
return null;
}
if (index === 0) {
return this.deleteAtHead();
}
if (index === this.length - 1) {
return this.deleteAtTail();
}
// Tìm node cần xóa
const nodeToBeDeleted = this.getNodeAtIndex(index);
if (!nodeToBeDeleted) return null; // Không tìm thấy node (không nên xảy ra nếu index hợp lệ)
const prevNode = nodeToBeDeleted.prev;
const nextNode = nodeToBeDeleted.next;
// Bỏ qua nodeToBeDeleted bằng cách nối prevNode và nextNode
if (prevNode) { // Kiểm tra phòng trường hợp lỗi logic
prevNode.next = nextNode;
}
if (nextNode) { // Kiểm tra phòng trường hợp lỗi logic
nextNode.prev = prevNode;
}
// Ngắt kết nối hoàn toàn node bị xóa (tùy chọn nhưng tốt cho garbage collection)
nodeToBeDeleted.prev = null;
nodeToBeDeleted.next = null;
this.length--;
return nodeToBeDeleted.value; // Trả về giá trị
}
Ứng dụng của Danh sách liên kết đôi
Danh sách liên kết đôi được sử dụng trong nhiều tình huống thực tế nhờ khả năng duyệt hai chiều và thao tác hiệu quả:
- Lịch sử trình duyệt: Các nút “Back” và “Forward” trong trình duyệt web có thể được triển khai bằng danh sách liên kết đôi, cho phép di chuyển tới lui giữa các trang đã xem.
- Chức năng Undo/Redo: Trong các trình soạn thảo văn bản hoặc phần mềm đồ họa, danh sách liên kết đôi lưu trữ các trạng thái hoặc hành động, cho phép người dùng hoàn tác hoặc làm lại các thao tác.
- Quản lý Tác vụ (Task Management): Trong một số hệ điều hành, danh sách các tiến trình đang chạy có thể được quản lý bằng danh sách liên kết đôi để dễ dàng thêm, xóa và duyệt qua các tiến trình.
- Bộ đệm (Cache): Các thuật toán cache như LRU (Least Recently Used) thường sử dụng danh sách liên kết đôi kết hợp với hash map để quản lý các mục trong bộ đệm, cho phép truy cập nhanh và loại bỏ mục ít sử dụng nhất một cách hiệu quả.
- Danh sách phát nhạc/video: Cho phép người dùng chuyển tới bài hát/video trước đó hoặc tiếp theo một cách dễ dàng.
Kết luận
Danh sách liên kết đôi là một cấu trúc dữ liệu mạnh mẽ, cung cấp sự linh hoạt trong việc duyệt và thao tác dữ liệu theo cả hai chiều. Mặc dù tốn thêm bộ nhớ cho con trỏ prev
và các thao tác có phần phức tạp hơn so với danh sách liên kết đơn, những ưu điểm về khả năng duyệt hai chiều và xóa nút hiệu quả làm cho nó trở thành công cụ hữu ích trong nhiều bài toán lập trình và ứng dụng thực tế.
Việc hiểu rõ cách thức hoạt động và cài đặt các thao tác cơ bản như chèn (đầu, cuối, vị trí bất kỳ) và xóa (đầu, cuối, vị trí bất kỳ) trên danh sách liên kết đôi là nền tảng quan trọng cho bất kỳ lập trình viên nào muốn làm chủ các cấu trúc dữ liệu và giải thuật. Hy vọng bài viết này đã cung cấp cái nhìn tổng quan và chi tiết, giúp bạn tự tin hơn khi làm việc với danh sách liên kết đôi.