欢迎访问我的博客首页。
优化时间和空间效率
1. 时间效率
1.1 数组中出现次数超过一半的数字
题目:从数组中找出出现次数超过一半的数字。
分析:如果数组有序,只需用 O(n) 的时间就能找出结果。因为排序的最低时间复杂度是
O
(
l
o
g
2
n
)
O(log_2n)
O(log2n),所以这种方法也是同样的时间复杂度。
1. 数组中第 n/2 小的数:O(n)
数组中出现次数超过一半的数字必然是数组的中位数,即如果 x 是数组 arr 中出现次数超过一半的数字,则 arr[n/2] = x。反之则不然。由于快速排序每次可以确定一个元素的位置,假如它确定了 arr[n/2] 的位置,那么我们统计一下数组中 arr[n/2] 的个数就知道结果了。因此该算法分两步:
- 使用快速排到 arr[n/2] 时停止,记录 arr[n/2] 的值为 x。
- 遍历 arr 统计 x 的次数是否大于等于 n 整除 2 加 1。
int getRand(int a, int b) {
return rand() % (b - a + 1) + a;
}
int partition(vector<int>& arr, int start, int end) {
int rdm = getRand(start, end);
swap(arr[rdm], arr[start]);
int pivotkey = arr[start];
while (start < end) {
while (start < end && arr[end] >= pivotkey)
end--;
arr[start] = arr[end];
while (start < end && arr[start] <= pivotkey)
start++;
arr[end] = arr[start];
}
arr[start] = pivotkey;
return start;
}
int majorityElement(vector<int>& nums) {
int size = nums.size();
int start = 0, middle = size >> 1, end = size - 1;
int index = partition(nums, start, end);
while (index != middle) {
if (index > middle) {
end = index - 1;
index = partition(nums, start, end);
}
else {
start = index + 1;
index = partition(nums, start, end);
}
}
return nums[middle];
}
代码:函数 majorityElement 的第 25 行指定求数组中第 n/2 大的数。注意在第 35 行返回结果前应该统计这个结果在数组中出现的次数,确定是不是满足要求。数组基本有序时基准元素总是最值,快速排序退化成选择排序。为了避免这种情况,第 6 行随机挑选待排序的元素作为基准元素。这一步很重要,否则在力扣会超时。
时间复杂度:使用 partition 函数解决 top k 问题的算法和快速排序、二分查找都不太一样:解决 top k 问题时每次处理数据的一半,另一半无须处理,处理一半时要逐个元素地处理。基准元素出现的位置的期望是在数组中间,所以时间复杂度是 O(n + n/2 + n/4 + … + 1) = O(n)。
2. 抵消法:O(n)
分析:出现次数超过一半也就是说该数字出现的次数比其它数字出现的次数和还要多。我们可以使用两个变量:number 记录元素,times 记录次数。遍历数组的元素 x,如果 x 与 number 相同,times 加 1,否则 times 减 1。times 为 0 时让 number = x, time = 1。
int majorityElement(vector<int>& nums) {
if (nums.size() == 0)
return 0;
int number, times = 0;
for (auto x : nums) {
if (times == 0) {
number = x;
times++;
}
else {
if (x == number)
times++;
else
times--;
}
}
return number;
}
这种方法简单且不会改变数组,第一种方法会改变数组。
1.2 最小的 k 个数
题目:从数组中找出前 k 个最小的数。
分析:这是 top k 问题。可以使用上一题的方法,利用 partition 函数确定第 k 个元素的位置,然后前 k 的元素即为所求,这会修改数组。
1. 排序树:O(nlogk)
先创建一个容积为 k 的容器,把数组的前 k 的元素放进来。然后每读取一个元素 x,如果 x 小于容器内的最大值,用 x 替换这个最大值,否则不操作。因为操作主要是更新最大值,所以可以用红黑树或大顶堆。下面以红黑树为例:
void GetLeastNumbers(const vector<int>& data, multiset<int, greater<int>>& leastNumbers, int k) {
leastNumbers.clear();
multiset<int, greater<int>>::iterator iterGreatest;
vector<int>::const_iterator it;
for (it = data.begin(); it != data.end(); it++) {
if (leastNumbers.size() < k)
leastNumbers.insert(*it);
else {
iterGreatest = leastNumbers.begin();
if (*it < *iterGreatest) {
leastNumbers.erase(iterGreatest);
leastNumbers.insert(*it);
}
}
}
}
vector<int> getLeastNumbers(vector<int>& arr, int k) {
if (k < 1)
return{};
if (arr.size() <= k)
return arr;
multiset<int, greater<int>> inSet;
GetLeastNumbers(arr, inSet, k);
vector<int> res;
for (auto x : inSet)
res.push_back(x);
reverse(res.begin(), res.end());
return res;
}
代码:上面使用的是 STL 中的红黑树。该算法时间复杂度是 n l o g 2 n nlog_2n nlog2n,虽然没有基于选择排序的方法好,但该算法不修改原数组,且可以逐个读取原数组的元素,适合处理海量数据。
2. 二叉排序树
1.3 数据流中的中位数
题目:从数据流中找出中位数。如果数据流中有奇数个值,中位数是排序后中间那个数。如果数据流中有偶数个值,中位数是排序后中间两个数的平均值。
分析:数据流随着时间增长,我们的程序需要从不断插入新数据的序列中找中位数。所以我们既要考虑插入性能也要考虑得到中位数的性能:
数据结构 | 插入的时间复杂度 | 得到中位数的时间复杂度 |
---|---|---|
无序数组 | O(1) | O(n) |
有序数组 | O(n) | O(1) |
有序链表 | O(n) | O(1) |
二叉搜索树 | 平均 O ( l o g 2 n ) O(log_2n) O(log2n),最差O(n) | 平均 O ( l o g 2 n ) O(log_2n) O(log2n),最差O(n) |
平衡二叉查找树(AVL树/红黑树) | O ( l o g 2 n ) O(log_2n) O(log2n) | O(1) |
堆 | O ( l o g 2 n ) O(log_2n) O(log2n) | O(1) |
表 1 不 同 存 储 方 式 下 求 解 中 位 数 的 性 能 比 较 表\ 1\quad不同存储方式下求解中位数的性能比较 表 1不同存储方式下求解中位数的性能比较
综上所述,使用平衡二叉查找树或堆的性能最好。
1. 使用堆
class MedianFinder {
public:
MedianFinder() {}
void addNum(int num) {
if (lMaxHeap.size() == 0 || num <= lMaxHeap.front()) {
lMaxHeap.push_back(num);
push_heap(lMaxHeap.begin(), lMaxHeap.end(), less<int>());
}
else {
rMinHeap.push_back(num);
push_heap(rMinHeap.begin(), rMinHeap.end(), greater<int>());
}
adjust();
}
double findMedian() {
if (lMaxHeap.size() == 0)
throw logic_error("No element!");
if (lMaxHeap.size() > rMinHeap.size())
return lMaxHeap.front();
return (lMaxHeap.front() + rMinHeap.front()) / 2.0;
}
private:
void adjust() {
if (lMaxHeap.size() > rMinHeap.size() + 1) {
int x = lMaxHeap.front();
pop_heap(lMaxHeap.begin(), lMaxHeap.end(), less<int>());
lMaxHeap.pop_back();
rMinHeap.push_back(x);
push_heap(rMinHeap.begin(), rMinHeap.end(), greater<int>());
}
else if (lMaxHeap.size() < rMinHeap.size()) {
int x = rMinHeap.front();
pop_heap(rMinHeap.begin(), rMinHeap.end(), greater<int>());
rMinHeap.pop_back();
lMaxHeap.push_back(x);
push_heap(lMaxHeap.begin(), lMaxHeap.end(), less<int>());
}
}
vector<int> lMaxHeap;
vector<int> rMinHeap;
};
调用 addNum 函数后,lMaxHeap 的元素数量可能比 rMinHeap 多 2 个、多 1 个、少 1 个,前后两种情况都要调整。调用 adjust 函数后,lMaxHeap 中的元素个数要么和 rMinHeap 相等,要么比它多 1 个。
使用 STL 中的函数 push_heap、pop_heap 及容器 vector 实现堆,使用比较仿函数 less 和 greater 实现大顶堆和小顶堆。注意添加元素时先调用 vector::push_back 再调用 push_heap,删除元素时先调用 pop_heap 再调用 vector::pop_back()。
2. 使用红黑树
当然我们还可以使用红黑树实现上述算法:
class MedianFinder {
public:
MedianFinder() {}
void addNum(int num) {
if (lTree.size() == 0 || num <= *lTree.cbegin())
lTree.insert(num);
else
rTree.insert(num);
adjust();
}
double findMedian() {
if (lTree.size() == 0)
throw logic_error("No element!");
if (lTree.size() > rTree.size())
return *lTree.cbegin();
return (*lTree.cbegin() + *rTree.cbegin()) / 2.0;
}
private:
void adjust() {
if (lTree.size() > rTree.size() + 1) {
int x = *lTree.cbegin();
lTree.erase(lTree.cbegin());
rTree.insert(x);
}
else if (lTree.size() < rTree.size()) {
int x = *rTree.cbegin();
rTree.erase(rTree.begin());
lTree.insert(x);
}
}
multiset<int, greater<int>> lTree;
multiset<int, less<int>> rTree;
};
红黑树是用链表实现的,链表的存储效率较低且不能随机访问。本题只需求最大值无需排序,所以应该优先使用堆。
1.4 连续子数组的最大和
题目:一组序列有若干整数,求它的和最大的那个连续子数组的最大和。
分析:注意结果可能是负数。
1. 动态规划
int maxSubArray(vector<int>& nums) {
if (nums.size() == 0)
return 0;
int size = nums.size();
int* dp = new int[size];
dp[0] = nums[0];
for (int i = 1; i < size; i++) {
dp[i] = max(dp[i - 1] + nums[i], nums[i]);
}
int res = dp[0];
for (int i = 0; i < size; i++)
if (res < dp[i])
res = dp[i];
delete[] dp;
return res;
}
代码:第 8 行的状态方程根据比大小决定要不要 nums[i] 前面的部分。
2. 简化版的动态规划
分析:既然小于 0 的前缀会减小最大和,我们遇到这样的前缀就及时丢掉。
int maxSubArray(vector<int>& nums) {
if (nums.size() == 0)
return 0;
int res = INT_MIN, sum = 0;
for (auto x : nums) {
sum = max(sum + x, x);
res = max(res, sum);
}
return res;
}
1.5 1~n 整数中 1 出现的次数
题目:输入一个整数 n,输出 1 到 n 这些十进制数中 1 出现的次数。比如 n = 12 时 1 出现了 5 次,分别出现在 1, 10, 11, 12 这 4 个数中。
1. 模拟达芬奇密码筒
问题:从 1 到 n 的十进制数中找出 x 出现的次数。
分析:假如 n 是一个 k 位数。我们可以统计 1 到 n 这些数中,第 1 个数位(个位)上为 x 的数的个数
c
o
u
n
t
1
count_1
count1,第 2 个数位(十位)上为 x 的数的个数
c
o
u
n
t
2
count_2
count2,…,第 k 个数位上为 x 的数的个数
c
o
u
n
t
k
count_k
countk。结果就是
c
o
u
n
t
1
+
c
o
u
n
t
2
+
.
.
.
+
c
o
u
n
t
k
count_1 + count_2 + ... + count_k
count1+count2+...+countk。
图
1
达
芬
奇
密
码
筒
图 \ 1 \quad 达芬奇密码筒
图 1达芬奇密码筒
当我们统计第 i 个数位上为 x 的数有多少个时,相当于在密码筒上把第 i 位固定为 x,然后滑动它左边的 k - i 位和右边的 i - 1位,统计这左右两边共 k - 1 位有多少种组合。下面以 n = 52014,x = 1 为例说明,其中 current 为 n 的第 i 个数位上的数,left 为左边 k - i 位数组成的十进制数,right 为右边 i - 1 位数组成的十进制数。
- 当 i = 2 时,left = 520,current = 1 = x,right = 4。当 left 取值 [0, 519] 时 right 任意取值 [0, 9],当 left 取值 520 时 right 取值 [0, 4],所以 c o u n t 2 = l e f t × 1 0 i − 1 + 1 × ( r i g h t + 1 ) = 5202 count_2 = left \times 10^{i - 1} + 1 \times (right + 1) = 5202 count2=left×10i−1+1×(right+1)=5202。
- 当 i = 3 时,left = 52,current = 0 < x,right = 14。left 只能在 [0, 51] 取值,right 任意取值 [0, 99],所以 c o u n t 3 = l e f t × 1 0 i − 1 = 5200 count_3 = left \times 10^{i - 1} = 5200 count3=left×10i−1=5200。
- 当 i = 4 时,left = 5,current = 2 > x,right = 14。left 取值 [0, 5],right 任意取值 [0, 999]。所以 c o u n t 4 = ( l e f t + 1 ) × 1 0 i − 1 = 6000 count_4 = (left + 1) \times 10^{i - 1} = 6000 count4=(left+1)×10i−1=6000
Δ c o u n t i = { l e f t × 1 0 i − 1 c u r r e n t < x ( l e f t + 1 ) × 1 0 i − 1 c u r r e n t > x l e f t × 1 0 i − 1 + 1 × ( r i g h t + 1 ) c u r r e n t = x \Delta count_i = \begin{cases} left \times 10^{i - 1} \ & current < x \\ (left + 1) \times 10^{i - 1} \ & current > x \\ left \times 10^{i - 1} + 1 \times (right + 1) \ & current = x \end{cases} Δcounti=⎩⎪⎨⎪⎧left×10i−1 (left+1)×10i−1 left×10i−1+1×(right+1) current<xcurrent>xcurrent=x
比如,求 1 到 52014 中 1 出现的次数,分析过程如下表 2:
i | left | current | right | c o u n t i count_i counti |
---|---|---|---|---|
52014 | ||||
1 | 5201 | 4 | 0 |
c
u
r
r
e
n
t
>
1
:
current > 1:
current>1: c o u n t 1 = ( l e f t + 1 ) × 1 0 i − 1 = ( 5201 + 1 ) × 1 0 0 = 5202. count_1 = (left + 1) \times 10^{i-1} = (5201 + 1) \times 10^0 = 5202. count1=(left+1)×10i−1=(5201+1)×100=5202. |
2 | 520 | 1 | 4 |
c
u
r
r
e
n
t
=
1
current = 1
current=1: c o u n t 2 = l e f t × 1 0 i − 1 + 1 × ( r i g h t + 1 ) = 520 × 1 0 1 + 1 × ( 4 + 1 ) = 5205. count_2 = left \times 10^{i - 1} + 1 \times (right + 1) = 520 \times 10^1 + 1 \times (4 + 1) = 5205. count2=left×10i−1+1×(right+1)=520×101+1×(4+1)=5205. |
3 | 52 | 0 | 14 |
c
u
r
r
e
n
t
<
1
current \lt 1
current<1: c o u n t 3 = l e f t × 1 0 i − 1 = 52 × 1 0 2 = 5200. count_3 = left \times 10^{i - 1} = 52 \times 10^2 = 5200. count3=left×10i−1=52×102=5200. |
4 | 5 | 2 | 014 |
c
u
r
r
e
n
t
>
1
current \gt 1
current>1: c o u n t 4 = ( l e f t + 1 ) × 1 0 i − 1 = ( 5 + 1 ) × 1 0 3 = 6000. count_4 = (left + 1) \times 10^{i - 1} = (5 + 1) \times 10^3 = 6000. count4=(left+1)×10i−1=(5+1)×103=6000. |
5 | 0 | 5 | 2014 |
c
u
r
r
e
n
t
>
1
current \gt 1
current>1: c o u n t 5 = ( l e f t + 1 ) × 1 0 i − 1 = ( 0 + 1 ) × 1 0 4 = 10000. count_5 = (left + 1) \times 10^{i - 1} = (0 + 1) \times 10^4 = 10000. count5=(left+1)×10i−1=(0+1)×104=10000. |
表 2 求 1 到 52014 中 1 出 现 的 次 数 表\ 2\quad 求 1 到 52014 中 1 出现的次数 表 2求1到52014中1出现的次数
首先注意 left、current、right 分别代表当前数位左面的部分、当前数位、当前数位右面的部分。然后根据当前数位与 1 的三种大小关系计算次数。
int NumberOfDigitX(int n, int x) {
if (x > 9 || x < 0 || n < 0)
return 0;
if (n == 0)
return x == 0;
int count = 0, left = n, current, right;
for (int i = 1; i <= ceil(log10(n + 1)); i++) {
left = left / 10;
current = (n - left * pow(10, i)) / pow(10, i - 1);
right = n - left * pow(10, i) - current * pow(10, i - 1);
if (current < x)
count += left * pow(10, i - 1);
else if (current > x)
count += (left + 1) * pow(10, i - 1);
else
count += left * pow(10, i - 1) + right + 1;
}
return count;
}
代码:上面的代码用于计算 1 到 n 中数字 x 出现的次数。注意第 7 行循环执行的条件是 i <= ceil(log10(n + 1)),当然条件也可以是 h i g h ≠ 0 high \neq 0 high=0。当 n = 0 时循环不执行,这种情况需提前在第 4、5 行单独处理。
2. 递归
int recurse(string& num, int x, int start = 0) {
if (start == num.size())
return 0;
int size = num.size() - start;
if (size == 1)
return num[start] - '0' >= x;
int first = num[start] - '0';
int numFirstDigit = 0;
if (first > x)
numFirstDigit = pow(10, size - 1);
else if (first == x)
numFirstDigit = atoi(num.substr(start + 1).c_str()) + 1;
int numOtherDigits = first * (size - 1) * pow(10, size - 2);
int numRecursive = recurse(num, x, start + 1);
return numFirstDigit + numOtherDigits + numRecursive;
}
int NumberOfDigitX(int n, int x) {
stringstream ss;
string num;
ss << n;
ss >> num;
return recurse(num, x);
}
1.6 数字序列中某一位的数字
题目:对 012345678910111213… 这样的序列,求任意第 n 位对应的数字。这个序列是序列化是从 0 开始连续的整数得到的。
图
2
图 \ 2
图 2
分析:把数位相同的数划分到同一区间,比如 0 ~ 9 是第一区间、10 ~ 99 是第二区间等。n 指向的是某个整数的某个数位,解决问题的关键是知道 n 指向的是哪个整数。想知道 n 指向的是哪个整数,先要知道每个区间的长度。
int findNthDigit(size_t n) {
int x = 1;
size_t sum = 0;
while (true) {
sum += 9 * pow(10, x - 1) * x;
if (sum >= n)
break;
x++;
}
size_t beginIndex = sum - 9 * pow(10, x - 1) * x + 1;
size_t beginNumber = pow(10, x - 1);
size_t residual = n - beginIndex;
int a = residual / x;
int b = residual % x;
int num = beginNumber + a;
string str = to_string(num);
return str[b] - '0';
}
为了避免混淆,我们把 99 这样的数叫做递增数,把序列中每个下标对应的数叫做下标数。
第一步找出 n 所指的递增数 num 位于哪个区间,比如 n=2889 指向的 999 在第 3 区间。第 4 到第 9 行用于找出 num 所在的区间 x。第 10 行的 beginIndex 是区间 x 的第一个下标数的下标。第 11 行的 beginNumber 是区间 x 的第一个递增数。第 12 行的 residual 是 n 于 beginIndex 的距离。已知这个区间每个递增数有 x 位,根据 residual 可以知道 n 指向的递增数是 num 的第 b 位数(最高位是第一位)。
1.7 把数组排成最小的数
题目:输入一个正整数数组,用数组元素拼接成一个最小的数。例如数组是 {3, 32, 321},则最小的数是 321323。
分析:本题的关键是排序,排序的关键是字符串的比较:如果 a + b > b + a,a 应该在 b 后面。
bool compare(string& a, string& b) {
string sa = a + b, sb = b + a;
return strcmp(sa.c_str(), sb.c_str()) >= 0;
}
int partition(vector<string>& arr, int start, int end) {
string pivotkey = arr[start];
while (start < end) {
while (start < end && compare(arr[end], pivotkey))
end--;
arr[start] = arr[end];
while (start < end && compare(pivotkey, arr[start]))
start++;
arr[end] = arr[start];
}
arr[start] = pivotkey;
return start;
}
void quick_sort(vector<string>& arr, int start, int end) {
if (start >= end)
return;
int pivotloc = partition(arr, start, end);
quick_sort(arr, start, pivotloc - 1);
quick_sort(arr, pivotloc + 1, end);
}
string minNumber(vector<int>& nums) {
if (nums.size() == 0)
return "";
vector<string> strs;
for (auto x: nums)
strs.push_back(to_string(x));
quick_sort(strs, 0, strs.size() - 1);
string res;
for (auto x: strs)
res += x;
return res;
}
1.8 把数字翻译成字符串
题目:给定一个数字,按照下面的规则翻译成字符串:数字 0 翻译成字符 a、数字 1 翻译成字符 b、…、数字 25 翻译成字符 z。注意一个数字可能有多种翻译,比如数字 12 可以翻译成字符 bc 和 m。对于给定的数字,输出有多少种翻译方法。
分析:这道题适合动态规划和递归。首先确定边界条件。只有 1 位数字时只有一种翻译,如果数字有两位或更多,就看这两位数能不能合并,下面代码第 3 行是不能合并的情况。
int translateNum(string& str, int start = 0) {
if (start >= str.size() - 1)
return 1;
if (str[start] == '0' || str[start] >= '3' || (str[start] == '2' && str[start + 1] > '5'))
return translateNum(str, start + 1);
return translateNum(str, start + 1) + translateNum(str, start + 2);
}
1.9 礼物的最大价值
题目:在一个 h 行 w 列的棋盘上,每格都有一个标有价值 value 的礼物,value > 0。从格子左上角走到右下角,取遇到的礼物。每一步只能只能向右或向下走一格。求可以取到礼物的最大价值。
分析:这是一个简单的动态规划。
int maxValue(vector<vector<int>>& grid) {
if (grid.size() == 0)
return 0;
int h = grid.size(), w = grid[0].size();
vector<vector<int>> dp(h, vector<int>(w));
for (int i = 0; i < h; i++)
for (int j = 0; j < w; j++) {
if (i == 0 && j == 0)
dp[0][0] = grid[0][0];
else if (i == 0)
dp[0][j] = dp[0][j - 1] + grid[0][j];
else if (j == 0)
dp[i][0] = dp[i - 1][0] + grid[i][0];
else
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
return dp[h - 1][w - 1];
}
1.10 最长不含重复字符的子字符串
题目:请从字符串中找出一个最长的不包含重复字符串的子字符串。字符串只包含 a 到 z 的小写字母。
分析:因为是找不重复子串而不是递增子串,所以动态规划和递归都不适合。因为要判断是否重复,所以可以使用 set、map,又因为不仅需要知道是否重复时,还需要知道重复的字符上一次出现在哪个位置,所以下标也需要保存,因此需要使用 map。
int lengthOfLongestSubstring(string s) {
if (s.size() == 0)
return 0;
int res = 0, start = 0;
map<char, int> mp;
for (int i = 0; i < s.size(); i++) {
if (mp.count(s[i]) == 0) {
mp.insert(pair<char, int>(s[i], i));
res = max(res, i - start + 1);
}
else {
int old_start = start;
start = mp[s[i]] + 1;
for (int j = old_start; j < start; j++)
mp.erase(s[j]);
mp.insert(pair<char, int>(s[i], i));
res = max(res, i - start + 1);
}
}
return res;
}
2. 时间效率与空间效率的平衡
一般情况下,提高时间效率比节省空间更重要,即时间复杂度为 O(n)、空间复杂度为 O(n) 的算法优于时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)、空间复杂度为 O(1) 的算法。
待处理的数据范围固定时,可以使用常量空间把时间复杂度从
O
(
n
2
)
O(n^2)
O(n2) 降到 O(n)。比如计数排序,比如涉及字符串的问题,如 2.2 节第一个只出现一次的字符。
2.1 丑数
2.2 第一个只出现一次的字符
1. 字符串
题目:输出字符串中第一个只出现一次的字符。
分析:字符共有 256 种,可以定义一个长度为 256 的数组记录每个字符出现的次数。
还可以使用 unordered_map,键存放字符,值存放次数。这样可以出现多少种字符申请多少空间,而不是固定申请长度为 256 的数组。同时,如果字符范围很大,比如包含汉字,使用 unordered_map 更合适。
char firstUniqChar(string s) {
char res = ' ';
if (s.size() == 0)
return res;
short count[256];
memset(count, 0, sizeof(short) * 256);
for (auto x: s) {
if (count[int(x)] == 0)
count[int(x)] = 1;
else
count[int(x)] = 2;
}
for (auto x: s)
if (count[int(x)] == 1) {
res = x;
break;
}
return res;
}
代码:时间复杂度 O(n),空间复杂度 O(1)。第 8 至第 11 行可以换成一行:count[int(x)]++。考虑到字符串可能很长,count 的元素类型应该是 int 或更大类型。
注意第 13行,第二次是遍历字符串而不是 count。count 只需记录三种状态:0 代表字符没有出现,1 代表出现 1 次,2 代表出现 2 次及以上。
2. 字符流
能不能实现只遍历一次字符串的算法呢?假如处理的不是字符串,而是字符流,两次遍历字符流意味着需要使用可增长容器把字符流存储下来。存储的空间复杂度是 O(n),第二次遍历的时间复杂度也是 O(n)。如果只遍历一次字符串,我们只需要长度为 256 的 count 就够了。储存和遍历长度为 256 的数组的空间复杂度和时间复杂度都是 O(1)。
处理字符流的算法如下。count[x] = y:y = -1 指 ASCII 码为 x 的字符至少已经出现了 2 次,y = 0 指 ASCII 码为 x 的字符没有出现过,y >= 1 指 ASCII 码为 x 的字符第一次出现。 定义一个初始值为 1 的变量 index。处理字符流的字符(ASCII 码是 code)时:如果 count[code] = -1,不处理;如果 count[code] = 0,让它等于 index++;如果 count[code] > 0,让它等于 -1。找第一个只出现一次的字符时,只需遍历长度为 256 的数组,在 y >= 1 的元素中找出最小的 y,返回它的下标。
2.3 数组中的逆序对
题目:在数组中的两个数,如果前面的一个数字大于后面的一个数字,则这两个数字组成一个逆序对。输入一个数组,请输出这个数组中的逆序对总数。例如数组 {7,5,6,4} 的逆序对总数是 5,分别是 (7,5)、(7,6,)、(7,4)、(5,4)、(6,4)。
分析:直接数里面的逆序对可能很难,如果我们每次消除一个逆序对直到数组中没有逆序对时,消除逆序对的个数就是原数组中逆序对的总数。而从数组满足元素从小到大排序时才没有逆序对。所以该问题可以转化为排序问题。交换不相邻的两个元素可能影响逆序对的数量,所以只有交换相邻元素的排序算法才满足要求,也就是说只能是稳定排序。
1. O(n2) 的算法
冒泡排序:因为冒泡排序是两两比较,所以交换的次数就是数组中的逆序对的总数。如下面程序的第 7 行。
插入排序:插入排序的前部分有序,假设每次插入操作时,元素前移了 x 的距离,把 x 累加就是数组中的逆序对的总数。如下面程序的第 19 行。
选择排序:选择排序在位置 i 后找到比 nums[i] 小的元素后与 nums[i] 直接交换。这样是不行的,因为交换不相邻的两个元素可能影响逆序对的数量。我们把交换改为插入,这样选择排序就变成了选择插入排序,如下面程序的第 36 至 41 行。
int bubbleSort(vector<int>& nums) {
int res = 0;
for (int i = nums.size() - 1; i > 0; i--)
for (int j = 0; j < i; j++)
if (nums[j] > nums[j + 1]) {
swap(nums[j], nums[j + 1]);
res++;
}
return res;
}
int insertSort(vector<int>& nums) {
int res = 0;
for (int i = 1; i < nums.size(); i++)
if (nums[i] < nums[i - 1]) {
int temp = nums[i], j;
for (j = i; j > 0 && nums[j - 1]>temp; j--) {
nums[j] = nums[j - 1];
res++;
}
nums[j] = temp;
}
return res;
}
int selectSort(vector<int>& nums) {
int res = 0;
for (int i = 0; i < nums.size() - 1; i++) {
int min = i;
for (int j = i + 1; j < nums.size(); j++) {
if (nums[j] < nums[min])
min = j;
}
if (min == i)
continue;
res += min - i;
int temp = nums[min], k;
for (k = min; k > i; k--)
nums[k] = nums[k - 1];
nums[k] = temp;
}
return res;
}
2. O(nlogn) 的算法
快速排序和堆排序都不是稳定排序,它们交换不相邻的元素,可能会改变逆序对数量,所以只能使用归并排序。
void merge(vector<int>& nums, vector<int>& temp, int start, int middle, int end, int& res) {
int p_left = start, p_right = middle + 1, p_temp = 0;
while (p_left <= middle && p_right <= end) {
if (nums[p_left] <= nums[p_right])
temp[p_temp++] = nums[p_left++];
else {
temp[p_temp++] = nums[p_right++];
res += middle - p_left + 1;
}
}
while (p_left <= middle)
temp[p_temp++] = nums[p_left++];
while (p_right <= end)
temp[p_temp++] = nums[p_right++];
for (int i = start; i <= end; i++)
nums[i] = temp[i - start];
}
void MergetSort(vector<int>& nums, vector<int>& temp, int start, int end, int& res) {
if (start >= end)
return;
int middle = (start + end) / 2;
MergetSort(nums, temp, start, middle, res);
MergetSort(nums, temp, middle + 1, end, res);
merge(nums, temp, start, middle, end, res);
}
int reversePairs(vector<int>& nums) {
vector<int> temp = vector<int>(nums.size());
int res = 0;
MergetSort(nums, temp, 0, nums.size() - 1, res);
return res;
}
代码:使用归并排序计算逆序对,关键是在合并两个有序链表时统计交换次数,如第 8 行。假如左边的元素数是 {3,5,6,7,8},右边的元素是 {2,4,7}。从左边拿元素时不涉及交换,所以第 5 行只拿元素不统计。从右边拿元素时,相当于把它从左边元素的后面移到前面,移动的距离就是左边还剩余元素的个数,所以这时需要统计,统计的方法如第 8 行。这里的移动和插入排序很像。
2.4 两个链表的第一个公共结点
题目:输入两个链表,请找出它们的第一个公共结点。
图
3
两
个
链
表
的
第
一
个
公
共
结
点
图\ 3\quad 两个链表的第一个公共结点
图 3两个链表的第一个公共结点
蛮力法:在链表 1 上每遍历一个结点,都去遍历一遍链表 2,直到遍历的两个结点相等。时间复杂度 O(mn)。
使用栈:时间复杂度 O(m+n),空间复杂度 O(m+n)。
先计算长度,再使用两个指针,长链表的指针先走:时间复杂度 O(m+n)。
交替跑路:指针 1 从链表 1 开始遍历到尾部,然后从链表 2 开始遍历到尾部,再从链表 1 开始遍历。指针 2 同时从链表 2 开始类似地遍历,直到它们相遇。时间复杂度 O(m+n)。