如何去除有序数组的重复元素

我们知道对于数组来说,在尾部插入、删除元素是比较高效的,时间复杂度是 O(1),但是如果在中间或者开头插入、删除元素,就会涉及数据的搬移,时间复杂度为 O(N),效率较低。
所以对于一般处理数组的算法问题,我们要尽可能只对数组尾部的元素进行操作,以避免额外的时间复杂度。
这篇文章讲讲如何对一个有序数组去重,先看下题目:

显然,由于数组已经排序,所以重复的元素一定连在一起,找出它们并不难,但如果毎找到一个重复元素就立即删除它,就是在数组中间进行删除操作,整个时间复杂度是会达到 O(N^2)。而且题目要求我们原地修改,也就是说不能用辅助数组,空间复杂度得是 O(1)。
其实,对于数组相关的算法问题,有一个通用的技巧:要尽量避免在中间删除元素,那我就想先办法把这个元素换到最后去。这样的话,最终待删除的元素都拖在数组尾部,一个一个 pop 掉就行了,每次操作的时间复杂度也就降低到 O(1) 了。
按照这个思路呢,又可以衍生出解决类似需求的通用方式:双指针技巧。具体一点说,应该是快慢指针。
我们让慢指针 slow 走左后面,快指针 fast 走在前面探路,找到一个不重复的元素就告诉 slow 并让 slow 前进一步。这样当 fast 指针遍历完整个数组 nums 后,**nums[0..slow] 就是不重复元素,之后的所有元素都是重复元素**。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int removeDuplicates(int[] nums) {
int n = nums.length;
if (n == 0) return 0;
int slow = 0, fast = 1;
while (fast < n) {
if (nums[fast] != nums[slow]) {
slow++;
// 维护 nums[0..slow] 无重复
nums[slow] = nums[fast];
}
fast++;
}
// 长度为索引 + 1
return slow + 1;
}

看下算法执行的过程:

再简单扩展一下,如果给你一个有序链表,如何去重呢?其实和数组是一模一样的,唯一的区别是把数组赋值操作变成操作指针而已:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ListNode deleteDuplicates(ListNode head) {
if (head == null) return null;
ListNode slow = head, fast = head.next;
while (fast != null) {
if (fast.val != slow.val) {
// nums[slow] = nums[fast];
slow.next = fast;
// slow++;
slow = slow.next;
}
// fast++
fast = fast.next;
}
// 断开与后面重复元素的连接
slow.next = null;
return head;
}

上一篇:如何高效解决接雨水问题
下一篇:如何寻找最长回文子串
目录

今天就来聊三道考察频率高,而且容易让人搞混的算法问题,分别是求子集(subset),求排列(permutation),求组合(combination)。
这几个问题都可以用回溯算法模板解决,同时子集问题还可以用数学归纳思想解决。读者可以记住这几个问题的回溯套路,就不怕搞不清了。

一、子集

问题很简单,输入一个不包含重复数字的数组,要求算法输出这些数字的所有子集。

1
vector<vector<int>> subsets(vector<int>& nums);

比如输入 nums = [1,2,3],你的算法应输出 8 个子集,包含空集和本身,顺序可以不同:
[ [],[1],[2],[3],[1,3],[2,3],[1,2],[1,2,3] ]
第一个解法是利用数学归纳的思想:假设我现在知道了规模更小的子问题的结果,如何推导出当前问题的结果呢?
具体来说就是,现在让你求 [1,2,3] 的子集,如果你知道了 [1,2] 的子集,是否可以推导出 [1,2,3] 的子集呢?先把 [1,2] 的子集写出来瞅瞅:
[ [],[1],[2],[1,2] ]
你会发现这样一个规律:
subset([1,2,3]) - subset([1,2])
= [3],[1,3],[2,3],[1,2,3]
而这个结果,就是把 sebset([1,2]) 的结果中每个集合再添加上 3。
换句话说,如果 A = subset([1,2]) ,那么:
subset([1,2,3])
= A + [A[i].add(3) for i = 1..len(A)]
这就是一个典型的递归结构嘛,[1,2,3] 的子集可以由 [1,2] 追加得出,[1,2] 的子集可以由 [1] 追加得出,base case 显然就是当输入集合为空集时,输出子集也就是一个空集。
翻译成代码就很容易理解了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
vector<vector<int>> subsets(vector<int>& nums) {
// base case,返回一个空集
if (nums.empty()) return {{}};
// 把最后一个元素拿出来
int n = nums.back();
nums.pop_back();
// 先递归算出前面元素的所有子集
vector<vector<int>> res = subsets(nums);
int size = res.size();
for (int i = 0; i < size; i++) {
// 然后在之前的结果之上追加
res.push_back(res[i]);
res.back().push_back(n);
}
return res;
}

这个问题的时间复杂度计算比较容易坑人。我们之前说的计算递归算法时间复杂度的方法,是找到递归深度,然后乘以每次递归中迭代的次数。对于这个问题,递归深度显然是 N,但我们发现每次递归 for 循环的迭代次数取决于 res 的长度,并不是固定的。
根据刚才的思路,res 的长度应该是每次递归都翻倍,所以说总的迭代次数应该是 2^N。或者不用这么麻烦,你想想一个大小为 N 的集合的子集总共有几个?2^N 个对吧,所以说至少要对 res 添加 2^N 次元素。
那么算法的时间复杂度就是 O(2^N) 吗?还是不对,2^N 个子集是 push_back 添加进 res 的,所以要考虑 push_back 这个操作的效率:

1
2
3
4
for (int i = 0; i < size; i++) {
res.push_back(res[i]); // O(N)
res.back().push_back(n); // O(1)
}

因为 res[i] 也是一个数组呀,push_back 是把 res[i] copy 一份然后添加到数组的最后,所以一次操作的时间是 O(N)。
综上,总的时间复杂度就是 O(N2^N),还是比较耗时的。
空间复杂度的话,如果不计算储存返回结果所用的空间的,只需要 O(N) 的递归堆栈空间。如果计算 res 所需的空间,应该是 O(N
2^N)。
第二种通用方法就是回溯算法。旧文「回溯算法详解」写过回溯算法的模板:

1
2
3
4
5
6
7
8
9
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择

只要改造回溯算法的模板就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vector<vector<int>> res;
vector<vector<int>> subsets(vector<int>& nums) {
// 记录走过的路径
vector<int> track;
backtrack(nums, 0, track);
return res;
}
void backtrack(vector<int>& nums, int start, vector<int>& track) {
res.push_back(track);
for (int i = start; i < nums.size(); i++) {
// 做选择
track.push_back(nums[i]);
// 回溯
backtrack(nums, i + 1, track);
// 撤销选择
track.pop_back();
}
}

可以看见,对 res 更新的位置处在前序遍历,也就是说,**res 就是树上的所有节点**:

二、组合

输入两个数字 n, k,算法输出 [1..n] 中 k 个数字的所有组合。

1
vector<vector<int>> combine(int n, int k);

比如输入 n = 4, k = 2,输出如下结果,顺序无所谓,但是不能包含重复(按照组合的定义,[1,2][2,1] 也算重复):
[
[1,2],
[1,3],
[1,4],
[2,3],
[2,4],
[3,4]
]
这也是典型的回溯算法,k 限制了树的高度,n 限制了树的宽度,继续套我们以前讲过的回溯算法模板框架就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
vector<vector<int>>res;
vector<vector<int>> combine(int n, int k) {
if (k <= 0 || n <= 0) return res;
vector<int> track;
backtrack(n, k, 1, track);
return res;
}
void backtrack(int n, int k, int start, vector<int>& track) {
// 到达树的底部
if (k == track.size()) {
res.push_back(track);
return;
}
// 注意 i 从 start 开始递增
for (int i = start; i <= n; i++) {
// 做选择
track.push_back(i);
backtrack(n, k, i + 1, track);
// 撤销选择
track.pop_back();
}
}

backtrack 函数和计算子集的差不多,区别在于,更新 res 的时机是树到达底端时。

三、排列

输入一个不包含重复数字的数组 nums,返回这些数字的全部排列。

1
vector<vector<int>> permute(vector<int>& nums);

比如说输入数组 [1,2,3],输出结果应该如下,顺序无所谓,不能有重复:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
「回溯算法详解」中就是拿这个问题来解释回溯模板的。这里又列出这个问题,是将「排列」和「组合」这两个回溯算法的代码拿出来对比。
首先画出回溯树来看一看:

我们当时使用 Java 代码写的解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
List<List<Integer>> res = new LinkedList<>();
/* 主函数,输入一组不重复的数字,返回它们的全排列 */
List<List<Integer>> permute(int[] nums) {
// 记录「路径」
LinkedList<Integer> track = new LinkedList<>();
backtrack(nums, track);
return res;
}
void backtrack(int[] nums, LinkedList<Integer> track) {
// 触发结束条件
if (track.size() == nums.length) {
res.add(new LinkedList(track));
return;
}

for (int i = 0; i < nums.length; i++) {
// 排除不合法的选择
if (track.contains(nums[i]))
continue;
// 做选择
track.add(nums[i]);
// 进入下一层决策树
backtrack(nums, track);
// 取消选择
track.removeLast();
}
}

回溯模板依然没有变,但是根据排列问题和组合问题画出的树来看,排列问题的树比较对称,而组合问题的树越靠右节点越少。
在代码中的体现就是,排列问题每次通过 contains 方法来排除在 track 中已经选择过的数字;而组合问题通过传入一个 start 参数,来排除 start 索引之前的数字。
以上,就是排列组合和子集三个问题的解法,总结一下
子集问题可以利用数学归纳思想,假设已知一个规模较小的问题的结果,思考如何推导出原问题的结果。也可以用回溯算法,要用 start 参数排除已选择的数字。
组合问题利用的是回溯思想,结果可以表示成树结构,我们只要套用回溯算法模板即可,关键点在于要用一个 start 排除已经选择过的数字。
排列问题是回溯思想,也可以表示成树结构套用算法模板,关键点在于使用 contains 方法排除已经选择的数字,前文有详细分析,这里主要是和组合问题作对比。
记住这几种树的形状,就足以应对大部分回溯算法问题了,无非就是 start 或者 contains 剪枝,也没啥别的技巧了。

上一篇:回溯算法详解
下一篇:二分查找详解
目录

如何调度考生的座位

这是 LeetCode 第 885 题,有趣且具有一定技巧性。这种题目并不像动态规划这类算法拼智商,而是看你对常用数据结构的理解和写代码的水平,个人认为值得重视和学习。
另外说句题外话,很多读者都问,算法框架是如何总结出来的,其实框架反而是慢慢从细节里抠出来的。希望大家看了我们的文章之后,最好能抽时间把相关的问题亲自做一做,纸上得来终觉浅,绝知此事要躬行嘛。
先来描述一下题目:假设有一个考场,考场有一排共 N 个座位,索引分别是 [0..N-1],考生会陆续进入考场考试,并且可能在任何时候离开考场。
你作为考官,要安排考生们的座位,满足:每当一个学生进入时,你需要最大化他和最近其他人的距离;如果有多个这样的座位,安排到他到索引最小的那个座位。这很符合实际情况对吧,
也就是请你实现下面这样一个类:

1
2
3
4
5
6
7
8
9
class ExamRoom {
// 构造函数,传入座位总数 N
public ExamRoom(int N);
// 来了一名考生,返回你给他分配的座位
public int seat();
// 坐在 p 位置的考生离开了
// 可以认为 p 位置一定坐有考生
public void leave(int p);
}

比方说考场有 5 个座位,分别是 [0..4]
第一名考生进入时(调用 seat()),坐在任何位置都行,但是要给他安排索引最小的位置,也就是返回位置 0。
第二名学生进入时(再调用 seat()),要和旁边的人距离最远,也就是返回位置 4。
第三名学生进入时,要和旁边的人距离最远,应该做到中间,也就是座位 2。
如果再进一名学生,他可以坐在座位 1 或者 3,取较小的索引 1。
以此类推。
刚才所说的情况,没有调用 leave 函数,不过读者肯定能够发现规律:
如果将每两个相邻的考生看做线段的两端点,新安排考生就是找最长的线段,然后让该考生在中间把这个线段「二分」,中点就是给他分配的座位。leave(p) 其实就是去除端点 p,使得相邻两个线段合并为一个
核心思路很简单对吧,所以这个问题实际上实在考察你对数据结构的理解。对于上述这个逻辑,你用什么数据结构来实现呢?

一、思路分析

根据上述思路,首先需要把坐在教室的学生抽象成线段,我们可以简单的用一个大小为 2 的数组表示。
另外,思路需要我们找到「最长」的线段,还需要去除线段,增加线段。
但凡遇到在动态过程中取最值的要求,肯定要使用有序数据结构,我们常用的数据结构就是二叉堆和平衡二叉搜索树了。二叉堆实现的优先级队列取最值的时间复杂度是 O(logN),但是只能删除最大值。平衡二叉树也可以取最值,也可以修改、删除任意一个值,而且时间复杂度都是 O(logN)。
综上,二叉堆不能满足 leave 操作,应该使用平衡二叉树。所以这里我们会用到 Java 的一种数据结构 TreeSet,这是一种有序数据结构,底层由红黑树维护有序性。
这里顺便提一下,一说到集合(Set)或者映射(Map),有的读者可能就想当然的认为是哈希集合(HashSet)或者哈希表(HashMap),这样理解是有点问题的。
因为哈希集合/映射底层是由哈希函数和数组实现的,特性是遍历无固定顺序,但是操作效率高,时间复杂度为 O(1)。
而集合/映射还可以依赖其他底层数据结构,常见的就是红黑树(一种平衡二叉搜索树),特性是自动维护其中元素的顺序,操作效率是 O(logN)。这种一般称为「有序集合/映射」。
我们使用的 TreeSet 就是一个有序集合,目的就是为了保持线段长度的有序性,快速查找最大线段,快速删除和插入。

二、简化问题

首先,如果有多个可选座位,需要选择索引最小的座位对吧?我们先简化一下问题,暂时不管这个要求,实现上述思路。
这个问题还用到一个常用的编程技巧,就是使用一个「虚拟线段」让算法正确启动,这就和链表相关的算法需要「虚拟头结点」一个道理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 将端点 p 映射到以 p 为左端点的线段
private Map<Integer, int[]> startMap;
// 将端点 p 映射到以 p 为右端点的线段
private Map<Integer, int[]> endMap;
// 根据线段长度从小到大存放所有线段
private TreeSet<int[]> pq;
private int N;
public ExamRoom(int N) {
this.N = N;
startMap = new HashMap<>();
endMap = new HashMap<>();
pq = new TreeSet<>((a, b) -> {
// 算出两个线段的长度
int distA = distance(a);
int distB = distance(b);
// 长度更长的更大,排后面
return distA - distB;
});
// 在有序集合中先放一个虚拟线段
addInterval(new int[] {-1, N});
}
/* 去除一个线段 */
private void removeInterval(int[] intv) {
pq.remove(intv);
startMap.remove(intv[0]);
endMap.remove(intv[1]);
}
/* 增加一个线段 */
private void addInterval(int[] intv) {
pq.add(intv);
startMap.put(intv[0], intv);
endMap.put(intv[1], intv);
}
/* 计算一个线段的长度 */
private int distance(int[] intv) {
return intv[1] - intv[0] - 1;
}

「虚拟线段」其实就是为了将所有座位表示为一个线段:

有了上述铺垫,主要 API seatleave 就可以写了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public int seat() {
// 从有序集合拿出最长的线段
int[] longest = pq.last();
int x = longest[0];
int y = longest[1];
int seat;
if (x == -1) { // 情况一
seat = 0;
} else if (y == N) { // 情况二
seat = N - 1;
} else { // 情况三
seat = (y - x) / 2 + x;
}
// 将最长的线段分成两段
int[] left = new int[] {x, seat};
int[] right = new int[] {seat, y};
removeInterval(longest);
addInterval(left);
addInterval(right);
return seat;
}
public void leave(int p) {
// 将 p 左右的线段找出来
int[] right = startMap.get(p);
int[] left = endMap.get(p);
// 合并两个线段成为一个线段
int[] merged = new int[] {left[0], right[1]};
removeInterval(left);
removeInterval(right);
addInterval(merged);
}

三种情况
至此,算法就基本实现了,代码虽多,但思路很简单:找最长的线段,从中间分隔成两段,中点就是 seat() 的返回值;找 p 的左右线段,合并成一个线段,这就是 leave(p) 的逻辑。

三、进阶问题

但是,题目要求多个选择时选择索引最小的那个座位,我们刚才忽略了这个问题。比如下面这种情况会出错:

现在有序集合里有线段 [0,4][4,9],那么最长线段 longest 就是后者,按照 seat 的逻辑,就会分割 [4,9],也就是返回座位 6。但正确答案应该是座位 2,因为 2 和 6 都满足最大化相邻考生距离的条件,二者应该取较小的。

遇到题目的这种要求,解决方式就是修改有序数据结构的排序方式。具体到这个问题,就是修改 TreeMap 的比较函数逻辑:

1
2
3
4
5
6
7
8
pq = new TreeSet<>((a, b) -> {
int distA = distance(a);
int distB = distance(b);
// 如果长度相同,就比较索引
if (distA == distB)
return b[0] - a[0];
return distA - distB;
});

除此之外,还要改变 distance 函数,不能简单地让它计算一个线段两个端点间的长度,而是让它计算该线段中点和端点之间的长度

1
2
3
4
5
6
7
8
private int distance(int[] intv) {
int x = intv[0];
int y = intv[1];
if (x == -1) return y;
if (y == N) return N - 1 - x;
// 中点和端点之间的长度
return (y - x) / 2;
}


这样,[0,4][4,9]distance 值就相等了,算法会比较二者的索引,取较小的线段进行分割。到这里,这道算法题目算是完全解决了。

四、最后总结

本文聊的这个问题其实并不算难,虽然看起来代码很多。核心问题就是考察有序数据结构的理解和使用,来梳理一下。
处理动态问题一般都会用到有序数据结构,比如平衡二叉搜索树和二叉堆,二者的时间复杂度差不多,但前者支持的操作更多。
既然平衡二叉搜索树这么好用,还用二叉堆干嘛呢?因为二叉堆底层就是数组,实现简单啊,详见旧文「二叉堆详解」。你实现个红黑树试试?操作复杂,而且消耗的空间相对来说会多一些。具体问题,还是要选择恰当的数据结构来解决。
希望本文对大家有帮助。

上一篇:如何在无限序列中随机抽取元素
下一篇:Union-Find算法详解
目录

如何高效寻找素数

素数的定义看起来很简单,如果一个数如果只能被 1 和它本身整除,那么这个数就是素数。
不要觉得素数的定义简单,恐怕没多少人真的能把素数相关的算法写得高效。比如让你写这样一个函数:

1
2
3
4
// 返回区间 [2, n) 中有几个素数 
int countPrimes(int n)
// 比如 countPrimes(10) 返回 4
// 因为 2,3,5,7 是素数

你会如何写这个函数?我想大家应该会这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int countPrimes(int n) {
int count = 0;
for (int i = 2; i < n; i++)
if (isPrim(i)) count++;
return count;
}
// 判断整数 n 是否是素数
boolean isPrime(int n) {
for (int i = 2; i < n; i++)
if (n % i == 0)
// 有其他整除因子
return false;
return true;
}

这样写的话时间复杂度 O(n^2),问题很大。首先你用 isPrime 函数来辅助的思路就不够高效;而且就算你要用 isPrime 函数,这样写算法也是存在计算冗余的
先来简单说下如果你要判断一个数是不是素数,应该如何写算法。只需稍微修改一下上面的 isPrim 代码中的 for 循环条件:

1
2
3
4
boolean isPrime(int n) {
for (int i = 2; i * i <= n; i++)
...
}

换句话说,i 不需要遍历到 n,而只需要到 sqrt(n) 即可。为什么呢,我们举个例子,假设 n = 12

1
2
3
4
5
12 = 2 × 6
12 = 3 × 4
12 = sqrt(12) × sqrt(12)
12 = 4 × 3
12 = 6 × 2

可以看到,后两个乘积就是前面两个反过来,反转临界点就在 sqrt(n)
换句话说,如果在 [2,sqrt(n)] 这个区间之内没有发现可整除因子,就可以直接断定 n 是素数了,因为在区间 [sqrt(n),n] 也一定不会发现可整除因子。
现在,isPrime 函数的时间复杂度降为 O(sqrt(N)),但是我们实现 countPrimes 函数其实并不需要这个函数,以上只是希望读者明白 sqrt(n) 的含义,因为等会还会用到。

高效实现 countPrimes

高效解决这个问题的核心思路是和上面的常规思路反着来:
首先从 2 开始,我们知道 2 是一个素数,那么 2 × 2 = 4, 3 × 2 = 6, 4 × 2 = 8… 都不可能是素数了。
然后我们发现 3 也是素数,那么 3 × 2 = 6, 3 × 3 = 9, 3 × 4 = 12… 也都不可能是素数了。
看到这里,你是否有点明白这个排除法的逻辑了呢?先看我们的第一版代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int countPrimes(int n) {
boolean[] isPrim = new boolean[n];
// 将数组都初始化为 true
Arrays.fill(isPrim, true);
for (int i = 2; i < n; i++)
if (isPrim[i])
// i 的倍数不可能是素数了
for (int j = 2 * i; j < n; j += i)
isPrim[j] = false;

int count = 0;
for (int i = 2; i < n; i++)
if (isPrim[i]) count++;

return count;
}

如果上面这段代码你能够理解,那么你已经掌握了整体思路,但是还有两个细微的地方可以优化。
首先,回想刚才判断一个数是否是素数的 isPrime 函数,由于因子的对称性,其中的 for 循环只需要遍历 [2,sqrt(n)] 就够了。这里也是类似的,我们外层的 for 循环也只需要遍历到 sqrt(n)

1
2
3
for (int i = 2; i * i < n; i++) 
if (isPrim[i])
...

除此之外,很难注意到内层的 for 循环也可以优化。我们之前的做法是:

1
2
for (int j = 2 * i; j < n; j += i) 
isPrim[j] = false;

这样可以把 i 的整数倍都标记为 false,但是仍然存在计算冗余。
比如 n = 25i = 4 时算法会标记 4 × 2 = 8,4 × 3 = 12 等等数字,但是这两个数字已经被 i = 2i = 3 的 2 × 4 和 3 × 4 标记了。
我们可以稍微优化一下,让 ji 的平方开始遍历,而不是从 2 * i 开始:

1
2
for (int j = i * i; j < n; j += i) 
isPrim[j] = false;

这样,素数计数的算法就高效实现了,其实这个算法有一个名字,叫做 Sieve of Eratosthenes。看下完整的最终代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int countPrimes(int n) {
boolean[] isPrim = new boolean[n];
Arrays.fill(isPrim, true);
for (int i = 2; i * i < n; i++)
if (isPrim[i])
for (int j = i * i; j < n; j += i)
isPrim[j] = false;

int count = 0;
for (int i = 2; i < n; i++)
if (isPrim[i]) count++;

return count;
}

该算法的时间复杂度比较难算,显然时间跟这两个嵌套的 for 循环有关,其操作数应该是:
n/2 + n/3 + n/5 + n/7 + …
= n × (1/2 + 1/3 + 1/5 + 1/7…)
括号中是素数的倒数。其最终结果是 O(N * loglogN),有兴趣的读者可以查一下该算法的时间复杂度证明。
以上就是素数算法相关的全部内容。怎么样,是不是看似简单的问题却有不少细节可以打磨呀?

上一篇:如何实现LRU算法
下一篇:如何计算编辑距离
目录

接雨水问题详解

接雨水这道题目挺有意思,在面试题中出现频率还挺高的,本文就来步步优化,讲解一下这道题。
先看一下题目:

就是用一个数组表示一个条形图,问你这个条形图最多能接多少水。

1
int trap(int[] height);

下面就来由浅入深介绍暴力解法 -> 备忘录解法 -> 双指针解法,在 O(N) 时间 O(1) 空间内解决这个问题。

一、核心思路

我第一次看到这个问题,无计可施,完全没有思路,相信很多朋友跟我一样。所以对于这种问题,我们不要想整体,而应该去想局部;就像之前的文章处理字符串问题,不要考虑如何处理整个字符串,而是去思考应该如何处理每一个字符。
这么一想,可以发现这道题的思路其实很简单。具体来说,仅仅对于位置 i,能装下多少水呢?

能装 2 格水。为什么恰好是两格水呢?因为 height[i] 的高度为 0,而这里最多能盛 2 格水,2-0=2。
为什么位置 i 最多能盛 2 格水呢?因为,位置 i 能达到的水柱高度和其左边的最高柱子、右边的最高柱子有关,我们分别称这两个柱子高度为 l_maxr_max位置 i 最大的水柱高度就是 min(l_max, r_max)
更进一步,对于位置 i,能够装的水为:

1
2
3
4
5
6
7
water[i] = min(
# 左边最高的柱子
max(height[0..i]),
# 右边最高的柱子
max(height[i..end])
) - height[i]



这就是本问题的核心思路,我们可以简单写一个暴力算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int trap(vector<int>& height) {
int n = height.size();
int ans = 0;
for (int i = 1; i < n - 1; i++) {
int l_max = 0, r_max = 0;
// 找右边最高的柱子
for (int j = i; j < n; j++)
r_max = max(r_max, height[j]);
// 找左边最高的柱子
for (int j = i; j >= 0; j--)
l_max = max(l_max, height[j]);
// 如果自己就是最高的话,
// l_max == r_max == height[i]
ans += min(l_max, r_max) - height[i];
}
return ans;
}

有之前的思路,这个解法应该是很直接粗暴的,时间复杂度 O(N^2),空间复杂度 O(1)。但是很明显这种计算 r_maxl_max 的方式非常笨拙,一般的优化方法就是备忘录。

二、备忘录优化

之前的暴力解法,不是在每个位置 i 都要计算 r_maxl_max 吗?我们直接把结果都缓存下来,别傻不拉几的每次都遍历,这时间复杂度不就降下来了嘛。
我们开两个数组 r_maxl_max 充当备忘录,l_max[i] 表示位置 i 左边最高的柱子高度,r_max[i] 表示位置 i 右边最高的柱子高度。预先把这两个数组计算好,避免重复计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int trap(vector<int>& height) {
if (height.empty()) return 0;
int n = height.size();
int ans = 0;
// 数组充当备忘录
vector<int> l_max(n), r_max(n);
// 初始化 base case
l_max[0] = height[0];
r_max[n - 1] = height[n - 1];
// 从左向右计算 l_max
for (int i = 1; i < n; i++)
l_max[i] = max(height[i], l_max[i - 1]);
// 从右向左计算 r_max
for (int i = n - 2; i >= 0; i--)
r_max[i] = max(height[i], r_max[i + 1]);
// 计算答案
for (int i = 1; i < n - 1; i++)
ans += min(l_max[i], r_max[i]) - height[i];
return ans;
}

这个优化其实和暴力解法差不多,就是避免了重复计算,把时间复杂度降低为 O(N),已经是最优了,但是空间复杂度是 O(N)。下面来看一个精妙一些的解法,能够把空间复杂度降低到 O(1)。

三、双指针解法

这种解法的思路是完全相同的,但在实现手法上非常巧妙,我们这次也不要用备忘录提前计算了,而是用双指针边走边算,节省下空间复杂度。
首先,看一部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
int trap(vector<int>& height) {
int n = height.size();
int left = 0, right = n - 1;

int l_max = height[0];
int r_max = height[n - 1];

while (left <= right) {
l_max = max(l_max, height[left]);
r_max = max(r_max, height[right]);
left++; right--;
}
}

对于这部分代码,请问 l_maxr_max 分别表示什么意义呢?
很容易理解,**l_maxheight[0..left] 中最高柱子的高度,r_maxheight[right..end] 的最高柱子的高度**。
明白了这一点,直接看解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int trap(vector<int>& height) {
if (height.empty()) return 0;
int n = height.size();
int left = 0, right = n - 1;
int ans = 0;

int l_max = height[0];
int r_max = height[n - 1];

while (left <= right) {
l_max = max(l_max, height[left]);
r_max = max(r_max, height[right]);

// ans += min(l_max, r_max) - height[i]
if (l_max < r_max) {
ans += l_max - height[left];
left++;
} else {
ans += r_max - height[right];
right--;
}
}
return ans;
}

你看,其中的核心思想和之前一模一样,换汤不换药。但是细心的读者可能会发现次解法还是有点细节差异:
之前的备忘录解法,l_max[i]r_max[i] 代表的是 height[0..i]height[i..end] 的最高柱子高度。

1
ans += min(l_max[i], r_max[i]) - height[i];


但是双指针解法中,l_maxr_max 代表的是 height[0..left]height[right..end] 的最高柱子高度。比如这段代码:

1
2
3
4
if (l_max < r_max) {
ans += l_max - height[left];
left++;
}


此时的 l_maxleft 指针左边的最高柱子,但是 r_max 并不一定是 left 指针右边最高的柱子,这真的可以得到正确答案吗?
其实这个问题要这么思考,我们只在乎 min(l_max, r_max)。对于上图的情况,我们已经知道 l_max < r_max 了,至于这个 r_max 是不是右边最大的,不重要,重要的是 height[i] 能够装的水只和 l_max 有关。

上一篇:如何运用二分查找算法
下一篇:如何去除有序数组的重复元素
目录

如何寻找最长回文子串

回文串是面试常常遇到的问题(虽然问题本身没啥意义),本文就告诉你回文串问题的核心思想是什么。
首先,明确一下什:回文串就是正着读和反着读都一样的字符串
比如说字符串 abaabba 都是回文串,因为它们对称,反过来还是和本身一样。反之,字符串 abac 就不是回文串。
可以看到回文串的的长度可能是奇数,也可能是偶数,这就添加了回文串问题的难度,解决该类问题的核心是双指针。下面就通过一道最长回文子串的问题来具体理解一下回文串问题:

1
string longestPalindrome(string s) {}

一、思考

对于这个问题,我们首先应该思考的是,给一个字符串 s,如何在 s 中找到一个回文子串?
有一个很有趣的思路:既然回文串是一个正着反着读都一样的字符串,那么如果我们把 s 反转,称为 s',然后在 ss' 中寻找最长公共子串,这样应该就能找到最长回文子串。
比如说字符串 abacd,反过来是 dcaba,它的最长公共子串是 aba,也就是最长回文子串。
但是这个思路是错误的,比如说字符串 aacxycaa,反转之后是 aacyxcaa,最长公共子串是 aac,但是最长回文子串应该是 aa
虽然这个思路不正确,但是这种把问题转化为其他形式的思考方式是非常值得提倡的
下面,就来说一下正确的思路,如何使用双指针。
寻找回文串的问题核心思想是:从中间开始向两边扩散来判断回文串。对于最长回文子串,就是这个意思:

1
2
3
for 0 <= i < len(s):
找到以 s[i] 为中心的回文串
更新答案

但是呢,我们刚才也说了,回文串的长度可能是奇数也可能是偶数,如果是 abba这种情况,没有一个中心字符,上面的算法就没辙了。所以我们可以修改一下:

1
2
3
4
for 0 <= i < len(s):
找到以 s[i] 为中心的回文串
找到以 s[i] 和 s[i+1] 为中心的回文串
更新答案

PS:读者可能发现这里的索引会越界,等会会处理。

二、代码实现

按照上面的思路,先要实现一个函数来寻找最长回文串,这个函数是有点技巧的:

1
2
3
4
5
6
7
8
9
10
string palindrome(string& s, int l, int r) {
// 防止索引越界
while (l >= 0 && r < s.size()
&& s[l] == s[r]) {
// 向两边展开
l--; r++;
}
// 返回以 s[l] 和 s[r] 为中心的最长回文串
return s.substr(l + 1, r - l - 1);
}

为什么要传入两个指针 lr 呢?因为这样实现可以同时处理回文串长度为奇数和偶数的情况

1
2
3
4
5
6
for 0 <= i < len(s):
# 找到以 s[i] 为中心的回文串
palindrome(s, i, i)
# 找到以 s[i] 和 s[i+1] 为中心的回文串
palindrome(s, i, i + 1)
更新答案

下面看下 longestPalindrome 的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
string longestPalindrome(string s) {
string res;
for (int i = 0; i < s.size(); i++) {
// 以 s[i] 为中心的最长回文子串
string s1 = palindrome(s, i, i);
// 以 s[i] 和 s[i+1] 为中心的最长回文子串
string s2 = palindrome(s, i, i + 1);
// res = longest(res, s1, s2)
res = res.size() > s1.size() ? res : s1;
res = res.size() > s2.size() ? res : s2;
}
return res;
}

至此,这道最长回文子串的问题就解决了,时间复杂度 O(N^2),空间复杂度 O(1)。
值得一提的是,这个问题可以用动态规划方法解决,时间复杂度一样,但是空间复杂度至少要 O(N^2) 来存储 DP table。这道题是少有的动态规划非最优解法的问题。
另外,这个问题还有一个巧妙的解法,时间复杂度只需要 O(N),不过该解法比较复杂,我个人认为没必要掌握。该算法的名字叫 Manacher’s Algorithm(马拉车算法),有兴趣的读者可以自行搜索一下。

上一篇:如何去除有序数组的重复元素
下一篇:如何k个一组反转链表
目录

我最近在 LeetCode 上做到两道非常有意思的题目,382 和 398 题,关于水塘抽样算法(Reservoir Sampling),本质上是一种随机概率算法,解法应该说会者不难,难者不会。
我第一次见到这个算法问题是谷歌的一道算法题:给你一个未知长度的链表,请你设计一个算法,只能遍历一次,随机地返回链表中的一个节点。
这里说的随机是均匀随机(uniform random),也就是说,如果有 n 个元素,每个元素被选中的概率都是 1/n,不可以有统计意义上的偏差。
一般的想法就是,我先遍历一遍链表,得到链表的总长度 n,再生成一个 [1,n] 之间的随机数为索引,然后找到索引对应的节点,不就是一个随机的节点了吗?
但题目说了,只能遍历一次,意味着这种思路不可行。题目还可以再泛化,给一个未知长度的序列,如何在其中随机地选择 k 个元素?想要解决这个问题,就需要著名的水塘抽样算法了。

算法实现

先解决只抽取一个元素的问题,这个问题的难点在于,随机选择是「动态」的,比如说你现在你有 5 个元素,你已经随机选取了其中的某个元素 a 作为结果,但是现在再给你一个新元素 b,你应该留着 a 还是将 b 作为结果呢,以什么逻辑选择 ab 呢,怎么证明你的选择方法在概率上是公平的呢?
先说结论,当你遇到第 i 个元素时,应该有 1/i 的概率选择该元素,1 - 1/i 的概率保持原有的选择。看代码容易理解这个思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 返回链表中一个随机节点的值 */
int getRandom(ListNode head) {
Random r = new Random();
int i = 0, res = 0;
ListNode p = head;
// while 循环遍历链表
while (p != null) {
// 生成一个 [0, i) 之间的整数
// 这个整数等于 0 的概率就是 1/i
if (r.nextInt(++i) == 0) {
res = p.val;
}
p = p.next;
}
return res;
}

对于概率算法,代码往往都是很浅显的,但是这种问题的关键在于证明,你的算法为什么是对的?为什么每次以 1/i 的概率更新结果就可以保证结果是平均随机(uniform random)?
证明:假设总共有 n 个元素,我们要的随机性无非就是每个元素被选择的概率都是 1/n 对吧,那么对于第 i 个元素,它被选择的概率就是:
$$
\begin{aligned}
&\frac{1}{i} \times (1 - \frac{1}{i+1}) \times (1 - \frac{1}{i+2}) \times … \times (1 - \frac{1}{n}) \
= &\frac{1}{i} \times \frac{i}{i+1} \times \frac{i+1}{i+2} \times … \times \frac{n-1}{n} \
= &\frac{1}{n}
\end{aligned}
$$
i 个元素被选择的概率是 1/i,第 i+1 次不被替换的概率是 1 - 1/(i+1),以此类推,相乘就是第 i 个元素最终被选中的概率,就是 1/n
因此,该算法的逻辑是正确的。
同理,如果要随机选择 k 个数,只要在第 i 个元素处以 k/i 的概率选择该元素,以 1 - k/i 的概率保持原有选择即可。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 返回链表中 k 个随机节点的值 */
int[] getRandom(ListNode head, int k) {
Random r = new Random();
int[] res = new int[k];
ListNode p = head;
// 前 k 个元素先默认选上
for (int j = 0; j < k && p != null; j++) {
res[j] = p.val;
p = p.next;
}
int i = k;
// while 循环遍历链表
while (p != null) {
// 生成一个 [0, i) 之间的整数
int j = r.nextInt(++i);
// 这个整数小于 k 的概率就是 k/i
if (j < k) {
res[j] = p.val;
}
p = p.next;
}
return res;
}

对于数学证明,和上面区别不大:
$$
\begin{aligned}
&\frac{k}{i} \times (1 - \frac{k}{i+1} \times \frac{1}{k}) \times (1 - \frac{k}{i+2} \times \frac{1}{k}) \times … \times (1 - \frac{k}{n} \times \frac{1}{k}) \
= &\frac{k}{i} \times (1 - \frac{1}{i+1}) \times (1 - \frac{1}{i+2}) \times … \times (1 - \frac{1}{n}) \
= &\frac{k}{i} \times \frac{i}{i+1} \times \frac{i+1}{i+2} \times … \times \frac{n-1}{n} \
= &\frac{k}{n}
\end{aligned}
$$
因为虽然每次更新选择的概率增大了 k 倍,但是选到具体第 i 个元素的概率还是要乘 1/k,也就回到了上一个推导。

拓展延伸

以上的抽样算法时间复杂度是 O(n),但不是最优的方法,更优化的算法基于几何分布(geometric distribution),时间复杂度为 O(k + klog(n/k))。由于涉及的数学知识比较多,这里就不列出了,有兴趣的读者可以自行搜索一下。
还有一种思路是基于「Fisher–Yates 洗牌算法」的。随机抽取 k 个元素,等价于对所有元素洗牌,然后选取前 k 个。只不过,洗牌算法需要对元素的随机访问,所以只能对数组这类支持随机存储的数据结构有效。
另外有一种思路也比较有启发意义:给每一个元素关联一个随机数,然后把每个元素插入一个容量为 k 的二叉堆(优先级队列)按照配对的随机数进行排序,最后剩下的 k 个元素也是随机的。
这个方案看起来似乎有点多此一举,因为插入二叉堆需要 O(logk) 的时间复杂度,所以整个抽样算法就需要 O(nlogk) 的复杂度,还不如我们最开始的算法。但是,这种思路可以指导我们解决加权随机抽样算法,权重越高,被随机选中的概率相应增大,这种情况在现实生活中是很常见的,比如你不往游戏里充钱,就永远抽不到皮肤。
最后,我想说随机算法虽然不多,但其实很有技巧的,读者不妨思考两个常见且看起来很简单的问题:
1、如何对带有权重的样本进行加权随机抽取?比如给你一个数组 w,每个元素 w[i] 代表权重,请你写一个算法,按照权重随机抽取索引。比如 w = [1,99],算法抽到索引 0 的概率是 1%,抽到索引 1 的概率是 99%。
2、实现一个生成器类,构造函数传入一个很长的数组,请你实现 randomGet 方法,每次调用随机返回数组中的一个元素,多次调用不能重复返回相同索引的元素。要求不能对该数组进行任何形式的修改,且操作的时间复杂度是 O(1)。
这两个问题都是比较困难的,以后有时间我会写一写相关的文章。

上一篇:如何判断回文链表
下一篇:如何调度考生的座位
目录

如何寻找消失的元素

之前也有文章写过几个有趣的智力题,今天再聊一道巧妙的题目。
题目非常简单:

给一个长度为 n 的数组,其索引应该在 [0,n),但是现在你要装进去 n + 1 个元素 [0,n],那么肯定有一个元素装不下嘛,请你找出这个缺失的元素。
这道题不难的,我们应该很容易想到,把这个数组排个序,然后遍历一遍,不就很容易找到缺失的那个元素了吗?
或者说,借助数据结构的特性,用一个 HashSet 把数组里出现的数字都储存下来,再遍历 [0,n] 之间的数字,去 HashSet 中查询,也可以很容易查出那个缺失的元素。
排序解法的时间复杂度是 O(NlogN),HashSet 的解法时间复杂度是 O(N),但是还需要 O(N) 的空间复杂度存储 HashSet。
第三种方法是位运算
对于异或运算(^),我们知道它有一个特殊性质:一个数和它本身做异或运算结果为 0,一个数和 0 做异或运算还是它本身。
而且异或运算满足交换律和结合律,也就是说:
2 ^ 3 ^ 2 = 3 ^ (2 ^ 2) = 3 ^ 0 = 3
而这道题索就可以通过这些性质巧妙算出缺失的那个元素。比如说 nums = [0,3,1,4]

为了容易理解,我们假设先把索引补一位,然后让每个元素和自己相等的索引相对应:

这样做了之后,就可以发现除了缺失元素之外,所有的索引和元素都组成一对儿了,现在如果把这个落单的索引 2 找出来,也就找到了缺失的那个元素。
如何找这个落单的数字呢,只要把所有的元素和索引做异或运算,成对儿的数字都会消为 0,只有这个落单的元素会剩下,也就达到了我们的目的。

1
2
3
4
5
6
7
8
9
10
int missingNumber(int[] nums) {
int n = nums.length;
int res = 0;
// 先和新补的索引异或一下
res ^= n;
// 和其他的元素、索引做异或
for (int i = 0; i < n; i++)
res ^= i ^ nums[i];
return res;
}


由于异或运算满足交换律和结合律,所以总是能把成对儿的数字消去,留下缺失的那个元素的。
至此,时间复杂度 O(N),空间复杂度 O(1),已经达到了最优,我们是否就应该打道回府了呢?
如果这样想,说明我们受算法的毒害太深,随着我们学习的知识越来越多,反而容易陷入思维定式,这个问题其实还有一个特别简单的解法:等差数列求和公式
题目的意思可以这样理解:现在有个等差数列 0, 1, 2,…, n,其中少了某一个数字,请你把它找出来。那这个数字不就是 sum(0,1,..n) - sum(nums) 嘛?

1
2
3
4
5
6
7
8
int missingNumber(int[] nums) {
int n = nums.length;
// 公式:(首项 + 末项) * 项数 / 2
int expect = (0 + n) * (n + 1) / 2;
int sum = 0;
for (int x : nums)
sum += x;
return expect - sum;

你看,这种解法应该是最简单的,但说实话,我自己也没想到这个解法,而且我去问了几个大佬,他们也没想到这个最简单的思路。相反,如果去问一个初中生,他也许很快就能想到。
做到这一步了,我们是否就应该打道回府了呢?
如果这样想,说明我们对细节的把控还差点火候。在用求和公式计算 expect 时,你考虑过整型溢出吗?如果相乘的结果太大导致溢出,那么结果肯定是错误的。
刚才我们的思路是把两个和都加出来然后相减,为了避免溢出,干脆一边求和一边减算了。很类似刚才位运算解法的思路,仍然假设 nums = [0,3,1,4],先补一位索引再让元素跟索引配对:

我们让每个索引减去其对应的元素,再把相减的结果加起来,不就是那个缺失的元素吗?

1
2
3
4
5
6
7
8
9
10
public int missingNumber(int[] nums) {
int n = nums.length;
int res = 0;
// 新补的索引
res += n - 0;
// 剩下索引和元素的差加起来
for (int i = 0; i < n; i++)
res += i - nums[i];
return res;
}

由于加减法满足交换律和结合律,所以总是能把成对儿的数字消去,留下缺失的那个元素的。
至此这道算法题目经历九曲十八弯,终于再也没有什么坑了。

上一篇:如何判定括号合法性
下一篇:如何寻找缺失和重复的元素
目录

今天就聊一道很看起来简单却十分巧妙的问题,寻找缺失和重复的元素。之前的一篇文章「寻找缺失元素」也写过类似的问题,不过这次的和上次的问题使用的技巧不同。
这是 LeetCode 645 题,我来描述一下这个题目:
给一个长度为 N 的数组 nums,其中本来装着 [1..N]N 个元素,无序。但是现在出现了一些错误,nums 中的一个元素出现了重复,也就同时导致了另一个元素的缺失。请你写一个算法,找到 nums 中的重复元素和缺失元素的值。

1
2
// 返回两个数字,分别是 {dup, missing}
vector<int> findErrorNums(vector<int>& nums);

比如说输入:nums = [1,2,2,4],算法返回 [2,3]
其实很容易解决这个问题,先遍历一次数组,用一个哈希表记录每个数字出现的次数,然后遍历一次 [1..N],看看那个元素重复出现,那个元素没有出现,就 OK 了。
但问题是,这个常规解法需要一个哈希表,也就是 O(N) 的空间复杂度。你看题目给的条件那么巧,在 [1..N] 的几个数字中恰好有一个重复,一个缺失,事出反常必有妖,对吧。
O(N) 的时间复杂度遍历数组是无法避免的,所以我们可以想想办法如何降低空间复杂度,是否可以在 O(1) 的空间复杂度之下找到重复和确实的元素呢?

思路分析

这个问题的特点是,每个元素和数组索引有一定的对应关系。
我们现在自己改造下问题,暂且将 nums 中的元素变为 [0..N-1],这样每个元素就和一个数组索引完全对应了,这样方便理解一些
如果说 nums 中不存在重复元素和缺失元素,那么每个元素就和唯一一个索引值对应,对吧?
现在的问题是,有一个元素重复了,同时导致一个元素缺失了,这会产生什么现象呢?会导致有两个元素对应到了同一个索引,而且会有一个索引没有元素对应过去
那么,如果我能够通过某些方法,找到这个重复对应的索引,不就是找到了那个重复元素么?找到那个没有元素对应的索引,不就是找到了那个缺失的元素了么?
那么,如何不使用额外空间判断某个索引有多少个元素对应呢?这就是这个问题的精妙之处了:
通过将每个索引对应的元素变成负数,以表示这个索引被对应过一次了

如果出现重复元素 4,直观结果就是,索引 4 所对应的元素已经是负数了:

对于缺失元素 3,直观结果就是,索引 3 所对应的元素是正数:

对于这个现象,我们就可以翻译成代码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
vector<int> findErrorNums(vector<int>& nums) {
int n = nums.size();
int dup = -1;
for (int i = 0; i < n; i++) {
int index = abs(nums[i]);
// nums[index] 小于 0 则说明重复访问
if (nums[index] < 0)
dup = abs(nums[i]);
else
nums[index] *= -1;
}
int missing = -1;
for (int i = 0; i < n; i++)
// nums[i] 大于 0 则说明没有访问
if (nums[i] > 0)
missing = i;

return {dup, missing};
}

这个问题就基本解决了,别忘了我们刚才为了方便分析,假设元素是 [0..N-1],但题目要求是 [1..N],所以只要简单修改两处地方即可得到原题的答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
vector<int> findErrorNums(vector<int>& nums) {
int n = nums.size();
int dup = -1;
for (int i = 0; i < n; i++) {
// 现在的元素是从 1 开始的
int index = abs(nums[i]) - 1;
if (nums[index] < 0)
dup = abs(nums[i]);
else
nums[index] *= -1;
}
int missing = -1;
for (int i = 0; i < n; i++)
if (nums[i] > 0)
// 将索引转换成元素
missing = i + 1;

return {dup, missing};
}

其实,元素从 1 开始是有道理的,也必须从一个非零数开始。因为如果元素从 0 开始,那么 0 的相反数还是自己,所以如果数字 0 出现了重复或者缺失,算法就无法判断 0 是否被访问过。我们之前的假设只是为了简化题目,更通俗易懂。

最后总结

对于这种数组问题,关键点在于元素和索引是成对儿出现的,常用的方法是排序、异或、映射
映射的思路就是我们刚才的分析,将每个索引和元素映射起来,通过正负号记录某个元素是否被映射。
排序的方法也很好理解,对于这个问题,可以想象如果元素都被从小到大排序,如果发现索引对应的元素如果不相符,就可以找到重复和缺失的元素。
异或运算也是常用的,因为异或性质 a ^ a = 0, a ^ 0 = a,如果将索引和元素同时异或,就可以消除成对儿的索引和元素,留下的就是重复或者缺失的元素。可以看看前文「寻找缺失元素」,介绍过这种方法。

上一篇:如何寻找消失的元素
下一篇:如何判断回文链表
目录

高频面试系列