目录
目标和
1 题目描述
给你一个整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个 表达式 :
- 例如,nums = [2, 1] ,可以在 2 之前添加 ‘+’ ,在 1 之前添加 ‘-’ ,然后串联起来得到表达式 “+2-1” 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
示例 1:
输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3
示例 2:
输入:nums = [1], target = 1
输出:1
提示:
- 1 <= nums.length <= 20
- 0 <= nums[i] <= 1000
- 0 <= sum(nums[i]) <= 1000
- -1000 <= target <= 1000
2 解题(Java)
树的后序遍历+记忆化搜索
class Solution {
Map<String,Integer> cache = new HashMap<>();
int target;
int[] nums;
public int findTargetSumWays(int[] nums, int target) {
this.target = target;
this.nums = nums;
return dfs(0, nums[0]) + dfs(0, -nums[0]);
}
int dfs(int index, int sum) {
String key = index + "_" + sum;
if (cache.containsKey(key)) {
return cache.get(key);
}
if (index == nums.length-1) {
cache.put(key, sum == target ? 1 : 0);
return cache.get(key);
}
int left = dfs(index + 1, sum + nums[index+1]);
int right = dfs(index + 1, sum - nums[index+1]);
cache.put(key, left + right);
return cache.get(key);
}
}
3 复杂性分析
路径总和 III
1 题目描述
给定一个二叉树的根节点 root ,和一个整数 targetSum ,求该二叉树里节点值之和等于 targetSum 的 路径 的数目。
路径 不需要从根节点开始,也不需要在叶子节点结束,但是路径方向必须是向下的(只能从父节点到子节点)。
示例 1:
输入:root = [10,5,-3,3,2,null,11,3,-2,null,1], targetSum = 8
输出:3
示例 2:
输入:root = [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22
输出:3
提示:
- 二叉树的节点个数的范围是 [0,1000]
- -109 <= Node.val <= 109
- -1000 <= targetSum <= 1000
2 解题(Java)
前缀和+回溯法(前序遍历):
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
//key为从根节点到当前节点的前缀和,value为此前缀和的数目
Map<Integer, Integer> dic = new HashMap<>();
int count;
int sum;
public int pathSum(TreeNode root, int sum) {
this.sum = sum;
//初始化:空节点,前缀和为0,个数为1
dic.put(0, 1);
recur(root, 0);
return count;
}
private void recur(TreeNode node, int preSum) {
if(node == null) return;
preSum += node.val;
count += dic.getOrDefault(preSum - sum, 0);
dic.put(preSum, dic.getOrDefault(preSum, 0) + 1);
recur(node.left, preSum);
recur(node.right, preSum);
//回溯到父节点前复原前缀和的数目
dic.put(preSum, dic.get(preSum) - 1);
}
}
3 复杂性分析
- 时间复杂度:O(N),每个节点都遍历一次;
- 空间复杂度:O(N),prefixMap占用O(N)空间;
除法求值*
1 题目描述
给你一个变量对数组 equations 和一个实数值数组 values 作为已知条件,其中 equations[i] = [Ai, Bi] 和 values[i] 共同表示等式 Ai / Bi = values[i] 。每个 Ai 或 Bi 是一个表示单个变量的字符串。
另有一些以数组 queries 表示的问题,其中 queries[j] = [Cj, Dj] 表示第 j 个问题,请你根据已知条件找出 Cj / Dj = ? 的结果作为答案。
返回 所有问题的答案 。如果存在某个无法确定的答案,则用 -1.0 替代这个答案。如果问题中出现了给定的已知条件中没有出现的字符串,也需要用 -1.0 替代这个答案。
注意:输入总是有效的。你可以假设除法运算中不会出现除数为 0 的情况,且不存在任何矛盾的结果。
示例 1:
输入:equations = [[“a”,“b”],[“b”,“c”]], values = [2.0,3.0], queries =
[[“a”,“c”],[“b”,“a”],[“a”,“e”],[“a”,“a”],[“x”,“x”]]
输出:[6.00000,0.50000,-1.00000,1.00000,-1.00000]
解释:
条件:a / b = 2.0, b / c = 3.0
问题:a / c = ?, b / a = ?, a / e = ?, a / a = ?, x / x = ?
结果:[6.0, 0.5, -1.0, 1.0, -1.0 ]
示例 2:
输入:equations = [[“a”,“b”],[“b”,“c”],[“bc”,“cd”]], values = [1.5,2.5,5.0], queries = [[“a”,“c”],[“c”,“b”],[“bc”,“cd”],[“cd”,“bc”]]
输出:[3.75000,0.40000,5.00000,0.20000]
示例 3:
输入:equations = [[“a”,“b”]], values = [0.5], queries = [[“a”,“b”],[“b”,“a”],[“a”,“c”],[“x”,“y”]]
输出:[0.50000,2.00000,-1.00000,-1.00000]
提示:
- 1 <= equations.length <= 20
- equations[i].length == 2
- 1 <= Ai.length, Bi.length <= 5
- values.length == equations.length
- 0.0 < values[i] <= 20.0
- 1 <= queries.length <= 20
- queries[i].length == 2
- 1 <= Cj.length, Dj.length <= 5
- Ai, Bi, Cj, Dj 由小写英文字母与数字组成
2 解题(Java)
图论:广度优先搜索
class Solution {
public double[] calcEquation(List<List<String>> equations, double[] values, List<List<String>> queries) {
// 1通过哈希表将每个不同的字符串映射成整数
int var_count = 0;
Map<String, Integer> dic = new HashMap<>();
for (int i=0; i<equations.size(); i++) {
if (!dic.containsKey(equations.get(i).get(0))) {
dic.put(equations.get(i).get(0), var_count++);
}
if (!dic.containsKey(equations.get(i).get(1))) {
dic.put(equations.get(i).get(1), var_count++);
}
}
// 2构建图:对于每个点,存储其直接连接到的所有点及对应的权值(两点的比值)
List<Pair>[] edges = new List[var_count];
for (int i=0; i<var_count; i++) {
edges[i] = new ArrayList<>();
}
for (int i=0; i<equations.size(); i++) {
int x = dic.get(equations.get(i).get(0)), y = dic.get(equations.get(i).get(1));
edges[x].add(new Pair(y, values[i]));
edges[y].add(new Pair(x, 1.0/values[i]));
}
// 3对任何一个查询,从起点出发,通过广度优先搜索的方式,不断更新起点和当前点的路径长度,直到搜索到终点为止
double[] res = new double[queries.size()];
for (int i=0; i<queries.size(); i++) {
List<String> query = queries.get(i);
double result = -1.0;
if (dic.containsKey(query.get(0)) && dic.containsKey(query.get(1))) {
int x = dic.get(query.get(0)), y = dic.get(query.get(1));
if (x == y) result = 1.0;
else {
Deque<Integer> pointsQueue = new LinkedList<>();
pointsQueue.offer(x);
double[] ratios = new double[var_count];
Arrays.fill(ratios, -1.0);
ratios[x] = 1.0;
while (!pointsQueue.isEmpty() && ratios[y] < 0) {
int a = pointsQueue.poll();
for (Pair pair : edges[a]) {
int b = pair.index;
if (ratios[b] > 0) continue;
ratios[b] = ratios[a] * pair.value;
pointsQueue.offer(b);
}
}
result = ratios[y];
}
}
res[i] = result;
}
return res;
}
class Pair {
int index;
double value;
Pair(int index, double value) {
this.index = index;
this.value = value;
}
}
}
3 复杂性分析
- 时间复杂度:O(ML+Q⋅(L+M)),其中 M 为边的数量,Q 为询问的数量,L 为字符串的平均长度。构建图时,需要处理 M条边,每条边都涉及到平均长度为L的字符串比较;处理查询时,每次查询首先要进行一次 O(L) 的比较,然后至多遍历 O(M) 条边;
- 空间复杂度:O(NL+M),其中 N 为点的数量,M 为边的数量,L 为字符串的平均长度。为了将每个字符串映射到整数,需要开辟空间为O(NL) 的哈希表;随后,需要花费 O(M) 的空间存储每条边的权重;处理查询时,还需要 O(N)的空间维护访问队列。最终,总的复杂度为 O(NL+M+N)=O(NL+M);
删除无效的括号
1 题目描述
给你一个由若干括号和字母组成的字符串 s ,删除最小数量的无效括号,使得输入的字符串有效。
返回所有可能的结果。答案可以按 任意顺序 返回。
示例 1:
输入:s = “()())()”
输出:[“(())()”,“()()()”]
示例 2:
输入:s = “(a)())()”
输出:[“(a())()”,“(a)()()”]
示例 3:
输入:s = “)(”
输出:[“”]
提示:
- 1 <= s.length <= 25
- s 由小写英文字母以及括号 ‘(’ 和 ‘)’ 组成
- s 中至多含 20 个括号
2 解题(Java)
class Solution {
private int len;
private char[] charArray;
private Set<String> res = new HashSet<>();
StringBuilder ans = new StringBuilder();
public List<String> removeInvalidParentheses(String s) {
this.len = s.length();
this.charArray = s.toCharArray();
// 计算多余的左右括号,左右括号都可能有多余
int leftRemove = 0;
int rightRemove = 0;
for (int i = 0; i < len; i++) {
if (charArray[i] == '(') {
leftRemove++;
} else if (charArray[i] == ')') {
if (leftRemove == 0) {
rightRemove++;
}
if (leftRemove > 0) {
leftRemove--;
}
}
}
//回溯算法,尝试每一种可能的删除操作
dfs(0, 0, 0, leftRemove, rightRemove);
return new ArrayList<>(res);
}
/**
* @param index 当前遍历到的下标
* @param leftCount 已经遍历到的左括号的个数
* @param rightCount 已经遍历到的右括号的个数
* @param leftRemove 应该删除的左括号的个数
* @param rightRemove 应该删除的右括号的个数
*/
private void dfs(int index, int leftCount, int rightCount, int leftRemove, int rightRemove) {
if (index == len) {
if (leftRemove == 0 && rightRemove == 0) {
res.add(ans.toString());
}
return;
}
char cur = charArray[index];
// 可能的操作1:删除当前遍历到的字符
if (cur == '(' && leftRemove > 0) {
// 由于leftRemove > 0,并且当前遇到的是左括号,因此可以尝试删除当前遇到的左括号
dfs(index + 1, leftCount, rightCount, leftRemove - 1, rightRemove);
}
if (cur == ')' && rightRemove > 0) {
// 由于rightRemove > 0,并且当前遇到的是右括号,因此可以尝试删除当前遇到的右括号
dfs(index + 1, leftCount, rightCount, leftRemove, rightRemove - 1);
}
// 可能的操作 2:保留当前遍历到的字符
ans.append(cur);
if (cur != '(' && cur != ')') {
// 如果不是括号,继续深度优先遍历
dfs(index + 1, leftCount, rightCount, leftRemove, rightRemove);
} else if (cur == '(') {
// 考虑左括号
dfs(index + 1, leftCount + 1, rightCount, leftRemove, rightRemove);
} else if (cur == ')' && rightCount < leftCount) {
// 考虑右括号
dfs(index + 1, leftCount, rightCount + 1, leftRemove, rightRemove);
}
ans.deleteCharAt(ans.length() - 1);
}
}
课程表
1 题目描述
你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1 。
在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai 则 必须 先学习课程 bi 。
- 例如,先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1 。
请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false 。
示例 1:
输入:numCourses = 2, prerequisites = [[1,0]]
输出:true
解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0 。这是可能的。
示例 2:
输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:总共有 2 门课程。学习课程 1 之前,你需要先完成课程 0 ;并且学习课程 0 之前,你还应先完成课程 1 。这是不可能的。
提示:
- 1 <= numCourses <= 105
- 0 <= prerequisites.length <= 5000
- prerequisites[i].length == 2
- 0 <= ai, bi < numCourses
- prerequisites[i] 中的所有课程对 互不相同
2 解题(Java)
2.1 解题思路
拓扑排序
- 借助一个标志列表 flags,用于判断每个节点 i (课程)的状态:
- 未被 DFS 访问:i == 0;
- 当前节点完成DFS 访问:i == -1;
- 已被当前节点启动的 DFS 访问:i == 1。
- 对 numCourses 个节点依次执行 DFS,判断每个节点起步 DFS 是否存在环,若存在环直接返回 False。
- DFS 流程:
- 终止条件:
- 当 flag[i] == -1,说明当前访问节点已完成 DFS 访问,无需再重复搜索,直接返回 True;
- 当 flag[i] == 1,说明在本轮 DFS 搜索中节点 i 被第 2 次访问,即 课程安排图有环 ,直接返回 False;
- 将当前访问节点 i 对应 flag[i] 置 1,即标记其被本轮 DFS 访问过;
- 递归访问当前节点 i 的所有邻接节点 j,当发现环直接返回 False;
- 当前节点所有邻接节点已被遍历,并没有发现环,则将当前节点 flag 置为 −1 并返回 True。
- 终止条件:
- 若整个图 DFS 结束并未发现环,返回 True。
2.2 代码
class Solution {
List<List<Integer>> adjacency = new ArrayList<>();
int[] flags;
public boolean canFinish(int numCourses, int[][] prerequisites) {
for (int i=0; i<numCourses; i++) {
adjacency.add(new ArrayList<>());
}
flags = new int[numCourses];
for (int[] cp : prerequisites) {
adjacency.get(cp[1]).add(cp[0]);
}
for (int i=0; i<numCourses; i++) {
if (!dfs(i)) return false;
}
return true;
}
private boolean dfs(int i) {
if (flags[i] == 1) return false;
if (flags[i] == -1) return true;
flags[i] = 1;
for (Integer j : adjacency.get(i)) {
if (!dfs(j)) return false;
}
flags[i] = -1;
return true;
}
}
3 复杂性分析
- 时间复杂度 O(N+M): 遍历一个图需要访问所有节点和所有临边,N 和 M 分别为节点数量和临边数量;
- 空间复杂度 O(N+M): 为建立邻接表所需额外空间,adjacency 长度为 N ,并存储 M 条临边的数据;
岛屿数量
1 题目描述
给你一个由 ‘1’(陆地)和 ‘0’(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。
示例 1:
输入:grid = [
["1","1","1","1","0"],
["1","1","0","1","0"],
["1","1","0","0","0"],
["0","0","0","0","0"]
]
输出:1
示例 2:
输入:grid = [
["1","1","0","0","0"],
["1","1","0","0","0"],
["0","0","1","0","0"],
["0","0","0","1","1"]
]
输出:3
提示:
- m == grid.length
- n == grid[i].length
- 1 <= m, n <= 300
- grid[i][j] 的值为 ‘0’ 或 ‘1’
2 解题(Java)
class Solution {
boolean[][] marked;
char[][] grid;
int res = 0;
int m, n;
public int numIslands(char[][] grid) {
this.grid = grid;
m = grid.length;
n = grid[0].length;
marked = new boolean[m][n];
for (int i=0; i<m; i++) {
for (int j=0; j<n; j++) {
if (grid[i][j] == '1' && marked[i][j] == false) {
res++;
dfs(i, j);
}
}
}
return res;
}
void dfs(int i, int j) {
marked[i][j] = true;
if (i+1 < m && grid[i+1][j] == '1' && marked[i+1][j] == false) dfs(i+1, j);
if (i-1 >= 0 && grid[i-1][j] == '1' && marked[i-1][j] == false) dfs(i-1, j);
if (j+1 < n && grid[i][j+1] == '1' && marked[i][j+1] == false) dfs(i, j+1);
if (j-1 >= 0 && grid[i][j-1] == '1' && marked[i][j-1] == false) dfs(i, j-1);
}
}
3 复杂性分析
- 时间复杂度O(MN);
- 空间复杂度O(MN):在最坏情况下,整个网格均为陆地,深度优先搜索的深度达到MN;
机器人的运动范围
1 题目描述
地上有一个m行n列的方格,从坐标 [0,0] 到坐标 [m-1,n-1] 。一个机器人从坐标 [0, 0] 的格子开始移动,它每次可以向左、右、上、下移动一格(不能移动到方格外),也不能进入行坐标和列坐标的数位之和大于k的格子。例如,当k为18时,机器人能够进入方格 [35, 37] ,因为3+5+3+7=18。但它不能进入方格 [35, 38],因为3+5+3+8=19。请问该机器人能够到达多少个格子?
示例 1:
输入:m = 2, n = 3, k = 1
输出:3
示例 2:
输入:m = 3, n = 1, k = 0
输出:1
2 解题(Java)
广度优先搜索BFS:
class Solution {
public int movingCount(int m, int n, int k) {
if (m <= 0 || n <= 0 || k < 0) return 0;
Deque<int[]> queue = new LinkedList<>();
queue.offer(new int[]{0, 0});
boolean[][] vis = new boolean[m][n];
vis[0][0] = true;
int ans = 1;
while (!queue.isEmpty()) {
int[] cell = queue.poll();
//向下移动
int x = cell[0] + 1, y = cell[1];
if (x < m && !vis[x][y] && get(x) + get(y) <= k) {
queue.offer(new int[]{x, y});
vis[x][y] = true;
ans++;
}
//向右移动
x = cell[0]; y = cell[1] + 1;
if (y < n && !vis[x][y] && get(x) + get(y) <= k) {
queue.offer(new int[]{x, y});
vis[x][y] = true;
ans++;
}
}
return ans;
}
public int get(int n) {
int res = 0;
while (n != 0) {
res += n % 10;
n /= 10;
}
return res;
}
}
3 复杂性分析
- 时间复杂度O(MN):其中 M 为矩阵的行数,N 为矩阵的列数,最差情况下,机器人遍历矩阵所有单元格,此时时间复杂度为O(MN) ;
- 空间复杂度O(MN):需要一个大小为 O(MN) 的标记结构来标记每个格子是否已经走过;
子集
1 题目描述
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
提示:
- 1 <= nums.length <= 10
- -10 <= nums[i] <= 10
- nums 中的所有元素 互不相同
2 解题(Java)
2.1 解法1(回溯)
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> tmp = new ArrayList<>();
int[] nums;
public List<List<Integer>> subsets(int[] nums) {
this.nums = nums;
backTrack(0);
return res;
}
void backTrack(int x) {
res.add(new ArrayList(tmp));
for (int i = x; i < nums.length; i++) {
tmp.add(nums[i]);
backTrack(i+1);
tmp.remove(tmp.size() - 1);
}
}
}
复杂性分析
时间复杂度O(n * 2 ^ n):一共 2 ^ n 个状态,每个状态使用 O(n) 的时间复制到答案列表中;
空间复杂度O(n):临时列表 temp 的空间代价是 O(n),递归时栈空间的代价为 O(n);
2.2 解法2(位运算)
解题思路
- 记原序列中元素的总数为 n。原序列中的每个数字 a_i的状态可能有两种,即「在子集中」和「不在子集中」;
- 我们用 1 表示「在子集中」,0 表示「不在子集中」,那么每一个子集可以对应一个长度为 n 的 0/1 序列,第 i 位表示 a_i 是否在子集中。例如,n = 3,a = {5,2,9} 时:
- 可以发现 0/1 序列对应的二进制数正好从 0 到 2 ^ n - 1 。因此可以枚举mask∈[0, 2 ^ n - 1],mask 的二进制表示是一个 0/1 序列,我们可以按照这个 0/1 序列在原集合当中取数。
代码
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
List<Integer> temp = new ArrayList<>();
int n = nums.length;
for (int mask = 0; mask < (1 << n); mask++) {
temp.clear();
for (int i=0; i<n; i++) {
if ((mask & (1 << i)) != 0) {
temp.add(nums[i]);
}
}
res.add(new ArrayList(temp));
}
return res;
}
}
复杂性分析
时间复杂度O(n * 2 ^ n):一共 2 ^ n 个状态,每个状态需要 O(n) 的时间来构造子集;
空间复杂度O(n):临时列表 temp 的空间代价是 O(n);
全排列
1 题目描述
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例:
输入: [1,2,3]
输出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
2 解题(Java)
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> ans = new ArrayList<>();
int[] nums;
public List<List<Integer>> permute(int[] nums) {
this.nums = nums;
for (int num : nums) {
ans.add(num);
}
backTrack(0);
return res;
}
public void backTrack(int x) {
if (x == nums.length) {
res.add(new ArrayList<>(ans));
} else {
for (int i = x; i < nums.length; i++) {
Collections.swap(ans, x, i);
backTrack(x + 1);
Collections.swap(ans, x, i);
}
}
}
}
3 复杂性分析
- 时间复杂度O(N*N!):N为数组的长度,时间复杂度取决于方案数,方案数为O(N!),每一个答案使用 O(N) 的时间复制到答案列表中,因此时间复杂度为O(N*N!);
- 空间复杂度O(N):空间复杂度取决于栈的深度,因此为O(N);
组合总和
1 题目描述
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的数字可以无限制重复被选取。
说明:
- 所有数字(包括 target)都是正整数。
- 解集不能包含重复的组合。
示例 1:
输入:candidates = [2,3,6,7], target = 7,
所求解集为:
[
[7],
[2,2,3]
]
示例 2:
输入:candidates = [2,3,5], target = 8,
所求解集为:
[
[2,2,2,2],
[2,3,3],
[3,5]
]
提示:
- 1 <= candidates.length <= 30
- 1 <= candidates[i] <= 200
- candidate中的每个元素都是独一无二的
- 1 <= target <= 500
2 解题(Java)
解题思路
- 对于这类寻找所有可行解的问题,一般用搜索回溯的算法来解决;
- 定义递归函数dfs(target,index),表示当前在candidates数组的第index位,还剩target要组合,已经组合的列表为ans,递归的终止条件为target<=0或index==candidates.length;
- 在dfs函数中,我们可以选择使用第index个数,即执行dfs(target - candidates[index], index);也可以选择跳过不用第index个数,即执行dfs(target, index + 1);
- 将搜索过程用一棵树来表达,每次搜索都会延伸出两个分支,直到递归的终止条件,这样就能不遗漏且不重复地找到所有可行解;
代码
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> ans = new ArrayList<>();
int[] candidates;
public List<List<Integer>> combinationSum(int[] candidates, int target) {
this.candidates = candidates;
dfs(target, 0);
return res;
}
public void dfs(int target, int index) {
if (target < 0 || index >= candidates.length) {
return;
}
if (target == 0) {
res.add(new ArrayList<>(ans));
return;
}
// 选择当前下标对应的数
ans.add(candidates[index]);
dfs(target - candidates[index], index);
ans.remove(ans.size() - 1);
// 跳过当前下标对应的数
dfs(target, index + 1);
}
}
3 复杂性分析
- 时间复杂度O(N * 2 ^ N):O(N * 2 ^ N)是一个比较松的上界,但实际运行中,由于使用target <= 0进行了剪枝,因此实际运行情况往往远远小于这个上界;
- 空间复杂度O(target):空间复杂度取决于递归的深度,最差情况下需要递归target层;
括号生成
1 题目描述
数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
示例 1:
输入:n = 3
输出:[“((()))”,“(()())”,“(())()”,“()(())”,“()()()”]
示例 2:
输入:n = 1
输出:[“()”]
提示:
1 <= n <= 8
2 解题(Java)
- 通过回溯法遍历各种组合;
- 剪枝(减少递归次数同时避免了判断有效括号):如果左括号数量不大于n,才可以放一个左括号;如果右括号数量小于左括号数量,才可以放一个右括号;
class Solution {
List<String> res = new ArrayList<>();
StringBuilder ans = new StringBuilder();
int max;
public List<String> generateParenthesis(int n) {
max = n;
backtrack(0, 0);
return res;
}
public void backtrack(int open, int close) {
if (ans.length() == max * 2) {
res.add(ans.toString());
return;
}
if (open < max) {
ans.append('(');
backtrack(open + 1, close);
ans.deleteCharAt(ans.length() - 1);
}
if (close < open) {
ans.append(')');
backtrack(open, close + 1);
ans.deleteCharAt(ans.length() - 1);
}
}
}
3 复杂性分析
复杂度依赖于最终形成多少个答案,官方解释由(4 ^ n)/(n ^ (3/2))渐进界定。
时间复杂度O((4 ^ N)/(N ^ (1/2)):在回溯过程中,每个答案需要O(N)的时间形成;
空间复杂度O(N):除答案数组外,空间复杂度取决于递归栈的深度,递归2N层;
打印从1到最大的n位数
1 题目描述
输入数字 n,按顺序打印出从 1 到最大的 n 位十进制数。比如输入 3,则打印出 1、2、3 一直到最大的 3 位数 999。
示例 1:
输入: n = 1
输出: 1,2,3,4,5,6,7,8,9
说明:
n 为正整数
2 解题(Java)
2.1 解题思路
本题的主要考点是大数越界情况下的打印。需要解决以下三个问题:
- 表示大数的变量类型:无论是 short / int / long … 任意变量类型,数字的取值范围都是有限的。因此,大数的表示应用 String 类型;
- 生成数字的字符串集:使用 int 类型时,每轮可通过 +1 生成下个数字,而此方法无法应用至 String 类型。并且, String 类型的数字的进位操作效率较低,例如 “9999” 至 “10000” 需要从个位到千位循环判断,进位 4 次。观察可知,生成的列表实际上是 n 位 0 - 9 的 全排列 ,因此可避开进位操作,通过递归生成数字的 String 列表;
- 递归生成全排列:基于分治算法的思想,先固定高位,向低位递归,当个位已被固定时,添加数字的字符串。例如当 n=2 时(数字范围 1 - 99 ),固定十位为 0 - 9 ,按顺序依次开启递归,固定个位 0 - 9 ,终止递归并添加数字字符串;
根据以上方法,可初步编写全排列代码:
class Solution {
StringBuilder res;
int n;
char[] num, loop = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'};
public String printNumbers(int n) {
this.n = n;
res = new StringBuilder(); // 数字字符串集
num = new char[n]; // 定义长度为 n 的字符列表
dfs(0); // 开启全排列递归
res.deleteCharAt(res.length() - 1); // 删除最后多余的逗号
return res.toString(); // 转化为字符串并返回
}
void dfs(int digit) {
if(digit == n) { // 终止条件:已固定完所有位
res.append(String.valueOf(num) + ","); // 拼接 num 并添加至 res 尾部,使用逗号隔开
}
for(char i : loop) { // 遍历 ‘0‘ - ’9‘
num[digit] = i; // 固定第 x 位为 i
dfs(digit + 1); // 开启固定第 x + 1 位
}
}
}
在此方法下,各数字字符串被逗号隔开,共同组成长字符串。返回的数字集字符串如下所示:
输入:n = 1
输出:"0,1,2,3,4,5,6,7,8,9"
输入:n = 2
输出:"00,01,02,...,10,11,12,...,97,98,99"
输入:n = 3
输出:"000,001,002,...,100,101,102,...,997,998,999"
观察可知,当前的生成方法仍有以下问题:
- 诸如 00,01,02,⋯ 应显示为 0,1,2,⋯ ,即应删除高位多余的 0 ;
- 此方法从 0 开始生成,而题目要求 列表从 1 开始 ;
以上两个问题的解决方法如下:
1 删除高位多余的 0 :
- 字符串左边界定义: 声明变量 start 规定字符串的左边界,以保证添加的数字字符串 num[start:] 中无高位多余的 0 。例如当n=2时,1−9 时 start = 1,10−99 时 start=0 ;
- 左边界 start 变化规律: 观察可知,当输出数字的所有位都是 9 时,则下个数字需要向更高位进 1 ,此时左边界 start需要减 1 (即高位多余的 0 减少一个)。例如当 n=3 (数字范围 1−999 )时,左边界 start 需要减 1 的情况有: “009” 进位至 “010” , “099” 进位至 “100” 。设数字各位中 9 的数量为 count_9 ,所有位都为 9 的判断条件可用以下公式表示:n - start == count_9;
- 统计 nine 的方法:固定第 x 位时,当 i = 9 则执行 count_9 = count_9 + 1 ,并在回溯前恢复 count_9 = count_9 - 1;
2 列表从 1 开始:
在以上方法的基础上,添加数字字符串前判断其是否为 “0” ,若为 “0” 则直接跳过。
2.3 代码
class Solution {
StringBuilder res;
int n, start, count_9 = 0;
char[] num, loop = {'0','1','2','3','4','5','6','7','8','9'};
public String printNumbers(int n) {
this.n = n;
res = new StringBuilder();
num = new char[n];
start = n - 1;
dfs(0);
res.deleteCharAt(res.length() - 1);
return res.toString();
}
void dfs(int digit) {
if (digit == n) {
String s = String.valueOf(num).substring(start);
if(!s.equals("0")) res.append(s + ",");
if (n - start == count_9) start--;
return;
}
for (char i : loop) {
if (i == '9') count_9++;
num[digit] = i;
dfs(digit + 1);
}
count_9--;
}
}
3 复杂性分析
- 时间复杂度 O(10 ^ n): 递归生成的排列的数量为 10^n;
- 空间复杂度 O(10 ^ n): 结果列表 res 的长度为 10 ^ n - 1,各数字字符串的长度区间为 1, 2, …, n,因此占用 O(10^n)大小的额外空间;
矩阵中的路径
1 题目描述
请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。路径可以从矩阵中的任意一格开始,每一步可以在矩阵中向左、右、上、下移动一格。如果一条路径经过了矩阵的某一格,那么该路径不能再次进入该格子。例如,在下面的3×4的矩阵中包含一条字符串“bfce”的路径(路径中的字母用加粗标出)。
[[“a”,“b”,“c”,“e”],
[“s”,“f”,“c”,“s”],
[“a”,“d”,“e”,“e”]]
但矩阵中不包含字符串“abfb”的路径,因为字符串的第一个字符b占据了矩阵中的第一行第二个格子之后,路径不能再次进入这个格子。
示例 1:
输入:board = [[“A”,“B”,“C”,“E”],[“S”,“F”,“C”,“S”],[“A”,“D”,“E”,“E”]], word = “ABCCED”
输出:true
示例 2:
输入:board = [[“a”,“b”],[“c”,“d”]], word = “abcd”
输出:false
2 解题(Java)
2.1 解题思路
典型的矩阵搜索问题,可使用 深度优先搜索(DFS)+ 剪枝 来解决。
- 深度优先搜索: 可以理解为暴力法遍历矩阵中所有字符串可能性。DFS 通过递归,先朝一个方向搜到底,再回溯至上个节点,沿另一个方向搜索,以此类推;
- 剪枝: 在搜索中,遇到这条路不可能和目标字符串匹配成功的情况(例如:此矩阵元素和目标字符不同或此元素已被访问),则应立即返回,称之为可行性剪枝 ;
DFS 解析:
- 递归参数: 当前元素在矩阵 board 中的行列索引 i 和 j ,当前目标字符在 words 中的索引 k ;
- 终止条件:
- 返回 false : (1) 行或列索引越界 或 (2) 当前矩阵元素与目标字符不同 或 (3) 当前矩阵元素已访问过 ( (3) 可合并至 (2) );
- 返回 true : k = len(word) - 1 ,即 words 已全部匹配;
- 递推工作:
- 标记当前矩阵元素: 将 board[i][j] 修改为 空字符 ‘’ ,代表此元素已访问过,防止之后搜索时重复访问;
- 搜索下一单元格: 朝当前元素的 上、下、左、右 四个方向开启下层递归,使用 或 连接 (代表只需找到一条可行路径就直接返回,不再做后续 DFS ),并记录结果至 res;
- 还原当前矩阵元素: 将 board[i][j] 元素还原至初始值,即 word[k] (当board[i][j] == word[k]时才会回溯到上一节点);
- 返回值: 返回布尔量 res ,代表是否搜索到目标字符串。
2.2 Java代码
class Solution {
char[][] board;
char[] words;
public boolean exist(char[][] board, String word) {
this.board = board;
this.words = word.toCharArray();
for (int i = 0; i < board.length; i++) {
for (int j = 0; j < board[0].length; j++) {
if (dfs(i, j, 0)) return true;
}
}
return false;
}
public boolean dfs(int i, int j, int k) {
if (i >= board.length || i < 0 || j >= board[0].length || j < 0 || board[i][j] != words[k]) {
return false;
}
if (k == words.length - 1) return true;
board[i][j] = '\0';
boolean res = dfs(i + 1, j, k + 1) || dfs(i - 1, j, k + 1) || dfs(i, j + 1, k + 1) || dfs(i, j - 1, k + 1);
board[i][j] = words[k];
return res;
}
}
3 复杂性分析
M,N 分别为矩阵行列大小,K 为字符串 word 长度。
-
时间复杂度O(3 ^ K * MN): 最差情况下,需要遍历矩阵中长度为 K 字符串的所有方案,时间复杂度为 O(3^K);矩阵中共有 MN 个起点,时间复杂度为 O(MN) :
- 方案数计算: 设字符串长度为 K ,搜索中每个字符有上、下、左、右四个方向可以选择,舍弃回头(上个字符)的方向,剩下 3 种选择,因此方案数的复杂度为 O(3^K)。
-
空间复杂度 O(K) : 搜索过程中的递归深度不超过 K ,因此系统因函数调用累计使用的栈空间占用 O(K)(因为函数返回后,系统调用的栈空间会释放)。
字符串的排列
1 题目描述
输入一个字符串,打印出该字符串中字符的所有排列。
你可以以任意顺序返回这个字符串数组,但里面不能有重复元素。
示例:
输入:s = “abc”
输出:[“abc”,“acb”,“bac”,“bca”,“cab”,“cba”]
限制:
1 <= s 的长度 <= 8
2 解题(Java)
2.1 解题思路
- 排列方案数量: 对于一个长度为 n 的字符串(假设字符互不重复),其排列共有 n×(n−1)×(n−2)…×2×1 种方案;
- 排列方案的生成方法: 根据字符串排列的特点,考虑深度优先搜索所有排列方案。即通过字符交换,先固定第 1 位字符( n 种情况)、再固定第 2 位字符( n-1 种情况)、… 、最后固定第 n 位字符( 1 种情况);
3. 重复方案与剪枝: 当字符串存在重复字符时,排列方案中也存在重复方案。为排除重复方案,需在固定某位字符时,保证 “每种字符只在此位固定一次” ,即遇到重复字符时不交换,直接跳过。从 DFS 角度看,此操作称为 “剪枝” ;
2.2 递归解析
- 终止条件: 当 x = len© - 1 时,代表所有位已固定(最后一位只有 1 种情况),则将当前组合 c 转化为字符串并加入 res,并返回;
- 递推参数: 当前固定位 x ;
- 递推工作: 初始化一个 Set ,用于排除重复的字符;将第 x 位字符与i∈[x,len©] 字符分别交换,并进入下层递归:
- 剪枝: 若 c[i] 在 Set 中,代表其是重复字符,因此“剪枝”;
- 将 c[i] 加入 Set ,以便之后遇到重复字符时剪枝;
- 固定字符: 将字符 c[i] 和 c[x] 交换,即固定 c[i] 为当前位字符;
- 开启下层递归: 调用 dfs(x + 1) ,即开始固定第 x + 1 个字符;
- 还原交换: 将字符 c[i] 和 c[x] 交换(还原之前的交换,如果不还原会丢失方案);
2.3 代码
class Solution {
List<String> res = new LinkedList<>();
char[] c;
public String[] permutation(String s) {
c = s.toCharArray();
dfs(0);
return res.toArray(new String[]{});
}
void dfs(int x) {
if(x == c.length - 1) {
res.add(String.valueOf(c)); // 添加排列方案
return;
}
Set<Character> set = new HashSet<>();
for(int i = x; i < c.length; i++) {
if(set.contains(c[i])) continue; // 重复,因此剪枝
set.add(c[i]);
swap(i, x); // 交换,将 c[i] 固定在第 x 位
dfs(x + 1); // 开启固定第 x + 1 位字符
swap(i, x); // 恢复交换,回溯到上一步
}
}
void swap(int a, int b) {
char tmp = c[a];
c[a] = c[b];
c[b] = tmp;
}
}
3 复杂性分析
- 时间复杂度 O(N!): N 为字符串 s 的长度;时间复杂度和字符串排列的方案数成线性关系,方案数为N×(N−1)×(N−2)…×2×1 ,因此复杂度为 O(N!)。
- 空间复杂度 O(N ^ 2) : 全排列的递归深度为 N ,系统累计使用栈空间大小为 O(N) ;递归中辅助Set累计存储的字符数量最多为 N + (N-1) + … + 2 + 1 = (N+1)N/2,即占用 O(N ^ 2 ) 的额外空间。
数字字符串转化成IP地址
1 题目描述
现在有一个只包含数字的字符串,将该字符串转化成IP地址的形式,返回所有可能的情况。
例如:
给出的字符串为"25525522135",返回[“255.255.22.135”, “255.255.221.35”]. (顺序没有关系)
示例1
输入
“25525522135”
返回值
[“255.255.22.135”,“255.255.221.35”]
2 解题(Java)
import java.util.*;
public class Solution {
ArrayList<String> res = new ArrayList<>();
public ArrayList<String> restoreIpAddresses(String s) {
backTrack(s, 0, 3);
return res;
}
// start:本次插入的起始位置
// cnt:剩余可插入'.'的次数
public void backTrack(String s, int start, int cnt) {
if (cnt == 0) {
String[] strs = s.split("\\.");
for (String str : strs) {
if (str.length() > 1 && str.charAt(0) == '0') return; // 排除有前导0的情况
if (Integer.parseInt(str) > 255) return; // 排除>255的情况
}
res.add(s);
return;
}
int n = s.length();
if (start + 1 < n) backTrack(s.substring(0, start+1) + "." + s.substring(start+1), start+2, cnt - 1); //插入到start+1位置,下一次从start+2位置开始
if (start + 2 < n) backTrack(s.substring(0, start+2) + "." + s.substring(start+2), start+3, cnt - 1); //插入到start+2位置,下一次从start+3位置开始
if (start + 3 < n) backTrack(s.substring(0, start+3) + "." + s.substring(start+3), start+4, cnt - 1); //插入到start+3位置,下一次从start+4位置开始
}
}
3 复杂性分析
- 时间复杂度O(N(N-1)*(N-2))*:N为字符串的长度;时间复杂度取决于方案数,经过剪枝,方案数小于*(N-1)*(N-2);
- 空间复杂度O(1):空间复杂度取决于递归深度;
电话号码的字母组合
1 题目描述
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
示例:
输入:"23"
输出:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"].
说明:
尽管上面的答案是按字典序排列的,但是你可以任意选择答案输出的顺序。
2 解题(Java)
2.1 解题思路
回溯算法:
- 用一个哈希表存储每个数字对应的所有字母;
- 回溯过程维护一个StringBuilder ans,表示字母组合;
- ans初始为空,每次取电话号码的一位数字,然后从哈希表中获得该数字对应的所有字母,并将其中一个加入ans中;
- 继续处理电话号码后一位数字,直到处理完电话号码的所有数字,即得到一个完整的字母组合,并将其加入结果res中;
- 回退,遍历其余的字母组合,依次加入res中,返回res即可;
注:
回溯算法主要用于寻找所有的可行解,如果发现一个解不可行,再回溯寻找其它解。而在本题中,由于每个数字对应的每个字母都可能进入字母组合,因此不存在不可行的解,相当于穷举所有解。
2.2 代码
class Solution {
List<String> res = new ArrayList<>();
StringBuilder ans = new StringBuilder();
Map<Character, String> dic = new HashMap<Character, String>() {{
put('2', "abc");
put('3', "def");
put('4', "ghi");
put('5', "jkl");
put('6', "mno");
put('7', "pqrs");
put('8', "tuv");
put('9', "wxyz");
}};
String digits;
public List<String> letterCombinations(String digits) {
this.digits = digits;
if (digits.length() == 0) return res;
backTrack(0);
return res;
}
public void backTrack(int index) {
if (index == digits.length()) {
res.add(ans.toString());
} else {
String letters = dic.get(digits.charAt(index));
for (int i=0; i<letters.length(); i++) {
ans.append(letters.charAt(i));
backTrack(index+1);
ans.deleteCharAt(index);
}
}
}
}
3 复杂性分析
- 时间复杂度O(3 ^ M * 4 ^ N):其中 M 是输入中对应3个字母的数字个数(包括数字 2、3、4、5、6、8),N 是输入中对应4个字母的数字个数(包括数字 7、9),当输入包含M个对应3个字母的数字和N个对应4个字母的数字时,不同的字母组合共有3 ^ M * 4 ^ N种,需要遍历每一种字母组合;
- 空间复杂度O(M + N):其中M是输入中对应3个字母的数字个数,N是输入中对应4个字母的数字个数,M+N是输入数字的总个数。除了返回值以外,空间复杂度主要取决于哈希表以及回溯过程中的递归调用层数,哈希表的大小与输入无关,可以看成常数,递归调用层数最大为M+N;