LeetcodeHot 100续

5. 最长回文子串

链接:link

问题描述

给定一个字符串 s,找到 s 中最长的回文子串。

核心思路

针对"最长回文子串"问题,以下是三种经典解法的完整实现:中心扩展法、动态规划、Manacher算法,涵盖不同时间和空间复杂度的方案。

方法一:中心扩展法(最优平衡方案)

核心思路

利用回文的对称性质,以每个字符(奇数长度)和每两个字符中间(偶数长度)为中心,向两侧扩展,记录最长回文子串。

class Solution {
public:
    string longestPalindrome(string s) {
        if (s.empty()) return "";
        int start = 0, maxLen = 0;
        
        for (int i = 0; i < s.size(); ++i) {
            int len1 = expand(s, i, i);       // 奇数长度回文
            int len2 = expand(s, i, i + 1);   // 偶数长度回文
            int len = max(len1, len2);
            
            if (len > maxLen) {
                maxLen = len;
                start = i - (len - 1) / 2;    // 计算起始索引
            }
        }
        
        return s.substr(start, maxLen);
    }
    
private:
    // 从l和r向两侧扩展,返回回文长度
    int expand(string& s, int l, int r) {
        while (l >= 0 && r < s.size() && s[l] == s[r]) {
            l--;
            r++;
        }
        return r - l - 1; // 实际回文长度
    }
};

复杂度

  • 时间:O(n²),每个中心最多扩展 O(n) 次,共 O(n) 个中心
  • 空间:O(1),仅用常数变量

方法二:动态规划(直观易懂)

核心思路

  1. 状态定义:dp[i][j] 表示 s[i…j] 是否为回文子串
  2. 递推关系:
    • 若 s[i] == s[j] 且 (j-i <= 2 或 dp[i+1][j-1] == true),则 dp[i][j] = true
    • 否则 dp[i][j] = false
  3. 边界条件:单个字符一定是回文
class Solution {
public:
    string longestPalindrome(string s) {
        int n = s.size();
        if (n < 2) return s;
        
        vector<vector<bool>> dp(n, vector<bool>(n, false));
        int start = 0, maxLen = 1;
        
        // 初始化:所有长度为1的子串都是回文
        for (int i = 0; i < n; ++i) {
            dp[i][i] = true;
        }
        
        // 按子串长度从小到大遍历
        for (int len = 2; len <= n; ++len) {
            for (int i = 0; i <= n - len; ++i) {
                int j = i + len - 1;
                
                if (s[i] == s[j]) {
                    // 长度为2或内部是回文
                    if (len == 2 || dp[i+1][j-1]) {
                        dp[i][j] = true;
                        if (len > maxLen) {
                            start = i;
                            maxLen = len;
                        }
                    }
                }
            }
        }
        
        return s.substr(start, maxLen);
    }
};

复杂度

  • 时间:O(n²),需要填充 n² 的dp表
  • 空间:O(n²),dp数组大小

方法三:Manacher算法(最优时间复杂度)

核心思路

  1. 预处理:在字符间插入特殊字符,统一处理奇偶长度回文
  2. 利用已知回文信息加速扩展
class Solution {
public:
    string longestPalindrome(string s) {
        if (s.empty()) return "";
        
        // 预处理:插入特殊字符
        string t = "#";
        for (char c : s) {
            t += c;
            t += '#';
        }
        
        int n = t.size();
        vector<int> p(n, 0);  // p[i]表示以i为中心的最长回文半径
        int center = 0, right = 0;
        int maxCenter = 0, maxLen = 0;
        
        for (int i = 0; i < n; ++i) {
            // 利用对称性快速初始化p[i]
            if (i < right) {
                int mirror = 2 * center - i;
                p[i] = min(right - i, p[mirror]);
            }
            
            // 中心扩展
            int a = i + (1 + p[i]);
            int b = i - (1 + p[i]);
            while (b >= 0 && a < n && t[a] == t[b]) {
                p[i]++;
                a++;
                b--;
            }
            
            // 更新最右边界和中心
            if (i + p[i] > right) {
                center = i;
                right = i + p[i];
            }
            
            // 更新最长回文
            if (p[i] > maxLen) {
                maxLen = p[i];
                maxCenter = i;
            }
        }
        
        // 提取原字符串中的回文子串
        int start = (maxCenter - maxLen) / 2;
        return s.substr(start, maxLen);
    }
};

复杂度

  • 时间:O(n),每个位置最多扩展一次。
  • 空间:O(n),存储预处理字符串和半径数组。

三种方法对比

方法时间复杂度空间复杂度优势场景
中心扩展法O(n²)O(1)面试首选,实现简单,空间最优
动态规划O(n²)O(n²)思路直观,适合理解回文性质
Manacher算法O(n)O(n)大数据量场景(如 n=1e5)

实际应用中,中心扩展法是平衡时间、空间和实现难度的最佳选择;动态规划适合初学者理解;Manacher算法适合对效率要求极高的场景。

1143. 最长公共子序列

链接:link

要解决“最长公共子序列”问题(LeetCode 1143),需要找到两个字符串中最长的公共子序列的长度(子序列不要求连续,但顺序必须一致)。例如,text1 = "abcde"text2 = "ace" 的最长公共子序列是 "ace",长度为 3。

核心思路

该问题是动态规划的经典应用,核心是通过子问题的解推导出当前问题的解:

  1. 状态定义:设 dp[i][j] 表示 text1 的前 i 个字符(text1[0..i-1])和 text2 的前 j 个字符(text2[0..j-1])的最长公共子序列长度。
  2. 递推关系
    • text1[i-1] == text2[j-1](当前字符相同):
      dp[i][j] = dp[i-1][j-1] + 1(公共子序列长度在之前基础上加 1)。
    • text1[i-1] != text2[j-1](当前字符不同):
      dp[i][j] = max(dp[i-1][j], dp[i][j-1])(取“去掉 text1 最后一个字符”或“去掉 text2 最后一个字符”两种情况的最大值)。
  3. 边界条件
    • i=0j=0(其中一个字符串为空),则 dp[0][j] = 0dp[i][0] = 0(空序列的公共子序列长度为 0)。

实现方案(空间优化版)

由于 dp[i][j] 只依赖 上一行(dp[i-1][j]同一行前一列(dp[i][j-1] 以及 左上角(dp[i-1][j-1],可使用一维数组优化空间复杂度。

步骤:

  1. 初始化一维数组 dp,长度为 n+1ntext2 的长度),初始值均为 0。
  2. 遍历 text1 的每个字符(索引 i 从 1 到 m):
    • 记录左上角的值 prev(初始为 0,对应 dp[i-1][j-1])。
    • 遍历 text2 的每个字符(索引 j 从 1 到 n):
      • 保存当前 dp[j]temp(用于更新下一轮的 prev)。
      • 若字符相同,dp[j] = prev + 1;否则,dp[j] = max(dp[j], dp[j-1])
      • 更新 prev = temp
  3. 返回 dp[n](最终结果)。

代码实现

#include <vector>
#include <string>
#include <algorithm>
using namespace std;

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int m = text1.size();
        int n = text2.size();
        
        // 用一维数组优化空间,长度为text2的长度+1
        vector<int> dp(n + 1, 0);
        
        for (int i = 1; i <= m; ++i) {
            int prev = 0; // 记录dp[i-1][j-1]
            for (int j = 1; j <= n; ++j) {
                int temp = dp[j]; // 保存当前dp[j](即dp[i-1][j])
                if (text1[i-1] == text2[j-1]) {
                    dp[j] = prev + 1; // 字符相同,来自左上角+1
                } else {
                    dp[j] = max(dp[j], dp[j-1]); // 字符不同,取上或左的最大值
                }
                prev = temp; // 更新prev为下一轮的dp[i-1][j-1]
            }
        }
        
        return dp[n];
    }
};

示例说明

text1 = "abcde", text2 = "ace" 为例:

  • 初始化 dp = [0,0,0,0]n=3)。
  • i=1text1[0] = 'a'):
    • j=1text2[0] = 'a'):字符相同,dp[1] = prev(0) + 1 = 1prev=0temp=0
    • j=2text2[1] = 'c'):字符不同,dp[2] = max(dp[2](0), dp[1](1)) = 1prev=0temp=0
    • j=3text2[2] = 'e'):字符不同,dp[3] = max(0, 1) = 1prev=0temp=0
    • 此时 dp = [0,1,1,1]
  • 后续遍历逐步更新,最终 dp[3] = 3(正确结果)。

复杂度分析

  • 时间复杂度:O(m×n),其中 mn 分别是两个字符串的长度。需遍历两个字符串的所有字符组合。
  • 空间复杂度:O(n)(优化后),使用一维数组存储状态,长度为较短字符串的长度(可进一步优化为 min(m,n))。

关键说明

  • 空间优化逻辑:通过一维数组和 prev 变量记录左上角的值,将二维数组的 O(m×n) 空间降至 O(n),尤其适合处理长字符串。
  • 子问题拆分:将两个长字符串的公共子序列问题拆分为更短字符串的子问题,利用递推关系高效求解。
  • 与最长公共子串的区别:子序列不要求连续,因此当字符不同时取“上或左”的最大值;而子串要求连续,需额外判断中断后重新计数。

该动态规划方法是解决最长公共子序列问题的标准方案,优化后的空间复杂度使其能处理更大规模的输入。

72. 编辑距离

链接:link

要解决“编辑距离”问题(LeetCode 72),需要计算将一个字符串(word1)转换为另一个字符串(word2)所需的最少操作次数。允许的操作包括:插入一个字符、删除一个字符、替换一个字符(每种操作的代价均为1)。这是动态规划的经典应用,核心是通过子问题的最优解推导当前问题的解。

核心思路

  1. 状态定义:设 dp[i][j] 表示将 word1 的前 i 个字符(word1[0..i-1])转换为 word2 的前 j 个字符(word2[0..j-1])所需的最少操作次数。
  2. 递推关系
    • word1[i-1] == word2[j-1](当前字符相同):
      无需操作,dp[i][j] = dp[i-1][j-1](直接继承前 i-1j-1 个字符的编辑距离)。
    • word1[i-1] != word2[j-1](当前字符不同):
      需要执行以下三种操作之一,取最小值:
      • 替换:将 word1[i-1] 替换为 word2[j-1],操作次数 = dp[i-1][j-1] + 1
      • 删除:删除 word1[i-1],操作次数 = dp[i-1][j] + 1
      • 插入:在 word1 后插入 word2[j-1],操作次数 = dp[i][j-1] + 1
        因此,dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) + 1
  3. 边界条件
    • i=0word1 为空):需插入 j 个字符,dp[0][j] = j
    • j=0word2 为空):需删除 i 个字符,dp[i][0] = i

实现方案(空间优化版)

由于 dp[i][j] 只依赖 上一行(dp[i-1][j]同一行前一列(dp[i][j-1]左上角(dp[i-1][j-1],可使用一维数组优化空间复杂度。

步骤:

  1. 初始化一维数组 dp,长度为 n+1nword2 的长度),dp[j] = j(对应 i=0 时的边界条件)。
  2. 遍历 word1 的每个字符(索引 i 从 1 到 m):
    • 记录左上角的值 prev(初始为 i-1,对应 dp[i-1][0])。
    • 更新 dp[0] = i(对应 j=0 时的边界条件)。
    • 遍历 word2 的每个字符(索引 j 从 1 到 n):
      • 保存当前 dp[j]temp(用于更新下一轮的 prev)。
      • 若字符相同,dp[j] = prev;否则,dp[j] = min(prev, min(dp[j], dp[j-1])) + 1
      • 更新 prev = temp
  3. 返回 dp[n](最终结果)。

代码实现

class Solution {
public:
    int minDistance(string word1, string word2) {
        int m = word1.size();
        int n = word2.size();
        
        // 用一维数组优化空间,长度为word2的长度+1
        vector<int> dp(n + 1);
        
        // 初始化边界:i=0时,dp[j] = j(插入j个字符)
        for (int j = 0; j <= n; ++j) {
            dp[j] = j;
        }
        
        for (int i = 1; i <= m; ++i) {
            int prev = dp[0]; // 记录左上角的值(dp[i-1][j-1])
            dp[0] = i; // 边界:j=0时,dp[i][0] = i(删除i个字符)
            
            for (int j = 1; j <= n; ++j) {
                int temp = dp[j]; // 保存当前dp[j](即dp[i-1][j])
                
                if (word1[i-1] == word2[j-1]) {
                    dp[j] = prev; // 字符相同,直接继承左上角
                } else {
                    // 字符不同,取替换、删除、插入的最小值+1
                    dp[j] = min(prev, min(dp[j], dp[j-1])) + 1;
                }
                
                prev = temp; // 更新prev为下一轮的左上角(dp[i-1][j-1])
            }
        }
        
        return dp[n];
    }
};

示例说明

word1 = "horse", word2 = "ros" 为例:

  • 初始化 dp = [0,1,2,3]n=3i=0 时的边界)。
  • i=1word1[0] = 'h'):
    • dp[0] = 1j=0 边界)。
    • j=1word2[0] = 'r'):字符不同,dp[1] = min(0, 1, 1) + 1 = 1
    • j=2word2[1] = 'o'):字符不同,dp[2] = min(1, 2, 1) + 1 = 2
    • j=3word2[2] = 's'):字符不同,dp[3] = min(2, 3, 2) + 1 = 3
    • 此时 dp = [1,1,2,3]
  • 后续遍历逐步更新,最终 dp[3] = 3(正确结果:horse → rorse(替换h→r)→ rose(删除r)→ ros(删除e),共3步)。

复杂度分析

  • 时间复杂度:O(m×n),其中 mn 分别是两个字符串的长度。需遍历两个字符串的所有字符组合。
  • 空间复杂度:O(n)(优化后),使用一维数组存储状态,长度为较短字符串的长度(可进一步优化为 min(m,n))。

关键说明

  • 空间优化逻辑:通过一维数组和 prev 变量记录左上角的值,将二维数组的 O(m×n) 空间降至 O(n),适合处理长字符串。
  • 操作的等价性:删除 word1 的字符等价于插入 word2 的字符,因此无需额外考虑对称操作,三种操作已覆盖所有可能。
  • 边界处理:空字符串与非空字符串的编辑距离等于非空字符串的长度(全插入或全删除)。

该动态规划方法是解决编辑距离问题的标准方案,优化后的空间复杂度使其能高效处理大规模输入,也是自然语言处理中相似度计算的基础算法。

136. 只出现一次的数字

链接:link

要解决“只出现一次的数字”问题(LeetCode 136),需要找出数组中唯一一个只出现一次的元素,其他元素均出现两次。这一问题可以通过位运算高效求解,时间复杂度为 O(n),空间复杂度为 O(1),是最优方案。

核心思路

利用异或运算(XOR) 的特性:

  1. 自反性:任何数与自身异或结果为 0(a ^ a = 0)。
  2. 恒等性:任何数与 0 异或结果为自身(a ^ 0 = a)。
  3. 交换律和结合律a ^ b ^ c = a ^ c ^ b,顺序不影响结果。

因此,将数组中所有元素依次异或,最终结果即为只出现一次的数字:

  • 成对出现的元素异或后结果为 0。
  • 0 与唯一出现一次的元素异或,结果为该元素。

实现步骤

  1. 初始化结果变量 result = 0
  2. 遍历数组中的每个元素,与 result 进行异或操作(result ^= num)。
  3. 遍历结束后,result 即为只出现一次的数字。

C++ 实现代码

class Solution {
public:
    int singleNumber(vector<int>& nums) {
        int result = 0;
        for (int num : nums) {
            result ^= num; // 异或运算累积
        }
        return result;
    }
};

示例说明

nums = [4,1,2,1,2] 为例:

  • 初始 result = 0
  • 第一步:0 ^ 4 = 4result = 4
  • 第二步:4 ^ 1 = 5result = 5
  • 第三步:5 ^ 2 = 7result = 7
  • 第四步:7 ^ 1 = 6result = 6
  • 第五步:6 ^ 2 = 4result = 4

最终返回 4,即数组中唯一出现一次的数字。

复杂度分析

  • 时间复杂度:O(n),仅遍历数组一次,每个元素参与一次异或运算。
  • 空间复杂度:O(1),只使用一个额外变量 result,与数组长度无关。

其他方案对比

方案时间复杂度空间复杂度特点
异或运算O(n)O(1)最优,无额外空间
哈希表O(n)O(n)直观,但需要额外空间
排序后遍历O(n log n)O(1)时间复杂度较高

关键说明

  • 异或的优势:无需额外空间,且运算速度快,是该问题的最优解。
  • 适用场景:仅当“除目标元素外,其他元素均出现偶数次”时有效,本题正好满足(其他元素均出现两次)。
  • 边界处理:即使数组长度为 1(只有一个元素),代码也能正确返回该元素(0 ^ 元素 = 元素)。

异或运算方案充分利用了问题的特性,是解决“只出现一次的数字”问题的最优雅且高效的方法,也是面试中的推荐解法。

169. 多数元素

链接:link

要解决“多数元素”问题(LeetCode 169),需要找出数组中出现次数**大于 **n/2 的元素(n 为数组长度)。多数元素一定存在(即至少出现 ⌊n/2⌋ + 1 次),这一特性让我们可以用高效算法求解。

核心思路

以下是三种经典解法,从直观到最优依次介绍:

方法一:哈希表(计数法)

思路:用哈希表统计每个元素的出现次数,遍历哈希表找到次数大于 n/2 的元素。

class Solution {
public:
    int majorityElement(vector<int>& nums) {
        unordered_map<int, int> count;
        int n = nums.size();
        
        // 统计每个元素的出现次数
        for (int num : nums) {
            count[num]++;
            // 提前判断,找到即返回
            if (count[num] > n / 2) {
                return num;
            }
        }
        
        return -1; // 题目保证存在,实际不会执行
    }
};

复杂度

  • 时间:O(n),遍历一次数组。
  • 空间:O(n),哈希表最多存储 n/2 个元素。

方法二:排序法

思路:数组排序后,多数元素一定在中间位置(索引 n/2),因为它出现次数超过一半。

class Solution {
public:
    int majorityElement(vector<int>& nums) {
        sort(nums.begin(), nums.end());
        return nums[nums.size() / 2];
    }
};

复杂度

  • 时间:O(n log n),排序的时间开销。
  • 空间:O(log n)(排序的递归栈空间)或 O(1)(取决于排序算法)。

方法三:摩尔投票法(最优解)

思路:利用多数元素的出现次数超过一半的特性,通过“抵消”非多数元素来找到目标:

  1. 初始化 candidate(候选多数元素)和 count(计数)。
  2. 遍历数组:
    • count == 0,将当前元素设为 candidate
    • 若当前元素与 candidate 相同,count++;否则,count--(抵消)。
  3. 最终 candidate 即为多数元素(因多数元素出现次数超过一半,无法被完全抵消)。
class Solution {
public:
    int majorityElement(vector<int>& nums) {
        int candidate = nums[0];
        int count = 1;
        
        for (int i = 1; i < nums.size(); ++i) {
            if (count == 0) {
                candidate = nums[i]; // 更换候选元素
            }
            // 相同则计数+1,不同则-1(抵消)
            count += (nums[i] == candidate) ? 1 : -1;
        }
        return candidate;
    }
};

复杂度

  • 时间:O(n),仅遍历一次数组。
  • 空间:O(1),只使用常数变量。

示例说明

nums = [2,2,1,1,1,2,2] 为例:

  • 摩尔投票法步骤
    1. 初始 candidate=2count=1
    2. i=1(元素2):相同,count=2
    3. i=2(元素1):不同,count=1
    4. i=3(元素1):不同,count=0
    5. i=4(元素1):count=0,更换 candidate=1count=1
    6. i=5(元素2):不同,count=0
    7. i=6(元素2):count=0,更换 candidate=2count=1
    8. 最终 candidate=2(正确,出现4次 > 7/2=3.5)。

方法对比

方法时间复杂度空间复杂度优势场景
哈希表O(n)O(n)直观,适合需要同时统计其他元素次数的场景
排序法O(n log n)O(log n)代码极简,无需额外逻辑
摩尔投票法O(n)O(1)最优,适合大数据量,空间敏感场景

关键说明

  • 摩尔投票法的核心:利用多数元素“数量优势”抵消其他元素,无需额外空间,是该问题的最优解。
  • 正确性保证:题目明确“多数元素一定存在”,因此无需验证最终 candidate 的出现次数。
  • 边界处理:数组长度为1时,直接返回该元素(摩尔投票法也能正确处理)。

摩尔投票法充分利用了问题的特性,是面试中推荐的解法,体现了对问题本质的深刻理解。

75. 颜色分类

链接:link

要解决“颜色分类”问题(LeetCode 75),需要将包含红色(0)、白色(1)、蓝色(2)的数组原地排序,使得相同颜色的元素相邻,且按 0、1、2 的顺序排列。这一问题也被称为“荷兰国旗问题”,最优解法是通过一次遍历实现 O(n) 时间复杂度和 O(1) 空间复杂度。

核心思路

利用三指针法划分区间:

  1. 指针定义
    • left:指向当前已处理的最后一个 0 的右侧(即 [0, left) 区间全为 0)。
    • right:指向当前已处理的第一个 2 的左侧(即 (right, n-1] 区间全为 2)。
    • current:当前遍历的元素索引(用于扫描未处理区间 [left, right])。
  2. 遍历规则
    • nums[current] == 0:与 nums[left] 交换,left++current++(0 已归位,继续扫描)。
    • nums[current] == 1:无需交换,current++(1 已在中间区间)。
    • nums[current] == 2:与 nums[right] 交换,right--(2 已归位,但交换后 current 不变,需重新检查新元素)。
  3. 终止条件current > right(所有元素处理完毕)。

实现步骤

  1. 初始化指针:left = 0right = n-1current = 0n 为数组长度)。
  2. 遍历数组,当 current <= right 时:
    • 根据 nums[current] 的值执行对应交换和指针移动。
  3. 遍历结束后,数组已按 0、1、2 排序。

代码实现

class Solution {
public:
    void sortColors(vector<int>& nums) {
        int n = nums.size();
        int left = 0;       // 0的右边界([0, left) 全为0)
        int right = n - 1;  // 2的左边界((right, n-1] 全为2)
        int current = 0;    // 当前遍历索引
        
        while (current <= right) {
            if (nums[current] == 0) {
                // 交换到0的区间,left右移,current继续
                swap(nums[current], nums[left]);
                left++;
                current++;
            } else if (nums[current] == 1) {
                // 1在中间,直接跳过
                current++;
            } else {
                // 交换到2的区间,right左移,current需重新检查
                swap(nums[current], nums[right]);
                right--;
            }
        }
    }
};

示例说明

nums = [2,0,2,1,1,0] 为例:

  1. 初始:left=0right=5current=0nums = [2,0,2,1,1,0]
  2. current=0(值2):与 right=5 交换 → nums = [0,0,2,1,1,2]right=4current=0
  3. current=0(值0):与 left=0 交换 → nums = [0,0,2,1,1,2]left=1current=1
  4. current=1(值0):与 left=1 交换 → nums = [0,0,2,1,1,2]left=2current=2
  5. current=2(值2):与 right=4 交换 → nums = [0,0,1,1,2,2]right=3current=2
  6. current=2(值1):直接跳过,current=3
  7. current=3(值1):直接跳过,current=4
  8. current=4 > right=3:循环结束,数组已排序为 [0,0,1,1,2,2]

复杂度分析

  • 时间复杂度:O(n),每个元素最多被遍历一次(currentright 移动方向固定,总步数为 n)。
  • 空间复杂度:O(1),仅使用三个指针变量,原地排序。

其他方案对比

方案时间复杂度空间复杂度特点
三指针法O(n)O(1)最优,一次遍历完成
计数排序O(n)O(1)需两次遍历(计数+重填)
内置排序函数O(n log n)O(log n)实现简单,但效率较低

关键说明

  • 三指针的区间划分:通过明确 leftright 的边界含义,确保每次交换后区间性质不变,无需重复处理元素。
  • current 指针的移动逻辑:处理 0 时 current 右移(交换后新元素必为 1 或已处理过),处理 2 时 current 不动(交换后可能是 0 或 1,需重新检查)。
  • 原地排序的优势:无需额外空间,适合大数据量场景,体现了荷兰国旗问题的经典解法思想。

三指针法是解决颜色分类问题的最优方案,通过一次遍历实现高效排序,是面试中推荐的解法。

31. 下一个排列

链接:link

要解决“下一个排列”问题(LeetCode 31),需要找到给定数字序列的下一个字典序更大的排列。如果不存在这样的排列(即序列已为最大排列),则将其重排为最小排列(升序)。例如,[1,2,3] 的下一个排列是 [1,3,2][3,2,1] 的下一个排列是 [1,2,3]

核心思路

下一个排列的生成需遵循“最小增幅”原则,步骤如下:

  1. 从后向前找第一个降序点:找到最大的索引 i,使得 nums[i] < nums[i+1](该位置是可以调整的起点,因为右侧存在更大的元素)。
  2. 从后向前找第一个比 nums[i] 大的元素:找到最大的索引 j,使得 nums[j] > nums[i](该元素是右侧最小的、比 nums[i] 大的元素,保证增幅最小)。
  3. **交换 nums[i] 和 **nums[j]:完成第一步调整,此时 i 右侧的元素仍为降序(因为原右侧是最大排列)。
  4. 反转 i 右侧的元素:将 i 右侧的降序改为升序(保证右侧为最小排列,整体增幅最小)。
  5. 边界情况:若步骤1未找到 i(序列为最大排列),直接反转整个数组得到最小排列。

实现步骤

  1. 从数组末尾开始遍历,寻找第一个 nums[i] < nums[i+1]i
  2. 若找到 i,再从末尾遍历寻找第一个 nums[j] > nums[i]j,交换 nums[i]nums[j]
  3. 反转 i 右侧的所有元素(无论是否找到 i,这一步都适用)。

代码实现

class Solution {
public:
    void nextPermutation(vector<int>& nums) {
        int n = nums.size();
        int i = n - 2;
        
        // 步骤1:找到第一个降序点i(nums[i] < nums[i+1])
        while (i >= 0 && nums[i] >= nums[i+1]) {
            i--;
        }
        
        // 步骤2:若找到i,找j并交换
        if (i >= 0) {
            int j = n - 1;
            while (nums[j] <= nums[i]) {
                j--;
            }
            swap(nums[i], nums[j]);
        }
        
        // 步骤3:反转i右侧的元素(若i=-1,反转整个数组)
        reverse(nums.begin() + i + 1, nums.end());
    }
};

示例说明

nums = [1,3,2] 为例:

  1. 步骤1:从后向前找 inums[0]=1 < nums[1]=3,故 i=0
  2. 步骤2:从后向前找 jnums[2]=2 > nums[0]=1,故 j=2,交换后 nums = [2,3,1]
  3. 步骤3:反转 i=0 右侧的元素 [3,1],得到 [2,1,3],即下一个排列。

nums = [3,2,1] 为例:

  1. 步骤1:未找到 ii=-1)。
  2. 步骤3:反转整个数组,得到 [1,2,3](最小排列)。

复杂度分析

  • 时间复杂度:O(n),其中 n 是数组长度。步骤1和步骤2各遍历一次数组(累计 O(n)),步骤3反转操作也是 O(n)。
  • 空间复杂度:O(1),仅使用常数个额外变量,原地修改数组。

关键说明

  • 降序点的意义i 是右侧存在更大元素的最左位置,调整该位置可得到最小增幅。
  • 交换的选择j 是右侧最小的、比 nums[i] 大的元素,确保交换后增幅最小。
  • 反转的作用i 右侧原是降序(最大排列),反转后变为升序(最小排列),保证整体是下一个排列。

该方法通过三步高效生成下一个排列,体现了对字典序排列规律的深刻理解,是解决该问题的最优方案。

287. 寻找重复数

链接:link

要解决“寻找重复数”问题(LeetCode 287),需要在一个包含 n+1 个整数的数组中找到唯一的重复数(数组元素范围是 [1, n],且只有一个数重复,可能重复多次)。要求不能修改数组、不能使用额外的 O(n) 空间,最优解法可通过二分查找快慢指针实现。

核心思路

约束条件分析

  • 数组长度为 n+1,元素范围 [1, n] → 由抽屉原理,必有重复数(至少出现 2 次)。
  • 限制:不能修改数组(排除排序法),空间复杂度需优于 O(n)(排除哈希表法)。

方法一:二分查找(基于计数)

思路

利用“小于等于 mid 的元素个数”判断重复数的位置:

  1. 若重复数为 target,则在 [1, target) 范围内,元素总数 ≤ target-1;在 [1, target] 范围内,元素总数 ≥ target+1
  2. 通过二分查找 [1, n] 区间,统计每个 mid 对应的元素计数,逐步缩小范围至 target
class Solution {
public:
    int findDuplicate(vector<int>& nums) {
        int left = 1, right = nums.size() - 1; // 元素范围是[1, n],n = size-1
        
        while (left < right) {
            int mid = left + (right - left) / 2;
            int count = 0; // 统计<=mid的元素个数
            
            for (int num : nums) {
                if (num <= mid) {
                    count++;
                }
            }
            
            // 若count > mid,说明重复数在[left, mid]
            if (count > mid) {
                right = mid;
            } else {
                // 否则在[mid+1, right]
                left = mid + 1;
            }
        }
        
        return left; // 循环结束时left == right,即为重复数
    }
};

复杂度

  • 时间:O(n log n),二分查找次数为 O(log n),每次计数遍历数组 O(n)。
  • 空间:O(1),仅用常数变量。

方法二:快慢指针(环形链表检测)

思路

将数组视为“索引→值”的映射(i → nums[i]),由于存在重复数,映射会形成环形链表(重复数是环的入口):

  1. 例如 nums = [1,3,4,2,2],映射为 0→1→3→2→4→2,环的入口是 2(重复数)。
  2. 用快慢指针检测环:
    • 慢指针 slow 每次走一步(slow = nums[slow])。
    • 快指针 fast 每次走两步(fast = nums[nums[fast]])。
    • 相遇后,将慢指针重置为起点,两指针同速前进,再次相遇点即为环入口(重复数)。
class Solution {
public:
    int findDuplicate(vector<int>& nums) {
        // 快慢指针找环
        int slow = nums[0];
        int fast = nums[nums[0]];
        
        while (slow != fast) {
            slow = nums[slow];
            fast = nums[nums[fast]];
        }
        
        // 找环入口(重复数)
        slow = 0;
        while (slow != fast) {
            slow = nums[slow];
            fast = nums[fast];
        }
        
        return slow;
    }
};

复杂度

  • 时间:O(n),快慢指针相遇最多遍历 O(n) 步,找环入口再遍历 O(n) 步。
  • 空间:O(1),仅用两个指针变量。

示例说明

nums = [3,1,3,4,2] 为例:

  • 二分查找
    • 初始 left=1, right=4mid=2,计数 ≤2 的元素(1,2)共 2 个 → left=3
    • mid=3,计数 ≤3 的元素(3,1,3,2)共 4 个 >3 → right=3,循环结束,返回 3。
  • 快慢指针
    • 初始 slow = nums[0] = 3fast = nums[nums[0]] = nums[3] =4
    • 第一次相遇:slow 路径 3→4→2→3fast 路径 4→2→3→4→2→3,相遇于 3。
    • 重置 slow=0,同速前进:slow 路径 0→3fast 路径 3→3,相遇于 3(重复数)。

方法对比

方法时间复杂度空间复杂度优势场景
二分查找O(n log n)O(1)思路直观,适合理解抽屉原理
快慢指针O(n)O(1)最优解,时间复杂度更低

关键说明

  • 二分查找的核心:利用“元素范围固定”的特性,通过计数判断重复数所在区间,无需修改数组。
  • 快慢指针的核心:将数组转化为环形链表模型,重复数作为环入口,用 Floyd 算法高效定位。
  • 约束满足:两种方法均不修改数组,空间复杂度 O(1),符合题目要求。

快慢指针法是该问题的最优解,时间复杂度 O(n),体现了将数组映射为链表解决问题的巧妙思路,是面试中的推荐解法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值