《剑指 Offer I》刷题笔记 31 ~ 40 题
小标题以 _
开头的题目和解法代表独立想到思路及编码完成,其他是对题解的学习。
VsCode 搭建的 Java 环境中 sourcePath 配置比较麻烦,可以用
java main.java
运行(JDK 11 以后)
Go 的数据结构:LeetCode 支持 https://godoc.org/github.com/emirpasic/gods 第三方库。
go get github.com/emirpasic/gods
双指针(简单)
31. 调整数组顺序使奇数位于偶数前面
题目:剑指 Offer 21. 调整数组顺序使奇数位于偶数前面
_解法1:头尾指针 + 辅助数组
思路:首尾双指针遍历一次,开辟新数组,奇数从前放,偶数从后放
class Solution {
public int[] exchange(int[] nums) {
int size = nums.length;
int[] res = new int[size];
// p1 - 偶数位置,p2 - 奇数位置
int p1 = 0, p2 = size - 1;
for (int i = 0; i < size; i++) {
if ((nums[i] & 1) != 0) // 奇数
res[p1++] = nums[i];
else // 偶数
res[p2--] = nums[i];
}
return res;
}
}
解法2:头尾指针 + 交换
思路:首尾双指针遍历一次,分别找到奇偶数后交换位置
class Solution {
public int[] exchange(int[] nums) {
int p1 = 0, p2 = nums.length - 1;
while (p1 < p2) {
// 从前往后找奇数
while (p1 < p2 && (nums[p1] & 1) == 1) p1++;
// 从后往前找偶数
while (p1 < p2 && (nums[p2] & 1) == 0) p2--;
// 前后都找到就交换
int tmp = nums[p1];
nums[p1] = nums[p2];
nums[p2] = tmp;
}
return nums;
}
}
解法3:快慢指针
思路:fast 移动,slow 不动,fast 遇到奇数则和 slow 交换(slow++,fast++)
class Solution {
public int[] exchange(int[] nums) {
int low = 0, fast = 0;
while (fast < nums.length) {
// fast遇到奇数则和slow交换
if ((nums[fast] & 1) == 1) {
int tmp = nums[fast];
nums[fast] = nums[low];
nums[low] = tmp;
low++;
}
fast++;
}
return nums;
}
}
32. 和为 s 的两个数字
_解法1:哈希
思路:遍历时将数据存到 set 中,判断 tartget-nums[i] 是否在 set 中存在
class Solution {
public int[] twoSum(int[] nums, int target) {
Set<Integer> set = new HashSet<>();
for (int i = 0; i < nums.length; i++) {
if (set.contains(target - nums[i]))
return new int[] {
nums[i], target - nums[i] };
set.add(nums[i]);
}
return null;
}
}
思路:优化循环开始的位置
class Solution {
public int[] twoSum(int[] nums, int target) {
Set<Integer> set = new HashSet<>();
// 优化循环开始位置
int mid = nums.length / 2, start = 0;
if (nums[mid] > target)
start = mid;
for (int i = start; i < nums.length; i++) {
if (set.contains(target - nums[i]))
return new int[] {
nums[i], target - nums[i] };
set.add(nums[i]);
}
return null;
}
}
// go 哈希
func twoSum(nums []int, target int) []int {
dic := make(map[int]bool)
for _, num := range nums {
if _, ok := dic[target-num]; ok {
return []int{
num, target - num}
}
dic[num] = true
}
return []int{
}
}
解法2:双指针
思路:对撞双指针,一个从头开始一个从尾开始,根据和 target 比较大小判断 两个指针的走势
class Solution {
public int[] twoSum2(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l < r) {
int num = nums[l] + nums[r];
if (num < target)
l++;
else if (num > target)
r--;
else
return new int[] {
nums[l], nums[r] };
}
return null;
}
}
33. 翻转单词顺序
_解法1:调库
思路:使用 \\s+
实现以空格分隔字符串(且数组中不包含空格),然后倒着拼接即可
class Solution {
public String reverseWords1(String s) {
// 以空格分隔字符串
String[] tmps = s.split("\\s+");
// 倒着拼接字符串
StringBuffer sb = new StringBuffer();
for (int i = s.length - 1; i > 0; i--) {
sb.append(tmps[i]);
sb.append(" ");
}
return sb.toString().trim();
}
}
解法2:双指针
面试题58 - I. 翻转单词顺序(双指针 / 库函数,清晰图解)
class Solution {
public String reverseWords(String s) {
s = s.trim(); // 删除首尾空格
int l = s.length() - 1, r = l;
StringBuilder sb = new StringBuilder();
while (l >= 0) {
while (l >= 0 && s.charAt(l) != ' ') l--; // 搜索首个空格
sb.append(s.subSequence(l+1, r+1) + " "); // 添加单词
while (l >= 0 && s.charAt(l) == ' ') l--; // 跳过单词间空格
r = l; // r 指向下个单词的尾字符
}
return sb.toString().trim();
}
}
搜索与回溯算法(中等)
34. 矩阵中的路径*
题解1:DFS + 剪枝
题解:面试题12. 矩阵中的路径( DFS + 剪枝 ,清晰图解)
难点:递归搜索匹配字符串过程中,需要
board[i][j] = '#'
来防止 ”走回头路“ 。当匹配字符串不成功时,会回溯返回,此时需要board[i][j] = word[k]
来 ”取消对此单元格的标记”。 在 DFS 过程中,每个单元格会多次被访问的,board[i][j] = '#'
只是要保证在当前匹配方案中不要走回头路,不复原。
利用 boolean[][] visited
记录当前 DFS 中访问过的位置。
class Solution {
public boolean exist(char[][] board, String word) {
// 记录访问过的位置
boolean[][] visited = new boolean[board.length][board[0].length];
char[] words = word.toCharArray();
for (int i = 0; i < board.length; i++)
for (int j = 0; j < board[0].length; j++)
if (dfs(board, words, visited, i, j, 0))
return true;
return false;
}
/**
* 深度优先搜索
*
* @param words 搜索目标
* @param board 搜索范围
* @param visited 已经搜索过的路径
* @param i 当前元素的行索引
* @param j 当前元素的列索引
* @param k 搜索目标在wrod中的索引
* @return
*/
boolean dfs(char[][] board, char[] words, boolean[][] visited, int i, int j, int k) {
// 边界情况判断:行越界、列越界、矩阵元素已经访问过
if (i >= board.length || i < 0 // 行越界
|| j >= board[0].length || j < 0 // 列越界
|| board[i][j] != words[k] // 当前元素不是想要搜索的元素
|| visited[i][j]) // 矩阵元素已经访问过
return false;
// 全部搜索完毕,返回true
if (k == words.length - 1)
return true;
// 剪枝, 防止下次递归往回查找
visited[i][j] = true;
boolean res = dfs(board, words, visited, i + 1, j, k + 1)
|| dfs(board, words, visited, i - 1, j, k + 1)
|| dfs(board, words, visited, i, j + 1, k + 1)
|| dfs(board, words, visited, i, j - 1, k + 1);
// 回溯,恢复原来的字符
visited[i][j] = false;
return res;
}
}
优化:不使用额外空间来记录访问过的位置
class Solution {
public boolean exist(char[][] board, String word) {
char[] words = word.toCharArray();
for (int i = 0; i < board.length; i++)
for (int j = 0; j < board[0].length; j++)
if (dfs(board, words, i, j, 0))
return true;
return false;
}
/**
* 深度优先搜索
*
* @param words 搜索目标
* @param board 搜索范围
* @param i 当前元素的行索引
* @param j 当前元素的列索引
* @param k 搜索目标在wrod中的索引
* @return
*/
boolean dfs(char[][] board, char[] words, 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;
// 全部搜索完毕,返回true
if (k == words.length - 1)
return true;
// 剪枝,将当前节点赋值 '#', 防止下次递归往回查找
board[i][j] = '#';
boolean res = dfs(board, words, i + 1, j, k + 1)
|| dfs(board, words, i - 1, j, k + 1)
|| dfs(board, words, i, j + 1, k + 1)
|| dfs(board, words, i, j - 1, k + 1);
// 回溯,恢复原来的字符
board[i][j] = words[k];
return res;
}
}
35. 机器人的运动范围
解法1:DFS
题解:剑指 Offer 13. 机器人的运动范围( 回溯算法,DFS / BFS ,清晰图解)
这个是我的思路,不过当时我踩了个坑,没有把 computeSum(i, j) > k
放到边界情况中判断…
class Solution {
int res = 0;
public int movingCount(int m, int n, int k) {
boolean[][] visited = new boolean[m][n];
dfs(m, n, visited, 0, 0, k);
return res;
}
void dfs(int m, int n, boolean[][] visited, int i, int j, int k) {
// 边界情况
if (i < 0 || i >= m || j < 0 || j >= n || visited[i][j] || computeSum(i, j) > k)
return;
res++;
visited[i][j] = true;
dfs(m, n, visited, i + 1, j, k);
dfs(m, n, visited, i - 1, j, k);
dfs(m, n, visited, i, j + 1, k);
dfs(m, n, visited, i, j - 1, k);
}
/**
* 计算 m 与 n 的数位和
*/
int computeSum(int m, int n) {
int sum = 0;
while (m > 0 || n > 0) {
sum += (m % 10 + n % 10);
m /= 10;
n /= 10;
}
return sum;
}
}
题解中 K 神的 DFS:将返回结果写到递归中
class Solution {
public int movingCount(int m, int n, int k) {
boolean[][] visited = new boolean[m][n];
return dfs(m, n, visited, 0, 0, k);
}
int dfs(int m, int n, boolean[][] visited, int i, int j, int k) {
if (i < 0 || i >= m || j < 0 || j >= n || visited[i][j] || computeSum(i, j) > k)
return 0;
visited[i][j] = true;
int right = dfs(m, n, visited, i + 1, j, k);
int down = dfs(m, n, visited, i, j + 1, k);
return down + right + 1;
}
/**
* 计算 m 与 n 的数位和
*/
int computeSum(int m, int n) {
int sum = 0;
while (m > 0 || n > 0) {
sum += (m % 10 + n % 10);
m /= 10;
n /= 10;
}
return sum;
}
}
解法2:BFS
BFS 做的操作其实和 DFS 差不多,区别是遍历方式不一样。
class Solution {
public int movingCount(int m, int n, int k) {
boolean[][] visited = new boolean[m][n];
int res = 0;
Queue<int[]> queue = new LinkedList<>();
// 从左上角坐标[0,0]点开始访问,add方法表示把坐标点加入到队列的队尾
queue.add(new int[] {
0, 0 });
while (queue.size() > 0) {
int[] x = queue.poll();
int i = x[0], j = x[1];
// 判断边界条件
if (i >= m || j >= n || computeSum(i, j) > k || visited[i][j])
continue;
visited[i][j] = true;
res++;
queue.add(new int[] {
i + 1, j });
queue.add(new int[] {
i, j + 1 });
}
return res;
}
/**
* 计算 m 与 n 的数位和
*/
int computeSum(int m, int n) {
int sum = 0;
while (m > 0 || n > 0) {
sum += (m % 10 + n % 10);
m /= 10;
n /= 10;
}
return sum;
}
}
36. 二叉树中和为某一值的路径
本题二叉树的定义:
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;
}
}
_解法1:DFS + 回溯
我的思路:每次递归时参数多传一个 sum,用于计算到当前节点为止的值
class Solution {
List<List<Integer>> res = new ArrayList<>();
public List<List<Integer>> pathSum(TreeNode root, int target) {
dfs(root, 0, new ArrayList<>(), target);
return res;
}
void dfs(TreeNode node, int sum, List<Integer> visited, int target) {
if (node == null) return;
sum += node.val;
visited.add(node.val);
// 注意题目要求必须是叶子节点
if (target == sum && node.left == null && node.right == null) {
// 不 new 则存到 res 中的 visited 也会跟着改
res.add(new ArrayList<>(visited));
}
// 向左搜索
dfs(node.left, sum, visited, target);
// 向右搜索
dfs(node.right, sum, visited, target);
// 回溯
visited.remove(visited.size() - 1);
sum -= node.val;
}
}
题解:面试题34. 二叉树中和为某一值的路径(回溯法,清晰图解)
思路:不需要维护一个 sum 在参数中传递,直接利用题目给的 target,每次减 node.val,变为 0 则满足条件。
class Solution {
LinkedList<List<Integer>> res = new LinkedList<>();
LinkedList<Integer> visited = new LinkedList<>();
public List<List<Integer>> pathSum(TreeNode root, int target) {
dfs(root, target);
return res;
}
void dfs(TreeNode node, int target) {
if (node == null) return;
visited.add(node.val);
target -= node.val;
if (target == 0 && node.left == null && node.right == null) {
res.add(new LinkedList<>(visited));
}
dfs(node.left, target);
dfs(node.right, target);
// 回溯
visited.removeLast();
}
}
37. 二叉搜索树与双向链表
_解法1:一次 DFS + 遍历 list
思路:中序遍历后将节点加到 list,再遍历 list 处理成双向链表。
class Solution {
List<Node> list = new LinkedList<>();
public Node treeToDoublyList(Node root) {
helper(root);
Node hNode = list.get(0);
// pre 一开始指向最后一个节点
Node pre = list.get(list.size() - 1);
for (int i = 0; i < list.size(); i++) {
Node node = list.get(i);
// 当前节点和前一节点,构造成双向循环链表
node.left = pre;
pre.right = node;
pre = node;
}
return hNode;
}
void helper(Node node) {
if (node == null) return;
helper(node.left);
list.add(node);
helper(node.right);
}
}
解法2:一次 DFS
题解:剑指 Offer 36. 二叉搜索树与双向链表(中序遍历,清晰图解)
思路:在中序遍历的同时,完成将二叉搜索树处理成双向链表
class Solution {
Node pre, head;
public Node treeToDoublyList(Node root) {
if (root == null)
return null;
helper(root);
// 头尾节点相互指向
head.left = pre;
pre.right = head;
return head;
}
/**
* 中序遍历将二叉搜索树 中间部分 处理成双向链表
*/
void helper(Node cur) {
if (cur == null)
return;
helper(cur.left);
// 处理头部
if (pre == null) {
head = cur;
pre = head;
} else {
pre.right = cur;
cur.left = pre;
pre = cur;
}
helper(cur.right);
}
}
38. 二叉搜索树的第 k 大节点
_解法1:中序遍历
性质:二叉搜索树的中序遍历为 递增序列
思路:完整的中序遍历一次,遍历到的元素是递增的,然后直接计算位置取元素即可。
class Solution {
public int kthLargest(TreeNode root, int k) {
List<Integer> list = new ArrayList<>();
helper(root, list);
return list.get(list.size() - k);
}
void helper(TreeNode node, List<Integer> list) {
if (node == null) return;
helper(node.left, list);
list.add(node.val);
helper(node.right, list);
}
}
解法2:倒中序遍历
思路:对二叉搜索树来说,中序遍历为递增序列,那么倒中序遍历就是递减序列,直接遍历到 k 次即可。
class Solution {
int res, k;
public int kthLargest(TreeNode root, int k) {
this.k = k;
helper(root);
return res;
}
void helper(TreeNode node) {
if (node == null) return;
helper(node.right);
// 遍历到满足条件直接结束
if (--k == 0) {
res = node.val;
return;
}
helper(node.left);
}
}
排序(简单)
39. 把数组排成最小的数++
第一次做到排序相关的题目,甚至不确定该不该用语言内置排序 API
给新手的建议:一开始刷到这种题,直接排序 API 搞起来,后面再尝试手撕各种排序算法
解法1:排序 API + 自定义规则
题解:剑指 Offer 45. 把数组排成最小的数(自定义排序,清晰图解)
思路:Java 中实现字典序是利用 A.compareTo(B)
,这题主要是想到 a + b > b + a
class Solution {
/**
* 系统API + 自定义排序
*/
public String minNumber(int[] nums) {
String[] strs = new String[nums.length];
for (int i = 0; i < nums.length; i++)
strs[i] = String.valueOf(nums[i]);
Arrays.sort(strs, (x, y) -> (x + y).compareTo(y + x));
StringBuilder res = new StringBuilder();
for (String s : strs)
res.append(s);
return res.toString();
}
/**
* Lambda 一行写法
*/
public String minNumber2(int[] nums) {
return Arrays.stream(nums)
.mapToObj(String::valueOf)
.sorted((a, b) -> (a + b).compareTo(b + a))
.reduce("", (a, b) -> a + b);
}
}
Go 语言中的排序利用 sort.Slice()
实现:
func minNumber(nums []int) string {
// 将整数数组按字符串形式排序
sort.Slice(nums, func(i, j int) bool {
x := fmt.Sprintf("%d%d", nums[i], nums[j])
y := fmt.Sprintf("%d%d", nums[j], nums[i])
return x < y
})
res := ""
for _, v := range nums {
res += fmt.Sprintf("%d", v)
}
return res
}
解法2:冒泡排序
题解:利用冒泡排序理解这道题
这种解法相对比较好理解:
class Solution {
public String minNumber(int[] nums) {
String[] strs = new String[nums.length];
for (int i = 0; i < nums.length; i++)
strs[i] = String.valueOf(nums[i]);
for (int i = 0; i < strs.length; i++) {
for (int j = 0; j < strs.length - 1; j++) {
// 比如 34,3 -----> 343 > 334 所以两个数需要交换位置
if ((strs[j] + strs[j + 1]).compareTo(strs[j + 1] + strs[j]) > 0) {
String tmp = strs[j + 1];
strs[j + 1] = strs[j];
strs[j] = tmp;
}
}
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < strs.length; i++)
sb.append(strs[i]);
return sb.toString();
}
}
解法3:快速排序
这个暂且放一放,记录几个模板,后面再看:
40. 扑克牌中的顺子
解法1:Set + 遍历
思路:
- 找出 max, min 并判断 max - min >= 5 为 false 否则为 true
- 遍历时通过 Set 判断是否有 0 以外的重复元素,有则 false
class Solution {
public boolean isStraight(int[] nums) {
Set<Integer> set = new HashSet<>();
int min = 14, max = 0;
for (int num : nums) {
// 跳过大小王
if (num == 0) continue;
// 若有重复,直接返回 false
if (set.contains(num)) return false;
set.add(num);
min = Math.min(num, min);
max = Math.max(num, min);
}
return max - min < 5;
}
}
解法2:排序 + 遍历
题解:面试题61. 扑克牌中的顺子(集合 Set / 排序,清晰图解)
class Solution {
public boolean isStraight(int[] nums) {
int joker = 0;
Arrays.sort(nums);
for (int i = 0; i < 4; i++) {
// 统计大小数量
if (nums[i] == 0)
joker++;
// 若有重复,提前返回 false
else if (nums[i] == nums[i++])
return false;
}
// 最大牌 - 最小牌 < 5 则满足条件
return nums[4] - nnums[joker] < 5;
}
}