剑指offer | 2.4数据结构相关题目

接下来,我将开设一个剑指 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,我们可以利用这一规则,在原数组上直接交换、排序,不需要额外空间。

核心逻辑

  1. 遍历数组,取当前数字 num = numbers[i]
  2. 若 num == i,说明位置正确,继续下一个
  3. 若 num != i,将 num 与 numbers[num] 比较
    1. 相等 → 找到重复数字
    2. 不相等 → 交换两者,让数字回到正确下标位置
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²),空格越多,移动次数越多,效率极差。

优化思路(双指针+从后往前)

核心思想

  1. 先统计空格数量,提前计算出新字符串长度
  2. 从后往前遍历,避免字符重复移动
  3. 使用双指针 :一个指向原字符串末尾,一个指向新空间末尾
    1. 遇到非空格 → 直接复制
    2. 遇到空格 → 依次写入 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;
    }
};

七、重建二叉树(难点)

重建二叉树_牛客题霸_牛客网

思路不难,难点在于代码实现

核心思路:

  1. 找根节点:序遍历数组的第一个元素,就是当前树的根节点值
  2. 划分左右子树:
    1. 中序遍历中找到根节点,根节点左侧为左子树,右侧为右子树
    2. 根据中序划分出的左右子树大小,对应截取前序遍历数组
  3. 递归构建:分别递归构建左右子树,连接到根节点
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. 当前节点有右子树:右孩子的最左节点,即为下一个节点
  2. 当前节点无右子树:向上遍历父节点,找到第一个作为左孩子的节点的父节点

代码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;
    }
};

九、用两个栈实现队列

用两个栈实现队列_牛客题霸_牛客网

核心思路

  1. stack1:专门负责入队
  2. stack2:专门负责出队
  3. 当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;
};
拓展:用两个队列实现栈

225. 用队列实现栈 - 力扣(LeetCode)

思路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 即可

  1. 入栈:先将元素入队2,然后将队1所有元素放入队2,再交互两队列元素,这样队1的队头元素始终是最后"入栈"的元素
  2. 出栈:直接将队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(); }
};
相关推荐
米啦啦.2 小时前
C+类的友元与静态成员函数,类模板
c++·友元·类模板
超绝振刀怪2 小时前
【C++可变模板参数】
开发语言·c++·可变模板参数
AI成长日志2 小时前
【笔面试算法学习专栏】哈希表基础:两数之和与字母异位词分组
学习·算法·面试
minji...3 小时前
Linux 线程同步与互斥(二) 线程同步,条件变量,pthread_cond_init/wait/signal/broadcast
linux·运维·开发语言·jvm·数据结构·c++
梓䈑3 小时前
高性能 C++ 日志实战:spdlog 核心架构解析与最佳实践指南
c++·架构
abant23 小时前
leetcode 239 单调队列 需要一些记忆
算法·leetcode·职场和发展
漫霂3 小时前
二叉树的统一迭代遍历
java·算法
炽烈小老头3 小时前
【每天学习一点算法 2026/04/08】阶乘后的零
学习·算法
Mr_Xuhhh3 小时前
算法刷题笔记:从滑动窗口到哈夫曼编码,我的算法进阶之路
开发语言·算法