那些年,被算法题“暴打”的我如何找到解题密码 🔑
你是否曾经和我一样,面对 LeetCode 题目时陷入这样的困境:
- 🤯 题目读了三遍依然抓不住重点
- 🕵️♂️ 明明知道某个算法,却不知道何时该用
- 🎯 总在暴力解法里兜圈子,想不出最优解
直到我发现:算法题就像密室逃脱,找到隐藏的「考点钥匙」就能破局!
曾经的我以为算法=死记硬背,直到被现实狠狠教育——面试时看到滑动窗口的题目,却用双指针暴力破解导致超时;明明该用哈希表秒杀的场景,却执着于数组遍历... 这些血泪教训让我明白:读题时识别考点,比盲目刷题更重要!
在这篇文章中,我将与你分享:
1️⃣ 如何像侦探一样挖掘题目线索(哈希表?双指针?)
2️⃣ 高频考点的解题模板(滑动窗口的起手式)
3️⃣ 那些让我拍大腿的「啊哈时刻」(Map 原来还能这样用!)
必备知识
时间复杂度
- 时间复杂度是衡量算法随数据规模增长所需的运行时间。不同的时间复杂度决定了算法处理大规模数据时的表现。
| 复杂度类型 | 描述 | 例子 | 时间消耗 |
|---|---|---|---|
| O(1) | 常数时间,不受数据规模影响 | 获取数组元素 | 最短 |
| O(log n) | 对数时间复杂度,时间增长缓慢 | 二分查找 | 较短 |
| O(n) | 线性时间,随数据规模线性增长 | 遍历数组 | 线性增长 |
| O(n log n) | 线性对数时间复杂度 | 快速排序、归并排序 | 介于线性和平方之间 |
| O(n²) | 平方时间,随数据规模平方增长 | 冒泡排序 | 增长较快 |
空间复杂度 衡量所需内存空间随数据规模的增长。
| 复杂度类型 | 描述 | 例子 |
|---|---|---|
| O(1) | 常数空间,不使用额外空间 | 不使用额外存储(如简单计算) |
| O(n) | 线性空间,随着数据规模线性增长 | 存储输入数据或临时数组 |
遍历方法
| 循环方式 | 适用场景 | 时间复杂度 | 性能 |
|---|---|---|---|
for | 适用于遍历数组(索引访问) | O(n) | 最快 |
while | 适用于复杂条件控制 | O(n) | 与 for 类似 |
for...of | 适用于遍历可迭代对象(数组、Set、Map) | O(n) | 比 for 略慢(迭代器开销) |
forEach | 适用于遍历数组(简洁,但不可中断) | O(n) | 比 for 慢(回调开销) |
map | 适用于创建新数组(函数式编程) | O(n) | 比 for 慢(创建新数组) |
for...in | 适用于遍历对象(不适用于数组) | O(n) | 最慢(遍历原型链、非整数键) |
总结
- 遍历最好的方法就是遍历一次
- map的时间复杂度是 o(1) 空间复杂度是 o(n)
- map 最适合从xx中找xx
- for 循环的时间最短
常见的双指针类型
- 两个指针在数据结构(如数组、字符串、链表)上遍历或操作,以提高时间和空间效率。
const list = [0,1,2,3,4,5,6,7]
索引: 0 1 2 3 4 5 6 7
start end // 开始是索引为0的 结束就是 length-1
// 循环的时候可以查找2个对象
| 双指针类型 | 作用 | 适用场景 | 常见题目 |
|---|---|---|---|
| 快慢指针 | 检测链表环、删除重复元素 | 链表 | 判断链表是否有环 |
| 对撞指针 | 两数求和、回文检测 | 排序数组、字符串 | 两数之和、回文判断 |
| 滑动窗口 | 变长子数组、子串 | 字符串、数组 | 最长无重复子串 |
| 归并指针 | 归并排序、合并有序链表 | 数组、链表 | 合并两个有序链表 |
🗝️ 两数之和:算法世界的「Hello World」
- leetcode第一题从入门到入土
- 给定一个整数数组
nums和一个整数目标值target,请你在该数组中找出 和为目标值target的那 两个 整数,并返回它们的数组下标。
示例
输入: nums = [2,7,11,15], target = 9
输出: [0,1]
解释: 因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
重点
当你看到「找出两个元素满足特定关系」时,这就是哈希表的标准求救信号!就像侦探在人群中快速锁定嫌疑人,哈希表能瞬间告诉你目标是否存在。
解题密码破译:
1️⃣ 创建Map作为嫌疑人档案库
2️⃣ 遍历时先计算需要的「另一半」数值
3️⃣ 查档案发现匹配立即逮捕(返回索引)
4️⃣ 未找到则记录当前数值特征(存档备案)
// 刑侦现场还原:哈希表破案实录
const twoSum = (nums, target) => {
const suspectDB = new Map(); // 嫌疑人数据库
for (let crimeScene = 0; crimeScene < nums.length; crimeScene++) {
const currentEvidence = nums[crimeScene];
const wantedSuspect = target - currentEvidence;
if (suspectDB.has(wantedSuspect)) {
return [suspectDB.get(wantedSuspect), crimeScene]; // 成功缉拿
}
suspectDB.set(currentEvidence, crimeScene); // 存档嫌疑人特征
}
};
🔑 最长公共前缀:字符串家族的「共同基因」
编写一个函数来查找字符串数组中的最长公共前缀。
如果不存在公共前缀,返回空字符串 ""。
示例
输入: strs = ["flower","flow","flight"]
输出: "fl"
题目特征分析:
把公共前缀想象成一组多米诺骨牌,当发现某张牌无法立住时,就不断缩短牌阵长度,直到所有牌都能稳定站立。
🌈 横向扫描法:多米诺骨牌式的优雅收缩
破案策略:
1️⃣ 以第一个字符串为基准模板
2️⃣ 像电梯一样逐层检查所有楼层(字符位置)
3️⃣ 当发现某个楼层无法通过时,立即停梯
重点
- 需要理解每次长度减一,再次重新开始匹配如果不匹配就继续缩短直到完全匹配或者全部都不匹配
String的startsWith()方法用来判断当前字符串是否以另外一个给定的子字符串开头,并根据判断结果返回true或false。
// 钥匙齿纹比对实验室
const longestCommonPrefix = (strs) => {
if (!strs.length) return ""; // 空牌阵直接返回
let domino = strs[0]; // 取第一张骨牌作为基准
for (let i = 1; i < strs.length; i++) {
// 当发现不匹配的骨牌时,不断缩短牌阵
while (!strs[i].startsWith(domino)) {
domino = domino.slice(0, -1); // 每次移除最后一张牌
if (domino === "") return ""; // 牌阵完全倒塌立即终止
}
}
return domino; // 最终稳定的牌阵
};
🧩 多米诺效应演示(以 ["flower","flow","flight"] 为例)
| 当前骨牌 | 比对字符串 | 操作记录 | 当前牌阵 |
|---|---|---|---|
| flower | flow | flow.startsWith(flower)? → ❌ | flow |
| flow | flow | ✅ | flow |
| flow | flight | flight.startsWith(flow)? → ❌ | fl |
| fl | flight | ✅ | fl |
🔍 最长无重复子串:滑动窗口的完美舞台
- 给定一个字符串
s,请你找出其中不含有重复字符的 最长 子串 的长度。
示例
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
重点
题目特征分析:
当题目出现「最长/最短子串」「连续/不重复」等关键词,就像在犯罪现场发现连续的脚印——滑动窗口就该登场了!
破案三部曲:
1️⃣ start指针标记证据链起点
2️⃣ Map记录每个字符最后出现位置
3️⃣ 发现重复立即收缩窗口(移动start)
索引: 0 1 2 3 4 5 6 7
字符: a b c a b c b b
-
start = 0,end = 0,窗口起始为空 -
start扩展,依次加入a → b → c -
碰到第二个
a,start移动到1,去掉a -
继续扩展
b → c,又遇到b,start移动到2
// 窗口滑动追踪:最长无重复子串抓捕行动
const lengthOfLongestSubstring = (s) => {
const charTracker = new Map(); // 字符追踪器
let evidenceChainStart = 0; // 证据链起始点
let maxChainLength = 0; // 最大证据链长度
for (let crimeScene = 0; crimeScene < s.length; crimeScene++) {
const currentEvidence = s[crimeScene];
if (charTracker.has(currentEvidence)) {
// 发现重复证据,更新证据链起点
evidenceChainStart = Math.max(evidenceChainStart, charTracker.get(currentEvidence) + 1);
}
// 更新最大证据链长度
maxChainLength = Math.max(maxChainLength, crimeScene - evidenceChainStart + 1);
// 记录最新证据位置
charTracker.set(currentEvidence, crimeScene);
}
return maxChainLength;
};
案件重演(以"abcabcbb"为例):
| 勘察位置 | 证据 | 证据库 | 证据链起点 | 最长证据链 |
|---|---|---|---|---|
| 0 (a) | a → | {a:0} | 0 | 1 |
| 1 (b) | b → | {a:0, b:1} | 0 | 2 |
| 2 (c) | c → | {a:0, b:1, c:2} | 0 | 3 |
| 3 (a) | a 🚨 | {a:3, b:1, c:2} | 1 | 3 |
| 4 (b) | b 🚨 | {a:3, b:4, c:2} | 2 | 3 |
| 5 (c) | c 🚨 | {a:3, b:4, c:5} | 3 | 3 |
| 6 (b) | b 🚨 | {a:3, b:6, c:5} | 5 | 3 |
| 7 (b) | b 🚨 | {a:3, b:7, c:5} | 7 | 1 |
最长回文子串:破解字符串的谜案 🔍
给你一个字符串 s,找到 s 中最长的回文子串
示例
输入: s = "babad"
输出: "bab"
解释: "aba" 同样是符合题意的答案。
重点
首先,你需要明确回文的特点:
- 回文子串就是正着读和反着读都一样的字符串,比如
"aba"或"racecar"。 - 回文串具有中心对称性。对于一个回文串,它有两个“中心”:一个是单个字符中心(如
"a"),另一个是两个字符中心(如"aa")
如果你见到“最长回文子串”这类题目,直接联想到回文特性和对称性,中心扩展法就该登场了!
破案三部曲:
1️⃣ 从字符串中的每个字符出发,尝试以此字符为回文的中心,进行左右扩展。
2️⃣ 以每对字符为中心,扩展形成偶数长度的回文。
3️⃣ 更新找到的最长回文子串,直到找到最终结果。
🚨 代码重现:中心扩展法追踪最长回文子串
/**
* @param {string} s
* @return {string}
*/
var longestPalindrome = function(s) {
let maxStr = ''
// 遍历每个字符,将其作为回文的中心扩展
for (let i = 0; i < s.length; i++) {
// 1. 以单个字符为中心扩展(奇数长度回文)
let str1 = expandAroundCenter(i, i);
// 2. 以两个相邻字符为中心扩展(偶数长度回文)
let str2 = expandAroundCenter(i, i + 1);
// 更新当前最长回文子串
if (str1.length > maxStr.length) {
maxStr = str1;
}
if (str2.length > maxStr.length) {
maxStr = str2;
}
}
return maxStr;
// 扩展回文子串的辅助函数
function expandAroundCenter(left, right) {
while (left >= 0 && right < s.length && s[left] === s[right]) {
left--;
right++;
} // 回文子串
return s.slice(left + 1, right);
}
};
🚀 小结:优化与启示
通过这个破解过程,我们可以得出以下几点总结:
1️⃣ 中心扩展法是高效的:时间复杂度为 O(n²) ,适用于大多数常见情况。如果题目规模更大时,考虑使用 Manacher 算法优化到 O(n) 。
2️⃣ 重点在于对称性:回文串的对称性是核心思路,它能够引导你高效扩展,从而解决问题。
3️⃣ 容易忘记:有偶数回文的情况