1.引言
数据结构的常用操作:插入,删除,查找。即,一个数据结构一般要具有能够插入、删除、查找的功能,比如对栈来说,栈支持的操作是从栈顶插入和删除,你不能对栈的栈底进行操作,也不能对栈的任意一个位置进行操作。
其次,在知道其支持的操作后,我们还需要清楚地知道每个操作的时间复杂度,时间复杂度的重要性我们在前面已经介绍过。如果我们不知道一个数据结构里各个操作的时间复杂度,那么如果我们的解法使用了该数据结构的话,解法整体的时间复杂度我们也就无从计算了
了解常用操作和时间复杂度之后,我们还需要了解这个数据结构的实现。例如我们知道 Python 中有 List 这种数据结构,他本质上是一个所谓大小可以动态调整的数组。但是我们知道在向计算机申请内存的时候,我们通常只能申请占用一块连续的内存,这显然是和动态调整大小相矛盾的。因此,动态数组的实现并不像我们想当然的那么简单,在后面的讲解中我们会再来详细讨论这个问题。
最后,我们需要了解数据结构的使用方式,即在我们所熟悉的语言中,有没有该数据结构的定义,他的接口应该怎样使用等。如果目前你还不太熟悉也没有关系,在后续的训练中我们需要大量使用各种数据结构,来提高我们对数据结构的敏感性和熟练程度。
2.数据结构介绍
2.1 线性数据结构
2.1.1 数组
一般需要支持:
- push_back - 在数组末尾插入新元素 -
- pop_back - 删除数组中的最后一个元素 -
- size - 返回数组长度 -
- index - 返回数组中下标为 i 的元素 -
这些操作一般在的时间内完成。
2.1.2 栈
装羽毛球的球筒,只能对顶部(尾部)进行操作,后进先出,一般需要支持:
- push - 放入一个元素到栈中 -
- pop - 从栈中删除一个元素 -
- top - 获取栈顶元素 -
- size - 返回栈的大小 -
一般不支持查找,这些操作一般在的时间内完成。
2.2.3 队列
和栈很像,都是操作受到限制的线性表,只能对尾部进行插入,只能对头部进行删除
- push - 放入一个元素到队列尾部 -
- pop - 从队首删除一个元素 -
- front- 获取队首元素 -
- size - 返回队列的大小 -
这些操作一般在的时间内完成。
2.2.4 双端队列
队列两端均可以进行插入和删除,如果只用一端,那么双端队列就会变成成栈;如果一端插入一端删除的话,就会变成普通队列。
2.2.5 链表
一辆连着很多车厢的火车,
- 火车头 - 链表头节点
- 从每节车厢可以到达后一节车厢,不可到达前面的车厢
- 每一节车厢均可存放货物(数据)
操作
- 插入 - 先把前一节车厢的挂钩指向新增的车厢,再把新增车厢的挂钩指向后一节车厢 - O(1)
- 删除 - 将待删除车厢的前一节车厢的挂钩直接指向后一节车厢 - O(1)
- 查找第K个节点 - 从头节点开始往后查找,应尽量避免随机查找 - O(n)
- 查找前一个节点 - 从头节点逐个查找 - O(n)
2.2 其他数据结构
2.2.1 字典
记录某个元素在之前出现过,并记录该元素对应的属性
分类
- 无序字典 - 基于hash算法,可以O(1)实现元素的存储和映射,但无法保留相对大小顺序
- 在C++中可使用平衡树实现
2.2.2 树
当一个节点有多个后继节点,但每个节点只有一个前驱节点时,即为树
3.例题
3.1 实现动态数组
动态数组:长度可以动态改变的数组,C++的Vector,python的list,都是动态数组。
- 满足长度的动态变化
- 不损失数组的随机查找效率
动态数组的操作要求
- push_back - 在数组末尾插入新元素
- pop_back - 删除数组中的最后一个元素
- size - 返回数组长度
- index - 返回数组中下标为 i 的元素
静态数组:容量不能随时改变的数组。分配一整块连续的内存,可以更快的寻址与访问,满足数组随机查找的特性
动态数组的操作实现
- push_back - 判断当前数组大小是否已满,若已满则申请两倍容量数组,拷贝所有内容并插入新数
- pop_back - 删除数组中的最后一个元素,数组大小 -1
- size - 返回当前元素数量
- index - 直接返回下标为 i 的数组元素
设最终申请的数组长度为n,申请空间的总大小为 n + n/2 + n/4 + ... 2n
时间复杂度为
class LCArray {
private:
int curSize;
int maxSize;
int* array;
public:
LCArray() {
curSize = 0;
maxSize = 1;
array = new int[1]; // 给指针分配内存空间,即给数组分配内存空间
} // 初始化
void push_back(int n) {
if (curSize == maxSize)
{
maxSize *= 2;
int* tmp = array;
array = new int[maxSize]; // array指向了新的内存空间
for (int i = 0; i < curSize; i++)
{
array[i] = tmp[i];
}
delete [] tmp;
}
array[curSize] = n; // 索引从0开始,把要插入的数给到数组末尾
curSize += 1; // 当前数组大小+1
}
void pop_back() {
curSize -= 1;
}
int size() {
return curSize;
}
int index(int idx) {
return array[idx];
}
};
/**
* Your LCArray object will be instantiated and called as such:
* LCArray* obj = new LCArray();
* obj->push_back(n);
* obj->pop_back();
* int param_3 = obj->size();
* int param_4 = obj->index(idx);
*/
3.2 用队列实现栈
(Leecode 225)请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(push
、top
、pop
和 empty
).
实现MyStack类:
- void push( int x ):将元素压入栈顶
- int pop ():返回栈顶元素,并移除该元素
- int top():返回栈顶元素
- boolean empty():如果栈是空的,返回true;否则,返回false。
注意:
- 你只能使用队列的基本操作 —— 也就是 push to back、peek/pop from front、size 和 is empty 这些操作。
- 你所使用的语言也许不支持队列。 你可以使用 list (列表)或者 deque(双端队列)来模拟一个队列 , 只要是标准的队列操作即可。
思路整理
本思路的前提:把队首当作栈底,把队尾当作栈顶。
- 新建两个队列q1 q2,q1用于存放所需栈顶,q2用于存放其余数据。
-
MyStack stack = new MyStack(); stack.push(1); stack.push(2); stack.top(); //检查队列q1中数的个数,如果大于1个,将q1中的数依次出队到q2,直到q1剩下一个数,即top stack.pop(); /* 在上一步的基础上,将q1中剩余的数出队,出队后q1为空,q2为队列中的其余数,此时将q1和q2的指针进行交换,那么q1又和之前的队列一样了 stack.empty(); // q1和q2若均为空,则返回空
- 时间复杂度:
push:
pop:
top:
empty:
class MyStack {
public:
queue<int> que1; //C++队列Queue是一种容器适配器,它给予程序员一种先进先出(FIFO)的数据结构
queue<int> que2;
/** Initialize your data structure here. */
MyStack() {
}
/** Push element x onto stack. */
void push(int x) {
que1.push(x);
}
/** Removes the element on top of the stack and returns that element. */
int pop() {
top(); // que1中剩一个栈顶元素,que2中是其余的元素
int res=que1.front(); // 存储到别的变量中,que1中的就可以删除了
que1.pop(); // 删除que1中的量,此时que2就是之前的que1
swap(que1,que2); // 二者交换,que1恢复原样
return res;
}
/** Get the top element. */
int top() {
while((int)(que1.size())>1){ // 这里加上(int)可以减少内存消耗
que2.push(que1.front()); // front() 返回队列第一个元素(队首元素)
que1.pop(); // pop() 删除队列第一个元素
}
return que1.front(); // que1中仅剩的元素就是栈顶元素了
}
/** Returns whether the stack is empty. */
bool empty() {
return que1.empty()&&que2.empty();
}
};
/**
* Your MyStack object will be instantiated and called as such:
* MyStack* obj = new MyStack();
* obj->push(x);
* int param_2 = obj->pop();
* int param_3 = obj->top();
* bool param_4 = obj->empty();
*/
用单队列实现方法:
前提:队首当作栈顶,那么入栈的时复杂度就会变为O(n)。
入栈操作时,首先获得入栈前的元素个数 n,然后将元素入队到队列,再将队列中的前 n 个元素(即除了新入栈的元素之外的全部元素)依次出队并入队到队列,此时队列的前端的元素即为新入栈的元素,且队列的前端和后端分别对应栈顶和栈底。
class MyStack {
public:
queue<int> q;
/** Initialize your data structure here. */
MyStack() {
}
/** Push element x onto stack. */
void push(int x) {
int n = q.size();
q.push(x);
for (int i = 0; i < n; i++) {
q.push(q.front());
q.pop();
}
}
/** Removes the element on top of the stack and returns that element. */
int pop() {
int r = q.front();
q.pop();
return r;
}
/** Get the top element. */
int top() {
int r = q.front();
return r;
}
/** Returns whether the stack is empty. */
bool empty() {
return q.empty();
}
};
时间复杂度:入栈操作O(n),其余操作都是O(1)。
入栈操作需要将队列中的 n 个元素出队,并入队 n+1 个元素到队列,共有 2n+1 次操作,每次出队和入队操作的时间复杂度都是 O(1),因此入栈操作的时间复杂度是 O(n)。
出栈操作对应将队列的前端元素出队,时间复杂度是 O(1)。
获得栈顶元素操作对应获得队列的前端元素,时间复杂度是 O(1)。
判断栈是否为空操作只需要判断队列是否为空,时间复杂度是 O(1)。
进阶
你能否实现每种操作的均摊时间复杂度为 O(1) 的栈?换句话说,执行 n 个操作的总时间复杂度 O(n) ,尽管其中某个操作可能需要比其他操作更长的时间。你可以使用两个以上的队列。
3.3 设计循环队列
(LeeCode 622)设计你的循环队列实现。 循环队列是一种线性数据结构,其操作表现基于 FIFO(先进先出)原则并且队尾被连接在队首之后以形成一个循环。它也被称为“环形缓冲器”。
循环队列的一个好处是我们可以利用这个队列之前用过的空间。在一个普通队列里,一旦一个队列满了,我们就不能插入下一个元素,即使在队列前面仍有空间。但是使用循环队列,我们能使用这些空间去存储新的值。
你的实现应该支持如下操作:
- MyCircularQueue(k): 构造器,设置队列长度为 k 。
- Front: 从队首获取元素。如果队列为空,返回 -1 。
- Rear: 获取队尾元素。如果队列为空,返回 -1 。
- enQueue(value): 向循环队列插入一个元素。如果成功插入则返回真。
- deQueue(): 从循环队列中删除一个元素。如果成功删除则返回真。
- isEmpty(): 检查循环队列是否为空。
- isFull(): 检查循环队列是否已满。
方法一:链表
- 不需要进行随机查找,利用链表特性随时申请和删除新的节点即可
方法二:循环队列
- 循环队列本质上是一个大小固定的数组,但队列的头指针和尾指针可以循环移动。
构造 - O(1)
- 定义一个长度为k的静态数组
- 设立两个指针,分别指向队列的首尾,初始状态下两个指针重叠
- 额外使用一个变量记录队列中的元素数量
获取队首/队尾元素 - O(1)
- 需要判断队列是否为空
插入 - O(1)
- 判断队列的长度,若已满则返回false
- 若还有空间,将元素插入尾指针对应位置,并将尾指针加一
- 若尾指针超过静态数组长度,则挪到开头
- 最后将size变量加一
删除 - O(1)
- 判断队列的长度,若为空则返回false
- 若非空,直接将头指针加一
- 若头指针超过静态数组长度,则挪到开头
- 最后将size变量减一
思考:若队列空间满了,如何扩容?
class MyCircularQueue {
public:
/** Initialize your data structure here. Set the size of the queue to be k. */
vector<int> q;
int head, tail;
int maxSize;
int size;
MyCircularQueue(int k) {
q.resize(k, 0); // 调整容器q的大小为k,扩容后的每个元素的值为0,默认为0
head = 0;
tail = -1; // 初始化时,尾指针应该和头指针重叠,这样初始化是为了方便插入
size = 0; // 尽管容器容量为k,但都是空的,所以size等于0,而不是k
maxSize = k;
}
/** Insert an element into the circular queue. Return true if the operation is successful. */
bool enQueue(int value) {
if (size == maxSize)
return false;
tail = (tail + 1) % maxSize; // 小的数对大的数取余,结果是小数本身
/* 1、正数对负数取余:即x % (-y) 相当于 x % y
2、负数对正数取余:即(-x) % y 相当于 -(x % y)
3、负数对负数取余:即(-x) % (-y) 相当于 -(x % y) */
q[tail] = value;
size++;
return true;
}
/** Delete an element from the circular queue. Return true if the operation is successful. */
bool deQueue() {
if (size == 0)
return false;
head = (head + 1) % maxSize;
size--;
return true;
}
/** Get the front item from the queue. */
int Front() {
if (size == 0)
return -1;
return q[head];
}
/** Get the last item from the queue. */
int Rear() {
if (size == 0)
return -1;
return q[tail];
}
/** Checks whether the circular queue is empty or not. */
bool isEmpty() {
return size == 0;
}
/** Checks whether the circular queue is full or not. */
bool isFull() {
return size == maxSize;
}
};
/**
* Your MyCircularQueue object will be instantiated and called as such:
* MyCircularQueue* obj = new MyCircularQueue(k);
* bool param_1 = obj->enQueue(value);
* bool param_2 = obj->deQueue();
* int param_3 = obj->Front();
* int param_4 = obj->Rear();
* bool param_5 = obj->isEmpty();
* bool param_6 = obj->isFull();
*/
3.4 两数之和
(LeetCode 1)给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出和为目标值 target 的那两个整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
暴力解法 - 二重循环枚举
- 对于数组中每一个数,循环遍历其他数字,判断两个数的和是否为目标值
- 时间复杂度:
这是最简单的解题思路,但却不是最优的时间复杂度,在解题或者面试的过程中,我们需要注意优先保证我们的复杂度是最优的。
O(n) 解法 - 查找表法
- 在遍历的同时,记录一些信息,以省去一层循环,这是“以空间换时间”的想法
- 查找表有两个常用的实现,①哈希表 ②平衡二叉搜索树
- 遍历数组,创建一个HashMap来存储数字与其坐标位置之间的映射
- 对于nums中的每个数字x,在HashMap中寻找与其对应的数字(target - x),如存在返回其存储的下标,否则则将该数字及其下标插入至该HashMap中,并返回 -1,即可保证不会让
x
和自己匹配。
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> hashtable;
for (int i = 0; i < nums.size(); ++i) {
auto it = hashtable.find(target - nums[i]); /* 通过给定主键查找元素,没找到:返回unordered_map::end */
if (it != hashtable.end()) {
return {it->second, i}; /* it->second取值(value) ,即索引;it->first 取键(key) */
}
hashtable[nums[i]] = i; /*因为我们通过nums找索引,即输入数字,要返回的是数组下标,所以当前数字作为key,索引(数组下标)作为value存入哈希表 */
}
return {};
}
};
3.5 求数组中每个数第一次出现的位置
给定一维数组,求数组中 从左往右 每个数字第一次出现的位置。如果是 第一次出现则返回-1
示例 1:
输入:[1,3,1,2,1]
输出:[-1,-1,0,-1,0]
小技巧
不管对于什么面试题目,在实际思考如何解决之前,都可以先考虑一下这道题是否有什么边界情况。优先思考边界情况也是面试中比较常用的技巧,
- 在没有思路的时候可以打开话题,避免冷场
- 可以防止最后漏掉边界情况的检查
暴力解法 - 二重循环枚举
查看每个数字的左边有没有出现过这个数
- 枚举数组中的所有数字
- 对于每个数字,枚举其前面的所有的数字
- 如果在第二步中发现存在一个相同的数字,则直接返回其下标,否则返回 -1
通过这道题来给大家一些建议,就算是暴力方法,我们也需要在脑海中有这样的思路步骤。先想好这些步骤可以帮助我们理清思路,明确细节,然后在写代码的时候直接按照这个思路写就好了。相反,最好不要在写的时候边写边思考,这样万一思考的不细致很可能会导致前面写的白费。
class Solution {
public:
vector<int> find_left_repeat_num(vector<int>& nums) {
vector<int> res;
for (int i = 0; i < nums.size(); ++i) {
int idx = -1;
for(int j = 0;j < i;++j){
if(nums[i] == nums[j]){
idx = j;
break;
}
}
res.push_back(idx);
}
return res;
}
};
O(n) 解法 - 查找表法
- 使用 dict (即 C++ 中的 unordered_map)来记录每个数字的位置
- 对于每个数字,枚举其前面的所有的数字
- 在 dict 中查询是否存在该数字,如存在 返回其存储的下标,否则则将该数字及其下标插入至 dict 中,并返回 -1
我的解法:
class Solution {
public:
vector<int> find_left_repeat_num(vector<int>& nums) {
unordered_map<int, int> mp;
vector<int> res;
for (int i = 0; i < nums.size(); ++i) {
auto it = mp.find(nums[i]);
if (it == mp.end()) {
mp[nums[i]] = i;
res.push_back(-1);
}
else
res.push_back(it->second);
}
return res;
}
};
官方题解:
class Solution {
public:
vector<int> find_left_repeat_num(vector<int>& nums) {
unordered_map<int, int> mp;
vector<int> res;
for (int i = 0; i < nums.size(); i++) {
if (!mp.count(nums[i])) {
mp[nums[i]] = i;
res.push_back(-1);
} else {
res.push_back(mp[nums[i]]);
}
}
return res;
}
};
unordered_map中:
使用count,返回的是被查找元素的个数。如果有,返回1;否则,返回0。
使用find,返回的是被查找元素的位置,没有则返回map.end()。