云计算百科
云计算领域专业知识百科平台

(三)数据结构与算法——经典算法

🗂️ 一、 哈希表:O(1) 神话的缔造者与冲突解决

1. 哈希表的原理是什么?

哈希表(Hash Table)是一种通过哈希函数把键(key)映射到数组下标,从而在平均 O(1) 时间内完成查找、插入、删除的数据结构。
核心原理四步走:

  • 准备一个固定大小的数组作为“桶数组”。

  • 用哈希函数 hash(key) 把键映射成一个整数。

  • 用这个整数对数组长度取模(或位运算)得到桶下标。

  • 把键值对放到这个桶里。

  • 2. 哈希冲突怎么解决?

    不同的 key 经过哈希函数后可能映射到同一个下标,这就是哈希冲突,无法完全避免。主要有两大解决方案:

    • 链地址法(拉链法):每个桶里挂一个链表(或红黑树),冲突的元素都挂在对应桶的链表上。Java HashMap 和 Redis 的 dict 都用这种方式。注意点:JDK 1.8 之后,当某个桶的链表长度 ≥ 8 且数组长度 ≥ 64 时,链表会转成红黑树,进一步优化最坏情况下的查找。

    • 开放寻址法:冲突时按一定规则在数组里寻找下一个空位。常见探测方式有线性探测(i, i+1, i+2…)、二次探测(i, i+1², i+2²…)、双重哈希。Java 的 ThreadLocalMap 就是用线性探测。

    3. 负载因子(Load Factor)

    • 公式:元素数量 / 桶数量。

    • 负载因子越大冲突越多。Java HashMap 默认 0.75,超过就触发扩容(rehash),以维持平均 O(1) 的查找性能。

    🧩 二、 归并排序:分治思想的完美体现

    1. 原理与实现

    归并排序是一种典型的分治算法,思想非常清晰,就是“分、治、合”三步:

  • 分:把数组从中间一分为二,分到底。

  • 治:递归地对两半分别排序。

  • 合:把两个已排序的子数组合并成一个有序数组。

  • 2. 复杂度与特性

    • 时间复杂度:最好、最坏、平均都是 O(n log n)(共 log n 层递归,每层合并总共处理 n 个元素)。

    • 空间复杂度:O(n)(需要额外的临时数组存放合并结果)。

    • 稳定性:稳定排序,合并时相同元素按原始顺序放入。

    3. 对比快排与应用场景

    • 相比快排的优势:稳定、最坏情况也是 O(n log n)(快排最坏是 O(n²))。

    • 相比快排的劣势:需要 O(n) 额外空间,不是原地排序。

    • 典型应用场景:

      • 外部排序(内存放不下的海量数据)。

      • 链表排序(对链表非常友好,空间可以做到 O(1))。

      • 需要稳定性的业务场景。

    #include <iostream>
    #include <vector>

    // 合并两个有序子数组
    void merge(vector<int>& arr, int left, int mid, int right) {
    vector<int> temp(right – left + 1);
    int i = left, j = mid + 1, k = 0;

    while (i <= mid && j <= right) {
    // 注意:这里用 <= 保证稳定性
    if (arr[i] <= arr[j]) {
    temp[k++] = arr[i++];
    } else {
    temp[k++] = arr[j++];
    }
    }
    while (i <= mid) temp[k++] = arr[i++];
    while (j <= right) temp[k++] = arr[j++];

    // 将临时数组拷贝回原数组
    for (int p = 0; p < k; ++p) {
    arr[left + p] = temp[p];
    }
    }

    void mergeSort(vector<int>& arr, int left, int right) {
    if (left >= right) return;
    int mid = left + (right – left) / 2;
    mergeSort(arr, left, mid); // 递归排序左半
    mergeSort(arr, mid + 1, right); // 递归排序右半
    merge(arr, left, mid, right); // 合并
    }

     三、 二分查找:思路很简单,细节是魔鬼

    1. 核心思想与标准实现

    二分查找是对有序数组进行查找的经典算法,时间复杂度 O(log n)。核心思想是每次比较中间元素,根据大小关系把查找范围缩小一半。

    2. 容易踩的坑

  • 整数溢出:不要写 (left + right) / 2,当 left 和 right 都接近 Integer.MAX_VALUE 时会溢出,必须写成 left + (right – left) / 2。

  • 边界区间的写法要统一:

    • 闭区间 [left, right]:循环条件 left <= right,更新时 left = mid + 1 或 right = mid – 1。

    • 左闭右开 [left, right):循环条件 left < right,更新时 left = mid + 1或right = mid(不减 1)。

    • ⚠️两种写法不能混用,否则要么死循环要么漏解。

  • 死循环:如果循环里某个分支忘记让 left 或 right 变化,会导致死循环。

  • 变体题:查找“第一个等于 target”、“最后一个等于 target”、“第一个大于等于 target”等变体,需要小心 < 和 <= 的取舍。

  • #include <vector>

    int binarySearch(const vector<int>& arr, int target) {
    int left = 0, right = arr.size() – 1;

    // 闭区间 [left, right]
    while (left <= right) {
    // 防溢出写法,面试必考!
    int mid = left + (right – left) / 2;

    if (arr[mid] == target) {
    return mid;
    } else if (arr[mid] < target) {
    left = mid + 1; // 更新左边界
    } else {
    right = mid – 1; // 更新右边界
    }
    }
    return -1; // 未找到
    }

    四、 DFS 与 BFS:图/树遍历的双子星

    1. 区别与实现

    • DFS(深度优先):一条路走到黑,走不通再回溯。通常用递归或栈实现。

    • BFS(广度优先):一层一层向外扩展。通常用队列实现。

    2. 对比表格

    维度DFSBFS
    实现 递归 / 栈 队列
    空间复杂度 O(h),h 是递归栈深度(树的高度) O(w),w 是树最宽一层的节点数
    能否求最短路径 不能直接求 可以(边权相等的图,BFS 第一次到达即最短路径)
    典型应用 全排列、子集、拓扑排序、检测环、连通分量、回溯 层序遍历、求最短路径、“最少几步”类问题

    3. 经典题举例

    • 二叉树前/中/后序遍历 → DFS

    • 二叉树层序遍历 → BFS

    • LeetCode 200 岛屿数量 → DFS/BFS 都行

    • LeetCode 994 腐烂的橘子(求最短感染时间) → BFS

    • 回溯类题目(全排列、N 皇后、子集、组合) → DFS

    💡 一句话区别:要找最短路径、最少步数用 BFS;要穷举所有方案或深挖某条路径用 DFS。

    五、 链表手撕:反转链表与环形链表

    1. 如何反转一个链表?

    这是链表手撕题里最经典的一道,要会两种解法。

    • 解法一:迭代(推荐,O(1) 空间)
      用三个指针 prev、curr、next 依次调转每个节点的指向。
      时间复杂度 O(n),空间复杂度 O(1)。

    struct ListNode {
    int val;
    ListNode *next;
    ListNode(int x) : val(x), next(nullptr) {}
    };

    ListNode* reverseList(ListNode* head) {
    ListNode* prev = nullptr;
    ListNode* curr = head;
    while (curr != nullptr) {
    ListNode* nextTemp = curr->next; // 暂存下一个节点
    curr->next = prev; // 反转当前节点指针
    prev = curr; // prev 前进
    curr = nextTemp; // curr 前进
    }
    return prev; // 最后 prev 就是新的头节点
    }

    • 解法二:递归
      递归到最后一个节点作为新头节点,然后在回溯的过程中改变指针指向。
      时间复杂度 O(n),但递归栈占用 O(n) 空间。
      ⚠️一定要考虑空链表(head == null)和只有一个节点的边界情况,上面两种写法都已经处理了。

    ListNode* reverseListRecursive(ListNode* head) {
    // 边界情况:空链表或只有一个节点
    if (head == nullptr || head->next == nullptr) return head;

    ListNode* newHead = reverseListRecursive(head->next);
    head->next->next = head; // 让下一个节点的 next 指向当前节点
    head->next = nullptr; // 当前节点的 next 设为 null,避免成环
    return newHead;
    }

    2. 如何判断一个链表是否有环?如何找到环的入口节点?

    这是快慢指针(Floyd 判圈算法)的经典应用题,两问都有标准答案。

    • 第一问:判断有没有环
      用两个指针 slow 和 fast,slow 每次走 1 步,fast 每次走 2 步。
      如果 fast 走到了 null,说明没有环;如果 fast 追上了 slow(相遇),说明有环。

    • 第二问:找环的入口节点
      关键结论(Floyd 算法的数学推导):在 slow 和 fast 相遇后,让其中一个指针回到链表头,两个指针都每次走一步,再次相遇的地方就是环的入口。
      数学原理:设头到入口距离为 a,入口到相遇点为 b,相遇点到入口(沿环走)为 c。相遇时 slow = a + b,fast = a + b + k(b + c)(多跑了 k 圈)。由 fast = 2 * slow 推出 a = (k – 1)(b + c) + c,含义就是:从头走 a 步,等价于从相遇点走 c 步再走若干整圈,两者一定会同时到达入口。

    ListNode* detectCycle(ListNode* head) {
    ListNode* slow = head;
    ListNode* fast = head;

    while (fast != nullptr && fast->next != nullptr) {
    slow = slow->next;
    fast = fast->next->next;

    if (slow == fast) { // 相遇,开始找入口
    ListNode* p = head;
    while (p != slow) {
    p = p->next;
    slow = slow->next;
    }
    return p; // 再次相遇的地方就是环的入口
    }
    }
    return nullptr; // 无环
    }

     六、 二叉树遍历:四种方式一网打尽

    二叉树遍历分两大类:DFS 三种 和 BFS 一种,一共四种,都是超高频考点。

    1. DFS 三种(根据根节点被访问的顺序区分)

    • 前序遍历:根 → 左 → 右

    • 中序遍历:左 → 根 → 右(二叉搜索树的中序遍历结果是升序的)

    • 后序遍历:左 → 右 → 根

    递归实现(以中序为例):代码非常简洁,牢记“左、根、右”的顺序递归调用即可。迭代实现需要显式用栈。

    #include <stack>

    vector<int> inorderTraversal(TreeNode* root) {
    vector<int> result;
    stack<TreeNode*> stk;
    TreeNode* curr = root;

    while (curr != nullptr || !stk.empty()) {
    // 一路向左,把左节点全部压栈
    while (curr != nullptr) {
    stk.push(curr);
    curr = curr->left;
    }
    // 弹出栈顶(最左节点)并访问
    curr = stk.top();
    stk.pop();
    result.push_back(curr->val);
    // 转向右子树
    curr = curr->right;
    }
    return result;
    }

    2. BFS 一种:层序遍历(Level Order)

    用队列实现,一层一层访问。
    核心技巧是在每一层开始前,记录当前队列的大小 size,然后用一个循环把当前层的节点全部处理完,并将其子节点加入队列。这样就能完美分层输出。

    #include <queue>

    vector<vector<int>> levelOrder(TreeNode* root) {
    vector<vector<int>> result;
    if (root == nullptr) return result;

    queue<TreeNode*> q;
    q.push(root);

    while (!q.empty()) {
    int size = q.size(); // 记录当前层的节点数
    vector<int> level;

    for (int i = 0; i < size; ++i) {
    TreeNode* node = q.front();
    q.pop();
    level.push_back(node->val);

    if (node->left) q.push(node->left);
    if (node->right) q.push(node->right);
    }
    result.push_back(level);
    }
    return result;
    }

     七、什么是动态规划(DP)?

    动态规划(Dynamic Programming)是一种把大问题拆解为重叠子问题、通过记录子问题的解来避免重复计算的算法思想。它本质上是“聪明的暴力枚举”。

    一个问题是能否用DP,通常看两个特征:

  • 最优子结构:原问题的最优解可以由子问题的最优解组合而来。

  • 重叠子问题:递归求解时会反复计算相同的子问题(这正是DP比暴力递归快的原因)。

  • 💡 掌握DP的解题套路(核心四步曲):

    • 定义状态:dp[i] 或 dp[i][j] 到底表示什么?

    • 状态转移方程:dp[i] 如何从之前的状态推导出来?

    • 初始化:边界状态如何赋值?

    • 遍历顺序:是从前往后、从后往前,还是按某种维度?

    🔥 进阶技巧(必备):
    先想暴力递归 -> 发现重叠子问题 -> 用数组/哈希表做记忆化搜索 -> 改写成自底向上的迭代DP -> 最后用滚动数组优化空间。掌握这套流程,80%的DP题都能套!

    💻 实战:斐波那契(滚动数组空间优化)

    以最基础的爬楼梯为例,状态转移方程 dp[i] = dp[i-1] + dp[i-2]。为了节省空间,我们不用存整个数组,只用两个变量。

    int climbStairs(int n) {
    if (n <= 2) return n;
    int prev2 = 1; // dp[1]
    int prev1 = 2; // dp[2]
    int current = 0;
    for (int i = 3; i <= n; ++i) {
    current = prev1 + prev2;
    prev2 = prev1;
    prev1 = current;
    }
    return current;
    }

    典型题目图谱: 0-1背包、完全背包(零钱兑换)、最长公共子序列(LCS)、最长递增子序列(LIS)、最长回文子串、编辑距离、打家劫舍

    贪心算法和动态规划有什么区别?

    两者都用于求最优解问题,但核心区别在于做决策时是否回头看。

    维度贪心算法动态规划
    决策方式 每一步都做当前看起来最优的选择 枚举所有可能的子问题解再做选择
    是否有后效性 假设当前选择不影响未来 基于之前所有状态推导
    正确性 只在“贪心选择性质”成立时才能得到最优解 只要状态转移方程定义对了,一定能得到最优解
    时间复杂度 通常更快,O(n) 或 O(n log n) 通常 O(n²) 或更高
    空间复杂度 通常 O(1) 通常 O(n) 或 O(n²)

    典型贪心题:

    • 找零钱(面值 1/5/10/25):每次尽量用大面额。

    • 区间调度(活动选择):按结束时间排序,依次选能加入的活动。

    • 霍夫曼编码:每次合并频率最小的两个节点。

    • 跳跃游戏:每一步都跳到“能到达的最远位置”。

    贪心是“每一步都走当前看起来最好的”,DP是“把所有可能性都算过再选最好的”。贪心更快但不一定正确,DP一定正确但更慢。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » (三)数据结构与算法——经典算法
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!