【剑指Offer】解题思路拆解Java版——第一期

不好意思,这段时间找工作没有更新公众号,今天刚入职新公司,才有空给大家更新文章,鸽了两周多实在是不好意思。

下面这段时间带来的是对于剑指Offer(第二版)一书中的算法题目进行阅读并分享。原书中一共66道题目,我们就一天11道,用六天的时间来进行讲解,最后一天来个总结,争取在一周的时间内介绍完这66道经典题目。要是喜欢的欢迎关注公众号《Java冢狐》来追更!

另外由于原书是C++代码编写而成,这边我们用Java来实现一遍,顺便说一下相关的面试知识点,一起进行面试前的复习。希望大家能够喜欢。

另外有些地方的讲解可能并不是十分到位,在此更推荐更大家去看原书。

那么话不多少,让我们开始今天的解题之路吧!

一、赋值运算符函数

C++题目,这个我们暂且略过。

二、实现单例模式

这个题目很简单,就是让我们去实现一个单例模式,这个在面试中也属于大厂的热门题目,尤其是在遇到设计模式的问题中,会问你了解哪些设计模式?并且问你在实际的项目开发中写过哪些设计模式?

通常我们用到的设计模式有:单例模式、工厂模式、构造器模式、原型模式、代理模式、组合模式、观察者模式、模板方法模式......

这些设计模式在Spring中都会有所设计,只需要根据自己的项目经历进行描述即可。

说回到本题的单例模式,通常一般分为懒汉式和饿汉式,我们需要掌握其内在逻辑以及熟练手撕这两种单例模式即可。要是想更加具体的了解单例模式,在我公众号的单例模式那一篇中有详细的介绍,可以再去复习一下,这里就不在赘述,直接上代码:

懒汉式(双重检查懒汉式):

懒汉式,即用到的时候在加载。

public class Singleton{
    private static volatile Singleton instance;

    private Singleton(){}

    public static Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                if(instacnce == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这种写法使用了两个if判断,并且同步的不是方法,而是代码块,更加高效。

饿汉式

饿汉式即,创建的时候就进行了初始化,避免了多线程同步问题。但是如果实例没有被使用,内存就浪费了。

public class Singleton{
    private final static INSTANCE = new Singleton();
    private Singleton(){}
    public static Singleton getInstance(){
        return INSTANCE;
    }
}

Tip:DCL单例模式为啥要加volatile:

因为新建对象的时候涉及到三个指令:

  • 申请一个内存
  • 构造方法执行
  • 建立关联

如果不加volatile,发生了指令重排序会导致先建立了关联而后执行构造方法。这样其他线程就会得到一个半初始化状态的变量,导致出现为问题

三、数组中重复的数字

  • 题目

在一个长度为 n 的数组 nums 里的所有数字都在 0~n-1 的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。

像这种找数字问题,使用哈希表可以更加高效的解决这个问题,反正只需要找到一个重复的元素即可。

    public int findRepeatNumber(int[] nums) {
        HashSet set = new HashSet();
        for (int i : nums) {
            if (!set.add(i))
                return i;
        }
        return -1;
    }

四、二维数组中的查找

  • 题目

在一个 n * m 的二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个高效的函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。

这种题目要是在面试中遇到,要是是在想不到思路就先暴力求解,然后再等面试官问如何优化的时候,再捋一捋头绪争取想出高效的算法。

其实对于这个题目的优化方向就是尽可能的缩小范围。即通过已知的有序逐步确定我们要找的数字在哪一个位置。

由于每一行和每一列都是有序的,所以我们首先就是要确认从哪个位置开始遍历,正常的思路当然是从左上角开始遍历,但是会存在一个问题,就是要是我们遍历的值比目标值小,那么要往下走还是往右走呢?

所以我们不应该从左上角开始遍历,应该从右上角,这样要是小的话就往下找,大的话就往左找,直到找到或者越界。

有了思路以后,代码编写就很简单了。

public boolean findNumberIn2DArray(int[][] matrix, int target) {
        if (matrix == null || matrix.length == 0 || matrix[0].length == 0)
            return false;
        int rows = matrix.length;
        int columns = matrix[0].length;
        int row = 0;
        int column = columns - 1;
        while (row < rows && column >= 0) {
            int nums = matrix[row][column];
            if (nums == target)
                return true;
            if (nums > target) {
                column--;
                continue;
            }
            if (nums < target) {
                row++;
                continue;
            }
        }
        return false;
    }

五、替换空格

  • 题目

请实现一个函数,把字符串中的每个空格替换成%20。

这个题目十分的简单,就是进行字符替换,可以直接使用正则表达式来进行替换搞定,一行代码就解决了:

public String replaceSpace(String s) {
        return s.replace(/\s/g, '%20');
    }

但是这种解决方法也就适用于网上答题,要是真面试官问你,要你手撕代码,可以用这个抖个机灵,真正肯定要用到以下的方法。拼接字符串

 public String replaceSpace(String s) {
        StringBuilder string = new StringBuilder();
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == ' ') {
                string.append("%20");
            } else {
                string.append(c);
            }
        }
        return string.toString();
    }

六、从尾到头打印链表

  • 问题

输入一个链表的头节点,从尾到头反过来返回每个节点的值(用数组返回)

看到这个题目第一个思路就是先翻转链表,然后再输出。这里顺便我们再复习一下翻转链表的操作,如下所示:

public int[] reversePrint(ListNode head) {
        if (head == null)
            return new int[0];
        ListNode ans = null;
        int len = 0;
        while (head != null) {
            ListNode temp = head.next;
            head.next = ans;
            ans = head;
            head = temp;
            len++;
        }
        int[] nums = new int[len];
        for (int i = 0; i < len; i++) {
            nums[i] = ans.val;
            if (i != len - 1) {
                ans = ans.next;
            }
        }
        return nums;
    }

但是在面试中,面试官很可能,不允许修改输入的数据,那么此时就需要我们借助栈来实现这个算法,如下所示:

public int[] reversePrint(ListNode head) {
        Stack stack = new Stack();
        ListNode temp = head;
        while (temp != null) {
            stack.push(temp);
            temp = temp.next;
        }
        int size = stack.size();
        int[] print = new int[size];
        for (int i = 0; i < size; i++) {
            print[i] = stack.pop().val;
        }
        return print;
    }

七、重建二叉树

  • 问题:

输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。

首先先要明确前序、中序、后续遍历分别是什么?

其实所谓的前中后,指的是根节点的位置,即:

  • 前序:根左右
  • 中序:左根右
  • 后续:左右根

所以说根据前序遍历或者后续遍历就能确定整个子树的根节点的位置,然后通过中序遍历知道其左树和右树分别是什么情况,然后依次类推,最终还原整个树

在其中要多次使用到中序节点的值和位置,方便起见用map来进行存储,代码如下所示

  public TreeNode buildTree(int[] preorder, int[] inorder) {
        if (preorder.length == 0)
            return null;
        Map indexMap = new HashMap();
        int length = preorder.length;
        for (int i = 0; i < length; i++) {
            indexMap.put(inorder[i], i);
        }
        TreeNode root = buildTree(preorder, 0, length - 1, inorder, 0, length - 1, indexMap);
        return root;
    }
    // 前序遍历结果、开始位置、结束位置、中序遍历结果、开始位置、结束位置、map
    public TreeNode buildTree(int[] preorder, int preorderStart, int preorderEnd, int[] inorder, int inorderStart, int inorderEnd, Map indexMap) {
        if (preorderStart > preorderEnd)
            return null;
        int rootVal = preorder[preorderStart];
        TreeNode root = new TreeNode(rootVal);
        if (preorderStart == preorderEnd) {
            return root;
        } else {
            int rootIndex = indexMap.get(rootVal);
            int leftNodes = rootIndex - inorderStart, rightNodes = inorderEnd - rootIndex;
            TreeNode leftSubtree = buildTree(preorder, preorderStart + 1, preorderStart + leftNodes, inorder, inorderStart, rootIndex - 1, indexMap);
            TreeNode rightSubtree = buildTree(preorder, preorderEnd - rightNodes + 1, preorderEnd, inorder, rootIndex + 1, inorderEnd, indexMap);
            root.left = leftSubtree;
            root.right = rightSubtree;
            return root;
        }
    }

八、二叉树的下一个节点

  • 问题

给定一颗二叉树和其中一个节点,如何找出中序遍历序列的下一个节点?树中的节点除了有两个分别指向左、右子点的指针还有一个指向父节点的指针。

根据中序遍历的特点,分为以下几种情况进行讨论

  • 该节点有右子树

那么下一个节点就是右子树中的最左节点

  • 没有右子树

    • 该节点是父节点的左子节点

那么下一个节点就是其父节点

    • 该节点是父节点的右子节点

那么这种情况是最复杂的一种,需要沿着指向父节点的指针一直向上遍历,直到找到一个是它父节点的左子节点的节点,然后这个节点的父节点就是我们要找的下一个节点如下所示:g的下一个节点就是a

【剑指Offer】解题思路拆解Java版——第一期_第1张图片

TreeLinkNode GetNext(TreeLinkNode node)
    {
        if(node==null) return null;
        if(node.right!=null){    //如果有右子树,则找右子树的最左节点
            node = node.right;
            while(node.left!=null) node = node.left;
            return node;
        }
        while(node.next!=null){ //没右子树,则找第一个当前节点是父节点左孩子的节点
            if(node.next.left==node) return node.next;
            node = node.next;
        }
        return null;   //退到了根节点仍没找到,则返回null
    }

九、用两个栈实现队列

  • 问题

用两个栈实现一个队列。队列的声明如下,请实现它的两个函数 appendTail 和 deleteHead,分别完成在队列尾部插入整数和在队列头部删除整数的功能。(若队列中没有元素,deleteHead操作返回 -1 )

题目告诉我们使用双栈来实现队列的两个方法,那么我们维护两个栈,第一个支持插入,第二个支持删除。

根据要实现的尾插和头删,我们每次往第一个栈利插入数据即是往最后插入数据,所以此题中需要特别注意的就是头部删除操作,由于删除的元素在第一个栈中的最里面,所以我们只需要在第二个栈为空的时候把第一个栈中的元素倒到第二个栈中。这样就完成了顺序的翻转,直接删除即可。要是第二个栈不为空,即已经从第一个栈中倒出元素了,那么我们直接删除第二个栈中的头结点即可

class CQueue {
    Deque stack1;
    Deque stack2;

    public CQueue() {
        stack1 = new LinkedList();
        stack2 = new LinkedList();
    }

    public void appendTail(int value) {
        stack1.push(value);
    }

    public int deleteHead() {
        if (stack2.isEmpty()) {
            while (!stack1.isEmpty()) {
                stack2.push(stack1.pop());
            }
        }
        if (stack2.isEmpty()) {
            return -1;
        } else {
            return stack2.pop();
        }
    }
}

镜像问题:用两个队列实现栈

class MyStack {
    Queue queue1;
    Queue queue2;

    public MyStack() {
        queue1 = new LinkedList();
        queue2 = new LinkedList();
    }

    public void push(int x) {
        queue2.offer(x);
        while (!queue1.isEmpty()) {
            queue2.offer(queue1.poll());
        }
        Queue temp = queue1;
        queue1 = queue2;
        queue2 = temp;
    }

    public int pop() {
        return queue1.poll();
    }
}

十、斐波那契数列

写出一个函数,输入n,求斐波那契数列的第n项。斐波那契数列定义如下:

  • F(0) = 0, F(1) = 1
  • F(N) = F(N - 1) + F(N - 2), 其中 N > 1

在刚开始学习递归函数的时候,这个题目算是一个例题,能很快的写出如下的代码:

class Solution {
    public int fib(int n) {
        if (n <= 0)
            return 0;
        if (n == 1)
            return 1;
        return fib(n - 1) + fib(n - 2);
    }
}

但是这种写法通常会直接Time Out,原因是由于n的变大,需要计算的中间值急剧上升,导致时间超长,而这些中间值中有很多是重复的,是重复计算,所以我们要优化掉这一块。

  • 额外数组

第一种思路就是把中间的值用一个额外的数组存放起来,避免中间值的计算,需要注意的是要取余防止越界

  • 动态规划

由于直接给出了转移公式,所以我们直接使用即可。即f(n+1)=f(n)+f(n-1)的状态转移方程

class Solution {
    public int fib(int n) {
        if (n <= 0)
            return 0;
        if (n == 1)
            return 1;
        int[] ans = new int[n + 1];
        ans[0] = 0;
        ans[1] = 1;
        for (int i = 2; i < n + 1; i++) {
            ans[i] = (ans[i - 1] + ans[i - 2]) % 1000000007;
        }
        return ans[n];
    }
}

同类问题:青蛙跳台

一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。

答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。

这个题目的本质也是求斐波那契数列,只是初始值不一样而已,其他都一样

十一、旋转数组的最小数字

把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。输入一个递增排序的数组的一个旋转,输出旋转数组的最小元素。例如,数组 [3,4,5,1,2][1,2,3,4,5] 的一个旋转,该数组的最小值为1。

我们一开始的想法肯定就是从头或者尾部,根据递增这个属性来找这个最小值这个元素,但是这种算法的最坏情况的复杂度是O(n),并不是十分的理想,我们应该充分利用递增这个性质来查询,比如使用二分查找来进行查询。使用二分查找就会出现中间值和最后一个的值得大小关系的三种情况:

  • 中间值比最后值大

即最小值在中间值右边

  • 中间值比最后值小

即最小值在中间值右边

  • 中间值和最后相等

这种情况较为复杂,因为我们无法判断最小值是在中间值得右边还是左边,所以我们不敢贸然舍弃其中一块,但是我们可以肯定的是,无论最后值是不是最小值总有中间值代替他,所以我们可以直接舍弃最后值即可。

 public int minArray(int[] numbers) {
        int left = 0;
        int right = numbers.length - 1;
        while (left < right) {
            // 用减法为了防止加法产生越界
            int temp = left + (right - left) / 2;
            if (numbers[temp] < numbers[right]) {
                right = temp;
            } else if (numbers[temp] > numbers[right]) {
                left = temp + 1;
            } else {
                right -= 1;
            }
        }
        return numbers[left];
    }

总结

以上就是剑指Offer中的第一题到第十一题的解题思路,不知道大家都看懂了没,其中有不少都是我们在面试中常碰见的题目,比如第二、三、六、九、十。都是面试中的常客,希望大家能多多理解和掌握。

最后

  • 如果觉得看完有收获,希望能关注一下,顺便给我点个赞,这将会是我更新的最大动力,感谢各位的支持
  • 欢迎各位关注我的公众号【java冢狐】,专注于java和计算机基础知识,保证让你看完有所收获,不信你打我
  • 求一键三连:点赞、转发、在看。
  • 如果看完有不同的意见或者建议,欢迎多多评论一起交流。感谢各位的支持以及厚爱。

——我是冢狐,和你一样热爱编程。

欢迎关注公众号“ Java冢狐”,获取最新消息

你可能感兴趣的:(【剑指Offer】解题思路拆解Java版——第一期)