0 前言
邻近校招,算法要命!!!
本文为研究剑指Offer过程中的笔记,整理出主要思路以及Java版本题解,以便记忆和复习。
参考整理来自《剑指Offer 第二版》。
特别注意,对每道题要首先考虑解题之外的要点:
- 特殊输入:传参为null,数组长度为0,空字符串等。
- 边界条件:数组、字符串长度是否满足题目要求等。
- 数值处理:对于数值考虑是否需要正负号,是否溢出等问题。
1 实现Singleton模式
题目
- 设计一个类,我们只能生成该类的一个实例。
思路
实现单例模式的基本步骤如下:
- 构造函数私有化,保证不能重复创建新实例;
- 提供一个类属性,用来保存实例化后的该单例;
- 提供一个
getInstance()
静态方法,该方法用来向外提供该单例。
实现单例模式有很多种方法,包括不加锁、方法锁、双重锁、静态内部类、枚举等。其中:
- 不加锁只适用于单线程环境。
- 方法锁适用于多线程环境但是性能较差。
- 双重锁优化了性能问题,但由于可能发生指令重排,因此需要对单例类属性添加
volatile
关键子。 - 静态内部类和枚举既适用于多线程环境,性能也较好。
- 为了解决反序列化问题,可以添加
readResolve()
方法。 - 枚举类天然防止反射攻击、反序列化问题。其他方法均可能出现这些问题。
代码
在这里只列出静态内部类的实现方式,其他方式可以参考https://blog.nowcoder.net/n/df5b458b6f1b47e490b60051d5e3dc13。
这种方法利用了JVM类加载机制,是懒汉式单例模式:
- 在第一次调用
getInstance()
方法前,没有直接使用过Inner
类,因此它没有被初始化,instance
为默认值null
。 - 当调用
getInstance()
方法后,
保证了线程安全,instance
即被赋值为单例。 - 由于
instance
被保存在Inner
类属性中,之后每次调用getInstance()
获取到的都是同一个实例对象。
public class Singleton {
// 1、构造函数私有化
private Singleton() {
}
// 2、提供一个类属性用于保存单例,这里用一个内部类保存
private static class Inner {
private static Singleton instance = new Singleton();
}
// 3、提供一个静态的获取该实例的方法
public static Singleton getInstance() {
return Inner.instance;
}
}
2 数组中重复的数字
2.1 题目一:找出数组中重复的数字
题目
在一个长度为n的数组里的所有数字都在0~(n-1)的范围内。数组中的某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。例如,如果输入长度为7的数组{2, 3, 1, 0, 2, 5, 3}
,那么对应的输出是重复的数字2或者3。
思路
三种方案:
- 先将数组排序,遍历数组找出重复数字(快速排序的时间复杂度为
O(nlogn)
)。 - 遍历数组过程中,使用哈希表或数组记录,如果已存在该数字,则找到(时间复杂度为
O(n)
,空间复杂度为O(n)
)。 - 遍历数组,根据索引与值的关系,将该索引对应的值从其他地方交换回来,如果发现待交换的两个值相同,则找到重复数字(时间复杂度
O(n)
)。
代码
前两种实现起来都比较简单,而第三种效率最高,因此这里主要讲解第三种实现方法。
实现步骤如下:
- 需要按照索引依次遍历,将每个索引位置上的值找回来,因此外层是一个
for (int i = 0; i < length; i++)
循环。 - 对于每个索引上的值,有两种情况:
index == value
,说明该索引位置上的值已经归位;index != value
,说明该索引位置上的值没有归位,需要循环将当前值(arr[index]
)与arr[value]
进行交换(可以理解成将value
归位),直到当前index == value
,即将当前索引位置上的值归位。
- 在交换的过程中,如果发现待交换的索引位置上的值发生了碰撞,两个值重复了,即找到了重复数字。
- 如果对索引遍历结束后,都没有发生交换碰撞,则说明此数组中没有重复数字。
以题目中长度为7的数组{2, 3, 1, 0, 2, 5, 3}
为例:
- 依次遍历索引
0, 1, 2, 3, 4, 5, 6
,对该索引上的值进行判断。 - 以索引0上的值2为例,发现
0 != 2
,因此需要对索引0上的值进行归位。- 首先将
arr[0]
与arr[2]
进行交换,将值2进行归位,得到数组{1, 3, 2, 0, 2, 5, 3}
。 - 此时发现索引0上的值为1,
0 != 1
,因此继续归位,将值1进行归位,得到数组{3, 1, 2, 0, 2, 5, 3}
。 - 此时发现索引0上的值为3,
0 != 3
,继续归位,将值3进行归位,得到数组{0, 1, 2, 3, 2, 5, 3}
。 - 此时发现索引0上的值为0,
0 == 0
,即索引0上的值归位成功。
- 首先将
- 按照上述方法,遍历索引
1, 2, 3, 4, 5, 6
,对该索引上的值进行归位。 - 当遍历到索引4时,发现值2,与索引2上的值发生了碰撞,即找到了重复数字2。
import java.util.ArrayList;
import java.util.List;
public class GetOffer {
/**
* 找出数组中重复的数字
* @param arr 数组
* @param duplication 重复的数字
* @return 是否有重复的数字
*/
public static boolean duplicate(int[] arr, List duplication) {
// 1、处理特殊输入等问题
if (arr == null || arr.length <= 0) {
return false; // 数组为空,或者数组长度不满足题目要求
}
if (duplication == null) {
throw new RuntimeException("请输入存放重复数字的容器"); // duplication为null
}
// 2、解题
// 对索引进行遍历,对每个索引进行归位
int length = arr.length;
for (int i = 0; i < length; i++) {
if (arr[i] < 0 || arr[i] > length - 1) {
return false; // 数组中的值不满足题目要求
}
// 如果 i != arr[i],则需要进行归位
while (i != arr[i]) {
// 发生碰撞,找到重复数字,保存并返回true
if (arr[i] == arr[arr[i]]) {
duplication.add(arr[i]);
return true;
}
// 交换,对当前值进行归位
swap(arr, i, arr[i]);
}
// 归位成功后,继续遍历下一个索引
}
// 遍历结束后都没有发生碰撞,说明数组中没有重复数字
return false;
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
/**
* 测试
* @param args
*/
public static void main(String[] args) {
int[] arr = {2, 3, 1, 0, 2, 5, 3};
List duplication = new ArrayList<>();
boolean isDup = duplicate(arr, duplication);
if (isDup) {
System.out.println("发现重复数字:" + duplication.get(0));
} else {
System.out.println("没有发现重复数字");
}
}
}
2.2 不修改数组找出重复的数字
题目
在一个长度为n+1的数组里的所有数字都在1~n的范围内,所以数组中至少有一个数字是重复的。
请找出数组中任意一个重复的数字,但不能修改输入的数组。
例如,如果输入长度为8的数组{2, 3, 5, 4, 3, 2, 6, 7}
,那么对应的输出是重复的数字2或者3。
思路
根据题意,数组中的值的范围为[1, n],即重复的值必定在这个范围中。
- 我们把从1n的数字从中间的数字m分为两部分,前面一半为1m,后面一半为m+1~n。
- 如果1m的数字的数目超过(m+1)-1,那么这一半的区间里一定包含重复的数字;否则,另一半m+1n的区间里一定包含重复的数字。由此缩小了范围。
- 重复将范围一分为二,并进行判断,最终即可确定重复的值。
以长度为8的数组{2, 3, 5, 4, 3, 2, 6, 7}
为例,值的范围为[1, 8]。
- 我们将值的范围分为[1, 4]和[5, 8]两部分。遍历数组,发现值在前半部分(即[1, 4])的数量为5,超过了4(4+1-1),说明重复的值的范围是[1, 4]。
- 继续缩小范围,分为[1, 2]和[3, 4]两部分。遍历数组,发现值在前半部分(即[1, 2])的数量为2,不大于2(2+1-1),说明后半部分(即[3, 4])中必定存在重复数字。【注意:并不能说明前半部分中没有重复的数字】
- 缩小范围,分为3、4两部分。遍历数组,发现值在前半部分(即值等于3)的数量为2,大于1(3+1-3),说明重复的值的范围是3。并且此时发现范围开始索引(3)等于范围结束索引(3),说明重复值为3。
代码
import java.util.ArrayList;
import java.util.List;
public class GetOffer {
public static int getDuplication(int[] arr) {
// 1、处理特殊输入等问题
if (arr == null) {
return -1;
}
int length = arr.length;
for (int i = 0; i < length; i++) {
if (arr[i] < 1 || arr[i] > length) {
return -1; // 数组中的值不满足题目要求
}
}
// 2、解题
int left = 1;
int right = length - 1;
while (left < right) {
// 将范围分成两部分:[left, middle]、[middle+1, right]
int middle = ((right - left) >> 1) + left;
// 统计数组在前半部分中的数量
int count = 0;
for (int i = 0; i < length; i++) {
if (arr[i] >= left && arr[i] <= middle) {
count++;
}
}
// 如果值在前半部分,则范围缩小为[left, middle],否则为[middle + 1, right]
if (count > (middle + 1 - left)) {
right = middle;
} else {
left = middle + 1;
}
}
// 最后left = right,从而锁定重复的值
return left;
}
/**
* 测试
*
* @param args
*/
public static void main(String[] args) {
int[] arr = {2, 3, 5, 4, 3, 2, 6, 7};
int duplication = getDuplication(arr);
System.out.println(duplication); // 3
}
}
3 二维数组中的查找
题目
在一个二维数组中,每一行都按照从左到右递增的顺序排列,每一列都按照从上到下递增的顺序排列。
请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。
思路
例如有如下数组:
1 2 3 4 5
2 3 4 5 6
3 4 5 6 7
从右上角开始遍历:
- 如果该数字等于要查找的数字,则查找过程结束;
- 如果该数字大于要查找的数字,则剔除这个数字所在的列(col--);
- 如果该数字小于要查找的数字,则剔除这个数字所在的行(row++)。
每次都可以在数组的查找范围中剔除一行或者一列。
代码
public class Solution {
public boolean Find(int target, int [][] array) {
// 判断特殊输入
if (array == null) {
return false;
}
int rows = array.length;
if (rows == 0) {
return false;
}
int cols = array[0].length;
if (cols == 0) {
return false;
}
// 从右上角往左下角遍历
int row = 0; // 第一行
int col = cols - 1; // 第一列
while (row < rows && col >= 0) {
if (array[row][col] == target) { // 当前值为target
return true;
} else if (array[row][col] > target) { // 当前值大于target,说明在左侧
col--;
} else { // 当前值小于target,说明在下面
row++;
}
}
return false; // 遍历结束,没有找到
}
}
4 从尾到头打印链表
题目
输入一个链表的头节点,从尾到头反过来打印出每个节点的值。
思路
方法一:栈
- 从头到尾遍历链表,每经过一个节点的时候,把该节点放到一个栈中。当遍历完整个链表后,再从栈顶开始逐个输出节点的值,此时输出的节点的顺序已经反过来了。
方法二:递归
- 每访问到一个节点的时候,如果有下一个节点就先递归输出下一个节点,再输出本节点自身。
代码
- 方法一:栈
import java.util.*;
public class Solution {
private class ListNode {
int val;
ListNode next = null;
ListNode(int val) {
this.val = val;
}
}
public ArrayList printListFromTailToHead(ListNode listNode) {
ArrayList list = new ArrayList<>();
// 判断特殊输入
if (listNode == null) {
return list;
}
// 遍历链表,将值存入栈中
Stack stack = new Stack<>();
stack.push(listNode.val);
while (listNode.next != null) {
listNode = listNode.next;
stack.push(listNode.val);
}
// 遍历栈,将值存入集合
while (!stack.isEmpty()) {
list.add(stack.pop());
}
return list;
}
}
- 方法二:递归(超时)
import java.util.*;
public class Solution {
private class ListNode {
int val;
ListNode next = null;
ListNode(int val) {
this.val = val;
}
}
public ArrayList printListFromTailToHead(ListNode listNode) {
ArrayList list = new ArrayList<>();
// 判断特殊输入
if (listNode == null) {
return list;
}
while (listNode.next != null) {
// 添加下一个节点的值
list.addAll(printListFromTailToHead(listNode.next));
// 添加当前节点的值
list.add(listNode.val);
}
return list;
}
}
5 重建二叉树
题目
输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。
假设输入的前序遍历和中序遍历的结果中都不含重复的数字。
例如,输入前序遍历序列{1, 2, 4, 7, 3, 5, 6, 8}
和中序遍历序列{4, 7, 2, 1, 5, 3, 8, 6}
,则重建二叉树并返回它的头节点。
思路
将前序遍历序列和中序遍历序列看成一组。它们长度相等,代表当前二叉树。
- 首先,我们需要在当前前序遍历序列和中序遍历序列中找出当前子二叉树的根节点(最终返回的节点);
- 然后,分别确定左右子树的前序遍历序列和中序遍历序列,分别指向当前根节点的左子节点和右子节点;
- 如此递归下去,直到该子二叉树没有左右子树(即左右子树的前序遍历序列和中序遍历序列不可再分)。
因此,关键问题在于:如何确定左右子树的前序遍历序列和中序遍历序列?
对于前序遍历序列和中序遍历序列,我们可以发现:
- 在前序遍历序列中,第一个数字总是树的根节点的值;
- 在中序遍历序列中,根节点的值在序列的中部;
- 在中序遍历序列中,左子树的节点的值位于根节点的值的左边,右子树的节点的值位于根节点的值的右边;
- 在前序遍历序列中,左子树的节点的值位于根节点之后序列的前半部分,右子树的节点的值则位于后半部分(可以根据中序遍历得到左右子树的大小来确定)。
以前序遍历序列{1, 2, 3, 7, 3, 5, 6, 8}
和中序遍历序列{4, 7, 2, 1, 5, 3, 8, 6}
为例:
-
设置变量
pL
、pR
、mL
、mR
分别代表前、中序遍历序列的开始索引和结束索引,最初分别为:0、7、0、7。 -
根据前序遍历序列可以得到根节点为:1(可以直接构建当前二叉树根节点);
-
由此可以查找根节点在中序遍历序列中的索引位置为:3;
-
在当前中序遍历序列中确定左子树的中序遍历序列为为
{4, 7, 2}
,长度为3。进而在前序遍历序列中确定左子树的前序遍历序列为{2, 3, 7}
。索引分别为:1、3、0、2。 -
按照同样的方法,确定右子树的前、中序遍历序列分别为
{3, 5, 6, 8}
和{5, 3, 8, 6}
。索引分别为:4、7、4、7。 -
分别对左右子树进行递归,将返回的根节点连接在当前根节点上。
代码
/**
* Definition for binary tree
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
public class Solution {
public TreeNode reConstructBinaryTree(int [] pre,int [] in) {
// 特殊输入
if (pre == null || in == null || pre.length == 0 || in.length == 0 || pre.length != in.length) {
return null;
}
return construct(pre, 0, pre.length - 1, in, 0, in.length - 1);
}
private TreeNode construct(int[] pre, int pL, int pR, int[] in, int iL, int iR) {
// 当前二叉树没有子树,直接返回当前根节点
if (pL > pR || iL > iR) {
return null;
}
// 1、根据前序遍历数列找出当前根节点的值
int val = pre[pL];
// 2、构建当前根节点
TreeNode root = new TreeNode(val);
// 3、根据根节点的值,找到其在中序遍历数列中的索引
int index = -1;
for (int i = iL; i <= iR; i++) {
if (val == in[i]) {
index = i;
}
}
if (index == -1) {
throw new RuntimeException("输入有误,不能重建二叉树!");
}
// 4、根据该索引分别得到左右子树的长度
int lLength = index - iL;
int rLength = iR - index;
// 5、构建出左右子树的前、中序遍历序列,从而分别得到左右子树
root.left = construct(pre, pL + 1, pL + lLength, in, iL, index - 1);
root.right = construct(pre, pL + lLength + 1, pR, in, index + 1, iR);
return root;
}
}