当前位置: 首页 > news >正文

C++ 经典数组算法题解析与实现教程

C++ 经典算法题解析与实现教程

本教程详细讲解了常见的算法题型,包括动态规划、分治法、双指针、位运算等核心技巧。每道题都配有详细的思路分析、图解说明和代码注释,适合算法初学者和面试准备者。

目录

  1. 最大子数组和问题
  2. 数组操作问题
  3. 字符串处理问题
  4. 链表问题
  5. 位运算问题

1. 最大子数组和问题

问题描述

LeetCode 53. Maximum Subarray

给定一个整数数组 nums,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例:

输入: nums = [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6

解法一: 分治法(线段树思想)

算法核心思想

这是一个经典的分治算法应用。我们将数组从中间分成左右两部分,那么最大子数组和可能出现在三个位置:

  1. 完全在左半部分
  2. 完全在右半部分
  3. 跨越中点(左半部分的某个后缀 + 右半部分的某个前缀)

关键在于如何高效地合并左右两部分的信息。

数据结构设计

为了能够合并区间信息,我们需要维护四个关键值:

struct Status {int lSum;  // 以区间左边界为起点的最大子数组和(必须包含左边界)int rSum;  // 以区间右边界为终点的最大子数组和(必须包含右边界)int mSum;  // 区间内的最大子数组和(可以在任意位置)int iSum;  // 区间所有元素的和
};

为什么需要这四个值?

  • lSumrSum:用于计算跨越中点的子数组和
  • mSum:记录当前区间的答案
  • iSum:用于合并计算新的 lSumrSum
图解说明

假设数组为 [-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
完整代码实现
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//    - 取左区间全部 + 右区间的lSumint lSum = max(l.lSum, l.iSum + r.lSum);// 3. 新区间的rSum有两种可能://    - 只取右区间的rSum//    - 取右区间全部 + 左区间的rSumint 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],我们有两个选择:

  1. 将它加入之前的子数组:pre + nums[i]
  2. 从它自己开始一个新的子数组: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
完整代码实现
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, 复制并移动slowslow fast↓    ↓[0, 1, 2, 2, 3, 0, 4, 2]第2步: nums[1]=1 ≠ 2, 复制并移动slowslow fast↓    ↓[0, 1, 2, 2, 3, 0, 4, 2]第3步: nums[2]=2 = 2, 只移动fastslow    fast↓       ↓[0, 1, 2, 2, 3, 0, 4, 2]第4步: nums[3]=2 = 2, 只移动fastslow       fast↓          ↓[0, 1, 2, 2, 3, 0, 4, 2]第5步: nums[4]=3 ≠ 2, 复制并移动slowslow    fast↓       ↓[0, 1, 3, 2, 3, 0, 4, 2]...最终结果: [0, 1, 3, 0, 4, _, _, _], 返回 slow=5
完整代码实现
/*** 原地移除数组中所有等于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;
}
代码优化版本
// 更简洁的写法
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)

    • 只使用了两个指针变量
    • 原地修改数组,没有使用额外空间
关键要点
  1. 双指针的本质:分离"读"和"写"操作

    • fast指针负责"读"(扫描)
    • slow指针负责"写"(保存结果)
  2. 为什么slow要自增

    • nums[slow++] = nums[fast] 等价于:
    nums[slow] = nums[fast];  // 先赋值
    slow = slow + 1;           // 再移动到下一个位置
    
  3. 边界情况

    • 数组为空:返回0
    • 所有元素都等于val:返回0
    • 没有元素等于val:返回原数组长度

2.2 合并两个有序数组

问题描述

LeetCode 88. Merge Sorted Array

给你两个按非递减顺序排列的整数数组 nums1nums2,另有两个整数 mn,分别表示 nums1nums2 中的元素数目。

请你合并 nums2nums1 中,使合并后的数组同样按非递减顺序排列。

注意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]
完整代码实现
/*** 合并两个有序数组* @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前移}}
}
详细逻辑说明

条件判断的优先级

if(i2 < 0 || (i1 >= 0 && nums1[i1] >= nums2[i2]))

这个条件分为两部分:

  1. i2 < 0:nums2已经全部放入nums1

    • 此时只需要将nums1剩余元素保持原位即可
    • 实际上这时 nums1[i1--] 就是在"移动"自己到自己
  2. i1 >= 0 && nums1[i1] >= nums2[i2]

    • i1 >= 0:确保nums1还有元素未处理
    • nums1[i1] >= nums2[i2]:nums1的当前元素更大或相等
    • 取较大的元素放入当前位置

为什么使用 >= 而不是 >

  • 当两个元素相等时,优先取nums1的元素
  • 这样可以保持稳定性(相同元素的相对顺序不变)
复杂度分析
  • 时间复杂度:O(m + n)

    • 需要处理两个数组的所有元素
    • 每个元素只被访问和移动一次
  • 空间复杂度:O(1)

    • 直接在nums1上进行原地操作
    • 只使用了3个指针变量
边界情况处理
// 测试用例
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]
关键技巧总结
  1. 从后往前的智慧:避免元素覆盖问题
  2. 条件判断的严谨性:必须先检查索引是否越界
  3. 指针自减的时机:在使用完当前元素后立即自减
  4. 空间利用:充分利用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
完整代码实现
/*** 寻找数组的中心索引* @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;  // 找到了,立即返回}}// 遍历完整个数组都没找到,返回-1return -1;
}
算法详解

为什么要分两步处理?

  1. 索引0的特殊性

    // 索引0时,left必然为0(左侧无元素)
    // right = 总和 - nums[0]
    if(right == 0) return 0;
    
  2. 从索引1开始的循环

    for(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右侧所有元素的和
另一种更清晰的实现
/*** 更直观的实现方式(推荐用于理解)*/
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等)
边界情况测试
测试用例:1. nums = [1, 7, 3, 6, 5, 6]输出: 32. nums = [1, 2, 3]输出: -1解释: 没有中心索引3. nums = [2, 1, -1]输出: 0解释: left=0, right=1+(-1)=04. nums = [1]输出: 0解释: 单个元素,left=0, right=05. nums = [-1, -1, -1, -1, -1, 0]输出: 2解释: left=(-1)+(-1)=-2, right=(-1)+(-1)+0=-2
常见错误

错误1:包含了当前元素

// ❌ 错误写法
if(left == right && left + nums[i] == total) {// 中心索引不应该包含nums[i]
}

错误2:没有处理负数

// ✅ 本算法自动处理负数
// 因为我们用的是加减法,不涉及绝对值

错误3:数组为空时的处理

// 如果需要处理空数组
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' 可以重复输出
算法思想:频率统计 + 取最小值

核心思路

  1. 对每个字符串,统计每个字母的出现频率
  2. 维护一个"最小频率数组",记录每个字母在所有字符串中的最小出现次数
  3. 根据最小频率构建结果数组

为什么取最小值?

  • 如果字母 ‘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"
完整代码实现
/*** 查找所有字符串中的公共字符(包括重复)* @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. 字符映射技巧

freq[ch - 'a']
// 将字符映射到数组索引
// 'a' -> 0, 'b' -> 1, ..., 'z' -> 25

2. 为什么初始化为101?

vector<int> minfreq(26, 101);
// 因为字符串长度 <= 100(LeetCode约束)
// 用101作为"无穷大"的替代
// 第一次更新时,min(101, 实际频率) = 实际频率

3. 构建单字符字符串

string(1, 'a')  // 创建字符串 "a"
// 等价于:
string s;
s.push_back('a');

4. emplace_back vs push_back

ans.emplace_back(string(1, i + 'a'));  // 直接在容器中构造对象
ans.push_back(string(1, i + 'a'));     // 先构造对象再拷贝// emplace_back 更高效,因为避免了拷贝
执行流程示例
输入: 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
    • 不计入输出数组的空间
    • 本质上是常数空间
优化版本
/*** 代码优化:减少重复计算*/
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;
}
关键技巧总结
  1. 字符计数数组:用固定大小的数组(26)替代哈希表
  2. 取最小值:多个集合的交集问题,通常需要取最小频率
  3. 字符映射ch - 'a' 是处理小写字母的标准技巧
  4. 构造字符串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
算法思想:栈 + 逆序处理 + 头插法

为什么使用栈?

  • 加法需要从低位到高位计算(个位、十位、百位…)
  • 但链表是从高位到低位存储的
  • 栈可以实现"逆序访问"的效果(先进后出)

算法步骤:

  1. 将两个链表的所有值压入栈中
  2. 从栈顶弹出元素(相当于从个位开始)
  3. 逐位相加,处理进位
  4. 使用头插法构建结果链表(保证高位在前)
图解说明
示例: 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
头插法详解

什么是头插法?

// 头插法:新节点总是插入到链表头部
ListNode* node = new ListNode(value);
node->next = head;  // 新节点指向原来的头节点
head = node;         // 更新头指针// 效果:后创建的节点在前面
// 创建顺序: 7 -> 0 -> 8 -> 7
// 最终链表: 7 -> 8 -> 0 -> 7 (正好是高位在前)
完整代码实现
/*** 链表节点定义*/
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?

while(!s1.empty() || !s2.empty() || carry)
  • !s1.empty():s1还有数字未处理
  • !s2.empty():s2还有数字未处理
  • carry:还有进位需要处理

关键场景:

99 + 1 = 100
当两个栈都空了,但carry=1,还需要再创建一个节点存储进位

2. 三目运算符处理空栈

int a = s1.empty() ? 0 : s1.top();
// 如果栈为空,用0参与计算
// 否则取栈顶元素

3. 头插法的精妙之处

// 传统尾插法(需要维护tail指针):
tail->next = new ListNode(sum);
tail = tail->next;// 头插法(只需要head指针):
ListNode* node = new ListNode(sum);
node->next = head;
head = node;// 头插法更简洁,且自动实现了逆序
执行流程示例
输入: l1 = [9,9], l2 = [1]代表: 99 + 1 = 100栈的状态:
s1: |9| |9|    s2: |1|迭代过程:
1. a=9, b=1, sum=10, carry=1, 当前位=0链表: 0 -> null2. a=9, b=0, sum=9+0+1=10, carry=1, 当前位=0链表: 0 -> 0 -> null3. 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) 空间复杂度,可以考虑:

/*** 方法:先反转链表,再相加,最后再反转回来* 空间复杂度: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);
}
关键技巧总结
  1. 栈实现逆序:处理需要从末尾开始的链表问题
  2. 头插法构建链表:自动实现逆序效果
  3. 进位处理:循环条件要包含 carry != 0
  4. 处理不等长:用三目运算符,空栈时补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)是位运算的一种,用符号 ^ 表示。

异或的核心性质:

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
// 两个相同的数可以"消掉"

经典应用:

// 数组中只有一个数出现1次,其他都出现2次,找出这个数
int findSingle(vector<int>& nums) {int result = 0;for(int num : nums) {result ^= num;  // 所有数异或}return result;  // 成对的数都消掉了,剩下的就是单独的数
}
算法思想:分组异或

核心难点:如何区分两个不同的数?

如果直接全部异或,得到的是 a ^ b(两个目标数字的异或结果),无法还原出 a 和 b。

**解决方案# C++ 经典算法题解析与实现教程

本教程详细讲解了常见的算法题型,包括动态规划、分治法、双指针、位运算等核心技巧。每道题都配有详细的思路分析、图解说明和代码注释,适合算法初学者和面试准备者。

http://www.dtcms.com/a/460839.html

相关文章:

  • 详解SOA架构,微服务架构,中台架构以及他们之间的区别和联系
  • 【C++学习笔记】伪随机数生成
  • Unity笔记(十二)——角色控制器、导航寻路系统
  • 关于嵌入式硬件需要了解的基础知识
  • 个人电脑做服务器网站目录型搜索引擎有哪些
  • 从赌场到AI:期望值如何用C++改变世界?
  • H3C网络设备 实验三: 搭建两个局域网,使两个局域网相互通信(路由器,自动分配ip,DHCP协议)
  • 【源码+文档+调试讲解】商品进销存管理系统SpringBoot016
  • 制造业中的多系统困境,如何通过iPaaS“破解”
  • CryptoJs 实现前端 Aes 加密
  • Dockerfile 应用案例-搭建Nginx镜像、部署扫雷、部署可道云平台
  • 文档抽取技术作为AI和自然语言处理的核心应用,正成为企业数字化转型的关键工具
  • MySQL 数据监控平台
  • 高并发内存池(七):大块内存的申请释放问题以及配合定长内存池脱离使用new
  • 可以为自己的小说建设网站企业官方网站格式
  • 学做静态网站商城设计app网站建设
  • 【Linux系统】线程安全与死锁问题
  • 分布式锁:Redisson的公平锁
  • 精密牙挺在牙齿脱位中的力学控制原理
  • 移动办公型网站开发温州做网站技术员
  • 【SpringAI】第六弹:深入解析 MCP 上下文协议、开发和部署 MCP 服务、MCP 安全问题与最佳实践
  • Unreal开发痛点破解!GOT Online新功能:Lua全监控 + LLM内存可视化!
  • 节后变电站如何通过智能在线监测系统发现「积劳成疾」的隐患?
  • 基于vscode在WSL中配置PlatformIO开发环境
  • C#基础15-线程安全集合
  • 门诊场景评测深度分析报告:医生-病人-测量代理交互对诊断影响机制研究(下)
  • USCTNET:一种用于物理一致性高光谱图像重建的深度展开核范数优化求解器
  • 为什么我的网站没有百度索引量南充市网站建设
  • 常规线扫描镜头有哪些类型?能做什么?
  • 企业级 K8s 深度解析:从容器编排到云原生基石的十年演进