C++实战㉔】解锁C++ STL魔法:list与deque实战秘籍
目录
- 一、list 容器的实战应用
- 1.1 list 的特点
- 1.2 list 的常用成员函数
- 1.3 list 与 vector 的适用场景对比
- 二、deque 容器的实战应用
- 2.1 deque 的特点
- 2.2 deque 的常用成员函数
- 2.3 deque 在队列与栈场景中的应用
- 三、list 与 deque 的实战技巧
- 3.1 list 与 deque 的迭代器特性
- 3.2 容器间的数据拷贝与转换
- 3.3 容器的遍历与算法结合使用
- 四、实战项目:任务调度系统(list/deque 版)
- 4.1 项目需求
- 4.2 list/deque 容器实现任务管理代码
- 4.3 调度逻辑测试与效率优化
一、list 容器的实战应用
1.1 list 的特点
list是 C++ STL 中的一种序列容器,它基于双向链表的数据结构实现。双向链表意味着每个节点都包含指向前一个节点和后一个节点的指针,这赋予了list独特的性质。
在插入和删除操作上,list表现得极为出色。当在list中插入一个新元素时,只需调整相关节点的指针,而无需移动其他元素。比如,在链表中间插入一个节点,仅需修改插入位置前后两个节点的指针指向,时间复杂度为O(1)O(1)O(1)。同样,删除操作也只需更新相邻节点的指针,就能将目标节点从链表中移除,时间复杂度同样为O(1)O(1)O(1)。这使得list在需要频繁进行插入和删除操作的场景中,具有明显的优势。
从内存使用角度来看,list的内存分配是离散的。每个节点在需要时单独分配内存,节点之间的内存地址不一定连续。这与vector等容器不同,vector需要一块连续的内存空间来存储元素。这种离散的内存分配方式,使得list在处理大量数据时,不容易受到内存碎片的影响,因为它不需要一次性申请大片连续内存。
不过,list的随机访问性能较差。由于其节点内存不连续,无法像数组或vector那样通过索引直接访问元素。如果要访问list中的第n个元素,必须从链表的头部或尾部开始,逐个遍历节点,直到找到目标元素,时间复杂度为O(n)O(n)O(n)。这在需要频繁随机访问元素的场景中,会成为list的一个短板。
1.2 list 的常用成员函数
- insert函数:用于在指定位置插入元素。它有多种重载形式,最常见的是在迭代器指向的位置之前插入一个元素。例如:
#include <iostream>
#include <list>int main() {std::list<int> myList = {1, 2, 3, 4};auto it = myList.begin();++it; // 让it指向第二个元素myList.insert(it, 99); // 在第二个元素之前插入99for (int num : myList) {std::cout << num << " ";}return 0;
}
上述代码中,先创建了一个包含1, 2, 3, 4的list,然后通过迭代器将99插入到第二个元素之前,最终输出结果为1 99 2 3 4。
- erase函数:用于删除指定位置的元素或指定范围内的元素。删除单个元素时,只需传入要删除元素的迭代器。例如:
#include <iostream>
#include <list>int main() {std::list<int> myList = {1, 2, 3, 4};auto it = myList.begin();++it; // 让it指向第二个元素myList.erase(it); // 删除第二个元素for (int num : myList) {std::cout << num << " ";}return 0;
}
这段代码会删除myList中的第二个元素,输出结果为1 3 4。
- sort函数:用于对list中的元素进行排序。list内部实现了自己的排序算法,通常是归并排序,这保证了排序的稳定性。使用时直接调用sort函数即可:
#include <iostream>
#include <list>int main() {std::list<int> myList = {5, 3, 1, 4, 2};myList.sort();for (int num : myList) {std::cout << num << " ";}return 0;
}
运行上述代码,myList中的元素会被排序,输出结果为1 2 3 4 5。
1.3 list 与 vector 的适用场景对比
- 随机访问需求:如果需要频繁地随机访问容器中的元素,vector是更好的选择。因为vector的元素存储在连续的内存空间中,可以通过下标直接访问,时间复杂度为O(1)O(1)O(1)。例如,在实现一个数组查询功能时,vector能够快速定位到指定位置的元素。而list的随机访问时间复杂度为O(n)O(n)O(n),性能较差,不适合此类场景。
- 插入和删除操作:当面临频繁的插入和删除操作,特别是在容器中间位置进行操作时,list表现更优。比如,在一个实时更新的数据列表中,经常需要插入新数据或删除旧数据,list的O(1)O(1)O(1)插入和删除时间复杂度能大大提高效率。而vector在中间位置插入或删除元素时,需要移动大量元素,时间复杂度为O(n)O(n)O(n),效率较低。不过,如果只是在vector的尾部进行插入和删除操作,其平均时间复杂度接近O(1)O(1)O(1),性能与list相当。
- 内存使用:vector在内存使用上更为紧凑,因为它的元素连续存储,没有额外的指针开销。但在某些情况下,当vector的容量不足需要重新分配内存时,可能会造成内存的浪费和性能的下降。list每个节点都有额外的指针用于指向前驱和后继节点,内存开销相对较大,但它不需要连续内存,在处理大规模动态数据时,能有效避免内存碎片化问题。
二、deque 容器的实战应用
2.1 deque 的特点
deque,即双端队列(double-ended queue),是 C++ STL 中一种功能强大的序列容器,它结合了数组和链表的一些优点。从数据结构角度来看,deque可以被视为一个动态数组。与普通数组不同,它的大小可以在运行时动态变化,无需手动管理内存的分配与释放。
deque最大的特点之一是支持在两端进行高效的插入和删除操作。无论是在队列的头部(push_front)还是尾部(push_back)插入或删除元素,时间复杂度都为(O(1))。这使得deque在需要频繁在两端进行数据操作的场景中表现出色,比如实现一个高效的任务调度队列,新任务可以从头部插入,完成的任务从尾部删除。
在内存结构上,deque并不要求所有元素存储在连续的内存空间中。它通常由多个固定大小的块组成,这些块在内存中可以是不连续的。通过一个映射表(通常是一个指针数组)来管理这些块,使得deque能够灵活地扩展和收缩。这种内存管理方式,避免了像vector在进行大量插入删除操作时可能出现的频繁内存重新分配和数据拷贝问题,从而提高了效率。
尽管deque的元素在内存中不连续,但它仍然提供了随机访问的能力,支持使用[]操作符和at()函数来访问元素。不过,由于需要通过映射表来定位元素,其随机访问的效率略低于vector。例如,在访问deque中第n个元素时,虽然时间复杂度在理论上为O(1)O(1)O(1),但实际执行速度可能会比访问vector中相同位置的元素慢一些。
2.2 deque 的常用成员函数
- push_front函数:用于在deque的头部插入一个元素。例如:
#include <iostream>
#include <deque>int main() {std::deque<int> myDeque;myDeque.push_front(10); // 在头部插入10myDeque.push_front(20); // 在头部插入20,此时deque为{20, 10}for (int num : myDeque) {std::cout << num << " ";}return 0;
}
上述代码创建了一个空的deque,然后使用push_front函数插入两个元素,最终输出结果为20 10。
- pop_front函数:用于删除deque头部的元素。需要注意的是,在调用pop_front之前,应确保deque不为空,否则会导致未定义行为。例如:
#include <iostream>
#include <deque>int main() {std::deque<int> myDeque = {10, 20, 30};myDeque.pop_front(); // 删除头部元素10,此时deque为{20, 30}for (int num : myDeque) {std::cout << num << " ";}return 0;
}
这段代码删除了myDeque头部的元素,输出结果为20 30。
- push_back函数:在deque的尾部插入一个元素,这是一种非常高效的操作,常用于实现队列的入队操作。例如:
#include <iostream>
#include <deque>int main() {std::deque<int> myDeque;myDeque.push_back(1);myDeque.push_back(2);for (int num : myDeque) {std::cout << num << " ";}return 0;
}
该代码在myDeque的尾部插入两个元素,输出结果为1 2。
- pop_back函数:删除deque尾部的元素,类似于栈的出栈操作。例如:
#include <iostream>
#include <deque>int main() {std::deque<int> myDeque = {1, 2, 3};myDeque.pop_back(); // 删除尾部元素3,此时deque为{1, 2}for (int num : myDeque) {std::cout << num << " ";}return 0;
}
运行上述代码,会删除myDeque尾部的元素,输出结果为1 2。
2.3 deque 在队列与栈场景中的应用
- 队列场景:队列是一种先进先出(FIFO, First-In-First-Out)的数据结构,deque的特性使其非常适合用于实现队列。通过push_back函数将元素添加到队列的尾部(入队操作),使用pop_front函数从队列的头部移除元素(出队操作)。例如:
#include <iostream>
#include <deque>class Queue {
private:std::deque<int> data;
public:void enqueue(int value) {data.push_back(value);}int dequeue() {if (data.empty()) {throw std::runtime_error("Queue is empty");}int value = data.front();data.pop_front();return value;}bool empty() const {return data.empty();}
};int main() {Queue q;q.enqueue(1);q.enqueue(2);q.enqueue(3);while (!q.empty()) {std::cout << q.dequeue() << " ";}return 0;
}
上述代码定义了一个Queue类,内部使用deque来实现队列的功能。通过enqueue和dequeue方法,分别实现了队列的入队和出队操作,最终输出结果为1 2 3。
- 栈场景:栈是一种后进先出(LIFO, Last-In-First-Out)的数据结构,deque同样可以用于实现栈。使用push_back函数将元素压入栈顶(相当于栈的push操作),通过pop_back函数从栈顶弹出元素(相当于栈的pop操作)。例如:
#include <iostream>
#include <deque>class Stack {
private:std::deque<int> data;
public:void push(int value) {data.push_back(value);}int pop() {if (data.empty()) {throw std::runtime_error("Stack is empty");}int value = data.back();data.pop_back();return value;}bool empty() const {return data.empty();}
};int main() {Stack s;s.push(1);s.push(2);s.push(3);while (!s.empty()) {std::cout << s.pop() << " ";}return 0;
}
这段代码定义了一个Stack类,利用deque实现了栈的功能。通过push和pop方法,分别完成了栈的压入和弹出操作,最终输出结果为3 2 1。
三、list 与 deque 的实战技巧
3.1 list 与 deque 的迭代器特性
- list 迭代器:list的迭代器是双向迭代器,基于双向链表的节点指针实现。这意味着它只能逐个移动到前一个或后一个元素,不支持随机访问。例如,要访问list中第n个元素,必须从当前位置开始,通过多次++或–操作,逐个遍历节点,直到找到目标元素。当在list中插入或删除元素时,只有指向被操作节点的迭代器会失效,其他迭代器不受影响。比如在链表中间插入一个新节点,除了指向插入位置的迭代器需要重新调整,其他迭代器仍然有效,因为链表的结构调整仅涉及插入位置的相邻节点指针变动。
- deque 迭代器:deque的迭代器是随机访问迭代器,虽然deque的元素在内存中不是完全连续存储,但通过其内部的映射表和块结构,迭代器可以实现随机访问。不过,由于需要通过映射表定位元素所在的块和块内偏移,其随机访问效率略低于vector的迭代器。在插入和删除操作时,如果是在deque的两端进行操作,迭代器的失效情况相对简单。例如,在头部或尾部插入元素,只有指向插入位置的迭代器会失效;但在中间位置插入或删除元素时,可能会导致多个迭代器失效,因为这可能涉及到块的重新分配和映射表的调整。
3.2 容器间的数据拷贝与转换
- list 转 vector:可以使用vector的构造函数,通过传入list的起始和结束迭代器来实现数据拷贝。示例代码如下:
#include <iostream>
#include <vector>
#include <list>int main() {std::list<int> myList = {1, 2, 3, 4, 5};std::vector<int> myVector(myList.begin(), myList.end());for (int num : myVector) {std::cout << num << " ";}return 0;
}
上述代码将myList中的元素拷贝到myVector中。需要注意的是,如果list中的元素是自定义类型且包含动态分配的资源,在进行数据拷贝时,会调用元素的拷贝构造函数,确保资源的正确复制。如果希望避免不必要的拷贝,可以使用移动语义,通过std::make_move_iterator将list的迭代器转换为移动迭代器,再进行数据转移。
- deque 转 list:同样可以利用list的构造函数,传入deque的迭代器范围来实现转换。示例代码如下:
#include <iostream>
#include <deque>
#include <list>int main() {std::deque<int> myDeque = {1, 2, 3, 4, 5};std::list<int> myList(myDeque.begin(), myDeque.end());for (int num : myList) {std::cout << num << " ";}return 0;
}
在进行容器间的数据转换时,要确保目标容器有足够的容量来接收数据。如果目标容器容量不足,可能会导致频繁的内存重新分配,影响性能。此外,还要注意数据类型的兼容性,确保源容器和目标容器的元素类型相同或可隐式转换。
3.3 容器的遍历与算法结合使用
- list 与算法结合:在遍历list时,可以结合 STL 算法来实现各种功能。例如,使用std::find算法查找list中的特定元素:
#include <iostream>
#include <list>
#include <algorithm>int main() {std::list<int> myList = {1, 2, 3, 4, 5};auto it = std::find(myList.begin(), myList.end(), 3);if (it != myList.end()) {std::cout << "找到元素: " << *it << std::endl;}return 0;
}
上述代码使用std::find在myList中查找元素3。由于list支持双向迭代器,许多 STL 算法都可以应用于list,如std::for_each用于对每个元素执行特定操作,std::remove_if用于根据条件删除元素等。
- deque 与算法结合:deque由于支持随机访问迭代器,除了可以使用与list相同的一些算法外,还能更好地与需要随机访问能力的算法配合。例如,使用std::sort算法对deque中的元素进行排序:
#include <iostream>
#include <deque>
#include <algorithm>int main() {std::deque<int> myDeque = {5, 3, 1, 4, 2};std::sort(myDeque.begin(), myDeque.end());for (int num : myDeque) {std::cout << num << " ";}return 0;
}
这段代码使用std::sort对myDeque进行排序。在结合算法使用时,要根据容器的迭代器特性选择合适的算法,以充分发挥容器和算法的优势,提高代码的效率和可读性。
四、实战项目:任务调度系统(list/deque 版)
4.1 项目需求
任务调度系统旨在高效管理和执行一系列任务,满足任务添加、删除和优先级调度的功能需求。
- 任务添加:系统应支持将新任务加入到任务队列中。每个任务包含任务 ID、任务描述、优先级等信息。新任务可以根据其优先级插入到合适的位置,确保高优先级任务优先执行。例如,在一个服务器管理系统中,当有紧急的系统更新任务时,应能快速将其添加到任务队列,并根据优先级安排执行顺序。
- 任务删除:可以根据任务 ID 或其他唯一标识从任务队列中删除指定任务。在任务执行过程中,如果某个任务被取消或不再需要执行,应能及时从队列中移除,释放相关资源。比如,在一个数据处理任务调度系统中,若某个数据采集任务因数据源故障无法继续执行,就需要将其从任务队列中删除。
- 优先级调度:根据任务的优先级对任务进行排序和调度。优先级高的任务优先从任务队列中取出并执行,确保关键任务能够及时得到处理。系统应提供灵活的优先级设置方式,用户可以根据任务的重要性和紧急程度为任务分配不同的优先级。例如,在一个实时监控系统中,报警任务的优先级应高于常规的数据采集任务。
4.2 list/deque 容器实现任务管理代码
下面给出使用list和deque实现任务管理的核心代码:
#include <iostream>
#include <list>
#include <deque>
#include <string>// 任务结构体定义
struct Task {int taskID;std::string description;int priority;Task(int id, const std::string& desc, int prio): taskID(id), description(desc), priority(prio) {}
};// 使用list实现任务管理
class TaskManagerList {
private:std::list<Task> tasks;public:// 添加任务,根据优先级插入void addTask(const Task& task) {auto it = tasks.begin();while (it != tasks.end() && it->priority >= task.priority) {++it;}tasks.insert(it, task);}// 删除任务,根据任务IDvoid deleteTask(int taskID) {tasks.remove_if([taskID](const Task& task) {return task.taskID == taskID;});}// 获取下一个任务(最高优先级)Task getNextTask() {if (tasks.empty()) {throw std::runtime_error("No tasks in the queue");}Task task = tasks.front();tasks.pop_front();return task;}// 打印所有任务void printTasks() const {for (const auto& task : tasks) {std::cout << "Task ID: " << task.taskID<< ", Description: " << task.description<< ", Priority: " << task.priority << std::endl;}}
};// 使用deque实现任务管理
class TaskManagerDeque {
private:std::deque<Task> tasks;public:// 添加任务,根据优先级插入void addTask(const Task& task) {auto it = tasks.begin();while (it != tasks.end() && it->priority >= task.priority) {++it;}tasks.insert(it, task);}// 删除任务,根据任务IDvoid deleteTask(int taskID) {for (auto it = tasks.begin(); it != tasks.end(); ++it) {if (it->taskID == taskID) {tasks.erase(it);return;}}}// 获取下一个任务(最高优先级)Task getNextTask() {if (tasks.empty()) {throw std::runtime_error("No tasks in the queue");}Task task = tasks.front();tasks.pop_front();return task;}// 打印所有任务void printTasks() const {for (const auto& task : tasks) {std::cout << "Task ID: " << task.taskID<< ", Description: " << task.description<< ", Priority: " << task.priority << std::endl;}}
};
4.3 调度逻辑测试与效率优化
- 调度逻辑测试:
- 测试用例设计:
- 添加多个不同优先级的任务,验证任务是否按照优先级顺序排列。例如,添加任务 A(优先级 3)、任务 B(优先级 1)、任务 C(优先级 2),然后检查任务队列中的顺序是否为 B、C、A。
- 添加任务后,删除其中一个任务,验证任务是否被正确删除,且剩余任务的顺序是否正确。比如删除任务 C,检查任务队列是否变为 B、A。
- 多次获取下一个任务,验证每次获取的是否是当前最高优先级的任务。
- 测试代码示例:
- 测试用例设计:
int main() {TaskManagerList managerList;// 添加任务managerList.addTask(Task(1, "Task 1", 3));managerList.addTask(Task(2, "Task 2", 1));managerList.addTask(Task(3, "Task 3", 2));// 打印任务,验证添加和排序std::cout << "Tasks in list:" << std::endl;managerList.printTasks();// 删除任务managerList.deleteTask(2);std::cout << "Tasks after deletion:" << std::endl;managerList.printTasks();// 获取下一个任务try {Task nextTask = managerList.getNextTask();std::cout << "Next task:" << std::endl<< "Task ID: " << nextTask.taskID<< ", Description: " << nextTask.description<< ", Priority: " << nextTask.priority << std::endl;} catch (const std::runtime_error& e) {std::cerr << e.what() << std::endl;}// 使用deque测试类似逻辑TaskManagerDeque managerDeque;managerDeque.addTask(Task(1, "Task 1", 3));managerDeque.addTask(Task(2, "Task 2", 1));managerDeque.addTask(Task(3, "Task 3", 2));std::cout << "Tasks in deque:" << std::endl;managerDeque.printTasks();managerDeque.deleteTask(2);std::cout << "Tasks after deletion in deque:" << std::endl;managerDeque.printTasks();try {Task nextTaskDeque = managerDeque.getNextTask();std::cout << "Next task in deque:" << std::endl<< "Task ID: " << nextTaskDeque.taskID<< ", Description: " << nextTaskDeque.description<< ", Priority: " << nextTaskDeque.priority << std::endl;} catch (const std::runtime_error& e) {std::cerr << e.what() << std::endl;}return 0;
}
- 效率优化:
- 对于 list:在插入任务时,虽然list的插入操作时间复杂度为(O(1)),但在寻找插入位置时,由于需要遍历链表,时间复杂度为(O(n))。可以考虑使用更高效的查找算法,如二分查找(前提是链表已经有序),来降低查找插入位置的时间复杂度。另外,在删除任务时,remove_if函数会遍历整个链表,当链表很长时效率较低。可以在插入任务时,同时维护一个任务 ID 到迭代器的映射表,这样在删除任务时,通过映射表可以快速定位到要删除的任务位置,将删除操作的时间复杂度降为O(1)O(1)O(1)。
- 对于 deque:在插入任务时,deque需要在中间位置插入元素,虽然它的插入操作平均时间复杂度为O(1)O(1)O(1),但在实际应用中,可能会因为内存块的调整和映射表的更新而导致性能下降。可以通过预分配足够的内存空间,减少插入操作时内存块的重新分配次数,从而提高性能。在删除任务时,同样可以通过维护任务 ID 到迭代器的映射表来提高删除效率。同时,在频繁进行任务添加和删除操作后,deque可能会出现内存碎片化问题,可以定期对deque进行整理,合并相邻的内存块,提高内存利用率。