【数据结构笔记】数据结构基础知识(上)

1.引言

        数据结构的常用操作插入,删除,查找。即,一个数据结构一般要具有能够插入、删除、查找的功能,比如对栈来说,栈支持的操作是从栈顶插入和删除,你不能对栈的栈底进行操作,也不能对栈的任意一个位置进行操作。

        其次,在知道其支持的操作后,我们还需要清楚地知道每个操作的时间复杂度,时间复杂度的重要性我们在前面已经介绍过。如果我们不知道一个数据结构里各个操作的时间复杂度,那么如果我们的解法使用了该数据结构的话,解法整体的时间复杂度我们也就无从计算了

        了解常用操作和时间复杂度之后,我们还需要了解这个数据结构的实现。例如我们知道 Python 中有 List 这种数据结构,他本质上是一个所谓大小可以动态调整的数组。但是我们知道在向计算机申请内存的时候,我们通常只能申请占用一块连续的内存,这显然是和动态调整大小相矛盾的。因此,动态数组的实现并不像我们想当然的那么简单,在后面的讲解中我们会再来详细讨论这个问题。        

        最后,我们需要了解数据结构的使用方式,即在我们所熟悉的语言中,有没有该数据结构的定义,他的接口应该怎样使用等。如果目前你还不太熟悉也没有关系,在后续的训练中我们需要大量使用各种数据结构,来提高我们对数据结构的敏感性和熟练程度。

2.数据结构介绍

2.1 线性数据结构

2.1.1 数组

一般需要支持:

  • push_back - 在数组末尾插入新元素 - O(1) 
  • pop_back - 删除数组中的最后一个元素 - O(1)
  • size - 返回数组长度 - O(1)
  • index - 返回数组中下标为 i 的元素 - O(1)

这些操作一般在O(1)的时间内完成。

2.1.2 栈

装羽毛球的球筒,只能对顶部(尾部)进行操作,后进先出,一般需要支持:

  • push - 放入一个元素到栈中 - O(1) 
  • pop - 从栈中删除一个元素  - O(1) 
  • top - 获取栈顶元素  - O(1) 
  • size - 返回栈的大小  - O(1) 

一般不支持查找,这些操作一般在O(1)的时间内完成。

2.2.3 队列

和栈很像,都是操作受到限制的线性表,只能对尾部进行插入,只能对头部进行删除

  • push - 放入一个元素到队列尾部 - O(1) 
  • pop - 从队首删除一个元素  - O(1) 
  • front- 获取队首元素  - O(1) 
  • size - 返回队列的大小  - O(1) 

 这些操作一般在O(1)的时间内完成。

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 + ... \leqslant 2n

时间复杂度为O(n)

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)的栈,并支持普通栈的全部四种操作(pushtoppop 和 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:O(1)

        pop:O(n)

        top:O(1)

        empty:O(1)

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^{2})

        这是最简单的解题思路,但却不是最优的时间复杂度,在解题或者面试的过程中,我们需要注意优先保证我们的复杂度是最优的。

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()。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

DUANDAUNNN

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值