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. 跨越中点(左半部分的某个后缀 + 右半部分的某个前缀)

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

数据结构设计

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

cpp 复制代码
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
完整代码实现
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],我们有两个选择:

  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
完整代码实现
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)

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

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

    • nums[slow++] = nums[fast] 等价于:
    cpp 复制代码
    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]
完整代码实现
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]))

这个条件分为两部分:

  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个指针变量
边界情况处理
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]
关键技巧总结
  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
完整代码实现
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;
}
算法详解

为什么要分两步处理?

  1. 索引0的特殊性

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

    cpp 复制代码
    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右侧所有元素的和
另一种更清晰的实现
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' 可以重复输出
算法思想:频率统计 + 取最小值

核心思路

  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"
完整代码实现
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;
}
关键技巧总结
  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
头插法详解

什么是头插法?

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);
}
关键技巧总结
  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)是位运算的一种,用符号 ^ 表示。

异或的核心性质:

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++ 经典算法题解析与实现教程

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

相关推荐
会跑的葫芦怪6 小时前
Go语言操作Redis
开发语言·redis·golang
美团技术团队6 小时前
可验证过程奖励在提升大模型推理效率中的探索与实践
人工智能·算法
泽虞6 小时前
《Qt应用开发》笔记
linux·开发语言·c++·笔记·qt
专职6 小时前
pytest详细教程
开发语言·python·pytest
专职7 小时前
pytest+requests+allure生成接口自动化测试报告
开发语言·python·pytest
风起云涌~7 小时前
【Java】浅谈ServiceLoader
java·开发语言
小邓儿◑.◑7 小时前
贪心算法 | 每周8题(二)
c++·算法·贪心算法
那我掉的头发算什么7 小时前
【数据结构】优先级队列(堆)
java·开发语言·数据结构·链表·idea
用户901951824247 小时前
【征文计划】基于 CXR-M SDK 打造 “AR 眼镜 + 手机” 户外步徒协同导航系统
算法