《剑指offer》各编程题Java版分析 -- 优化时间和空间效率

260 阅读19分钟

面试题39 -- 数组中出现次数超过一半的数字

题目:数组中有一个数字出现的次数超过数组长度的一半,请找出这个数字。例如,输入一个长度为9的数组{1,2,3,2,2,2,5,4,2}。由于数字2在数组中出现了5词,超过数组长度的一半,因此输出2。

通过列举出若干种情况之后,我们可以归纳总结出,数组中出现的次数超过一半的数字一定是数组排序之后的中位数(median),因此本题变为求解数组排序之后的中位数。而求解中位数最直观的办法就是对数组进行排序,然后取中间的数字返回,时间复杂度是O(nlogn)。那么有没有更快的办法呢?

基于parititon函数的时间复杂度O(n)的算法(需要变动输入的数组)

我们注意到,我们需要的是排序之后的中位数,即len/2位置的数字,而并不需要关注排序这之前那坨比它小的数字的顺序、这之后那坨比它大的数字的顺序,于是我们想到了快速排序(quick sort)的思想,对数字进行分堆(partition)。快排的思想是把每个堆都递归往下一直分到不可再分的情况,那么总体就是排序好的了,我们只需要进行的到一半,即当分完[0,len/2)(len/2, len)之后就达到目的了。

image.png

image.png

public int majorityElement(int[] nums) {
    if (nums == null || nums.length == 0) {
        throw new IllegalArgumentException("nums should not be empty");
    }
    int middle = nums.length/2;
    int index = partition(nums, 0, nums.length);
    int start = 0;
    int end = nums.length;
    while(index != middle) {
        if (middle < index) {
            end = index;
        } else {
            start = index + 1;
        }
        index = partition(nums, start, end);
    }
    return nums[index];
}

// return the index of pivot
private int partition(int[] nums, int start, int end) {
    int pivot = nums[end-1];
    int firstOfLarger = start;
    for (int i = start; i < end-1; i++) {
        if (nums[i] <= pivot) {
            swap(nums, i, firstOfLarger);
            firstOfLarger++;
        }
    }
    swap(nums, firstOfLarger, end-1);
    return firstOfLarger;
}

private void swap(int[] nums, int i, int j) {
    int temp = nums[i];
    nums[i] = nums[j];
    nums[j] = temp;
}

关于parition的时间复杂度,《算法导论》里有分析过,不对所有元素进行排序,而只是parition到某一个位置,时间复杂度为O(n),书上有更严谨的证明公式。这里笔者可以粗浅的理解下:
虽然第一次partition就需要遍历整个数组一次,但是从第二次开始就只需要处理上次的一半(要么parition左半边部分,要么右半部分),然后第三次则又是一半的一半...这样算下来后面几次不会对整体的时间复杂度造成量级的影响,所以是O(n)

摩尔投票法

虽然本题的算法时间复杂度O(n),已经优化到最优,但是这种解法的问题是需要变动输入的int[] nums数组,在真实使用场景下变更请求者输入的参数一般不会被接受。所以有了更巧妙的解决办法--摩尔投票法。

关于摩尔投票法的原理,知乎大神有一个比较好的解释 here

摩尔算法的核心的 对拼消耗,假设你在玩WAR3的忍村大战,或者你是在三国乱世的一方势力(比如曹操),只要你的兵力足够强(指超过总数的一半),那么不论是怎么开战不论谁先打谁,小兵一换一最后赢家一定是你。

image.png

public int majorityElement(int[] nums) {
    if (nums == null || nums.length == 0) {
        throw new IllegalArgumentException("nums should not be empty");
    }
    int winner = nums[0];
    int count = 1;
    for (int i = 1; i < nums.length; i++) {
        if (nums[i] == winner) { // winner's strength up
            count++;
        } else {
            count--; // winnner's strength down
            if (count == 0) { // winner is down, new winner come up
                winner = nums[i];
                count = 1;
            }
        }
    }
    return winner;
}

面试题40 -- 最小的k个数

题目:输入n个整数,找出其中最小的k个数。例如,输入4、5、1、6、2、7、3、8 这8个数字,则最小的4个数字是1、2、3、4

基于parition的时间复杂度O(n)的算法,需要修改输入的数组

和上题一样,当我们求最小的k个数的时候,我们不需要知道这最小的k个数的顺序是怎么样的,也不需要知道后面那些数的顺序是怎么样的。所以这种思路很适合快排的parition分区思想。从这个角度理解,其实本题是上一题的扩展,上一题求中位数即最小的len/2个数,本题扩展为求最小的k个数。

public int[] getLeastNumbers(int[] arr, int k) {
    if (arr == null || arr.length == 0 || arr.length < k || k == 0) {
        return new int[0];
    }
    int index = partition(arr, 0, arr.length-1);
    int start = 0;
    int end = arr.length-1;
    while(index != k-1) {
        if (index > k-1) {
            end = index - 1;
        } else {
            start = index + 1;
        }
        index = partition(arr, start, end);
    }
    return Arrays.copyOf(arr, k);
}

private int partition(int[] arr, int start, int end) {
    int firstOfBigger = start;
    int pivot = arr[end];
    for (int i = start; i < end; i++) {
        if (arr[i] <= pivot) {
            swap(arr, i, firstOfBigger);
            firstOfBigger++;
        }
    }
    swap(arr, firstOfBigger, end);
    return firstOfBigger;
}

private void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

基于大顶堆的算法,时间复杂度O(nlogk)

大顶堆这种数据结构设计就是用来求topK数据的,相对于上面解法不同的是,时间复杂度升高为O(nlogk)带来的是不需要调整输入的数组,以及适合处理海量数据。为什么适合处理海量数据?假设输入的数组是一个巨大的有1T的数字,想要求top10,上面的方法根本无法将这些加载到内存,而大顶堆永远只维护着到当前为止的top10。

public class Solution {

    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0 || arr == null || arr.length == 0) {
            return new int[0];
        }
        Queue<Integer> pq = new PriorityQueue<>((v1, v2) -> v2 - v1);
        for (int num : arr) {
            if (pq.size() < k) { // build max heap
                pq.offer(num);
            } else {
                if (num < pq.peek()) { // only when num is less than heap top, replace root and adjust
                    pq.poll();
                    pq.offer(num);
                }
            }
        }
        int[] res = new int[pq.size()];
        int idx = 0;
        for (int num : pq) {
            res[idx++] = num;
        }
        return res;
    }
}

关于大顶堆/小顶堆

手写大顶堆(小顶堆类似)时,首先用数组承载二叉树,然后分为两步:

  1. 构建大顶堆(buildHeap),从最后一个非根节点(heap/2-1)开始进行调整(adjust),一直调整到根节点,初始化整个大顶堆,需要时间log(k)
  2. 每当需要有数加入时,将根节点替换为输入的数,然后对根节点进行调整

调整的过程就是从左右孩子中找到更大的节点,与之交换,交换后在新位置再进行调整

基于红黑树的方法,时间复杂度O(nlogk)

与大顶堆的想法类似,维护一个大小为k的红黑树,因为数字有可能重复,所以需要用Map的数据结构维护num -> count of num。

public int[] getLeastNumbers(int[] arr, int k) {
    if (k == 0 || arr == null || arr.length == 0) {
        return new int[0];
    }
    TreeMap<Integer, Integer> map = new TreeMap<>();
    int cnt = 0;
    for (int num : arr) {
        if (cnt < k) {
            map.put(num, map.getOrDefault(num, 0) + 1);
            cnt++;
            continue;
        }
        int maxInMap = map.lastEntry().getKey();
        if (num < maxInMap) {
            map.put(num, map.getOrDefault(num, 0) + 1);
            countDownOrRemove(map, maxInMap);
        }
    }
    int[] result = new int[k];
    int idx = 0;
    for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
        int freq = entry.getValue();
        for (int i = 0; i < freq; i++) {
            result[idx++] = entry.getKey();
        }
    }
    return result;
}

private void countDownOrRemove(TreeMap<Integer, Integer> map, int num) {
    if (map.get(num) == 1) {
        map.remove(num);
    } else {
        map.put(num, map.get(num) - 1);
    }
}

面试题41 -- 数据流中的中位数

题目:如何得到一个数据流的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均数。

此题有两个方法,一个是addNum,另一个是findMedian,一上来先想到的最简单的方法就是每次查询都对数组进行排序,这样的话时间复杂度为:

  • addNum--O(1)
  • findMedian--O(nlogn)

寻找中位数更快的方式可以从面试题39中看到,用parition的方法,时间复杂度为:

  • addNum -- O(1)
  • findMedian -- O(n)

如果不是在查询时,而是在插入时就要保持数组的顺序,则需要插入排序,时间复杂度为:

  • addNum -- O(n),因为每次插入都需要找到num的位置并且把其后的数组向后挪一位
  • findMedian -- O(1),只需要在排好序的数组中找中间值

如果维护数字的不是一个普通的的数组,而是有层级关系的二叉搜索树,那么插入的节点的时间复杂度可以减低为:

  • addNum -- O(logn)
  • findMedian -- O(1)

如果是普通的二叉搜索树,有一定可能在插入的过程中出现极度不平衡最后演变为链表,那就又回到O(n)复杂度了,因此需要用平衡二叉树/红黑树

平衡二叉树的思路就是用树的层级结构来降低add操作的复杂度,那么大顶堆/小顶堆也同样能够做到。维护两个堆,左边是比较小的一半,右边是比较大的另一半,维持左右的大小的平衡

image.png

class MedianFinder {

    private Queue<Integer> smaller;
    private Queue<Integer> larger;

    /** initialize your data structure here. */
    public MedianFinder() {
        smaller = new PriorityQueue<>((v1, v2) -> (v2 - v1));
        larger = new PriorityQueue<>();
    }

    public void addNum(int num) {
        if (smaller.size() == larger.size()) {
            if (larger.size() == 0 || num <= larger.peek()) {
                smaller.offer(num);
            } else {
                int smalliestOfLarger = larger.poll();
                larger.offer(num);
                smaller.offer(smalliestOfLarger);
            }
        } else {
            if (smaller.size() == 0 || num >= smaller.peek()) {
                larger.offer(num);
            } else {
                int largestOfSmaller = smaller.poll();
                smaller.offer(num);
                larger.offer(largestOfSmaller);
            }
        }
    }

    public double findMedian() {
        if (smaller.size() == 0 && larger.size() == 0) {
            return -1;
        }
        if (smaller.size() == larger.size()) {
            return (smaller.peek() + larger.peek()) / 2.0;
        } else {
            return smaller.peek();
        }
    }
}

/**
 * Your MedianFinder object will be instantiated and called as such:
 * MedianFinder obj = new MedianFinder();
 * obj.addNum(num);
 * double param_2 = obj.findMedian();
 */

面试题42 -- 连续子数组的最大和

题目:输入一个整型数组,数组里有正数也有负数。数组中的一个或连续多个数组组成一个子数组。求所有子数组的和的最大值。要求时间复杂度为O(n)。

首先最直观的解法肯定是列出所有连续子数组,时间复杂度为O(n^2)

书中提出的办法是通过举例分析出数组的规律,其实这里可以理解为是一种滑动窗口的方式

同时通过举例列举也可以分析出此题具有 动态规划 的特性,设f(i)为以第i个数字为结尾的子数组的最大和。

  • 重叠子问题:f(i)取决于f(i-1)即第i-1位结尾的子数组的最大和
  • 最优子结构:f(i)取决于f(i-1)即第i-1位结尾的子数组的最大和,是不是负数,需不要从头开始
class Solution {
    public int maxSubArray(int[] nums) {
        if (nums == null || nums.length == 0) {
            return Integer.MIN_VALUE;
        }
        int max = Integer.MIN_VALUE;
        int curSum = 0;
        for (int num : nums) {
            curSum += num;
            if (num > curSum) {
                curSum = num;
            }
            max = Math.max(max, curSum);
        }
        return max;
    }
}

面试题43 -- 1~n整数中1出现的次数

题目:输入一个整数n,求1~n这n个整数的十进制表示中1出现的次数。例如,输入12,1~12这些整数中包含1的数字有1、10、11、和12,1一共出现了5次。

最简单的解法,就是从1开始遍历到n,对其中每个数字都判断包含1的个数,因为需要对每个数字进行判断,所以时间复杂度为O(nlogn),n个数字,每个数字需要判断logn位。

此外还可以想到通过列举出所有数值总结规律

image.png

列举到后面,我们不难看出,

1~991的个数 = 1~91的个数 * 10 + 10
1~9991的个数 = 1~991的个数 * 10 + 100
1~99991的个数 = 1~9991的个数 * 10 + 1000
...
1~999(n个9)中1的个数 = 1~999(n-19)中1的个数 * 10 + 10^n

以数字10086为例,通过上面的规律,我们可以得到1~9999中1的个数,相当于已经数完了1到9999,还剩下10000~10086,首位为1的情况比较特殊,比如这里1的个数是0到86中1的个数+86(因为数了86次1xxxx),即

1~10086中1的个数 = 1~9999中1的个数 + 0~86中1的个数 + 86+1(0~86共有87个数字)

如果是数字50086,则这里为

1~50086中1的个数 = 1~9999中1的个数 * 5 + 10000(因为数了这么多次1xxxx)+ 0~86中1的个数

得到如下代码

class Solution {
    public int countDigitOne(int n) {
        if(n < 1) {
            return 0;
        }
        if(n < 10) {
            return 1;
        }
        int powOfTen = powOfTen(n); // n十进制最高位是多少
        int highNum = n / (int)Math.pow(10, powOfTen); // 最高位的值
        int remain = n % (int)Math.pow(10, powOfTen); // 剩下的部分
        if(highNum == 1) { // 最高位是1的时候特殊处理
            return countDigitOneOfDecimal(powOfTen) + (remain + 1) + countDigitOne(remain);
        } else {
            return countDigitOneOfDecimal(powOfTen) * highNum + (int)Math.pow(10, powOfTen) + countDigitOne(remain);
        }
    }

    private int powOfTen(int n) {
        int res = 0;
        while(n != 0) {
            n /= 10;
            res++;
        }
        return res-1;
    }

    private int countDigitOneOfDecimal(int powOfTen) {
        if(powOfTen == 1) {
            return 1;
        }
        return countDigitOneOfDecimal(powOfTen-1) * 10 + (int)Math.pow(10, powOfTen-1);
    }
}

面试题44 -- 数字序列中某一位的数字

题目:数字以0123456789101112131415...的格式序列化到一个字符序列中。在这个序列中,第5位(从0开始计数)是5,第13位是1,第19位是4,等等。请写一个函数,求任意第n为对应的数字。

本次依然是先列举出尽可能多的数字,然后从中找出规律

image.png

如图,我们需要找到序列中某一位的数字,需要找到:

  1. 这一位对应的整数数字是多少(如index=15,对应的数字是12)
  2. 这一位对应的整数数字的第几位(如index=15,对应的数字12 从左往右 从0数 第1位)

通过列举,可以看出各自分别需要计算的公式如上,得到公式后即可写出如下代码,笔者一开始的case并没有100%通过,最后排查发现是需要注意 int溢出 的问题

class Solution {
    public int findNthDigit(int n) {
        if (n < 0) {
            return -1;
        }
        int index = n;
        int digits = 1; // 1位数字
        while(true) {
            long numbers = countOfDigits(digits); // 计算digits位数字总共有多少个数,注意int溢出问题,要用long
            if (index < numbers*digits) {
                return digitAtIndex(index, digits);
            }
            index -= (numbers*digits);
            digits++;
        }
    }

    private long countOfDigits(int digits) {
        if (digits == 1) {
            return 10;
        }
        return (long)Math.pow(10, digits-1) * 9;
    }

    private int digitAtIndex(int index, int digits) {
        long num = beginNumber(digits) + (index/digits); // 计算digits位数字中index落到哪个整数
        int pos = digits - (index % digits) - 1; // 计算digits在数字的哪一位
        for (int i = 0; i < pos; i++) {
            num /= 10;
        }
        return (int) (num % 10);
    }

    private int beginNumber(int digits) {
        if (digits == 1) {
            return 0;
        }
        return (int)Math.pow(10, digits-1);
    }
}

面试题45 -- 把数组排成最小的数

题目:输入一个正整数数组,把数组里所有数字拼接起来排成一个数,打印能拼接出的所有数字中最小的一个。例如,输入数组{3,32,321},则打印出这3个数字能排成的最小数字321323。

当只有两个数字的时候例如{32,321},所能拼接出的数字是32'321和321'32,相对更小的数字是321'32,可以定义321“小于”32,反证法可以证明,这个小于操作具有传递性,即当321小于32时,如果32又小于3,则321一定也小于3 。

按照我们特殊的排序方式来对输入数组进行排序,具体实现可以通过1)自行实现快排或2)使用JDK库中提供的排序能力,实现代码如下。

class Solution {
    public String minNumber(int[] nums) {
        if (nums == null || nums.length == 0) {
            return "";
        }
        quickSort(nums, 0, nums.length - 1);
        StringBuilder sb = new StringBuilder();
        for (int num : nums) {
            sb.append(num);
        }
        return sb.toString();
    }

    private void quickSort(int[] nums, int start, int end) {
        if (start >= end) {
            return;
        }
        int index = partition(nums, start, end);
        quickSort(nums, start, index-1);
        quickSort(nums, index, end);
    }

    private int partition(int[] nums, int start, int end) {
        int pivot = nums[end];
        int firstOfLarger = start;
        for (int i = start; i < end; i++) {
            if (concatCompare(nums[i],pivot) < 0) {
                swap(nums, i, firstOfLarger);
                firstOfLarger++;
            }
        }
        swap(nums, firstOfLarger, end);
        return firstOfLarger;
    }

    private int concatCompare(int a, int b) {
        return Long.parseLong(""+a+b) - Long.parseLong(""+b+a) > 0 ? 1 : -1;
    }

    private void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }
}

本题的重点在于理解这种特殊的排序,理解其具有传递性的特性,而要使人信服,需要列出详尽的反证法步骤才行,而这更注重的是数学的理解,详细证明可以看 这里

除掉以上数学特性后,剩下的就是手写快排了。

面试题46 -- 把数字翻译成字符串

题目:给定一个数字,我们按照如下规则把它翻译为字符串:0翻译成"a",1翻译成"b",……,11翻译成"l",……,25翻译成"z"。一个数字可能有多个翻译。例如:12258有5种不同的翻译,分别是"bccfi"、"bwfi","bczi","mcfi","mzi"。请编程实现一个函数,用来计算一个数字有多少种不同的翻译方法。

递归的解法

通过对题目的分析以及举例列举,我们可以发现,以12258为例,12258可能翻译成的字符串组合为

  • 1作为字母b,拼接剩下的数字2258所有可能的字符串组合
  • 12作为字母m,拼接剩下的数字258所有可能的字符串组合

得到如下递归执行的调用栈

image.png

可以很容易得到递归的解法如下

class Solution {

    private int result = 0;

    public int translateNum(int num) {
        if (num < 0) {
            return -1; // illegal input
        }
        dfs(0, num+"");
        return result;
    }

    private void dfs(int index, String num) {
        if (index >= num.length()) {
            result+=1;
            return;
        }
        dfs(index+1, num);
        if (index + 1 < num.length() && isLegalAlpha(num.substring(index, index+2))) {
            dfs(index+2, num);
        }
    }

    private boolean isLegalAlpha(String num) {
        if (num.charAt(0) == '0') {
            return false;
        }
        int digit = Integer.parseInt(num);
        return digit >=0 && digit <= 25;
    }
}

以上dfs递归执行的调用栈中我们可以看出,递归的方法存在的大量重复的计算,例如translateNum(58)每次都重复算了一遍,因此想到可以用动态规划迭代来解决重复计算的问题。

dp的解法

通过上面的dfs递归调用分析,我们可以得到如下的状态转移方程

image.png

带入到12258的例子就是如下,

  • 一位的数字1一定能够组成字母,所以取1组成字母后,剩下问题就是2258能有几种不同的字母组合
  • 两位的数字 有可能 组成字母,所以需要判断下,如果能组成字母,那么剩下的问题就是258能有几种组合;如果不能组成字母,则0个
  • 三位以上的数字就不可能组成字母,不用考虑,没戏

image.png

class Solution2 {

    public int translateNum(int num) {
        if (num < 0) {
            return -1; // illegal input
        }
        int dp_next = 1;
        int dp_next2 = 1;
        int x = num % 10;
        int y = num % 10;
        while (num != 0) {
            num /= 10;
            x = num % 10;
            int temp = isAlpha(10 * x + y) ? (dp_next + dp_next2) : dp_next;
            dp_next2 = dp_next;
            dp_next = temp;
            y = x;
        }
        return dp_next;
    }

    private boolean isAlpha(int temp) {
        return temp >= 10 && temp <= 25;
    }

    public int translateNum2(int num) {
        int a = 1, b = 1, x, y = num % 10;
        while(num != 0) {
            num /= 10;
            x = num % 10;
            int tmp = 10 * x + y;
            int c = (tmp >= 10 && tmp <= 25) ? a + b : a;
            b = a;
            a = c;
            y = x;
        }
        return a;
    }

}

以上解法用动图表示如下

interview46.gif

面试题47 -- 礼物的最大价值

题目:在一个m*n的棋盘的每一格都放有一个礼物,每个礼物都有一定的价值(价值大于0)。你可以从棋盘的左上角开始拿格子里的礼物,并每次向右或者向下移动一格,直到到达棋盘的右下角。给定一个棋盘及其上面的礼物,请计算你最多能拿到多少价值的礼物?

通过对问题的分析我们可以看出,到达某位置的最优解取决于:

  1. 到达该位置上面的最优解
  2. 到达该位置左边的最优解

以上二者取最最大的,这具有典型的动态规划特性,可以得到如下的状态转移方程

image.png

最后计算过程如下

interview47.gif

class Solution {
    public int maxValue(int[][] grid) {
        if(grid == null || grid.length == 0 || grid[0].length == 0) {
            return 0;
        }
        int[] dp = new int[grid[0].length];
        for(int i = 0; i < grid.length; i++) {
            for(int j = 0; j < grid[0].length; j++) {
                int up = i == 0 ? 0 : dp[j];
                int left = j == 0 ? 0 : dp[j-1];
                dp[j] = Math.max(up, left) + grid[i][j];
            }
        }
        return dp[dp.length-1];
    }
}

面试题48 -- 最长不含重复字符的子字符串

题目:请从字符串中找出一个最长的不包含重复字符的子字符串,计算该最长子字符串的长度。假设字符串中只包含'a'~'z'的字符。例如,在字符串"arabcacfr"中,最长的不含重复字符的子字符串是"acfr",长度为4。

书中的解法是通过动态规划+哈希表,本文中我们采用更直观的滑动窗口+哈希表的方式来解决这个问题。

滑动窗口的滑动过程如下图所示,窗口中始终维持着不重复的字母,当遇到重复的的字母时,则 收缩窗口的左边界直到弹出重复的字母

interview48_1.gif

我们可以看出,和书中动态规划解法不同的是,当遇到重复字母时,我们需要O(n)的时间收缩窗口。显然这是可以被优化的,因为我们可以通过哈希的数据结构保存每个字母对应的下标位置,进而在O(1)时间内直接定位到窗口左边界应该收缩到的位置。优化后的过程如下

interview48_2.gif

class Solution {
    public int lengthOfLongestSubstring(String s) {
        if (s == null) {
            return 0;
        }
        Map<Character, Integer> char2Index = new HashMap<>();
        int longestLength = 0;
        int left = 0;
        for (int i = 0; i < s.length(); i++) {
            if (char2Index.containsKey(s.charAt(i))) {
                left = Math.max(left, char2Index.get(s.charAt(i)) + 1); // 取最大的,左窗口都收回来总不能再回去吧
            }
            char2Index.put(s.charAt(i), i);
            longestLength = Math.max(longestLength, i-left+1); // 更新最大值
        }
        return longestLength;
    }
}

面试题49 -- 丑数

题目:我们把只包含因子2、3和5的数称作丑数(Ugly Number)。求按从小到大的顺序的第1500个丑数。例如,6、8都是丑数,但14不是,因为它包含因子7.习惯上我们把1当做第一个丑数。

最简单粗暴的方法,从1开始遍历每一个数,判断当前数字是否为丑数,如果是,则计数加一,一直到找到第1500个丑数为止。

判断是否为丑数的办法是不断对该数字除2、3和5,如果最后能够除尽(即除到剩下1),则是丑数,否则不是。因为思路很简单,直接贴代码如下:

private boolean isUgly(int number) {
    while(number % 2 == 0) {
        number /= 2;
    }
    while(number % 3 == 0) {
        number /= 3;
    }
    while(number % 5 == 0) {
        number /= 5;
    }
    return number == 1;
}

public int getUglyNumber(int index) {
    if(index <= 0) return 0;
    int number = 0;
    int uglyFound = 0;
    while(uglyFound < index) {
        number++;
        if(isUgly(number)) {
            unglyFound++;
        }
    }
    return number;
}

优化的解法,结合丑数的特性(是2、3、5的乘积组合),我们可以在丑数的范围内找第1500个,即跳过那些非丑数。

该解法的执行过程如下图所示。核心思路在于利用已有的丑数进行运算,找出最近的下一个丑数(这两个丑数之间就能够跳过一大堆非丑数),定义三个指针M2、M3、M5,算法的执行过程可以想象成是一局飞行棋比赛,为了找到下一个最小的丑数,M2、M3、M5三名玩家每次都要各自乘以2/3/5,然后从结果中找到最小的作为下一个丑数,并且各自都跳到下一个候选位置。

image.png

class Solution {
    public int nthUglyNumber(int n) {
        if (n <= 0) {
            return 0;
        }
        int m2 = 0;
        int m3 = 0;
        int m5 = 0;
        int[] uglyNumbers = new int[n];
        uglyNumbers[0]=1;
        for (int index = 1; index < n; index++) {
            int nextUgly = min(uglyNumbers[m2]*2, uglyNumbers[m3]*3, uglyNumbers[m5]*5);
            uglyNumbers[index] = nextUgly;
            while(uglyNumbers[m2] * 2 <= uglyNumbers[index]) {
                m2++;
            }
            while(uglyNumbers[m3] * 3 <= uglyNumbers[index]) {
                m3++;
            }
            while(uglyNumbers[m5] * 5 <= uglyNumbers[index]) {
                m5++;
            }
        }
        return uglyNumbers[n-1];
    }

    private int min(int a, int b, int c) {
        int relativeMin = Math.min(a,b);
        return Math.min(relativeMin, c);
    }
}

interview49.gif

面试题50 -- 第一个只出现一次的字符

题目一:字符串中第一个只出现一次的字符

在字符串中找出第一个只出现一次的字符。如输入"abaccdeff",则输出'b'。

本题比较简单,通过一个哈希表(jdk提供的或自行实现256大小的数组)来存储每个字符出现的次数,然后再遍历一次字符串找到第一个次数为1的字符并返回。

public class Solution {
    public char firstUniqChar(String s) {
        if (s == null || s.length() == 0) {
            return ' ';
        }
        int[] charMap = new int[256];
        for (int i = 0; i < s.length(); i++) {
            charMap[s.charAt(i)]++;
        }
        for (int i = 0; i < s.length(); i++) {
            if (charMap[s.charAt(i)] == 1) {
                return s.charAt(i);
            }
        }
        return ' ';
    }
}

题目二:字符流中第一个只出现一次的字符

请实现一个函数,用来找出字符流中第一个只出现一次的字符。例如,当从字符流中只读出前两个字符"go"时,第一个只出现一次的字符是'g';当从该字符流中读出前6个字符"google"时,第一个只出现一次的字符是"l"。

和上一题不同的是,由于输入是一个字符流,所以不会再有上一题的第二轮遍历,需要在第一轮遍历时对已经重复的字符进行某种标记

  1. 书中的解法,哈希表中记录字符出现的序号,对于已经重复了的字符,其序号记负数
  2. 维护一个有序的Set集合记录所有不重复的字符,再维护另一个Set集合记录已经重复过了的字符
public class CharStatistics {
    private Set<Character> appearingOnceChars = new LinkedHashSet<>();
    private Set<Character> duplicateChars = new HashSet<>();
    
    public void insert(char ch) {
        if (duplicateChars.contains(ch)) {
            return;
        }
        if (appearingOnceChars.contains(ch)) {
            appearingOnceChars.remove(ch);
            duplicateChars.add(ch);
            return;
        }
        appearingOnceChars.add(ch);
    }

    public char firstAppearingOnce() {
        if (appearingOnceChars.isEmpty()) {
            return ' ';
        }
        return appearingOnceChars.iterator().next();
    }
}

面试题51 -- 数组中的逆序对

题目:在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。例如,在数组{7,5,6,4}中,一共存在5个逆序对,分别是(7,6)、(7,5)、(7,4)、(6,4)和(5,4)。

本题最直观的解法,就是两层循环遍历数组中每一对数字,判断是否为逆序对,时间复杂度为O(n^2)。显然这道题不可能这么简单。

我们可以看出,逆序对是存在这样一种规律的:以{7,5,6,4}为例,如果知道了{5,4}能够构成逆序对,并且知道比5大的还有两个数即6和7,就能够快速得到以4作为结尾的逆序对的个数。而能够利用到这样的特性的方式就是归并排序

如下图所示,我们利用分治算法的思想,将计算{7,5,6,4}中逆序对的个数拆分为

  1. 计算{7,5}中逆序对的个数 leftPairs
  2. 计算{6,4}中逆序对的个数 rightPairs
  3. 计算前面两对数组之前的逆序对的个数 crossPairs

而1和2的步骤中又可以继续进行递归的拆分,直到数组的大小为1,一个元素的数组是不可能构成逆序对的,所以直接返回0(递归的出口)。

而3的步骤正是归并排序也是本题计算逆序对的核心工作。

interview51.gif

class Solution {
    public int reversePairs(int[] nums) {
        if(nums == null || nums.length == 0) {
            return 0;
        }
        int[] clones = new int[nums.length];
        return reversePairsCore(nums, 0, nums.length-1, clones);
    }
    
    private int reversePairsCore(int[] nums, int left, int right, int[] clones) {
        if(left == right) {
            return 0;
        }
        int mid = left + (right - left) / 2;
        int leftPairs = reversePairsCore(nums, left, mid, clones);
        int rightPairs = reversePairsCore(nums, mid+1, right, clones);
        if(nums[mid] < nums[mid+1]) {
            return leftPairs + rightPairs;
        }
        int crossPairs = mergeAndCount(nums, left, mid, right, clones);
        return leftPairs + rightPairs + crossPairs;
    }
    
    private int mergeAndCount(int[] nums, int left, int mid, int right, int[] clones) {
        for(int i = left; i <= right; i++) clones[i] = nums[i];
        int pairs = 0;
        int i = left;
        int j = mid+1;
        int cur = left;
        while(i <= mid && j <= right) {
            if(clones[i] <= clones[j]) {
                nums[cur++] = clones[i++];
            } else {
                pairs += (mid-i+1);
                nums[cur++] = clones[j++];
            }
        }
        while(i <= mid) nums[cur++] = clones[i++];
        while(j <= right) nums[cur++] = clones[j++];
        return pairs;
    }
}

面试题52 -- 两个链表的第一个公共节点

题目:输入两个链表,找出它们的第一个公共节点。

暴力法,对链表1中的每一个节点,都在链表2中走一遍判断是否有相等,这样的话时间复杂度是O(mn),m和n分别为两个链表的长度。

通过分析链表的特性,我们可以看出,当两个链表出现一个公共节点之后,后面就是完全重合的了,因此两个链表的结构类似如下图

image.png

为了求第一个公共节点,我们可以倒着从末尾向链表头的方向找到“最后一个公共节点”,因此需要利用栈结构后进先出的特性,为此需要O(m+n)的辅助空间。

最优的解法是分别遍历一遍两个链表,得到各自的长度,根据如图所示两个链表的结构特性,只要长的链表先多走几步,然后两个链表按同样的节奏向前走,就一定能找到公共节点。这种方法不需要使用额外的辅助空间。

interview52.gif

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */
public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        if(headA == null || headB == null) {
            return null;
        }
        int listALength = len(headA);
        int listBLength = len(headB);
        int delta = listALength > listBLength ? (listALength - listBLength) : (listBLength - listALength);
        ListNode longerList = listALength > listBLength ? headA : headB;
        ListNode anotherList = listALength > listBLength ? headB : headA;
        for(int i = 0; i < delta; i++) longerList = longerList.next;
        ListNode node1 = longerList;
        ListNode node2 = anotherList;
        while(node1 != node2) {
            if(node1 == null || node2 == null) return null;
            node1 = node1.next;
            node2 = node2.next;
        }
        return node1;
    }

    private int len(ListNode head) {
        int len = 0;
        for(ListNode node = head; node != null; node = node.next) {
            len++;
        }
        return len;
    }
}

除了书中的解法外,此题还有另一种更为巧妙的解法,笔者愿将其命名为《你的名字》式解法。

interview52_2.gif

当第二次走到公共节点碰面时,A、B两个指针都同时走过了相同的路程,common + lenA_before_common + lenB_before_comon,因此相遇。此外,读者可以自行分析一下,当A和B没有交集的时候,此题也能返回正确的结果。

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */
public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        ListNode node1 = headA;
        ListNode node2 = headB;
        while (node1 != node2) {
            node1 = node1 != null ? node1.next : headB;
            node2 = node2 != null ? node2.next : headA;
        }
        return node1;
    }
}

leetcode上一个比较有意思的解读,23333。

image.png

image.png