题目 16: 两数相加(链表)
难度: 中等
题目描述: 给你两个非空的链表,表示两个非负的整数。它们每位数字都是按照逆序的方式存储的,并且每个节点只能存储一位数字。请你将两个数相加,并以相同形式返回一个表示和的链表。
详细讲解: 【问题分析】 这就像小学学的竖式加法,从个位开始逐位相加,需要处理进位。
【核心思路】
- 同时遍历两个链表,从头到尾逐位相加
- 用一个变量carry记录进位(0或1)
- 每次计算:sum = val1 + val2 + carry
- 当前位的值:sum % 10
- 新的进位:sum / 10
- 创建新节点存储当前位的值
【举例说明】 链表1: 2 → 4 → 3 (表示342) 链表2: 5 → 6 → 4 (表示465)
相加过程: 位1: 2+5=7, carry=0 → 7 位2: 4+6=10, carry=1 → 0 位3: 3+4+1=8, carry=0 → 8 结果: 7 → 0 → 8 (表示807)
【注意事项】
- 两个链表长度可能不同
- 最后可能还有进位(如99+1=100)
【复杂度分析】
- 时间复杂度:O(max(m,n))
- 空间复杂度:O(max(m,n))
Java实现:
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode current = dummy;
int carry = 0; // 进位
while (l1 != null || l2 != null || carry != 0) {
// 获取当前位的值(如果链表已结束则为0)
int val1 = (l1 != null) ? l1.val : 0;
int val2 = (l2 != null) ? l2.val : 0;
// 计算当前位的和
int sum = val1 + val2 + carry;
carry = sum / 10; // 新的进位
// 创建新节点
current.next = new ListNode(sum % 10);
current = current.next;
// 移动指针
if (l1 != null) l1 = l1.next;
if (l2 != null) l2 = l2.next;
}
return dummy.next;
}
JavaScript实现:
function addTwoNumbers(l1, l2) {
const dummy = { val: 0, next: null };
let current = dummy;
let carry = 0; // 进位
while (l1 || l2 || carry) {
// 获取当前位的值
const val1 = l1 ? l1.val : 0;
const val2 = l2 ? l2.val : 0;
// 计算当前位的和
const sum = val1 + val2 + carry;
carry = Math.floor(sum / 10); // 新的进位
// 创建新节点
current.next = { val: sum % 10, next: null };
current = current.next;
// 移动指针
if (l1) l1 = l1.next;
if (l2) l2 = l2.next;
}
return dummy.next;
}
题目 17: 无重复字符的最长子串
难度: 中等
题目描述: 给定一个字符串 s,请你找出其中不含有重复字符的最长子串的长度。
详细讲解: 【问题分析】 要找"不含重复字符"的"最长"子串。关键是高效地检测重复。
【核心思路 - 滑动窗口】 想象一个可伸缩的窗口在字符串上滑动:
- 用Set存储窗口内的字符(快速检测重复)
- 右指针不断右移扩大窗口
- 遇到重复字符时,左指针右移缩小窗口
- 记录过程中的最大窗口长度
【图解过程】 字符串: "abcabcbb"
窗口变化: [a]bc... → 长度1 [ab]c... → 长度2 [abc]a... → 长度3 a[bca]b... → 遇到a重复,左指针移动 ab[cab]c... → 遇到b重复,左指针移动 ...
最长长度:3 (abc)
【通俗理解】 就像用橡皮筋圈字符:
- 往右拉(扩大窗口)尽量圈更多字符
- 遇到重复了,从左边松开(缩小窗口)
- 记住圈过的最大长度
【复杂度分析】
- 时间复杂度:O(n) - 每个字符最多被访问2次
- 空间复杂度:O(min(m,n)) - m是字符集大小
Java实现:
public int lengthOfLongestSubstring(String s) {
Set<Character> set = new HashSet<>();
int maxLen = 0;
int left = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
// 如果字符重复,移动左指针
while (set.contains(c)) {
set.remove(s.charAt(left));
left++;
}
// 加入当前字符
set.add(c);
// 更新最大长度
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
JavaScript实现:
function lengthOfLongestSubstring(s) {
const set = new Set();
let maxLen = 0;
let left = 0;
for (let right = 0; right < s.length; right++) {
const c = s[right];
// 如果字符重复,移动左指针
while (set.has(c)) {
set.delete(s[left]);
left++;
}
// 加入当前字符
set.add(c);
// 更新最大长度
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
题目 18: 括号生成
难度: 中等
题目描述: 数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且有效的括号组合。
详细讲解: 【问题分析】 生成n对括号的所有有效组合。比如n=3,要生成: ((())), (()()), (())(), ()(()), ()()()
【核心思路 - 回溯】 有效括号的规则:
- 左括号数量 = 右括号数量 = n
- 任何时候,右括号数量不能超过左括号
【递归策略】 在每个位置可以选择:
- 添加左括号(如果左括号数 < n)
- 添加右括号(如果右括号数 < 左括号数)
【图解决策树】 n=2时: "" / ( 添加左括号 /
(( () 可以添加左或右 | /
(() ()( ()) 继续递归 | | | (()) ()( ()) 完成
【通俗理解】 想象你在写括号:
- 随时可以写左括号(只要没用完)
- 写右括号要小心(不能比左括号多)
- 写完n对就记录下来
【复杂度分析】
- 时间复杂度:O(4^n/√n) - 卡特兰数
- 空间复杂度:O(n) - 递归深度
Java实现:
public List<String> generateParenthesis(int n) {
List<String> result = new ArrayList<>();
backtrack(result, "", 0, 0, n);
return result;
}
private void backtrack(List<String> result, String current,
int open, int close, int max) {
// 递归出口:完成一组括号
if (current.length() == max * 2) {
result.add(current);
return;
}
// 添加左括号
if (open < max) {
backtrack(result, current + "(", open + 1, close, max);
}
// 添加右括号(只有当右括号数量少于左括号时)
if (close < open) {
backtrack(result, current + ")", open, close + 1, max);
}
}
JavaScript实现:
function generateParenthesis(n) {
const result = [];
function backtrack(current, open, close) {
// 递归出口:完成一组括号
if (current.length === n * 2) {
result.push(current);
return;
}
// 添加左括号
if (open < n) {
backtrack(current + '(', open + 1, close);
}
// 添加右括号
if (close < open) {
backtrack(current + ')', open, close + 1);
}
}
backtrack('', 0, 0);
return result;
}
题目 19: 编辑距离
难度: 困难
题目描述: 给你两个单词 word1 和 word2,请返回将 word1 转换成 word2 所使用的最少操作数。你可以对一个单词进行如下三种操作:插入一个字符、删除一个字符、替换一个字符。
详细讲解: 【问题分析】 这是经典的动态规划问题,也叫Levenshtein距离。
【核心思路】 创建dp[i][j]表示word1的前i个字符转换为word2的前j个字符需要的最少操作数。
【状态转移】 如果word1[i-1] == word2[j-1]: 字符相同,不需要操作 dp[i][j] = dp[i-1][j-1]
如果字符不同,选择代价最小的操作:
- 替换:dp[i-1][j-1] + 1
- 删除word1[i]:dp[i-1][j] + 1
- 插入word2[j]:dp[i][j-1] + 1
dp[i][j] = min(这三种操作)
【图解】 word1 = "horse", word2 = "ros"
"" r o s
"" 0 1 2 3 h 1 1 2 3 o 2 2 1 2 r 3 2 2 2 s 4 3 3 2 e 5 4 4 3
答案:dp[5][3] = 3 操作:horse → rorse → rose → ros
【通俗理解】 想象你在编辑文本:
- 字符相同:不用改,看前面的编辑次数
- 字符不同:改、删、插,选最省的那个
【复杂度分析】
- 时间复杂度:O(m×n)
- 空间复杂度:O(m×n)
Java实现:
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
int[][] dp = new int[m + 1][n + 1];
// 初始化边界
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
// 填充dp表
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (word1.charAt(i-1) == word2.charAt(j-1)) {
// 字符相同
dp[i][j] = dp[i-1][j-1];
} else {
// 字符不同,选择最小代价
dp[i][j] = Math.min(
Math.min(
dp[i-1][j-1], // 替换
dp[i-1][j] // 删除
),
dp[i][j-1] // 插入
) + 1;
}
}
}
return dp[m][n];
}
JavaScript实现:
function minDistance(word1, word2) {
const m = word1.length, n = word2.length;
const dp = Array(m+1).fill(0).map(() => Array(n+1).fill(0));
// 初始化边界
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
// 填充dp表
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (word1[i-1] === word2[j-1]) {
// 字符相同
dp[i][j] = dp[i-1][j-1];
} else {
// 字符不同,选择最小代价
dp[i][j] = Math.min(
dp[i-1][j-1], // 替换
dp[i-1][j], // 删除
dp[i][j-1] // 插入
) + 1;
}
}
}
return dp[m][n];
}
题目 20: 零钱兑换
难度: 中等
题目描述: 给你一个整数数组 coins 表示不同面额的硬币,以及一个整数 amount 表示总金额。计算并返回可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
详细讲解: 【问题分析】 这是完全背包问题的变种,每种硬币可以用无限次。
【核心思路 - 动态规划】 dp[i]表示凑成金额i所需的最少硬币数。
对于每个金额i:
- 尝试使用每种硬币coin
- 如果i >= coin,可以用这个硬币
- dp[i] = min(dp[i], dp[i-coin] + 1)
【举例说明】 coins = [1, 2, 5], amount = 11
dp数组构建过程: 金额0: 0个硬币 金额1: 1个硬币(1) 金额2: 1个硬币(2) 或 2个硬币(1+1),取最小=1 金额3: 2个硬币(2+1) 金额4: 2个硬币(2+2) 金额5: 1个硬币(5) 金额6: 2个硬币(5+1) 金额7: 2个硬币(5+2) ... 金额11: 3个硬币(5+5+1)
【通俗理解】 就像找钱:
- 要凑11元
- 可以用5元、5元、1元(3个硬币) ✓
- 或用2元×5 + 1元(6个硬币)
- 选最少的方案
【复杂度分析】
- 时间复杂度:O(amount × n) - n是硬币种类
- 空间复杂度:O(amount)
Java实现:
public int coinChange(int[] coins, int amount) {
// dp[i]表示凑成金额i的最少硬币数
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1); // 初始化为不可能的大值
dp[0] = 0; // 金额0需要0个硬币
// 对每个金额
for (int i = 1; i <= amount; i++) {
// 尝试每种硬币
for (int coin : coins) {
if (i >= coin) {
// 可以使用这个硬币
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// 如果dp[amount]还是初始值,说明无解
return dp[amount] > amount ? -1 : dp[amount];
}
JavaScript实现:
function coinChange(coins, amount) {
// dp[i]表示凑成金额i的最少硬币数
const dp = new Array(amount + 1).fill(amount + 1);
dp[0] = 0; // 金额0需要0个硬币
// 对每个金额
for (let i = 1; i <= amount; i++) {
// 尝试每种硬币
for (const coin of coins) {
if (i >= coin) {
// 可以使用这个硬币
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// 如果dp[amount]还是初始值,说明无解
return dp[amount] > amount ? -1 : dp[amount];
}
题目 21: 打家劫舍
难度: 中等
题目描述: 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
详细讲解: 【问题分析】 不能偷相邻的房子,要求最大金额。
【核心思路 - 动态规划】 对于第i个房子,有两个选择:
- 偷第i个房子:金额 = nums[i] + dp[i-2](不能偷i-1)
- 不偷第i个房子:金额 = dp[i-1](保持前一个状态)
选择金额较大的方案。
【状态转移方程】 dp[i] = max(nums[i] + dp[i-2], dp[i-1])
【举例说明】 nums = [2, 7, 9, 3, 1]
dp[0] = 2 (偷第0个) dp[1] = 7 (不偷0,偷1) dp[2] = 11 (偷0和2: 2+9=11) dp[3] = 11 (不偷3: max(3+7, 11)=11) dp[4] = 12 (偷4: max(1+11, 11)=12)
答案:12(偷0、2、4号房)
【通俗理解】 每到一个房子前思考:
- 偷这家:拿这家的钱 + 上上家能偷的最大值
- 不偷这家:保持上一家能偷的最大值
- 选择更多的那个
【空间优化】 只需要记住前两个状态,不需要整个数组。
【复杂度分析】
- 时间复杂度:O(n)
- 空间复杂度:O(1) - 优化后
Java实现:
public int rob(int[] nums) {
if (nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
// prev2: dp[i-2], prev1: dp[i-1]
int prev2 = nums[0];
int prev1 = Math.max(nums[0], nums[1]);
for (int i = 2; i < nums.length; i++) {
int current = Math.max(nums[i] + prev2, prev1);
prev2 = prev1;
prev1 = current;
}
return prev1;
}
JavaScript实现:
function rob(nums) {
if (nums.length === 0) return 0;
if (nums.length === 1) return nums[0];
// prev2: dp[i-2], prev1: dp[i-1]
let prev2 = nums[0];
let prev1 = Math.max(nums[0], nums[1]);
for (let i = 2; i < nums.length; i++) {
const current = Math.max(nums[i] + prev2, prev1);
prev2 = prev1;
prev1 = current;
}
return prev1;
}
题目 22: 旋转数组
难度: 简单
题目描述: 给定一个数组,将数组中的元素向右旋转 k 个位置,其中 k 是非负数。
详细讲解: 【问题分析】 把数组的后k个元素移到前面。
【核心思路 - 三次反转】 这是一个巧妙的方法:
- 反转整个数组
- 反转前k个元素
- 反转后n-k个元素
【图解过程】 原数组: [1,2,3,4,5,6,7], k=3
步骤1 - 反转整个数组: [7,6,5,4,3,2,1]
步骤2 - 反转前k个: [5,6,7,4,3,2,1]
步骤3 - 反转后n-k个: [5,6,7,1,2,3,4] ✓
【为什么有效?】 向右旋转k位 = 把后k个放前面 反转操作可以改变元素顺序,三次反转正好达到效果。
【其他方法】
- 暴力法:每次右移一位,重复k次 - O(n×k)
- 使用额外数组 - O(n)空间
- 环状替换 - 较复杂
【注意】 k可能大于数组长度,需要取模:k = k % n
【复杂度分析】
- 时间复杂度:O(n) - 三次遍历
- 空间复杂度:O(1) - 原地操作
Java实现:
public void rotate(int[] nums, int k) {
int n = nums.length;
k = k % n; // 处理k大于n的情况
// 三次反转
reverse(nums, 0, n - 1); // 反转整个数组
reverse(nums, 0, k - 1); // 反转前k个
reverse(nums, k, n - 1); // 反转后n-k个
}
private void reverse(int[] nums, int start, int end) {
while (start < end) {
int temp = nums[start];
nums[start] = nums[end];
nums[end] = temp;
start++;
end--;
}
}
JavaScript实现:
function rotate(nums, k) {
const n = nums.length;
k = k % n; // 处理k大于n的情况
// 辅助函数:反转数组的一部分
function reverse(start, end) {
while (start < end) {
[nums[start], nums[end]] = [nums[end], nums[start]];
start++;
end--;
}
}
// 三次反转
reverse(0, n - 1); // 反转整个数组
reverse(0, k - 1); // 反转前k个
reverse(k, n - 1); // 反转后n-k个
}
题目 23: 合并区间
难度: 中等
题目描述: 以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi]。请你合并所有重叠的区间,并返回一个不重叠的区间数组。
详细讲解: 【问题分析】 把重叠的区间合并成一个。比如[1,3]和[2,6]重叠,合并为[1,6]。
【核心思路】
- 先按区间起点排序(这很关键!)
- 遍历排序后的区间:
- 如果当前区间和前一个不重叠,加入结果
- 如果重叠,合并(更新前一个区间的结束点)
【判断重叠】 区间A[start1, end1]和B[start2, end2]重叠的条件: start2 <= end1(排序后start2一定>=start1)
【图解】 原始: [[1,3],[2,6],[8,10],[15,18]]
排序后: [[1,3],[2,6],[8,10],[15,18]]
处理过程: [1,3] → 加入结果 [2,6] → 2<=3,重叠!合并为[1,6] [8,10] → 8>6,不重叠,加入结果 [15,18] → 15>10,不重叠,加入结果
结果: [[1,6],[8,10],[15,18]]
【通俗理解】 想象时间轴上的会议安排:
- 先按开始时间排序
- 如果下一个会议在当前会议结束前开始,就合并
- 否则是独立的会议
【复杂度分析】
- 时间复杂度:O(n log n) - 排序
- 空间复杂度:O(n) - 结果数组
Java实现:
public int[][] merge(int[][] intervals) {
if (intervals.length <= 1) return intervals;
// 1. 按起点排序
Arrays.sort(intervals, (a, b) -> a[0] - b[0]);
List<int[]> result = new ArrayList<>();
int[] current = intervals[0];
result.add(current);
// 2. 遍历所有区间
for (int i = 1; i < intervals.length; i++) {
int[] interval = intervals[i];
if (interval[0] <= current[1]) {
// 重叠,合并
current[1] = Math.max(current[1], interval[1]);
} else {
// 不重叠,加入新区间
current = interval;
result.add(current);
}
}
return result.toArray(new int[result.size()][]);
}
JavaScript实现:
function merge(intervals) {
if (intervals.length <= 1) return intervals;
// 1. 按起点排序
intervals.sort((a, b) => a[0] - b[0]);
const result = [];
let current = intervals[0];
result.push(current);
// 2. 遍历所有区间
for (let i = 1; i < intervals.length; i++) {
const interval = intervals[i];
if (interval[0] <= current[1]) {
// 重叠,合并
current[1] = Math.max(current[1], interval[1]);
} else {
// 不重叠,加入新区间
current = interval;
result.push(current);
}
}
return result;
}
题目 24: 螺旋矩阵
难度: 中等
题目描述: 给你一个 m 行 n 列的矩阵 matrix,请按照顺时针螺旋顺序,返回矩阵中的所有元素。
详细讲解: 【问题分析】 按照"右→下→左→上"的螺旋顺序遍历矩阵。
【核心思路】 维护四个边界:
- top: 上边界
- bottom: 下边界
- left: 左边界
- right: 右边界
按顺序遍历:
- 从左到右遍历上边界,top++
- 从上到下遍历右边界,right--
- 从右到左遍历下边界,bottom--
- 从下到上遍历左边界,left++
重复直到边界相遇。
【图解】 矩阵: 1 2 3 4 5 6 7 8 9 10 11 12
第1轮: →→→ (1,2,3,4) top=0→1 ↓ (8,12) right=3→2 ←←← (11,10,9) bottom=2→1 ↑ (5) left=0→1
第2轮: → (6,7) top=1→2 ↓ (11) right=2→1
结果: [1,2,3,4,8,12,11,10,9,5,6,7]
【边界条件】
- 只剩一行或一列时要特殊处理
- 防止重复遍历
【复杂度分析】
- 时间复杂度:O(m×n)
- 空间复杂度:O(1) - 不计结果数组
Java实现:
public List<Integer> spiralOrder(int[][] matrix) {
List<Integer> result = new ArrayList<>();
if (matrix.length == 0) return result;
int top = 0, bottom = matrix.length - 1;
int left = 0, right = matrix[0].length - 1;
while (top <= bottom && left <= right) {
// 1. 从左到右
for (int i = left; i <= right; i++) {
result.add(matrix[top][i]);
}
top++;
// 2. 从上到下
for (int i = top; i <= bottom; i++) {
result.add(matrix[i][right]);
}
right--;
// 3. 从右到左(检查是否还有行)
if (top <= bottom) {
for (int i = right; i >= left; i--) {
result.add(matrix[bottom][i]);
}
bottom--;
}
// 4. 从下到上(检查是否还有列)
if (left <= right) {
for (int i = bottom; i >= top; i--) {
result.add(matrix[i][left]);
}
left++;
}
}
return result;
}
JavaScript实现:
function spiralOrder(matrix) {
const result = [];
if (matrix.length === 0) return result;
let top = 0, bottom = matrix.length - 1;
let left = 0, right = matrix[0].length - 1;
while (top <= bottom && left <= right) {
// 1. 从左到右
for (let i = left; i <= right; i++) {
result.push(matrix[top][i]);
}
top++;
// 2. 从上到下
for (let i = top; i <= bottom; i++) {
result.push(matrix[i][right]);
}
right--;
// 3. 从右到左
if (top <= bottom) {
for (let i = right; i >= left; i--) {
result.push(matrix[bottom][i]);
}
bottom--;
}
// 4. 从下到上
if (left <= right) {
for (let i = bottom; i >= top; i--) {
result.push(matrix[i][left]);
}
left++;
}
}
return result;
}
题目 25: 跳跃游戏
难度: 中等
题目描述: 给定一个非负整数数组 nums,你最初位于数组的第一个下标。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。
详细讲解: 【问题分析】 从起点开始,每次可以跳1到nums[i]步,问能否到达终点。
【核心思路 - 贪心】 维护一个"最远可达位置":
- 遍历数组
- 更新从当前位置能到达的最远位置
- 如果最远位置>=终点,返回true
- 如果当前位置>最远位置,说明跳不到这里,返回false
【图解】 nums = [2,3,1,1,4]
位置0: 最远=2 (可以跳到位置1或2) 位置1: 最远=4 (可以跳到位置2,3,4) 位置2: 最远=4 到达终点!返回true
nums = [3,2,1,0,4]
位置0: 最远=3 位置1: 最远=3 位置2: 最远=3 位置3: 最远=3 (到了一个0,卡住了) 位置4: 无法到达!
【通俗理解】 想象你在跳石头过河:
- 记住"我能跳到的最远的石头"
- 如果最远的石头能到对岸,就能过河
- 如果前面的石头我都跳不到,就过不去
【复杂度分析】
- 时间复杂度:O(n)
- 空间复杂度:O(1)
Java实现:
public boolean canJump(int[] nums) {
int maxReach = 0; // 最远可达位置
for (int i = 0; i < nums.length; i++) {
// 如果当前位置超过最远可达位置,跳不到这里
if (i > maxReach) {
return false;
}
// 更新最远可达位置
maxReach = Math.max(maxReach, i + nums[i]);
// 如果已经可以到达终点
if (maxReach >= nums.length - 1) {
return true;
}
}
return true;
}
JavaScript实现:
function canJump(nums) {
let maxReach = 0; // 最远可达位置
for (let i = 0; i < nums.length; i++) {
// 如果当前位置超过最远可达位置,跳不到这里
if (i > maxReach) {
return false;
}
// 更新最远可达位置
maxReach = Math.max(maxReach, i + nums[i]);
// 如果已经可以到达终点
if (maxReach >= nums.length - 1) {
return true;
}
}
return true;
}
题目 26: 不同路径
难度: 中等
题目描述: 一个机器人位于一个 m x n 网格的左上角。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角。问总共有多少条不同的路径?
详细讲解: 【问题分析】 机器人只能往右或往下走,求到达终点的路径数。
【核心思路 - 动态规划】 dp[i][j]表示到达位置(i,j)的路径数。
【状态转移】 到达(i,j)只有两种方式:
- 从上面(i-1,j)往下走
- 从左边(i,j-1)往右走
所以:dp[i][j] = dp[i-1][j] + dp[i][j-1]
【初始化】
- 第一行:只能一直往右,dp[0][j] = 1
- 第一列:只能一直往下,dp[i][0] = 1
【图解】 3×3网格的dp表:
0 1 2
0 1 1 1 1 1 2 3 2 1 3 6
到达(2,2)有6条路径。
【通俗理解】 想象走迷宫:
- 每个格子的路径数 = 上面来的路径数 + 左边来的路径数
- 边界上只有一条路(一直走边)
【空间优化】 可以用一维数组,从左到右更新。
【复杂度分析】
- 时间复杂度:O(m×n)
- 空间复杂度:O(n) - 优化后
Java实现:
public int uniquePaths(int m, int n) {
// 使用一维数组优化空间
int[] dp = new int[n];
Arrays.fill(dp, 1); // 第一行全是1
// 从第二行开始
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
// dp[j]当前是上一行的值,dp[j-1]是当前行左边的值
dp[j] = dp[j] + dp[j-1];
}
}
return dp[n-1];
}
JavaScript实现:
function uniquePaths(m, n) {
// 使用一维数组优化空间
const dp = new Array(n).fill(1); // 第一行全是1
// 从第二行开始
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
// dp[j]当前是上一行的值,dp[j-1]是当前行左边的值
dp[j] = dp[j] + dp[j-1];
}
}
return dp[n-1];
}
题目 27: 搜索旋转排序数组
难度: 中等
题目描述: 整数数组 nums 按升序排列,数组中的值互不相同。在传递给函数之前,nums 在预先未知的某个下标 k 上进行了旋转。例如,[0,1,2,4,5,6,7] 在下标 3 处经旋转后变为 [4,5,6,7,0,1,2]。给你旋转后的数组 nums 和一个整数 target,如果 nums 中存在这个目标值,则返回它的下标,否则返回 -1。
详细讲解: 【问题分析】 数组是排序的,但被旋转了。要求O(log n)时间,提示用二分查找。
【核心思路 - 修改的二分查找】 旋转后的数组有个特点:
- 至少有一半是有序的
- 判断target在哪一半,然后在有序的一半用二分查找
【判断逻辑】
- 计算mid
- 判断哪一半有序:
- 如果nums[left] <= nums[mid]:左半有序
- 否则右半有序
- 判断target在不在有序的那一半
- 调整left或right
【图解】 nums = [4,5,6,7,0,1,2], target = 0
第1轮: left=0, right=6, mid=3 nums[mid]=7, nums[left]=4 左半[4,5,6,7]有序,0不在这里 搜索右半 → left=4
第2轮: left=4, right=6, mid=5 nums[mid]=1, nums[left]=0 左半[0,1]有序,0在这里 搜索左半 → right=4
第3轮: left=4, right=4, mid=4 找到了!nums[4]=0
【通俗理解】 虽然整体乱了,但局部还是有序的:
- 找到有序的那一半
- 判断target是不是在有序部分
- 在有序部分用正常的二分查找
【复杂度分析】
- 时间复杂度:O(log n)
- 空间复杂度:O(1)
Java实现:
public int search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
}
// 判断哪一半有序
if (nums[left] <= nums[mid]) {
// 左半有序
if (nums[left] <= target && target < nums[mid]) {
// target在左半
right = mid - 1;
} else {
// target在右半
left = mid + 1;
}
} else {
// 右半有序
if (nums[mid] < target && target <= nums[right]) {
// target在右半
left = mid + 1;
} else {
// target在左半
right = mid - 1;
}
}
}
return -1;
}
JavaScript实现:
function search(nums, target) {
let left = 0, right = nums.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
return mid;
}
// 判断哪一半有序
if (nums[left] <= nums[mid]) {
// 左半有序
if (nums[left] <= target && target < nums[mid]) {
// target在左半
right = mid - 1;
} else {
// target在右半
left = mid + 1;
}
} else {
// 右半有序
if (nums[mid] < target && target <= nums[right]) {
// target在右半
left = mid + 1;
} else {
// target在左半
right = mid - 1;
}
}
}
return -1;
}
题目 28: 乘积最大子数组
难度: 中等
题目描述: 给你一个整数数组 nums,请你找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
详细讲解: 【问题分析】 和最大子数组和类似,但乘法有特殊性:
- 负数×负数=正数(负负得正)
- 所以要同时维护最大值和最小值
【核心思路】 维护两个值:
- maxProduct: 以当前位置结尾的最大乘积
- minProduct: 以当前位置结尾的最小乘积
对于当前数nums[i]:
- 如果是正数:max变大,min变小
- 如果是负数:max和min互换(负负得正)
- 如果是0:重新开始
【状态转移】 maxProduct = max(nums[i], maxProduct×nums[i], minProduct×nums[i]) minProduct = min(nums[i], maxProduct×nums[i], minProduct×nums[i])
【举例】 nums = [2, 3, -2, 4]
i=0: max=2, min=2, result=2 i=1: max=6, min=3, result=6 i=2: max=-2, min=-12, result=6 (注意-12变成了min,因为负数) i=3: max=4, min=-48, result=6
【通俗理解】 乘法的特殊性:
- 遇到负数,最大和最小会交换
- 所以要同时记录两个极值
- 负数可能让最小值变成最大值
【复杂度分析】
- 时间复杂度:O(n)
- 空间复杂度:O(1)
Java实现:
public int maxProduct(int[] nums) {
int maxProduct = nums[0];
int minProduct = nums[0];
int result = nums[0];
for (int i = 1; i < nums.length; i++) {
// 如果当前数是负数,最大最小值会互换
if (nums[i] < 0) {
int temp = maxProduct;
maxProduct = minProduct;
minProduct = temp;
}
// 更新最大和最小乘积
maxProduct = Math.max(nums[i], maxProduct * nums[i]);
minProduct = Math.min(nums[i], minProduct * nums[i]);
// 更新结果
result = Math.max(result, maxProduct);
}
return result;
}
JavaScript实现:
function maxProduct(nums) {
let maxProduct = nums[0];
let minProduct = nums[0];
let result = nums[0];
for (let i = 1; i < nums.length; i++) {
// 如果当前数是负数,最大最小值会互换
if (nums[i] < 0) {
[maxProduct, minProduct] = [minProduct, maxProduct];
}
// 更新最大和最小乘积
maxProduct = Math.max(nums[i], maxProduct * nums[i]);
minProduct = Math.min(nums[i], minProduct * nums[i]);
// 更新结果
result = Math.max(result, maxProduct);
}
return result;
}
题目 29: 字典序排数
难度: 中等
题目描述: 给你一个整数 n,按字典序返回范围 [1, n] 内所有整数。例如,给定 n = 13,返回 [1,10,11,12,13,2,3,4,5,6,7,8,9]。
详细讲解: 【问题分析】 字典序就是按字符串排序的顺序。比如"10"在"2"前面(因为'1'<'2')。
【核心思路 - DFS】 把数字想象成一棵树:
1 2 3 ... 9
/|\ /|\ /|\
10 11 12 20 21 30 31
/|\
100 101 102
按深度优先遍历这棵树,就是字典序。
【DFS策略】
- 从1到9,依次作为起点
- 对每个数字,尝试在后面加0-9
- 如果加完的数字<=n,继续递归
- 按DFS顺序加入结果
【举例】 n=13:
从1开始: 1 → 10 → 100(>13,停止) 10 → 11 → 110(>13,停止) 11 → 12 → 120(>13,停止) 12 → 13 → 130(>13,停止) 13(完成) 从2开始: 2 → 20(>13,停止) 从3到9...
结果: [1,10,11,12,13,2,3,4,5,6,7,8,9]
【通俗理解】 像查字典:
- 先看第一个字母
- 第一个字母相同,看第二个字母
- 数字也是同样的道理
【复杂度分析】
- 时间复杂度:O(n)
- 空间复杂度:O(log n) - 递归深度
Java实现:
public List<Integer> lexicalOrder(int n) {
List<Integer> result = new ArrayList<>();
// 从1到9开始DFS
for (int i = 1; i <= 9; i++) {
dfs(i, n, result);
}
return result;
}
private void dfs(int current, int n, List<Integer> result) {
// 如果超过n,停止
if (current > n) {
return;
}
// 加入当前数字
result.add(current);
// 尝试在后面加0-9
for (int i = 0; i <= 9; i++) {
int next = current * 10 + i;
if (next > n) {
break;
}
dfs(next, n, result);
}
}
JavaScript实现:
function lexicalOrder(n) {
const result = [];
function dfs(current) {
// 如果超过n,停止
if (current > n) {
return;
}
// 加入当前数字
result.push(current);
// 尝试在后面加0-9
for (let i = 0; i <= 9; i++) {
const next = current * 10 + i;
if (next > n) {
break;
}
dfs(next);
}
}
// 从1到9开始DFS
for (let i = 1; i <= 9; i++) {
dfs(i);
}
return result;
}
题目 30: 数组中的第K个最大元素
难度: 中等
题目描述: 给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
详细讲解: 【问题分析】 找第K大的元素。暴力解法是排序后取第K个,时间O(n log n)。我们可以优化。
【方法一 - 最小堆】 维护一个大小为K的最小堆:
- 遍历所有元素
- 如果堆大小<K,直接加入
- 如果堆满了且当前元素>堆顶,替换堆顶
- 最后堆顶就是第K大元素
【为什么用最小堆?】 堆顶是堆中最小的,保证堆里始终是前K大的元素。
【方法二 - 快速选择(QuickSelect)】 类似快速排序的思想:
- 选择一个pivot,把数组分成两部分
- 如果pivot的位置正好是K,找到了
- 如果位置<K,在右边继续找
- 如果位置>K,在左边继续找
平均时间O(n),比排序更快!
【图解(最小堆)】 nums = [3,2,1,5,6,4], k = 2
遍历过程(维护大小为2的最小堆): 堆: [3] 堆: [2,3] 堆: [2,3] (1<2,不加入) 堆: [3,5] (5>2,替换2) 堆: [5,6] (6>3,替换3) 堆: [5,6] (4<5,不加入)
答案: 5(堆顶)
【通俗理解】 就像选班级前K名:
- 用一个小本子记录前K名(堆)
- 新来一个人,如果比最后一名强,把最后一名挤出去
- 最后小本子上最后一名就是第K名
【复杂度分析】
- 最小堆:时间O(n log k),空间O(k)
- 快速选择:平均O(n),最坏O(n²),空间O(1)
Java实现(最小堆):
public int findKthLargest(int[] nums, int k) {
// 创建最小堆
PriorityQueue<Integer> heap = new PriorityQueue<>();
for (int num : nums) {
heap.offer(num);
// 如果堆大小超过k,移除最小的
if (heap.size() > k) {
heap.poll();
}
}
// 堆顶就是第k大元素
return heap.peek();
}
Java实现(快速选择):
public int findKthLargest(int[] nums, int k) {
// 第k大 = 第(n-k)小
return quickSelect(nums, 0, nums.length - 1, nums.length - k);
}
private int quickSelect(int[] nums, int left, int right, int k) {
if (left == right) {
return nums[left];
}
// 随机选择pivot(避免最坏情况)
int pivotIndex = left + new Random().nextInt(right - left + 1);
pivotIndex = partition(nums, left, right, pivotIndex);
if (k == pivotIndex) {
return nums[k];
} else if (k < pivotIndex) {
return quickSelect(nums, left, pivotIndex - 1, k);
} else {
return quickSelect(nums, pivotIndex + 1, right, k);
}
}
private int partition(int[] nums, int left, int right, int pivotIndex) {
int pivot = nums[pivotIndex];
// 把pivot移到最右边
swap(nums, pivotIndex, right);
int storeIndex = left;
for (int i = left; i < right; i++) {
if (nums[i] < pivot) {
swap(nums, storeIndex, i);
storeIndex++;
}
}
// 把pivot放到正确位置
swap(nums, storeIndex, right);
return storeIndex;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
JavaScript实现(最小堆):
function findKthLargest(nums, k) {
// JavaScript没有内置堆,用数组模拟最小堆
const heap = [];
for (const num of nums) {
heap.push(num);
heap.sort((a, b) => a - b); // 简化版,实际应用应该用真正的堆
// 如果堆大小超过k,移除最小的
if (heap.length > k) {
heap.shift();
}
}
// 堆顶就是第k大元素
return heap[0];
}
JavaScript实现(快速选择):
function findKthLargest(nums, k) {
// 第k大 = 第(n-k)小
return quickSelect(nums, 0, nums.length - 1, nums.length - k);
}
function quickSelect(nums, left, right, k) {
if (left === right) {
return nums[left];
}
// 随机选择pivot
let pivotIndex = left + Math.floor(Math.random() * (right - left + 1));
pivotIndex = partition(nums, left, right, pivotIndex);
if (k === pivotIndex) {
return nums[k];
} else if (k < pivotIndex) {
return quickSelect(nums, left, pivotIndex - 1, k);
} else {
return quickSelect(nums, pivotIndex + 1, right, k);
}
}
function partition(nums, left, right, pivotIndex) {
const pivot = nums[pivotIndex];
// 把pivot移到最右边
[nums[pivotIndex], nums[right]] = [nums[right], nums[pivotIndex]];
let storeIndex = left;
for (let i = left; i < right; i++) {
if (nums[i] < pivot) {
[nums[storeIndex], nums[i]] = [nums[i], nums[storeIndex]];
storeIndex++;
}
}
// 把pivot放到正确位置
[nums[storeIndex], nums[right]] = [nums[right], nums[storeIndex]];
return storeIndex;
}
总结
这15道题目涵盖了全栈开发面试中的高频算法题型:
📊 按类型分类:
链表(1题):
- 两数相加
字符串(3题):
- 无重复字符的最长子串
- 括号生成
- 编辑距离
动态规划(5题):
- 编辑距离
- 零钱兑换
- 打家劫舍
- 不同路径
- 乘积最大子数组
数组(4题):
- 旋转数组
- 合并区间
- 螺旋矩阵
- 第K个最大元素
贪心算法(1题):
- 跳跃游戏
二分查找(1题):
- 搜索旋转排序数组
💡 学习建议:
- 按难度递进:先掌握简单题,再挑战中等和困难题
- 理解思路:不要死记代码,理解算法思想最重要
- 多练习:每道题至少手写3遍
- 举一反三:同类型题目的解题思路是相通的
- 复杂度分析:面试时要能说出时间和空间复杂度
🎯 面试技巧:
- 沟通:先说思路,不要急着写代码
- 测试用例:考虑边界情况
- 优化:从暴力解法开始,逐步优化
- 代码规范:变量命名清晰,注释关键步骤
祝你面试顺利!🚀