《算法笔记》——第七章 数据结构 学习记录

STL中没有实现栈的清空,所以如果需要实现栈的清空,可以用一个while循环反复pop出元素直到栈空。

而事实上,更常用的方法是重新定义一个栈以变相实现栈的清空,因为这并不需要花很多时间,STL的stack进行定义的时间复杂度是\(O(1)\)

队列

STL中也没有实现队列的清空,所以如果需要实现队列的清空,可以用一个while循环反复pop出元素直到队列为空。

更常用的方法是重新定义一个队列以实现队列的清空,因为这并不需要花很多时间,STL的queue进行定义的时间复杂度和定义stack一样都是\(O(1)\)

链表

线性表是一类很常用的数据结构,分为顺序表和链表。其中顺序表可以简单地理解成前面介绍的“数组”这个概念,而这里将要讲解一下链表。

按正常方式定义一个数组时,计算机会从内存中取出一块连续的地址来存放给定长度的数组;而链表则使由若干个结点组成(每个结点代表一个元素),且结点在内存中的存储位置通常是不连续的。除此之外,链表的两个结点之间一般通过一个指针来从一个结点指向另一个结点,因此链表的结点一般由两部分构成,即数据域和指针域。

使用malloc函数或new运算符为链表结点分配内存空间

之前已经讲解如何去定义链表结点类型,那么如何在每次需要使用新结点时临时分配相应大小的内存空间给新结点呢?本节介绍两种方法,即C语言中的malloc函数与C++中的new运算符,读者可以按自己的喜好选择写法(推荐使用new运算符)。

  1. malloc函数
    malloc函数是C语言中stdlib.h头文件下用于申请动态内存的函数,其返回类型是申请的同变量类型的指针,其基本用法如下:
typename* p = (typename*)malloc(sizeof(typename));

以申请一个int型变量和一个node型结构体变量为例:

int* p = (int*)malloc(sizeof(int));
node* p = (node*)malloc(sizeof(node));

这个写法的逻辑是:以需要申请的内存空间大小(即sizeof(node) 为malloc函数的参数,这样malloc函数就会向内存申请一块大小为sizeof(node)的空间,并且返回指向这块空间的指针。

但是此时这个指针是一个未确定类型的指针void*,因此需要把它强制转换为node* 型的指针,因此在malloc之前加上(node* )。这样等号右边就得到了一个node* 型的指针,并通过赋值等号把这个指针赋给node* 型的指针变量p,就成功申请了一块node类型大小的内存空间,即一个node型的结构体变量,并通过指针p来访问它。如果申请失败,则会返回空指针NULL。

有读者肯定会问,什么时候会申请失败呢?其实一般来说,如果只是申请一个链表结点的话是不会失败的,失败一般发生在使用malloc申请了较大的动态数组,即

int* p = (int*)malloc(1000000 * sizeof(int));

这种情况下malloc会返回空指针NULL并赋值给p。因此只要是正常分配一个结点的空间,是不会失败的,当然发生死循环导致无限申请的情况除外。

  1. new运算符
    new是C++中用来申请动态空间的运算符,其返回类型同样是申请的同变量类型的指针,其基本用法如下:
typename* p = new typename;

同样以申请一个int型变量和一个node型结构体变量为例:

int* p = new int;
node* p = new node;

可以看到,new 的写法比malloc 要简洁许多,只需要“new+类型名”即可分配一块该类型的内存空间,并返回一个对应类型的指针。如果申请失败,则会启动C++异常机制处理而不是返回空指针NULL。和malloc同理,如果是使用new申请了较大的动态数组,即:

int* p = new int[1000000];

这时会发生异常,并在没有特殊处理的情况下直接退出程序。不过,只要是正常分配一个结点的空间,也是不会失败的。

内存泄露

内存泄露是指使用malloc与new开辟出来的内存空间在使用过后没有释放,导致其在程序结束之前始终占据该内存空间,这在一些较大的程序中很容易导致内存消耗过快以致最后无内存可分配。C/C++语言的设计者认为,程序员完全有能力自己控制内存的分配与释放,因此把对内存的控制操作全部交给了程序员。

因此初学者需要记住,在使用完malloc与new开辟出来的空间后必须将其释放,否则会造成内存泄露。下面讲解如何释放malloc与new开辟出来的空间。

  1. free函数
    free函数是对应malloc函数的,同样是在stdlib.h头文件下。其使用方法非常简单,只需要在free的参数中填写需要释放的内存空间的指针变量(不妨设为p)即可:
free(p);

free函数主要实现了两个效果:释放指针变量p所指向的内存空间;将指针变量p指向空地址NULL。

由此可以知道,在free函数执行之后,指针变量p本身并没有消失,只不过让它指向了空地址NULL,但是它原指向的内存是确实被释放了的。需要注意的是,malloc函数与free函数必须成对出现,否则容易产生内存泄露。

  1. delete运算符
    delete运算符是对应new运算符的,其使用方法和实现效果均与free 相同。

使用delete只需要在delete的参数中填写需要释放的内存空间的指针变量(不妨设为p)即可:

delete(p);

和free函数一样,new运算符与delete运算符必须成对出现,否则会容易产生内存泄露。

不过一般在考试中,分配的空间在程序结束时即被释放,因此即便不释放空间,也不会产生什么影响,并且内存大小一般也足够一道题的使用了。 但是从编程习惯上,读者应养成即时释放空间的习惯。

本书为了使算法的讲解更侧重于对思路的讲解,因此在代码中没有释放空间,希望读者阅读时注意。

  1. 创建链表
    一般使用for循环来建立需要的链表。
#include<iostream>
using namespace std;
struct node {
    int data;
    node* next;
};

node* create(int array[])
{
    node *head,*pre;
    head = new node;
    head->next = NULL;
    pre = head;
    
    for(int i=0;i<5;i++)
    {
        node* p = new node;
        p->data = array[i];
        p->next = NULL;
        pre->next = p;
        pre = p;
    }
    return head;
}

int main()
{
    int array[5]={5, 3, 6, 1, 2};
    node* L = create(array);
    
    node *p = L->next;
    while(p != NULL)
    {
        cout<<p->data<<' ';
        p = p->next;
    }
    return 0;
}
  1. 查找元素
    如果已经有了一条链表,那么如何查找其中是否有给定的元素x呢?很简单,只需从第一个结点开始,不断判断当前结点的数据域是否等于x,如果等于,那么就给计数器count加1。这样当到达链表结尾时,count 的值就是链表中元素x的个数。

  2. 插入元素
    对链表来说,插入元素是指在链表给定位置的地方插入一个结点。例如在链表5→3→6→1→2的第3个位置插入元素4,就会使链表变为5→3→4→6→1→2。

很多初学者往往会很困惑,所谓在第i个位置插入元素是插在这个位置之前还是之后,即上面的例子最后会形成5→3→6→4→1→2还是5→3→4→6→1→2。

事实上,在第3个位置插入元素4的意思是指在插入完成之后第3个位置的元素就是4,这就可以理解了,应该是把原先第3个位置开始的元素让出来给需要插入的数。图为插入元素4之前与之后的链表对比图。

通过图知道在插入过程中哪些指针发生了变化。容易看出来:
1. 元素4所在结点的指针域next指向了元素6所在结点的地址。
2. 元素3所在结点的指针域next指向了元素4所在结点的地址。

void insert(node* head, int pos, int x)
{
    node *p = head;
    for(int i=0;i<pos-1;i++)
        p=p->next; //pos-1是为了到插入位置的前一个结点
    node* q = new node;
    q->data = x;
    q->next = p->next;
    p->next = q;
}

操作顺序必须是先把新结点的指针域next指向后继结点6,之后才能把元素3所在结点的指针域指向新结点的地址。

  1. 删除元素
    对链表来说,删除元素是指删除链表上所有值为给定的数x。例如删除链表5→3→6→1→2中的6,就会使链表变为5→3→1→2。

删除操作是这样进行的:

  1. 由指针变量p枚举结点,另一个指针变量pre表示p指向结点的前驱结点。
  2. 当p所指结点的数据域恰好为x时,进行下面三个操作。
    • 令pre所指结点的指针域next指向p所指结点的下一个结点。
    • 释放p所指结点的内存空间。
    • 令p指向pre所指结点的下一个节点。
void del(node* head, int x)
{
    node* p = head->next;
    node* pre = head;
    while(p != NULL)
    {
        if(p->data == x)
        {
            pre->next = p->next;
            delete(p);
            p = pre->next;
        }
        else
        {
            pre = p;
            p = p->next;
        }
    }
}

静态链表

前面讲解的都是动态链表,即需要指针来建立结点之间的连接关系。而对有些问题来说,结点的地址是比较小的整数(例如5位数的地址),这样就没有必要去建立动态链表,而应使用方便得多的静态链表。

静态链表的实现原理是hash,即通过建立一个结构体数组,并令数组的下标直接表示结点的地址,来达到直接访问数组中的元素就能访问结点的效果。另外,由于结点的访问非常方便,因此静态链表是不需要头结点的。静态链表结点定义的方法如下:

struct Node {
	typename data;
	int next;
}node[size];

在上面的定义中,next是一个int型的整数,用以存放下一个结点的地址(事实上就是数组下标)。例如,如果初始结点的地址为1111, 第二个结点的地址是2222,第三个结点的地址是3333,且第三个结点为链表末尾,那么整个静态链表的结点就可以通过下面的写法连接起来:

node[11111] = 22222;
node[22222] = 33333;
node[33333] = -1;//-1对应动态链表中的NULL,表示没有后继结点

另外一点需要注意的是,把结构体类型名和结构体变量名设成了不同的名字(即Node与node), 事实上在一般情况下它们是可以相同的,但是由于静态链表是由数组实现的,那么就有可能需要对其进行排序,这时如果结构体类型名和结构体变量名相同,sort函数就会报编译出错的问题,因此,在使用静态链表时,尽量不要把结构体类型名和结构体变量名取成相同的名字。

静态链表解题步骤

  1. 定义静态链表
struct Node {
	int address;
	typename data;
	int next;
	XXX; // 结点的某个性质
}node[100010];

上面的定义中,我们把结点的地址、数据域、指针域都进行了定义,并且留了一个XXX来适应不同的题目(例如可以设置成结点是否为链表上的一个结点)。

  1. 在程序的开始,对静态链表进行初始化。一般来说,需要对定义中的XXX进行初始化,将其定义为正常情况下达不到的数字(一般来说需要小于所有能达到的数字,理由在第四步说明),例如对结点是否在链表上这个性质来说,我们可以初始化为0(即false), 表示结点不在链表上。
for(int i=0;i<maxn;i++)
	node[i].XXX = 0;
  1. 题目一般都会给出一条链表的首结点的地址,那么我们就可以依据这个地址来遍历得到整条链表。需要注意的是,这-一步同时也是我们对结点的性质XXX进行标记、并且对有效结点的个数进行计数的时候,例如对结点是否在链表上这个性质来说,当我们遍历链表时,就可以把XXX置为1 (即true)。
int p = begin,count = 0;
whlie(p != -1)
{
	XXX = 1;
	count++;
	p = node[p].next;
}
  1. 由于使用静态链表时,是直接采用地址映射(hash) 的方式,这就会使得数组下标的不连续,而很多时候题目给出的结点并不都是有效结点(即可能存在不在链表上的结点)。

为了能够可控地访问有效结点,一般都需要用对数组进行排序以把有效结点移到数组左端,这样就可以用步骤3得到的count来访问它们。既然需要把有效结点移到前面,那么就可以用之前定义的XXX来帮忙。

在步骤2, XXX需要被初始化为比正常结点的XXX取值要小的数值,这个做法就可以在这一步起到作用。由于无效结点的XXX在步骤3中不会被修改,因此一定比有效结点的XXX小。

于是在写sort的排序函数cmp时,就可以在cmp的两个参数结点中有无效结点时按XXX从大到小排序,这样就可以把有效结点全部移到数组左端。

一般来说,题目一定会有额外的要求,因此cmp函数中一般都需要有第二级排序,不过这需要以不同的题目要求来确定。

例如,如果题目的要求需要把链表按结点顺序排序,就需要在cmp函数中建立第二级排序,即在cmp的两个参数结点中有无效结点时按XXX从大到小排序,而当两个参数结点都是有效结点时按结点在链表中的位置从小到大排序(结点的顺序可以在第三步得到)。

  1. 在经历了步骤4后,链表中的有效结点就都在数组左端了,且已经按结点的性质进行了排序,接下来就要看题目在排序之后具体要求做什么了( 比较常见的是按各种不同的要求输出链表)。
posted @ 2021-02-18 12:01  Dazzling!  阅读(51)  评论(0编辑  收藏  举报