数据结构基础面试题

精选 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
5
int arr[10]; /* 栈上, 编译时确定大小 */
6
int *p = malloc(n * sizeof(int)); /* 堆上, 运行时确定 */

Q2: 反转数组?

🧠 秒懂: 反转数组就像把一排人前后对调——用双指针从两头往中间走,左右交换,走到中间就完成了,时间O(n)空间O(1)。

使用双指针或递归方式实现:

1
void 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
/* 双指针法 */
2
void 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: 删除有序数组中的重复元素(原地)?

🧠 秒懂: 原地去重就像整理书架——用快慢指针,慢指针指向不重复序列的末尾,快指针往前探路,遇到新值就放到慢指针后面。

使用快慢指针原地去除重复元素:

1
int 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
/* 字符串反转? - 示例实现 */
2
void 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
/* 判断回文字符串? - 示例实现 */
2
int 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 自己实现? - 示例实现 */
2
size_t my_strlen(const char *s) {
3
const char *p = s;
4
while (*p) p++;
5
return p - s;
6
}
7
8
char *my_strcpy(char *dst, const char *src) {
9
char *ret = dst;
10
while ((*dst++ = *src++) != '\0');
11
return ret;
12
}
13
14
int 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: 处理内存重叠(可能从后往前拷贝) */
3
void *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),原地完成。

1
void 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)。

1
int 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)。

1
void 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 是模式串长。

1
void 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
11
int 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。面试中经常考这道题来看你对边界条件的处理能力。

1
int 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 位数),只能用字符串模拟手工加法:从末尾开始逐位相加,处理进位,结果倒序存储最后反转。这在嵌入式中不常考,但在算法面试中是基础题。核心就是模拟我们小学学的竖式加法。

1
char *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 步。

1
int 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),某些方向可能提前结束。

1
void 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) 都快。

1
int 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) — 处理边界情况 */
2
int 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)。

1
int 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
13
int 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)。

1
int 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)。

链表操作的核心是指针的修改:

1
typedef struct ListNode {
2
int val;
3
struct ListNode *next;
4
} ListNode;
5
6
/* 创建节点 */
7
ListNode *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
/* 头插法建链表 */
15
ListNode *head = NULL;
5 collapsed lines
16
for (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,每次把当前节点指向前一个,就像逐个掉头,面试必须秒写。

使用双指针或递归方式实现:

1
ListNode *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
/* 递归版 */
13
ListNode *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 判环法) */
2
int 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 出发, 一个从相遇点出发, 再次相遇即入口 */
13
ListNode *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: 合并两个有序链表?

🧠 秒懂: 合并有序链表就像拉拉链——两个指针各指一条链表,每次取较小的节点接到结果链上,用虚拟头节点简化边界处理。

链表操作的核心是指针的修改:

1
ListNode *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即可。

链表操作的核心是指针的修改:

1
ListNode *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: 链表中间节点?

🧠 秒懂: 找中间节点用快慢指针——快走两步慢走一步,快到末尾时慢正好在中间,常用于链表归并排序的分割步骤。

链表操作的核心是指针的修改:

1
ListNode *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: 判断两个链表是否相交?

🧠 秒懂: 两链表相交就像两条路汇成一条——先算长度差让长的先走差值步,然后一起走,相遇的节点就是交点。或者各走完自己的走对方的,也能相遇。

链表操作的核心是指针的修改:

1
ListNode *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))。

1
ListNode *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) 做法。注意比较完后最好把链表恢复原样(再反转一次)。

1
int 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)。这也是面试中最常考的链表排序方法。

1
ListNode *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 题,面试中能写出来说明链表操作非常熟练。

1
ListNode *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) 要求 getput 都是 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) */
2
typedef struct Node { int val; struct Node *next, *random; } Node;
3
4
/* 方法: 交错插入法 O(n)时间 O(1)空间 */
5
Node* 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.next
15
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内核大量使用。

双向链表每个节点有 prevnext 两个指针。优势:删除操作不需要遍历找前驱(单链表删除需要知道前一个节点)。插入需要修改4个指针,删除也是4个指针。双向链表是 LRU 缓存和 Linux 内核链表的基础数据结构。

1
/* 在 node 之后插入 new_node */
2
void 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(已知位置) */
9
void 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就是编号。

1
int 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 的元素(随机决定)。查找时从顶层开始,先向右走到不能走(下一个比目标大),再往下走一层继续向右,逐层缩小范围。

1
Level 3: 1 ───────────────→ 9 ───→ nil
2
Level 2: 1 ───→ 4 ────────→ 9 ───→ nil
3
Level 1: 1 → 3 → 4 → 5 → 7 → 9 → nil

查找平均 O(logn),插入/删除也是 O(logn)。Redis 的有序集合(ZSET)底层就用跳表——比平衡树实现简单、范围查询方便。


Q37: 有序链表去重?

🧠 秒懂: 有序链表去重就像排队时踢掉重复的人——当前和下一个相同就删下一个,一次遍历O(n)搞定,注意要释放删除节点的内存。

遍历链表,如果当前节点的值和下一个节点相同,就删除下一个节点:

1
void 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)空间。

无序链表中相同元素可能不相邻,需要用**哈希表(或数组)**记录已出现的值:

1
void 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,遍历原链表把每个节点挂到对应链表上,最后拼接:

1
Node *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 右旋 */
2
Node* 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)。

Terminal window
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 模板 */
2
queue<int> q;
3
q.push(start); visited[start] = true; // 访问标记数组
4
while (!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 100
3
typedef struct { // 结构体定义
4
int data[MAX_SIZE];
5
int top;
6
} Stack;
7
8
void stack_init(Stack *s) { s->top = -1; }
9
int stack_empty(Stack *s) { return s->top == -1; }
10
int stack_full(Stack *s) { return s->top == MAX_SIZE - 1; }
11
12
void stack_push(Stack *s, int val) {
13
if (!stack_full(s))
14
s->data[++s->top] = val;
15
}
10 collapsed lines
16
int stack_pop(Stack *s) {
17
if (!stack_empty(s))
18
return s->data[s->top--]; // 出栈
19
return -1;
20
}
21
int stack_peek(Stack *s) {
22
if (!stack_empty(s))
23
return s->data[s->top];
24
return -1;
25
}

十大排序算法复杂度速查表:

排序算法平均O最好O最坏O空间O稳定性嵌入式适用
冒泡排序n^2nn^21稳定小数据量教学
选择排序n^2n^2n^21不稳定交换次数少
插入排序n^2nn^21稳定近似有序/小数据
希尔排序n^1.3nn^21不稳定中等数据量
归并排序nlognnlognnlognn稳定外部排序
快速排序nlognnlognn^2logn不稳定通用首选
堆排序nlognnlognnlogn1不稳定内存受限
计数排序n+kn+kn+kk稳定整数小范围
桶排序n+kn+kn^2n+k稳定均匀分布浮点
基数排序n*kn*kn*kn+k稳定定长整数/字符串

嵌入式面试重点:堆排序O(1)空间+O(nlogn)时间,最适合内存受限的MCU场景。快排在平均情况最快但最坏O(n^2)且不稳定。如果需要稳定排序且内存充足选归并。

Q50: 队列的实现(环形数组)?

🧠 秒懂: 环形队列就像传送带——用数组+头尾指针实现,尾追上头就满了,空间循环利用不浪费,嵌入式里UART缓冲区、RTOS消息队列都用这个。

基本数据结构的实现:

1
/* 队列的实现(环形数组)? - 示例实现 */
2
#define QSIZE 100
3
typedef struct { // 结构体定义
4
int data[QSIZE];
5
int head, tail, count;
6
} Queue;
7
8
void queue_init(Queue *q) { q->head = q->tail = q->count = 0; }
9
int queue_empty(Queue *q) { return q->count == 0; }
10
int queue_full(Queue *q) { return q->count == QSIZE; }
11
12
void 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
}
19
int 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
/* 有效括号匹配? - 示例实现 */
2
int 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),就像两个杯子倒水实现先进先出。

经典面试题(栈后进先出→队列先进先出):

1
typedef struct {
2
Stack in_stack; // 入队用
3
Stack out_stack; // 出队用
4
} MyQueue;
5
6
void queue_push(MyQueue *q, int val) {
7
stack_push(&q->in_stack, val);
8
}
9
10
int 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
// 题目: 每日温度,找到每个位置后面第一个更高温度的天数
2
void 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)。

1
typedef struct { // 结构体定义
2
int data[MAX_SIZE];
3
int min_stack[MAX_SIZE]; // 辅助栈记录最小值
4
int top;
5
int min_top;
6
} MinStack;
7
8
void 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
14
void 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
20
int getMin(MinStack *s) { return s->min_stack[s->min_top]; }

Q55: 优先级队列(堆实现)?

🧠 秒懂: 优先级队列用堆实现——就像急诊室按严重程度排序而不是先来后到,插入和取最值都是O(logn),RTOS调度器选最高优先级任务就靠这个。

1
typedef struct { // 结构体定义
2
int data[MAX_SIZE];
3
int size;
4
} MinHeap;
5
6
void 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
19
int 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
// 简化版: 只支持 + - * / ()
2
int 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
// 滑动窗口最大值(单调双端队列)
2
void 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
// 递归版二叉树前序遍历
2
void preorder_recursive(TreeNode *root) {
3
if (!root) return;
4
visit(root);
5
preorder_recursive(root->left);
6
preorder_recursive(root->right);
7
}
8
9
// 非递归版(显式栈)
10
void 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消息队列就像邮箱——任务间通过队列传递固定大小的消息,发送方往里放,接收方取出处理,带阻塞超时机制实现任务同步和数据传递。

嵌入式中消息队列的底层实现:

1
typedef 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
13
int 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 * +"
2
void 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用栈实现深入到底再回溯(像走迷宫走到死胡同再返回),两者是图/树遍历的两大基本方法。

Terminal window
1
DFS(深度优先): 用栈(或递归)
2
沿一条路走到底再回溯
3
适合: 路径搜索/拓扑排序/连通分量
4
5
BFS(广度优先): 用队列
6
一层一层扩展
7
适合: 最短路径/层次遍历/二分图判定
8
9
嵌入式场景:
10
DFS: 状态机遍历/协议解析树
11
BFS: 事件广播/网络拓扑发现

Q62: 环形队列判满判空?

🧠 秒懂: 环形队列判满判空有三种方法——空一格法(tail+1==head是满)、计数法(size变量)、标志位法(flag区分),最常用的是空一格法。

环形队列常见的两种方式:

1
// 方式1: 浪费一个位置(最常用)
2
#define QUEUE_SIZE 16
3
typedef struct {
4
int buf[QUEUE_SIZE];
5
int head, tail;
6
} CircQueue;
7
// 空: head == tail
8
// 满: (tail + 1) % QUEUE_SIZE == head
9
// 长度: (tail - head + QUEUE_SIZE) % QUEUE_SIZE
10
11
// 方式2: 用count计数
12
typedef struct {
13
int buf[QUEUE_SIZE];
14
int head, count;
15
} CircQueue2;
3 collapsed lines
16
// 空: count == 0
17
// 满: count == QUEUE_SIZE
18
// tail = (head + count) % QUEUE_SIZE

Q63: 栈溢出检测(嵌入式)?

🧠 秒懂: 嵌入式栈溢出就像水杯满了——RTOS每个任务栈独立,溢出会覆盖相邻内存导致诡异bug,检测方法:栈末尾填魔数、MPU保护、FreeRTOS栈水位线。

嵌入式中栈溢出是常见崩溃原因:

1
// FreeRTOS 栈溢出检测方法:
2
// 1. 任务创建时栈填充0xA5A5A5A5
3
// 2. 检测栈底的magic值是否被覆盖
4
configCHECK_FOR_STACK_OVERFLOW = 2 // 在FreeRTOSConfig.h中
5
6
void 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
5
typedef struct {
6
volatile uint32_t head; // ISR写
7
volatile uint32_t tail; // 主循环写
8
uint8_t buf[256]; // 大小必须是2的幂
9
} LockFreeQueue;
10
11
// ISR中入队
12
void 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
// 主循环出队
21
int 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
平衡二叉树: 任何节点左右子树高度差≤1
10
11
性质:
12
第i层最多 2^(i-1) 个节点
13
深度为k的树最多 2^k - 1 个节点
14
叶子数 = 度2节点数 + 1

Q66: 二叉树遍历(前/中/后序)?

🧠 秒懂: 前中后序就是根在前面、中间还是后面访问——前序(根左右)用于复制树,中序(左根右)用于BST排序输出,后序(左右根)用于释放内存。

1
typedef struct TreeNode {
2
int val;
3
struct TreeNode *left, *right;
4
} TreeNode;
5
6
// 前序: 根 → 左 → 右
7
void preorder(TreeNode *root) {
8
if (!root) return;
9
printf("%d ", root->val);
10
preorder(root->left);
11
preorder(root->right);
12
}
13
14
// 中序: 左 → 根 → 右 (BST中序遍历是有序的!)
15
void 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
// 后序: 左 → 右 → 根
23
void 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)?

🧠 秒懂: 层序遍历用队列——先把根入队,每次出队一个节点并把它的子节点入队,就像广播一层一层地向下传播,可以轻松求树的最大宽度和深度。

1
void 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)最差
2
TreeNode* 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
// 插入
10
TreeNode* 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)且原地排序。

1
void 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
12
void 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<<N
4
5
void 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 26
3
4
typedef struct TrieNode { // 结构体定义
5
struct TrieNode *children[ALPHABET_SIZE];
6
int is_end;
7
} TrieNode;
8
9
void 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
22
int 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中利用大小关系查找,普通二叉树用递归从两头向上找交汇点。

1
TreeNode* 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用#表示)
2
void 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
// 反序列化
10
TreeNode* 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)/2
4
// 左孩子: 2*i+1
5
// 右孩子: 2*i+2
6
7
// 应用: 堆、定时器管理(最小堆)、优先级调度
8
// 嵌入式优势: 无动态内存分配,缓存友好
9
10
int tree[MAX_NODES];
11
int parent(int i) { return (i - 1) / 2; }
12
int left(int i) { return 2 * i + 1; }
13
int right(int i) { return 2 * i + 2; }

Q76: 判断平衡二叉树?

🧠 秒懂: 判断平衡二叉树——递归求每个节点的左右子树高度差,只要有一个节点高度差>1就不平衡,自底向上O(n)比自顶向下O(n²)高效。

1
int 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
11
int 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:45
8
编码: F=0, C=100, D=101, A=1100, B=1101, E=111
9
短编码给高频字符 → 总编码长度最短
10
11
嵌入式应用: 固件压缩/传输数据压缩

Q78: B树和B+树?

🧠 秒懂: B树每个节点能存多个key——像一本书的多级目录,减少磁盘IO次数。B+树数据全在叶子节点且叶子串起来,更适合数据库范围查询。

数据库索引和文件系统常用:

1
B树(m阶):
2
- 每个节点最多m个子节点, m-1个关键字
3
- 所有叶子在同一层
4
- 查找可能在任何层结束
5
6
B+树:
7
- 数据只在叶子节点
8
- 叶子节点形成链表(范围查询高效)
9
- 内部节点只存索引(更多分支→更矮)
10
11
嵌入式应用:
12
- Flash文件系统(LittleFS用B树变体)
13
- 大容量数据索引(SD卡/eMMC)

Q79: 线段树(区间查询)?

🧠 秒懂: 线段树把区间问题拆分到树节点上——每个节点存一段区间的信息(和/最大值等),查询和修改都是O(logn),适合频繁区间查询的场景。

高效进行区间操作的数据结构:

1
int tree[4*MAX_N]; // 线段树数组(4倍空间)
2
3
// 建树
4
void 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)
13
int 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),用于连通性问题。

解决连通性问题的高效结构:

1
int parent[MAX_N];
2
int rank_arr[MAX_N];
3
4
void uf_init(int n) {
5
for (int i = 0; i < n; i++) { parent[i] = i; rank_arr[i] = 0; }
6
}
7
8
int uf_find(int x) { // 路径压缩
9
if (parent[x] != x) parent[x] = uf_find(parent[x]);
10
return parent[x];
11
}
12
13
void 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: 二叉树的直径?

🧠 秒懂: 二叉树直径是最长路径的边数——不一定经过根节点,递归求每个节点的左深度+右深度取最大值就是答案。

1
int diameter = 0;
2
int 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 32
2
#define POOL_SIZE 64
3
4
typedef struct { // 结构体定义
5
uint8_t pool[POOL_SIZE][BLOCK_SIZE];
6
uint8_t free_list[POOL_SIZE]; // 位图或链表索引
7
int free_count;
8
} MemPool;
9
10
void* 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
22
void 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路由表的最长前缀匹配:

Terminal window
1
路由表(二进制前缀):
2
192.168.1.0/24 接口1
3
192.168.0.0/16 接口2
4
0.0.0.0/0 默认网关
5
6
用Trie(按位):
7
从IP高位到低位查找
8
每次匹配到一个路由就记录(可能更长前缀继续匹配)
9
最后返回最长匹配的路由
10
11
嵌入式场景: lwIP的路由表查找

Q84: 树的Morris遍历(O(1)空间)?

🧠 秒懂: Morris遍历利用叶子节点的空指针——临时连成线索回到祖先节点,省掉了栈或递归的O(n)空间,但会临时修改树结构(遍历完恢复)。

不用栈/递归实现中序遍历(空间O(1)):

1
void 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
// 最小堆管理多个定时器(最近超时的在堆顶)
2
typedef struct {
3
uint32_t expire_tick; // 超时时刻
4
void (*callback)(void *);
5
void *arg;
6
} Timer;
7
8
Timer timer_heap[MAX_TIMERS];
9
int timer_count = 0;
10
11
// 主循环检查
12
void 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 → value
2
3
哈希函数:
4
整数: hash = key % table_size (table_size取素数更好)
5
字符串: hash = sum(s[i] * 31^i) % table_size
6
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 256
2
3
typedef struct HashNode { // 结构体定义
4
char *key;
5
int value;
6
struct HashNode *next;
7
} HashNode;
8
9
typedef struct { HashNode *buckets[TABLE_SIZE]; } HashMap; // 结构体定义
10
11
unsigned 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
17
void 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
31
int 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 found
39
}

Q88: 两数之和(哈希表经典应用)?

🧠 秒懂: 两数之和用哈希表——遍历数组每个数x,查哈希表里有没有target-x,有就找到了,没有就把x存进去,一次遍历O(n)搞定。

1
// O(N)解法: 遍历数组,对每个num查找target-num是否在哈希表中
2
typedef struct { int key; int idx; UT_hash_handle hh; } HashEntry;
3
4
int* 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 3
4
5
uint8_t bf_bits[BF_SIZE / 8];
6
7
void 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
14
int 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: 一致性哈希(分布式系统)?

🧠 秒懂: 一致性哈希把服务器和数据映射到一个环上——增减服务器只影响相邻节点的数据,不会引起全局数据迁移,分布式系统解决扩缩容问题的利器。

了解即可(面试可能问原理):

Terminal window
1
传统哈希: hash(key) % N 增减节点需要大量数据迁移
2
一致性哈希: 将哈希空间组织成环
3
- 节点映射到环上
4
- key顺时针找最近节点
5
- 增删节点只影响相邻区域(约1/N的数据迁移)
6
7
嵌入式相关: 分布式IoT网关的负载均衡

Q91: 哈希表在嵌入式中的应用?

🧠 秒懂: 嵌入式端哈希表要注意——内存受限用静态数组而非动态分配,哈希函数要简单高效(如取模),用于注册表、命令解析、设备查找等场景。

1
1. 命令表:
2
hash("LED_ON") → LED_ON_handler
3
hash("TEMP_READ") → TEMP_READ_handler
4
O(1)命令分发(比if-else链快)
5
6
2. 事件订阅:
7
hash(EVENT_ID) → subscriber_list
8
快速找到事件的所有订阅者
9
10
3. 缓存(LRU):
11
hash(key) → cached_value
12
快速判断数据是否已缓存
13
14
4. 去重:
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²)(已排序时)。

1
void 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)额外空间,链表排序的首选。

1
void 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
12
void 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对小数组就用插入排序。

1
void 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)用插入排序最快,中等规模用快排,需要稳定性用归并,内存紧张用堆排,几乎有序用冒泡/插入。

1
N < 16: 插入排序(代码小,无递归,近有序很快)
2
N中等: 快排(一般最快)或堆排(确定性+无额外内存)
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)
2
int 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
14
int 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
// 按位排序,从低位到高位(用计数排序做稳定分配)
2
void 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
// 方法: 给相等元素加序号,排序后检查序号顺序
2
typedef struct { int val; int orig_idx; } Item;
3
4
int 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: 提前终止(某趟无交换 → 已有序)
2
void 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. 邻接矩阵(适合稠密图)
2
int graph[N][N]; // graph[i][j]=权重, 0表示无边
3
// 空间O(N²), 查询O(1), 遍历邻居O(N)
4
5
// 2. 邻接表(适合稀疏图, 嵌入式常用)
6
typedef struct Edge { int to; int weight; struct Edge *next; } Edge;
7
Edge *adj[N]; // adj[i]指向i的所有邻居链表
8
// 空间O(N+E), 遍历邻居O(度)

Q104: BFS(广度优先搜索)?

🧠 秒懂: BFS就像往池塘扔石头——从起点一圈一圈向外扩展,用队列保证层层推进,能找最短路径,适合无权图的最短距离。

1
void 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就像走迷宫——沿一条路走到底,走不通就回退换一条,用栈(或递归)实现,适合连通性判断、拓扑排序、路径搜索。

1
void 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
// 单源最短路径(权重非负)
2
void 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算法):
3
void 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
// 按边权排序 + 并查集检测环
2
typedef struct { int u, v, w; } Edge;
3
4
int cmp_edge(const void *a, const void *b) {
5
return ((Edge*)a)->w - ((Edge*)b)->w;
6
}
7
8
int 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, 求最大价值
2
int 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基础上加了启发函数(估计到终点的距离),优先搜索更可能离终点近的节点,机器人路径规划常用。

路径规划算法(机器人/游戏常用):

1
A*: f(n) = g(n) + h(n)
2
g(n): 起点到n的实际代价
3
h(n): n到终点的估计代价(启发函数)
4
5
用优先级队列(按f值排序):
6
1. 将起点加入open_list
7
2. 取f最小的节点展开
8
3. 对每个邻居: 计算g和h, 加入open_list
9
4. 直到到达终点
10
11
启发函数选择:
12
曼哈顿距离: |x1-x2| + |y1-y2| (4方向移动)
13
欧几里得距离: sqrt((x1-x2)² + (y1-y2)²) (任意方向)
14
15
嵌入式应用: 小车路径规划、扫地机器人

Q111: KMP字符串匹配?

🧠 秒懂: KMP在这里再提一次是因为字符串匹配太重要——核心是next数组(部分匹配表),匹配失败时模式串不从头开始而是跳到合适位置,面试必掌握。

1
// 构建next数组(部分匹配表)
2
void 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
14
int 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. 判断奇偶
2
n & 1 // 1=奇, 0=偶
3
4
// 2. 交换两数(不用临时变量)
5
a ^= b; b ^= a; a ^= b;
6
7
// 3. 取最低位的1
8
n & (-n) // lowbit
9
10
// 4. 清除最低位的1
11
n & (n - 1)
12
13
// 5. 统计1的个数(Brian Kernighan)
14
int count = 0;
15
while (n) { n &= (n-1); count++; }
8 collapsed lines
16
17
// 6. 判断是否是2的幂
18
(n > 0) && (n & (n-1)) == 0
19
20
// 7. 设置/清除/翻转第k位
21
n | (1<<k) // 设置
22
n & ~(1<<k) // 清除
23
n ^ (1<<k) // 翻转

💡 面试追问: 环形缓冲区怎么判断满和空?用size计数还是浪费一个格子? 🔧 嵌入式建议: UART/ADC/音频数据接收必用环形缓冲区。推荐size计数法(清晰);大小用2的幂(可以用位与代替取模)。

Q113: 位图(Bitmap)在嵌入式中的应用?

🧠 秒懂: 位图用一个bit表示一个状态——32位int可以管理32个标志位,嵌入式中用于GPIO状态、任务标志、中断标志、RTOS事件组,极省空间。

1
// 用1位表示一个状态(极省空间)
2
#define BITMAP_SIZE 1024
3
uint32_t bitmap[BITMAP_SIZE / 32];
4
5
void bitmap_set(int n) { bitmap[n/32] |= (1u << (n%32)); }
6
void bitmap_clear(int n) { bitmap[n/32] &= ~(1u << (n%32)); }
7
int 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
5
uint8_t buf[BUF_SIZE];
6
uint32_t head = 0, tail = 0;
7
8
void push(uint8_t data) {
9
buf[head & BUF_MASK] = data; // head & 0xFF 代替 head % 256
10
head++;
11
}
12
13
uint8_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
// 表驱动状态机(查表法)
2
typedef enum { ST_IDLE, ST_RUNNING, ST_ERROR, ST_COUNT } State;
3
typedef enum { EV_START, EV_STOP, EV_FAULT, EV_RESET, EV_COUNT } Event;
4
5
typedef void (*Action)(void);
6
typedef struct { State next_state; Action action; } Transition;
7
8
Transition 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
15
void 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内核的通用双向链表
2
struct list_head {
3
struct list_head *next, *prev;
4
};
5
6
// 嵌入到任意结构体中
7
struct 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查表法(空间换时间)
2
static const uint16_t crc16_table[256] = {
3
0x0000, 0xC0C1, 0xC181, 0x0140, /* ... 256 entries ... */
4
};
5
6
uint16_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字节ROM
2 collapsed lines
16
// 逐位计算: 速度慢, 不占额外空间
17
// 折中: 半字节查表(16项表)

Q118: 内存对齐和结构体填充?

🧠 秒懂: 内存对齐就像停车位——变量地址要对齐到其类型大小的倍数(4字节int对齐到4的倍数),结构体可能有空洞(padding),用sizeof确认实际大小。

1
// 面试高频: 下面结构体占多少字节?
2
struct A {
3
char a; // offset 0, size 1
4
// padding 3 bytes
5
int b; // offset 4, size 4
6
char c; // offset 8, size 1
7
// padding 3 bytes (总大小对齐到最大成员=4)
8
}; // sizeof = 12
9
10
struct B {
11
char a; // offset 0
12
char c; // offset 1
13
// padding 2 bytes
14
int b; // offset 4
15
}; // sizeof = 8 (调整顺序可以省空间!)
6 collapsed lines
16
17
// 嵌入式通信中: __attribute__((packed)) 取消填充
18
struct __attribute__((packed)) Packet {
19
uint8_t type;
20
uint32_t data; // 非对齐访问!某些CPU会fault
21
}; // sizeof = 5

Q119: 软件定时器的数据结构选择?

🧠 秒懂: 软件定时器用最小堆效率最高——堆顶是最快到期的定时器O(1)检查,增删O(logn)。时间轮(TimeWheel)适合大量定时器,FreeRTOS用链表。

Terminal window
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
14
Linux内核: 分层时间轮
15
FreeRTOS: 有序链表(定时器数量通常不多)

Q120: 数据结构在嵌入式面试中的考察重点?

🧠 秒懂: 数据结构面试考察重点——链表反转+环检测必考,栈队列基本操作必会,排序快排+归并手写,树的遍历+BST,嵌入式还要会环形缓冲区和位操作。

Terminal window
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
- 说明嵌入式场景下的选择理由(内存有限/无动态分配)