C++ 经典算法题解析与实现教程
本教程详细讲解了常见的算法题型,包括动态规划、分治法、双指针、位运算等核心技巧。每道题都配有详细的思路分析、图解说明和代码注释,适合算法初学者和面试准备者。
目录
1. 最大子数组和问题
问题描述
LeetCode 53. Maximum Subarray
给定一个整数数组 nums
,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
输入: nums = [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6
解法一: 分治法(线段树思想)
算法核心思想
这是一个经典的分治算法应用。我们将数组从中间分成左右两部分,那么最大子数组和可能出现在三个位置:
- 完全在左半部分
- 完全在右半部分
- 跨越中点(左半部分的某个后缀 + 右半部分的某个前缀)
关键在于如何高效地合并左右两部分的信息。
数据结构设计
为了能够合并区间信息,我们需要维护四个关键值:
cpp
struct Status {
int lSum; // 以区间左边界为起点的最大子数组和(必须包含左边界)
int rSum; // 以区间右边界为终点的最大子数组和(必须包含右边界)
int mSum; // 区间内的最大子数组和(可以在任意位置)
int iSum; // 区间所有元素的和
};
为什么需要这四个值?
lSum
和rSum
:用于计算跨越中点的子数组和mSum
:记录当前区间的答案iSum
:用于合并计算新的lSum
和rSum
图解说明
假设数组为 [-2, 1, -3, 4]
,分治过程如下:
[-2, 1, -3, 4]
/ \
[-2, 1] [-3, 4]
/ \ / \
[-2] [1] [-3] [4]
回溯合并:
[-2]: lSum=-2, rSum=-2, mSum=-2, iSum=-2
[1]: lSum=1, rSum=1, mSum=1, iSum=1
[-2,1]: 合并后 lSum=max(-2, -2+1)=-1, rSum=max(1, 1-2)=1,
mSum=max(-2, 1, -2+1)=1, iSum=-1
完整代码实现
cpp
class Solution {
public:
// 定义状态结构体,用于记录区间的四个关键信息
struct Status {
int lSum; // 从左边界开始的最大子数组和
int rSum; // 到右边界结束的最大子数组和
int mSum; // 区间内的最大子数组和(答案)
int iSum; // 区间总和
};
/**
* pushUp函数:合并左右两个子区间的信息
* @param l 左子区间的状态
* @param r 右子区间的状态
* @return 合并后的状态
*/
Status pushUp(Status l, Status r) {
// 1. 区间总和 = 左区间和 + 右区间和
int iSum = l.iSum + r.iSum;
// 2. 新区间的lSum有两种可能:
// - 只取左区间的lSum
// - 取左区间全部 + 右区间的lSum
int lSum = max(l.lSum, l.iSum + r.lSum);
// 3. 新区间的rSum有两种可能:
// - 只取右区间的rSum
// - 取右区间全部 + 左区间的rSum
int rSum = max(r.rSum, r.iSum + l.rSum);
// 4. 新区间的mSum有三种可能:
// - 在左子区间内(l.mSum)
// - 在右子区间内(r.mSum)
// - 跨越中点(左区间的rSum + 右区间的lSum)
int mSum = max(max(l.mSum, r.mSum), l.rSum + r.lSum);
return (Status) {lSum, rSum, mSum, iSum};
}
/**
* get函数:递归求解区间[l, r]的状态
* @param a 原始数组
* @param l 区间左边界
* @param r 区间右边界
* @return 该区间的状态信息
*/
Status get(vector<int> &a, int l, int r) {
// 递归终止条件:只有一个元素
if (l == r) {
// 单个元素时,四个值都等于该元素本身
return (Status) {a[l], a[l], a[l], a[l]};
}
// 分治:找到中点,分成左右两部分
int m = (l + r) >> 1; // 等价于 (l + r) / 2,但位运算更快
// 递归求解左右子区间
Status lSub = get(a, l, m); // 左半部分 [l, m]
Status rSub = get(a, m + 1, r); // 右半部分 [m+1, r]
// 合并左右子区间的信息
return pushUp(lSub, rSub);
}
/**
* 主函数:求最大子数组和
*/
int maxSubArray(vector<int>& nums) {
// 调用递归函数,返回整个数组的mSum(最大子数组和)
return get(nums, 0, nums.size() - 1).mSum;
}
};
复杂度分析
-
时间复杂度:O(n)
- 每个元素只会被访问一次
- 递归树的高度为 log n,每层处理 n 个元素
- 总时间复杂度为 O(n log n),但由于每层合并是 O(1),实际为 O(n)
-
空间复杂度:O(log n)
- 递归调用栈的深度为 O(log n)
解法二: 动态规划(Kadane算法)
算法核心思想
这是一个更优雅的解法,基于贪心思想。我们维护一个变量 pre
,表示以当前位置结尾的最大子数组和。
关键决策 :对于当前元素 nums[i]
,我们有两个选择:
- 将它加入之前的子数组:
pre + nums[i]
- 从它自己开始一个新的子数组:
nums[i]
我们选择两者中的较大值。如果 pre < 0
,说明之前的子数组是累赘,不如重新开始。
图解说明
以数组 [-2, 1, -3, 4, -1, 2, 1, -5, 4]
为例:
索引: 0 1 2 3 4 5 6 7 8
元素: -2 1 -3 4 -1 2 1 -5 4
pre: -2 1 -2 4 3 5 6 1 5
maxAns: -2 1 1 4 4 5 6 6 6
详细过程:
i=0: pre = max(-2, -2) = -2, maxAns = -2
i=1: pre = max(-2+1, 1) = 1, maxAns = 1 (从1重新开始)
i=2: pre = max(1-3, -3) = -2, maxAns = 1
i=3: pre = max(-2+4, 4) = 4, maxAns = 4 (从4重新开始)
i=4: pre = max(4-1, -1) = 3, maxAns = 4
i=5: pre = max(3+2, 2) = 5, maxAns = 5
i=6: pre = max(5+1, 1) = 6, maxAns = 6
i=7: pre = max(6-5, -5) = 1, maxAns = 6
i=8: pre = max(1+4, 4) = 5, maxAns = 6
完整代码实现
cpp
class Solution {
public:
/**
* 动态规划解法(Kadane算法)
* @param nums 输入数组
* @return 最大子数组和
*/
int maxSubArray(vector<int>& nums) {
// pre: 以当前位置结尾的最大子数组和
// maxAns: 目前为止遇到的最大子数组和(全局最优解)
int pre = 0;
int maxAns = nums[0]; // 初始化为第一个元素
// 遍历数组中的每个元素
for(size_t i = 0; i < nums.size(); i++) {
// 状态转移方程:
// pre = max(pre + nums[i], nums[i])
// 含义:要么延续之前的子数组,要么从当前元素重新开始
pre = max(pre + nums[i], nums[i]);
// 更新全局最大值
maxAns = max(maxAns, pre);
}
return maxAns;
}
};
为什么这个算法是正确的?
贪心策略的正确性证明:
假设 dp[i]
表示以 nums[i]
结尾的最大子数组和,那么:
- 如果
dp[i-1] > 0
,则dp[i] = dp[i-1] + nums[i]
(加上之前的和更大) - 如果
dp[i-1] <= 0
,则dp[i] = nums[i]
(从当前元素重新开始)
这正是 pre = max(pre + nums[i], nums[i])
所表达的含义。
复杂度分析
- 时间复杂度:O(n) - 只需遍历一次数组
- 空间复杂度:O(1) - 只使用了两个变量
两种解法对比
特性 | 分治法 | 动态规划 |
---|---|---|
时间复杂度 | O(n) | O(n) |
空间复杂度 | O(log n) | O(1) |
代码复杂度 | 较复杂 | 简洁 |
思想 | 分治 | 贪心/DP |
推荐度 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
建议:面试中优先使用动态规划解法,代码简洁且易于理解。
2. 数组操作问题
2.1 移除元素
问题描述
LeetCode 27. Remove Element
给你一个数组 nums
和一个值 val
,你需要原地 移除所有数值等于 val
的元素,并返回移除后数组的新长度。不要使用额外的数组空间,必须仅使用 O(1) 额外空间并原地修改输入数组。
示例:
输入: nums = [3,2,2,3], val = 3
输出: 2, nums = [2,2,_,_]
解释: 函数应该返回新的长度 2, 并且 nums 中的前两个元素均为 2
算法思想:双指针法
核心思路:使用快慢双指针
- 慢指针 slow:指向下一个要填充的位置(结果数组的末尾)
- 快指针 fast:用于遍历整个数组,寻找不等于 val 的元素
工作原理:
- 快指针不断向前移动,扫描整个数组
- 当快指针指向的元素不等于 val 时,将该元素复制到慢指针位置,然后慢指针前进
- 当快指针指向的元素等于 val 时,跳过该元素,只有快指针前进
图解说明
示例: nums = [0,1,2,2,3,0,4,2], val = 2
初始状态:
slow fast
↓ ↓
[0, 1, 2, 2, 3, 0, 4, 2]
第1步: nums[0]=0 ≠ 2, 复制并移动slow
slow fast
↓ ↓
[0, 1, 2, 2, 3, 0, 4, 2]
第2步: nums[1]=1 ≠ 2, 复制并移动slow
slow fast
↓ ↓
[0, 1, 2, 2, 3, 0, 4, 2]
第3步: nums[2]=2 = 2, 只移动fast
slow fast
↓ ↓
[0, 1, 2, 2, 3, 0, 4, 2]
第4步: nums[3]=2 = 2, 只移动fast
slow fast
↓ ↓
[0, 1, 2, 2, 3, 0, 4, 2]
第5步: nums[4]=3 ≠ 2, 复制并移动slow
slow fast
↓ ↓
[0, 1, 3, 2, 3, 0, 4, 2]
...最终结果: [0, 1, 3, 0, 4, _, _, _], 返回 slow=5
完整代码实现
cpp
/**
* 原地移除数组中所有等于val的元素
* @param nums 输入数组(会被原地修改)
* @param val 要移除的值
* @return 移除后数组的新长度
*/
int removeElement(vector<int>& nums, int val) {
int fast = 0; // 快指针:遍历数组
int slow = 0; // 慢指针:指向下一个要填充的位置
// 快指针遍历整个数组
for(; fast < nums.size(); fast++) {
// 如果当前元素不等于val,则保留该元素
if(nums[fast] != val) {
// 将fast指向的元素复制到slow位置
nums[slow++] = nums[fast];
// slow++ 表示慢指针前进一步,准备接收下一个有效元素
}
// 如果等于val,则跳过(只有fast前进,slow不动)
}
// slow的最终值就是新数组的长度
// 因为slow始终指向下一个要填充的位置
return slow;
}
代码优化版本
cpp
// 更简洁的写法
int removeElement(vector<int>& nums, int val) {
int slow = 0;
for(int fast = 0; fast < nums.size(); fast++) {
if(nums[fast] != val) {
nums[slow++] = nums[fast];
}
}
return slow;
}
复杂度分析
-
时间复杂度:O(n)
- 快指针遍历数组一次,n 为数组长度
- 每个元素最多被访问两次(读取和复制)
-
空间复杂度:O(1)
- 只使用了两个指针变量
- 原地修改数组,没有使用额外空间
关键要点
-
双指针的本质:分离"读"和"写"操作
- fast指针负责"读"(扫描)
- slow指针负责"写"(保存结果)
-
为什么slow要自增:
nums[slow++] = nums[fast]
等价于:
cppnums[slow] = nums[fast]; // 先赋值 slow = slow + 1; // 再移动到下一个位置
-
边界情况:
- 数组为空:返回0
- 所有元素都等于val:返回0
- 没有元素等于val:返回原数组长度
2.2 合并两个有序数组
问题描述
LeetCode 88. Merge Sorted Array
给你两个按非递减顺序 排列的整数数组 nums1
和 nums2
,另有两个整数 m
和 n
,分别表示 nums1
和 nums2
中的元素数目。
请你合并 nums2
到 nums1
中,使合并后的数组同样按非递减顺序排列。
注意 :nums1
的长度为 m + n
,其中前 m
个元素表示应合并的元素,后 n
个元素为 0,应忽略。
示例:
输入: nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出: [1,2,2,3,5,6]
解释: 合并 [1,2,3] 和 [2,5,6] 的结果是 [1,2,2,3,5,6]
算法思想:逆向双指针
为什么从后往前填充?
- 如果从前往后填充,会覆盖 nums1 中还未处理的元素
- nums1 的后半部分是空的(都是0),从后往前填充不会覆盖有用数据
- 从后往前可以直接在 nums1 上完成合并,不需要额外空间
三指针策略:
i1
:指向 nums1 的有效元素末尾(索引 m-1)i2
:指向 nums2 的末尾(索引 n-1)i
:指向 nums1 的实际末尾(索引 m+n-1),即当前要填充的位置
图解说明
示例: nums1 = [1,2,3,0,0,0], m=3, nums2 = [2,5,6], n=3
初始状态:
i1 i
↓ ↓
nums1: [1, 2, 3, 0, 0, 0]
nums2: [2, 5, 6]
↓
i2
比较: nums1[2]=3 vs nums2[2]=6
6更大,放入nums1[5]
i1 i
↓ ↓
nums1: [1, 2, 3, 0, 0, 6]
nums2: [2, 5, 6]
↓
i2
比较: nums1[2]=3 vs nums2[1]=5
5更大,放入nums1[4]
i1 i
↓ ↓
nums1: [1, 2, 3, 0, 5, 6]
nums2: [2, 5, 6]
↓
i2
比较: nums1[2]=3 vs nums2[0]=2
3更大,放入nums1[3]
i1 i
↓ ↓
nums1: [1, 2, 3, 3, 5, 6]
nums2: [2, 5, 6]
↓
i2
比较: nums1[1]=2 vs nums2[0]=2
相等,任选一个(这里选nums1)
i1 i
↓ ↓
nums1: [1, 2, 2, 3, 5, 6]
nums2: [2, 5, 6]
↓
i2
最终结果: [1, 2, 2, 3, 5, 6]
完整代码实现
cpp
/**
* 合并两个有序数组
* @param nums1 第一个数组,长度为m+n,后n个位置为0(预留空间)
* @param m nums1中有效元素的个数
* @param nums2 第二个数组,长度为n
* @param n nums2中元素的个数
*/
void merge(vector<int>& nums1, int m, vector<int>& nums2, int n) {
int i1 = m - 1; // nums1有效元素的最后一个索引
int i2 = n - 1; // nums2的最后一个索引
// 从后往前填充nums1,i是当前要填充的位置
for(int i = m + n - 1; i >= 0; i--) {
// 情况1: nums2已经全部处理完(i2 < 0)
// 或者 nums1[i1] >= nums2[i2]
// 此时应该取nums1[i1]
if(i2 < 0 || (i1 >= 0 && nums1[i1] >= nums2[i2])) {
nums1[i] = nums1[i1--]; // 取nums1的元素,i1前移
}
// 情况2: nums1已经全部处理完,或者 nums2[i2] > nums1[i1]
// 此时应该取nums2[i2]
else {
nums1[i] = nums2[i2--]; // 取nums2的元素,i2前移
}
}
}
详细逻辑说明
条件判断的优先级:
cpp
if(i2 < 0 || (i1 >= 0 && nums1[i1] >= nums2[i2]))
这个条件分为两部分:
-
i2 < 0
:nums2已经全部放入nums1- 此时只需要将nums1剩余元素保持原位即可
- 实际上这时
nums1[i1--]
就是在"移动"自己到自己
-
i1 >= 0 && nums1[i1] >= nums2[i2]
:i1 >= 0
:确保nums1还有元素未处理nums1[i1] >= nums2[i2]
:nums1的当前元素更大或相等- 取较大的元素放入当前位置
为什么使用 >=
而不是 >
?
- 当两个元素相等时,优先取nums1的元素
- 这样可以保持稳定性(相同元素的相对顺序不变)
复杂度分析
-
时间复杂度:O(m + n)
- 需要处理两个数组的所有元素
- 每个元素只被访问和移动一次
-
空间复杂度:O(1)
- 直接在nums1上进行原地操作
- 只使用了3个指针变量
边界情况处理
cpp
// 测试用例
1. nums1=[1], m=1, nums2=[], n=0
输出: [1]
2. nums1=[0], m=0, nums2=[1], n=1
输出: [1]
3. nums1=[2,0], m=1, nums2=[1], n=1
输出: [1,2]
4. nums1=[1,2,3,0,0,0], m=3, nums2=[2,5,6], n=3
输出: [1,2,2,3,5,6]
关键技巧总结
- 从后往前的智慧:避免元素覆盖问题
- 条件判断的严谨性:必须先检查索引是否越界
- 指针自减的时机:在使用完当前元素后立即自减
- 空间利用:充分利用nums1预留的空间
2.3 寻找数组的中心索引
问题描述
LeetCode 724. Find Pivot Index
给你一个整数数组 nums
,请计算数组的中心下标。
数组中心下标是数组的一个下标,其左侧所有元素相加的和等于右侧所有元素相加的和。如果中心下标位于数组最左端,那么左侧数之和视为0,因为在下标的左侧不存在元素。这一规则同样适用于中心下标位于数组最右端的情况。
如果数组有多个中心下标,应该返回最靠近左边的那一个。如果数组不存在中心下标,返回 -1。
示例:
输入: nums = [1, 7, 3, 6, 5, 6]
输出: 3
解释:
中心下标是 3
左侧数之和 sum = nums[0] + nums[1] + nums[2] = 1 + 7 + 3 = 11
右侧数之和 sum = nums[4] + nums[5] = 5 + 6 = 11
算法思想:前缀和 + 双指针
核心思路:
- 维护两个变量:
left
(左侧和)和right
(右侧和) - 遍历数组,动态更新左右两侧的和
- 当
left == right
时,找到中心索引
关键观察:
- 对于索引
i
,左侧和 =nums[0] + ... + nums[i-1]
- 右侧和 =
nums[i+1] + ... + nums[n-1]
- 不包括
nums[i]
本身
图解说明
示例: nums = [1, 7, 3, 6, 5, 6]
初始化:
- 计算总和 right = 1+7+3+6+5+6 = 28
- left = 0
- right -= nums[0] = 28-1 = 27
索引 0: left=0, right=27 ❌ (0 ≠ 27)
↓
[1, 7, 3, 6, 5, 6]
索引 1: left=0+1=1, right=27-7=20 ❌ (1 ≠ 20)
↓
[1, 7, 3, 6, 5, 6]
索引 2: left=1+7=8, right=20-3=17 ❌ (8 ≠ 17)
↓
[1, 7, 3, 6, 5, 6]
索引 3: left=8+3=11, right=17-6=11 ✅ (11 = 11)
↓
[1, 7, 3,找到中心索引!
左侧: [1, 7, 3] 和为11
中心: 6
右侧: [5, 6] 和为11
完整代码实现
cpp
/**
* 寻找数组的中心索引
* @param nums 输入数组
* @return 中心索引,不存在则返回-1
*/
int pivotIndex(vector<int>& nums) {
int left = 0; // 左侧元素的和
int right = 0; // 右侧元素的和
// 第一步:计算整个数组的总和(初始时作为右侧和)
for(int i = 0; i < nums.size(); i++)
right += nums[i];
// 第二步:特殊处理索引0(左侧没有元素)
right -= nums[0]; // 右侧和 = 总和 - nums[0]
if(right == 0) // 如果右侧和为0,说明索引0就是中心索引
return 0;
// 第三步:从索引1开始遍历,检查每个索引是否为中心索引
for(int i = 1; i < nums.size(); i++) {
// 更新左侧和:加上前一个元素
left += nums[i - 1];
// 更新右侧和:减去当前元素
right -= nums[i];
// 检查是否找到中心索引
if(left == right) {
return i; // 找到了,立即返回
}
}
// 遍历完整个数组都没找到,返回-1
return -1;
}
算法详解
为什么要分两步处理?
-
索引0的特殊性:
cpp// 索引0时,left必然为0(左侧无元素) // right = 总和 - nums[0] if(right == 0) return 0;
-
从索引1开始的循环:
cppfor(int i = 1; i < nums.size(); i++) { // 对于索引i: // left应该包含 nums[0]...nums[i-1] // right应该包含 nums[i+1]...nums[n-1] }
状态转移过程:
对于每个新的索引i(从1开始):
1. left = left + nums[i-1] (左侧新增一个元素)
2. right = right - nums[i] (右侧减少当前元素)
这样保证了:
- left始终是索引i左侧所有元素的和
- right始终是索引i右侧所有元素的和
另一种更清晰的实现
cpp
/**
* 更直观的实现方式(推荐用于理解)
*/
int pivotIndex(vector<int>& nums) {
// 1. 计算总和
int total = 0;
for(int num : nums) {
total += num;
}
// 2. 从左到右遍历,维护左侧和
int leftSum = 0;
for(int i = 0; i < nums.size(); i++) {
// 当前索引的右侧和 = 总和 - 左侧和 - 当前元素
int rightSum = total - leftSum - nums[i];
// 检查是否为中心索引
if(leftSum == rightSum) {
return i;
}
// 更新左侧和(为下一次循环准备)
leftSum += nums[i];
}
return -1;
}
这种实现的优点:
- 逻辑更清晰,容易理解
- 不需要特殊处理索引0
- 代码结构更统一
复杂度分析
-
时间复杂度:O(n)
- 第一次遍历计算总和:O(n)
- 第二次遍历查找中心索引:O(n)
- 总时间复杂度:O(n)
-
空间复杂度:O(1)
- 只使用了常数个变量(left, right, total等)
边界情况测试
cpp
测试用例:
1. nums = [1, 7, 3, 6, 5, 6]
输出: 3
2. nums = [1, 2, 3]
输出: -1
解释: 没有中心索引
3. nums = [2, 1, -1]
输出: 0
解释: left=0, right=1+(-1)=0
4. nums = [1]
输出: 0
解释: 单个元素,left=0, right=0
5. nums = [-1, -1, -1, -1, -1, 0]
输出: 2
解释: left=(-1)+(-1)=-2, right=(-1)+(-1)+0=-2
常见错误
错误1:包含了当前元素
cpp
// ❌ 错误写法
if(left == right && left + nums[i] == total) {
// 中心索引不应该包含nums[i]
}
错误2:没有处理负数
cpp
// ✅ 本算法自动处理负数
// 因为我们用的是加减法,不涉及绝对值
错误3:数组为空时的处理
cpp
// 如果需要处理空数组
if(nums.empty()) return -1;
3. 字符串处理问题
查找常用字符
问题描述
LeetCode 1002. Find Common Characters
给你一个字符串数组 words
,请你找出所有在 words
的每个字符串中都出现的共用字符(包括重复字符),并以数组形式返回。你可以按任意顺序返回答案。
示例:
输入: words = ["bella","label","roller"]
输出: ["e","l","l"]
解释:
- 'e' 在所有字符串中都出现1次
- 'l' 在 "bella" 中出现2次,在 "label" 中出现1次,在 "roller" 中出现2次
所以最多只能取1次(取最小值)
- 'l' 可以重复输出
算法思想:频率统计 + 取最小值
核心思路:
- 对每个字符串,统计每个字母的出现频率
- 维护一个"最小频率数组",记录每个字母在所有字符串中的最小出现次数
- 根据最小频率构建结果数组
为什么取最小值?
- 如果字母 'a' 在第一个字符串中出现3次,在第二个字符串中出现2次
- 那么最多只能取2次(受限于最少出现的那个字符串)
图解说明
示例: words = ["bella", "label", "roller"]
第1步:处理 "bella"
字母频率: a:1, b:1, e:1, l:2
minfreq: a:1, b:1, e:1, l:2, ...其他字母:101
第2步:处理 "label"
字母频率: a:1, b:1, e:1, l:2
更新minfreq (取最小值):
a: min(1,1)=1, b: min(1,1)=1, e: min(1,1)=1, l: min(2,2)=2
第3步:处理 "roller"
字母频率: e:1, l:2, o:1, r:2
更新minfreq:
a: min(1,0)=0 (roller中没有a)
b: min(1,0)=0 (roller中没有b)
e: min(1,1)=1 ✓
l: min(2,2)=2 ✓ (但下一步会减到1,因为label只有1个l)
等等,让我们重新仔细计算...
实际上 "label" 中 l 的个数:
l-a-b-e-l → l出现2次 ❌
仔细数:l(1个), a, b, e, l(第2个) → 共2个 ✓
"roller" 中 l 的个数:
r-o-l-l-e-r → l出现2次 ✓
所以最终 l 的最小频率 = min(2,2,2) = 2 ❌
让我再检查一遍...实际上题目示例输出是 ["e","l","l"]
说明 l 确实应该是2次
最终结果:
- e: 最小频率=1 → 输出1个 "e"
- l: 最小频率=2 → 输出2个 "l"
完整代码实现
cpp
/**
* 查找所有字符串中的公共字符(包括重复)
* @param words 字符串数组
* @return 公共字符数组
*/
vector<string> commonChars(vector<string>& words) {
// minfreq[i] 表示字母 ('a'+i) 在所有字符串中的最小出现次数
// 初始化为101(一个足够大的数,因为字符串长度不超过100)
vector<int> minfreq(26, 101);
// 遍历每个字符串
for(string &word : words) {
// freq[i] 统计当前字符串中字母 ('a'+i) 的出现次数
vector<int> freq(26, 0);
// 统计当前字符串中每个字母的频率
for(char &ch : word) {
++freq[ch - 'a']; // ch-'a' 将字符映射到 0-25
}
// 更新全局最小频率
// 对于每个字母,取当前字符串的频率和历史最小频率的较小值
for(int i = 0; i < 26; ++i) {
minfreq[i] = min(minfreq[i], freq[i]);
}
}
// 根据最小频率构建结果数组
vector<string> ans;
for(int i = 0; i < 26; ++i) {
// 如果字母 ('a'+i) 的最小频率 > 0,说明它在所有字符串中都出现过
while(minfreq[i]-- > 0) {
// string(1, ch) 创建一个只包含字符ch的字符串
ans.emplace_back(string(1, i + 'a'));
}
}
return ans;
}
代码详解
1. 字符映射技巧
cpp
freq[ch - 'a']
// 将字符映射到数组索引
// 'a' -> 0, 'b' -> 1, ..., 'z' -> 25
2. 为什么初始化为101?
cpp
vector<int> minfreq(26, 101);
// 因为字符串长度 <= 100(LeetCode约束)
// 用101作为"无穷大"的替代
// 第一次更新时,min(101, 实际频率) = 实际频率
3. 构建单字符字符串
cpp
string(1, 'a') // 创建字符串 "a"
// 等价于:
string s;
s.push_back('a');
4. emplace_back vs push_back
cpp
ans.emplace_back(string(1, i + 'a')); // 直接在容器中构造对象
ans.push_back(string(1, i + 'a')); // 先构造对象再拷贝
// emplace_back 更高效,因为避免了拷贝
执行流程示例
cpp
输入: words = ["cool", "lock", "cook"]
初始化: minfreq = [101, 101, ..., 101] (26个)
处理 "cool":
freq: c:1, o:2, l:1 (其他为0)
minfreq: c:1, o:2, l:1 (其他为101)
处理 "lock":
freq: c:1, k:1, l:1, o:1
minfreq: c:min(1,1)=1, k:min(101,1)=1, l:min(1,1)=1,
o:min(2,1)=1
处理 "cook":
freq: c:1, k:1, o:2
minfreq: c:min(1,1)=1, k:min(1,1)=1, l:min(1,0)=0,
o:min(1,2)=1
最终 minfreq: c:1, k:1, l:0, o:1
构建结果: ["c", "k", "o"]
复杂度分析
-
时间复杂度:O(n × m)
- n:字符串个数
- m:字符串的平均长度
- 外层循环 n 次,每次处理一个长度为 m 的字符串
- 内层统计频率和更新最小值都是 O(m) 和 O(26)
-
空间复杂度:O(1)
- minfreq 数组固定大小 26
- freq 数组固定大小 26
- 不计入输出数组的空间
- 本质上是常数空间
优化版本
cpp
/**
* 代码优化:减少重复计算
*/
vector<string> commonChars(vector<string>& words) {
vector<int> minfreq(26, INT_MAX); // 使用INT_MAX更标准
for(const string &word : words) { // const引用避免拷贝
vector<int> freq(26, 0);
for(char ch : word) {
++freq[ch - 'a'];
}
for(int i = 0; i < 26; ++i) {
minfreq[i] = min(minfreq[i], freq[i]);
}
}
vector<string> ans;
for(int i = 0; i < 26; ++i) {
ans.insert(ans.end(), minfreq[i], string(1, 'a' + i));
// insert 可以一次插入多个相同元素
}
return ans;
}
关键技巧总结
- 字符计数数组:用固定大小的数组(26)替代哈希表
- 取最小值:多个集合的交集问题,通常需要取最小频率
- 字符映射 :
ch - 'a'
是处理小写字母的标准技巧 - 构造字符串 :
string(count, ch)
可以快速创建重复字符
4. 链表问题
两数相加(链表形式)
问题描述
LeetCode 445. Add Two Numbers II
给你两个非空链表来代表两个非负整数。数字最高位位于链表开始位置。它们的每个节点只存储一位数字。将这两数相加会返回一个新的链表。
你可以假设除了数字 0 之外,这两个数字都不会以零开头。
示例:
输入: l1 = [7,2,4,3], l2 = [5,6,4]
代表数字: 7243 + 564 = 7807
输出: [7,8,0,7]
链表可视化:
7 -> 2 -> 4 -> 3
+ 5 -> 6 -> 4
----------------------
7 -> 8 -> 0 -> 7
算法思想:栈 + 逆序处理 + 头插法
为什么使用栈?
- 加法需要从低位到高位计算(个位、十位、百位...)
- 但链表是从高位到低位存储的
- 栈可以实现"逆序访问"的效果(先进后出)
算法步骤:
- 将两个链表的所有值压入栈中
- 从栈顶弹出元素(相当于从个位开始)
- 逐位相加,处理进位
- 使用头插法构建结果链表(保证高位在前)
图解说明
示例: l1 = [7,2,4,3], l2 = [5,6,4]
步骤1: 将链表压栈
s1: |3| <- 栈顶 s2: |4| <- 栈顶
|4| |6|
|2| |5|
|7| <- 栈底 <- 栈底
步骤2: 从栈顶开始相加(从个位开始)
第1次: 3 + 4 + 0(进位) = 7, 进位=0
创建节点: 7 -> null
head指向7
第2次: 4 + 6 + 0 = 10, 进位=1, 当前位=0
创建节点: 0 -> 7 -> null
head指向0
第3次: 2 + 5 + 1 = 8, 进位=0
创建节点: 8 -> 0 -> 7 -> null
head指向8
第4次: 7 + 0 + 0 = 7, 进位=0
创建节点: 7 -> 8 -> 0 -> 7 -> null
head指向7
最终结果: 7 -> 8 -> 0 -> 7
头插法详解
什么是头插法?
cpp
// 头插法:新节点总是插入到链表头部
ListNode* node = new ListNode(value);
node->next = head; // 新节点指向原来的头节点
head = node; // 更新头指针
// 效果:后创建的节点在前面
// 创建顺序: 7 -> 0 -> 8 -> 7
// 最终链表: 7 -> 8 -> 0 -> 7 (正好是高位在前)
完整代码实现
cpp
/**
* 链表节点定义
*/
struct ListNode {
int val;
ListNode *next;
ListNode(int x) : val(x), next(nullptr) {}
};
/**
* 两数相加(链表形式,高位在前)
* @param l1 第一个链表
* @param l2 第二个链表
* @return 相加结果的链表
*/
ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
stack<int> s1, s2; // 用于存储两个链表的值
/*
* 第1步:将两条链表的所有值压入栈
* 作用:实现逆序访问(从个位开始)
*/
while(l1) {
s1.push(l1->val);
l1 = l1->next;
}
while(l2) {
s2.push(l2->val);
l2 = l2->next;
}
int carry = 0; // 进位(初始为0)
ListNode* head = nullptr; // 结果链表的头节点(初始为空)
/*
* 第2步:从栈顶开始弹出元素并相加
* 循环条件:只要还有数字或还有进位就继续
*/
while(!s1.empty() || !s2.empty() || carry) {
// 获取当前位的两个数字(如果栈为空则取0)
int a = s1.empty() ? 0 : s1.top();
if(!s1.empty()) s1.pop(); // 弹出已使用的元素
int b = s2.empty() ? 0 : s2.top();
if(!s2.empty()) s2.pop();
// 计算当前位的和(包括进位)
int sum = a + b + carry;
carry = sum / 10; // 新的进位(整除10)
sum %= 10; // 当前位的值(对10取余)
/*
* 第3步:使用头插法构建新节点
* 为什么用头插法?
* - 我们从低位到高位计算
* - 但结果需要高位在前
* - 头插法正好实现了"倒序"效果
*/
ListNode* node = new ListNode(sum);
node->next = head; // 新节点的next指向当前头节点
head = node; // 更新头指针为新节点
}
return head;
}
代码详解
1. 为什么循环条件是三个条件的OR?
cpp
while(!s1.empty() || !s2.empty() || carry)
!s1.empty()
:s1还有数字未处理!s2.empty()
:s2还有数字未处理carry
:还有进位需要处理
关键场景:
99 + 1 = 100
当两个栈都空了,但carry=1,还需要再创建一个节点存储进位
2. 三目运算符处理空栈
cpp
int a = s1.empty() ? 0 : s1.top();
// 如果栈为空,用0参与计算
// 否则取栈顶元素
3. 头插法的精妙之处
cpp
// 传统尾插法(需要维护tail指针):
tail->next = new ListNode(sum);
tail = tail->next;
// 头插法(只需要head指针):
ListNode* node = new ListNode(sum);
node->next = head;
head = node;
// 头插法更简洁,且自动实现了逆序
执行流程示例
cpp
输入: l1 = [9,9], l2 = [1]
代表: 99 + 1 = 100
栈的状态:
s1: |9| |9| s2: |1|
迭代过程:
1. a=9, b=1, sum=10, carry=1, 当前位=0
链表: 0 -> null
2. a=9, b=0, sum=9+0+1=10, carry=1, 当前位=0
链表: 0 -> 0 -> null
3. a=0, b=0, sum=0+0+1=1, carry=0, 当前位=1
链表: 1 -> 0 -> 0 -> null
输出: [1,0,0] ✓
复杂度分析
-
时间复杂度:O(max(m, n))
- m, n 分别是两个链表的长度
- 需要遍历两个链表各一次:O(m + n)
- 需要处理max(m, n)+1位数字(可能有进位)
-
空间复杂度:O(m + n)
- 两个栈分别存储 m 和 n 个元素
- 结果链表不计入空间复杂度(这是输出)
不使用栈的解法(进阶)
如果面试官要求 O(1) 空间复杂度,可以考虑:
cpp
/**
* 方法:先反转链表,再相加,最后再反转回来
* 空间复杂度:O(1)
*/
ListNode* reverseList(ListNode* head) {
ListNode *prev = nullptr, *curr = head;
while(curr) {
ListNode* next = curr->next;
curr->next = prev;
prev = curr;
curr = next;
}
return prev;
}
ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
// 1. 反转两个链表
l1 = reverseList(l1);
l2 = reverseList(l2);
// 2. 从低位到高位相加(现在低位在前面)
ListNode *dummy = new ListNode(0), *curr = dummy;
int carry = 0;
while(l1 || l2 || carry) {
int sum = (l1 ? l1->val : 0) + (l2 ? l2->val : 0) + carry;
carry = sum / 10;
curr->next = new ListNode(sum % 10);
curr = curr->next;
if(l1) l1 = l1->next;
if(l2) l2 = l2->next;
}
// 3. 反转结果链表
return reverseList(dummy->next);
}
关键技巧总结
- 栈实现逆序:处理需要从末尾开始的链表问题
- 头插法构建链表:自动实现逆序效果
- 进位处理 :循环条件要包含
carry != 0
- 处理不等长:用三目运算符,空栈时补0
5. 位运算问题
5.1 数组中两个只出现一次的数字
问题描述
剑指 Offer II 070 / LeetCode 260
一个整数数组 nums
里除两个数字之外,其他数字都出现了两次。请找出这两个只出现一次的数字。要求时间复杂度是O(n),空间复杂度是O(1)。
示例:
输入: nums = [1,2,1,3,2,5]
输出: [3,5] 或 [5,3]
解释: 除了3和5,其他数字都出现两次
前置知识:异或运算的性质
异或(XOR)是位运算的一种,用符号 ^
表示。
异或的核心性质:
cpp
1. a ^ a = 0 // 相同的数异或结果为0
2. a ^ 0 = a // 任何数与0异或等于自身
3. a ^ b = b ^ a // 交换律
4. (a ^ b) ^ c = a ^ (b ^ c) // 结合律
推论:
a ^ b ^ a = (a ^ a) ^ b = 0 ^ b = b
// 两个相同的数可以"消掉"
经典应用:
cpp
// 数组中只有一个数出现1次,其他都出现2次,找出这个数
int findSingle(vector<int>& nums) {
int result = 0;
for(int num : nums) {
result ^= num; // 所有数异或
}
return result; // 成对的数都消掉了,剩下的就是单独的数
}
算法思想:分组异或
核心难点:如何区分两个不同的数?
如果直接全部异或,得到的是 a ^ b
(两个目标数字的异或结果),无法还原出 a 和 b。
**解决方案# C++ 经典算法题解析与实现教程
本教程详细讲解了常见的算法题型,包括动态规划、分治法、双指针、位运算等核心技巧。每道题都配有详细的思路分析、图解说明和代码注释,适合算法初学者和面试准备者。