数据结构基础面试题
精选 155 道数据结构与算法高频面试题(C 语言实现),涵盖数组、链表、栈、队列、树、堆、哈希表、图、排序、查找等。 每题配详细答案、C 代码实现和复杂度分析。
## ★ 题目分类导航
- 一、数组与字符串(Q1~Q20)
- 二、链表(Q21~Q48)
- 三、栈与队列(Q49~Q64)
- 四、树与堆(Q65~Q85)
- 五、哈希表(Q86~Q91)
- 六、排序算法(Q92~Q102)
- 七、图与高级算法(Q103~Q120)
一、数组与字符串(Q1~Q20)
Q1: 数组的基本特性?
🧠 秒懂: 数组就像一排储物柜——编号连续、大小相同,知道编号就能直接打开(O(1)随机访问),但中间插入要把后面的全部往后挪。
1- 连续内存, 随机访问 O(1)2- 插入/删除 O(n) (需要移动元素)3- 大小固定(静态数组)4
5int arr[10]; /* 栈上, 编译时确定大小 */6int *p = malloc(n * sizeof(int)); /* 堆上, 运行时确定 */Q2: 反转数组?
🧠 秒懂: 反转数组就像把一排人前后对调——用双指针从两头往中间走,左右交换,走到中间就完成了,时间O(n)空间O(1)。
使用双指针或递归方式实现:
1void reverse(int *arr, int n) {2 int left = 0, right = n - 1;3 while (left < right) {4 int tmp = arr[left];5 arr[left] = arr[right];6 arr[right] = tmp;7 left++;8 right--;9 }10}11/* 时间 O(n), 空间 O(1) */Q3: 两数之和(数组已排序)?
🧠 秒懂: 有序数组两数之和用双指针——一个从头一个从尾,和太大右指针左移,和太小左指针右移,比暴力两层循环从O(n²)降到O(n)。
利用双指针法在已排序数组中寻找目标和:
1/* 双指针法 */2void two_sum(int *arr, int n, int target) {3 int l = 0, r = n - 1;4 while (l < r) {5 int sum = arr[l] + arr[r];6 if (sum == target) {7 printf("(%d, %d)\n", arr[l], arr[r]);8 return;9 } else if (sum < target) {10 l++;11 } else {12 r--;13 }14 }15}1 collapsed line
16/* O(n) */Q4: 删除有序数组中的重复元素(原地)?
🧠 秒懂: 原地去重就像整理书架——用快慢指针,慢指针指向不重复序列的末尾,快指针往前探路,遇到新值就放到慢指针后面。
使用快慢指针原地去除重复元素:
1int removeDuplicates(int *arr, int n) {2 if (n <= 1) return n;3 int slow = 0;4 for (int fast = 1; fast < n; fast++) {5 if (arr[fast] != arr[slow]) {6 arr[++slow] = arr[fast];7 }8 }9 return slow + 1; /* 新长度 */10}11/* 快慢指针, O(n) */Q5: 字符串反转?
🧠 秒懂: 字符串反转就像把一排扑克牌倒过来——双指针从两头交换到中间,或者用栈先全部压入再弹出,是最基础的面试热身题。
1/* 字符串反转? - 示例实现 */2void reverse_str(char *s) {3 int len = strlen(s);4 for (int i = 0, j = len - 1; i < j; i++, j--) {5 char tmp = s[i];6 s[i] = s[j];7 s[j] = tmp;8 }9}Q6: 判断回文字符串?
🧠 秒懂: 回文判断就像照镜子——从两头向中间比较,对称位置字符相同就是回文,‘abcba’是,‘abcda’不是,双指针O(n)搞定。
从两端向中间逐字符比较:
1/* 判断回文字符串? - 示例实现 */2int is_palindrome(const char *s) {3 int l = 0, r = strlen(s) - 1;4 while (l < r) {5 if (s[l] != s[r]) return 0;6 l++;7 r--;8 }9 return 1;10}Q7: strlen/strcpy/strcmp/strcat 自己实现?
🧠 秒懂: 自己实现这4个函数是面试经典——关键是注意strlen不含’\0’、strcpy要拷贝’\0’、strcmp逐字符比较返回差值、strcat找到末尾再拷贝。
手动实现标准库函数,理解其底层原理:
1/* strlen/strcpy/strcmp/strcat 自己实现? - 示例实现 */2size_t my_strlen(const char *s) {3 const char *p = s;4 while (*p) p++;5 return p - s;6}7
8char *my_strcpy(char *dst, const char *src) {9 char *ret = dst;10 while ((*dst++ = *src++) != '\0');11 return ret;12}13
14int my_strcmp(const char *s1, const char *s2) {15 while (*s1 && *s1 == *s2) { s1++; s2++; }2 collapsed lines
16 return *(unsigned char *)s1 - *(unsigned char *)s2;17}Q8: memmove vs memcpy?
🧠 秒懂: memcpy直接拷贝可能在内存重叠时出错,memmove会先判断方向——就像搬家时从前往后搬和从后往前搬的区别,memmove更安全但稍慢。
两者的关键区别在于是否处理内存重叠:
1/* memcpy: 不处理内存重叠, 速度快 */2/* memmove: 处理内存重叠(可能从后往前拷贝) */3void *my_memmove(void *dst, const void *src, size_t n) {4 char *d = dst;5 const char *s = src;6 if (d < s) {7 while (n--) *d++ = *s++; /* 从前往后 */8 } else {9 d += n; s += n;10 while (n--) *--d = *--s; /* 从后往前 */11 }12 return dst;13}Q9: 旋转数组(右移 k 位)?
🧠 秒懂: 旋转数组就像队伍整体右移k位——最巧妙的方法是三次反转:先整体反转,再反转前k个,再反转剩下的,O(n)时间O(1)空间。
把数组 [1,2,3,4,5,6,7] 右移 3 位变成 [5,6,7,1,2,3,4]。最巧妙的方法是三次反转法:先把整个数组反转变成 [7,6,5,4,3,2,1],再把前 k 个反转变成 [5,6,7,4,3,2,1],最后把剩下的反转变成 [5,6,7,1,2,3,4]。本质上是利用两次反转等于不变的数学性质。注意 k 要对 n 取模,防止 k > n 的情况。时间 O(n),空间 O(1),原地完成。
1void rotate(int *arr, int n, int k) {2 k %= n;3 reverse(arr, 0, n - 1); /* 全部反转 */4 reverse(arr, 0, k - 1); /* 前k个反转 */5 reverse(arr, k, n - 1); /* 后面反转 */6}Q10: 找数组中出现次数超过一半的数(摩尔投票法)?
🧠 秒懂: 摩尔投票法就像’打擂台’——每个数和当前候选人PK,相同加分不同减分,减到0换人,最终剩下的就是超过一半的那个数。
这道题核心思想是消消乐:想象不同数字之间互相”抵消”,最后剩下的一定是出现超过一半的那个数。算法维护一个候选人 candidate 和计数 count:遇到相同的数 count++,遇到不同的 count—,count 减到 0 就换候选人。因为多数元素超过一半,抵消完一定剩它。时间 O(n),空间 O(1)。
1int majorityElement(int *arr, int n) {2 int candidate = arr[0], count = 1;3 for (int i = 1; i < n; i++) {4 if (count == 0) { candidate = arr[i]; count = 1; }5 else if (arr[i] == candidate) count++;6 else count--;7 }8 return candidate; /* 题目保证一定存在 */9}Q11: 合并两个有序数组?
🧠 秒懂: 合并两个有序数组就像两队人按身高合并成一队——两个指针分别指向两个数组,每次取较小的放入结果,谁先走完把另一个剩余部分直接接上。
两个已排序数组 a[m] 和 b[n],合并到 a 中(a 有足够空间)。关键技巧是从后往前填:用三个指针分别指向 a 的末尾、b 的末尾和结果的末尾,每次取较大的放到结果末尾。这样可以原地合并不需要额外空间,也不会覆盖还没处理的数据。如果从前往前填,就需要移动元素,复杂度变成 O(m*n)。
1void merge(int *a, int m, int *b, int n) {2 int i = m - 1, j = n - 1, k = m + n - 1;3 while (i >= 0 && j >= 0) {4 a[k--] = (a[i] > b[j]) ? a[i--] : b[j--];5 }6 while (j >= 0) a[k--] = b[j--]; /* b 有剩余 */7 /* a 有剩余不用处理,已经在原位 */8}Q12: KMP 字符串匹配?
🧠 秒懂: KMP就像聪明地找书中的关键词——匹配失败时不从头开始,而是利用已匹配部分的信息(next数组)跳过不必要的比较,从O(mn)降到O(m+n)。
暴力匹配在不匹配时主串指针要回退,KMP 的核心改进是:主串指针永远不回退,只移动模式串指针。这靠预处理出 next 数组实现——next[j] 表示模式串前 j 个字符的最长相等前后缀长度。当位置 j 失配时,模式串跳到 next[j] 位置继续匹配,相当于利用已匹配的信息跳过不可能成功的比较。时间复杂度 O(m+n),其中 m 是主串长, n 是模式串长。
1void build_next(const char *pat, int *next, int n) {2 next[0] = 0;3 int len = 0, i = 1;4 while (i < n) {5 if (pat[i] == pat[len]) { next[i++] = ++len; }6 else if (len > 0) { len = next[len - 1]; }7 else { next[i++] = 0; }8 }9}10
11int kmp_search(const char *text, const char *pat) {12 int m = strlen(text), n = strlen(pat);13 int next[n];14 build_next(pat, next, n);15 int i = 0, j = 0;8 collapsed lines
16 while (i < m) {17 if (text[i] == pat[j]) { i++; j++; }18 else if (j > 0) { j = next[j - 1]; }19 else { i++; }20 if (j == n) return i - n; /* 找到 */21 }22 return -1;23}Q13: atoi 实现?
🧠 秒懂: atoi就像人工把’123’变成数字123——要处理的边界多到爆:前导空格、正负号、非数字字符、溢出(超过INT_MAX/INT_MIN),面试重点考细心。
把字符串 " -123abc" 转为整数 -123。看起来简单但边界极多:(1) 跳过前导空格 (2) 处理正负号 (3) 逐字符转数字 (4) 遇到非数字字符停止 (5) 溢出检测是最关键的——在乘10加新数字之前要判断是否会超过 INT_MAX/INT_MIN。面试中经常考这道题来看你对边界条件的处理能力。
1int my_atoi(const char *s) {2 while (*s == ' ') s++; /* 跳空格 */3 int sign = 1;4 if (*s == '-' || *s == '+') { sign = (*s == '-') ? -1 : 1; s++; }5 long result = 0;6 while (*s >= '0' && *s <= '9') {7 result = result * 10 + (*s - '0');8 if (result * sign > INT_MAX) return INT_MAX;9 if (result * sign < INT_MIN) return INT_MIN;10 s++;11 }12 return (int)(result * sign);13}Q14: 大数相加(字符串模拟)?
🧠 秒懂: 大数相加就像小学竖式加法——从最低位对齐逐位相加,进位向前传,结果用字符串存储,不受整数位数限制。
当两个数大到 long long 都装不下时(如 100 位数),只能用字符串模拟手工加法:从末尾开始逐位相加,处理进位,结果倒序存储最后反转。这在嵌入式中不常考,但在算法面试中是基础题。核心就是模拟我们小学学的竖式加法。
1char *addStrings(const char *a, const char *b) {2 int i = strlen(a) - 1, j = strlen(b) - 1;3 int carry = 0, k = 0;4 char result[1024];5 while (i >= 0 || j >= 0 || carry) {6 int sum = carry;7 if (i >= 0) sum += a[i--] - '0';8 if (j >= 0) sum += b[j--] - '0';9 result[k++] = sum % 10 + '0';10 carry = sum / 10;11 }12 result[k] = '\0';13 /* 反转 result */14 for (int l = 0, r = k - 1; l < r; l++, r--) {15 char t = result[l]; result[l] = result[r]; result[r] = t;3 collapsed lines
16 }17 return strdup(result);18}Q15: 最长无重复字符子串(滑动窗口)?
🧠 秒懂: 滑动窗口就像一个可伸缩的框在数组上滑动——用哈希记录窗口内字符,遇到重复就收缩左边界,维护最大窗口长度。
给定字符串找最长的不含重复字符的连续子串。用滑动窗口:维护左右指针 [left, right],right 不断右移扩大窗口。用一个数组(哈希表)记录每个字符是否在窗口中。当新字符已存在时,不断收缩左边界直到没有重复。每次更新最大长度。时间 O(n),因为 left 和 right 各自最多走 n 步。
1int lengthOfLongestSubstring(const char *s) {2 int count[128] = {0}; /* ASCII 字符计数 */3 int left = 0, max_len = 0;4 for (int right = 0; s[right]; right++) {5 count[(int)s[right]]++;6 while (count[(int)s[right]] > 1) {7 count[(int)s[left]]--;8 left++;9 }10 int len = right - left + 1;11 if (len > max_len) max_len = len;12 }13 return max_len;14}Q16: 螺旋矩阵遍历?
🧠 秒懂: 螺旋遍历就像剥洋葱——从外圈到内圈,每圈按右→下→左→上的顺序走,用四个边界变量控制,走完一边就收缩对应边界。
按照右→下→左→上的顺序一圈一圈地遍历二维矩阵。维护四个边界:top/bottom/left/right,每遍历完一条边就收缩对应边界。当上下边界交叉或左右边界交叉时停止。面试中要注意矩阵不是正方形的情况(如 3×5),某些方向可能提前结束。
1void spiralOrder(int mat[][N], int rows, int cols) {2 int top = 0, bot = rows - 1, left = 0, right = cols - 1;3 while (top <= bot && left <= right) {4 for (int j = left; j <= right; j++) printf("%d ", mat[top][j]);5 top++; // 入栈6 for (int i = top; i <= bot; i++) printf("%d ", mat[i][right]);7 right--;8 if (top <= bot) {9 for (int j = right; j >= left; j--) printf("%d ", mat[bot][j]);10 bot--;11 }12 if (left <= right) {13 for (int i = bot; i >= top; i--) printf("%d ", mat[i][left]);14 left++;15 }2 collapsed lines
16 }17}Q17: 二维有序数组查找?
🧠 秒懂: 二维有序数组查找(杨氏矩阵)——从右上角开始,目标大了往下走,目标小了往左走,像走楼梯一样,O(m+n)就能找到。
一个 m×n 矩阵,每行从左到右递增,每列从上到下递增,查找目标值。从右上角出发:如果当前值等于目标就找到了;如果当前值大于目标,说明这整列都太大,往左走;如果当前值小于目标,说明这整行都太小,往下走。每步淘汰一行或一列,时间 O(m+n)。这比暴力 O(mn) 和逐行二分 O(mlogn) 都快。
1int searchMatrix(int mat[][N], int rows, int cols, int target) {2 int i = 0, j = cols - 1; /* 右上角出发 */3 while (i < rows && j >= 0) {4 if (mat[i][j] == target) return 1;5 else if (mat[i][j] > target) j--;6 else i++;7 }8 return 0;9}Q18: 字符串转整数的边界处理?
🧠 秒懂: 字符串转整数的边界就像走钢丝——前导空格要跳过,正负号只能有一个,遇到非数字停止,最重要的是溢出检测要在乘10之前判断。
这是 Q13 的面试追问重点。面试官关心你能否处理所有边界:空字符串、全空格、只有正负号("+")、前导零("00123")、溢出("999999999999")、中间出现非数字("12a3" → 12)。最关键的是溢出判断:在 result = result * 10 + digit 之前检查 result > (INT_MAX - digit) / 10。建议面试时先列出所有边界case再写代码。
1/* 字符串转整数(atoi) — 处理边界情况 */2int myAtoi(const char *s) {3 while (*s == ' ') s++; // 1. 跳过前导空格4 int sign = 1;5 if (*s == '-' || *s == '+') // 2. 处理正负号6 sign = (*s++ == '-') ? -1 : 1;7 long result = 0;8 while (*s >= '0' && *s <= '9') { // 3. 逐位转换9 result = result * 10 + (*s++ - '0');10 if (result * sign > INT_MAX) return INT_MAX; // 4. 溢出检查11 if (result * sign < INT_MIN) return INT_MIN;12 }13 return (int)(result * sign);14}15/* 关键边界: 溢出/空串/非数字字符/正负号/前导空格 */Q19: 数组中第 K 大元素(快速选择)?
🧠 秒懂: 快速选择就像考试只需找班级第K名不需要全排——基于快排partition,每次只递归一半,平均O(n)就能找到第K大,比排序的O(nlogn)快。
排序后取第 K 大需要 O(nlogn),但快速选择算法平均 O(n):类似快排的 partition,每次只递归一半。partition 把数组分成”比 pivot 大”和”比 pivot 小”两部分,如果 pivot 刚好在第 K 个位置就找到了;如果 K 在左半边就只处理左半边,右半边直接丢弃。平均每次问题规模减半:n + n/2 + n/4 + … = O(n)。
1int partition(int *arr, int lo, int hi) {2 int pivot = arr[hi], i = lo; // 基准元素(快排)3 for (int j = lo; j < hi; j++) {4 if (arr[j] >= pivot) { /* 降序partition, 大的在前 */5 int t = arr[i]; arr[i] = arr[j]; arr[j] = t;6 i++;7 }8 }9 int t = arr[i]; arr[i] = arr[hi]; arr[hi] = t;10 return i;11}12
13int findKthLargest(int *arr, int n, int k) {14 int lo = 0, hi = n - 1;15 while (lo <= hi) {7 collapsed lines
16 int p = partition(arr, lo, hi);17 if (p == k - 1) return arr[p];18 else if (p < k - 1) lo = p + 1;19 else hi = p - 1;20 }21 return -1;22}Q20: 子数组最大和(Kadane 算法)?
🧠 秒懂: Kadane算法就像走路记账——每走一步决定是’接着前面的路走’还是’重新开始’,取两者较大值,同时更新全局最大值,O(n)搞定。
给定数组 [-2,1,-3,4,-1,2,1,-5,4],找连续子数组的最大和(答案是 [4,-1,2,1]=6)。Kadane 算法的思想很简单:维护 current_sum 表示以当前元素结尾的最大子数组和。对每个元素,要么加入前面的子数组(current_sum + arr[i]),要么自己单独开始一个新子数组(arr[i]),取较大值。同时更新全局最大值。只需遍历一次,时间 O(n) 空间 O(1)。
1int maxSubArray(int *arr, int n) {2 int current_sum = arr[0], max_sum = arr[0];3 for (int i = 1; i < n; i++) {4 /* 要么接上前面, 要么自己重新开始 */5 current_sum = (current_sum + arr[i] > arr[i]) ?6 current_sum + arr[i] : arr[i];7 if (current_sum > max_sum) max_sum = current_sum;8 }9 return max_sum;10}二、链表(Q21~Q48)
Q21: 单链表定义?
🧠 秒懂: 单链表就像火车车厢——每节车厢(节点)装数据和指向下节车厢的钩子(指针),只能从头往后走不能倒退,插入删除只需改钩子O(1)。
链表操作的核心是指针的修改:
1typedef struct ListNode {2 int val;3 struct ListNode *next;4} ListNode;5
6/* 创建节点 */7ListNode *new_node(int val) {8 ListNode *node = (ListNode *)malloc(sizeof(ListNode)); // 动态分配节点9 node->val = val;10 node->next = NULL; // 修改指针指向11 return node;12}13
14/* 头插法建链表 */15ListNode *head = NULL;5 collapsed lines
16for (int i = 0; i < n; i++) {17 ListNode *node = new_node(data[i]);18 node->next = head; // 修改指针指向19 head = node;20}Q22: 反转链表(最高频!)
🧠 秒懂: 反转链表是面试第一高频题——迭代法用三个指针:prev、curr、next,每次把当前节点指向前一个,就像逐个掉头,面试必须秒写。
使用双指针或递归方式实现:
1ListNode *reverse(ListNode *head) {2 ListNode *prev = NULL, *curr = head;3 while (curr) {4 ListNode *next = curr->next;5 curr->next = prev; // 修改指针指向6 prev = curr;7 curr = next;8 }9 return prev;10}11
12/* 递归版 */13ListNode *reverse_rec(ListNode *head) {14 if (!head || !head->next) return head; // 头节点的下一个15 ListNode *new_head = reverse_rec(head->next); // 头节点的下一个5 collapsed lines
16 head->next->next = head; // 修改指针指向17 head->next = NULL; // 修改指针指向18 return new_head;19}20/* 时间 O(n), 空间 O(1) / O(n) */💡 面试追问: 递归和迭代两种写法?时间空间复杂度?在面试中应该写哪种? 🔧 嵌入式建议: 嵌入式面试最高频链表题!迭代版O(1)空间(推荐)。递归版简洁但栈溢出风险(嵌入式栈小)。
📊 链表 vs 数组 对比表
| 特性 | 数组 | 链表 |
|---|---|---|
| 内存分配 | 连续(编译期/一次性) | 分散(运行时按节点) |
| 随机访问 | O(1) | O(n) |
| 头部插入/删除 | O(n)(需移动) | O(1) |
| 尾部插入 | O(1)(预留空间时) | O(n)/O(1)(有尾指针) |
| 中间插入/删除 | O(n) | O(1)(已知位置时) |
| 缓存友好性 | ⭐好(连续内存) | 差(跳转多) |
| 嵌入式推荐 | ⭐静态数组(无碎片) | 静态链表(预分配节点池) |
Q23: 判断链表有环?
🧠 秒懂: 判断链表有环用快慢指针——快指针每次走两步慢指针走一步,如果有环一定会相遇(就像操场跑步快的人会追上慢的人)。
链表操作的核心是指针的修改:
1/* 快慢指针(Floyd 判环法) */2int hasCycle(ListNode *head) {3 ListNode *slow = head, *fast = head;4 while (fast && fast->next) {5 slow = slow->next;6 fast = fast->next->next;7 if (slow == fast) return 1; /* 有环 */8 }9 return 0;10}11
12/* 找环入口点: 相遇后, 一个从 head 出发, 一个从相遇点出发, 再次相遇即入口 */13ListNode *detectCycle(ListNode *head) {14 ListNode *slow = head, *fast = head;15 while (fast && fast->next) {13 collapsed lines
16 slow = slow->next;17 fast = fast->next->next;18 if (slow == fast) {19 ListNode *p = head;20 while (p != slow) {21 p = p->next;22 slow = slow->next;23 }24 return p; /* 环入口 */25 }26 }27 return NULL;28}Q24: 合并两个有序链表?
🧠 秒懂: 合并有序链表就像拉拉链——两个指针各指一条链表,每次取较小的节点接到结果链上,用虚拟头节点简化边界处理。
链表操作的核心是指针的修改:
1ListNode *merge(ListNode *l1, ListNode *l2) {2 ListNode dummy = {0, NULL};3 ListNode *tail = &dummy;4 while (l1 && l2) {5 if (l1->val <= l2->val) {6 tail->next = l1; l1 = l1->next; // 修改指针指向7 } else {8 tail->next = l2; l2 = l2->next; // 修改指针指向9 }10 tail = tail->next;11 }12 tail->next = l1 ? l1 : l2; // 修改指针指向13 return dummy.next;14}Q25: 删除链表倒数第 N 个节点?
🧠 秒懂: 删除倒数第N个就像’间隔N步’走路——快指针先走N步,然后快慢一起走,快到终点时慢指针恰好在倒数第N+1个位置,改next即可。
链表操作的核心是指针的修改:
1ListNode *removeNthFromEnd(ListNode *head, int n) {2 ListNode dummy = {0, head};3 ListNode *fast = &dummy, *slow = &dummy;4 /* fast 先走 n+1 步 */5 for (int i = 0; i <= n; i++) fast = fast->next;6 /* 同步走到 fast == NULL */7 while (fast) {8 fast = fast->next;9 slow = slow->next;10 }11 ListNode *del = slow->next;12 slow->next = del->next; // 修改指针指向13 free(del);14 return dummy.next;15}Q26: 链表中间节点?
🧠 秒懂: 找中间节点用快慢指针——快走两步慢走一步,快到末尾时慢正好在中间,常用于链表归并排序的分割步骤。
链表操作的核心是指针的修改:
1ListNode *middleNode(ListNode *head) {2 ListNode *slow = head, *fast = head;3 while (fast && fast->next) {4 slow = slow->next;5 fast = fast->next->next;6 }7 return slow; /* 偶数个节点时返回第二个中间 */8}Q27: 判断两个链表是否相交?
🧠 秒懂: 两链表相交就像两条路汇成一条——先算长度差让长的先走差值步,然后一起走,相遇的节点就是交点。或者各走完自己的走对方的,也能相遇。
链表操作的核心是指针的修改:
1ListNode *getIntersection(ListNode *headA, ListNode *headB) {2 ListNode *pA = headA, *pB = headB;3 while (pA != pB) {4 pA = pA ? pA->next : headB;5 pB = pB ? pB->next : headA;6 }7 return pA; /* NULL = 不相交, 非 NULL = 交点 */8}9/* 原理: 两指针走完自己的链表后走对方的, 走过的总距离相等 */Q28: 两数相加(链表表示)?
🧠 秒懂: 链表两数相加就像竖式加法——两个链表从低位到高位逐位相加,用carry记录进位,注意最后还有进位要多加一个节点。
两个非负整数用链表逆序存储(如 342 → 2→4→3),求和返回链表。思路和大数相加一样:从头开始逐位相加加进位。因为链表已经是低位在前,所以天然对齐,不需要反转。注意最后 carry 可能为 1,需要多创建一个节点。时间 O(max(m,n))。
1ListNode *addTwoNumbers(ListNode *l1, ListNode *l2) {2 ListNode dummy = {0, NULL};3 ListNode *tail = &dummy;4 int carry = 0;5 while (l1 || l2 || carry) {6 int sum = carry;7 if (l1) { sum += l1->val; l1 = l1->next; }8 if (l2) { sum += l2->val; l2 = l2->next; }9 tail->next = new_node(sum % 10); // 修改指针指向10 tail = tail->next;11 carry = sum / 10;12 }13 return dummy.next;14}Q29: 回文链表判断?
🧠 秒懂: 回文链表判断——快慢指针找中点,反转后半部分,然后从头和从中间逐个比较,相同就是回文,检查完再把链表恢复原样。
判断链表 1→2→2→1 是否回文。不能像数组一样双向遍历,所以分三步:(1) 快慢指针找中点 (2) 反转后半段链表 (3) 前半段和反转后的后半段逐一比较。时间 O(n),空间 O(1)。也可以用栈(空间 O(n))但面试官更想看 O(1) 做法。注意比较完后最好把链表恢复原样(再反转一次)。
1int isPalindrome(ListNode *head) {2 ListNode *slow = head, *fast = head;3 while (fast && fast->next) { slow = slow->next; fast = fast->next->next; }4 /* slow 指向后半段起点 */5 ListNode *rev = reverse(slow); /* 反转后半段 */6 ListNode *p = head, *q = rev;7 int result = 1;8 while (q) {9 if (p->val != q->val) { result = 0; break; }10 p = p->next; q = q->next;11 }12 reverse(rev); /* 恢复原链表 */13 return result;14}Q30: 链表排序(归并排序)?
🧠 秒懂: 链表归并排序——快慢指针分成两半,递归排序各半,再合并两个有序链表,是链表唯一能做到O(nlogn)且O(1)空间的排序方法。
链表不支持随机访问,所以快排效率不高(partition需要随机访问)。归并排序非常适合链表:用快慢指针找中点把链表分成两半,递归排序后合并两个有序链表。合并操作在链表上是 O(1) 空间的(不需要额外数组),总时间 O(nlogn)。这也是面试中最常考的链表排序方法。
1ListNode *sortList(ListNode *head) {2 if (!head || !head->next) return head; // 头节点的下一个3 /* 快慢指针找中点(慢指针前一个位置) */4 ListNode *slow = head, *fast = head->next; // 头节点的下一个5 while (fast && fast->next) { slow = slow->next; fast = fast->next->next; }6 ListNode *mid = slow->next;7 slow->next = NULL; /* 断开 */8 ListNode *left = sortList(head);9 ListNode *right = sortList(mid);10 return merge(left, right); /* 合并两个有序链表(Q24) */11}Q31: K 个一组翻转链表?
🧠 秒懂: K个一组翻转就像把火车每K节车厢整体掉头——先数K个,翻转这一段,连接上一段和下一段,不够K个的保持原样。
给链表 1→2→3→4→5,K=3,结果 3→2→1→4→5(最后不足K个不翻转)。思路:每次数 K 个节点出来翻转,翻转后接到前面的尾部,然后处理下一组。难点在于指针管理:需要记住每组的前驱节点和后继节点。这是 LeetCode hard 题,面试中能写出来说明链表操作非常熟练。
1ListNode *reverseKGroup(ListNode *head, int k) {2 ListNode dummy = {0, head};3 ListNode *prev_group = &dummy;4 while (1) {5 ListNode *kth = prev_group;6 for (int i = 0; i < k; i++) {7 kth = kth->next;8 if (!kth) return dummy.next; /* 不足k个 */9 }10 ListNode *next_group = kth->next;11 ListNode *curr = prev_group->next, *prev = next_group;12 for (int i = 0; i < k; i++) {13 ListNode *tmp = curr->next;14 curr->next = prev; // 修改指针指向15 prev = curr;7 collapsed lines
16 curr = tmp;17 }18 ListNode *tmp = prev_group->next; /* 原来的第一个=翻转后的最后一个 */19 prev_group->next = prev; /* 接上翻转后的头 */20 prev_group = tmp;21 }22}Q32: LRU 缓存(双向链表+哈希表)?
🧠 秒懂: LRU缓存就像酒店前台——最近用过的放前面,最久没用的在后面,满了就淘汰最后一个。双向链表+哈希表实现O(1)的读写和淘汰。
LRU(Least Recently Used) 要求 get 和 put 都是 O(1)。用双向链表维护访问顺序(最近使用的放头部,最久未使用的在尾部),用哈希表存 key→链表节点 的映射实现 O(1) 查找。get 时把节点移到头部;put 时如果已存在就更新并移到头部,不存在就新建节点插入头部,满了就删尾部。这是面试超高频题。
1/* 核心操作: */2/* get(key): hash找到节点 → 移到头部 → 返回值 */3/* put(key,val): hash找到 → 更新+移到头部; 没找到 → 新建插头部, 满了删尾 */4/* 双向链表保证 移动/删除/插入 都是 O(1) */Q33: 复制带随机指针的链表?
🧠 秒懂: 复制带随机指针的链表——先在每个节点后面插入一个拷贝节点,然后复制random指针指向,最后拆分成两条链表,巧妙避免哈希表。
每个节点除了 next 还有一个 random 指向链表中任意节点。方法一:哈希表存 原节点→新节点 的映射,两次遍历(第一次建节点,第二次连 next和random)。方法二(巧妙):在每个原节点后面插入它的副本 A→A'→B→B'→C→C',然后 A'.random = A.random.next(原节点的random的下一个就是副本),最后拆分成两个链表。方法二空间 O(1)。
1/* 复制带随机指针的链表(LeetCode 138) */2typedef struct Node { int val; struct Node *next, *random; } Node;3
4/* 方法: 交错插入法 O(n)时间 O(1)空间 */5Node* copyRandomList(Node* head) {6 if (!head) return NULL;7 // 1. 在每个节点后插入副本: A→A'→B→B'→C→C'8 for (Node *cur = head; cur; cur = cur->next->next) {9 Node *copy = (Node*)malloc(sizeof(Node)); // 动态分配节点10 copy->val = cur->val;11 copy->next = cur->next; // 修改指针指向12 cur->next = copy; // 修改指针指向13 }14 // 2. 设置random: A'.random = A.random.next15 for (Node *cur = head; cur; cur = cur->next->next)10 collapsed lines
16 cur->next->random = cur->random ? cur->random->next : NULL;17 // 3. 拆分两个链表18 Node *new_head = head->next; // 头节点的下一个19 for (Node *cur = head; cur; cur = cur->next) {20 Node *copy = cur->next;21 cur->next = copy->next; // 修改指针指向22 copy->next = copy->next ? copy->next->next : NULL; // 修改指针指向23 }24 return new_head;25}Q34: 双向链表插入/删除?
🧠 秒懂: 双向链表每个节点有prev和next两个指针——像双向车道,前后都能走,插入删除更方便(知道节点就能O(1)操作),Linux内核大量使用。
双向链表每个节点有 prev 和 next 两个指针。优势:删除操作不需要遍历找前驱(单链表删除需要知道前一个节点)。插入需要修改4个指针,删除也是4个指针。双向链表是 LRU 缓存和 Linux 内核链表的基础数据结构。
1/* 在 node 之后插入 new_node */2void insert_after(ListNode2 *node, ListNode2 *new_node) {3 new_node->next = node->next; // 修改指针指向4 new_node->prev = node;5 if (node->next) node->next->prev = new_node;6 node->next = new_node; // 修改指针指向7}8/* 删除 node(已知位置) */9void delete_node(ListNode2 *node) {10 if (node->prev) node->prev->next = node->next; // 修改指针指向11 if (node->next) node->next->prev = node->prev;12 free(node);13}Q35: 循环链表(约瑟夫环)?
🧠 秒懂: 约瑟夫环就像’丢手帕’游戏——n个人围成圈每次数m个淘汰一个,用循环链表模拟,或者用递推公式f(n,m)=(f(n-1,m)+m)%n直接算。
n 个人围成圈,从第 1 个人开始报数,报到 m 的人出列,然后从下一个人继续。求最后剩下谁。用循环链表模拟:建立 n 个节点的环形链表,每次走 m-1 步删除当前节点。也可以用数学公式递推:f(1)=0, f(n)=(f(n-1)+m)%n,结果+1就是编号。
1int josephus(int n, int m) {2 int result = 0; /* f(1) = 0 */3 for (int i = 2; i <= n; i++) {4 result = (result + m) % i;5 }6 return result + 1; /* 转为1-based编号 */7}Q36: 跳表(Skip List)?
🧠 秒懂: 跳表就像给链表加了’快车道’——多层索引,查找时从最高层开始往右走,走不动就下一层,平均O(logn)查找,Redis的有序集合就用跳表。
跳表是对有序链表的加速结构。普通有序链表查找 O(n)太慢。跳表建立多层”快车道”——底层包含所有元素,每上一层约保留 1/2 的元素(随机决定)。查找时从顶层开始,先向右走到不能走(下一个比目标大),再往下走一层继续向右,逐层缩小范围。
1Level 3: 1 ───────────────→ 9 ───→ nil2Level 2: 1 ───→ 4 ────────→ 9 ───→ nil3Level 1: 1 → 3 → 4 → 5 → 7 → 9 → nil查找平均 O(logn),插入/删除也是 O(logn)。Redis 的有序集合(ZSET)底层就用跳表——比平衡树实现简单、范围查询方便。
Q37: 有序链表去重?
🧠 秒懂: 有序链表去重就像排队时踢掉重复的人——当前和下一个相同就删下一个,一次遍历O(n)搞定,注意要释放删除节点的内存。
遍历链表,如果当前节点的值和下一个节点相同,就删除下一个节点:
1void remove_duplicates(Node *head) {2 Node *curr = head;3 while (curr && curr->next) {4 if (curr->val == curr->next->val) {5 Node *dup = curr->next;6 curr->next = dup->next; // 修改指针指向7 free(dup);8 } else {9 curr = curr->next;10 }11 }12}13/* [1,1,2,3,3] → [1,2,3] */时间 O(n),空间 O(1)。因为链表有序,相同元素必然相邻。
Q38: 无序链表去重?
🧠 秒懂: 无序链表去重需要用哈希表记录已出现的值——遍历链表,没见过的加入哈希表,见过的删除,O(n)时间O(n)空间。
无序链表中相同元素可能不相邻,需要用**哈希表(或数组)**记录已出现的值:
1void remove_dup_unsorted(Node *head) {2 int seen[10000] = {0}; /* 简单用数组做哈希 */3 Node *prev = NULL, *curr = head;4 while (curr) {5 if (seen[curr->val]) {6 prev->next = curr->next; // 修改指针指向7 free(curr);8 curr = prev->next;9 } else {10 seen[curr->val] = 1;11 prev = curr;12 curr = curr->next;13 }14 }15}时间 O(n),空间 O(n)。不用额外空间的话只能双重循环 O(n²)。
Q39: 分隔链表(按值分区)?
🧠 秒懂: 分隔链表就像根据身高分成两队——小于x的站左边,大于等于x的站右边,最后接起来,用两个虚拟头节点分别收集再连接。
给定值 x,把链表分成”所有小于 x 的节点在前、大于等于 x 的在后”(保持原序)。方法:创建两个虚拟链表 less 和 greater,遍历原链表把每个节点挂到对应链表上,最后拼接:
1Node *partition(Node *head, int x) {2 Node less_head = {0}, greater_head = {0};3 Node *less = &less_head, *greater = &greater_head;4 while (head) {5 if (head->val < x) { less->next = head; less = less->next; } // 修改指针指向6 else { greater->next = head; greater = greater->next; } // 修改指针指向7 head = head->next; // 头节点的下一个8 }9 greater->next = NULL; // 修改指针指向10 less->next = greater_head.next; // 修改指针指向11 return less_head.next;12}Q40: 旋转链表(右移k位)
🧠 秒懂: 旋转链表就像把项链断开重接——先连成环,然后在正确位置断开,关键是k要对长度取模,避免转了整圈白忙活。
先遍历得到长度 n 和尾节点,k %= n,把尾节点接到头部形成环,然后在第 n-k 个节点处断开。例如 [1,2,3,4,5] 右移 2 → [4,5,1,2,3]。
平衡二叉树(AVL 树):每个节点左右子树高度差不超过 1。插入/删除后如果失衡,通过旋转(左旋/右旋/左右旋/右左旋)恢复平衡。查找/插入/删除都是 O(log n)。比普通 BST 保证了最坏情况性能。缺点:频繁插入删除时旋转操作多。红黑树放宽了平衡条件(最长路径不超过最短的 2 倍),旋转更少。
1/* AVL 右旋 */2Node* rightRotate(Node* y) {3 Node* x = y->left;4 y->left = x->right; // 指向左子树5 x->right = y; // 指向右子树6 y->height = 1 + max(height(y->left), height(y->right));7 x->height = 1 + max(height(x->left), height(x->right));8 return x;9}Q41: 删除所有等于 val 的节点?
🧠 秒懂: 删除所有等于val的节点要用虚拟头节点——因为头节点也可能要删,用prev和curr双指针遍历,curr等于val就跳过。
用哨兵头节点(dummy),遍历时检查 curr->next->val,命中则跳过并 free。哨兵头节点简化了”删除头节点”的特殊情况处理。
红黑树规则:(1) 节点是红或黑 (2) 根是黑 (3) 叶子(NIL)是黑 (4) 红节点的子节点是黑(不能连续红) (5) 从任一节点到叶子的路径包含相同数量的黑节点。插入新节点默认红色,通过变色和旋转修复违规。Linux 内核大量使用(CFS 调度器/内存管理/epoll)。
1红黑树节点颜色规则:2 B(10)3 / \4 R(5) R(15)5 / \ / \6 B(3) B(7) B(12) B(20)7规则: 红节点的子节点必须是黑色Q42~Q43: 链表表示的两数相加:逆序(LeetCode 2)直接逐位相加进位;正序需要先反转两个链表再相加再反转回来(或用栈辅助)。
Q42: 扁平化多级双向链表?
🧠 秒懂: 扁平化多级双向链表就像把嵌套文件夹展开成一维列表——遇到child就递归或用栈展开,把child链表插入next位置。
节点有 next 和 child 两个指针。DFS 处理——遇到 child 就递归展开并插入当前位置之后,原 next 接到展开后的尾部。
B 树:每个节点可以有多个键和子节点(阶为 m 则每个节点最多 m-1 个键和 m 个子节点)。所有叶子在同一层。查找从根开始,在节点内二分查找确定进入哪个子节点。适合磁盘存储——每个节点对应一个磁盘块,减少 IO 次数。B+ 树的数据只在叶子节点,叶子节点用链表连接——范围查询高效。
Q43: 链表快速排序?
🧠 秒懂: 链表快速排序——选头节点做pivot,遍历一次分成小于和大于两部分,递归排序后连接,比数组快排少了swap但多了分组操作。
选头节点做 pivot,遍历分成 <pivot / =pivot / >pivot 三个子链表,递归排序后拼接。不过链表排序更推荐归并排序(Q118已介绍)。
字典树(Trie):每个节点代表一个字符,从根到某节点的路径组成一个前缀。用于字符串前缀匹配(自动补全/拼写检查/IP 路由最长匹配)。空间消耗大(每个节点 26 个子指针)——用压缩 Trie(Radix Tree)合并只有一个子节点的路径。Linux 内核页表管理用 Radix Tree。
Q44: 两两交换相邻节点?
🧠 秒懂: 两两交换就像舞伴互换位置——每次取两个节点交换顺序,用prev指针处理连接关系,注意处理好前后指针更新。
[1,2,3,4] → [2,1,4,3]。用哨兵头节点,每次操作两个节点:prev→A→B→C 变成 prev→B→A→C,然后 prev 跳到 A 继续。
图的存储:(1) 邻接矩阵——二维数组 G[i][j]=权重,查询边 O(1),空间 O(V^2)(稀疏图浪费) (2) 邻接表——每个顶点一个链表(或数组)存储相邻顶点,空间 O(V+E),遍历邻居高效。稠密图用矩阵,稀疏图用邻接表。嵌入式中图不常用,但路由/状态机可抽象为图。
Q45: 反转链表的 m 到 n 区间?
🧠 秒懂: 反转m到n区间——先走到m位置,然后用头插法反转m到n这段,最后把断开的三段重新接上,一次遍历O(n)。
先走到第 m-1 个节点(反转段的前驱),对 m~n 段做反转(Q22的方法),再把反转后的段接回原链表。
DFS(深度优先搜索):用栈(递归)实现,沿一条路径走到底再回溯。用于:拓扑排序/连通分量/路径查找。BFS(广度优先搜索):用队列实现,逐层扩展。用于:最短路径(无权图)/层序遍历。时间复杂度都是 O(V+E)。DFS 空间 O(V)(递归深度),BFS 空间 O(V)(队列大小)。
1/* BFS 模板 */2queue<int> q;3q.push(start); visited[start] = true; // 访问标记数组4while (!q.empty()) {5 int u = q.front(); q.pop(); // 队头指针6 for (int v : adj[u])7 if (!visited[v]) { visited[v] = true; q.push(v); } // 访问标记数组8}Q46: 奇偶链表分离?
🧠 秒懂: 奇偶链表分离——把奇数位和偶数位的节点分别串成两条链表,最后奇链尾接偶链头,一次遍历O(n)空间O(1)。
把下标为奇数的节点放前面、偶数的放后面。维护 odd 和 even 两个指针各自串联奇偶节点,最后 odd 尾接 even 头。
Dijkstra 算法求单源最短路径(无负权边)。贪心策略:每次选距离最小的未访问节点,更新其邻居的最短距离。用优先队列(最小堆)优化后时间 O((V+E)logV)。不能处理负权边——用 Bellman-Ford(O(VE))。嵌入式中用于网络路由(OSPF 协议)。
Q47: 重排链表?
🧠 秒懂: 重排链表L0→Ln→L1→Ln-1→…——先找中点切成两半,反转后半部分,然后交替合并前后两半,三步走经典操作。
L0→Ln→L1→Ln-1→… 三步:(1) 找中点(快慢指针) (2) 反转后半部分 (3) 交叉合并前后两半。
最小生成树(MST):连通无向图中权值和最小的树。Kruskal 算法:边排序后贪心选最小边(不成环——用并查集判断),O(ElogE)。Prim 算法:从一个顶点开始,每次选连接已选和未选顶点的最小边,用最小堆 O(ElogV)。稀疏图 Kruskal 好,稠密图 Prim 好。
Q48: 链表在嵌入式中的应用?
🧠 秒懂: 链表在嵌入式中很常见——RTOS任务链表、内存池空闲块链表、消息队列,嵌入式用静态数组模拟链表可以避免动态内存分配的碎片问题。
Linux 内核用 list_head 侵入式双向循环链表——链表节点嵌入在数据结构体中(而不是数据指针存在链表节点里),用 container_of 宏从节点反推数据。FreeRTOS 就绪列表、延迟列表也是链表实现。
拓扑排序:对 DAG(有向无环图)排列节点使所有边从前指向后。Kahn 算法:入度为 0 的入队,每次取一个移除其出边(减邻居入度),入度变 0 的入队。如果排出的节点数 < 总节点数说明有环。用于:任务依赖调度/编译顺序/Makefile 依赖分析。时间 O(V+E)。
三、栈与队列(Q49~Q64)
Q49: 栈的特性和实现?
🧠 秒懂: 栈就像一摞盘子——只能从顶部放入(push)和取出(pop),后进先出(LIFO),用数组或链表都能实现,函数调用就靠栈来管理。
1/* 数组实现 */2#define MAX_SIZE 1003typedef struct { // 结构体定义4 int data[MAX_SIZE];5 int top;6} Stack;7
8void stack_init(Stack *s) { s->top = -1; }9int stack_empty(Stack *s) { return s->top == -1; }10int stack_full(Stack *s) { return s->top == MAX_SIZE - 1; }11
12void stack_push(Stack *s, int val) {13 if (!stack_full(s))14 s->data[++s->top] = val;15}10 collapsed lines
16int stack_pop(Stack *s) {17 if (!stack_empty(s))18 return s->data[s->top--]; // 出栈19 return -1;20}21int stack_peek(Stack *s) {22 if (!stack_empty(s))23 return s->data[s->top];24 return -1;25}十大排序算法复杂度速查表:
| 排序算法 | 平均O | 最好O | 最坏O | 空间O | 稳定性 | 嵌入式适用 |
|---|---|---|---|---|---|---|
| 冒泡排序 | n^2 | n | n^2 | 1 | 稳定 | 小数据量教学 |
| 选择排序 | n^2 | n^2 | n^2 | 1 | 不稳定 | 交换次数少 |
| 插入排序 | n^2 | n | n^2 | 1 | 稳定 | 近似有序/小数据 |
| 希尔排序 | n^1.3 | n | n^2 | 1 | 不稳定 | 中等数据量 |
| 归并排序 | nlogn | nlogn | nlogn | n | 稳定 | 外部排序 |
| 快速排序 | nlogn | nlogn | n^2 | logn | 不稳定 | 通用首选 |
| 堆排序 | nlogn | nlogn | nlogn | 1 | 不稳定 | 内存受限 |
| 计数排序 | n+k | n+k | n+k | k | 稳定 | 整数小范围 |
| 桶排序 | n+k | n+k | n^2 | n+k | 稳定 | 均匀分布浮点 |
| 基数排序 | n*k | n*k | n*k | n+k | 稳定 | 定长整数/字符串 |
嵌入式面试重点:堆排序O(1)空间+O(nlogn)时间,最适合内存受限的MCU场景。快排在平均情况最快但最坏O(n^2)且不稳定。如果需要稳定排序且内存充足选归并。
Q50: 队列的实现(环形数组)?
🧠 秒懂: 环形队列就像传送带——用数组+头尾指针实现,尾追上头就满了,空间循环利用不浪费,嵌入式里UART缓冲区、RTOS消息队列都用这个。
基本数据结构的实现:
1/* 队列的实现(环形数组)? - 示例实现 */2#define QSIZE 1003typedef struct { // 结构体定义4 int data[QSIZE];5 int head, tail, count;6} Queue;7
8void queue_init(Queue *q) { q->head = q->tail = q->count = 0; }9int queue_empty(Queue *q) { return q->count == 0; }10int queue_full(Queue *q) { return q->count == QSIZE; }11
12void enqueue(Queue *q, int val) {13 if (!queue_full(q)) {14 q->data[q->tail] = val;15 q->tail = (q->tail + 1) % QSIZE;12 collapsed lines
16 q->count++;17 }18}19int dequeue(Queue *q) {20 if (!queue_empty(q)) {21 int val = q->data[q->head];22 q->head = (q->head + 1) % QSIZE;23 q->count--;24 return val;25 }26 return -1;27}Q51: 有效括号匹配?
🧠 秒懂: 括号匹配用栈天然适合——遇到左括号压栈,遇到右括号弹栈检查是否配对,最后栈为空则全部匹配,是栈的经典应用。
括号匹配是栈的经典应用,面试手撕代码高频题:
1/* 有效括号匹配? - 示例实现 */2int isValid(const char *s) {3 Stack st;4 stack_init(&st);5 while (*s) {6 if (*s == '(' || *s == '[' || *s == '{') {7 stack_push(&st, *s);8 } else {9 if (stack_empty(&st)) return 0;10 char top = stack_pop(&st);11 if ((*s == ')' && top != '(') ||12 (*s == ']' && top != '[') ||13 (*s == '}' && top != '{'))14 return 0;15 }4 collapsed lines
16 s++;17 }18 return stack_empty(&st);19}★ 常用数据结构选型(嵌入式场景):
| 数据结构 | 查找 | 插入/删除 | 内存 | 嵌入式应用 |
|---|---|---|---|---|
| 数组 | O(1)随机/O(n)查找 | O(n) | 连续,紧凑 | 查找表/配置参数 |
| 链表 | O(n) | O(1) | 离散,有指针开销 | 动态队列/内存池 |
| 栈 | O(1)栈顶 | O(1) | 小 | 函数调用/表达式解析 |
| 队列 | O(1)队头 | O(1) | 小 | 消息队列/环形缓冲区 |
| 哈希表 | O(1)平均 | O(1)平均 | 有浪费 | 缓存/符号表 |
| 二叉搜索树 | O(logn) | O(logn) | 指针开销 | 有序数据管理 |
| 堆 | O(1)最值 | O(logn) | 数组实现紧凑 | 优先级队列/定时器管理 |
| 红黑树 | O(logn) | O(logn) | 平衡保证 | Linux内核(调度/内存) |
★ 排序算法对比(面试必背):
| 算法 | 平均 | 最坏 | 最好 | 空间 | 稳定 | 嵌入式适用 |
|---|---|---|---|---|---|---|
| 冒泡 | O(n²) | O(n²) | O(n) | O(1) | ✅ | 超小数据 |
| 选择 | O(n²) | O(n²) | O(n²) | O(1) | ❌ | 数据交换少时 |
| 插入 | O(n²) | O(n²) | O(n) | O(1) | ✅ | 近有序小数据★ |
| 希尔 | O(n^1.3) | O(n²) | O(n) | O(1) | ❌ | 中等数据 |
| 快排 | O(nlogn) | O(n²) | O(nlogn) | O(logn) | ❌ | 通用首选★ |
| 归并 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | ✅ | 外排序/链表排序 |
| 堆排 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | ❌ | Top-K问题★ |
| 计数 | O(n+k) | O(n+k) | O(n+k) | O(k) | ✅ | 范围已知整数 |
嵌入式建议: MCU上内存有限,优先用插入排序(数据量小)或快排(数据量大)。堆排序适合寻找前N个最大/最小值(只维护大小为N的堆)。归并排序需要O(n)额外空间,MCU上慎用。
Q52: 用两个栈实现队列?
🧠 秒懂: 两个栈实现队列——栈A负责入队(push到A),栈B负责出队(B空时把A全部倒入B再pop),就像两个杯子倒水实现先进先出。
经典面试题(栈后进先出→队列先进先出):
1typedef struct {2 Stack in_stack; // 入队用3 Stack out_stack; // 出队用4} MyQueue;5
6void queue_push(MyQueue *q, int val) {7 stack_push(&q->in_stack, val);8}9
10int queue_pop(MyQueue *q) {11 if (stack_empty(&q->out_stack)) {12 // out_stack空时,把in_stack全部倒过来13 while (!stack_empty(&q->in_stack)) {14 stack_push(&q->out_stack, stack_pop(&q->in_stack));15 }4 collapsed lines
16 }17 return stack_pop(&q->out_stack);18}19// 均摊时间复杂度: O(1)Q53: 单调栈的应用?
🧠 秒懂: 单调栈就像排队看身高——维护一个递增或递减的栈,能快速找到每个元素左/右第一个更大/更小的值,O(n)解决’下一个更大元素’问题。
单调栈解决”找下一个更大/更小元素”问题:
1// 题目: 每日温度,找到每个位置后面第一个更高温度的天数2void dailyTemperatures(int *T, int n, int *result) {3 int stack[n], top = -1;4 for (int i = 0; i < n; i++) {5 while (top >= 0 && T[i] > T[stack[top]]) {6 int idx = stack[top--];7 result[idx] = i - idx;8 }9 stack[++top] = i; // 存下标10 }11}12// 时间O(n),每个元素最多入栈出栈各一次Q54: 最小栈(O(1)获取最小值)?
🧠 秒懂: 最小栈用一个辅助栈同步记录当前最小值——每次push时把当前最小值也压入辅助栈,pop时同步弹出,getMin就是辅助栈顶,O(1)。
1typedef struct { // 结构体定义2 int data[MAX_SIZE];3 int min_stack[MAX_SIZE]; // 辅助栈记录最小值4 int top;5 int min_top;6} MinStack;7
8void push(MinStack *s, int val) {9 s->data[++s->top] = val;10 if (s->min_top < 0 || val <= s->min_stack[s->min_top])11 s->min_stack[++s->min_top] = val;12}13
14void pop(MinStack *s) {15 if (s->data[s->top] == s->min_stack[s->min_top])5 collapsed lines
16 s->min_top--;17 s->top--;18}19
20int getMin(MinStack *s) { return s->min_stack[s->min_top]; }Q55: 优先级队列(堆实现)?
🧠 秒懂: 优先级队列用堆实现——就像急诊室按严重程度排序而不是先来后到,插入和取最值都是O(logn),RTOS调度器选最高优先级任务就靠这个。
1typedef struct { // 结构体定义2 int data[MAX_SIZE];3 int size;4} MinHeap;5
6void heap_push(MinHeap *h, int val) {7 h->data[h->size] = val;8 // 上浮9 int i = h->size++;10 while (i > 0) {11 int parent = (i - 1) / 2;12 if (h->data[i] < h->data[parent]) {13 int tmp = h->data[i]; h->data[i] = h->data[parent]; h->data[parent] = tmp;14 i = parent;15 } else break;18 collapsed lines
16 }17}18
19int heap_pop(MinHeap *h) {20 int val = h->data[0];21 h->data[0] = h->data[--h->size];22 // 下沉23 int i = 0;24 while (2*i+1 < h->size) {25 int child = 2*i+1;26 if (child+1 < h->size && h->data[child+1] < h->data[child]) child++;27 if (h->data[i] > h->data[child]) {28 int tmp = h->data[i]; h->data[i] = h->data[child]; h->data[child] = tmp;29 i = child;30 } else break;31 }32 return val;33}Q56: 栈在表达式求值中的应用?
🧠 秒懂: 表达式求值用两个栈——一个放数字一个放运算符,遇到运算符比较优先级决定是压栈还是先算,遇到右括号把到左括号之间的都算完。
中缀表达式求值(两个栈:数栈+运算符栈):
1// 简化版: 只支持 + - * / ()2int calculate(const char *s) {3 int num_stack[100], op_stack[100];4 int nt = -1, ot = -1;5
6 auto int priority(char c) { return (c == '+' || c == '-') ? 1 : 2; }7 auto void compute() {8 int b = num_stack[nt--], a = num_stack[nt--];9 char op = op_stack[ot--];10 switch(op) {11 case '+': num_stack[++nt] = a+b; break;12 case '-': num_stack[++nt] = a-b; break;13 case '*': num_stack[++nt] = a*b; break;14 case '/': num_stack[++nt] = a/b; break;15 }4 collapsed lines
16 }17 // ... 遍历s, 数字入num_stack, 运算符按优先级处理18 return num_stack[0];19}Q57: 双端队列(Deque)的应用?
🧠 秒懂: 双端队列两头都能进出——既能当栈又能当队列用,滑动窗口最大值问题就用单调双端队列,O(n)搞定原本O(nk)的问题。
1// 滑动窗口最大值(单调双端队列)2void maxSlidingWindow(int *nums, int n, int k, int *result) {3 int deque[n], front = 0, rear = -1; // 存下标4 int ri = 0;5
6 for (int i = 0; i < n; i++) {7 // 移除超出窗口的元素8 while (front <= rear && deque[front] <= i - k)9 front++;10 // 保持递减(移除比当前小的)11 while (front <= rear && nums[deque[rear]] <= nums[i])12 rear--;13 deque[++rear] = i;14
15 if (i >= k - 1)3 collapsed lines
16 result[ri++] = nums[deque[front]];17 }18}Q58: 栈实现递归转非递归?
🧠 秒懂: 递归转非递归就是手动模拟系统栈——把递归参数压入自己的栈中循环处理,可以避免栈溢出风险,嵌入式中栈空间有限时必须会这招。
将递归DFS改为显式栈(嵌入式避免栈溢出):
1// 递归版二叉树前序遍历2void preorder_recursive(TreeNode *root) {3 if (!root) return;4 visit(root);5 preorder_recursive(root->left);6 preorder_recursive(root->right);7}8
9// 非递归版(显式栈)10void preorder_iterative(TreeNode *root) {11 TreeNode *stack[100];12 int top = -1;13 if (root) stack[++top] = root;14
15 while (top >= 0) {6 collapsed lines
16 TreeNode *node = stack[top--];17 visit(node);18 if (node->right) stack[++top] = node->right; // 右先入栈19 if (node->left) stack[++top] = node->left; // 左后入栈(先出)20 }21}Q59: 消息队列在RTOS中的实现?
🧠 秒懂: RTOS消息队列就像邮箱——任务间通过队列传递固定大小的消息,发送方往里放,接收方取出处理,带阻塞超时机制实现任务同步和数据传递。
嵌入式中消息队列的底层实现:
1typedef struct {2 uint8_t *buf;3 uint16_t item_size;4 uint16_t capacity;5 volatile uint16_t head;6 volatile uint16_t tail;7 volatile uint16_t count;8 // RTOS信号量9 SemaphoreHandle_t sem_space; // 可写空间10 SemaphoreHandle_t sem_data; // 可读数据11} MsgQueue;12
13int mq_send(MsgQueue *q, void *msg, uint32_t timeout) {14 if (xSemaphoreTake(q->sem_space, timeout) != pdTRUE)15 return -1; // 超时5 collapsed lines
16 memcpy(q->buf + q->head * q->item_size, msg, q->item_size);17 q->head = (q->head + 1) % q->capacity;18 xSemaphoreGive(q->sem_data); // 通知接收方19 return 0;20}Q60: 中缀转后缀表达式(逆波兰)?
🧠 秒懂: 中缀转后缀用运算符栈——数字直接输出,运算符按优先级入栈或弹出,后缀表达式计算更简单(无括号无优先级),计算器和编译器都用这个。
编译器/计算器内部常用:
1// "3+4*2" → "3 4 2 * +"2void infix_to_postfix(const char *infix, char *postfix) {3 char op_stack[100]; int top = -1;4 int pi = 0;5
6 for (int i = 0; infix[i]; i++) {7 if (isdigit(infix[i])) {8 postfix[pi++] = infix[i];9 } else if (infix[i] == '(') {10 op_stack[++top] = '(';11 } else if (infix[i] == ')') {12 while (top >= 0 && op_stack[top] != '(')13 postfix[pi++] = op_stack[top--];14 top--; // 弹出'('15 } else { // 运算符7 collapsed lines
16 while (top >= 0 && priority(op_stack[top]) >= priority(infix[i]))17 postfix[pi++] = op_stack[top--];18 op_stack[++top] = infix[i];19 }20 }21 while (top >= 0) postfix[pi++] = op_stack[top--];22}Q61: 栈和队列在BFS/DFS中的应用?
🧠 秒懂: BFS用队列保证层层推进(像水波纹扩散),DFS用栈实现深入到底再回溯(像走迷宫走到死胡同再返回),两者是图/树遍历的两大基本方法。
1DFS(深度优先): 用栈(或递归)2 → 沿一条路走到底再回溯3 → 适合: 路径搜索/拓扑排序/连通分量4
5BFS(广度优先): 用队列6 → 一层一层扩展7 → 适合: 最短路径/层次遍历/二分图判定8
9嵌入式场景:10 DFS: 状态机遍历/协议解析树11 BFS: 事件广播/网络拓扑发现Q62: 环形队列判满判空?
🧠 秒懂: 环形队列判满判空有三种方法——空一格法(tail+1==head是满)、计数法(size变量)、标志位法(flag区分),最常用的是空一格法。
环形队列常见的两种方式:
1// 方式1: 浪费一个位置(最常用)2#define QUEUE_SIZE 163typedef struct {4 int buf[QUEUE_SIZE];5 int head, tail;6} CircQueue;7// 空: head == tail8// 满: (tail + 1) % QUEUE_SIZE == head9// 长度: (tail - head + QUEUE_SIZE) % QUEUE_SIZE10
11// 方式2: 用count计数12typedef struct {13 int buf[QUEUE_SIZE];14 int head, count;15} CircQueue2;3 collapsed lines
16// 空: count == 017// 满: count == QUEUE_SIZE18// tail = (head + count) % QUEUE_SIZEQ63: 栈溢出检测(嵌入式)?
🧠 秒懂: 嵌入式栈溢出就像水杯满了——RTOS每个任务栈独立,溢出会覆盖相邻内存导致诡异bug,检测方法:栈末尾填魔数、MPU保护、FreeRTOS栈水位线。
嵌入式中栈溢出是常见崩溃原因:
1// FreeRTOS 栈溢出检测方法:2// 1. 任务创建时栈填充0xA5A5A5A53// 2. 检测栈底的magic值是否被覆盖4configCHECK_FOR_STACK_OVERFLOW = 2 // 在FreeRTOSConfig.h中5
6void vApplicationStackOverflowHook(TaskHandle_t task, char *name) {7 printf("Stack overflow in task: %s\n", name);8 while(1); // 停下来调试9}10
11// 裸机方案: MPU保护栈边界12// 在栈底设置MPU只读区域 → 写入时触发MemManage异常Q64: 无锁队列(Lock-Free)在嵌入式中的应用?
🧠 秒懂: 无锁队列用CAS原子操作代替互斥锁——生产者消费者都通过原子比较交换操作来移动指针,避免加锁开销和优先级反转,适合ISR和任务间通信。
中断和主循环之间的无锁通信:
1// 单生产者单消费者的无锁环形队列(ISR→主循环)2// 要点: head只有ISR写, tail只有主循环写3// 不需要锁! (前提: 32位对齐的读写是原子的)4
5typedef struct {6 volatile uint32_t head; // ISR写7 volatile uint32_t tail; // 主循环写8 uint8_t buf[256]; // 大小必须是2的幂9} LockFreeQueue;10
11// ISR中入队12void lfq_push(LockFreeQueue *q, uint8_t byte) {13 uint32_t next = (q->head + 1) & 0xFF;14 if (next != q->tail) {15 q->buf[q->head] = byte;11 collapsed lines
16 q->head = next;17 }18}19
20// 主循环出队21int lfq_pop(LockFreeQueue *q, uint8_t *byte) {22 if (q->head == q->tail) return 0;23 *byte = q->buf[q->tail];24 q->tail = (q->tail + 1) & 0xFF;25 return 1;26}四、树与堆(Q65~Q85)
Q65: 二叉树的基本概念?
🧠 秒懂: 二叉树——每个节点最多两个孩子(左和右),是最基本的树结构,像一个倒着长的树,从根到叶,面试中各种树问题都基于这个展开。
1术语:2 度: 节点的子节点数量(二叉树最大为2)3 深度: 根到该节点的路径长度(根=0或1看定义)4 高度: 该节点到最远叶子的路径长度5
6特殊二叉树:7 满二叉树: 每层节点都是满的8 完全二叉树: 除最后一层外都满,最后一层左对齐9 平衡二叉树: 任何节点左右子树高度差≤110
11性质:12 第i层最多 2^(i-1) 个节点13 深度为k的树最多 2^k - 1 个节点14 叶子数 = 度2节点数 + 1Q66: 二叉树遍历(前/中/后序)?
🧠 秒懂: 前中后序就是根在前面、中间还是后面访问——前序(根左右)用于复制树,中序(左根右)用于BST排序输出,后序(左右根)用于释放内存。
1typedef struct TreeNode {2 int val;3 struct TreeNode *left, *right;4} TreeNode;5
6// 前序: 根 → 左 → 右7void preorder(TreeNode *root) {8 if (!root) return;9 printf("%d ", root->val);10 preorder(root->left);11 preorder(root->right);12}13
14// 中序: 左 → 根 → 右 (BST中序遍历是有序的!)15void inorder(TreeNode *root) {13 collapsed lines
16 if (!root) return;17 inorder(root->left);18 printf("%d ", root->val);19 inorder(root->right);20}21
22// 后序: 左 → 右 → 根23void postorder(TreeNode *root) {24 if (!root) return;25 postorder(root->left);26 postorder(root->right);27 printf("%d ", root->val);28}💡 面试追问: 快排最坏情况是什么?怎么优化?和归并排序怎么选? 🔧 嵌入式建议: 嵌入式排序:数据量小(≤100)用插入排序(代码小);数据量大用堆排(原地/O(nlogn));qsort()是C标准库。
📊 常用排序算法对比表
| 算法 | 时间(平均) | 时间(最坏) | 空间 | 稳定性 | 嵌入式适用 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | ✅ | 小数据量 |
| 插入排序 | O(n²) | O(n²) | O(1) | ✅ | ⭐小/近有序 |
| 快速排序 | O(nlogn) | O(n²) | O(logn) | ❌ | ⭐通用首选 |
| 归并排序 | O(nlogn) | O(nlogn) | O(n) | ✅ | 需要稳定时 |
| 堆排序 | O(nlogn) | O(nlogn) | O(1) | ❌ | 大数据/TopK |
| 计数排序 | O(n+k) | O(n+k) | O(k) | ✅ | 范围小的整数 |
💡 嵌入式首选: 小数组用插入排序, 通用场景用快排, 需要O(1)空间用堆排
Q67: 层序遍历(BFS)?
🧠 秒懂: 层序遍历用队列——先把根入队,每次出队一个节点并把它的子节点入队,就像广播一层一层地向下传播,可以轻松求树的最大宽度和深度。
1void levelOrder(TreeNode *root) {2 if (!root) return;3 TreeNode *queue[1000];4 int front = 0, rear = 0;5 queue[rear++] = root;6
7 while (front < rear) {8 int size = rear - front; // 当前层节点数9 for (int i = 0; i < size; i++) {10 TreeNode *node = queue[front++];11 printf("%d ", node->val);12 if (node->left) queue[rear++] = node->left;13 if (node->right) queue[rear++] = node->right;14 }15 printf("\n"); // 每层换行2 collapsed lines
16 }17}Q68: 二叉搜索树(BST)的操作?
🧠 秒懂: BST左小右大——查找、插入都是O(logn)平均,删除最复杂(找后继替代),退化成链表就变O(n)了,所以才有AVL和红黑树来保持平衡。
1// 查找: O(logN)平均, O(N)最差2TreeNode* bst_search(TreeNode *root, int val) {3 while (root && root->val != val) {4 root = (val < root->val) ? root->left : root->right;5 }6 return root;7}8
9// 插入10TreeNode* bst_insert(TreeNode *root, int val) {11 if (!root) {12 TreeNode *node = malloc(sizeof(TreeNode));13 node->val = val; node->left = node->right = NULL;14 return node;15 }8 collapsed lines
16 if (val < root->val) root->left = bst_insert(root->left, val);17 else root->right = bst_insert(root->right, val);18 return root;19}20
21// 删除(最复杂的操作)22// 三种情况: 叶子/一个子节点/两个子节点23// 两个子节点: 用右子树最小值(后继)替换💡 面试追问: while(left<=right)和while(left<right)区别?mid怎么防溢出?二分查找的变体? 🔧 嵌入式建议: 嵌入式查表/校准数据查找用二分。注意:数据必须有序;嵌入式Flash中的查找表用二分比遍历快得多。
Q69: 堆排序?
🧠 秒懂: 堆排序用一棵’假装’的完全二叉树——在数组上用下标关系模拟父子节点,建堆O(n),每次取堆顶再调整O(logn),总共O(nlogn)且原地排序。
1void heapify(int *arr, int n, int i) {2 int largest = i;3 int left = 2*i + 1, right = 2*i + 2;4 if (left < n && arr[left] > arr[largest]) largest = left;5 if (right < n && arr[right] > arr[largest]) largest = right;6 if (largest != i) {7 int tmp = arr[i]; arr[i] = arr[largest]; arr[largest] = tmp;8 heapify(arr, n, largest);9 }10}11
12void heap_sort(int *arr, int n) {13 // 建堆: O(N)14 for (int i = n/2 - 1; i >= 0; i--)15 heapify(arr, n, i);7 collapsed lines
16 // 逐个取出最大值: O(NlogN)17 for (int i = n-1; i > 0; i--) {18 int tmp = arr[0]; arr[0] = arr[i]; arr[i] = tmp;19 heapify(arr, i, 0);20 }21}22// 时间: O(NlogN), 空间: O(1), 不稳定Q70: TopK问题(堆的应用)?
🧠 秒懂: TopK问题用小顶堆——维护一个大小为K的小顶堆,新元素比堆顶大就替换并调整,最后堆里就是最大的K个元素,O(nlogK)比全排序快。
1// 从N个数中找最大的K个: 用小顶堆(大小K)2// 如果新数>堆顶 → 替换堆顶并下沉3// 时间: O(NlogK), 比排序O(NlogN)更优当K<<N4
5void topK(int *nums, int n, int k, int *result) {6 MinHeap heap;7 heap_init(&heap);8
9 for (int i = 0; i < n; i++) {10 if (heap.size < k) {11 heap_push(&heap, nums[i]);12 } else if (nums[i] > heap_top(&heap)) {13 heap_pop(&heap);14 heap_push(&heap, nums[i]);15 }5 collapsed lines
16 }17 // 堆中就是最大的K个数18 for (int i = 0; i < k; i++)19 result[i] = heap_pop(&heap);20}Q71: AVL树和红黑树的区别?
🧠 秒懂: AVL树严格平衡(左右子树高度差≤1)查找快但插入删除旋转多,红黑树近似平衡(用颜色规则)旋转少——Java的TreeMap、Linux的CFS调度器用红黑树。
| 特性 | AVL树 | 红黑树 |
|---|---|---|
| 平衡条件 | 高度差≤1(严格) | 黑色节点高度差≤1(松弛) |
| 查找速度 | 略快(更平衡) | 略慢 |
| 插入/删除 | 旋转次数多(O(logN)) | 旋转最多2~3次 |
| 适用场景 | 查找多的场景 | 插入删除频繁 |
| 典型使用 | 数据库索引 | Linux进程调度/C++ map |
Q72: 前缀树(Trie)?
🧠 秒懂: Trie就像字典的目录结构——每个节点代表一个字符,从根到某节点的路径就是一个前缀,适合前缀匹配和自动补全,空间换时间。
用于字符串前缀匹配(如自动补全/路由表):
1/* 前缀树(Trie)? - 示例实现 */2#define ALPHABET_SIZE 263
4typedef struct TrieNode { // 结构体定义5 struct TrieNode *children[ALPHABET_SIZE];6 int is_end;7} TrieNode;8
9void trie_insert(TrieNode *root, const char *word) {10 TrieNode *cur = root;11 while (*word) {12 int idx = *word - 'a';13 if (!cur->children[idx]) {14 cur->children[idx] = calloc(1, sizeof(TrieNode)); // 动态分配内存15 }16 collapsed lines
16 cur = cur->children[idx];17 word++;18 }19 cur->is_end = 1;20}21
22int trie_search(TrieNode *root, const char *word) {23 TrieNode *cur = root;24 while (*word) {25 int idx = *word - 'a';26 if (!cur->children[idx]) return 0;27 cur = cur->children[idx];28 word++;29 }30 return cur->is_end;31}Q73: 二叉树的最近公共祖先(LCA)?
🧠 秒懂: LCA就是两个节点’回家路上’最先碰到的共同祖先——BST中利用大小关系查找,普通二叉树用递归从两头向上找交汇点。
1TreeNode* lowestCommonAncestor(TreeNode *root, TreeNode *p, TreeNode *q) {2 if (!root || root == p || root == q) return root;3 TreeNode *left = lowestCommonAncestor(root->left, p, q);4 TreeNode *right = lowestCommonAncestor(root->right, p, q);5 if (left && right) return root; // p和q分别在左右子树6 return left ? left : right;7}8// 时间O(N), 空间O(H)(递归栈)Q74: 二叉树的序列化和反序列化?
🧠 秒懂: 序列化就是把树变成字符串(存储/传输),反序列化是从字符串重建树——通常用前序/层序遍历加null标记,面试常考。
1// 前序遍历序列化(NULL用#表示)2void serialize(TreeNode *root, char *buf, int *pos) {3 if (!root) { buf[(*pos)++] = '#'; buf[(*pos)++] = ','; return; }4 *pos += sprintf(buf + *pos, "%d,", root->val);5 serialize(root->left, buf, pos);6 serialize(root->right, buf, pos);7}8
9// 反序列化10TreeNode* deserialize(char **ptr) {11 if (**ptr == '#') { (*ptr) += 2; return NULL; }12 int val = 0, sign = 1;13 if (**ptr == '-') { sign = -1; (*ptr)++; }14 while (**ptr != ',') { val = val * 10 + (**ptr - '0'); (*ptr)++; }15 (*ptr)++; // skip ','6 collapsed lines
16 TreeNode *node = malloc(sizeof(TreeNode));17 node->val = sign * val;18 node->left = deserialize(ptr);19 node->right = deserialize(ptr);20 return node;21}Q75: 完全二叉树的数组存储?
🧠 秒懂: 完全二叉树用数组存——节点i的左孩子在2i+1,右孩子在2i+2,父亲在(i-1)/2,不浪费空间不用指针,堆就是这么存的。
堆的底层实现方式:
1// 完全二叉树可以用数组高效存储(无需指针)2// 下标从0开始:3// 父节点: (i-1)/24// 左孩子: 2*i+15// 右孩子: 2*i+26
7// 应用: 堆、定时器管理(最小堆)、优先级调度8// 嵌入式优势: 无动态内存分配,缓存友好9
10int tree[MAX_NODES];11int parent(int i) { return (i - 1) / 2; }12int left(int i) { return 2 * i + 1; }13int right(int i) { return 2 * i + 2; }Q76: 判断平衡二叉树?
🧠 秒懂: 判断平衡二叉树——递归求每个节点的左右子树高度差,只要有一个节点高度差>1就不平衡,自底向上O(n)比自顶向下O(n²)高效。
1int height(TreeNode *root) {2 if (!root) return 0;3 int lh = height(root->left);4 if (lh == -1) return -1;5 int rh = height(root->right);6 if (rh == -1) return -1;7 if (abs(lh - rh) > 1) return -1; // 不平衡8 return (lh > rh ? lh : rh) + 1;9}10
11int isBalanced(TreeNode *root) {12 return height(root) != -1;13}14// 时间O(N), 一次遍历(自底向上)Q77: 哈夫曼树(最优前缀编码)?
🧠 秒懂: 哈夫曼树按频率建——频率低的在深层(长编码),频率高的在浅层(短编码),保证总编码长度最短,数据压缩的基础。
数据压缩的基础(嵌入式资源受限时有用):
1构建方法:2 1. 统计字符频率3 2. 将所有字符作为叶子节点放入最小堆4 3. 每次取出两个最小频率节点合并5 4. 重复直到只剩一个根6
7例: A:5, B:9, C:12, D:13, E:16, F:458 编码: F=0, C=100, D=101, A=1100, B=1101, E=1119 短编码给高频字符 → 总编码长度最短10
11嵌入式应用: 固件压缩/传输数据压缩Q78: B树和B+树?
🧠 秒懂: B树每个节点能存多个key——像一本书的多级目录,减少磁盘IO次数。B+树数据全在叶子节点且叶子串起来,更适合数据库范围查询。
数据库索引和文件系统常用:
1B树(m阶):2 - 每个节点最多m个子节点, m-1个关键字3 - 所有叶子在同一层4 - 查找可能在任何层结束5
6B+树:7 - 数据只在叶子节点8 - 叶子节点形成链表(范围查询高效)9 - 内部节点只存索引(更多分支→更矮)10
11嵌入式应用:12 - Flash文件系统(LittleFS用B树变体)13 - 大容量数据索引(SD卡/eMMC)Q79: 线段树(区间查询)?
🧠 秒懂: 线段树把区间问题拆分到树节点上——每个节点存一段区间的信息(和/最大值等),查询和修改都是O(logn),适合频繁区间查询的场景。
高效进行区间操作的数据结构:
1int tree[4*MAX_N]; // 线段树数组(4倍空间)2
3// 建树4void build(int *arr, int node, int start, int end) {5 if (start == end) { tree[node] = arr[start]; return; }6 int mid = (start + end) / 2;7 build(arr, 2*node, start, mid);8 build(arr, 2*node+1, mid+1, end);9 tree[node] = tree[2*node] + tree[2*node+1]; // 维护区间和10}11
12// 区间查询[l,r]的和: O(logN)13int query(int node, int start, int end, int l, int r) {14 if (r < start || end < l) return 0;15 if (l <= start && end <= r) return tree[node];4 collapsed lines
16 int mid = (start + end) / 2;17 return query(2*node, start, mid, l, r) +18 query(2*node+1, mid+1, end, l, r);19}Q80: 并查集(Union-Find)?
🧠 秒懂: 并查集就像找族长——合并时小家族挂到大家族下面,查找时路径压缩(直接指向族长),判断两人是否同族近似O(1),用于连通性问题。
解决连通性问题的高效结构:
1int parent[MAX_N];2int rank_arr[MAX_N];3
4void uf_init(int n) {5 for (int i = 0; i < n; i++) { parent[i] = i; rank_arr[i] = 0; }6}7
8int uf_find(int x) { // 路径压缩9 if (parent[x] != x) parent[x] = uf_find(parent[x]);10 return parent[x];11}12
13void uf_union(int x, int y) { // 按秩合并14 int rx = uf_find(x), ry = uf_find(y);15 if (rx == ry) return;5 collapsed lines
16 if (rank_arr[rx] < rank_arr[ry]) parent[rx] = ry;17 else if (rank_arr[rx] > rank_arr[ry]) parent[ry] = rx;18 else { parent[ry] = rx; rank_arr[rx]++; }19}20// 几乎O(1)的时间复杂度(阿克曼函数逆)Q81: 二叉树的直径?
🧠 秒懂: 二叉树直径是最长路径的边数——不一定经过根节点,递归求每个节点的左深度+右深度取最大值就是答案。
1int diameter = 0;2int depth(TreeNode *root) {3 if (!root) return 0;4 int l = depth(root->left);5 int r = depth(root->right);6 diameter = (l + r > diameter) ? l + r : diameter;7 return (l > r ? l : r) + 1;8}9// diameter即为二叉树直径(最长路径经过的节点数-1)Q82: 内存池的堆管理?
🧠 秒懂: 内存池用堆管理空闲块——把大内存切成固定大小的块用空闲链表管理,分配O(1),避免碎片化,嵌入式中比malloc+free可靠得多。
嵌入式中的固定大小内存池(避免碎片):
1#define BLOCK_SIZE 322#define POOL_SIZE 643
4typedef struct { // 结构体定义5 uint8_t pool[POOL_SIZE][BLOCK_SIZE];6 uint8_t free_list[POOL_SIZE]; // 位图或链表索引7 int free_count;8} MemPool;9
10void* pool_alloc(MemPool *mp) {11 if (mp->free_count == 0) return NULL;12 for (int i = 0; i < POOL_SIZE; i++) {13 if (mp->free_list[i]) {14 mp->free_list[i] = 0;15 mp->free_count--;11 collapsed lines
16 return mp->pool[i];17 }18 }19 return NULL;20}21
22void pool_free(MemPool *mp, void *ptr) {23 int idx = ((uint8_t*)ptr - (uint8_t*)mp->pool) / BLOCK_SIZE;24 mp->free_list[idx] = 1;25 mp->free_count++;26}Q83: 字典树在网络路由中的应用?
🧠 秒懂: 字典树在路由中做最长前缀匹配——IP地址按位插入Trie,查找时尽可能走深,最后匹配到的节点对应的路由就是目标。
IP路由表的最长前缀匹配:
1路由表(二进制前缀):2 192.168.1.0/24 → 接口13 192.168.0.0/16 → 接口24 0.0.0.0/0 → 默认网关5
6用Trie(按位):7 从IP高位到低位查找8 每次匹配到一个路由就记录(可能更长前缀继续匹配)9 最后返回最长匹配的路由10
11嵌入式场景: lwIP的路由表查找Q84: 树的Morris遍历(O(1)空间)?
🧠 秒懂: Morris遍历利用叶子节点的空指针——临时连成线索回到祖先节点,省掉了栈或递归的O(n)空间,但会临时修改树结构(遍历完恢复)。
不用栈/递归实现中序遍历(空间O(1)):
1void morris_inorder(TreeNode *root) {2 TreeNode *cur = root;3 while (cur) {4 if (!cur->left) {5 printf("%d ", cur->val);6 cur = cur->right;7 } else {8 // 找前驱(左子树最右节点)9 TreeNode *pred = cur->left;10 while (pred->right && pred->right != cur)11 pred = pred->right;12
13 if (!pred->right) {14 pred->right = cur; // 建线索15 cur = cur->left;9 collapsed lines
16 } else {17 pred->right = NULL; // 恢复18 printf("%d ", cur->val);19 cur = cur->right;20 }21 }22 }23}24// 适合嵌入式: 栈空间极度受限时Q85: 堆在定时器管理中的应用?
🧠 秒懂: 定时器用最小堆管理——堆顶永远是最快到期的定时器,检查时只看堆顶O(1),插入和删除O(logn),比链表遍历O(n)高效得多。
1// 最小堆管理多个定时器(最近超时的在堆顶)2typedef struct {3 uint32_t expire_tick; // 超时时刻4 void (*callback)(void *);5 void *arg;6} Timer;7
8Timer timer_heap[MAX_TIMERS];9int timer_count = 0;10
11// 主循环检查12void timer_check(uint32_t now) {13 while (timer_count > 0 && timer_heap[0].expire_tick <= now) {14 Timer t = heap_extract_min(timer_heap, &timer_count);15 t.callback(t.arg);3 collapsed lines
16 }17}18// O(logN)插入/删除, O(1)检查最近超时五、哈希表(Q86~Q91)
Q86: 哈希表的基本原理?
🧠 秒懂: 哈希表就像按身份证号查人——key经过哈希函数算出一个下标直接存取,平均O(1),是查找速度最快的数据结构。
1核心: key → hash(key) → index → value2
3哈希函数:4 整数: hash = key % table_size (table_size取素数更好)5 字符串: hash = sum(s[i] * 31^i) % table_size6
7冲突解决:8 1. 链地址法(拉链): 每个位置挂链表9 2. 开放寻址法: 冲突后找下一个空位10 线性探测: h+1, h+2, h+3...11 二次探测: h+1, h+4, h+9...12 双重哈希: h + i*hash2(key)Q87: 哈希表的C实现(链地址法)?
🧠 秒懂: 链地址法就是每个哈希桶挂一条链表——冲突了就追加到链表上,简单但链表长了查找变慢,可以设置负载因子触发扩容。
1#define TABLE_SIZE 2562
3typedef struct HashNode { // 结构体定义4 char *key;5 int value;6 struct HashNode *next;7} HashNode;8
9typedef struct { HashNode *buckets[TABLE_SIZE]; } HashMap; // 结构体定义10
11unsigned int hash(const char *key) {12 unsigned int h = 0;13 while (*key) h = h * 31 + *key++;14 return h % TABLE_SIZE;15}24 collapsed lines
16
17void hashmap_put(HashMap *map, const char *key, int value) {18 unsigned int idx = hash(key);19 HashNode *node = map->buckets[idx];20 while (node) {21 if (strcmp(node->key, key) == 0) { node->value = value; return; }22 node = node->next;23 }24 HashNode *new_node = malloc(sizeof(HashNode)); // 动态分配内存25 new_node->key = strdup(key);26 new_node->value = value;27 new_node->next = map->buckets[idx];28 map->buckets[idx] = new_node;29}30
31int hashmap_get(HashMap *map, const char *key, int *value) {32 unsigned int idx = hash(key);33 HashNode *node = map->buckets[idx];34 while (node) {35 if (strcmp(node->key, key) == 0) { *value = node->value; return 1; }36 node = node->next;37 }38 return 0; // not found39}Q88: 两数之和(哈希表经典应用)?
🧠 秒懂: 两数之和用哈希表——遍历数组每个数x,查哈希表里有没有target-x,有就找到了,没有就把x存进去,一次遍历O(n)搞定。
1// O(N)解法: 遍历数组,对每个num查找target-num是否在哈希表中2typedef struct { int key; int idx; UT_hash_handle hh; } HashEntry;3
4int* twoSum(int *nums, int n, int target) {5 HashEntry *map = NULL;6 static int result[2];7
8 for (int i = 0; i < n; i++) {9 int complement = target - nums[i];10 HashEntry *found;11 HASH_FIND_INT(map, &complement, found);12 if (found) {13 result[0] = found->idx; result[1] = i;14 return result;15 }6 collapsed lines
16 HashEntry *entry = malloc(sizeof(HashEntry)); // 动态分配内存17 entry->key = nums[i]; entry->idx = i;18 HASH_ADD_INT(map, key, entry);19 }20 return NULL;21}Q89: 布隆过滤器(Bloom Filter)?
🧠 秒懂: 布隆过滤器用多个哈希函数和位数组——说’不存在’一定不存在,说’存在’可能误判,适合缓存穿透、爬虫URL去重等容忍小概率误判的场景。
空间高效的概率型数据结构(嵌入式IoT常用):
1// 判断元素"可能存在"或"一定不存在"2#define BF_SIZE 1024 // 位数组大小(位)3#define NUM_HASH 34
5uint8_t bf_bits[BF_SIZE / 8];6
7void bf_add(const char *key) {8 for (int i = 0; i < NUM_HASH; i++) {9 unsigned int h = hash_func(key, i) % BF_SIZE;10 bf_bits[h / 8] |= (1 << (h % 8));11 }12}13
14int bf_check(const char *key) {15 for (int i = 0; i < NUM_HASH; i++) {8 collapsed lines
16 unsigned int h = hash_func(key, i) % BF_SIZE;17 if (!(bf_bits[h / 8] & (1 << (h % 8))))18 return 0; // 一定不存在19 }20 return 1; // 可能存在(有误判率)21}22
23// 应用: 网络爬虫URL去重/缓存过滤/IoT设备白名单Q90: 一致性哈希(分布式系统)?
🧠 秒懂: 一致性哈希把服务器和数据映射到一个环上——增减服务器只影响相邻节点的数据,不会引起全局数据迁移,分布式系统解决扩缩容问题的利器。
了解即可(面试可能问原理):
1传统哈希: hash(key) % N → 增减节点需要大量数据迁移2一致性哈希: 将哈希空间组织成环3 - 节点映射到环上4 - key顺时针找最近节点5 - 增删节点只影响相邻区域(约1/N的数据迁移)6
7嵌入式相关: 分布式IoT网关的负载均衡Q91: 哈希表在嵌入式中的应用?
🧠 秒懂: 嵌入式端哈希表要注意——内存受限用静态数组而非动态分配,哈希函数要简单高效(如取模),用于注册表、命令解析、设备查找等场景。
11. 命令表:2 hash("LED_ON") → LED_ON_handler3 hash("TEMP_READ") → TEMP_READ_handler4 O(1)命令分发(比if-else链快)5
62. 事件订阅:7 hash(EVENT_ID) → subscriber_list8 快速找到事件的所有订阅者9
103. 缓存(LRU):11 hash(key) → cached_value12 快速判断数据是否已缓存13
144. 去重:15 DMA接收的CAN帧 → hash(ID+data) → 丢弃重复帧六、排序算法(Q92~Q102)
💡 面试追问: LRU的时间复杂度?用什么数据结构实现O(1)?LinkedHashMap了解吗? 🔧 嵌入式建议: 嵌入式Cache管理/DNS缓存/资源池都用LRU思想。手写LRU是面试超高频题。
Q92: 常见排序算法对比?
🧠 秒懂: 排序算法对比就像工具箱——冒泡O(n²)但简单、快排平均O(nlogn)最常用、归并O(nlogn)稳定但要额外空间、堆排原地但不稳定。
| 算法 | 平均 | 最差 | 空间 | 稳定 | 特点 |
|---|---|---|---|---|---|
| 冒泡 | O(N²) | O(N²) | O(1) | ✓ | 简单,几乎不用 |
| 选择 | O(N²) | O(N²) | O(1) | ✗ | 交换次数少 |
| 插入 | O(N²) | O(N²) | O(1) | ✓ | 小数据/近有序很快 |
| 快排 | O(NlogN) | O(N²) | O(logN) | ✗ | 通常最快 |
| 归并 | O(NlogN) | O(NlogN) | O(N) | ✓ | 稳定+确定性 |
| 堆排 | O(NlogN) | O(NlogN) | O(1) | ✗ | 原地+确定性 |
| 计数 | O(N+K) | O(N+K) | O(K) | ✓ | 整数范围小时 |
Q93: 快速排序的实现?
🧠 秒懂: 快排——选基准把数组分成两半(小的左边大的右边),递归排两半,分治思想,平均O(nlogn)最快,但最坏O(n²)(已排序时)。
1void quick_sort(int *arr, int left, int right) {2 if (left >= right) return;3 int pivot = arr[left + (right-left)/2]; // 中间作为枢轴4 int i = left, j = right;5 while (i <= j) {6 while (arr[i] < pivot) i++;7 while (arr[j] > pivot) j--;8 if (i <= j) {9 int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;10 i++; j--;11 }12 }13 quick_sort(arr, left, j);14 quick_sort(arr, i, right);15}1 collapsed line
16// 平均O(NlogN), 最差O(N²)(已有序数组+首元素做枢轴)Q94: 归并排序的实现?
🧠 秒懂: 归并排序——先分成两半各自排好,再合并两个有序数组,稳定且一定是O(nlogn),缺点是需要O(n)额外空间,链表排序的首选。
1void merge(int *arr, int *tmp, int left, int mid, int right) {2 int i = left, j = mid + 1, k = left;3 while (i <= mid && j <= right) {4 if (arr[i] <= arr[j]) tmp[k++] = arr[i++];5 else tmp[k++] = arr[j++];6 }7 while (i <= mid) tmp[k++] = arr[i++];8 while (j <= right) tmp[k++] = arr[j++];9 memcpy(arr + left, tmp + left, (right - left + 1) * sizeof(int)); // 内存拷贝10}11
12void merge_sort(int *arr, int *tmp, int left, int right) {13 if (left >= right) return;14 int mid = left + (right - left) / 2;15 merge_sort(arr, tmp, left, mid);4 collapsed lines
16 merge_sort(arr, tmp, mid + 1, right);17 merge(arr, tmp, left, mid, right);18}19// 稳定 + O(NlogN)确定性 + 但需要O(N)额外空间Q95: 插入排序(小数据最优)?
🧠 秒懂: 插入排序就像打牌时理牌——每次拿一张插到已排好序的手牌中正确位置,小数据量时比快排快(少了递归开销),STL的sort对小数组就用插入排序。
1void insertion_sort(int *arr, int n) {2 for (int i = 1; i < n; i++) {3 int key = arr[i], j = i - 1;4 while (j >= 0 && arr[j] > key) {5 arr[j+1] = arr[j];6 j--;7 }8 arr[j+1] = key;9 }10}11// 最好O(N)(已有序), 最差O(N²)12// 嵌入式优势: 代码短,N<16时比快排快(无递归开销)Q96: 嵌入式中排序算法的选择?
🧠 秒懂: 嵌入式选排序看场景——数据量小(<50)用插入排序最快,中等规模用快排,需要稳定性用归并,内存紧张用堆排,几乎有序用冒泡/插入。
1N < 16: 插入排序(代码小,无递归,近有序很快)2N中等: 快排(一般最快)或堆排(确定性+无额外内存)3需要稳定: 归并排序(但需要O(N)额外空间)4内存极度受限: 堆排序(O(1)额外空间)5已知数据范围小: 计数排序(O(N))6
7实际应用:8 - qsort标准库: 通常混合快排+插入排序9 - RTOS任务优先级: 维护有序链表(插入排序思想)10 - 传感器采样中值滤波: 小数组排序(插入排序)Q97: 第K大的元素(快速选择)?
🧠 秒懂: 快速选择(QuickSelect)——和快排一样做partition,但只递归目标所在的那一半,平均O(n)找到第K大,不需要完整排序。
1// 基于快排partition,平均O(N)2int partition(int *arr, int left, int right) {3 int pivot = arr[right], i = left;4 for (int j = left; j < right; j++) {5 if (arr[j] <= pivot) {6 int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;7 i++;8 }9 }10 int tmp = arr[i]; arr[i] = arr[right]; arr[right] = tmp;11 return i;12}13
14int quickSelect(int *arr, int left, int right, int k) {15 int pivot_idx = partition(arr, left, right);5 collapsed lines
16 if (pivot_idx == k) return arr[k];17 else if (pivot_idx < k) return quickSelect(arr, pivot_idx+1, right, k);18 else return quickSelect(arr, left, pivot_idx-1, k);19}20// 平均O(N), 最差O(N²)Q98: 外部排序(大数据)?
🧠 秒懂: 外部排序——数据太大放不进内存,分批读入排序写回磁盘,然后多路归并合并有序块,数据库和大数据处理中常用。
数据量超过内存时的排序方法:
1步骤:2 1. 分块: 将大文件分成多个能放入内存的块3 2. 内排: 每块在内存中排序后写回磁盘4 3. 多路归并: 从每个排好序的块各取一个元素5 → 放入最小堆 → 取出最小 → 输出6 → 从该块再取一个放入堆7
8嵌入式场景:9 - 大量日志排序(Flash→RAM→Flash)10 - SD卡中的大文件排序11
12优化: K路归并用最小堆→O(NlogK)Q99: 基数排序(非比较排序)?
🧠 秒懂: 基数排序——从最低位到最高位逐位排序(每位用计数排序),不比较大小,O(d×n),适合整数和定长字符串排序。
1// 按位排序,从低位到高位(用计数排序做稳定分配)2void radix_sort(int *arr, int n) {3 int max_val = arr[0];4 for (int i = 1; i < n; i++)5 if (arr[i] > max_val) max_val = arr[i];6
7 int *output = malloc(n * sizeof(int)); // 动态分配内存8 for (int exp = 1; max_val / exp > 0; exp *= 10) {9 int count[10] = {0};10 for (int i = 0; i < n; i++)11 count[(arr[i] / exp) % 10]++;12 for (int i = 1; i < 10; i++)13 count[i] += count[i-1];14 for (int i = n-1; i >= 0; i--) {15 int digit = (arr[i] / exp) % 10;7 collapsed lines
16 output[--count[digit]] = arr[i];17 }18 memcpy(arr, output, n * sizeof(int)); // 内存拷贝19 }20 free(output); // 释放内存(建议之后置NULL)21}22// O(d*(N+K)), d=位数, K=基数(10)Q100: 稳定排序的意义?
🧠 秒懂: 稳定排序保证相等元素的相对顺序不变——冒泡、插入、归并是稳定的,快排、堆排不稳定,需要保持原有顺序时必须用稳定排序。
1稳定: 相等元素排序后相对顺序不变2不稳定: 可能改变相等元素的相对顺序3
4重要场景:5 1. 多关键字排序:6 先按时间排序(稳定) → 再按优先级排序(稳定)7 → 同优先级的仍按时间有序!8
9 2. 结构体排序:10 两个传感器数据值相同 → 保持原始采集顺序11
12嵌入式: 通常数据量小,稳定性不那么重要13 但如果需要稳定 → 选归并/插入排序Q101: 排序算法的稳定性验证?
🧠 秒懂: 验证排序稳定性——给相等元素加编号排序后检查编号顺序,如’3a 3b’排完还是’3a 3b’就是稳定的,变成’3b 3a’就不稳定。
如何验证一个排序实现是否稳定:
1// 方法: 给相等元素加序号,排序后检查序号顺序2typedef struct { int val; int orig_idx; } Item;3
4int is_stable_sort(void (*sort_func)(Item*, int), Item *arr, int n) {5 sort_func(arr, n);6 for (int i = 1; i < n; i++) {7 if (arr[i].val == arr[i-1].val) {8 if (arr[i].orig_idx < arr[i-1].orig_idx)9 return 0; // 不稳定!10 }11 }12 return 1; // 稳定13}Q102: 冒泡排序的优化?
🧠 秒懂: 冒泡优化——加一个flag,如果某趟没有发生交换说明已经有序了直接结束,最好情况从O(n²)优化到O(n)。
1// 优化1: 提前终止(某趟无交换 → 已有序)2void bubble_sort_opt(int *arr, int n) {3 for (int i = 0; i < n-1; i++) {4 int swapped = 0;5 for (int j = 0; j < n-1-i; j++) {6 if (arr[j] > arr[j+1]) {7 int tmp = arr[j]; arr[j] = arr[j+1]; arr[j+1] = tmp;8 swapped = 1;9 }10 }11 if (!swapped) break; // 已有序12 }13}14// 最好O(N)(已有序时第一趟无交换就退出)七、图与高级算法(Q103~Q120)
Q103: 图的表示方法?
🧠 秒懂: 图有两种存储方式——邻接矩阵(二维数组,适合稠密图,O(1)查询)和邻接表(链表数组,适合稀疏图,省空间),嵌入式中后者更常用。
1// 1. 邻接矩阵(适合稠密图)2int graph[N][N]; // graph[i][j]=权重, 0表示无边3// 空间O(N²), 查询O(1), 遍历邻居O(N)4
5// 2. 邻接表(适合稀疏图, 嵌入式常用)6typedef struct Edge { int to; int weight; struct Edge *next; } Edge;7Edge *adj[N]; // adj[i]指向i的所有邻居链表8// 空间O(N+E), 遍历邻居O(度)Q104: BFS(广度优先搜索)?
🧠 秒懂: BFS就像往池塘扔石头——从起点一圈一圈向外扩展,用队列保证层层推进,能找最短路径,适合无权图的最短距离。
1void bfs(int graph[][N], int n, int start) {2 int visited[N] = {0};3 int queue[N], front = 0, rear = 0;4
5 visited[start] = 1;6 queue[rear++] = start;7
8 while (front < rear) {9 int node = queue[front++];10 printf("%d ", node);11 for (int i = 0; i < n; i++) {12 if (graph[node][i] && !visited[i]) {13 visited[i] = 1;14 queue[rear++] = i;15 }4 collapsed lines
16 }17 }18}19// 应用: 最短路径(无权图)、层次遍历、连通性检测Q105: DFS(深度优先搜索)?
🧠 秒懂: DFS就像走迷宫——沿一条路走到底,走不通就回退换一条,用栈(或递归)实现,适合连通性判断、拓扑排序、路径搜索。
1void dfs(int graph[][N], int n, int node, int *visited) {2 visited[node] = 1;3 printf("%d ", node);4 for (int i = 0; i < n; i++) {5 if (graph[node][i] && !visited[i]) {6 dfs(graph, n, i, visited);7 }8 }9}10// 应用: 拓扑排序、连通分量、路径搜索、回溯算法Q106: Dijkstra最短路径?
🧠 秒懂: Dijkstra就像修路工从起点往外修——每次找距离最小的未访问节点,更新其邻居的最短距离,用最小堆O((V+E)logV),不能处理负权边。
1// 单源最短路径(权重非负)2void dijkstra(int graph[][N], int n, int src, int *dist) {3 int visited[N] = {0};4 for (int i = 0; i < n; i++) dist[i] = INT_MAX;5 dist[src] = 0;6
7 for (int count = 0; count < n; count++) {8 // 找未访问的最小距离节点9 int u = -1, min_dist = INT_MAX;10 for (int i = 0; i < n; i++)11 if (!visited[i] && dist[i] < min_dist) { u = i; min_dist = dist[i]; }12 if (u == -1) break;13 visited[u] = 1;14
15 // 更新邻居7 collapsed lines
16 for (int v = 0; v < n; v++) {17 if (graph[u][v] && !visited[v] && dist[u] + graph[u][v] < dist[v])18 dist[v] = dist[u] + graph[u][v];19 }20 }21}22// O(N²)简单版, 用堆优化可达O((N+E)logN)Q107: 拓扑排序?
🧠 秒懂: 拓扑排序就像排课程先后顺序——必须先修完先修课才能选后续课,用入度法(BFS)或DFS后序反转,结果不唯一但必须是DAG。
1// 有向无环图(DAG)的线性排序(依赖关系)2// BFS方法(Kahn算法):3void topological_sort(int graph[][N], int n) {4 int in_degree[N] = {0};5 for (int i = 0; i < n; i++)6 for (int j = 0; j < n; j++)7 if (graph[i][j]) in_degree[j]++;8
9 int queue[N], front = 0, rear = 0;10 for (int i = 0; i < n; i++)11 if (in_degree[i] == 0) queue[rear++] = i;12
13 while (front < rear) {14 int node = queue[front++];15 printf("%d ", node);8 collapsed lines
16 for (int i = 0; i < n; i++) {17 if (graph[node][i]) {18 if (--in_degree[i] == 0) queue[rear++] = i;19 }20 }21 }22}23// 应用: 任务调度/编译依赖/初始化顺序Q108: 最小生成树(Kruskal)?
🧠 秒懂: Kruskal最小生成树——把所有边按权重排序,从小到大选边(用并查集判断是否成环),选n-1条边就完成,贪心思想。
1// 按边权排序 + 并查集检测环2typedef struct { int u, v, w; } Edge;3
4int cmp_edge(const void *a, const void *b) {5 return ((Edge*)a)->w - ((Edge*)b)->w;6}7
8int kruskal(Edge *edges, int edge_count, int n) {9 qsort(edges, edge_count, sizeof(Edge), cmp_edge);10 uf_init(n);11 int mst_weight = 0, count = 0;12
13 for (int i = 0; i < edge_count && count < n-1; i++) {14 if (uf_find(edges[i].u) != uf_find(edges[i].v)) {15 uf_union(edges[i].u, edges[i].v);6 collapsed lines
16 mst_weight += edges[i].w;17 count++;18 }19 }20 return mst_weight;21}Q109: 动态规划基础(背包问题)?
🧠 秒懂: 动态规划就像’记账本’——把大问题拆成子问题,子问题的结果记录下来避免重复计算,背包问题是经典入门:n个物品有重量和价值,背包容量有限,求最大价值。
1// 0-1背包: N个物品, 容量W, 求最大价值2int knapsack(int *weight, int *value, int n, int W) {3 int dp[W+1];4 memset(dp, 0, sizeof(dp));5
6 for (int i = 0; i < n; i++) {7 for (int w = W; w >= weight[i]; w--) { // 逆序!8 if (dp[w-weight[i]] + value[i] > dp[w])9 dp[w] = dp[w-weight[i]] + value[i];10 }11 }12 return dp[W];13}14// 空间优化到O(W), 时间O(NW)Q110: A*算法(启发式搜索)?
🧠 秒懂: A*算法就像有导航的路径搜索——在Dijkstra基础上加了启发函数(估计到终点的距离),优先搜索更可能离终点近的节点,机器人路径规划常用。
路径规划算法(机器人/游戏常用):
1A*: f(n) = g(n) + h(n)2 g(n): 起点到n的实际代价3 h(n): n到终点的估计代价(启发函数)4
5 用优先级队列(按f值排序):6 1. 将起点加入open_list7 2. 取f最小的节点展开8 3. 对每个邻居: 计算g和h, 加入open_list9 4. 直到到达终点10
11启发函数选择:12 曼哈顿距离: |x1-x2| + |y1-y2| (4方向移动)13 欧几里得距离: sqrt((x1-x2)² + (y1-y2)²) (任意方向)14
15嵌入式应用: 小车路径规划、扫地机器人Q111: KMP字符串匹配?
🧠 秒懂: KMP在这里再提一次是因为字符串匹配太重要——核心是next数组(部分匹配表),匹配失败时模式串不从头开始而是跳到合适位置,面试必掌握。
1// 构建next数组(部分匹配表)2void buildNext(const char *pattern, int *next, int m) {3 next[0] = -1;4 int i = 0, j = -1;5 while (i < m) {6 if (j == -1 || pattern[i] == pattern[j]) {7 i++; j++; next[i] = j;8 } else {9 j = next[j];10 }11 }12}13
14int kmp_search(const char *text, const char *pattern) {15 int n = strlen(text), m = strlen(pattern);12 collapsed lines
16 int next[m+1];17 buildNext(pattern, next, m);18
19 int i = 0, j = 0;20 while (i < n) {21 if (j == -1 || text[i] == pattern[j]) { i++; j++; }22 else j = next[j];23 if (j == m) return i - m; // 匹配成功24 }25 return -1;26}27// 时间O(N+M), 比暴力O(NM)快Q112: 位运算技巧?
🧠 秒懂: 位运算技巧就像程序员的’魔术’——x&(x-1)去掉最低位的1、x^x=0、n&1判断奇偶、1<<k设置第k位,嵌入式操作寄存器天天用。
嵌入式面试高频(寄存器操作基础):
1// 1. 判断奇偶2n & 1 // 1=奇, 0=偶3
4// 2. 交换两数(不用临时变量)5a ^= b; b ^= a; a ^= b;6
7// 3. 取最低位的18n & (-n) // lowbit9
10// 4. 清除最低位的111n & (n - 1)12
13// 5. 统计1的个数(Brian Kernighan)14int count = 0;15while (n) { n &= (n-1); count++; }8 collapsed lines
16
17// 6. 判断是否是2的幂18(n > 0) && (n & (n-1)) == 019
20// 7. 设置/清除/翻转第k位21n | (1<<k) // 设置22n & ~(1<<k) // 清除23n ^ (1<<k) // 翻转💡 面试追问: 环形缓冲区怎么判断满和空?用size计数还是浪费一个格子? 🔧 嵌入式建议: UART/ADC/音频数据接收必用环形缓冲区。推荐size计数法(清晰);大小用2的幂(可以用位与代替取模)。
Q113: 位图(Bitmap)在嵌入式中的应用?
🧠 秒懂: 位图用一个bit表示一个状态——32位int可以管理32个标志位,嵌入式中用于GPIO状态、任务标志、中断标志、RTOS事件组,极省空间。
1// 用1位表示一个状态(极省空间)2#define BITMAP_SIZE 10243uint32_t bitmap[BITMAP_SIZE / 32];4
5void bitmap_set(int n) { bitmap[n/32] |= (1u << (n%32)); }6void bitmap_clear(int n) { bitmap[n/32] &= ~(1u << (n%32)); }7int bitmap_test(int n) { return (bitmap[n/32] >> (n%32)) & 1; }8
9// 应用:10// 1. 内存块分配管理(FreeRTOS堆管理)11// 2. IO引脚状态记录12// 3. 事件标志组(EventGroup)13// 4. 权限位/状态机标志Q114: 环形缓冲区的位操作优化?
🧠 秒懂: 环形缓冲区大小设为2的幂——取模运算变成与运算(index & (size-1)),省掉除法操作,在MCU上速度提升明显。
1// 当size是2的幂时, 用位与代替取模2#define BUF_SIZE 256 // 必须是2的幂3#define BUF_MASK (BUF_SIZE - 1)4
5uint8_t buf[BUF_SIZE];6uint32_t head = 0, tail = 0;7
8void push(uint8_t data) {9 buf[head & BUF_MASK] = data; // head & 0xFF 代替 head % 25610 head++;11}12
13uint8_t pop(void) {14 uint8_t data = buf[tail & BUF_MASK];15 tail++;5 collapsed lines
16 return data;17}18
19// 可用数据量: head - tail (利用无符号溢出自动处理)20// 注意: head/tail不取模,让它们自然溢出 → 简化逻辑Q115: 状态机在嵌入式中的数据结构实现?
🧠 秒懂: 状态机用枚举+switch或函数指针表实现——每个状态是一个枚举值或函数,事件触发状态转移,嵌入式协议解析、按键处理、设备控制都靠状态机。
1// 表驱动状态机(查表法)2typedef enum { ST_IDLE, ST_RUNNING, ST_ERROR, ST_COUNT } State;3typedef enum { EV_START, EV_STOP, EV_FAULT, EV_RESET, EV_COUNT } Event;4
5typedef void (*Action)(void);6typedef struct { State next_state; Action action; } Transition;7
8Transition table[ST_COUNT][EV_COUNT] = {9 /* EV_START EV_STOP EV_FAULT EV_RESET */10 [ST_IDLE] = {{ST_RUNNING, do_start}, {ST_IDLE, NULL}, {ST_ERROR, do_alarm}, {ST_IDLE, NULL}},11 [ST_RUNNING] = {{ST_RUNNING, NULL}, {ST_IDLE, do_stop}, {ST_ERROR, do_alarm}, {ST_IDLE, do_reset}},12 [ST_ERROR] = {{ST_ERROR, NULL}, {ST_ERROR, NULL}, {ST_ERROR, NULL}, {ST_IDLE, do_reset}},13};14
15void fsm_dispatch(State *state, Event event) {4 collapsed lines
16 Transition *t = &table[*state][event];17 if (t->action) t->action();18 *state = t->next_state;19}Q116: 双向链表在内核中的应用(Linux list_head)?
🧠 秒懂: Linux list_head是侵入式双向链表——结构体里嵌入链表节点而不是链表节点里放数据指针,通过container_of宏反推结构体地址,内核到处都用。
1// Linux内核的通用双向链表2struct list_head {3 struct list_head *next, *prev;4};5
6// 嵌入到任意结构体中7struct task_struct {8 int pid;9 struct list_head list; // 链入就绪队列10};11
12// container_of: 从list_head指针反推结构体指针13#define container_of(ptr, type, member) \14 ((type *)((char *)(ptr) - offsetof(type, member)))15
5 collapsed lines
16// 遍历17#define list_for_each_entry(pos, head, member) \18 for (pos = container_of((head)->next, typeof(*pos), member); \19 &pos->member != (head); \20 pos = container_of(pos->member.next, typeof(*pos), member))Q117: CRC校验的数据结构实现?
🧠 秒懂: CRC校验就像给数据算’指纹’——选定多项式后对数据做模2除法,余数就是CRC值,接收端重新算一次比对,不一致说明传输出错。
1// CRC-16查表法(空间换时间)2static const uint16_t crc16_table[256] = {3 0x0000, 0xC0C1, 0xC181, 0x0140, /* ... 256 entries ... */4};5
6uint16_t crc16(const uint8_t *data, uint16_t len) {7 uint16_t crc = 0xFFFF;8 while (len--) {9 crc = (crc >> 8) ^ crc16_table[(crc ^ *data++) & 0xFF];10 }11 return crc;12}13
14// 嵌入式选择:15// 查表法: 速度快, 占256×2=512字节ROM2 collapsed lines
16// 逐位计算: 速度慢, 不占额外空间17// 折中: 半字节查表(16项表)Q118: 内存对齐和结构体填充?
🧠 秒懂: 内存对齐就像停车位——变量地址要对齐到其类型大小的倍数(4字节int对齐到4的倍数),结构体可能有空洞(padding),用sizeof确认实际大小。
1// 面试高频: 下面结构体占多少字节?2struct A {3 char a; // offset 0, size 14 // padding 3 bytes5 int b; // offset 4, size 46 char c; // offset 8, size 17 // padding 3 bytes (总大小对齐到最大成员=4)8}; // sizeof = 129
10struct B {11 char a; // offset 012 char c; // offset 113 // padding 2 bytes14 int b; // offset 415}; // sizeof = 8 (调整顺序可以省空间!)6 collapsed lines
16
17// 嵌入式通信中: __attribute__((packed)) 取消填充18struct __attribute__((packed)) Packet {19 uint8_t type;20 uint32_t data; // 非对齐访问!某些CPU会fault21}; // sizeof = 5Q119: 软件定时器的数据结构选择?
🧠 秒懂: 软件定时器用最小堆效率最高——堆顶是最快到期的定时器O(1)检查,增删O(logn)。时间轮(TimeWheel)适合大量定时器,FreeRTOS用链表。
1需求: 管理大量定时器, 支持高效的插入/删除/查询最近超时2
3方案对比:4 有序链表: 插入O(N), 查询最近O(1), 适合定时器少5 最小堆: 插入O(logN), 查询O(1), 删除O(logN), 通用方案6 时间轮: 插入O(1), 推进O(1), 适合大量短定时器7
8时间轮(Timer Wheel):9 类似钟表:10 - 256格的环形数组(每格一个链表)11 - tick++时推进到下一格,执行该格所有定时器12 - 超出一圈的放到更高层13
14Linux内核: 分层时间轮15FreeRTOS: 有序链表(定时器数量通常不多)Q120: 数据结构在嵌入式面试中的考察重点?
🧠 秒懂: 数据结构面试考察重点——链表反转+环检测必考,栈队列基本操作必会,排序快排+归并手写,树的遍历+BST,嵌入式还要会环形缓冲区和位操作。
1高频考点:2 1. 链表操作(翻转/环/合并) - 指针操作能力3 2. 栈/队列(用数组实现) - 基本功4 3. 环形缓冲区 - 串口/DMA必备5 4. 排序(快排/插入/堆排) - 算法基础6 5. 哈希表 - 查找效率7 6. 堆(优先级队列) - RTOS调度/定时器8 7. 位操作 - 寄存器/标志位9 8. 状态机 - 协议解析/控制逻辑10 9. 树(尤其完全二叉树/堆) - 数据组织11 10. 内存对齐/结构体 - 通信协议12
13面试建议:14 - 手写代码(白板),重点是指针和边界条件15 - 分析时间复杂度和空间复杂度1 collapsed line
16 - 说明嵌入式场景下的选择理由(内存有限/无动态分配)