作为一名从零开始刷题的算法爱好者,我深知初学者面对LeetCode时的手足无措。今天我将分享自己第一天刷数组类题目的完整心路历程,重点解析四种典型题型的解题思路和优化过程。这些题目看似简单,却蕴含着算法设计中最重要的双指针技巧。
数组是最基础的数据结构,但相关算法题往往能考察编程基本功和优化思维。我选择的四道题目(LeetCode 27、26、283、844)由浅入深,覆盖了数组操作的常见场景。通过记录从暴力解法到优化解法的完整思考过程,希望能帮助其他刷题者建立系统的解题方法论。
题目要求原地移除数组中所有等于给定值val的元素,并返回新数组的长度。关键在于"原地"二字——不能使用额外数组空间,只能修改输入数组。
我的第一反应是朴素的暴力解法:遍历数组,遇到val就将后续元素全部前移一位。这种思路直接但效率低下:
cpp复制int removeElement(vector<int>& nums, int val) {
int size = nums.size();
for(int i = 0; i < size; i++) {
if(nums[i] == val) {
for(int j = i + 1; j < size; j++) {
nums[j-1] = nums[j];
}
size--;
i--;
}
}
return size;
}
注意点:元素前移后,数组大小减1,且当前下标i需要回退一位,否则会跳过检查新移到当前位置的元素。
时间复杂度分析:最坏情况下(数组全为val),外层循环n次,内层移动平均n/2次,总体O(n²)时间复杂度。空间复杂度O(1)满足要求,但效率显然不理想。
仔细观察发现,我们其实只需要关注不等于val的元素。这提示可以使用快慢双指针:
优化后的代码:
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),空间复杂度仍为O(1)。慢指针slow始终指向下一个有效元素的存放位置,快指针fast则不断向前探索非val元素。
题目要求删除升序排列数组中的重复项,同样需要原地修改。与前一题不同之处在于,重复项的判定是与前一个元素比较。
首先处理边界情况:
cpp复制if(nums.size() <= 1) return nums.size();
维护慢指针slow标记唯一序列的末尾,快指针fast扫描新元素。当发现nums[fast] ≠ nums[slow-1]时,将其加入唯一序列:
cpp复制int removeDuplicates(vector<int>& nums) {
if(nums.empty()) return 0;
int slow = 1;
for(int fast = 1; fast < nums.size(); fast++) {
if(nums[fast] != nums[slow-1]) {
nums[slow++] = nums[fast];
}
}
return slow;
}
关键点:slow初始为1,因为第一个元素必定唯一。比较nums[fast]与nums[slow-1]而非nums[fast-1],可以避免数组越界风险。
最简单的思路是将所有非零元素前移,然后在数组末尾补零:
cpp复制void moveZeroes(vector<int>& nums) {
int slow = 0;
for(int fast = 0; fast < nums.size(); fast++) {
if(nums[fast] != 0) {
nums[slow++] = nums[fast];
}
}
while(slow < nums.size()) {
nums[slow++] = 0;
}
}
这种方法需要两次遍历,但时间复杂度仍是O(n)。
更巧妙的解法是在一次遍历中通过交换完成操作:
cpp复制void moveZeroes(vector<int>& nums) {
for(int slow = 0, fast = 0; fast < nums.size(); fast++) {
if(nums[fast] != 0) {
swap(nums[slow++], nums[fast]);
}
}
}
这种解法中,slow总是指向第一个零的位置。当fast遇到非零元素时,与slow位置的元素交换,然后slow前进。这样保证了:
最直观的思路是使用栈结构模拟退格过程:
cpp复制string process(string str) {
string res;
for(char c : str) {
if(c != '#') {
res.push_back(c);
} else if(!res.empty()) {
res.pop_back();
}
}
return res;
}
bool backspaceCompare(string s, string t) {
return process(s) == process(t);
}
时间复杂度O(m+n),空间复杂度O(m+n)(需要额外栈空间)。
更优解法是从字符串末尾开始逆向处理,通过记录退格数来决定是否跳过字符:
cpp复制bool backspaceCompare(string s, string t) {
int i = s.length()-1, j = t.length()-1;
int skipS = 0, skipT = 0;
while(i >= 0 || j >= 0) {
// 处理s的退格
while(i >= 0) {
if(s[i] == '#') { skipS++; i--; }
else if(skipS > 0) { skipS--; i--; }
else break;
}
// 处理t的退格
while(j >= 0) {
if(t[j] == '#') { skipT++; j--; }
else if(skipT > 0) { skipT--; j--; }
else break;
}
// 比较当前字符
if(i >= 0 && j >= 0 && s[i] != t[j])
return false;
// 一个字符串已处理完,另一个还有剩余
if((i >= 0) != (j >= 0))
return false;
i--; j--;
}
return true;
}
这种解法的时间复杂度O(m+n),但空间复杂度优化到O(1),是最优解法。
通过这四道题目,我们可以总结出双指针应用的几种常见模式:
在实际刷题中,我建议:
数组类题目虽然基础,但熟练掌握后对后续更复杂的数据结构和算法学习大有裨益。每次刷题后,建议记录下自己的思考过程,这样进步会更快。