Fighting——Day2

复习<<数据结构与算法——栈>>

栈是一种“操作受限”的线性表,从栈的操作特性上来看,栈是一种“操作受限”的线性表,只允许在一端进行数据的插入与删除。后进者先出,先进者后出,这就是典型的“栈”结构。

  • 为什么要引入栈?

我们之前学习了顺序表和链表这两种结构体,分别是线性表的不同实现方式。对于这两种数据结构,我们没有任何的操作限制,可以在任意位置进行增删查改,那为什么会引入栈这种数据结构呢,因为目前看来栈带给我们的只有“限制”,我们大可以用前两个数据结构就可以完成我们的操作。

其实,从功能的角度上来说,顺序表或链表确实可以替代栈,但是我们需要知道,特定的数据结构是对特定场景的抽象,而且,顺序表和链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就必不可免更加不可控,自然就更容易出错。

当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,我们就应该首选“栈”这种数据结构。

  • O(1)时间复杂度的插入和删除操作

由于栈本身"操作受限"的特点,其插入删除操作只在栈的一端进行,因此其具有高效的插入和删除操作。

  • 栈在函数调用中的应用

栈在函数调用过程中有着很实际性的作用,我们知道操作系统给每个线程分配了一块独立的内存空间,这块内存空间被组织成“堆栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将函数内的参数、局部变量及返回地址封装成栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

下面根据一个例子,更加直观的说明栈这种数据结构在函数调用中的作用:

#include<iostream>
using namespace std;
 
int fun()
{
    int c = 1;
    int d = 1;
    return c + d;
}
 
int main()
{
    int a = 1;
    int b = 1;
    b = fun();
    cout << "a = " << a << endl;
    cout << "b = " << b << endl;
    return 0;
}

首先说明,在堆栈中变量分布是从高地址到低地址分布,EBP是指向栈底的指针。ESP指向栈顶,程序执行时移动,ESP向下移动分配空间,ESP向上移动释放空间,ESP又称为栈顶指针。

  1. 函数main开始执行,系统为其分配了一块堆栈空间,随着函数的执行,变量a,变量b入栈。此时EBP指向栈底,ESP随着变量的入栈不断向下移动。
  2. 紧接着执行到fun()函数调用时,将当前函数地址入栈,紧接着跳转到fun()函数中。此时将EBP地址保存入栈,并将ESP值赋给EBP。
  3. fun()开始执行,变量c,变量d入栈,最终fun()执行结束。此时变量不断入栈,ESP移动,函数执行结束,将该函数的栈帧全部出栈,ESP返回到之前位置,并将之前存入栈中的函数地址值重新赋值给EBP,将存储地址出栈。
  • 栈在表达式求值以及括号匹配中的应用

我们知道编译在计算表达式值的时候,就采用了栈这种数据结构。下面给出具体的实现过程:

编译器通过两个栈实现表达式的求值,其中一个保存操作数的栈,另一个保存运算符的栈。当我们从左到右遍历表达式,遇到数字时,就直接压入操作数栈;遇到运算符,就与运算符栈的栈顶元素比较。如果比运算符栈顶元素的优先级高,就将当前运算符压入栈,如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取两个操作数,然后进行计算,再把计算结果压入操作数栈,继续比较。

除了用栈实现表达式求值,我们也可以借助栈来检测表达式中的括号是否匹配。下面给出具体实现过程:

我们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,将其压入栈中;当扫描到右括号的时候,从栈顶取出一个左括号。如果能够匹配,则继续扫描剩下的字符。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。当所有括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则说明有未匹配的左括号,为非法格式。

  • 通过两个栈实现浏览器的前进后退功能

我们使用两个栈,X和Y,我们把首次浏览的页面依次压入栈X,当点击退后按钮时,再依次从栈X中出栈,并将出栈的数据依次压入栈Y中。当我们点击前进按钮时,我们依次从栈Y中出栈,再将出栈的数据压入栈X中。当栈X中没有数据时,就说明没有页面可以继续后退浏览了。当栈Y中没有数据,就说明没有页面可以点击前进按钮了。
 

复习<<数据结构与算法——队列>>

和栈一样,队列也是一种“操作受限”的线性表。队列跟栈类似,支持的基本操作也只有两个:入队enqueue(),表示放一个数据到队列的尾部;出队dequeue(),从队列头部取一个元素。因此,队列也是一种操作受限的线性表数据结构。

  • 为什么要引入队列?

由于前面已经介绍过为什么要引入栈,所以这里就废话少说,是由于不同功能的需要,出现一类需要先入先出的数据结构。而队列的出现,则是为了满足这种需求而生。总而言之,当某个数据集合只涉及在一端插入和在另一端删除数据,并且满足先进先出的特性,我们就应该首选"队列"这种数据结构。

  • 假性队满问题

在顺序队列中,我们在不断更新数据的同时,整个队列(tali->head)的位置在数组中不断偏移,因此造成当队尾(tail)已经到底数组的末端,但队首(head)却并未在数组的首端,而此时便造成了假性队满的问题。关于这种情况,我们首先采用的解决思路便是,当出现假性队满时,我们将整个队列(tail->head)移动到数组的最前端,这样便解决了假性队满的问题。后来,便引入了循环队列,循环队列的产生避免了数据的大量搬移,这里需要注意的是,由于我们在判断队列是否为满和是否为空的条件上需要存在不同的判断条件:

队空条件:
tail == head
队满条件:
head == (tail + 1) % QueueSize

因此,在循环顺序队列当中,会出现一个存储位置的浪费。

  • 队列的应用

队列的应用简单的有循环队列、阻塞队列、并发队列,队列除了在线程池请求排队的情况下,还可以应用在任何有限资源池中,用于排队请求,比如数据库连接池等等。实际上对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队,这里就不一一细说。

复习<<数据结构与算法——排序算法>>

  • 冒泡排序(Bubble Sort)

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。

下面给出冒泡排序的实现(利用了哨兵优化):

void bubbleSort(int a[], int n)    // 冒泡排序,a表示数组,n表示数组的大小
{
    if (n <= 1) return;
    
    for (int i = 0; i < n; ++i)
    {
        bool flag = false;
        for (int j = 0; j < n - i - 1; ++j)
        {
            if (a[j] > a[j + 1])
            {
                int temp = a[j];
                a[j] = a[j + 1];
                a[j + 1] = temp;
                flag = true;        // 表示有数据交换
            }
        }
        if (!flag) break;           // 没有数据交换
    }
}

下面对冒泡排序进行分析:

  1. 首先冒泡过程当中只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为O(1),因此冒泡排序是一个原地排序算法。
  2. 在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
  3. 最好情况下,要排序的数据已经是有序的了,只需进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是O(n)。而最坏情况下是,要排序的数据刚好是逆序排列的,我们需要进行n次冒泡操作,所以最坏情况时间复杂度为O(n²)。
  • 插入排序(Insertion Sort)

首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。

下面给出插入排序的实现:

void insertionSort(int a[], int n)    // 插入排序,a表示数组,n表示数组的大小
{
    if (n <= 1) return;
    
    for (int i = 1; i < n; ++i)
    {
        int value = a[i];
        // 查找插入的位置
        for (int j = i - 1; j >= 0; --j)
        {
            if (a[j] > value)        // 元素的比较
                a[j + 1] = a[j];     // 元素的移动
            else
                break;
        }
        // 插入数据
        a[j + 1] = value;
    }
}

下面对插入排序进行分析:

  1. 首先从插入排序的实现过程来看,其运行中并不需要额外的存储空间,所以空间复杂度是O(1),因此插入排序是一个原地排序算法。
  2. 在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现的元素后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
  3. 最好情况下,要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序区间里查找插入位置,每次只需比较一个数据就能确定插入的位置。所以这种情况下,最好的时间复杂度为O(n)(从尾到头遍历已经有序的数据)。如果数据是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据(整个有序区间),所以最坏情况时间复杂度为O(n²)。已知我们在一个数组当中插入一个数据的平均时间复杂度为O(n),因此对于插入排序来说,每次插入操作就相当于在数组中插入一个数据,循环执行n次插入操作,所以平均情况下时间复杂度为O(n²)。
  • 选择排序(Selection Sort)

选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间(初始情况下已排序区间为空)。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

下面给出选择排序的实现:

void selectionSort(int a[], int n)
{
    for (int i = 0; i < n - 1; ++i)
    {
        int value = i;
        int j = i + 1;
        for (; j < n; ++j)
        {
            if (a[j] < a[value])
                value = j;
        }
        int flag = a[i];
        a[i] = a[value];
        a[value] = flag;
    }
}

下面对选择排序进行分析:

  1. 首先选择排序空间复杂度为O(1),是一种原地排序算法。
  2. 从之前的选择排序(举例)原理图中,我们可以看出每次选择排序都要找到剩余未排序元素中的最(小)值,并和前面的元素交换位置,这样破坏了稳定性,因此选择排序本身并不是一个稳定的排序算法。
  3. 选择排序最好情况下时间复杂度、最坏情况下时间复杂度和平均情况下时间复杂度都为O(n²),这一点从实现代码就可以明显的看出,无论要排序数据的有序度如何,选择排序执行的流程都不会改变。

剑指offer五道题

  • 剑指offer(三十二):把数组排成最小的数
  • 剑指offer(三十五):数组中的逆序对
  • 剑指offer(三十七):数字在排序数组中出现的次数
  • 剑指offer(四十):数组中只出现一次的数字
  • 剑指offer(五十):数组中重复的数字
发布了37 篇原创文章 · 获赞 42 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_42570248/article/details/90236665