面试题38.字符串的排列
一般回溯法
class Solution {
public String[] permutation(String s) {
List<String> res = new ArrayList<>();
char[] ch = s.toCharArray();
Arrays.sort(ch);
StringBuilder sb = new StringBuilder();
//标记哪个字符正在被使用,哪个没有使用
boolean[] used = new boolean[ch.length];
dfs(ch, 0, sb, used, res);
String[] arr = new String[res.size()];
for(int i = 0; i < arr.length; i++) {
arr[i] = res.get(i);
}
return arr;
}
public void dfs(char[] ch, int index, StringBuilder sb, boolean[] used, List<String> res) {
if(index >= ch.length) {
res.add(sb.toString());
return;
}
for(int i = 0; i < ch.length; i++) {
if(used[i]) continue;
//剪枝
//使结果数组中没有重复元素
if(i > 0 && ch[i] == ch[i - 1] && !used[i - 1]) continue;
used[i] = true;
sb.append(ch[i]);
dfs(ch, index + 1, sb, used, res);
used[i] = false;
sb.deleteCharAt(sb.length() - 1);
}
}
}
去重回溯法
根据字符串排列的特点,考虑深度优先搜索所有排列方案。即通过字符交换,先固定第 1 位字符( n 种情况)、再固定第 2 位字符( n−1 种情况)、… 、最后固定第 n 位字符( 1 种情况)。
当字符串存在重复字符时,排列方案中也存在重复的排列方案。为排除重复方案,需在固定某位字符时,保证 “每种字符只在此位固定一次” ,即遇到重复字符时不交换,直接跳过。从 DFS 角度看,此操作称为 “剪枝” 。
-
1、终止条件: 当 x = len( c ) - 1 时,代表所有位已固定(最后一位只有 1 种情况),则将当前组合 c 转化为字符串并加入 res ,并返回;
-
2、递推参数: 当前固定位 x ;
-
3、递推工作: 初始化一个 Set ,用于排除重复的字符;将第 x 位字符与 i ∈ [x, len( c )] 字符分别交换,并进入下层递归;
- 剪枝: 若 c[i] 在 Set 中,代表其是重复字符,因此 “剪枝” ;
- 将 c[i] 加入 Set ,以便之后遇到重复字符时剪枝;
- 固定字符: 将字符 c[i] 和 c[x] 交换,即固定 c[i] 为当前位字符;
- 开启下层递归: 调用 dfs(x + 1) ,即开始固定第 x + 1 个字符;
- 还原交换: 将字符 c[i] 和 c[x] 交换(还原之前的交换);
class Solution {
List<String> res = new LinkedList<>();
char[] c;
public String[] permutation(String s) {
c = s.toCharArray();
dfs(0);
return res.toArray(new String[0]);
}
public void dfs(int x) {
if(x == c.length - 1) {
res.add(String.valueOf(c));
return;
}
Set<Character> set = new HashSet<>();
for(int i = x; i < c.length; i++) {
if(set.contains(c[i]) continue; //重复,剪枝
set.add(c[i]);
swap(i, x); // 交换,将 c[i] 固定在第 x 位
dfs(x + 1); // 开启固定第 x + 1 位字符
swap(x, i); // 恢复交换
}
}
public void swap(int a, int b) {
char tmp = c[a];
c[a] = c[b];
c[b] = tmp;
}
}
- 时间复杂度 O(N!N) : N 为字符串 s 的长度;时间复杂度和字符串排列的方案数成线性关系,方案数为 N×(N−1)×(N−2)…×2×1 ,即复杂度为 O(N!);字符串拼接操作 join() 使用 O(N) ;因此总体时间复杂度为 O(N!N) 。
- 空间复杂度 O(N2) : 全排列的递归深度为 N ,系统累计使用栈空间大小为 O(N) ;递归中辅助 Set 累计存储的字符数量最多为 N+(N−1)+…+2+1=(N+1)N/2 ,即占用 O(N2) 的额外空间。
—————————————————————————————
面试题39.数组中出现超过一半的数字
1、排序法
出现次数最多的元素大于n/2次的就是众数,可以先排序,然后下标是n/2的元素一定是众数,n为奇数或者偶数都可以。
class Solution {
public int majorityElement(int[] nums) {
Arrays.sort(nums);
return nums[nums.length/2];
}
}
- 时间复杂度:O(nlogn)。将数组排序的时间复杂度为 O(nlogn)。
- 空间复杂度:O(logn)。如果使用语言自带的排序算法,需要使用 O(logn) 的栈空间。如果自己编写堆排序,则只需要使用 O(1) 的额外空间。
2、map法
class Solution {
public int majorityElement(int[] nums) {
Map<Integer, Integer> map = new HashMap<>();
int len = nums.length / 2;
for(int num : nums) {
map.put(num, map.getOrDefault(num, 0) + 1);
if(map.get(num) > len) return num;
}
return 0;
}
}
- 时间复杂度O(n),其中 n 是数组 nums 的长度。遍历数组 nums 一次,对于 nums 中的每一个元素,将其插入哈希表都只需要常数时间。如果在遍历时没有维护最大值,在遍历结束后还需要对哈希表进行遍历,因为哈希表中占用的空间为 O(n),因此总时间复杂度为 O(n)。
- 空间复杂度O(n)。哈希表最多包含 n - ⌊n/2⌋ 个键值对,所以占用的空间为 O(n)。这是因为任意一个长度为 n 的数组最多只能包含 n 个不同的值,但题中保证 nums 一定有一个众数,会占用(最少)⌊n/2⌋ + 1 个数字。因此最多有 n - (⌊n/2⌋ + 1) 个不同的其他数字,所以最多有n - ⌊n/2⌋ 个不同的元素。
3、摩尔投票法
推论一:若记众数的票数为 +1,非众数的票数为 -1,则一定有所有数字的票数和 > 0
推论二:若数组的前 a 个数字的票数和 = 0,则数组剩余(n - a)个数字的票数和一定仍 > 0,即后 (n-a) 个数字的 众数仍为 x 。
class Solution {
public int majorityElement(int[] nums) {
int x = 0, votes = 0;
for(int num : nums) {
//当票数=0,说明前面的数字都已经抵消,剩下的数字中多的那个数还是众数
//所以不妨先假设当前数字就是众数
if(votes == 0) x = num;
//如果当前数字等于目前的众数,则票数+1,否则票数-1
votes += x == num ? 1 : -1;
}
return x;
}
}
位运算
如果一个数字的出现次数超过了数组长度的一半, 那么这个数字二进制的各个bit的出现次数同样超过了数组长度的一半
class Solution {
public int majorityElement(int[] nums) {
int[] bit = new int[32];
int len = nums.length;
for(int num : nums) {
for(int i = 0; i < 32; i++) {
//如果当前数字的当前位为1,则在数组中相应的位置上值+1
if(((num >>> i) & 1) == 1) bit[i]++;
}
}
int res = 0;
for(int i = 0; i < 32; i++) {
if(bit[i] > len / 2) {
res = res | (1 << i);
}
}
return res;
}
}
————————————————————————————————————————
面试题40.最小的k个数
方法一:堆
使用一个大小为 k 的最大堆,将数组中的元素依次入堆,当堆的大小超过 k 时,便将多出的元素从堆顶弹出。
这样,由于每次从堆顶弹出的数都是堆中最大的,最小的 k 个元素一定会留在堆里。这样,把数组中的元素全部入堆之后,堆中剩下的 k 个元素就是最大的 k 个数了。
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if(k == 0) return new int[0];
Queue<Integer> heap = new PriorityQueue<>(k, (i1, i2) -> Integer.compare(i2, i1));
for(int i : arr) {
//当前数字小于堆顶元素才会入堆
if(heap.isEmpty() || heap.size() < k || i < heap.peek()) {
heap.offer(i);
}
if(heap.size() > k) heap.poll(); //删除堆顶最大元素
}
//将堆中的元素存入数组
int[] res = new int[heap.size()];
int j = 0;
for(int i : heap) {
res[j++] = i;
}
return res;
}
}
方法二:快排变形
我们的目的是寻找最小的 k 个数。假设经过一次 partition 操作,枢纽元素位于下标 m,也就是说,左侧的数组有 m 个元素,是原数组中最小的 m 个数。那么:
- 若 k = m,我们就找到了最小的 k 个数,就是左侧的数组;
- 若 k < m ,则最小的 k 个数一定都在左侧数组中,我们只需要对左侧数组递归地 parition 即可;
- 若 k > m,则左侧数组中的 m 个数都属于最小的 k 个数,我们还需要在右侧数组中寻找最小的 k−m 个数,对右侧数组递归地 partition 即可。
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if(arr.length <= k) return arr;
quickSort(arr, k, 0, arr.length - 1);
int[] res = new int[k];
for(int i = 0; i < k; i++) {
res[i] = arr[i];
}
return res;
}
public void quickSort(int[] arr, int k, int leftBound, int rightBound) {
if(leftBound >= rightBound) return;
int mid = partition(arr, k, leftBound, rightBound);
if(mid == k) return;
else if(mid < k) quickSort(arr, k, mid + 1, rightBound);
else quickSort(arr, k, leftBound, mid - 1);
}
public int partition(int[] arr, int k, int leftBound, int rightBound) {
int pivot = arr[rightBound];
int left = leftBound;
int right = rightBound - 1;
while(left <= right) {
while(left <= right && arr[left] <= pivot) left++;
while(left <= right && arr[right] > pivot) right--;
if(left < right) swap(arr, left, right);
}
swap(arr, left, rightBound);
return left;
}
public void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
————————————————————————————————————————
面试题41.数据流中的中位数
解题思路:
给定一个长度为 N 的无序数组,其中中位数的计算方法:首先对数组执行排序(使用 O(NlogN) 时间),然后返回中间元素即可(使用 O(1) 时间)。
根据以上思路,可以将数据流保存在一个列表中,并在添加元素时保持数组有序。此方法的时间复杂度为 O(N) ,其中包括: 查找元素插入位置 O(logN) (二分查找)、向数组某位置插入元素 O(N) (插入位置之后的元素都需要向后移动一位)。
建立一个 小顶堆 A 和 大顶堆 B,各保存列表的一半元素,且规定:
- A 保存较大的一半,长度为 N / 2(N为偶数)或 (N + 1)/ 2(N为奇数)
- B 保存较小的一半,长度为 N / 2(N为偶数)或 (N - 1)/ 2(N为奇数)
算法流程:设元素总数为 N = m + n,其中 m 和 n 分别为 A 和 B 中的元素个数。
addNum(num) 函数:
- 1.当 m = n(即 N 为偶数):需向 A 添加一个元素。实现方法:将新元素 num 插入至 B,再将 B 堆顶元素插入至 A;
- 2.当 m != n(即 N 为奇数):需向 B 添加一个元素。实现方法:将新元素 num 插入至 A,再将 A 堆顶元素插入至 B;
假设插入数字 num 遇到情况 1. 。由于 num 可能属于 “较小的一半” (即属于 B ),因此不能将 num 直接插入至 A 。而应先将 num 插入至 B ,再将 B 堆顶元素插入至 A 。这样做,如果num大于B的堆顶,则插入后num成为了B新的堆顶元素,此时就弹出插入A;如果num小于B的堆顶,此时弹出B的堆顶,再插入A,这样就可以始终保持 A 保存较大一半、 B 保存较小一半。
findMedian() 函数:
- 1.当 m = n(即 N 为偶数):则中位数为(A 的堆顶元素 + B 的堆顶元素)/ 2
- 2.当 m != n(即 N 为奇数): 则中位数为 A 的堆顶元素
当从数据流中读出的数的个数为偶数的时候,我们想办法让两个堆中的元素个数相等,两个堆顶元素的平均值就是所求的中位数;
当从数据流中读出的数的个数为奇数的时候,我们想办法让最小堆的元素个数永远比最大堆的元素个数多 1 个
class MedianFinder {
Queue<Integer> minHeap, manHeap;
/** initialize your data structure here. */
public MedianFinder() {
minHeap = new PriorityQueue<>(); //小顶堆,保存较大的一半
maxHeap = new PriorityQueue<>((x, y) -> (y - x)); //大顶堆,保存较小的一半
}
public void addNum(int num) {
if(minHeap.size() != maxHeap.size()) {
//因为约定小顶堆个数要多于大顶堆,而此时小顶堆元素已经比大顶堆多一个
//所以要往大顶堆中加元素
minHeap.add(num);
maxHeap.add(minHeap.poll());
} else {
maxHeap.add(num);
minHeap.add(maxHeap.poll());
}
}
public double findMedian() {
if(minHeap.size() != maxHeap.size()) return minHeap.peek();
else return (minHeap.size() + maxHeap.size()) / 2;
}
}
-
时间复杂度:
- 查找中位数 O(1) : 获取堆顶元素使用 O(1) 时间;
- 添加数字 O(logN) : 堆的插入和弹出操作使用 O(logN) 时间。
-
空间复杂度 O(N) : 其中 N 为数据流中的元素数量,小顶堆 A 和大顶堆 B 最多同时保存 N 个元素。