《剑指offer》各编程题Java版分析 -- 面试中的各项能力

332 阅读20分钟

面试题53 -- 在排序数组中查找数字

题目一:数字在排序数组中出现的次数

统计一个数字在排序数组中出现的次数。例如,输入排序数组{1,2,3,3,3,3,4,5}和数字3,由于3在这个数组中出现了4次,因此输出4。

排序数组查找 某个数出现的次数,要素察觉。就是经典的二分查找法,通过二分查找法分别找到数组中最左边的目标数字最右边的目标数字,然后求出二者的距离就可以。

注意覆盖边界条件

二分查找注意牢记相关的套路,例如区间的开闭。

class Solution {
    public int search(int[] nums, int target) {
        if (nums == null || nums.length == 0) {
            return 0;
        }
        int left = binSearchLeft(nums, 0, nums.length-1, target);
        int right = binSearchRight(nums, 0, nums.length-1, target);
        if(left == -1 || right == -1) return 0;
        return right - left + 1;
    }

    private int binSearchLeft(int[] nums, int start, int end, int target) {
        while(start <= end) {
            int mid = start + (end - start)/2;
            if (isFirstTarget(nums, mid, target)) {
                return mid;
            } else if (nums[mid] > target || (nums[mid] == target && nums[mid-1] == target)) {
                end = mid-1; 
            } else {
                start = mid+1;
            }
        }
        return -1;
    }

    private int binSearchRight(int[] nums, int start, int end, int target) {
        while(start <= end) {
            int mid = start + (end - start)/2;
            if (isLastTarget(nums, mid, target)) {
                return mid;
            } else if (nums[mid] < target || (nums[mid] == target && nums[mid+1] == target)) {
                start = mid+1;
            } else {
                end = mid-1; 
            }
        }
        return -1;
    }

    private boolean isLastTarget(int[] nums, int index, int target) {
        return nums[index] == target && (index == nums.length-1 || nums[index+1] > target);
    }

    private boolean isFirstTarget(int[] nums, int index, int target) {
        return nums[index] == target && (index == 0 || nums[index-1] < target);
    }

}

题目二:0~n-1中缺失的数字

一个长度为n-1的递增排序数组中的所有数字都是唯一的,并且每个数字都在范围0~n-1之内。在范围0~n-1内的n个数字中有且只有一个数字不在该数组中,请找出这个数字。

和上一题一样,也是二分查找的变种,比较简单,就是在有序数组中找到 第一个下标与对应数字不匹配的数

class Solution {
    public int missingNumber(int[] nums) {
        if (nums == null || nums.length == 0) {
            return -1;
        }
        int start = 0;
        int end = nums.length-1;
        while(start <= end) {
            int mid = start + (end-start)/2;
            if (isFirstMisMatch(nums, mid)) {
                return mid == 0 ? 0 : nums[mid]-1;
            } else if (nums[mid] == mid) {
                start = mid+1;
            } else {
                end = mid-1;
            }
        }
        return nums[nums.length-1] + 1;
    }

    private boolean isFirstMisMatch(int[] nums, int index) {
        return nums[index] != index && (index == 0 || nums[index-1]==index-1);
    }
}

题目三:数组中数值和下标相等的元素

假设一个单调递增的数组里的每一个元素都是整数并且是唯一的。请编程实现一个函数,找出数组中任意一个数值等于其下标的元素。例如,在数组{-3,-1,1,3,5}中,数字3和它的下标相等。

也是二分查找的一个变种,只不过这次要查找的是数值和下标相等的元素

  • 当发现数值小于下标时,左区间收缩
  • 当发现数值大于下标时,右区间收缩
class Solution {
    public int findNumberEqualIndex(int[] nums) {
        if (nums == null || nums.length == 0) {
            return -1;
        }
        int start = 0;
        int end = nums.length-1;
        while(start <= end) {
            int mid = start + (end - start) / 2;
            if (nums[mid] == mid) {
                return mid;
            }
            if (nums[mid]<mid) {
                start = mid + 1;
            } else {
                end  = mid - 1;
            }
        }
        return -1;
    }
}

面试题54 -- 二叉搜索树的第k大节点

题目:给定一棵二叉搜索树,请找出其中第k大的节点。例如,在图中的二叉搜索树里,按节点数值大小顺序,第三大节点的值是4。

image.png

我们知道,二叉搜索树的中序遍历结果刚好就是从小到大的顺序,题目的要求是找出第k大的节点。leetcode中是要求找到从大到小排第k个的节点。

  • 如果是从小到大的顺序,则执行中序遍历(左-根-右)
  • 如果是从大到小的顺序,则执行反向的中序遍历(右-根-左)

本文以leetcode中的为准,在中序遍历的递归执行过程中,每当遍历到中节点时,就更新一次k的取值。

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {

    private int k;

    public int kthLargest(TreeNode root, int k) {
        this.k = k;
        TreeNode kthNode = kthLargestCore(root);
        if (kthNode == null) {
            return -1;
        }
        return kthNode.val;
    }

    private TreeNode kthLargestCore(TreeNode node) {
        if (node == null) {
            return null;
        }
        TreeNode kthNode = null;
        if (node.right != null) { // 右
            kthNode = kthLargestCore(node.right);
        }
        // 中
        if (kthNode != null) { // 已经找到该节点,返回
            return kthNode;
        } else { 
            if (k == 1) { // 刚好找到,返回中节点
                return node;
            } else { // 尚未找到该节点,更新k的值
                k--;
            }
        }
        if (node.left != null) { // 左
            kthNode = kthLargestCore(node.left);
        }
        return kthNode;
    }
}

面试题55 -- 二叉树的深度

题目一:二叉树的深度

输入一颗二叉树的根节点,求该树的深度。从根节点到叶节点依次经过的节点(含根、叶节点)形成树的一条路径,最长路径的长度为树的深度。

题目过于简单,直接贴代码略过

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {
    public int maxDepth(TreeNode root) {
        if (root == null) {
            return 0;
        }
        return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1; 
    }
}

题目二:平衡二叉树

输入一颗二叉树的根节点,判断该树是不是平衡二叉树。如果某二叉树中任意节点的左、右子树的深度相差不超过1,那么它就是一颗平衡二叉树。

interview55.gif

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {

    private static final int DEPTH_NOT_BALANCE = -1;

    public boolean isBalanced(TreeNode root) {
        return getDepthOrBalance(root) != DEPTH_NOT_BALANCE;
    }

    private int getDepthOrBalance(TreeNode node) {
        if (node == null) {
            return 0;
        }
        int leftDepth = getDepthOrBalance(node.left);
        if (leftDepth == DEPTH_NOT_BALANCE) {
            return leftDepth;
        }
        int rightDepth = getDepthOrBalance(node.right);
        if (rightDepth == DEPTH_NOT_BALANCE) {
            return rightDepth;
        }
        int diff = leftDepth - rightDepth;
        if (diff > 1 || diff < -1) {
            return DEPTH_NOT_BALANCE;
        }
        return Math.max(leftDepth, rightDepth) + 1;
    }

}

面试题56 - 数组中数字出现的次数

题目一:数组中只出现一次的两个数字

一个整型数组里除两个数字之外,其他数字都出现了两次,请写程序找出这两个只出现一次的数字。要求时间复杂度是O(n),空间复杂度是O(1)。

最简单的解决办法是通过额外空间的哈希表存储每个数字出现的次数,最后找到只出现一次的数字,但是题目明确要求空间复杂度是O(1),所以不可行。

解决这个问题的前置知识是:两个相同的数字的异或结果为0

如图,以5为例,5异或5(写作5^5)=0

image.png

而题目的要求是有两个只出现一次的数字,以数组{2,4,3,6,3,2,5,5}为例,由于以上提到的异或的特性,将数组中的数组全部异或一遍后,得到的结果其实就是这两个唯一数字异或的结果,即4^6

image.png

4^6的结果中为1的位,就是4和6的二进制中那些不相同的位,我们随便取异或结果中为1的某一位,根据这一位是1还是0将数组中的数字分为两组,每组中相同的数字会因为异或特性而消消乐,最后每组剩下的数字就是这两个单独数字。

class Solution {
    public int[] singleNumbers(int[] nums) {
        if (nums == null || nums.length < 2) {
            return null;
        }
        int xor = xorAll(nums);
        int mask = findMask(xor);
        int singleNumber1 = 0;
        int singleNumber2 = 0;
        for(int num : nums) {
            if ((mask & num) == 0) {
                singleNumber1 ^= num;
            } else {
                singleNumber2 ^= num;
            }
        }
        return new int[]{singleNumber1, singleNumber2};
    }

    private int xorAll(int[] nums) {
        int xor = nums[0];
        for (int i = 1; i < nums.length; i++) {
            xor ^= nums[i];
        }
        return xor;
    }

    private int findMask(int xor) {
        int mask = 1;
        while((xor & mask) == 0) {
            mask <<= 1;
        }
        return mask;
    }
}

题目二:数组中唯一只出现一次的数字

在一个数组中除一个数字只出现一次之外,其他数组都出现了三次。请找出那个只出现一次的数字。

比较好理解的方法

因为其它数字都出现了三次,那么用上一题中异或的特性就无法解决了,但是通过对异或特性的扩展,我们可以想到,可以将所有数字的每一位二进制相加。

某一个数字出现了三次,那么相加后,这个数字二进制表示中各个1所在的位置出现1的次数也肯定正好是3次。

image.png

而对于只出现一次的数字,它的各个位置必然会导致该位出现1的次数%3=1

image.png

因此可以看出,我们的目标就是找出 各个比特位1的个数不能被3整除的那些位,它们拼接起来自然就是只出现一次的数字。

实现上,首先通过一个list维护各个比特位中1的个数

class MySolution {

    public int singleNumber(int[] nums) {
        List<Integer> bitCounts = new ArrayList<>();
        for(int num : nums) {
            addBit(num, bitCounts); // 算出各个比特位数字之和,即1的个数
        }
        int singleNumber = 0;
        int mask = 1;
        for (int bitCount : bitCounts) {
            if (bitCount % 3 != 0) {
                singleNumber += mask;
            }
            mask <<= 1;
        }
        return singleNumber;
    }

    private void addBit(int num, List<Integer> bitCounts) {
        int i = 0;
        while(num != 0) {
            int bit = num & 1;
            if (i == bitCounts.size()) {
                bitCounts.add(bit);
            } else {
                bitCounts.set(i, bitCounts.get(i) + bit);
            }
            i++;
            num >>= 1;
        }
    }
}

涉及到状态机、位运算的方法

详见leetcode中 k神的解法

基于前面的结论,每个比特位中1的个数最终除以3的余数只有三种状态,0、1、2,这三种状态的相互转化可以通过状态机表达如下

image.png

上一种方式我们通过一个list保存每个bit位的和,而如果考虑上图状态机的话,其实只需要存储0、1、2这三种情况, 能否将每个比特为的和的结果压缩到一个比特位,使得整个求出的结果存在一个int整数里? 因为有2这个数字在,显然是不可能的。

image.png

于是想到 用两个数字 去分别存 各个比特为求和模3后 第一位和第二位比特位的数字。这样我们就用两个int数字合成表示某一位的结果(有点绕了)。

image.png

通过对状态机中ones、twos各种情况的分析以及一些 数学特性 ,得到了如下的公式(公式计算过程可以参考K神),最后从一位推广到整个int,即从上图中虚线框中的10的运算公式,推导出00、10、00、整个ones和twos都可以通过这种逻辑运算方式算出。

one = one ^ n & ~two
two = two ^ n & ~one

image.png

image.png

如图,从结果中可以看出,只要输入是符合题目要求的 只有一个数字出现 一次 ,那么其结果求和取模一定是0或1,不可能是2(比如下图,再加一个6就出现2了),所以ones、twos中的twos最终结果一定是0,所以只返回ones即是结果。

image.png

class Solution {
    public int singleNumber(int[] nums) {
        int ones = 0;
        int twos = 0;
        for (int num : nums) {
            // 下面这两行相当于三进制的异或
            ones = ones ^ num & ~twos;
            twos = twos ^ num & ~ones;
        }
        return ones;
    }
}

面试题57 -- 和为s的数字

题目一:和为s的两个数字

输入一个递增排序的数组和一个数字s,在数组中查找两个数,使得它们的和正好是s。如果有多对数字的和等于s,则输出任意一对即可。

比较简单的一道题目,因为输入的数组是 有序 递增的,所以使用双指针分别指向第一个和最后一个位置,然后分三种情况:

  1. 两个指针指向的数字之和刚好就是target,则返回
  2. 两个指针指向的数字之和小了,则右移左指针
  3. 两个指针指向的数字之和大了,则左移右指针

image.png

为什么会想到这种方法?首先要找到和为指定值的两个数字,那肯定需要两个指针吧。为什么想到分别指向第一个和最后一个?因为这样初始化双指针的时候能够 拎得清,如果求和结果小了,那只能右移左指针,右指针已经贴边了,没法移了,另一种情况同理。

class Solution {
    public int[] twoSum(int[] nums, int target) {
        if (nums == null || nums.length == 0) {
            return new int[0];
        }
        int i = 0, j = nums.length-1;
        while(i < j) {
            int twoSum = nums[i] + nums[j];
            if (twoSum == target) {
                return new int[]{nums[i], nums[j]};
            } else if (twoSum < target) {
                i++;
            } else {
                j--;
            }
        }
        return new int[0];
    }
}

题目二:和为s的连续整数序列

输入一个正数s,打印出所有和为s的连续整数序列(至少含有两个数)。例如,输入15,由于1+2+3+4+5=4+5+6=7+8=15,所以打印出3个连续序列1~5、4~6、7~8。

如下图,以target=9为例,由于是寻找连续的整数序列,这符合 滑动窗口 的特性,我们定义左右边界,初始化为1和2,然后根据sum与target的比较结果不断动态调整左右边界。

interview57.gif

  • 当sum<target时,说明窗口包含的数太少了,则右边界右移,扩充窗口(为什么不向左扩充?因为我们一直是以向右的方向扩充,所以左边界是刚收缩过来的,向左扩充可能的结果之间必然已经覆盖到了)
  • 当sum>target时,说明窗口包含的数太多了,则左边界右移,收缩窗口
  • 当sum==target时,说明找到了一个合适的序列,加入结果,右边界右移,继续以上步骤

执行的出口,当左边界已经超过target/2时,
∵ right > left and left > target/2
∴ left+right > target

光left+right就超过了target,更不用说from left to right的sum了,所以终止循环。

class Solution {
    public int[][] findContinuousSequence(int target) {
        int left = 1, right = 2;
        int sum = left + right;
        List<int[]> result = new ArrayList<>();
        int halfTarget = target >> 1;
        while (left <= halfTarget) {
            if (sum == target) {
                addContinuousSequence(result, left, right);
                right++;
                sum += right;
                continue;
            }
            if (sum < target) {
                right++;
                sum += right;
            } else {
                sum -= left;
                left++;
            }
        }
        return result.toArray(new int[0][]);
    }

    private void addContinuousSequence(List<int[]> result, int left, int right) {
        int[] sequence = new int[right - left + 1];
        int index = 0;
        for (int i = left; i <= right; i++) {
            sequence[index++] = i;
        }
        result.add(sequence);
    }
}

面试题58 -- 翻转字符串

题目一:翻转单词顺序

输入一个英文句子,翻转句子中单词的顺序,但单词内字符的顺序不变。为简单起见,标点符号和普通字母一样处理。例如输入字符串"I am a student.",则输出"student. a am I"

完全倒序的情况可以通过从后向前遍历输出,本题中的是需要在倒序的情况下保持完整单词的部分顺序不变,所以需要利用 双指针 在从后向前遍历的时候判断空格作为单词的分界,然后控制输出的顺序,如下图。

interview58.gif

class Solution {
    public String reverseWords(String s) {
        s = s.trim();
        StringBuilder sb = new StringBuilder();
        int i = s.length()-1, j = s.length()-1;
        while(i >= 0) {
            while(i >= 0 && s.charAt(i) != ' ') i--;
            sb.append(s.substring(i+1, j+1)).append(' ');
            while(i >= 0 && s.charAt(i) == ' ') i--;
            j = i;
        }
        return sb.toString().trim();
    }
}

注:和书中的解法不同的是,书中使用C++语言,C++中的字符串就是char数组,所以可以实现原地翻转(空间复杂度O(1)),Java中除非输入是char数组,否则输入String的话,由于String的不可变特性,我们无法对其内部的char数组进行操作,所以只能借助新的StringBuilder来翻转,空间复杂度变成了O(1)。

题目二:左旋转字符串

字符串的左旋转操作是把字符串前面的若干个字符转移到字符串的尾部。请定义一个函数实现左旋转操作的功能。比如,输入字符串"abcdefg"和数字2,该函数将返回左旋转两位得到的结果"cdefgab"。

此题比较简单,不管是使用String自带的split方法,还是通过遍历先添加n->len(s)部分的字符串,后添加0->n的部分,都比较好实现。

class Solution {
    public String reverseLeftWords(String s, int n) {
        if (s == null || s.length() == 0 || n >= s.length()) {
            return "";
        }
        StringBuilder sb = new StringBuilder();
        for(int i = n; i < s.length(); i++) {
            sb.append(s.charAt(i));
        }
        for (int i = 0; i < n; i++) {
            sb.append(s.charAt(i));
        }
        return sb.toString();
    }
}

书中因为是C++实现的方式,可以通过三次字符串的翻转实现原地的旋转操作(是题目一的扩展)。

  1. 翻转n->len(s)的部分
  2. 翻转0->n的部分
  3. 整个字符串翻转

面试题59 -- 队列的最大值

题目一:滑动窗口的最大值

给定一个数组和滑动窗口的大小,请找出所有滑动窗口里的最大值。例如,如果输入数组{2,3,4,2,6,2,5,1}及滑动窗口的大小3,那么一共存在6个滑动窗口,它们的最大值分别为{4,4,6,6,6,5}。

书中解法称为单调队列,首先最直观的解决办法是从前向后遍历所有指定大小的窗口,每次对每个窗口都计算一次当前窗口中最大的值。这样做的时间复杂度为O(nums数组大小 * 窗口大小)。

image.png

我们很容易的可以感觉到其中必然存在着大量重复的计算,例如图中计算窗口2、3、4时,已经知道了4是最大的,4左边的2和3已经不可能有希望成为窗口中的最大值了。因为它们俩在左边,本身就会比当前最大值4更早的从窗口中滑出,不存在耗到4退出轮到它们当最大值的可能性。

image.png

基于以上的分析,我们可以发现,我们可以用一个双向队列deque来存储最大值的候选人:

  1. 当从右边最新滑入一个数字时,从deque中左边弹出所有比这个数字小的数,因为这些数已经没有成为下一任老大的可能性了
  2. 基于以上的特性,可以看出这个deque其实是一个非严格单调递减的序列(即从大到小的序列)

interview59.gif

因此我们的算法如图遍历所有滑动窗口,维护双向队列deque,在遍历的过程中关注三件事:

  1. 当从右边最新滑入一个数字时,从deque中左边弹出所有比这个数字小的数
  2. 当左边滑出一个数字时,关注deque是否也需要弹出相关的数字(所以deque中需要保存数字的下标,而不是数字,否则这种情况没法从deque中找到对应关系)
  3. 当滑动窗口成形时,添加当前队列最左边的值(因为是非严格单调递减的序列,所以是最大值)到结果集。
    public int[] maxSlidingWindow(int[] nums, int k) {
        if (nums == null || nums.length == 0 || k < 0 || nums.length < k) {
            return new int[0];
        }
        int[] res = new int[nums.length - k + 1];
        int index = 0;
        Deque<Integer> deque = new LinkedList<>();
        int left = 1 - k;
        int right = 0;
        while(right < nums.length) {
            while (!deque.isEmpty() && nums[deque.getLast()] < nums[right]) deque.removeLast();
            deque.addLast(right);
            if (!deque.isEmpty() && deque.getFirst() < left) deque.removeFirst();
            if (left >= 0) res[index++] = nums[deque.getFirst()];
            left++;
            right++;
        }
        return res;
    }

题目二:队列的最大值

请定义一个队列并实现函数max得到队列里的最大值,要求函数max、push_back和pop_front的时间复杂度都是O(1)。

基本上是利用了上一题的结论,在MaxQueue中维护两个队列,数据队列dataDeque与最大值队列maxDeque,最大值队列依然是一个 非严格递减 的队列,和上一题的原因一样,当一个数字被加入队列(上一题是滑动窗口)后,那么队列里比这个数字小的数没有成为下一任老大的可能性,因为它们必定会先从队列(滑动窗口)中出队(滑出),只有后来的更小的值才有可能。

另一方面出队列时,判断下当前弹出的是否是最大值(比较两个队列的头部),依此决定是否也要弹出maxDeque。

public class MaxQueue {

    private final Deque<Integer> dataDeque;

    private final Deque<Integer> maxDeque;

    public MaxQueue() {
        dataDeque = new LinkedList<>();
        maxDeque = new LinkedList<>();
    }

    public int max_value() {
        if (maxDeque.isEmpty()) {
            return -1;
        }
        return maxDeque.getFirst();
    }

    public void push_back(int value) {
        while(!maxDeque.isEmpty() && maxDeque.getLast() < value) {
            maxDeque.removeLast();
        }
        maxDeque.addLast(value);
        dataDeque.addLast(value);
    }

    public int pop_front() {
        if (dataDeque.isEmpty()) {
            return -1;
        }
        if (!maxDeque.isEmpty() && maxDeque.getFirst().equals(dataDeque.getFirst())) {
            maxDeque.removeFirst();
        }
        return dataDeque.removeFirst();
    }
}

面试题60 -- n个骰子的点数

题目:把n个骰子扔在地上,所有骰子朝上一面的点数之和为s。输入n,打印出s的所有可能的值出现的概率。

DFS的解决方法

n个骰子投掷的结果,第一个骰子可能的取值为1、2、3、4、5、6,在第一个骰子的结果基础上,第二个骰子又依次可以取值1-6,因此这个问题可以表达为一颗DFS树。

image.png

因此可以用DFS来解决这个问题。首先计算可能出现的概率需要分别知道分子和分母。

  • 分母:扔n个骰子所有可能性的排列组合,通过高中排列组合的知识可以知道,扔n个骰子的所有可能性的组合共有6^n
  • 分子:每个点数和可能出现的次数,这个是我们需要求解的部分

DFS的几个要素:

  1. 层数,即当前统计到了第几个骰子
  2. 临时结果,即统计到当前这个骰子的时候sum为多少
  3. 返回值,即每个点数sum出现的次数,本题是一个数组

DFS递归的出口:当前 层数 到达n,即已经统计完n个骰子的点数sum,在返回值数组中给相应的sum值统计+1

public class Solution1 {

    private static final int MAX_PIPS = 6;

    public double[] dicesProbability(int n) {
        if (n < 1) {
            return null;
        }
        // n个骰子,最大的sum是n个6,最小的sum是n个1,所以可能的sum有 6n-n+1 个
        int[] occurrences = new int[MAX_PIPS * n - n + 1];
        probability(n, 0, 0, occurrences);
        double[] probabilities = new double[MAX_PIPS*n - n + 1];
        double allProbabilities = Math.pow(MAX_PIPS, n);
        for(int i = 0; i < occurrences.length; i++) {
            probabilities[i] = occurrences[i] / allProbabilities;
        }
        return probabilities;
    }


    private void probability(int n, int cur, int sum, int[] occurrences) {
        if (cur == n) { // 递归出口
            incrementProbabilities(occurrences, n, sum);
            return;
        }
        for (int i = 1; i <= MAX_PIPS; i++) {
            // cur这次的骰子可能摇到1,2,3..6,都去试试
            probability(n, cur+1, sum+i, occurrences);
        }
    }


    private void incrementProbabilities(int[] occurrences, int n, int sum) {
        occurrences[sum - n]++;
    }
}

动态规划的解法

DFS的解法是遍历了n个骰子所有可能的排列组合,就像奇异博士穿梭于所有的平行宇宙,走遍了所有的可能性得到的结果。但是如果我们用自顶向下的角度去分析,就会发现,DFS这种方法存在大量重复的计算。

假如n=2,你当前正在分析第二个骰子的结果,第二个骰子可能摇出的点数为1-6,两个骰子可能的sum值范围为2-12。

我们分别对2到12的可能性进行分析,用possibility(n, num)表示n个骰子投出sum为num的可能次数:

  • 2,只可能是 在一个骰子的时候摇出点数sum为1时,当前骰子摇出点数1,所以sum为2的可能性possibility(2, 2) = possibility(1, 1)
  • 3,两种情况
    • 1个骰子的时候点数sum为1 时,当前摇出2
    • 1个骰子的时候点数sum为2 时,当前摇出1 所以 possibility(2, 3) = possibility(1, 1) + possibility(1, 2)
  • ...

image.png

通过定一个一个如图所示的二维数组dp[i][j],横坐标i为骰子的数量,纵坐标j为当前数量个骰子出现某个点数sum的次数,写出dp公式

image.png

interview61.gif

public class Solution2 {

    public double[] dicesProbability(int n) {
        if (n < 1) {
            return null;
        }
        int[][] dp = new int[n][6*n+1];
        for (int num = 1; num <= 6; num++) {
            dp[0][num] = 1;
        }
        for (int times = 1; times < n; times++) {
            for(int num = times+1; num <= 6*n; num++) {
                for(int pips = 1; pips <= 6; pips++) {
                    if (pips >= num) {
                        break;
                    }
                    dp[times][num] += dp[times-1][num-pips];
                }
            }
        }
        double allPossibilities = Math.pow(6, n);
        double[] res = new double[6*n-n+1];
        int index = 0;
        for (int num = n; num <= 6*n; num++) {
            res[index++] = dp[n-1][num] / allPossibilities;
        }
        return res;
    }

}

通过对dp数组分析,我们发现,其实只有两行数组是我们需要的,所以我们可以进一步优化存储空间,将二维数组dp转为两个一维数组。

public class Solution2_2 {

    public double[] dicesProbability(int n) {
        if (n < 1) {
            return null;
        }
        int[] dp = new int[6*n+1];
        for (int num = 1; num <= 6; num++) {
            dp[num] = 1;
        }
        for (int times = 1; times < n; times++) {
            int[] tmp = new int[6 * n + 1];
            for(int num = times+1; num <= 6*n; num++) {
                for(int pips = 1; pips <= 6; pips++) {
                    if (pips >= num) {
                        break;
                    }
                    tmp[num] += dp[num-pips];
                }
            }
            dp = tmp;
        }
        double allPossibilities = Math.pow(6, n);
        double[] res = new double[6*n-n+1];
        int index = 0;
        for (int num = n; num <= 6*n; num++) {
            res[index++] = dp[num] / allPossibilities;
        }
        return res;
    }
}

面试题61 -- 扑克牌中的顺子

题目:从扑克牌中随机抽5张牌,判断是不是一个顺子,即这5张牌是不是连续的。2~10为数字本身,A为1,J为11,Q为12,K为13,而大、小王可以看成任意数字。

设大小王的数字为0,判断是否为顺子,假设我们抽到的五张牌是{4,5,1,3,0}:

  • 首先需要经过 排序,排序过后是{0,1,3,4,5}
  • 统计数组中0的个数
  • 统计数组中数字的gap,判断0能否足够填充这些gap
  • 注意数字中有重复数字的情况
  • 注意{0,0}这样的情况也算作顺子

interview61_.gif

class Solution {
    public boolean isStraight(int[] nums) {
        if (nums == null || nums.length == 0) {
            return false;
        }
        Arrays.sort(nums);
        int countOfZero = 0;
        for(int i = 0; i < nums.length; i++) {
            if (nums[i] == 0) {
                countOfZero++;
                continue;
            }
            if (i > 0 && nums[i-1] > 0 && nums[i] > 0) {
                int gap = nums[i] - nums[i-1] - 1;
                if (gap == -1) { // 两个重复数字(对子)
                    return false;
                } else if (gap > countOfZero) { // 0不够用了
                    return false;
                } else {
                    countOfZero -= gap; // 0够用,消耗掉
                }
            }
        }
        return true;
    }
}

面试题62 -- 圆圈中最后剩下的数字(约瑟夫环)

题目:0,1,...,n-1 这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字。求出这个圆圈里剩下的最后一个数字。

方法1,模拟链表

最直观的解决办法,就是模拟题目描述的删除步骤,先构建一个链表,然后在循环中删除(注意遍历到末尾的时候重新从头部开始)。时间复杂度O(mn),在LeetCode上执行会超时。

class Solution {
    public int lastRemaining(int n, int m) {
        if(n <= 0 || m <= 0) {
            return -1;
        }
        LinkedList<Integer> link = new LinkedList<>();
        for (int i = 0; i < n; i++) {
            link.add(i);
        }
        Iterator<Integer> iter = link.iterator();
        while(link.size() > 1) {
            for (int i = 0; i < m; i++) {
                if(!iter.hasNext()) {
                    iter = link.iterator();
                }
                iter.next();
            }
            iter.remove();
        }
        return link.get(0);
    }
}

此题还有数组的解法,即建立bool数组,在遍历的过程中标记每个被踢出的元素为false,只有为true的元素才计数+1,直到剩下的数字数量为1时。

方法2,动态规划,数学解法

“定义n个数字、每次删除第m个数字”这样的结果为f(n,m),通过推理分析得到f(n,m)与f(n-1,m)的关系,从而有了状态转移方程,进而求解。

以n=5,m=3即f(5,3)为例,第一次删除后的结果如下,第一次删除后结果为长度为4的循环链表,我们需要计划它和f(4,3)的关系

image.png

长度为4的普通循环链表如下图,它的每一位与上一步得到的循环链表的每一项的对应关系通过计算分析可以得到

image.png

有了这个映射关系,那么“求解n个数字最后剩下的结果”这个问题,就可以转移为“求解n-1个数字最后剩下的结果”,然后对这个结果进行如上转换(因为f(n-1,m)不论你是多少,0、1、2、3,以上公式都可以映射到3、4、0、1中对应的数字)

image.png

和斐波那契额一样,有了状态转移方程,就可以很容易写出代码

class Solution {
    public int lastRemaining(int n, int m) {
        if (n < 1 || m < 1) {
            return -1;
        }
        int last = 0;
        for (int i = 2; i <= n; i++) {
            last = (last + m) % i;
        }
        return last;
    }
}

面试题63 -- 股票的最大利润

题目:假设把某股票的价格按照时间先后顺序存储在数组中,请问买卖该股票一次可能获得的最大利润是多少?例如,一只股票在某些时间节点的价格为{9,11,8,5,7,12,16,14}。如果我们能再价格为5的时候买入并在价格为16时卖出,则能收获最大的利润11。

一个经典的动态规划问题,首先需要自上而下的分析,设dp[i]为第i天能够获取的最大利润,通过分析可以发现,第i天能够获取的最大利润,要么是前i-1天能够获取的最大利润,要么是前i-1天内最低价时买入,第i天卖出。用状态转移方程可以表达为:

image.png

可以发现dp[i]只取决于dp[i-1],所以可以进一步优化存储空间,用一个变量profile代替dp[i]

image.png

class Solution {
    public int maxProfit(int[] prices) {
        if (prices == null || prices.length == 0) {
            return 0;
        }
        int minPrice = prices[0];
        int profit = 0;
        for (int i = 1; i < prices.length; i++) {
            profit = Math.max(profit, prices[i] - minPrice);
            minPrice = Math.min(minPrice, prices[i]);
        }
        return profit;
    }
}

面试题64 -- 求 1+2+...+n

题目:求 1+2+...+n,要求不能使用乘除法,for,while,if,else,swtich,case等关键字及条件判断语句(A?B:C)。

很无聊的一道题,由于语言特性,利用bool运算的短路特性作为递归的终止条件

public class Solution {
    public int sumNums(int n) {
        boolean x = n > 1 && (n += sumNums(n - 1)) > 0;
        return n;
    }
}

面试题65 -- 不用加减乘除做加法

题目:写一个函数,求两个整数之和,要求在函数体内不得使用+、-、x、÷四则运算符号。

不能用加减乘除,那我们能用的只有位运算了,通过对加法的特性的分析,我们可以得到如下结论:

  1. a+b,如果不考虑进位的话,其实等同于异或运算(1+1=0,0+1=1)
  2. a+b的进位,可用通过求与左移得到(1+1进位1,0+0进位0,0+1进位0)

综上,可以得到如下代码

class Solution {
    public int add(int a, int b) {
        int sum = 0;
        int carry = 0;
        do {
            sum = a ^ b;
            carry = (a & b) << 1;
            a = sum;
            b = carry;
        } while (b != 0);
        return sum;
    }
}

面试题66 -- 构建乘积数组

题目:给定一个数组A[0,1,...,n-1],请构建一个数组B[0,1,...,n-1],其中B中的元素B[i]=A[0]*A[1]*...*A[i-1]*A[i+1]*...*A[n-1]。不能使用除法

题目如果没有限制不能使用除法,那么就可以求出A[0]*...*A[n-1],然后对于每个B[i],都对乘积➗A[i],同时注意下A[i]==0的情况即可。

但是现在题目要求不可以用除法,那我们还可以用乘法。

image.png

从上到下分别计算 A0、A0*A1、A0*A2*A3、...,记为C[i]

从下到上分别计算 A[n-1]、A[n-1]*A[n-2]、A[n-1]*A[n-2]*A[n-3]、...,记为D[i]

class Solution {
    public int[] constructArr(int[] a) {
        int[] c = new int[a.length];
        for(int i = 0; i < a.length; i++) {
            if (i == 0) {
                c[i] = 1;    
            } else {
                c[i] = c[i-1] * a[i-1];
            }
        }
        int[] d = new int[a.length];
        for (int i = a.length-1; i >= 0; i--) {
            if (i == a.length-1) {
                d[i] = 1;
            } else {
                d[i] = d[i+1] * a[i+1];
            }
        }
        int[] b = new int[a.length];
        for (int i = 0; i < a.length; i++) {
            b[i] = c[i] * d[i];
        }
        return b;
    }
}