简介:本项目基于C++语言,利用单链表数据结构实现集合的交集、并集和差集运算,支持从文件读取集合数据及将运算结果保存回文件。通过自定义Node结构体构建链表,项目涵盖链表的基本操作、文件I/O处理、内存管理与异常控制等核心内容。该实现不仅强化了对线性结构的操作理解,也提升了对数据持久化和程序健壮性的掌握,是数据结构学习中典型的综合实践案例。
单链表与集合运算的系统性实现:从基础结构到工程落地
你有没有想过,为什么我们每天用的微信好友列表、淘宝推荐商品,甚至权限管理系统背后都离不开一个看似简单的数据结构——链表?🤔 其实啊,这些复杂系统的底层逻辑,往往就藏在像单链表这样的“小工具”里。今天咱们不整虚的,直接上硬核干货,带你从零开始,用 C++ 手搓一个 完整的集合类系统 ,支持去重、交集、并集、差集,还能读写文件,最后再加点安全防护,让它真正能跑在生产环境里!
别担心代码多,我会像讲故事一样,一步步拆解每个环节的设计思路和坑点。准备好了吗?Let’s go!🚀
1. 单链表:不只是教科书里的玩具
先来打地基。说到链表,大家第一反应可能就是教科书里的 Node 结构体:
struct Node {
int data;
Node* next;
Node(int val) : data(val), next(nullptr) {}
};
这玩意儿看起来简单,但真要把它变成一个可用的数据结构,光有节点可不够。我们得有个“管家”——也就是链表类,来管理头指针、内存释放、插入删除这些操作。
class LinkedList {
private:
Node* head;
public:
LinkedList() : head(nullptr) {}
~LinkedList() {
while (head) {
Node* temp = head;
head = head->next;
delete temp;
}
}
void push_front(int val) {
Node* newNode = new Node(val);
newNode->next = head;
head = newNode;
}
void print() const {
Node* curr = head;
while (curr) {
std::cout << curr->data << " -> ";
curr = curr->next;
}
std::cout << "nullptr\n";
}
};
看到析构函数没?🔥 这就是新手最容易翻车的地方 。你不手动 delete ,程序跑一次就内存泄漏了。我以前实习时就因为忘了写析构,被导师骂得狗血淋头 😂。
不过现在问题来了:如果我们拿这个链表当集合用,怎么防止重复元素呢?比如连续 push_front(5) 两次,结果就有两个 5 了。这显然不符合集合的“唯一性”原则。那怎么办?
2. 集合去重:暴力法 vs 哈希加速
💥 暴力查重:简单但慢得让人心碎
最直接的办法就是在每次插入前遍历一遍链表,看看有没有相同的值:
bool contains(int value) const {
Node* curr = head;
while (curr) {
if (curr->data == value) return true;
curr = curr->next;
}
return false;
}
void add(int value) {
if (!contains(value)) {
push_front(value);
}
}
这叫“暴力比较法”,优点是 不用额外空间,代码清晰 ;缺点嘛……时间复杂度是 O(n),如果我要插 n 个元素,总时间就是 O(n²)。
想象一下你要处理 10000 个用户 ID,光查重就得做快一亿次比较……CPU 表示:我裂开了 💀。
📊 小贴士:O(n²) 是什么概念?n=1000 时大约要 50 万次操作;n=5000 就要 1250 万次。现代 CPU 每秒能算几亿次,听起来好像还行?但别忘了还有缓存命中、分支预测失败等问题,实际性能远不如理论值。
所以,对于稍大一点的数据,这种办法基本没法用。
🚀 加速方案:借力标准库容器
既然链表自己找得慢,那就请个“助理”帮忙记一下哪些数已经存在过呗!C++ 标准库里的 std::unordered_set 就是个好帮手,平均查找时间 O(1):
#include <unordered_set>
class Set {
private:
Node* head;
std::unordered_set<int> lookup; // 助理小本本 📒
public:
Set() : head(nullptr) {}
bool contains(int value) const {
return lookup.find(value) != lookup.end();
}
void add(int value) {
if (lookup.find(value) == lookup.end()) {
Node* newNode = new Node(value);
newNode->next = head;
head = newNode;
lookup.insert(value); // 同步更新小本本
}
}
~Set() { /* 正常释放链表 */ }
};
这样插入 n 个元素的总时间就降到了 O(n),提升了好几个数量级!当然代价是多用了点内存(哈希表本身),而且你得保证链表和哈希表同步更新,否则状态就乱了。
⚠️ 注意:这里有个隐藏雷区——异常安全!如果
new Node()抛出std::bad_alloc(虽然少见),而你已经把 value 写进lookup了,那就会出现“哈希表里有记录,但链表没节点”的不一致状态。工业级代码必须考虑这种极端情况,可以用 RAII 或者“先建节点再写表”的顺序规避。
✅ 安全版本长这样:
void addSafe(int value) {
if (lookup.count(value)) return;
Node* newNode = nullptr;
try {
newNode = new Node(value);
} catch (...) {
throw std::runtime_error("Memory allocation failed");
}
newNode->next = head;
head = newNode;
lookup.insert(value);
}
你看,是不是瞬间感觉代码稳了很多?😎
flowchart TB
Start([开始 add(value)]) --> Check{已存在?}
Check -- 是 --> End1([结束])
Check -- 否 --> Alloc[尝试 new Node]
Alloc --> Fail{分配失败?}
Fail -- 是 --> Throw[抛异常]
Fail -- 否 --> Link[链接到链首]
Link --> Update[更新head]
Update --> Sync[同步哈希表]
Sync --> End2([完成])
style Start fill:#4CAF50,color:white
style End1 fill:#FF9800,color:black
style Throw fill:#F44336,color:white
style Update fill:#2196F3,color:white
这张流程图清楚展示了工业级插入的完整路径——连内存不足都照顾到了,这才是靠谱的做法!
3. 集合交集:不只是数学公式
有了带去重的链表集合,接下来玩点更酷的: 求交集 !
数学上很简单:A ∩ B = {x | x ∈ A 且 x ∈ B}。但在代码里怎么做效率最高?
🔁 双重循环法:适合教学,不适合实战
最朴素的想法是遍历 A 的每个元素,在 B 中查是否存在:
LinkedList intersect(const LinkedList& other) const {
LinkedList result;
if (isEmpty() || other.isEmpty()) return result;
Node* curr = head;
while (curr) {
if (other.contains(curr->data)) {
result.add(curr->data); // 利用自带去重
}
curr = curr->next;
}
return result;
}
逻辑没错,但时间复杂度是 O(m×n),m 和 n 分别是两个链表长度。当两者都是 1000,就要比较 100 万次!太慢了。
⏩ 性能优化两大杀招
✅ 方案一:预排序 + 双指针扫描
如果你允许对原链表排序(不影响集合语义),就可以先排序,然后像归并排序那样双指针前进:
LinkedList intersectSorted(const LinkedList& other) const {
auto a = *this; a.sort(); // 假设有 sort()
auto b = other; b.sort();
LinkedList res;
Node *p = a.head, *q = b.head;
while (p && q) {
if (p->data == q->data) {
res.add(p->data);
p = p->next; q = q->next;
} else if (p->data < q->data) {
p = p->next;
} else {
q = q->next;
}
}
return res;
}
总时间 O(m log m + n log n),比 O(mn) 快多了。但问题是:你得改结构或者复制副本,空间开销大。
✅ 方案二:哈希辅助查找(推荐)
把较小的那个集合扔进 unordered_set ,然后遍历另一个集合去查:
LinkedList intersectHash(const LinkedList& other) const {
LinkedList result;
std::unordered_set<int> smallSet;
// 选小的放哈希表,省空间
const LinkedList& small = (size() <= other.size()) ? *this : other;
const LinkedList& large = (size() > other.size()) ? *this : other;
// 构建哈希索引
Node* curr = small.head;
while (curr) {
smallSet.insert(curr->data);
curr = curr->next;
}
// 遍历大的,在哈希表中查
curr = large.head;
while (curr) {
if (smallSet.find(curr->data) != smallSet.end()) {
result.add(curr->data);
}
curr = curr->next;
}
return result;
}
时间复杂度直接干到 O(m + n),是目前最快的通用做法!
| 方法 | 时间复杂度 | 空间复杂度 | 是否修改原数据 | 推荐场景 |
|---|---|---|---|---|
| 双重循环 | O(mn) | O(1) | 否 | 数据极小(<100) |
| 排序+双指针 | O(m log m + n log n) | O(1) 或 O(m+n) | 是/否 | 支持排序且追求速度 |
| 哈希辅助 | O(m + n) | O(min(m,n)) | 否 | 大数据量、频繁查询 |
选择哪个?看你的业务需求咯~ 🤔
graph LR
A[选策略] --> B{能排序吗?}
B -- 是 --> C[双指针法]
B -- 否 --> D{内存够吗?}
D -- 是 --> E[哈希法]
D -- 否 --> F[忍着用双重循环]
4. 并集 & 差集:组合拳出击
🧩 并集(Union):合并去重一条龙
并集的目标是把两个集合的所有元素合起来,还要自动去重。
最简单的做法就是复用 add() 函数:
LinkedList unionWith(const LinkedList& other) const {
LinkedList result;
// 插入自己的所有元素
Node* curr = head;
while (curr) {
result.add(curr->data);
curr = curr->next;
}
// 插入对方的所有元素
curr = other.head;
while (curr) {
result.add(curr->data);
curr = curr->next;
}
return result;
}
但如果 result.add() 内部是暴力查重,那整体就是 O((m+n)^2),太慢了。升级版加个哈希表:
LinkedList unionOptimized(const LinkedList& other) const {
std::unordered_set<int> seen;
LinkedList result;
auto safeInsert = [&](int val) {
if (seen.find(val) == seen.end()) {
seen.insert(val);
result.push_back(val); // 假设尾插 O(1)
}
};
Node* curr = head;
while (curr) { safeInsert(curr->data); curr = curr->next; }
curr = other.head;
while (curr) { safeInsert(curr->data); curr = curr->next; }
return result;
}
时间复杂度 O(m + n),完美!
🔻 差集(Difference):筛掉共有的
差集 A - B 表示在 A 中但不在 B 中的元素。注意方向敏感哦,A-B ≠ B-A!
LinkedList difference(const LinkedList& other) const {
std::unordered_set<int> otherSet;
Node* curr = other.head;
while (curr) {
otherSet.insert(curr->data);
curr = curr->next;
}
LinkedList result;
curr = head;
while (curr) {
if (otherSet.find(curr->data) == otherSet.end()) {
result.push_back(curr->data);
}
curr = curr->next;
}
return result;
}
一样的套路,先建哈希索引,再筛选。时间 O(m + n),稳得很。
flowchart LR
Start --> LoadB["加载B到哈希表"]
LoadB --> TraverseA["遍历A"]
TraverseA --> Check{"在B中?"}
Check -- 否 --> AddToResult
AddToResult --> Next
Check -- 是 --> Next
Next --> TraverseA
TraverseA -.-> EndNull
EndNull --> ReturnResult
5. 文件持久化:让数据活下来
上面都在内存里玩,关机就没了。要想实用,还得支持读写文件!
📁 文本文件格式设计
我们定义一种简洁又人性化的格式:
- 每行一个集合
- 元素用空格或逗号分隔
-
#开头为注释 - 忽略空白行
例子:
# 用户A标签
1 3 5 7 9
# 用户B标签
2,3,6,7,8
📥 读取实现
bool loadSetsFromFile(const std::string& filename,
std::vector<std::list<int>>& sets) {
std::ifstream file(filename);
if (!file.is_open()) return false;
std::string line;
while (std::getline(file, line)) {
// 清理前后空白
auto start = line.find_first_not_of(" \t");
auto end = line.find_last_not_of(" \t");
if (start == std::string::npos) continue; // 空行
line = line.substr(start, end - start + 1);
if (line.empty() || line[0] == '#') continue;
// 统一分隔符为空格
std::replace(line.begin(), line.end(), ',', ' ');
std::list<int> current;
std::stringstream ss(line);
int val;
while (ss >> val) {
if (std::find(current.begin(), current.end(), val) == current.end()) {
current.push_back(val);
}
}
sets.push_back(current);
}
return true;
}
📤 写回文件
void writeResultToFile(const std::string& file,
const std::string& label,
const std::list<int>& result) {
std::ofstream out(file, std::ios::app); // 追加模式
if (!out.is_open()) return;
out << "=== " << label << " ===\n";
for (int x : result) out << x << " ";
out << "\n\n";
out.close();
}
加上时间戳更专业:
std::string getTimestamp() {
auto now = std::chrono::system_clock::now();
auto t = std::chrono::system_clock::to_time_t(now);
std::ostringstream oss;
oss << std::put_time(std::localtime(&t), "%Y-%m-%d %H:%M:%S");
return oss.str();
}
输出长这样:
=== Intersection of A and B === [2025-04-05 14:23:10]
3 7
=== Union of A and C === [2025-04-05 14:23:10]
1 3 5 7 9 10 20 30
审计日志的感觉立马就有了吧?😉
graph TD
A[开始主程序] --> B{读取输入文件}
B -- 成功 --> C[解析多组集合]
B -- 失败 --> D[报错并退出]
C --> E[执行各种集合运算]
E --> F[构建结果]
F --> G[打开输出流]
G --> H[写入带标签结果]
H --> I[关闭文件]
I --> J[释放资源]
J --> K[结束]
6. 最后一步:健壮性与测试
再牛的功能,没有测试也是白搭。写几个关键断言:
LinkedListSet s;
assert(s.add(1));
assert(!s.add(1)); // 第二次应失败
assert(s.size() == 1);
// 测试空集
LinkedList empty;
assert(empty.unionWith(s).size() == 1);
// 测试差集
auto diff = s.difference(empty);
assert(diff.size() == 1);
边界情况全覆盖:
| 类型 | 示例输入 | 预期行为 |
|---|---|---|
| 正常输入 | A={1,2}, B={2,3} | A∪B={1,2,3}, A−B={1} |
| 空集参与 | A={}, B={1,2} | A∪B=B, A∩B={} |
| 全重复 | A={1,2}, B={1,2} | A−B={}, A∩B=A |
| 单元素 | A={5}, B={} | A∪B={5}, A−B={5} |
结语:从玩具到武器
你看,一个简简单单的单链表,经过层层包装——
✅ 加上去重机制 → 变成集合
✅ 实现交并差 → 支持复杂运算
✅ 接入文件IO → 实现持久化
✅ 引入哈希优化 → 提升性能
✅ 添加异常处理 → 保障安全
它就不再是实验室里的玩具,而是可以嵌入真实系统的 重型武器 了!
下次面试官问你“链表有什么用”,你就把这套流程甩他脸上 😎。不是为了炫技,而是告诉你: 每一个底层结构,只要深入打磨,都能撑起一片天 。
所以,别再说“学链表没用了”——是你还没把它用对地方而已 😉。
Keep coding, keep hacking! 💻✨
简介:本项目基于C++语言,利用单链表数据结构实现集合的交集、并集和差集运算,支持从文件读取集合数据及将运算结果保存回文件。通过自定义Node结构体构建链表,项目涵盖链表的基本操作、文件I/O处理、内存管理与异常控制等核心内容。该实现不仅强化了对线性结构的操作理解,也提升了对数据持久化和程序健壮性的掌握,是数据结构学习中典型的综合实践案例。
931

被折叠的 条评论
为什么被折叠?



