Danh sách¶
Một danh sách (list) là một khái niệm cấu trúc dữ liệu trừu tượng biểu diễn một tập hợp các phần tử có thứ tự, hỗ trợ các thao tác như truy cập, sửa đổi, chèn, xóa và duyệt qua các phần tử, mà không yêu cầu người dùng phải xem xét các giới hạn về dung lượng. Danh sách có thể được triển khai dựa trên danh sách liên kết hoặc mảng.
- Một danh sách liên kết có thể được coi là một danh sách một cách tự nhiên: nó hỗ trợ chèn, xóa, tìm kiếm và cập nhật, đồng thời có thể tăng kích thước linh hoạt khi cần thiết.
- Một mảng cũng hỗ trợ chèn, xóa, tìm kiếm và cập nhật, nhưng vì độ dài của nó là cố định, nên nó chỉ có thể được coi là một danh sách có giới hạn dung lượng.
Khi một danh sách được triển khai bằng mảng, độ dài cố định của nó làm cho nó kém thực tế hơn. Điều này là do chúng ta thường không thể xác định trước lượng dữ liệu cần lưu trữ là bao nhiêu, khiến việc chọn một dung lượng thích hợp trở nên khó khăn. Nếu dung lượng quá nhỏ sẽ không đáp ứng được nhu cầu; nếu quá lớn sẽ gây lãng phí không gian bộ nhớ.
Để giải quyết vấn đề này, chúng ta có thể sử dụng một mảng động (dynamic array) để triển khai danh sách. Nó thừa hưởng tất cả các ưu điểm của mảng trong khi hỗ trợ thay đổi kích thước động trong quá trình thực thi chương trình.
Trên thực tế, các kiểu danh sách được cung cấp bởi thư viện chuẩn của nhiều ngôn ngữ lập trình đều được triển khai bằng mảng động, ví dụ như list trong Python, ArrayList trong Java, vector trong C++, và List trong C#. Trong các phần thảo luận sau đây, chúng ta sẽ coi "danh sách" và "mảng động" là các khái niệm tương đương.
Các thao tác danh sách thông thường¶
Khởi tạo danh sách¶
Chúng ta thường khởi tạo danh sách theo một trong hai cách: rỗng hoặc có giá trị xác định trước:
/* Khởi tạo danh sách */
// Không có giá trị khởi tạo
List<Integer> nums1 = new ArrayList<>();
// Có giá trị khởi tạo (lưu ý rằng các phần tử mảng nên sử dụng lớp bao bọc Integer[] thay vì int[])
Integer[] numbers = new Integer[] { 1, 3, 2, 5, 4 };
List<Integer> nums = new ArrayList<>(Arrays.asList(numbers));
Trực quan hóa mã nguồn
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%88%97%E8%A1%A8%0A%20%20%20%20%23%20%E6%97%A0%E5%88%9D%E5%A7%8B%E5%80%BC%0A%20%20%20%20nums1%20%3D%20%5B%5D%0A%20%20%20%20%23%20%E6%9C%89%E5%88%9D%E5%A7%8B%E5%80%BC%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D%&cumulative=false&curInstr=4&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
Truy cập phần tử¶
Vì danh sách về bản chất là một mảng, chúng ta có thể truy cập và cập nhật các phần tử với độ phức tạp thời gian \(O(1)\), điều này rất hiệu quả.
Trực quan hóa mã nguồn
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%88%97%E8%A1%A8%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D%0A%0A%20%20%20%20%23%20%E8%AE%BF%E9%97%AE%E5%85%83%E7%B4%A0%0A%20%20%20%20num%20%3D%20nums%5B1%5D%20%20%23%20%E8%AE%BF%E9%97%AE%E7%B4%A2%E5%BC%95%201%20%E5%A4%84%E7%9A%84%E5%85%83%E7%B4%A0%0A%0A%20%20%20%20%23%20%E6%9B%B4%E6%96%B0%E5%85%83%E7%B4%A0%0A%20%20%20%20nums%5B1%5D%20%3D%200%20%20%20%20%23%20%E5%B0%86%E7%B4%A2%E5%BC%95%201%20%E5%A4%84%E7%9A%84%E5%85%83%E7%B4%A0%E6%9B%B4%E6%96%B0%E4%B8%BA%200&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
Chèn và xóa phần tử¶
So với mảng, danh sách có thể thêm và xóa phần tử một cách tự do. Thêm một phần tử vào cuối danh sách có độ phức tạp thời gian là \(O(1)\), nhưng việc chèn và xóa phần tử vẫn có hiệu năng tương tự như mảng, với độ phức tạp thời gian là \(O(n)\).
/* Xóa sạch danh sách */
nums.clear();
/* Thêm phần tử vào cuối */
nums.push_back(1);
nums.push_back(3);
nums.push_back(2);
nums.push_back(5);
nums.push_back(4);
/* Chèn một phần tử vào giữa */
nums.insert(nums.begin() + 3, 6); // Chèn số 6 tại chỉ số 3
/* Xóa một phần tử */
nums.erase(nums.begin() + 3); // Xóa phần tử tại chỉ số 3
/* Xóa sạch danh sách */
nums = nil
/* Thêm phần tử vào cuối */
nums = append(nums, 1)
nums = append(nums, 3)
nums = append(nums, 2)
nums = append(nums, 5)
nums = append(nums, 4)
/* Chèn một phần tử vào giữa */
nums = append(nums[:3], append([]int{6}, nums[3:]...)...) // Chèn số 6 tại chỉ số 3
/* Xóa một phần tử */
nums = append(nums[:3], nums[4:]...) // Xóa phần tử tại chỉ số 3
/* Xóa sạch danh sách */
nums.removeAll()
/* Thêm phần tử vào cuối */
nums.append(1)
nums.append(3)
nums.append(2)
nums.append(5)
nums.append(4)
/* Chèn một phần tử vào giữa */
nums.insert(6, at: 3) // Chèn số 6 tại chỉ số 3
/* Xóa một phần tử */
nums.remove(at: 3) // Xóa phần tử tại chỉ số 3
Trực quan hóa mã nguồn
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E6%9C%89%E5%88%9D%E5%A7%8B%E5%80%BC%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%E6%B8%85%E7%A9%BA%E5%88%97%E8%A1%A8%0A%20%20%20%20nums.clear%28%29%0A%20%20%20%20%0A%20%20%20%20%23%20%E5%9C%A8%E5%B0%BE%E9%83%A8%E6%B7%BB%E5%8A%A0%E5%85%83%E7%B4%A0%0A%20%20%20%20nums.append%281%29%0A%20%20%20%20nums.append%283%29%0A%20%20%20%20nums.append%282%29%0A%20%20%20%20nums.append%285%29%0A%20%20%20%20nums.append%284%29%0A%20%20%20%20%0A%20%20%20%20%23%20%E5%9C%A8%E4%B8%AD%E9%97%B4%E6%8F%92%E5%85%A5%E5%85%83%E7%B4%A0%0A%20%20%20%20nums.insert%283,%206%29%20%20%23%20%E5%9C%A8%E7%B4%A2%E5%BC%95%203%20%E5%A4%84%E6%8F%92%E5%85%A5%E6%95%B0%E5%AD%97%206%0A%20%20%20%20%0A%20%20%20%20%23%20%E5%88%A0%E9%99%A4%E5%85%83%E7%B4%A0%0A%20%20%20%20nums.pop%283%29%20%20%20%20%20%20%20%20%23%20%E5%88%A0%E9%99%A4%E7%B4%A2%E5%BC%95%203%20%E5%A4%84%E7%9A%84%E5%85%83%E7%B4%A0&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
Duyệt danh sách¶
Tương tự như mảng, danh sách có thể được duyệt bằng chỉ số hoặc bằng cách lặp trực tiếp qua các phần tử.
Trực quan hóa mã nguồn
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%88%97%E8%A1%A8%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%E9%80%9A%E8%BF%87%E7%B4%A2%E5%BC%95%E9%81%8D%E5%8E%86%E5%88%97%E8%A1%A8%0A%20%20%20%20count%20%3D%200%0A%20%20%20%20for%20i%20in%20range%28len%28nums%29%29%3A%0A%20%20%20%20%20%20%20%20count%20%2B%3D%20nums%5Bi%5D%0A%0A%20%20%20%20%23%20%E7%9B%B4%E6%8E%A5%E9%81%8D%E5%8E%86%E5%88%97%E8%A1%A8%E5%85%83%E7%B4%A0%0A%20%20%20%20for%20num%20in%20nums%3A%0A%20%20%20%20%20%20%20%20count%20%2B%3D%20num&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
Ghép nối danh sách¶
Cho một danh sách mới nums1, chúng ta có thể ghép nối nó vào cuối danh sách ban đầu.
Trực quan hóa mã nguồn
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%88%97%E8%A1%A8%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%E6%8B%BC%E6%8E%A5%E4%B8%A4%E4%B8%AA%E5%88%97%E8%A1%A8%0A%20%20%20%20nums1%20%3D%20%5B6,%208,%207,%2010,%209%5D%0A%20%20%20%20nums%20%2B%3D%20nums1%20%20%23%20%E5%B0%86%E5%88%97%E8%A1%A8%20nums1%20%E6%8B%BC%E6%8E%A5%E5%88%B0%20nums%20%E4%B9%8B%E5%90%8E&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
Sắp xếp danh sách¶
Sau khi sắp xếp danh sách, chúng ta có thể sử dụng các thuật toán tìm kiếm nhị phân (binary search) và hai con trỏ (two-pointer) vốn rất thường gặp trong các bài toán thuật toán về mảng.
Trực quan hóa mã nguồn
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%88%97%E8%A1%A8%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%E6%8E%92%E5%BA%8F%E5%88%97%E8%A1%A8%0A%20%20%20%20nums.sort%28%29%20%20%23%20%E6%8E%92%E5%BA%8F%E5%90%8E%EF%BC%8C%E5%88%97%E8%A1%A8%E5%85%83%E7%B4%A0%E4%BB%8E%E5%B0%8F%E5%88%B0%E5%A4%A7%E6%8E%92%E5%88%97&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
Triển khai danh sách¶
Nhiều ngôn ngữ lập trình tích hợp sẵn kiểu danh sách, chẳng hạn như Java, C++ và Python. Việc triển khai chúng khá phức tạp và các tham số được cân nhắc kỹ lưỡng, ví dụ như dung lượng ban đầu, bội số mở rộng, v.v. Bạn đọc quan tâm có thể tham khảo mã nguồn để tìm hiểu thêm.
Để hiểu sâu hơn về cách hoạt động của danh sách, chúng ta sẽ thử triển khai một danh sách đơn giản với ba cân nhắc thiết kế chính:
- Dung lượng ban đầu: Chọn một dung lượng ban đầu hợp lý cho mảng bên dưới. Trong ví dụ này, chúng ta chọn 10 làm dung lượng ban đầu.
- Theo dõi kích thước: Khai báo một biến
sizeđể ghi lại số lượng phần tử hiện tại trong danh sách và cập nhật biến này trong thời gian thực khi phần tử được chèn hoặc xóa. Dựa vào biến này, chúng ta có thể xác định vị trí cuối danh sách và quyết định xem có cần mở rộng hay không. - Cơ chế mở rộng: Khi dung lượng danh sách đã đầy tại thời điểm chèn phần tử, chúng ta cần mở rộng. Chúng ta tạo một mảng lớn hơn dựa trên bội số mở rộng, sau đó di chuyển tuần tự toàn bộ phần tử từ mảng hiện tại sang mảng mới. Trong ví dụ này, chúng ta quy định mảng sẽ được mở rộng gấp 2 lần kích thước trước đó mỗi lần.