数据结构(C++版)第三版邓俊辉配套习题精讲与算法实战
简介:《数据结构(C++版)习题解析 第三版 邓俊辉》是清华大学数据结构课程的经典配套教材,系统讲解了基于C++语言的数据结构与算法核心内容。本书涵盖数组、链表、栈、队列、树、图等基础结构,深入剖析排序、查找、图遍历等关键算法,并引入动态规划、贪心算法、回溯法等高级算法思想。结合C++模板与STL应用,辅以丰富图示和典型习题解析,帮助读者扎实掌握理论知识并提升编程实践能力。适合准备面试、科研学习及算法进阶的开发者阅读与实战训练。
数据结构的艺术:从基础构建到工程实践
在现代软件开发中,我们每天都在与数据打交道。无论是处理用户请求、分析日志流还是渲染图形界面,背后都离不开一个核心命题——如何高效地组织和操作这些数据?这正是数据结构存在的意义。
让我讲个真实的故事。某次我参与一个物联网项目,设备上报的传感器数据量极大,最初团队用了简单的数组来缓存最近1000条记录。结果上线后发现内存占用飙升,GC频繁触发,系统响应延迟严重。后来我们改用循环队列+对象池的方式重构,不仅内存稳定了,吞吐量还提升了3倍。这个经历让我深刻体会到: 选对数据结构,往往比优化算法更重要 。
所以今天咱们就一起深入探讨这个问题。不是那种教科书式的罗列定义,而是像老友聊天一样,聊聊这些结构背后的“为什么”——它们为何存在?何时该用?又该如何实现?
想象一下你要整理书房里的书。最直接的方法是按顺序摆成一排,想拿哪本就数到第几本。这就是 数组 的思维:连续存放,随机访问快如闪电。但问题来了,如果中间要插入一本新书呢?得把后面所有书都往后挪一位——想想看,要是有上千本书,这得多麻烦!
于是你换了种方式:每本书上贴个标签,写着“下一本在哪儿”。这样你只需要改两个标签就能完成插入或删除。这种链式管理就是 链表 的思想精髓。它牺牲了直接跳转的能力(不能再靠编号找书),却换来了极致灵活的动态调整。
这两种截然不同的组织策略,其实对应着计算机中最基本的两种存储哲学:
- 顺序存储 :用一块连续的内存空间存放元素,典型代表就是数组。
- 链式存储 :通过指针连接离散分布的节点,典型代表是链表。
// 顺序存储 vs 链式存储 的直观对比
struct ListNode {
int val;
ListNode* next; // 指向下一个节点,体现链式结构
};
int arr[5] = {1, 2, 3, 4, 5}; // 连续内存块,体现顺序结构
🤔 看到这里你可能会问:“既然链表这么灵活,为什么不全用它?”
好问题!这就引出了性能权衡的核心矛盾—— 读写偏好 。数组的优势在于 $O(1)$ 的随机访问能力,而链表的优势在于 $O(1)$ 的局部修改效率。选择的关键在于你的应用场景更看重哪种操作。
为了更清晰地展示这种差异,让我们看看下面这张图:
graph TD
A[开始访问第i个元素] --> B{是否为数组?}
B -->|是| C[计算偏移量: base + i * sizeof(T)]
B -->|否| D[从头节点出发]
D --> E[逐个遍历next指针]
E --> F{i-- > 0?}
F -->|否| G[返回当前节点数据]
F -->|是| E
C --> H[直接返回内存地址内容]
看到了吗?数组访问依赖于 算术运算 ,一步到位;而链表则需要一步步“跳”过去。这也解释了为何数据库索引喜欢用B+树(基于数组思想),而编辑器的撤销功能常用链表实现。
说到这儿,不得不提一个程序员必备的思维方式—— 复杂度分析 。别被那些数学符号吓到,Big-O本质上是在问:“当数据规模翻倍时,我的程序会慢多少?”
举个例子:
void printList(ListNode* head) {
while (head != nullptr) {
std::cout << head->val << " ";
head = head->next;
}
}
这段代码的时间复杂度是 $O(n)$,因为每个节点只访问一次。空间复杂度是 $O(1)$,因为我们只用了几个固定变量。但如果改成递归版本:
void printListRecursive(ListNode* head) {
if (head == nullptr) return;
std::cout << head->val << " ";
printListRecursive(head->next);
}
虽然时间仍是 $O(n)$,但空间变成了 $O(n)$!因为每次调用都会在栈上压一层函数帧。在嵌入式设备上跑这种代码,可能几百层就爆栈了 😱
所以记住一句话: 同样的功能,不同的实现,资源消耗可能天差地别 。
这时候你就明白为什么要有 抽象数据类型 (ADT)了。它就像一份合同,规定了我能做什么操作,而不关心你是怎么实现的。比如“获取第 i 个元素”这个接口,不管是数组还是链表都应该提供统一语义。
我们可以这样设计一个通用线性表:
template<typename T>
class LinearList {
private:
T* data; // 数据存储区
int length; // 当前长度
int capacity; // 最大容量
public:
LinearList(int cap = 100) : capacity(cap), length(0) {
data = new T[capacity];
}
~LinearList() {
delete[] data;
}
bool Insert(int i, const T& e) {
if (i < 1 || i > length + 1 || length >= capacity)
return false;
for (int j = length; j >= i; --j)
data[j] = data[j - 1];
data[i - 1] = e;
++length;
return true;
}
bool Delete(int i) {
if (i < 1 || i > length)
return false;
for (int j = i; j < length; ++j)
data[j - 1] = data[j];
--length;
return true;
}
T GetElem(int i) const {
if (i < 1 || i > length) throw std::out_of_range("Index out of range");
return data[i - 1];
}
int LocateElem(const T& e) const {
for (int i = 0; i < length; ++i)
if (data[i] == e)
return i + 1;
return -1;
}
int Length() const { return length; }
};
💡 小技巧:注意到
Insert是从后往前移动元素吗?这是为了避免覆盖还没处理的数据。很多初学者容易在这里犯错,导致数据丢失。
这个类封装了底层细节,使用者只需知道“我能插入、删除、查询”,完全不必关心数据是如何搬移的。未来你可以悄悄把它换成链表实现,只要接口不变,调用方代码就无需改动——这就是抽象的力量 ✨
现在让我们聚焦到具体结构。先说说 数组 ,这个看似简单的家伙其实大有讲究。
静态数组在编译期确定大小,放在栈上自动管理:
void func() {
int staticArr[10]; // 函数退出自动释放
}
优点是快,缺点是死板。万一你需要存一万条数据呢?栈空间可撑不住。这时就得上 动态数组 :
int* dynamicArr = new int[10];
// ... 使用 ...
delete[] dynamicArr; // 必须手动释放!
⚠️ 注意:
new[]和delete[]必须配对使用,否则会有内存泄漏风险。现代C++推荐用智能指针或标准容器替代。
说到标准容器, std::vector 其实就是动态数组的完美封装。但我们不妨自己动手造个轮子,理解其内部机制:
template<typename T>
class DynamicArray {
private:
T* data;
int size;
int capacity;
void resize() {
capacity *= 2;
T* newData = new T[capacity];
for (int i = 0; i < size; ++i)
newData[i] = data[i];
delete[] data;
data = newData;
}
public:
DynamicArray(int cap = 10) : size(0), capacity(cap) {
data = new T[capacity];
}
~DynamicArray() { delete[] data; }
void push_back(const T& val) {
if (size == capacity) resize();
data[size++] = val;
}
T& operator[](int index) {
if (index < 0 || index >= size)
throw std::out_of_range("Index out of bounds");
return data[index];
}
int getSize() const { return size; }
int getCapacity() const { return capacity; }
};
看到 resize() 了吗?这就是 vector 扩容的秘密——容量不够时自动翻倍。这样做是为了摊销成本:虽然单次扩容很贵,但均摊下来每次插入只有 $O(1)$ 的代价。这叫 摊还分析 (Amortized Analysis),是算法设计中的高级技巧。
多维数组又是另一番风景。比如二维矩阵:
int matrix[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9,10,11,12}
};
它在内存中是 按行优先 连续排列的:
地址顺序: 1 2 3 4 5 6 7 8 9 10 11 12
访问公式为: addr[i][j] = base + (i * cols + j) * sizeof(T) 。正因为这种连续性,CPU缓存可以预取后续数据,大大提升访问速度。
但如果你用指针数组实现:
int** arr = new int*[rows];
for(...) arr[i] = new int[cols];
每一行都在不同内存块,缓存命中率会暴跌。性能差距可达数倍之多!所以在高性能计算中,我们通常用单指针模拟二维数组:
template<typename T>
class Matrix {
private:
T* data;
int rows, cols;
public:
Matrix(int r, int c) : rows(r), cols(c) {
data = new T[r * c];
}
~Matrix() { delete[] data; }
T& operator()(int i, int j) {
return data[i * cols + j];
}
};
这样既能享受连续内存的好处,又能保持行列访问的语义清晰。
接下来轮到我们的另一位主角—— 链表 登场了。
相比数组,链表解决了“固定大小”和“移动成本高”的痛点。它的基本单元是一个节点:
struct Node {
int data;
Node* next;
Node(int x) : data(x), next(nullptr) {}
};
创建两个节点并连接起来:
Node* head = new Node(1);
head->next = new Node(2);
🔍 注意野指针风险!记得及时释放资源:
void freeList(Node* head) {
while (head) {
Node* temp = head;
head = head->next;
delete temp;
}
}
不过手动物理管理太容易出错了。现代C++应该用RAII思想武装自己:
#include <memory>
using NodePtr = std::unique_ptr<Node>;
让智能指针自动接管生命周期,从此告别内存泄漏噩梦 🎉
链表的经典操作之一是 反转 。给你一条链表 1→2→3→null ,怎么变成 null←1←2←3 ?
迭代法思路很巧妙:
Node* reverseList(Node* head) {
Node* prev = nullptr;
Node* curr = head;
while (curr) {
Node* nextTemp = curr->next; // 保存下一个
curr->next = prev; // 断开重连
prev = curr; // 前进
curr = nextTemp;
}
return prev;
}
graph LR
A[prev=null, curr=head] --> B{curr != null?}
B -->|yes| C[保存next]
C --> D[断开curr->next]
D --> E[指向prev]
E --> F[prev=curr]
F --> G[curr=next]
G --> B
B -->|no| H[返回prev]
整个过程就像拉链一样逐步倒序连接,时间复杂度 $O(n)$,空间 $O(1)$,堪称教科书级的优雅实现。
另一个常见需求是 合并两个有序链表 。你可以暴力拆开再重组,但更好的办法是双指针归并:
Node* mergeTwoLists(Node* l1, Node* l2) {
Node dummy(0);
Node* tail = &dummy;
while (l1 && l2) {
if (l1->val <= l2->val) {
tail->next = l1;
l1 = l1->next;
} else {
tail->next = l2;
l2 = l2->next;
}
tail = tail->next;
}
tail->next = l1 ? l1 : l2;
return dummy.next;
}
是不是有点像归并排序的合并阶段?没错,很多高级算法都是由这些基础模块组合而成的。
聊完线性结构,我们升级一下难度,进入 栈与队列 的世界。
栈遵循“后进先出”(LIFO)原则,就像一摞盘子,只能从顶部取放。它的核心操作就三个: push (入栈)、 pop (出栈)、 top (查看栈顶)。
用C++实现一个泛型栈:
template<typename T>
class Stack {
private:
std::vector<T> data;
public:
void push(const T& item) {
data.push_back(item);
}
T pop() {
if (isEmpty()) {
throw std::runtime_error("Stack underflow");
}
T topItem = data.back();
data.pop_back();
return topItem;
}
T& top() {
if (isEmpty()) throw std::runtime_error("Stack is empty");
return data.back();
}
bool isEmpty() const { return data.empty(); }
};
这里偷了个懒,直接用 vector 当底裤。虽然简单有效,但在极端场景下可能不够高效。比如固定大小的小栈,用数组反而更快:
#define MAX_SIZE 1000
template<typename T>
class ArrayStack {
private:
T arr[MAX_SIZE];
int topIndex;
public:
ArrayStack() : topIndex(-1) {}
void push(const T& item) {
if (topIndex == MAX_SIZE - 1) {
throw std::overflow_error("Stack overflow");
}
arr[++topIndex] = item;
}
T pop() {
if (isEmpty()) throw std::underflow_error("Stack underflow");
return arr[topIndex--];
}
};
空间紧凑、缓存友好,特别适合高频小数据量的操作。当然缺点也很明显——容量上限写死,容易溢出。
相比之下,链式栈没有容量限制:
template<typename T>
class LinkedStack {
private:
Node<T>* head;
int count;
public:
LinkedStack() : head(nullptr), count(0) {}
void push(const T& item) {
Node<T>* newNode = new Node<T>(item);
newNode->next = head;
head = newNode;
++count;
}
T pop() {
if (isEmpty()) throw std::underflow_error("Stack underflow");
Node<T>* temp = head;
T value = temp->data;
head = head->next;
delete temp;
--count;
return value;
}
};
理论上可以无限增长,但每次分配节点都有额外开销。所以选哪种实现,取决于你的具体需求:
graph TD
A[选择栈实现方式] --> B{数据量是否可预测?}
B -->|是| C[使用数组实现]
B -->|否| D[使用链表实现]
C --> E[优点: 访问快, 内存紧凑]
D --> F[优点: 动态扩展, 无溢出]
C --> G[缺点: 容量固定, 易溢出]
D --> H[缺点: 指针开销, 分配延迟]
说到栈,就不能不提 函数调用栈 。每次你调用一个函数,系统就会创建一个 栈帧 ,保存参数、局部变量和返回地址。递归函数尤其依赖这一点:
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // 每次调用都在栈上压一层
}
调用 factorial(4) 会产生4个栈帧。随着递归回退,各层依次完成乘法并释放。这就是为什么深度递归可能导致 栈溢出 ——进程的栈空间通常只有几MB。
解决方案之一是手动模拟递归过程:
int iterativeFactorial(int n) {
std::stack<int> stk;
for (int i = n; i > 1; --i) {
stk.push(i);
}
int result = 1;
while (!stk.empty()) {
result *= stk.top();
stk.pop();
}
return result;
}
虽然代码变复杂了,但堆内存远大于栈,能支持更大规模计算。这也是尾递归优化的基本原理——把递归转成迭代。
另一边厢, 队列 遵循“先进先出”(FIFO)原则,像排队买票一样公平有序。标准实现是循环队列,避免普通数组队列的“假溢出”问题。
所谓假溢出,是指虽然数组没满,但 rear 指针已经到头了。解决方案是把数组首尾相连,形成逻辑上的环形结构:
template<typename T>
class CircularQueue {
private:
std::vector<T> data;
int front, rear;
int capacity;
public:
CircularQueue(int k) : capacity(k + 1), front(0), rear(0) {
data.resize(capacity);
}
bool enQueue(const T& value) {
if ((rear + 1) % capacity == front) {
return false; // full
}
data[rear] = value;
rear = (rear + 1) % capacity;
return true;
}
bool deQueue() {
if (isEmpty()) return false;
front = (front + 1) % capacity;
return true;
}
T Front() const {
if (isEmpty()) throw std::runtime_error("Queue is empty");
return data[front];
}
bool isEmpty() const {
return front == rear;
}
bool isFull() const {
return (rear + 1) % capacity == front;
}
};
关键技巧是多申请一个位置,用 (rear + 1) % cap == front 判断满状态,从而区分空和满的情况。这种设计广泛应用于操作系统任务调度、网络缓冲区等高吞吐场景。
更强大的变体是 双端队列 (Deque),允许两端插入删除。它在滑动窗口问题中大放异彩:
std::vector<int> maxSlidingWindow(std::vector<int>& nums, int k) {
std::deque<int> dq; // 存储索引,保持对应值递减
std::vector<int> result;
for (int i = 0; i < nums.size(); ++i) {
// 移除超出窗口范围的索引
while (!dq.empty() && dq.front() <= i - k)
dq.pop_front();
// 移除小于当前值的元素,维持递减性
while (!dq.empty() && nums[dq.back()] < nums[i])
dq.pop_back();
dq.push_back(i);
// 当窗口形成后开始记录结果
if (i >= k - 1)
result.push_back(nums[dq.front()]);
}
return result;
}
这个算法的精妙之处在于维护了一个 单调递减队列 ,确保队首始终是当前窗口最大值的下标。时间复杂度从暴力解法的 $O(nk)$ 降到惊人的 $O(n)$!
最后我们来到非线性结构的殿堂—— 二叉树 。
每个节点最多有两个子节点,这种分叉特性让它在搜索、排序等领域极具优势。特殊形态包括:
- 满二叉树 :所有内部节点都有两个子节点
- 完全二叉树 :除了最后一层外全满,且左对齐
- 平衡二叉树 :左右子树高度差不超过1
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
遍历方式有三种经典DFS路径:
- 前序 (根→左→右):序列化/复制树
- 中序 (左→根→右):BST输出有序序列
- 后序 (左→右→根):释放内存/表达式求值
递归实现简洁明了,非递归则需借助栈模拟调用过程。以中序为例:
void iterativeInorder(TreeNode* root) {
std::stack<TreeNode*> stk;
TreeNode* curr = root;
while (curr || !stk.empty()) {
while (curr) {
stk.push(curr);
curr = curr->left;
}
curr = stk.top(); stk.pop();
std::cout << curr->val << " ";
curr = curr->right;
}
}
而 层次遍历 则要用队列实现广度优先搜索:
void levelOrder(TreeNode* root) {
if (!root) return;
std::queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
TreeNode* node = q.front(); q.pop();
std::cout << node->val << " ";
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
至于 图 这种更为复杂的结构,主要表示方法有:
- 邻接矩阵 :$O(V^2)$ 空间,适合稠密图
- 邻接表 :$O(V+E)$ 空间,适合稀疏图
- 边集数组 :用于Kruskal等算法
class Graph {
public:
int V;
vector<vector<pair<int, int>>> adjList; // {目标顶点, 权重}
Graph(int V) : V(V), adjList(V) {}
void addEdge(int u, int v, int w = 1) {
adjList[u].push_back({v, w});
adjList[v].push_back({u, w}); // 无向图
}
};
DFS/BFS在此基础上展开,构成了路径查找、连通性判断等算法的基础。
整篇文章看下来,你会发现一个重要规律: 没有最好的数据结构,只有最适合的解决方案 。选择的关键在于理解操作模式——你是频繁查找?还是大量增删?数据规模是否可预测?内存是否受限?
就像一位经验丰富的厨师不会只用一把刀,真正的程序员也应该掌握各种结构的适用场景。下次当你面对一个新的问题时,不妨先问问自己:
“我的数据像是图书馆的书架,还是快递分拣线?”
答案往往就在这个问题里 🧠💡
简介:《数据结构(C++版)习题解析 第三版 邓俊辉》是清华大学数据结构课程的经典配套教材,系统讲解了基于C++语言的数据结构与算法核心内容。本书涵盖数组、链表、栈、队列、树、图等基础结构,深入剖析排序、查找、图遍历等关键算法,并引入动态规划、贪心算法、回溯法等高级算法思想。结合C++模板与STL应用,辅以丰富图示和典型习题解析,帮助读者扎实掌握理论知识并提升编程实践能力。适合准备面试、科研学习及算法进阶的开发者阅读与实战训练。
更多推荐


所有评论(0)