面试题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)之后就达到目的了。
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的忍村大战,或者你是在三国乱世的一方势力(比如曹操),只要你的兵力足够强(指超过总数的一半),那么不论是怎么开战不论谁先打谁,小兵一换一最后赢家一定是你。
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;
}
}
关于大顶堆/小顶堆
手写大顶堆(小顶堆类似)时,首先用数组承载二叉树,然后分为两步:
- 构建大顶堆(buildHeap),从最后一个非根节点(heap/2-1)开始进行调整(adjust),一直调整到根节点,初始化整个大顶堆,需要时间log(k)
- 每当需要有数加入时,将根节点替换为输入的数,然后对根节点进行调整
调整的过程就是从左右孩子中找到更大的节点,与之交换,交换后在新位置再进行调整
基于红黑树的方法,时间复杂度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操作的复杂度,那么大顶堆/小顶堆也同样能够做到。维护两个堆,左边是比较小的一半,右边是比较大的另一半,维持左右的大小的平衡
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位。
此外还可以想到通过列举出所有数值总结规律
列举到后面,我们不难看出,
1~99中1的个数 = 1~9中1的个数 * 10 + 10
1~999中1的个数 = 1~99中1的个数 * 10 + 100
1~9999中1的个数 = 1~999中1的个数 * 10 + 1000
...
1~999(n个9)中1的个数 = 1~999(n-1个9)中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为对应的数字。
本次依然是先列举出尽可能多的数字,然后从中找出规律
如图,我们需要找到序列中某一位的数字,需要找到:
- 这一位对应的整数数字是多少(如index=15,对应的数字是12)
- 这一位对应的整数数字的第几位(如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所有可能的字符串组合
得到如下递归执行的调用栈
可以很容易得到递归的解法如下
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递归调用分析,我们可以得到如下的状态转移方程
带入到12258的例子就是如下,
- 一位的数字1一定能够组成字母,所以取1组成字母后,剩下问题就是2258能有几种不同的字母组合
- 两位的数字 有可能 组成字母,所以需要判断下,如果能组成字母,那么剩下的问题就是258能有几种组合;如果不能组成字母,则0个
- 三位以上的数字就不可能组成字母,不用考虑,没戏
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;
}
}
以上解法用动图表示如下
面试题47 -- 礼物的最大价值
题目:在一个m*n的棋盘的每一格都放有一个礼物,每个礼物都有一定的价值(价值大于0)。你可以从棋盘的左上角开始拿格子里的礼物,并每次向右或者向下移动一格,直到到达棋盘的右下角。给定一个棋盘及其上面的礼物,请计算你最多能拿到多少价值的礼物?
通过对问题的分析我们可以看出,到达某位置的最优解取决于:
- 到达该位置上面的最优解
- 到达该位置左边的最优解
以上二者取最最大的,这具有典型的动态规划特性,可以得到如下的状态转移方程
最后计算过程如下
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。
书中的解法是通过动态规划+哈希表,本文中我们采用更直观的滑动窗口+哈希表的方式来解决这个问题。
滑动窗口的滑动过程如下图所示,窗口中始终维持着不重复的字母,当遇到重复的的字母时,则 收缩窗口的左边界直到弹出重复的字母。
我们可以看出,和书中动态规划解法不同的是,当遇到重复字母时,我们需要O(n)的时间收缩窗口。显然这是可以被优化的,因为我们可以通过哈希的数据结构保存每个字母对应的下标位置,进而在O(1)时间内直接定位到窗口左边界应该收缩到的位置。优化后的过程如下
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,然后从结果中找到最小的作为下一个丑数,并且各自都跳到下一个候选位置。
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);
}
}
面试题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"。
和上一题不同的是,由于输入是一个字符流,所以不会再有上一题的第二轮遍历,需要在第一轮遍历时对已经重复的字符进行某种标记:
- 书中的解法,哈希表中记录字符出现的序号,对于已经重复了的字符,其序号记负数
- 维护一个有序的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}中逆序对的个数拆分为
- 计算{7,5}中逆序对的个数 leftPairs
- 计算{6,4}中逆序对的个数 rightPairs
- 计算前面两对数组之前的逆序对的个数 crossPairs
而1和2的步骤中又可以继续进行递归的拆分,直到数组的大小为1,一个元素的数组是不可能构成逆序对的,所以直接返回0(递归的出口)。
而3的步骤正是归并排序也是本题计算逆序对的核心工作。
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分别为两个链表的长度。
通过分析链表的特性,我们可以看出,当两个链表出现一个公共节点之后,后面就是完全重合的了,因此两个链表的结构类似如下图
为了求第一个公共节点,我们可以倒着从末尾向链表头的方向找到“最后一个公共节点”,因此需要利用栈结构后进先出的特性,为此需要O(m+n)的辅助空间。
最优的解法是分别遍历一遍两个链表,得到各自的长度,根据如图所示两个链表的结构特性,只要长的链表先多走几步,然后两个链表按同样的节奏向前走,就一定能找到公共节点。这种方法不需要使用额外的辅助空间。
/**
* 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;
}
}
除了书中的解法外,此题还有另一种更为巧妙的解法,笔者愿将其命名为《你的名字》式解法。
当第二次走到公共节点碰面时,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。