题目:四数之和
-
给你一个由
n
个整数组成的数组nums
,和一个目标值target
。请你找出并返回满足下述全部条件且不重复的四元组[nums[a], nums[b], nums[c], nums[d]]
(若两个四元组元素一一对应,则认为两个四元组重复)。可以按 任意顺序 返回答案 :-
0 <= a, b, c, d < n
-
a
、b
、c
和d
互不相同 -
nums[a] + nums[b] + nums[c] + nums[d] == target
-
题解
-
最朴素的方法是使用四重循环枚举所有的四元组,然后使用哈希表进行去重操作,得到不包含重复四元组的最终答案。假设数组的长度是 nnn,则该方法中,枚举的时间复杂度为 O ( n 4 ) O(n^4) O(n4),去重操作的时间复杂度和空间复杂度也很高,因此需要换一种思路。
-
为了避免枚举到重复四元组,则需要保证每一重循环枚举到的元素不小于其上一重循环枚举到的元素,且在同一重循环中不能多次枚举到相同的元素。为了实现上述要求,可以对数组进行排序,并且在循环过程中遵循以下两点:
-
每一种循环枚举到的下标必须大于上一重循环枚举到的下标;
-
同一重循环中,如果当前元素与上一个元素相同,则跳过当前元素。
-
-
使用上述方法,可以避免枚举到重复四元组,但是由于仍使用四重循环,时间复杂度仍是 O ( n 4 ) O(n^4) O(n4)。注意到数组已经被排序,因此可以使用双指针的方法去掉一重循环。
-
使用两重循环分别枚举前两个数,然后在两重循环枚举到的数之后使用双指针枚举剩下的两个数。假设两重循环枚举到的前两个数分别位于下标 i 和 j,其中 i<j。初始时,左右指针分别指向下标 j+1 和下标 n−1。每次计算四个数的和,并进行如下操作:
-
如果和等于 target,则将枚举到的四个数加到答案中,然后将左指针右移直到遇到不同的数,将右指针左移直到遇到不同的数;
-
如果和小于 target,则将左指针右移一位;
-
如果和大于 target,则将右指针左移一位。
-
-
使用双指针枚举剩下的两个数的时间复杂度是 O(n),因此总时间复杂度是 O ( n 3 ) O(n^3) O(n3),低于 O ( n 4 ) O(n^4) O(n4)。具体实现时,还可以进行一些剪枝操作:
-
在确定第一个数之后,如果 nums[i]+nums[i+1]+nums[i+2]+nums[i+3]>target,说明此时剩下的三个数无论取什么值,四数之和一定大于 target,因此退出第一重循环;
-
在确定第一个数之后,如果 nums[i]+nums[n−3]+nums[n−2]+nums[n−1]<target,说明此时剩下的三个数无论取什么值,四数之和一定小于 target,因此第一重循环直接进入下一轮,枚举 nums[i+1];
-
在确定前两个数之后,如果 nums[i]+nums[j]+nums[j+1]+nums[j+2]>target,说明此时剩下的两个数无论取什么值,四数之和一定大于 target,因此退出第二重循环;
-
在确定前两个数之后,如果 nums[i]+nums[j]+nums[n−2]+nums[n−1]<target,说明此时剩下的两个数无论取什么值,四数之和一定小于 target,因此第二重循环直接进入下一轮,枚举 nums[j+1]。
-
-
class Solution { public: vector<vector<int>> fourSum(vector<int>& nums, int target) { vector<vector<int>> res; if(nums.size()<4) return res; sort(nums.begin(),nums.end()); int len_nums = nums.size(); for(int i=0;i<len_nums-3;i++){ if(i>0&&nums[i]==nums[i-1]) continue; if((long) nums[i]+nums[i+1]+nums[i+2] +nums[i+3]>target) break; if((long) nums[i] +nums[len_nums-1]+nums[len_nums-2]+nums[len_nums-3]<target) continue; for(int j=i+1;j<len_nums-2;j++){ if(j>i+1&&nums[j]==nums[j-1]) continue; if((long) nums[i] +nums[j]+nums[j+1]+nums[j+2]>target) break; if((long) nums[i]+nums[j]+nums[len_nums-1]+nums[len_nums-2]<target) continue; int left=j+1,right=len_nums-1; while(left<right){ long sum = (long) nums[i]+nums[j]+nums[left]+nums[right]; if(sum==target){ res.push_back({ nums[i],nums[j],nums[left],nums[right]}); while(left<right&&nums[right]==nums[right-1]) right--; right--; }else if(sum<target){ left++; }else{ right--; } } } } return res; } };
-
时间复杂度: O ( n 3 ) O(n^3) O(n3),其中 n 是数组的长度。排序的时间复杂度是 O ( log n ) O(\log n) O(logn),枚举四元组的时间复杂度是 O ( n 3 ) O(n^3) O(n3),因此总时间复杂度为 O ( n 3 + n log n ) = O ( n 3 ) O(n^3+n\log n)=O(n^3) O(n3+nlogn)=O(n3)。空间复杂度: O ( log n ) O(\log n) O(logn),其中 n 是数组的长度。空间复杂度主要取决于排序额外使用的空间。此外排序修改了输入数组 nums,实际情况中不一定允许,因此也可以看成使用了一个额外的数组存储了数组 nums 的副本并排序,空间复杂度为 O(n)。
-
一般来说哈希表都是用来快速判断一个元素是否出现集合里。对于哈希表,要知道哈希函数和哈希碰撞在哈希表中的作用。哈希函数是把传入的key映射到符号表的索引上。哈希碰撞处理有多个key映射到相同索引上时的情景,处理碰撞的普遍方式是拉链法和线性探测法。
STL基础
-
STL,英文全称 standard template library,中文可译为标准模板库或者泛型库,其包含有大量的模板类和模板函数,是 C++ 提供的一个基础模板的集合,用于完成诸如输入/输出、数学计算等功能。
-
STL 最初由惠普实验室开发,于 1998 年被定为国际标准,正式成为 C++ 程序库的重要组成部分。值得一提的是,如今 STL 已完全被内置到支持 C++ 的编译器中,无需额外安装,这可能也是 STL 被广泛使用的原因之一。STL 就位于各个 C++ 的头文件中,即它并非以二进制代码的形式提供,而是以源代码的形式提供。
-
从根本上说,STL 是一些容器、算法和其他一些组件的集合,所有容器和算法都是总结了几十年来算法和数据结构的研究成果,汇集了许多计算机专家学者经验的基础上实现的,因此可以说,STL 基本上达到了各种存储方法和相关算法的高度优化。
-
以 C++ 定义数组的操作为例,在 C++ 中如果定义一个数组,可以采用如下方式:
-
int a[n];
-
这种定义数组的方法需要事先确定好数组的长度,即 n 必须为常量,这意味着,如果在实际应用中无法确定数组长度,则一般会将数组长度设为可能的最大值,但这极有可能导致存储空间的浪费。所以除此之外,还可以采用在堆空间中动态申请内存的方法,此时长度可以是变量:
-
int *p = new int[n];
-
-
这种定义方式可根据变量 n 动态申请内存,不会出现存储空间浪费的问题。但是,如果程序执行过程中出现空间不足的情况时,则需要加大存储空间,此时需要进行如下操作:
-
新申请一个较大的内存空间,即执行
int * temp = new int[m];
-
将原内存空间的数据全部复制到新申请的内存空间中,即执行
memecpy(temp, p, sizeof(int)*n);
-
将原来的堆空间释放,即执行
delete [] p; p = temp;
-
-
而完成相同的操作,如果采用 STL 标准库,则会简单很多,因为大多数操作细节将不需要程序员关心。下面是使用向量模板类 vector 实现以上功能的示例:
-
vector <int> a; //定义 a 数组,当前数组长度为 0,但和普通数组不同的是,此数组 a 可以根据存储数据的数量自动变长。 //向数组 a 中添加 10 个元素 for (int i = 0; i < 10 ; i++) a.push_back(i) //还可以手动调整数组 a 的大小 a.resize(100); a[90] = 100; //还可以直接删除数组 a 中所有的元素,此时 a 的长度变为 0 a.clear(); //重新调整 a 的大小为 20,并存储 20 个 -1 元素。 a.resize(20, -1)
-
-
对比以上两种使用数组的方式不难看出,使用 STL 可以更加方便灵活地处理数据。所以,大家只需要系统地学习 STL,便可以集中精力去实现程序的功能,而无需再纠结某些细节如何用代码实现。
STL六大部件
-
通常认为,STL 是由容器、算法、迭代器、函数对象、适配器、内存分配器这 6 部分构成,其中后面 4 部分是为前 2 部分服务的
-
组成 含义 容器 一些封装数据结构的模板类,例如 vector 向量容器、list 列表容器等。 算法 STL 提供了非常多(大约 100 个)的数据结构算法,它们都被设计成一个个的模板函数,这些算法在 std 命名空间中定义,其中大部分算法都包含在头文件 中,少部分位于头文件 中。 迭代器 在 C++ STL 中,对容器中数据的读和写,是通过迭代器完成的,扮演着容器和算法之间的胶合剂。 函数对象 如果一个类将 () 运算符重载为成员函数,这个类就称为函数对象类,这个类的对象就是函数对象(又称仿函数)。 适配器 可以使一个类的接口(模板的参数)适配成用户指定的形式,从而让原本不能在一起工作的两个类工作在一起。值得一提的是,容器、迭代器和函数都有适配器。 内存分配器 为容器类模板提供自定义的内存申请和释放功能,由于往往只有高级用户才有改变内存分配策略的需求。 -
在惠普实验室最初发行的版本中,STL 被组织成 48 个头文件;但在 C++ 标准中,它们被重新组织为 13 个头文件
- <iterator>;<functional>;<vector>;<deque>;<list>;<queue>;<stack>;<set>;<map>;<algorithm>;<numeric>;<memory>;<utility>
-
在实际的开发过程中,合理组织数据的存取与选择处理数据的算法同等重要,存取数据的方式往往会直接影响到对它们进行增删改查操作的复杂程度和时间消耗。事实上,当程序中存在对时耗要求很高的部分时,数据结构的选择就显得尤为重要,有时甚至直接影响程序执行的成败。
-
简单的理解容器,它就是一些模板类的集合,但和普通模板类不同的是,容器中封装的是组织数据的方法(也就是数据结构)。STL 提供有 3 类标准容器,分别是序列容器、排序容器和哈希容器,其中后两类容器有时也统称为关联容器。
-
容器种类 功能 序列容器 主要包括 vector 向量容器、list 列表容器以及 deque 双端队列容器。之所以被称为序列容器,是因为元素在容器中的位置同元素的值无关,即容器不是排序的。将元素插入容器时,指定在什么位置,元素就会位于什么位置。 排序容器 包括 set 集合容器、multiset多重集合容器、map映射容器以及 multimap 多重映射容器。排序容器中的元素默认是由小到大排序好的,即便是插入元素,元素也会插入到适当位置。所以关联容器在查找时具有非常好的性能。 哈希容器 C++ 11 新加入 4 种关联式容器,分别是 unordered_set 哈希集合、unordered_multiset 哈希多重集合、unordered_map 哈希映射以及 unordered_multimap 哈希多重映射。和排序容器不同,哈希容器中的元素是未排序的,元素的位置由哈希函数确定。
-
-
以上 3 类容器的存储方式完全不同,因此使用不同容器完成相同操作的效率也大不相同。所以在实际使用时,要善于根据想实现的功能,选择合适的容器。
-
尽管不同容器的内部结构各异,但它们本质上都是用来存储大量数据的,换句话说,都是一串能存储多个数据的存储单元。因此,诸如数据的排序、查找、求和等需要对数据进行遍历的操作方法应该是类似的。
-
既然类似,完全可以利用泛型技术,将它们设计成适用所有容器的通用算法,从而将容器和算法分离开。但实现此目的需要有一个类似中介的装置,它除了要具有对容器进行遍历读写数据的能力之外,还要能对外隐藏容器的内部差异,从而以统一的界面向算法传送数据。
-
这是泛型思维发展的必然结果,于是迭代器就产生了。简单来讲,迭代器和 C++ 的指针非常类似,它可以是需要的任意类型,通过迭代器可以指向容器中的某个元素,如果需要,还可以对该元素进行读/写操作。
-
STL 标准库为每一种标准容器定义了一种迭代器类型,这意味着,不同容器的迭代器也不同,其功能强弱也有所不同。容器的迭代器的功能强弱,决定了该容器是否支持 STL 中的某种算法。常用的迭代器按功能强弱分为输入迭代器、输出迭代器、前向迭代器、双向迭代器、随机访问迭代器 5 种。
-
前向迭代器(forward iterator)
- 假设 p 是一个前向迭代器,则 p 支持 ++p,p++,*p 操作,还可以被复制或赋值,可以用 == 和 != 运算符进行比较。此外,两个前向迭代器可以互相赋值。
-
双向迭代器(bidirectional iterator)
- 双向迭代器具有正向迭代器的全部功能,除此之外,假设 p 是一个双向迭代器,则还可以进行 --p 或者 p-- 操作(即一次向后移动一个位置)。
-
随机访问迭代器(random access iterator)
-
随机访问迭代器具有双向迭代器的全部功能。除此之外,假设 p 是一个随机访问迭代器,i 是一个整型变量或常量,则 p 还支持以下操作:
-
- p+=i:使得 p 往后移动 i 个元素。
- p-=i:使得 p 往前移动 i 个元素。
- p+i:返回 p 后面第 i 个元素的迭代器。
- p-i:返回 p 前面第 i 个元素的迭代器。
- p[i]:返回 p 后面第 i 个元素的引用。
-
此外,两个随机访问迭代器 p1、p2 还可以用 <、>、<=、>= 运算符进行比较。另外,表达式 p2-p1 也是有定义的,其返回值表示 p2 所指向元素和 p1 所指向元素的序号之差(也可以说是 p2 和 p1 之间的元素个数减一)。
-
-
不同容器的迭代器
-
容器 对应的迭代器类型 array 随机访问迭代器 vector 随机访问迭代器 deque 随机访问迭代器 list 双向迭代器 set / multiset 双向迭代器 map / multimap 双向迭代器 forward_list 前向迭代器 unordered_map / unordered_multimap 前向迭代器 unordered_set / unordered_multiset 前向迭代器 stack 不支持迭代器 queue 不支持迭代器 -
容器适配器 stack 和 queue 没有迭代器,它们包含有一些成员函数,可以用来对元素进行访问。
-
-
下面就以 vector 容器为例,实际感受迭代器的用法和功能。通过前面的学习,vector 支持随机访问迭代器,因此遍历 vector 容器有以下几种做法。下面的程序中,每个循环演示了一种做法:
-
//遍历 vector 容器。 #include <iostream> //需要引入 vector 头文件 #include <vector> using namespace std; int main() { vector<int> v{ 1,2,3,4,5,6,7,8,9,10}; //v被初始化成有10个元素 cout << "第一种遍历方法:" << endl; //size返回元素个数 for (int i = 0; i < v.size(); ++i) cout << v[i] <<" "; //像普通数组一样使用vector容器 //创建一个正向迭代器,当然,vector也支持其他 3 种定义迭代器的方式 cout << endl << "第二种遍历方法:" << endl; vector<int>::iterator i; //用 != 比较两个迭代器 for (i = v.begin(); i != v.end(); ++i) cout << *i << " "; cout << endl << "第三种遍历方法:" << endl; for (i = v.begin(); i < v.end(); ++i) //用 < 比较两个迭代器 cout << *i << " "; cout << endl << "第四种遍历方法:" << endl; i = v.begin(); while (i < v.end()) { //间隔一个输出 cout << *i << " "; i += 2; // 随机访问迭代器支持 "+= 整数" 的操作 } }
-
-
所谓序列容器,即以线性排列(类似普通数组的存储方式)来存储某一指定类型(例如 int、double 等)的数据,需要特殊说明的是,该类容器并不会自动对存储的元素按照值的大小进行排序。需要注意的是,序列容器只是一类容器的统称,并不指具体的某个容器,序列容器大致包含以下几类容器:
-
array<T,N>(数组容器):表示可以存储 N 个 T 类型的元素,是 C++ 本身提供的一种容器。此类容器一旦建立,其长度就是固定不变的,这意味着不能增加或删除元素,只能改变某个元素的值;
-
vector<T>(向量容器):用来存放 T 类型的元素,是一个长度可变的序列容器,即在存储空间不足时,会自动申请更多的内存。使用此容器,在尾部增加或删除元素的效率最高(时间复杂度为 O(1) 常数阶),在其它位置插入或删除元素效率较差(时间复杂度为 O(n) 线性阶,其中 n 为容器中元素的个数);
-
deque<T>(双端队列容器):和 vector 非常相似,区别在于使用该容器不仅尾部插入和删除元素高效,在头部插入或删除元素也同样高效,时间复杂度都是 O(1) 常数阶,但是在容器中某一位置处插入或删除元素,时间复杂度为 O(n) 线性阶;
-
list<T>(链表容器):是一个长度可变的、由 T 类型元素组成的序列,它以双向链表的形式组织元素,在这个序列的任何地方都可以高效地增加或删除元素(时间复杂度都为常数阶 O(1)),但访问容器中任意元素的速度要比前三种容器慢,这是因为 list<T> 必须从第一个元素或最后一个元素开始访问,需要沿着链表移动,直到到达想要的元素。
-
forward_list<T>(正向链表容器):和 list 容器非常类似,只不过它以单链表的形式组织元素,它内部的元素只能从第一个元素开始访问,是一类比链表容器快、更节省内存的容器。
-
容器中常见的函数成员
-
array、vector 和 deque 容器的函数成员
-
函数成员 函数功能 array<T,N> vector<T> deque<T> begin() 返回指向容器中第一个元素的迭代器。 是 是 是 end() 返回指向容器最后一个元素所在位置后一个位置的迭代器 是 是 是 rbegin() 返回指向最后一个元素的迭代器。 是 是 是 rend() 返回指向第一个元素所在位置前一个位置的迭代器。 是 是 是 assign() 用新元素替换原有内容。 是 是 operator=() 复制同类型容器的元素,或者用初始化列表替换现有内容。 是 是 是 size() 返回实际元素个数。 是 是 是 max_size() 返回元素个数的最大值。这通常是一个很大的值,一般是 2 32 − 1 2^{32}-1 232−1。 是 是 是 capacity() 返回当前容量。 是 empty() 判断容器中是否有元素,若无元素,则返回 true;反之,返回 false。 是 是 是 resize() 改变实际元素的个数。 是 是 shrink _to_fit() 将内存减少到等于当前元素实际所使用的大小。 是 是 push_back() 在序列的尾部添加一个元素。 是 是 pop_back() 移出序列尾部的元素。 是 是 insert() 在指定的位置插入一个或多个元素。 是 是 data() 返回指向容器中第一个元素的指针。 是 是 swap() 交换两个容器的所有元素。 是 是 是 clear() 移出一个元素或一段元素。 是 是 erase() 移出所有的元素,容器大小变为 0。 是 是 emplace() 在指定的位置直接生成一个元素。 是 是
-
-
list 和 forward_list 的函数成员
-
函数成员 函数功能 list<T> forward_list<T> begin() 返回指向容器中第一个元素的迭代器。 是 是 end() 返回指向容器最后一个元素所在位置后一个位置的迭代器。 是 是 rbegin() 返回指向最后一个元素的迭代器。 是 rend() 返回指向第一个元素所在位置前一个位置的迭代器。 是 assign() 用新元素替换原有内容。 是 是 operator=() 复制同类型容器的元素,或者用初始化列表替换现有内容。 是 是 size() 返回实际元素个数。 是 resize() 改变实际元素的个数。 是 是 empty() 判断容器中是否有元素,若无元素,则返回 true;反之,返回 false。 是 是 push_back() 在序列的尾部添加一个元素。 是 push_front() 在序列的起始位置添加一个元素。 是 是 emplace() 在指定位置直接生成一个元素。 是 emplace_after() 在指定位置的后面直接生成一个元素。 是 insert() 在指定的位置插入一个或多个元素。 是 pop_back() 移除序列尾部的元素。 是 pop_front() 移除序列头部的元素。 是 是 reverse() 反转容器中某一段的元素。 是 是 erase() 移除指定位置的一个元素或一段元素。 是 erase_after() 移除指定位置后面的一个元素或一段元素。 是 remove() 移除所有和参数匹配的元素。 是 是 unique() 移除所有连续重复的元素。 是 是 clear() 移除所有的元素,容器大小变为 0。 是 是 swap() 交换两个容器的所有元素。 是 是 sort() 对元素进行排序。 是 是 merge() 合并两个有序容器。 是 是 splice() 移动指定位置前面的所有元素到另一个同类型的 list 中。 是 splice_after() 移动指定位置后面的所有元素到另一个同类型的 list 中。 是
-