剑指offer03:数组中重复的数字
题目链接:LCR 120. 寻找文件副本
题目描述:设备中存有 n 个文件,文件 id 记于数组 documents。若文件 id 相同,则定义为该文件存在副本。请返回任一存在副本的文件 id。
class Solution {
public int findRepeatDocument(int[] documents) {
if (documents == null || documents.length == 0) {
return -1;
}
for (int i = 0; i < documents.length; i++) {
// 遍历:如果下标i不等于元素的值就进行交换
// 目标就是把数字排好序
while (documents[i] != i) {
// 判断是否出现了重复元素
if (documents[i] == documents[documents[i]]) {
// 返回重复数字
return documents[i];
}
int temp = documents[documents[i]];
documents[documents[i]] = documents[i];
documents[i] = temp;
}
}
return -1;
}
}
剑指offer04:二维数组中的查找
题目描述:m*n 的二维数组 plants 记录了园林景观的植物排布情况,具有以下特性:
- 每行中,每棵植物的右侧相邻植物不矮于该植物;
- 每列中,每棵植物的下侧相邻植物不矮于该植物。
- 请判断 plants 中是否存在目标高度值 target。
class Solution {
public boolean findTargetIn2DPlants(int[][] plants, int target) {
// 左到右是顺序的首先想到二分查找
// 但有一个条件没有用到说明不是最优解,上到下也是升序的
// 每个元素都比它上面的元素大 比它右边的元素小
// 所以考虑从左下角(右上角也可以)去元素和target比较 大于或者小于都可以剔除行或者列达到缩小范围目的
if (plants == null || plants.length <= 0 || plants[0].length <= 0) {
return false;
}
// 定义行和列
int rows = plants.length;
int cols = plants[0].length;
// 定义左下角的坐标
int row = rows - 1;
int col = 0;
// 主方向为右上 行减少 列增大
// 如果行坐标大于等于0 并且 列坐标小于cols 说明还在有效范围内 继续循环
while (row >= 0 && col < cols) {
if (target > plants[row][col]) {
// 如果目标值大于当前值 说明目标值在当前值的右侧 舍弃当前列 坐标右移
col++;
} else if (target < plants[row][col]) {
// 如果目标值小于当前值 说明目标值在当前值的上方 舍弃当前行 坐标上移
row--;
} else {
// 如果目标值等于当前值 说明找到了目标值 返回true
return true;
}
}
return false;
}
}
剑指offer05:替换空格
题目链接:LCR 122. 路径加密
题目描述:假定一段路径记作字符串 path,其中以 “.” 作为分隔符。现需将路径加密,加密方法为将 path 中的分隔符替换为空格 " ",请返回加密后的字符串。
class Solution {
public String pathEncryption(String path) {
// 简单做法:遍历字符串 然后不是"."直接用stringBuilder拼 遇到"."就拼个空格
// 时间复杂度O(n) 空间复杂度O(n)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < path.length(); i++) {
char c = path.charAt(i);
if (c == '.') {
sb.append(" ");
} else {
sb.append(c);
}
}
return sb.toString();
// 基于原地扩容的思想:遍历数组 看总共有几个空格 然后把字符串原地扩容
// 利用两个指针:P2指向扩容后的尾端 P1指向原数组的尾端
// P1每遍历一次如果不是空格则把字符放到P2位置上 就这样不断移动复制
// 当遇到空格时 P2移动插入替换的元素 P1正常移动
// 当P1和P2移动到同一个位置了就表示替换完毕了
// 时间复杂度O(n) 空间复杂度O(1)
int count = 0;
for(int i = 0; i < path.length(); i++) {
if (path.charAt(i) == ' ') {
count ++;
}
}
char[] res = new char[path.length() + count * 2];
int k = res.length - 1;
for (int i = path.length() - 1; i >= 0; i--) {
if (path.charAt(i) == ' ') {
res[k--] = '0';
res[k--] = '2';
res[k--] = '%';
} else {
res[k--] = path.charAt(i);
}
}
return new String(res);
}
}
剑指offer06:从尾到头打印链表
题目链接:LCR 123. 图书整理 I
题目描述:书店店员有一张链表形式的书单,每个节点代表一本书,节点中的值表示书的编号。为更方便整理书架,店员需要将书单倒过来排列,就可以从最后一本书开始整理,逐一将书放回到书架上。请倒序返回这个书单链表。
class Solution {
public int[] reverseBookList(ListNode head) {
// 链表需要从头节点遍历 但要求输出结果是从尾部倒序输出
// 典型后进先出的思想 可以用栈来实现
// 时间复杂度 O(n) 空间复杂度 O(n)
/*
* Deque<Integer> stack = new ArrayDeque<>();
* ListNode cur = head;
*
* while (cur != null) {
* Integer value = cur.val;
* stack.push(value);
* cur = cur.next;
* }
* int[] res = new int[stack.size()];
*
* for (int i = 0; i < res.length; i++) {
* Integer value = stack.pop();
* res[i] = value;
* }
* return res;
*/
// 其实递归的思想的本质就是一个栈结构 满足条件不断递归遍历链表
// 当不满足条件时跳出递归依次输出 实现遍历的倒序
// 回顾递归三步曲:1.确定传参和方法的返回值 2.确定递归终止条件 3.确定单层递归逻辑
// 时间复杂度 O(n) 空间复杂度 O(n) 每递归函数都占用栈空间
// 第三种方法就是利用数组 遍历链表时候从后往前存数组 然后输出数组
// 时间复杂度 O(n) 空间复杂度 O(1)
if (head == null) {
return new int[0];
}
ListNode cur = head;
int len = 0;
while (cur != null) {
len++;
cur = cur.next;
}
int[] res = new int[len];
for (int i = len - 1; i >= 0; i--) {
res[i] = head.val;
head = head.next;
}
return res;
}
}
剑指offer07:重建二叉树
题目链接:LCR 124. 推理二叉树
题目描述:某二叉树的先序遍历结果记录于整数数组 preorder,它的中序遍历结果记录于整数数组 inorder。请根据 preorder 和 inorder 的提示构造出这棵二叉树并返回其根节点。
思路:
class Solution {
// map<元素,下标位置> 利用map记录中序遍历中元素和下标对应位置
Map<Integer, Integer> map = new HashMap<>();
public TreeNode deduceTree(int[] preorder, int[] inorder) {
// 已知二叉树前序和中序 前序的第一节点就是根节点 然后在中序中找到根节点坐标 根节点左边的为左子树 右边为右子树
// 那现在分别找到左右子树的前序遍历和中序遍历 就是一个上面的子问题 所以通过递归方法完成
if (preorder == null || preorder.length <= 0) {
return null;
}
// 遍历中序 把元素和下标位置存入map中
for (int i = 0; i < inorder.length; i++) {
map.put(inorder[i], i);
}
// 递归遍历
TreeNode root = f(preorder, 0, preorder.length - 1, inorder, 0, inorder.length - 1);
return root;
}
TreeNode f(int[] preorder, int l1, int r1, int[] inorder, int l2, int r2) {
// 递归结束条件(已经越过叶子节点则递归结束)
if (l1 > r1 && l2 > r2) {
return null;
}
// 找到根节点
TreeNode root = new TreeNode(preorder[l1]);
// 从map中找到根节点在中序遍历中的位置
int i = map.get(preorder[l1]);
// 递归遍历左子树
root.left = f(preorder, l1 + 1, l1 + i - l2, inorder, l2, i - 1);
// 递归遍历右子树
root.right = f(preorder, l1 + i - l2 + 1, r1, inorder, i + 1, r2);
return root;
}
}
剑指offer09.两个栈实现队列
题目链接:LCR 125. 图书整理 II
题目描述:读者来到图书馆排队借还书,图书管理员使用两个书车来完成整理借还书的任务。书车中的书从下往上叠加存放,图书管理员每次只能拿取书车顶部的书。排队的读者会有两种操作:
- push(bookID):把借阅的书籍还到图书馆。
- pop():从图书馆中借出书籍。
为了保持图书的顺序,图书管理员每次取出供读者借阅的书籍是 最早 归还到图书馆的书籍。你需要返回 每次读者借出书的值 。如果没有归还的书可以取出,返回 -1 。
// 力扣的改编题挺有意思,根据题目需要思考几个问题
// 在进行还书的时候 书是自下向上堆叠的 所以还书只能从上面堆 有点像栈结构
// 但是又要求管理员在提供给用户借书的时候又要保证从顶端取又要满足是最早归还到图书馆的书 也就是先进先出 这是队列的特性
// 但是我们的栈是后进先出 所以我们就需要实现利用两个栈来模拟队列的先进先出
import java.util.ArrayDeque;
import java.util.Deque;
class CQueue {
// 在进行push压栈时 其中一个栈stack1工作 此时栈顶的是最后压栈的元素
// 进行pop时要求弹出最早压栈的元素 此时需要stack2工作 把栈stack1的元素按顺序全部弹出到stack2中
// 此时stack2的栈顶就是最早压栈的元素 从stack2弹出即可
Deque<Integer> stack1, stack2;
public CQueue() {
stack1 = new ArrayDeque<>();
stack2 = new ArrayDeque<>();
}
public void appendTail(int value) {
stack1.push(value);
}
public int deleteHead() {
// 如果stack2不为空时 栈顶的元素就是最早压栈的元素 直接弹出即可
if (!stack2.isEmpty()) {
return stack2.pop();
} else {
// 如果stack2为空时 需要把stack1的元素全部弹出到stack2中
// 注意必须全部出栈 否则再入栈顺序就会被打乱
while (!stack1.isEmpty()) {
stack2.push(stack1.pop());
}
return stack2.isEmpty() ? -1 : stack2.pop();
}
}
}
递归和循环
重复计算相同的问题,通常可以由递归或者循环两种解法。递归方式更加简洁,但在使用时要注意以下几点:
- 递归每次都调用函数本身,会不断在栈中分配内存空间,同时压栈和出栈操作也需要一定的时间;
- 递归的本质是将一个问题拆解成两个或多个子问题,如果多喝小问题存在相互重叠的部分,就存在重复计算;
- 递归的层级太多,可能会出现调用栈溢出的现象。
剑指offer10-I.斐波那契数列
题目链接:LCR 126. 斐波那契数
题目描述:
斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给定 n ,请计算 F(n) 。
class Solution {
int[] arr;
public int fib(int n) {
// 带有大量重复计算子问题的解法 重复计算的数量会随n的增大乘指数增加
/*
* if (n <= 1) {
* return n;
* }
* return fib(n - 1) + fib(n - 2);
*/
// 优化版递归:定义数组保存状态(也可以利用map判断是否计算过)
// 时间复杂度:O(n) 空间复杂度:O(n)
/*
* arr = new int[n + 1];
* for (int i = 0; i <= n; i++) {
* arr[i] = -1;
* }
* return f(n);
*/
// 动态规划:自底向上 空间复杂度:O(1)
// 刚才做法是自上而下的,现在利用自下而上
// 从小到大循环计算 当前值 = 前两数相加
if (n <= 1) {
return n;
}
int a = 0;
int b = 1;
int c;
for (int i = 2; i <= n; i++) {
c = (a + b) % 1000000007;
a = b;
b = c;
}
return b;
}
/*
* int f(int n) {
* // 终止条件:小于等于1时返回本身
* if (n <= 1) {
* return n;
* }
* // 优化的关键:每次递归都需要将值存到数组中 所以在递归之前先判断是否已经计算过
* if (arr[n] != -1) {
* return arr[n];
* }
* int sum = (f(n - 1) + f(n - 2)) % 1000000007;
* arr[n] = sum;
* return arr[n];
* }
*/
}
剑指offer10-II.青蛙跳台阶问题
题目链接:LCR 127. 跳跃训练
题目描述:今天的有氧运动训练内容是在一个长条形的平台上跳跃。平台有 num 个小格子,每次可以选择跳 一个格子 或者 两个格子。请返回在训练过程中,学员们共有多少种不同的跳跃方式。
结果可能过大,因此结果需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
class Solution {
public int trainWays(int num) {
// 设青蛙跳n级台阶一共有f(n)种跳法
// 由于青蛙每次只能跳1级或2级台阶,
// 从 n>=2 开始就会有两种不同的选择
// 1.一次跳1级 此时跳法数目等于剩下n-1级台阶的跳法数目 即为f(n-1)
// 2.一次跳2级 此时跳法数目等于剩下n-2级台阶的跳法数目 即为f(n-2)
// 所以f(n) = f(n-1) + f(n-2) 斐波那契问题
// 题目要求n=0结果为1
if (num <= 1) {
return 1;
}
int a = 1;
int b = 1;
int c;
for (int i = 2; i <= num; i++) {
c = (a + b) % 1000000007;
a = b;
b = c;
}
return b;
}
}
查找和排序
在排序的数组或者部分排序的数组中查找一个数字或者统计某个数字出现的次数,都可以尝试用二分查找算法。
剑指offer11.旋转数组的最小数字
题目链接:LCR 128. 库存管理 I
题目描述:仓库管理员以数组 stock 形式记录商品库存表。stock[i] 表示商品 id,可能存在重复。原库存表按商品 id 升序排列。现因突发情况需要进行商品紧急调拨,管理员将这批商品 id 提前依次整理至库存表最后。请你找到并返回库存表中编号的 最小的元素 以便及时记录本次调拨。
思路:
class Solution {
public int stockManagement(int[] stock) {
// 数组旋转过后可以分为两个递增子数组 满足前面的子数组的元素都大于等于后面子数组的元素
// 同时要明确最小元素是这两个子数组的分界点 同样也是第二个子数组的第一个元素
// 虽然部分排序 但同样也适用二分法求解
// 逻辑:利用两个指针分别指向第一个元素和最后一个元素 代表搜索的边界
// 用中间元素来和两个边界值进行比较
// 1.如果中间元素大于等于第一个元素 则表明此中间元素是第一个子数组的元素 这也就说明最小元素一定在它的后面 此时缩小搜索边界范围
// 2.如果中间元素小于第一个元素 则表明此中间元素是第二个子数组的元素 这也就说明最小元素一定在它的前面 同样缩小边界
// 3.当两个边界值相邻时终止 此时最小元素就是第二个指针所指向的元素
if (stock == null || stock.length == 0) {
return -1;
}
// 特例1:假如数组旋转前面0个元素 也就是原封不动 此时最小的元素就是第一个元素 直接返回 所以这里将mid初始化成left
int left = 0, right = stock.length - 1, mid = left;
while (stock[left] >= stock[right]) {
// 终止条件
if (right - left == 1) {
mid = right;
break;
}
mid = (left + right) / 2;
// 特例2:当发生中间元素、两个指针指向的元素都相等的时候(三者相同)就会发现无法移动两个指针来缩小查找范围
// 此时只能顺序查找
if (stock[left] == stock[right] && stock[left] == stock[mid]) {
return minInOrder(stock, left, right);
}
if (stock[mid] >= stock[left]) {
left = mid;
} else if (stock[mid] <= stock[right]) {
right = mid;
}
}
return stock[mid];
}
int minInOrder(int[] stock, int left, int right) {
int result = stock[left];
for (int i = left + 1; i <= right; i++) {
if (stock[i] < result) {
result = stock[i];
}
}
return result;
}
}
回溯法
它从解决问题每一步的所有可能选项里系统地选择出一个可行的解决方案,所以非常适用于由多个步骤组成的问题,并且每个步骤都有多个选项。
回溯法解决问题的所有选项可以形象用树状结构表示:在某一步有 n 个可能的选项,那么该步骤可以看成是树状结构中的一个节点,每个选项看成树中节点连接线,经过这些链接线到达该节点的 n 个子节点。树的叶节点对应着终结状态。如果在叶节点的状态满足题目的约束条件,那么找到了一个可行的解决方案;如果不满足则只好回溯到它的上一个节点再尝试其他选项。
剑指offer12.矩阵中的路径
题目链接:LCR 129. 字母迷宫
题目描述:字母迷宫游戏初始界面记作 m x n 二维字符串数组 grid,请判断玩家是否能在 grid 中找到目标单词 target。
注意:寻找单词时 必须 按照字母顺序,通过水平或垂直方向相邻的单元格内的字母构成,同时,同一个单元格内的字母 不允许被重复使用 。
思路:
// 某个格子作为起点按照水平和垂直两个方向遍历
// 如果目标单词第i个字符是这个当前格子的字符 那么在当前格子的相邻格子寻找第i+1个字符
// 如果目标单词第i个字符不是当前格子的字符 说明当前格子不是搜索目标 进行回退继续遍历
class Solution {
int n;
int m;
int len;
boolean[][] vis;
public boolean wordPuzzle(char[][] grid, String target) {
// 1.遍历二维数组 固定起点 这个起点和目标单词的第一个字符相同
// 2.指定搜索的方向顺序 右->下->左->上
// 3.按照上述顺序进行dfs搜索遍历 和 目标单词对应字符进行比对
// 4.如果比对成功 继续搜索并记录访问过的格子(题目不允许被重复使用)
// 5.如果当前格子的相邻格子都没有找到目标字符则进行回溯(删除访问记录)
this.n = grid.length; // 行
this.m = grid[0].length; // 列
this.len = target.length();
this.vis = new boolean[n][m];
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (dfs(grid, target, i, j, 0)) {
return true;
}
}
}
return false;
}
private boolean dfs(char[][] grid, String target, int i, int j, int idx) {
// 判断是否出界、是否被访问过、是否和目标单词对应字符相同
if (i < 0 || i >= n || j < 0 || j >= m || vis[i][j] || grid[i][j] != target.charAt(idx)) {
return false;
}
// 执行这里说明找到了匹配字符
if (idx == len - 1) {
return true;
}
// 访问记录
vis[i][j] = true;
// 四个方向遍历
boolean res = dfs(grid, target, i, j + 1, idx + 1) ||
dfs(grid, target, i + 1, j, idx + 1) ||
dfs(grid, target, i, j - 1, idx + 1) ||
dfs(grid, target, i - 1, j, idx + 1);
// 回溯重新定位
vis[i][j] = false;
return res;
}
}
剑指offer13.机器人的运动范围
题目链接:LCR 130. 衣橱整理
题目描述:家居整理师将待整理衣橱划分为 m x n 的二维矩阵 grid,其中 grid[i][j] 代表一个需要整理的格子。整理师自 grid[0][0] 开始 逐行逐列 地整理每个格子。
整理规则为:在整理过程中,可以选择 向右移动一格 或 向下移动一格,但不能移动到衣柜之外。同时,不需要整理 digit(i) + digit(j) > cnt 的格子,其中 digit(x) 表示数字 x 的各数位之和。
请返回整理师 总共需要整理多少个格子。
class Solution {
int m;
int n;
int cnt;
boolean[][] vis;
public int wardrobeFinishing(int m, int n, int cnt) {
// 深度优先遍历方法
this.m = m;
this.n = n;
this.cnt = cnt;
vis = new boolean[m][n];
return dfs(0, 0);
}
int dfs(int x, int y) {
// 是否在方格之内、是否访问过、是否是障碍物
if (x < 0 || x >= m || y < 0 || y >= n || vis[x][y] || cnt < sum(x) + sum(y)) {
return 0;
}
vis[x][y] = true;
return 1 + dfs(x + 1, y) + dfs(x - 1, y) + dfs(x, y + 1) + dfs(x, y - 1);
}
int sum(int x) {
int res = 0;
while (x != 0) {
res += x % 10;
x /= 10;
}
return res;
}
}
动态规划与贪婪算法
应用动态规划求解问题的四个特点:
- 问题的目标是求一个问题的最优解;
- 整体问题的最优解是依赖各个子问题的最优解;
- 把大问题分解成若干个小问题,这些小问题之间还有相互重叠的更小的子问题;
- 从上往下分析问题,从下往上求解问题:为了避免重复求解子问题,可以从下往上的顺序先计算小问题的最优解并存储下来,再以此为基础求取大问题的最优解。
剑指offer14-I.剪绳子
题目链接:LCR 131. 砍竹子 I
题目描述:现需要将一根长为正整数 bamboo_len 的竹子砍为若干段,每段长度均为正整数。请返回每段竹子长度的最大乘积是多少。
class Solution {
public int cuttingBamboo(int bamboo_len) {
// 1.O(n2)时间和O(n)空间动态规划求解
// 2.O(1)时间和空间贪心算法求解
// 考虑只切两段:两段尽可能相等乘积才能获得最大值
// 考虑当绳子小于5时默认不切(要不没有 要不切完乘积更小) 大于等于5时继续切
// 切完最终绳子只有 1 2 3 4 这四种状态
// 当2和4同时存在的时候 改成3*3 乘积能够更大
// 所以尽可能让绳子切成3 并且 2 4两种状态不能同时出现
// 假设绳子为n res = n/3计算出绳子能够切多少个长度为3的段
// mod = n % 3 余数 0 1 2
// 当余数为0时 说明最终乘积为3^res
// 当余数为1时 说明最终乘积为3^(res-1) * 4
// 当余数为2时 说明最终乘积为3^res * 2
if (bamboo_len == 2) {
return 1;
}
if (bamboo_len == 3) {
return 2;
}
int res = bamboo_len / 3;
int mod = bamboo_len % 3;
if (mod == 0) {
return pow(3, res);
} else if (mod == 1) {
return pow(3, res - 1) * 4;
} else {
return pow(3, res) * 2;
}
}
public int pow(int a, int b) {
int sum = 1;
for (int i = 0; i < b; i++) {
sum *= a;
}
return sum;
}
}
剑指offer14-II.剪绳子II
题目链接:LCR 132. 砍竹子 II
题目描述:现需要将一根长为正整数 bamboo_len 的竹子砍为若干段,每段长度均为 正整数。请返回每段竹子长度的 最大乘积 是多少。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
class Solution {
public int cuttingBamboo(int bamboo_len) {
if (bamboo_len == 2) {
return 1;
}
if (bamboo_len == 3) {
return 2;
}
int res = bamboo_len / 3;
int mod = bamboo_len % 3;
int p = 1000000007;
if (mod == 0) {
return (int) pow(3, res);
} else if (mod == 1) {
return (int) (pow(3, res - 1) * 4 % p);
} else {
return (int) (pow(3, res) * 2 % p);
}
}
// 循环求 a^n % p
long pow(int a, int n) {
long res = 1;
for (int i = 0; i < n; i++) {
res = (res * a) % 1000000007;
}
return res;
}
}
剑指offer15.二进制中1的个数
题目链接:LCR 133. 位 1 的个数
题目描述:编写一个函数,输入是一个无符号整数(以二进制串的形式),返回其二进制表达式中数字位数为 ‘1’ 的个数(也被称为 汉明重量).)。
class Solution {
// you need to treat n as an unsigned value
public int hammingWeight(int n) {
// 解法1:循环右移 最低位和1进行与运算 如果结果为1 则统计数加1
// 如果数字为负数 则在进行有符号右移的时候会出现死循环
// 解法2:数字n不动 1进行循环左移进行与运算 如果结果为1 则统计数加1
// 这种解法循环次数取决于n的位数
// int count = 0;
// int flag = 1;
// while (flag != 0) {
// if ((n & flag) != 0)
// count++;
// flag = flag << 1;
// }
// return count;
// 解法3:一个整数减去1 再和原整数做与运算 会把该整数最右边的1变为0
// 举例: 1100 和 1011 做与运算 结果为 1000
// 举例:1011 和 1010 做与运算 结果为 1010
// 也就是说一个整数有多少个1 就可以进行多少次这样的操作 进而统计1的个数
int count = 0;
while (n != 0) {
n = n & (n - 1);
count++;
}
return count;
}
}
总结:
- 从数据结构角度:数组和字符串是两种最基本的数据结构;链表是面试中使用频率最高的一种数据结构;如果想加大难度,很有可能选用与树(尤其是二叉树)相关的面试题;由于栈与递归密切相关;队列在图(包括树)的宽度优先遍历中需要用到;
- 从算法角度:查找(特别是二分查找)和排序(快速排序和归并排序)是面试经常考察的算法;回溯法很适合解决迷宫及其类似的问题;如果是求解一个问题的最优解,可以尝试使用动态规划;如果发现利用动态规划分析问题时每一步都存在一个能得到最优解的选择,可以尝试贪婪算法。基于循环和递归的不同实现,他们的时间复杂度可能大不相同,很多时候我们会用自上而下的递归思路分析问题,却用基于自下而上的循环实现代码;