接下来,我将开设一个剑指 Offer 算法题解专栏,专门记录书中高频算法题的详细思路、代码实现与关键点总结
本篇为数据结构专题 ,收录面试题 3~9(面试题 1、2 非典型算法题,暂不记录),所有题解均保留最直观的解题思路,代码可直接运行。
三、数组中重复的数字
思路1:标记数组法
这是最直观、最容易想到的解法。
我们可以创建一个标记数组,初始值全部为 0,用 1 表示对应数字已经被访问过。遍历原数组时,若当前数字对应的标记已经是 1,说明该数字重复;否则将其标记为 1。同时必须对非法输入(数字小于 0 或大于等于数组长度)进行判断。
cpp
class Solution {
public:
int duplicate(vector<int>& numbers) {
// write code here
// 标志数组
int n = numbers.size();
vector<int> flag(n);
// 遍历数组
for (auto num : numbers) {
// 不合理的输入
if (num < 0 || num >= n) {
return -1;
}
if (flag[num] != 0) {// 遇到的数字已被标记
return num;
} else {
flag[num] = 1;// 标记当前数字
}
}
return -1;
}
};
复杂度
时间复杂度:O (n),只需遍历一次数组
空间复杂度:O (n),额外开辟了标记数组
思路2:下标定位法(原地算法,最优解)
仔细观察题目规律:如果数组中没有重复数字,那么排序后数字与下标一一对应 ,即 numbers[i] == i,我们可以利用这一规则,在原数组上直接交换、排序,不需要额外空间。
核心逻辑
- 遍历数组,取当前数字 num = numbers[i]
- 若 num == i,说明位置正确,继续下一个
- 若 num != i,将 num 与 numbers[num] 比较
- 相等 → 找到重复数字
- 不相等 → 交换两者,让数字回到正确下标位置
cpp
class Solution {
public:
int duplicate(vector<int>& numbers) {
// write code here
// 就地查找
// 遇到数字m,如果它等于当前下标i,继续比较下一个数字
// 不等于下标i,将下标m处的数字和数字m比较,如果相等→重复;不相等则继续交换
int n = numbers.size();
for (int i = 0; i < n; i++) {
int num = numbers[i];
// 不合法的数字
if (num < 0 || num >= n) {
return -1;
}
// 当前数字刚好等于下标
if (num == i) {
continue;
}
// 当前数字num等于下标num处的数字→重复
if (num == numbers[num]) {
return num;
} else { // 不等于→交换两处的数字
swap(numbers[i], numbers[num]);
}
}
return -1;
}
};
复杂度
时间复杂度:O (n)
空间复杂度:O (1),无需额外空间
四、二维数组中的查找
暴力解法/一般解法:从左上角或右下角逐个遍历,每次比较只能排除一个元素,时间复杂度 O (n²),效率极低。
优化思路(选点排除)
利用数组有序 的特性,选择右上角 或左下角作为起点:
- 右上角:比目标小 → 排除本行;比目标大 → 排除本列
- 左下角:比目标大 → 排除本行;比目标小 → 排除本列每次比较都能排除一行或一列,效率大幅提升。
从右上角开始查找
cpp
class Solution {
public:
bool Find(int target, vector<vector<int> >& array) {
// write code here
if (array.size() == 0) { // 空数组
return false;
}
// 右上角开始寻找
int i = 0; // 行号
int j = array[0].size() - 1; // 列号
while (i < array.size() && j >= 0) {
if (target == array[i][j]) {
return true;
} else if (target > array[i][j]) {
i++;// 抛弃一行数据
} else {
j--;// 抛弃一列
}
}
return false;
}
};
从左下角开始查找
cpp
class Solution {
public:
bool Find(int target, vector<vector<int> >& array) {
// write code here
if (array.size() == 0) { // 空数组
return false;
}
// 左下角开始寻找
int i = array.size() - 1; // 行号
int j = 0; // 列号
while (i >= 0 && j < array[0].size()) {
if (target == array[i][j]) {
return true;
} else if (target < array[i][j]) {
i--;// 抛弃一行数据
} else {
j++;// 抛弃一列
}
}
return false;
}
};
复杂度
时间复杂度:O (n + m)(n 行 m 列)
空间复杂度:O (1)
五、替换空格
常规思路:从前往后遍历字符串,遇到空格就扩容2个空间,并将后面的元素全部向后移动2位,再在当前位置放入%20,时间复杂度:O(n²),空格越多,移动次数越多,效率极差。
优化思路(双指针+从后往前)
核心思想
- 先统计空格数量,提前计算出新字符串长度
- 从后往前遍历,避免字符重复移动
- 使用双指针 :一个指向原字符串末尾,一个指向新空间末尾
- 遇到非空格 → 直接复制
- 遇到空格 → 依次写入 0 2 %
cpp
class Solution {
public:
string replaceSpace(string s) {
// write code here
// 从后往前遍历+双指针
if (s.size() == 0) {// 特殊情况处理:空字符串
return s;
}
// 计算空格的个数
int count = 0;
for (const auto& x : s) {
if (x == ' ') {
count++;
}
}
if (count == 0) { //没有空格,直接返回s
return s;
}
int p = s.size();
// 扩容
s.resize(s.size() + 2 * count);
// 从后往前遍历+双指针
int q = s.size();
while (p >= 0) {
if (s[p] == ' ') {
s[q--] = '0';
s[q--] = '2';
s[q--] = '%';
p--;
} else {
s[q--] = s[p--];
}
}
return s;
}
};
复杂度
时间复杂度:O (n)
空间复杂度:O (n)(字符串本身需要扩容)
六、从尾到头打印链表
利用栈"先进后出"的特性,先遍历链表,将所有节点入栈,再依次出栈存入结果数组,即可实现逆序输出
cpp
class Solution {
public:
vector<int> printListFromTailToHead(ListNode* head) {
vector<int> res;
if (head == nullptr) { // 空链表,直接返回
return res;
}
stack<int> st;
ListNode* p = head;
// 遍历链表,全部入栈
while (p != nullptr) {
st.push(p->val);
p = p->next;
}
// 出栈,实现逆序
while (!st.empty()) {
res.push_back(st.top());
st.pop();
}
return res;
}
};
七、重建二叉树(难点)
思路不难,难点在于代码实现
核心思路:
- 找根节点:序遍历数组的第一个元素,就是当前树的根节点值
- 划分左右子树:
- 在中序遍历中找到根节点,根节点左侧为左子树,右侧为右子树
- 根据中序划分出的左右子树大小,对应截取前序遍历数组
- 递归构建:分别递归构建左右子树,连接到根节点
cpp
/**
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* };
*/
#include <limits.h>
struct TreeNode* reConstructBinaryTree(int* preOrder, int preOrderLen,
int* vinOrder, int vinOrderLen ) {
// write code here
// 遍历数组为空→叶结点
if (preOrderLen == 0 || vinOrderLen == 0) {
return NULL;
}
// 创建根节点,需要手动申请空间
struct TreeNode* root = (struct TreeNode*)malloc(sizeof(struct TreeNode));
root->val = preOrder[0];// 根节点的值前序遍历数组的第一个值
root->left = NULL;
root->right = NULL;
// 在中序数组中寻找根节点
int rootIdx = 0;
while (vinOrder[rootIdx] != root->val) {
rootIdx++;
}
// 递归构建左子树
root->left = reConstructBinaryTree(preOrder + 1, rootIdx, vinOrder, rootIdx);
// 递归构建右子树
root->right = reConstructBinaryTree(
preOrder + 1 + rootIdx, // 右子树前序起点
preOrderLen - rootIdx - 1, // 右子树前序长度
vinOrder + rootIdx + 1, // 右子树中序起点
vinOrderLen - rootIdx - 1 // 右子树中序长度
);
return root;
}
八、二叉树的下一个节点
核心思路
分两种核心情况:
- 当前节点有右子树:右孩子的最左节点,即为下一个节点
- 当前节点无右子树:向上遍历父节点,找到第一个作为左孩子的节点的父节点
代码1(基础版)
cpp
class Solution {
public:
TreeLinkNode* GetNext(TreeLinkNode* pNode) {
// 当前节点有右子树→找到右子树的最左节点
if (pNode->right != nullptr) {
TreeLinkNode* p = pNode -> right;
while (p->left != nullptr) {
p = p->left;
}
return p;
} else {
TreeLinkNode* parent = pNode -> next; // 父节点
// 判断当前节点是父节点的左孩子还是右孩子
if (parent->left == pNode) { // 左孩子,直接返回父节点
return parent;
} else { // 右孩子,找第一个为左孩子节点的父节点
TreeLinkNode* child = parent;
parent = parent->next;
while (parent->next != nullptr && child == parent->right) {
child = parent;
parent = parent->next;
}
// 没有节点为左孩子节点→返回空
if (parent->next == nullptr) {
return nullptr;
}
return parent;
}
}
return nullptr;
}
};
代码2(判空)
在指针使用前增加判空,避免空指针访问
cpp
class Solution {
public:
TreeLinkNode* GetNext(TreeLinkNode* pNode) {
// 需要对指针判空
if (pNode == nullptr) {
return nullptr;
}
// 当前节点有右子树→找到右子树的最左节点
if (pNode->right != nullptr) {
TreeLinkNode* p = pNode -> right;
while (p->left != nullptr) {
p = p->left;
}
return p;
} else {
TreeLinkNode* parent = pNode -> next; // 父节点
// 在使用left/righ/next前,都需要对指针判空
if (parent == nullptr) {
return nullptr;
}
// 判断当前节点是父节点的左孩子还是右孩子
if (parent->left == pNode) { // 左孩子,直接返回父节点
return parent;
} else { // 右孩子,找第一个为左孩子节点的父节点
TreeLinkNode* child = parent;
parent = parent->next;
// 在使用left/righ/next前,都需要对指针判空
if (parent == nullptr) {
return nullptr;
}
while (parent->next != nullptr && child == parent->right) {
child = parent;
parent = parent->next;
}
// 没有节点为左孩子节点→返回空
if (child == parent->right) {
return nullptr;
}
else return parent;
}
}
return nullptr;
}
};
代码3(最优版)
合并重复逻辑:对于当前节点是左孩子节点的第一次判断,可以和后面的找是左孩子的节点融合
cpp
#include <pthread.h>
class Solution {
public:
TreeLinkNode* GetNext(TreeLinkNode* pNode) {
// 需要对指针判空
if (pNode == nullptr) {
return nullptr;
}
TreeLinkNode* pNext = nullptr;// 下一节点
// 当前节点有右子树→找到右子树的最左节点
if (pNode->right != nullptr) {
TreeLinkNode* p = pNode -> right;
while (p->left != nullptr) {
p = p->left;
}
pNext = p;
} else if (pNode->next != nullptr) { // 父节点不为空
TreeLinkNode* parent = pNode -> next; // 父节点
TreeLinkNode* cur = pNode;
while (parent != nullptr && cur == parent->right) {
cur = parent;
parent = parent->next;
}
pNext = parent;
}
return pNext;
}
};
九、用两个栈实现队列
核心思路
- stack1:专门负责入队
- stack2:专门负责出队
- 当stack2为空时,将stack1所有元素倒入stack2,实现先进先出顺序
cpp
class Solution {
public:
void push(int node) {
stack1.push(node);
}
int pop() {
// 栈2为空,把栈1全部倒入栈2
if (stack2.empty()) {
while (!stack1.empty()) {
int top = stack1.top();
stack1.pop();
stack2.push(top);
}
}
// 出队:出栈2的栈顶元素
int res = stack2.top();
stack2.pop();
return res;
}
private:
stack<int> stack1;
stack<int> stack2;
};
拓展:用两个队列实现栈
思路1(基础版)
两个队列:queue1和queue2
- queue1 负责存数据
- pop /top 时,将 queue1 前 n-1 个元素暂存到 queue2,取出最后一个元素后再放回
cpp
class MyStack {
private:
queue<int> queue1;
queue<int> queue2;
public:
MyStack() {}
void push(int x) { queue1.push(x); }
int pop() {
// 保留队尾元素,其余进队2
while (queue1.size() > 1) {
queue2.push(queue1.front());
queue1.pop();
}
int res = queue1.front();
queue1.pop(); // 队尾元素出队
// 将队2所有元素放回队1
while (!queue2.empty()) {
queue1.push(queue2.front());
queue2.pop();
}
return res;
}
int top() {
// 将队1的所有元素放入队2
int res = queue1.front(); // 记录队1的队尾元素
while (!queue1.empty()) {
res = queue1.front();
queue2.push(res);
queue1.pop();
}
// 将队2所有元素放回队1
while (!queue2.empty()) {
queue1.push(queue2.front());
queue2.pop();
}
return res;
}
bool empty() { return queue1.empty(); }
};
官方最优思路
入队时直接维护队头始终为栈顶,出队直接 pop 即可
- 入栈:先将元素入队2,然后将队1所有元素放入队2,再交互两队列元素,这样队1的队头元素始终是最后"入栈"的元素
- 出栈:直接将队1的队头元素出队即可
cpp
class MyStack {
private:
queue<int> que1;
queue<int> que2;
public:
MyStack() {}
void push(int x) {
// 入队2
que2.push(x);
// 队1元素全部放入队2
while (!que1.empty()) {
que2.push(que1.front());
que1.pop();
}
// 两队列元素交换
swap(que1, que2);
}
int pop() {
// 出队1的队头元素
int res = que1.front();
que1.pop();
return res;
}
int top() { return que1.front(); }
bool empty() { return que1.empty(); }
};