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),仅用常数变量
方法二:动态规划(直观易懂)
核心思路
- 状态定义:dp[i][j] 表示 s[i…j] 是否为回文子串
- 递推关系:
- 若 s[i] == s[j] 且 (j-i <= 2 或 dp[i+1][j-1] == true),则 dp[i][j] = true
- 否则 dp[i][j] = false
- 边界条件:单个字符一定是回文
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算法(最优时间复杂度)
核心思路
- 预处理:在字符间插入特殊字符,统一处理奇偶长度回文
- 利用已知回文信息加速扩展
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。
核心思路
该问题是动态规划的经典应用,核心是通过子问题的解推导出当前问题的解:
- 状态定义:设
dp[i][j]表示text1的前i个字符(text1[0..i-1])和text2的前j个字符(text2[0..j-1])的最长公共子序列长度。 - 递推关系:
- 若
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最后一个字符”两种情况的最大值)。
- 若
- 边界条件:
- 若
i=0或j=0(其中一个字符串为空),则dp[0][j] = 0或dp[i][0] = 0(空序列的公共子序列长度为 0)。
- 若
实现方案(空间优化版)
由于 dp[i][j] 只依赖 上一行(dp[i-1][j]) 和 同一行前一列(dp[i][j-1]) 以及 左上角(dp[i-1][j-1]),可使用一维数组优化空间复杂度。
步骤:
- 初始化一维数组
dp,长度为n+1(n为text2的长度),初始值均为 0。 - 遍历
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。
- 保存当前
- 记录左上角的值
- 返回
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=1(text1[0] = 'a'):j=1(text2[0] = 'a'):字符相同,dp[1] = prev(0) + 1 = 1,prev=0→temp=0。j=2(text2[1] = 'c'):字符不同,dp[2] = max(dp[2](0), dp[1](1)) = 1,prev=0→temp=0。j=3(text2[2] = 'e'):字符不同,dp[3] = max(0, 1) = 1,prev=0→temp=0。- 此时
dp = [0,1,1,1]。
- 后续遍历逐步更新,最终
dp[3] = 3(正确结果)。
复杂度分析
- 时间复杂度:O(m×n),其中
m和n分别是两个字符串的长度。需遍历两个字符串的所有字符组合。 - 空间复杂度:O(n)(优化后),使用一维数组存储状态,长度为较短字符串的长度(可进一步优化为
min(m,n))。
关键说明
- 空间优化逻辑:通过一维数组和
prev变量记录左上角的值,将二维数组的 O(m×n) 空间降至 O(n),尤其适合处理长字符串。 - 子问题拆分:将两个长字符串的公共子序列问题拆分为更短字符串的子问题,利用递推关系高效求解。
- 与最长公共子串的区别:子序列不要求连续,因此当字符不同时取“上或左”的最大值;而子串要求连续,需额外判断中断后重新计数。
该动态规划方法是解决最长公共子序列问题的标准方案,优化后的空间复杂度使其能处理更大规模的输入。
72. 编辑距离
链接:link
要解决“编辑距离”问题(LeetCode 72),需要计算将一个字符串(word1)转换为另一个字符串(word2)所需的最少操作次数。允许的操作包括:插入一个字符、删除一个字符、替换一个字符(每种操作的代价均为1)。这是动态规划的经典应用,核心是通过子问题的最优解推导当前问题的解。
核心思路
- 状态定义:设
dp[i][j]表示将word1的前i个字符(word1[0..i-1])转换为word2的前j个字符(word2[0..j-1])所需的最少操作次数。 - 递推关系:
- 若
word1[i-1] == word2[j-1](当前字符相同):
无需操作,dp[i][j] = dp[i-1][j-1](直接继承前i-1和j-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。
- 替换:将
- 若
- 边界条件:
- 若
i=0(word1为空):需插入j个字符,dp[0][j] = j。 - 若
j=0(word2为空):需删除i个字符,dp[i][0] = i。
- 若
实现方案(空间优化版)
由于 dp[i][j] 只依赖 上一行(dp[i-1][j])、同一行前一列(dp[i][j-1]) 和 左上角(dp[i-1][j-1]),可使用一维数组优化空间复杂度。
步骤:
- 初始化一维数组
dp,长度为n+1(n为word2的长度),dp[j] = j(对应i=0时的边界条件)。 - 遍历
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。
- 保存当前
- 记录左上角的值
- 返回
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=3,i=0时的边界)。 i=1(word1[0] = 'h'):dp[0] = 1(j=0边界)。j=1(word2[0] = 'r'):字符不同,dp[1] = min(0, 1, 1) + 1 = 1。j=2(word2[1] = 'o'):字符不同,dp[2] = min(1, 2, 1) + 1 = 2。j=3(word2[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),其中
m和n分别是两个字符串的长度。需遍历两个字符串的所有字符组合。 - 空间复杂度:O(n)(优化后),使用一维数组存储状态,长度为较短字符串的长度(可进一步优化为
min(m,n))。
关键说明
- 空间优化逻辑:通过一维数组和
prev变量记录左上角的值,将二维数组的 O(m×n) 空间降至 O(n),适合处理长字符串。 - 操作的等价性:删除
word1的字符等价于插入word2的字符,因此无需额外考虑对称操作,三种操作已覆盖所有可能。 - 边界处理:空字符串与非空字符串的编辑距离等于非空字符串的长度(全插入或全删除)。
该动态规划方法是解决编辑距离问题的标准方案,优化后的空间复杂度使其能高效处理大规模输入,也是自然语言处理中相似度计算的基础算法。
136. 只出现一次的数字
链接:link
要解决“只出现一次的数字”问题(LeetCode 136),需要找出数组中唯一一个只出现一次的元素,其他元素均出现两次。这一问题可以通过位运算高效求解,时间复杂度为 O(n),空间复杂度为 O(1),是最优方案。
核心思路
利用异或运算(XOR) 的特性:
- 自反性:任何数与自身异或结果为 0(
a ^ a = 0)。 - 恒等性:任何数与 0 异或结果为自身(
a ^ 0 = a)。 - 交换律和结合律:
a ^ b ^ c = a ^ c ^ b,顺序不影响结果。
因此,将数组中所有元素依次异或,最终结果即为只出现一次的数字:
- 成对出现的元素异或后结果为 0。
- 0 与唯一出现一次的元素异或,结果为该元素。
实现步骤
- 初始化结果变量
result = 0。 - 遍历数组中的每个元素,与
result进行异或操作(result ^= num)。 - 遍历结束后,
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 = 4→result = 4。 - 第二步:
4 ^ 1 = 5→result = 5。 - 第三步:
5 ^ 2 = 7→result = 7。 - 第四步:
7 ^ 1 = 6→result = 6。 - 第五步:
6 ^ 2 = 4→result = 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)(取决于排序算法)。
方法三:摩尔投票法(最优解)
思路:利用多数元素的出现次数超过一半的特性,通过“抵消”非多数元素来找到目标:
- 初始化
candidate(候选多数元素)和count(计数)。 - 遍历数组:
- 若
count == 0,将当前元素设为candidate。 - 若当前元素与
candidate相同,count++;否则,count--(抵消)。
- 若
- 最终
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] 为例:
- 摩尔投票法步骤:
- 初始
candidate=2,count=1。 i=1(元素2):相同,count=2。i=2(元素1):不同,count=1。i=3(元素1):不同,count=0。i=4(元素1):count=0,更换candidate=1,count=1。i=5(元素2):不同,count=0。i=6(元素2):count=0,更换candidate=2,count=1。- 最终
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) 空间复杂度。
核心思路
利用三指针法划分区间:
- 指针定义:
left:指向当前已处理的最后一个 0 的右侧(即[0, left)区间全为 0)。right:指向当前已处理的第一个 2 的左侧(即(right, n-1]区间全为 2)。current:当前遍历的元素索引(用于扫描未处理区间[left, right])。
- 遍历规则:
- 若
nums[current] == 0:与nums[left]交换,left++且current++(0 已归位,继续扫描)。 - 若
nums[current] == 1:无需交换,current++(1 已在中间区间)。 - 若
nums[current] == 2:与nums[right]交换,right--(2 已归位,但交换后current不变,需重新检查新元素)。
- 若
- 终止条件:
current > right(所有元素处理完毕)。
实现步骤
- 初始化指针:
left = 0,right = n-1,current = 0(n为数组长度)。 - 遍历数组,当
current <= right时:- 根据
nums[current]的值执行对应交换和指针移动。
- 根据
- 遍历结束后,数组已按 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] 为例:
- 初始:
left=0,right=5,current=0,nums = [2,0,2,1,1,0]。 current=0(值2):与right=5交换 →nums = [0,0,2,1,1,2],right=4,current=0。current=0(值0):与left=0交换 →nums = [0,0,2,1,1,2],left=1,current=1。current=1(值0):与left=1交换 →nums = [0,0,2,1,1,2],left=2,current=2。current=2(值2):与right=4交换 →nums = [0,0,1,1,2,2],right=3,current=2。current=2(值1):直接跳过,current=3。current=3(值1):直接跳过,current=4。current=4 > right=3:循环结束,数组已排序为[0,0,1,1,2,2]。
复杂度分析
- 时间复杂度:O(n),每个元素最多被遍历一次(
current和right移动方向固定,总步数为 n)。 - 空间复杂度:O(1),仅使用三个指针变量,原地排序。
其他方案对比
| 方案 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 三指针法 | O(n) | O(1) | 最优,一次遍历完成 |
| 计数排序 | O(n) | O(1) | 需两次遍历(计数+重填) |
| 内置排序函数 | O(n log n) | O(log n) | 实现简单,但效率较低 |
关键说明
- 三指针的区间划分:通过明确
left和right的边界含义,确保每次交换后区间性质不变,无需重复处理元素。 - current 指针的移动逻辑:处理 0 时
current右移(交换后新元素必为 1 或已处理过),处理 2 时current不动(交换后可能是 0 或 1,需重新检查)。 - 原地排序的优势:无需额外空间,适合大数据量场景,体现了荷兰国旗问题的经典解法思想。
三指针法是解决颜色分类问题的最优方案,通过一次遍历实现高效排序,是面试中推荐的解法。
31. 下一个排列
链接:link
要解决“下一个排列”问题(LeetCode 31),需要找到给定数字序列的下一个字典序更大的排列。如果不存在这样的排列(即序列已为最大排列),则将其重排为最小排列(升序)。例如,[1,2,3] 的下一个排列是 [1,3,2],[3,2,1] 的下一个排列是 [1,2,3]。
核心思路
下一个排列的生成需遵循“最小增幅”原则,步骤如下:
- 从后向前找第一个降序点:找到最大的索引
i,使得nums[i] < nums[i+1](该位置是可以调整的起点,因为右侧存在更大的元素)。 - 从后向前找第一个比
nums[i]大的元素:找到最大的索引j,使得nums[j] > nums[i](该元素是右侧最小的、比nums[i]大的元素,保证增幅最小)。 - **交换
nums[i]和 **nums[j]:完成第一步调整,此时i右侧的元素仍为降序(因为原右侧是最大排列)。 - 反转
i右侧的元素:将i右侧的降序改为升序(保证右侧为最小排列,整体增幅最小)。 - 边界情况:若步骤1未找到
i(序列为最大排列),直接反转整个数组得到最小排列。
实现步骤
- 从数组末尾开始遍历,寻找第一个
nums[i] < nums[i+1]的i。 - 若找到
i,再从末尾遍历寻找第一个nums[j] > nums[i]的j,交换nums[i]和nums[j]。 - 反转
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:从后向前找
i,nums[0]=1 < nums[1]=3,故i=0。 - 步骤2:从后向前找
j,nums[2]=2 > nums[0]=1,故j=2,交换后nums = [2,3,1]。 - 步骤3:反转
i=0右侧的元素[3,1],得到[2,1,3],即下一个排列。
以 nums = [3,2,1] 为例:
- 步骤1:未找到
i(i=-1)。 - 步骤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 的元素个数”判断重复数的位置:
- 若重复数为
target,则在[1, target)范围内,元素总数 ≤target-1;在[1, target]范围内,元素总数 ≥target+1。 - 通过二分查找
[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]),由于存在重复数,映射会形成环形链表(重复数是环的入口):
- 例如
nums = [1,3,4,2,2],映射为0→1→3→2→4→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=4,mid=2,计数 ≤2 的元素(1,2)共 2 个 →left=3。 mid=3,计数 ≤3 的元素(3,1,3,2)共 4 个 >3 →right=3,循环结束,返回 3。
- 初始
- 快慢指针:
- 初始
slow = nums[0] = 3,fast = nums[nums[0]] = nums[3] =4。 - 第一次相遇:
slow路径3→4→2→3,fast路径4→2→3→4→2→3,相遇于 3。 - 重置
slow=0,同速前进:slow路径0→3,fast路径3→3,相遇于 3(重复数)。
- 初始
方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 优势场景 |
|---|---|---|---|
| 二分查找 | O(n log n) | O(1) | 思路直观,适合理解抽屉原理 |
| 快慢指针 | O(n) | O(1) | 最优解,时间复杂度更低 |
关键说明
- 二分查找的核心:利用“元素范围固定”的特性,通过计数判断重复数所在区间,无需修改数组。
- 快慢指针的核心:将数组转化为环形链表模型,重复数作为环入口,用 Floyd 算法高效定位。
- 约束满足:两种方法均不修改数组,空间复杂度 O(1),符合题目要求。
快慢指针法是该问题的最优解,时间复杂度 O(n),体现了将数组映射为链表解决问题的巧妙思路,是面试中的推荐解法。
1039

被折叠的 条评论
为什么被折叠?



