1. 问题分析与解法思路
这道题目要求我们统计数组中每个数字比它小的数字的个数。比如给定数组[8,1,2,2,3],我们需要返回[4,0,1,1,3],表示8有4个数字比它小,1有0个数字比它小,以此类推。
最直观的解法是暴力法:对于每个数字,遍历整个数组统计比它小的数字个数。这种方法的时间复杂度是O(n²),在数据量大的情况下效率很低。
更优的解法是使用排序+二分查找:
- 复制原数组并进行排序
- 对于原数组中的每个数字,在排序后的数组中使用二分查找找到它的位置
- 该位置前面的元素个数就是比它小的数字个数
这种解法的时间复杂度是O(n log n),主要来自排序操作,二分查找的时间是O(log n),整体效率比暴力法高很多。
2. 代码实现详解
让我们仔细分析这个高效的解法实现:
cpp复制class Solution {
public:
vector<int> smallerNumbersThanCurrent(vector<int>& nums) {
vector<int> tr = nums; // 复制原数组
sort(tr.begin(), tr.end()); // 排序复制后的数组
vector<int> ret; // 存储结果
int ind;
for(int& i : nums) {
// 使用lower_bound进行二分查找
ind = lower_bound(tr.begin(), tr.end(), i) - tr.begin();
ret.push_back(ind);
}
return ret;
}
};
2.1 关键点解析
-
数组复制:首先复制原数组是为了保留原始顺序,因为排序会改变元素位置。
-
排序操作:使用标准库的sort函数进行排序,时间复杂度为O(n log n)。
-
二分查找:使用lower_bound函数进行二分查找:
- lower_bound返回第一个不小于目标值的元素位置
- 这个位置之前的元素都是小于目标值的
- 位置索引直接就是比目标值小的元素个数
-
结果存储:遍历原数组,对每个元素在排序后的数组中进行查找,将结果存入ret数组。
2.2 时间复杂度分析
- 数组复制:O(n)
- 排序:O(n log n)
- n次二分查找:O(n log n)
- 总时间复杂度:O(n log n)
空间复杂度是O(n),主要是存储排序后的数组和结果数组。
3. 算法优化与变种
虽然这个解法已经很高效,但我们还可以考虑一些优化和变种:
3.1 使用哈希表预计算
另一种思路是先排序,然后建立一个哈希表记录每个数字第一次出现的位置:
cpp复制class Solution {
public:
vector<int> smallerNumbersThanCurrent(vector<int>& nums) {
vector<int> sorted = nums;
sort(sorted.begin(), sorted.end());
unordered_map<int, int> pos;
for(int i = sorted.size()-1; i >= 0; --i) {
pos[sorted[i]] = i;
}
vector<int> res;
for(int num : nums) {
res.push_back(pos[num]);
}
return res;
}
};
这种方法同样高效,而且对于重复元素处理更加直观。
3.2 计数排序法
如果题目中数字范围有限(比如0-100),可以使用计数排序的思路:
cpp复制class Solution {
public:
vector<int> smallerNumbersThanCurrent(vector<int>& nums) {
vector<int> count(101, 0);
for(int num : nums) {
count[num]++;
}
for(int i = 1; i <= 100; ++i) {
count[i] += count[i-1];
}
vector<int> res;
for(int num : nums) {
res.push_back(num == 0 ? 0 : count[num-1]);
}
return res;
}
};
这种方法时间复杂度是O(n),但只适用于数值范围有限的情况。
4. 边界条件与测试用例
在实现这类算法时,需要考虑以下边界条件:
- 空数组输入
- 所有元素相同的情况
- 元素全部递增或递减的情况
- 包含重复元素的情况
- 最小值和最大值的情况
一些测试用例示例:
- 输入:[8,1,2,2,3] → 输出:[4,0,1,1,3]
- 输入:[6,5,4,8] → 输出:[2,1,0,3]
- 输入:[7,7,7,7] → 输出:[0,0,0,0]
- 输入:[1,2,3,4,5] → 输出:[0,1,2,3,4]
- 输入:[5,4,3,2,1] → 输出:[4,3,2,1,0]
5. 实际应用与面试技巧
这类问题在实际面试中经常出现,主要考察以下几点:
- 对基础算法的掌握(排序、查找)
- 时间复杂度的分析能力
- 边界条件的考虑
- 代码实现的简洁性
在面试中,建议按照以下步骤进行:
- 先提出暴力解法并分析其复杂度
- 然后提出优化思路(排序+二分查找)
- 讨论可能的变种和优化空间
- 考虑边界条件和测试用例
- 最后实现代码并解释关键部分
提示:在面试中,即使知道最优解,也应该从简单解法开始逐步优化,展示你的思考过程。
6. 常见错误与调试技巧
在实现这个算法时,容易犯的错误包括:
- 忘记复制原数组:直接排序原数组会丢失原始顺序
- 二分查找使用不当:应该使用lower_bound而不是upper_bound
- 重复元素处理不当:需要确保重复元素的计数正确
- 边界条件处理不当:特别是当数组包含最小值时
调试技巧:
- 打印排序前后的数组对比
- 对于特定元素,打印二分查找的过程和结果
- 使用小规模测试用例手动验证
7. 性能对比与选择建议
不同解法的性能对比:
- 暴力法:O(n²) - 仅适用于小规模数据
- 排序+二分查找:O(n log n) - 通用解法,适合大多数情况
- 哈希表法:O(n log n) - 与排序法相当,代码更直观
- 计数排序:O(n) - 仅适用于有限数值范围
选择建议:
- 如果数值范围有限且不大,优先考虑计数排序
- 一般情况下,排序+二分查找是最佳选择
- 面试中,可以依次讨论各种解法的优劣
8. 扩展思考
这个问题可以扩展为:
- 统计大于当前数字的数字个数
- 统计等于当前数字的数字个数
- 统计左边比当前数字小的数字个数(保持原始顺序)
- 统计右边比当前数字小的数字个数
这些变种都可以使用类似的思路解决,只是需要调整比较条件和统计方式。
在实际工程中,这类统计操作常用于数据分析、排名计算等场景。掌握这类基础算法可以帮助我们高效处理各种数据统计需求。