剑指offer(java)
3.数组中重复的数字
3.1 可修改数组
题目
在一个长度为n的数组里的所有数字都在0到n-1的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。例如,如果输入长度为7的数组{2, 3, 1, 0, 2, 5, 3},那么对应的输出是重复的数字2或者3。
思路
可用哈希表,但这样时间复杂度是O(n),空间复杂度也是O(n)
这里用的交换的思想,如果这个数组中没有重复的数字,那么当数组排序之后数字i应该出现在下标为i的位置。
public class Solution2 {
/**
* 找到数组中一个重复的数字
* 返回-1代表无重复数字或者输入无效
*/
public int getDuplicate(int[] arr) {
if (arr == null || arr.length <= 0) {
System.out.println("数组输入无效!");
return -1;
}
for (int a : arr) {
if (a < 0 || a > arr.length - 1) {
System.out.println("数字大小超出范围!");
return -1;
}
}
for (int i = 0; i < arr.length; i++) {
int temp;
while (arr[i] != i) {
if (arr[arr[i]] == arr[i])
return arr[i];
// 交换arr[arr[i]]和arr[i]
temp = arr[i];
arr[i] = arr[temp];
arr[temp] = temp;
}
}
System.out.println("数组中无重复数字!");
return -1;
}
}
时间复杂度:O(n),虽有一个两重循环,但每个数字只要一次交换就能被确定位置,n个数字,最多只要n次。
空间复杂度:O(1),所有步骤都在输入的数组上进行,不需要额外分配内存(交换的思想),、
3.2 不可修改数组
题目
思路
数组长度为n+1,而数字只从1到n,说明必定有重复数字。可以由二分查找法拓展:把1-n的数字从中间数字m分成两部分,若前一半1-m的数字数目超过m个,说明重复数字在前一半区间,否则,在后半区间m+1~n。每次在区间中都一分为二,知道找到重复数字。
public class Solution2 {
public static int getDuplicate(int[] arr) {
if (arr == null || arr.length <= 0) {
System.out.println("数组输入无效!");
return -1;
}
for (int a : arr) {
if (a < 1 || a > arr.length - 1) {
System.out.println("数字大小超出范围!");
return -1;
}
}
int low = 1;
int high = arr.length - 1; // high即为题目的n
int mid, count;
while (low <= high) {
mid = ((high - low) >> 1) + low;
count = countRange(arr, low, mid);
if (low == high) {
if (count > 1) //如果重复,次数必定大于1
return low;
else
break; // 意味着count=1,不过必有重复,应该不会出现这种情况吧?
}
if (count > mid - low + 1) {
high = mid; //这边为什么不是high=mid+1,因为如2,3,5,4,3,2,6,7
//1-4范围的数字出现了5次,那么1-4范围内必定有重复,那么范围就缩减到了1-4,而不是1-5(mid=4)
} else {
low = mid + 1;//同理,1-4若出现了4次,那5-7必定出现了4次,那么范围就缩减到了5-7
}
}
return -1;
}
/**
* 返回在[low,high]范围中数字的个数
*/
public static int countRange(int[] arr, int low, int high) {
if (arr == null)
return 0;
int count = 0;
for (int a : arr) {
if (a >= low && a <= high)
count++;
}
return count;
}
public static void main(String[] args) {
System.out.print("test3:");
int[] a = { 2, 3, 5, 4, 3, 2, 6, 7 };
int dup = getDuplicate(a);
if (dup >= 0)
System.out.println("重复数字为:" + dup);
}
}
时间复杂度:函数countRange()将被调用O(logn)次,每次需要O(n)的时间,因此时间复杂度:O(nlogn) (while循环为O(logn),coutRange()函数为O(n))
空间复杂度:O(1)
4.二维数组中的查找
题目
在一个二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。
思路
查找整数时,如果从左上角开始查找,情况较为复杂,可以转换思路,从右上角开始查找:左边数字比较小,右边数字比较大,容易进行判断。
public class Solution2 {
public boolean Find(int target, int[][] matrix) {
if (matrix == null || matrix.length == 0 || matrix[0].length == 0)//粗略的检查了行不为0,列不为0的情况
return false;
int rows = matrix.length, cols = matrix[0].length;//row:行 cols:列
int r = 0, c = cols - 1; // 从右上角开始
while (r <= rows - 1 && c >= 0) { //不知道什么时候停下来的情况使用while,而不是for
if (target == matrix[r][c])
return true;
else if (target > matrix[r][c])
r++;
else
c--;
}
return false;
}
}
5.替换空格
题目
将一个字符串中的空格替换成 “%20”。
思路
首先要询问面试官是新建一个字符串还是在原有的字符串上修改,本题要求在原有字符串上进行修改。
若从前往后依次替换,在每次遇到空格字符时,都需要移动后面O(n)个字符,对于含有O(n)个空格字符的字符串而言,总的时间效率为O(n2)。
转变思路:先计算出需要的总长度,然后从后往前进行复制和替换,,则每个字符只需要复制一次即可。时间效率为O(n)。
public class Solution2 {
public String replaceSpace(StringBuffer str) {
if (str == null) {
System.out.println("输入错误!");
return null;
}
int length = str.length();
int indexOfOriginal = length-1;
//根据空格的数目重新定义字符串的长度
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) == ' ')
length += 2;
}
str.setLength(length); //void setLength(int newLength)
//下面这种方法和上面的for循环一个目的
// for (int i = 0; i <= P1; i++)
// if (str.charAt(i) == ' ')
// str.append(" ");
int indexOfNew = length-1;
while (indexOfNew > indexOfOriginal) { // 即只要还有空格就执行
char c = str.charAt(indexOfOriginal);
if (c != ' ') {
str.setCharAt(indexOfNew--, c);//void setCharAt(int index, char ch)
} else {
str.setCharAt(indexOfNew--, '0');
str.setCharAt(indexOfNew--, '2');
str.setCharAt(indexOfNew--, '%');
}
indexOfOriginal--;
}
return str.toString();
}
}
6.从尾到头打印链表
题目
输入一个链表的头结点,从尾到头反过来打印出每个结点的值。
思路
结点遍历顺序只能从头到尾,但是输出的顺序却为从尾到头,是典型的“后进先出”问题,这就要联想到使用栈,从而也可以联想到使用递归。
import java.util.Arrays;
import java.util.Stack;
public class Solution2 {
private class ListNode {
int key;
ListNode next;
public ListNode(int key) {
this.key = key;
this.next = null;
}
}
// 采用栈
public void printListReversingly_Iteratively(ListNode node) {
Stack<ListNode> stack = new Stack<ListNode>();
while (node != null) { //存入栈
stack.push(node);
node = node.next;
}
while (!stack.empty()) { //取出
System.out.println(stack.pop().key);
}
}
}
//使用递归
public void printListReversingly_Recursively(ListNode node) {
if (node != null) {
printListReversingly_Recursively(node.next);
System.out.println(node.key);
}
else { //这边的return可加可不加
return;
}
}
7.重建二叉树
题目
根据二叉树的前序遍历和中序遍历的结果,重建出该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。
思路
递归,可见《剑指Offer》P63
遇到有关树的遍历,递归等,不用深推纠结,就拿初始两个步骤验证即可。
package easy_10;
import java.util.HashMap;
import java.util.Map;
public class Solution2 {
private class TreeNode {
int val;
TreeNode left;
TreeNode right;
public TreeNode(int val) {
this.val= val;
this.left = null;
this.right = null;
}
}
private Map<Integer, Integer> indexForInOrders = new HashMap<>();
public TreeNode reConstructBinaryTree(int[] pre, int[] in) {
for (int i = 0; i < in.length; i++)
indexForInOrders.put(in[i], i);
return reConstructBinaryTree(pre, 0, pre.length - 1, 0);
}
private TreeNode reConstructBinaryTree(int[] pre, int preL, int preR, int inL) {
if (preL > preR) //如preL=2,leftTreeSize=0;此时preL+1=3,preL + leftTreeSize=2
return null;
TreeNode root = new TreeNode(pre[preL]);//在前序数组中求根节点
int inIndex = indexForInOrders.get(root.val); //求根节点在中序数组中的位置
int leftTreeSize = inIndex - inL; //求出中序数组根节点为root时的左子树长度
//根据这个长度,在前序数组中将此左子树当作一个新的树求解(起点为pre+1,末尾点为preL+leftTreeSize)
//inL表示该数组在中序数组中的最左端索引
root.left = reConstructBinaryTree(pre, preL + 1, preL + leftTreeSize, inL);
root.right = reConstructBinaryTree(pre, preL + leftTreeSize + 1, preR, inL + leftTreeSize + 1);
return root;
}
public static void main(String[] args) {
int[] pre= {1,2,4,7,3,5,6,8};
int[] in= {4,7,2,1,5,3,8,6};
TreeNode node=new Solution2().reConstructBinaryTree(pre,in);
}
}
8.二叉树的下一个节点
题目
给定一个二叉树和其中的一个结点,请找出中序遍历顺序的下一个结点并且返回。注意,树中的结点不仅包含左右子结点,同时包含指向父结点的指针。
思路
首先自己在草稿纸上画图,进行分析(不再展开)。可以发现下一个结点的规律为:
- 若当前结点有右子树时,其下一个结点为右子树中最左子结点;
- 若当前结点无右子树时,
- 若当前结点为其父结点的左子结点时,其下一个结点为其父结点;
- 若当前结点为其父结点的右子结点时,继续向上遍历父结点的父结点,直到找到一个结点 是其父结点的左子结点
(与1.中判断相同)
,该结点即为下一结点。(好好意会)
import java.util.HashMap;
import java.util.Map;
public class Solution2 {
private class TreeLinkNode {
int val;
TreeLinkNode left = null;
TreeLinkNode right = null;
TreeLinkNode parent = null;
TreeLinkNode(int val) {
this.val = val;
}
}
public TreeLinkNode GetNext(TreeLinkNode pNode) {
if (pNode == null) {
System.out.print("结点为null ");
return null;
}
//如果一个节点有右子树,那么它的下一个节点就是它的右子树中的最左子节点
if (pNode.right != null) {
pNode = pNode.right;
while (pNode.left != null)
pNode = pNode.left;
return pNode;
}
//如果一个节点没有右子树的情况
while (pNode.parent != null) { //大前提:没右子树但有父节点的情况
if (pNode == pNode.parent.left) { //如果该节点是他父节点的左子节点,那么它的下一个节点就是他的父节点
return pNode.parent; //该节点是他父节点的左子节点的情况(该节点是他父节点的右子节点的情况包含这种情况)
}
pNode = pNode.parent; //如果该节点是他父节点的右子节点,就需要沿着父节点一直向上爬,直到
//找到一个节点,该节点是其父节点的左子节点。
}
return null; //没右子树且没父节点的情况
}
}
9.用两个栈实现队列
题目
用两个栈实现一个队列。队列的声明如下,请实现它的两个函数appendTail和deleteHead,分别完成在队列尾部插入结点和在队列头部删除结点的功能。
思路
这道题较简单,自己先试着模拟一下插入删除的过程(在草稿纸上动手画一下):插入肯定是往一个栈stack1中一直插入;删除时,直接出栈无法实现队列的先进先出规则,这时需要将元素从stack1出栈,压到另一个栈stack2中,然后再从stack2中出栈就OK了。需要稍微注意的是:当stack2中还有元素,stack1中的元素不能压进来;当stack2中没元素时,stack1中的所有元素都必须压入stack2中。否则顺序就会被打乱。
import java.util.Stack;
public class Solution2 {
Stack<Integer> in = new Stack<Integer>();
Stack<Integer> out = new Stack<Integer>();
public void appednTail(int node) {
in.push(node);
}
public int deleteHead() throws Exception {
if (out.isEmpty())
while (!in.isEmpty())
out.push(in.pop()); //必须一次性把in栈中的元素全部压入out栈
if (out.isEmpty()) //如果in栈中没有元素压入out栈,那out栈就为空,那就取不了元素了
throw new Exception("queue is empty");
return out.pop(); //如果out不为空,那就直接从out栈中弹出元素
}
}
9.2用两个队列实现栈
思路
一个队列加入元素和弹出元素时,需要把队列A中的其他元素放到另外一个队列B中,然后在原来的队列A中删除最后一个元素。两个队列始终保持只有一个队列是有数据的
import java.util.LinkedList;
import java.util.Queue;
public class Solution2<T> {
// 记住队列是接口!!!
private Queue<T> queue1 = new LinkedList<>();
private Queue<T> queue2 = new LinkedList<>();
//压栈、元素要插入到非空的队列
public boolean push(T t) {
if (!queue1.isEmpty()) {
return queue1.offer(t);
} else {
return queue2.offer(t);
}
}
/**
* 弹出并删除元素
*/
public T pop() {
if (queue1.isEmpty() && queue2.isEmpty()) {
throw new RuntimeException("queue is empty");
}
if (!queue1.isEmpty() && queue2.isEmpty()) {
while (queue1.size() > 1) {
queue2.offer(queue1.poll());//把queue1中的元素放入queue2中
}
return queue1.poll(); //此时quuee中只剩下一个元素了,将他从队列中取出
}
if (queue1.isEmpty() && !queue2.isEmpty()) {
while (queue2.size() > 1) {
queue1.offer(queue2.poll());
}
return queue2.poll();
}
return null;
}
}
10.斐波那契数列
题目
思路
如果直接写递归函数,由于会出现很多重复计算,效率非常底,不采用。例如,计算 f(4) 需要计算 f(3) 和 f(2),计算 f(3) 需要计算 f(2) 和 f(1),可以看到 f(2) 被重复计算了。
要避免重复计算,采用从下往上计算,可以把计算过了的保存起来,下次要计算时就不必重复计算了:先由f(0)和f(1)计算f(2),再由f(1)和f(2)计算f(3)……以此类推就行了,计算第n个时,只要保存第n-1和第n-2项就可以了。
可读性较高的写法
public class Solution2 {
public long Fib(long n) {
if(n<0)
throw new RuntimeException("下标错误,应从0开始!");
if (n == 0)
return 0;
if (n == 1)
return 1;
long prePre = 0;
long pre = 1;
long result = 1;
for (long i = 2; i <= n; i++) {
/*考虑到第 i 项只与第 i-1 和第 i-2 项有关,因此只需要存储
前两项的值就能求解第 i 项,从而将空间复杂度由 O(N) 降低为 O(1)。*/
result = prePre + pre;
prePre = pre;//将prePre指针指到他后面一格pre上
pre = result;//将pre指针指到他后面一格result上
//0 + 1 = 2; prePre=0,pre=1;
//1 + 2 = 3; prePre=1,pre=2;
}
return result;
}
}
时间复杂度:O(n),for循环
空间复杂度:O(1)
第二种解法
思路:递归是将一个问题划分成多个子问题求解,动态规划也是如此,但是动态规划会把子问题的解缓存起来,从而避免重复求解子问题。,因此这里参照动态规划的思路来求解。
public class Solution2 {
public int Fibonacci(int n) {
if (n <= 1)
return n;
int[] fib = new int[n + 1];//因为要取到fib[n]这个值,所以长度得是n+1
fib[1] = 1;
for (int i = 2; i <= n; i++)
fib[i] = fib[i - 1] + fib[i - 2];//简单明了,将所有的值都存储在数组里了
return fib[n];
}
}
时间复杂度:O(n),for循环
空间复杂度:O(n),创建n+1长度的数组
10.2青蛙跳台阶问题
题目
一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
思路
如果只有1级台阶,那只有一种跳法,如果有2级台阶,那么有两种跳法:一次跳1级,连续跳两次;或者一次跳2级。
现在我们把n级台阶时的跳法看成n的函数,记为f(n)。当n>3时,一是第一次跳有2种选择,如果跳1级,那么此时跳法的数目等于后面剩下的n-1级台阶的跳法数目,即为f(n-1);二是第一次跳2级,此时跳法数目等于后面剩下的n-2级台阶的跳法数目,即为f(n-2)。因此,n级台阶的不同跳法的总数f(n)=f(n-1)+f(n-2)。这不就是斐波那契数列吗?!
public class Solution2 {
public int Fibonacci02(int n) {
if (n < 1)
return -1; //代表出异常了
int[] fib = new int[n + 1];
fib[1] = 1; //1级台阶只有一种跳法
fib[2] = 2; //2级台阶有两种跳法
for (int i = 3; i <= n; i++)
fib[i] = fib[i - 1] + fib[i - 2];
return fib[n];
}
}
10.3矩形覆盖
题目
我们可以用 2X1 的小矩形横着或者竖着去覆盖更大的矩形。请问用 n 个 2X1 的小矩形无重叠地覆盖一个 2Xn 的大矩形,总共有多少种方法?
思路
和上面一样,是斐波那契数列的应用,一模一样,n=1时,一种,n=2时,两种。。。。
11.旋转数组的最小数字
题目
把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。输入一个递增排序的数组的一个旋转,输出旋转数组的最小元素。例如数组 {3, 4, 5, 1, 2}为{1, 2, 3, 4, 5}的一个旋转,该数组的最小值为1。
思路
见《剑指Offer》P83,
数组在一定程度上是排序的,很容易分析出:可以采用二分法来寻找最小数字。将旋转数组对半分可以得到一个包含最小元素的新旋转数组,以及一个非递减排序的数组。新的旋转数组的数组元素是原数组的一半,从而将问题规模减少了一半,这种折半性质的算法的时间复杂度为 O(logN)
但是这里面有一些陷阱:
1.递增排序数组的本身是自己的旋转,则最小数字是第一个数字
2.中间数字与首尾数字大小相等,如{1,0,1,1,1,1}和{1,1,1,1,0,1},无法采用二分法,只能顺序查找。
public class Solution2 {
public class MinNumberInRotatedArray {
public int minNumberInRotateArray(int[] array) {
if (array == null || array.length <= 0) // 空数组或null时返回0
return 0;
int low = 0;
int high = array.length - 1;
int mid = (low + high) / 2;
// 对应陷阱1,本身就是升序数组,若非如此,左边数组的第一个元素必定大于右边数组的最后一个元素
if (array[low] < array[high])
return array[low]; // 升序数组本身也是一个旋转数组
// 对应陷阱2,中间数字与首尾数字相等,1 0 1 1 1 或者 1 1 1 0 1这种情况
// 此时二分法失效,只能使用顺序查找,而此时只可能有如0,1;1,2等两种数字
if (array[mid] == array[high] && array[mid] == array[low]) {
for (int i = 1; i <= high; i++) {
if (array[i] < array[i - 1]) // 相邻比较,注意首中尾三个相等的元素必定两种元素中较大的
return array[i]; // 把较小者返回出来,该元素就是题目要求的最小值了
}
return array[low]; // 数组元素都相同的情况
}
// 正常情况
while (low < high) {
//前后指针紧挨着,此时low指向第一个数组的末尾,high指向第二个数组的开头
if (high - low == 1)
break;
mid = (low + high) / 2;
if (array[mid] <= array[high])
high = mid;
if (array[mid] > array[low])
low = mid;
}
return array[high];
}
}
}
第二种更加精炼版本,推荐用这种,思路一模一样
public class Solution2 {
public class MinNumberInRotatedArray {
public int minNumberInRotateArray(int[] nums) {
if (nums == null || nums.length <= 0) // 空数组或null时返回0
return 0;
int low = 0, high = nums.length - 1;
while (low < high) { //最终会使得low=high(即最小值位置)而跳出循环
int m = low + (high - low) / 2;
if (nums[low] == nums[m] && nums[m] == nums[high])
return minNumber(nums, low, high);
else if (nums[m] <= nums[high])
high = m; //说不定m指向的就是最小元素,因此不能high=mid-1
else //说明nums[m]>nums[high],m指针肯定指向左子树组,且最小元素在后面
low = m + 1; //这也就是为什么这边m+1,上面却没有-1
}
return nums[low];
}
private int minNumber(int[] nums, int l, int h) {
for (int i = l; i < h; i++)
if (nums[i] > nums[i + 1])
return nums[i + 1];
return nums[l];
}
}
}
注意这段代码的一些细节:
1.使用low=mid+1,而不是low=mid,最终会使得low=high(即最小值位置)而跳出循环;
2.使用high=mid,而不是high=mid-1,因为有可能mid就是最小值点,不能减1;