数据结构与算法(C/C++实现)
第一章 C++回顾
1.1 C++初步
1、C++命名空间
例如小李和小韩都参与了一个文件管理系统的开发,它们都定义了一个全局变量 fp,用来指明前打开的文件,将他们的代码整合在一起编译时,很明显编译器会提示 fp 重复定义(Redefinition)错误。
为了解决合作开发时的命名冲突问题,C++ 引入了命名空间(Namespace)
的概念。请看下面的例子:
namespace Li{ //小李的变量定义
FILE fp = NULL;
}
namespace Han{ //小韩的变量定义
FILE fp = NULL
}
小李与小韩各自定义了以自己姓氏为名的命名空间,此时再将他们的 fp 变量放在一起编译就不会有任何问题。
命名空间有时也被称为名字空间、名称空间。namespace 是C++中的关键字,用来定义一个命名空间,语法格式为:
namespace name{
//variables, functions, classes
}
name
是命名空间的名字,它里面可以包含变量、函数、类、typedef、#define 等,最后由{ }
包围。
使用变量、函数时要指明它们所在的命名空间。以上面的 fp 变量为例,可以这样来使用:
Li :: fp = fopen("one.txt", "r"); //使用小李定义的变量 fp
Han :: fp = fopen("two.txt", "rb+"); //使用小韩定义的变量 fp
::
是一个新符号,称为域解析操作符,在C++中用来指明要使用的命名空间。
除了直接使用域解析操作符,还可以采用 using 声明,例如:
using Li :: fp;
fp = fopen("one.txt", "r"); //使用小李定义的变量 fp
Han :: fp = fopen("two.txt", "rb+"); //使用小韩定义的变量 fp
在代码的开头用using
声明了Li::fp
,它的意思是,``using 声明以后的程序中如果出现了未指明命名空间的
fp,就使用
Li::fp;但是若要使用小韩定义的
fp,仍然需要
Han::fp`。
using 声明不仅可以针对命名空间中的一个变量,也可以用于声明整个命名空间,例如:
using namespace Li;
fp = fopen("one.txt", "r"); //使用小李定义的变量 fp
Han :: fp = fopen("two.txt", "rb+"); //使用小韩定义的变量 fp
如果命名空间 Li 中还定义了其他的变量,那么同样具有 fp 变量的效果。在 using 声明后,如果有未具体指定命名空间的变量产生了命名冲突,那么默认采用命名空间 Li 中的变量。
命名空间内部不仅可以声明或定义变量,对于其它能在命名空间以外声明或定义的名称,同样也都能在命名空间内部进行声明或定义,例如类、函数、typedef、#define 等都可以出现在命名空间中。
2、C++标准库和std命名空间
为了避免头文件重名,新版 C++ 库也对头文件的命名做了调整,去掉了后缀.h
,所以老式 C++ 的iostream.h
变成了iostream
,fstream.h
变成了fstream
。而对于原来C语言的头文件,也采用同样的方法,但在每个名字前还要添加一个c
字母,所以C语言的stdio.h
变成了cstdio
,stdlib.h
变成了cstdlib
可以发现,对于不带
.h
的头文件,所有的符号都位于命名空间 std 中,使用时需要声明命名空间 std;对于带.h
的头文件,没有使用任何命名空间,所有符号都位于全局作用域。这也是 C++ 标准所规定的
在C语言中,我们通常会使用 scanf 和 printf 来对数据进行输入输出操作。在C++语言中,C语言的这一套输入输出库我们仍然能使用,但是 C++ 又增加了一套新的、更容易使用的输入输出库。
在C++ 中的输入与输出可以看做是一连串的数据流,输入即可视为从文件或键盘中输入程序中的一串数据流,而输出则可以视为从程序中输出一连串的数据流到显示屏或文件中。在编写 C++ 程序时,如果需要使用输入输出时,则需要包含头文件iostream,包含了用于输入输出的对象,例如常见的
cin表示标准输入、
cout表示标准输出、
cerr`表示标准错误。iostream 是 Input Output Stream 的缩写,意思是“输入输出流”。cout 和 cin 都是 C++ 的内置对象,而不是关键字。
3、new和delete操作符
在C语言中,动态分配内存用 malloc() 函数,释放内存用 free() 函数。如下所示:
int *p = (int*) malloc( sizeof(int) * 10 ); //分配10个int型的内存空间
free(p); //释放内存
在C++中,这两个函数仍然可以使用,但是C++又新增了两个关键字,new 和 delete:new 用来动态分配内存,delete 用来释放内存。
int *p = new int; //分配1个int型的内存空间
delete p; //释放内存
//new 操作符会根据后面的数据类型来推断所需空间的大小。
//如果希望分配一组连续的数据,可以使用 new[]:
int *p = new int[10]; //分配10个int型的内存空间
delete[] p;
用 new[] 分配的内存需要用 delete[] 释放,它们是一一对应的。
和 malloc() 一样,new 也是在堆区分配内存,必须手动释放,否则只能等到程序运行结束由操作系统回收。为了避免内存泄露,通常 new 和 delete、new[] 和 delete[] 操作符应该成对出现,并且不要和C语言中 malloc()、free() 一起混用。
4、inline 内联函数
一个 C/C++ 程序的执行过程可以认为是多个函数之间的相互调用过程,它们形成了一个或简单或复杂的调用链条,这个链条的起点是 main(),终点也是 main()。当 main() 调用完了所有的函数,它会返回一个值(例如
return 0;
)来结束自己的生命,从而结束整个程序。函数调用是有时间和空间开销的。程序在执行一个函数之前需要做一些准备工作,要将实参、局部变量、返回地址以及若干寄存器都压入栈中,然后才能执行函数体中的代码;函数体中的代码执行完毕后还要清理现场,将之前压入栈中的数据都出栈,才能接着执行函数调用位置以后的代码。
如果函数体代码比较多,需要较长的执行时间,那么函数调用机制占用的时间可以忽略;如果函数只有一两条语句,那么大部分的时间都会花费在函数调用机制上,这种时间开销就就不容忽视。
为了消除函数调用的时空开销,C++ 提供一种提高效率的方法,即在编译时将函数调用处用函数体替换,类似于C语言中的宏展开。这种在函数调用处直接嵌入函数体的函数称为``内联函数(Inline Function)`,又称内嵌函数或者内置函数。
注意,要在函数定义处添加 inline 关键字,在函数声明处添加 inline 关键字虽然没有错,但这种做法是无效的,编译器会忽略函数声明处的 inline 关键字。
使用内联函数的缺点也是非常明显的,编译后的程序会存在多份相同的函数拷贝,如果被声明为内联函数的函数体非常大,那么编译后的程序体积也将会变得很大,所以再次强调,一般只将那些短小的、频繁调用的函数声明为内联函数。
1.2 C++ 函数提高
1、函数参数
1、传值参数
int abc(int a ,int b,int)
{
return a+b+c;
}
a,b,c是形参
,每一个形参都是整形;可以使z=abc(2,x,y)调用求和函数。那么2,x,y分别对应a,b,c的实参。形参a,b,c实际上是传值参数
,在运行时,函数a,b,c执行前,把实参复制给形参。复制过程是由形参类型的复制构造函数
2、默认参数
在C++中,函数的形参列表中的形参是可以有默认值的
语法:返回值类型 函数名(参数A=默认值1,参数B=默认值2)
int add(int a, int b = 10, int c = 20)
{
return a + b + c;
}
//如果某个位置有默认参数,那么从这个位置往后,都必须有默认值
//声明和实现只能一个有默认参数
/*错误示例
int add(int a, int b = 10, int c)
{
return a + b + c;
}
*/
2、函数重载
**作用:**函数名相同,提高复用性
函数重载满足的条件:
- 同一个作用域下
- 函数名称相同
- 函数参数类型不同,或者个数不同,或者顺序不同
int add(int a, int b)
{
return a + b;
}
int add(int a, int b, int c)
{
return a + b + c;
}
double add(double a, double b, double c)
{
return a + b + c;
}
int main()
{
int m, n;
cout << " 1 : " << add(1, 2) << endl;
cout << " 2 : " << add(1, 2, 3) << endl;
cout << " 3 : " << add(1.1, 2.2, 3.3) << endl;
system("pause");
return 0;
}
函数重载的注意事项:
- 引用作为函数重载的条件
- 函数重载碰到默认参数
注意:编译器的二义性
3、模板函数
假定我们希望编写另外一个函数来计算相同的表达式,不过这次a、b和c是float类型,结果也是float类型。程序1-2中给出了具体的代码。区别仅在于形参以及函数返回值的类型不同。
float abc(float a, float b, float c)
{
return a+b+c;
}
与其对每一种可能的形参类型都编写一个相应函数的新版本,不如编写一段通用代码,它的参数类型是一个变量,它的值由编译器来确定。这种通用代码使用的是模板语句。
template <class T>
T abc(T a, T b, T c)
{
return a+b+c;
}
从这段通用代码,编译器通过既可T替换为int,也可把T替换为float 。事实上,通过把T替换为double或long,编译器还可以构造出函数abc的双精度型版本和长整型版本。把函数abc编写成模板函数,我们就不必了解形参的类型了。
1.3 C++类和对象
C++面向对象的三大特性:封装、继承、多态,C++任务万事万物皆为对象,对象有其属性和行为。
类是创建对象的模板,一个类可以创建多个对象,每个对象都是类类型的一个变量;创建对象的过程也叫类的实例化。每个对象都是类的一个具体实例,拥有类的成员变量和成员函数。
有些教程将类的成员变量称为类的属性(Property),将类的成员函数称为类的方法(Method)。在面向对象的编程语言中,经常把函数(Function)称为方法(Method)。
class Student
{
public:
//属性(Property)
string name;
int old;
float score;
//方法(Method)
void StudentInformation()
{
cout << "the old of " << name << "is " << old << ","
<< "and his(her) score is " << score << endl;
}
};
1、封装的意义
访问的三种权限:
公共权限(public)
:成员 类内可以访问 类外可以访问
保护权限(protected)
:成员 类内可以访问 类外不可以访问 子类可以访问父类
私有权限(private)
:成员 类内可以访问 类外不可以访问 子类不可以访问父类
class Person
{
public:
string m_name;
protected:
int m_car;
private:
int m_card_ID;
public:
void func()
{
m_name = "zwy";
m_car = 123;
m_card_ID = 123456;
}
};
成员属性设置为私有
1、可以自己控制读写属性
2、对于写可以检测数据有效性
2、对象的初始化和清理
1、构造函数和析构函数
构造函数语法:类名(){}
- 构造函数没有返回值,也不写void
- 函数名与类名相同
- 构造函数也可以有参数,因此也可以发生重载
- 程序在调用对象的时候自动调用构造,无需手动调用,而且只会调用一次
析构函数语法:~类名(){}
- 析构函数没有返回值,也不写void
- 函数名与类名相同,在名称前加上符号 ~
- 析构函数不可以有参数,因此不可以发生重载
- 程序在对象销毁的时候自动调用析构,无需手动调用,而且只会调用一次
class Person
{
public:
Person()
{
cout << "1" << endl;
}
~Person()
{
cout << "2" << endl;
}
};
2、构造函数的分类
-
按照参数分类:无参构造(默认构造)和有参构造
-
按照类型分类:拷贝构造 和 普通构造(默认构造)
//有参构造 Person(int a) { cout << "1" << endl; } //拷贝构造 Person(const Person &p) { //将传入类的所有属性拷贝到本个上 }
3、函数模板
-
C++另一种编程思想称为泛型编程。主要利用的技术就是模板
-
C++提供两种模板机制:函数模板和类模板
函数模板语法:
//定义
template<typename T>
void func(T &a,T &b)
{
}
//使用
func<datatype>(a,b) ;
解释:
template ———声明模板
typename ——— 表明其后面的是一种数据类型,可用class替换
T ——— 通用数据类型,名称可替换,通常为大写字母
函数模板作用: 建立-一个通用函数,其函数返回值类型和形参类型可以不具体制定,用-个虚拟的类型来代表。
函数模板的局限性:假设有个类—人,它有两个属性—名字和年龄。能否通过函数模板直接判断是否是同一个人?
class Person{
public:
string _name;
int _age;
Person(string name, int age) : _name(name), _age(age){};
~Person(){};
};
以下为错误示例:
/*
template <typename T>
bool func(T &a, T &b)
{
if (a == b)
return true;
else
return false;
}
void test_1()
{
Person P1("Tom", 10);
Person P2("Tom", 10);
bool re = func(P1, P2);
}*/
解决方法:
template <typename T>
bool func(T &a, T &b)
{
if (a == b)
return true;
else
return false;
}
template <>
bool func(Person &a, Person &b)
{
if (a._age == b._age && a._name == b._name)
return true;
else
return false;
}
void test_2()
{
Person P1("Tom", 10);
Person P2("Tom", 10);
bool re = func<Person>(P1, P2);
cout << re << endl;
}
4、类模板
类模板语法:
template <class nameType, class ageType = int>
class Person
{
public:
nameType _name;
ageType _age;
Person(nameType name, ageType age) : _name(name), _age(age){};
~Person(){};
};
解释:
template ———声明模板
typename ——— 表明其后面的是一种数据类型,可用class替换
T ——— 通用数据类型,名称可替换,通常为大写字母
第二章 程序性能分析
2.1 空间复杂度
1 、空间复杂度的组成
程序所需要的空间主要由以下部分构成:
(1)指令空间( instruction space ):指令空间是指编译之后的程序指令所需要的存储空间。
指令空间的数量取决于如下因素:
●把程序转换成机器代码的编译器。
●在编译时的编译器选项。
●目标计算机。
(2)数据空间( data space ):数据空间是指所有常量和变量值所需要的存储空间。它由两个部分构成:
●常量和简单变量所需要的存储空间。
●动态数组和动态类实例等动态对象所需要的空间。
类型 | 空间大小 | 范围 |
---|---|---|
bool | 1 | {true,flase} |
char | 1 | {-128,127} |
unsigned char | 1 | {0,255} |
short | 2 | {-32768,32767} |
unsigned short | 2 | {0,65535} |
long | 4 | {-231,-231-1} |
unsigned long | 4 | {0,232-1} |
int | 4 | {-231,-231-1} |
unsigned int | 4 | {0,232-1} |
float | 4 | ±3.4E±38 (7位) |
double | 8 | ±1.7E±308(15位) |
long double | 10 | ±1.2E±4392(19位) |
(3)环境栈空间( environment stack space ):环境栈用来保存暂停的陋数和方法在恢复运行时所需要的信息。例如,如果函数foo调用了丽数goo,那么我们至少要保存在函数goo结束时函数foo继续执行的指令地址。
●返回地址。
●正在调用的函数的所有局部变量的值以及形式参数的值( 仅对递归函数而言)。
2.2 时间复杂度
2.3 操作计数
2.4 步数
第三章 线性表——数组描述
3.1 线性表的数据结构
线性表( linear list)也称有序表( ordered list), 它的每一个实例都是元素的一一个有序集合。每一个实例的形式为(e0,e1,e2,e3…en)其中n是有穷自然数,ei 是线性表的元素,i是元素ei 的索引,n是线性表的长度或大小。元素可以被看做原子,它们本身的结构与线性表的结构无关。当n=0时,线性表为空;当n>0时,e0是线性表的第0个元素或首元素, en-1 是线性表的最后一个元素。可以认为e0先于e1,e1先于e2等等。除了这种先后关系之外,线性表不再有其他关系。
对线性表的操作:
- 创建线性表
ChaLenArray *Init_Array() //初始化一个动态数组
{
ChaLenArray *NewArray = (ChaLenArray *)malloc(sizeof(ChaLenArray));
NewArray->size = 0;
NewArray->cap = 20;
NewArray->pAddr = (int *)malloc(sizeof(int) * NewArray->cap);
return NewArray;
}
- 撤销一个线性表。
void FreeSpace_Array(ChaLenArray *arr) //释放一个动态数组内存空间
{
if (arr == NULL)
return;
if (arr->pAddr != NULL)
{
/* code */
free(arr->pAddr);
}
free(arr);
}
- 确定线性表的长度。
int Size_Array(ChaLenArray *arr)
{
if (arr == NULL)
return -1;
return arr->size;
}
- 按一个给定的索引查找一个元素。
int At_Array(ChaLenArray *arr, int pos)
{
if (arr == NULL)
return -1;
return arr->pAddr[pos];
}
- 按一个给定的元素查找其索引。
int Find_Array(ChaLenArray *arr, int value) //找到一个元素
{
if (arr == NULL)
return -1;
int pos = -1;
for (int i = 0; i < arr->size; i++)
{
if (arr->pAddr[i] == value)
{
pos = i;
break;
}
}
return pos;
}
- 按一个给定的索引删除- -个元素。
void RemoveByPos_Array(ChaLenArray *arr, int pos) //通过位置删除一个元素
{
if (arr == NULL)
return;
if (pos < 0 || pos >= arr->size)
return;
for (int i = pos; i < arr->size - 1; i++)
{
arr->pAddr[i] = arr->pAddr[i + 1];
}
arr->size--;
}
- 按一个给定的索引插入-一个元素。
void Push_Back_Array(ChaLenArray *arr, int value) //尾部插入一个数据
{
if (arr == NULL)
return;
if (arr->size >= arr->cap)
{
int *Newspce = (int *)malloc(arr->cap * 2 * sizeof(int));
memcpy(Newspce, arr->pAddr, arr->cap * sizeof(int));
free(arr->pAddr);
arr->cap = arr->cap * 2;
arr->pAddr = Newspce;
}
arr->pAddr[arr->size] = value;
arr->size++;
}
- 从左至右顺序输出线性表元素。
void Printf_Array(ChaLenArray *arr) //打印
{
if (arr == NULL)
return;
for (int i = 0; i < arr->size; i++)
{
/* code */
printf("%d ", arr->pAddr[i]);
}
printf("\n");
}
3.2 变长一维数组
一维数组array,线性表元素存在array[0,n-1]中,要增加或减少这个数组长度,首先要创建一个新数组,然后把array复制到这个数组,最后改变数组array的值,是它能够引用新数组。
//c++实现
template <class T>
void ChangeLength1D(T *a, int OldLength, int NewLength)
{
//if (NewLength <= 0)
// throw "new length must be >=0";
T *temp = new T[NewLength];
int number = min(OldLength, NewLength);
copy(a, a + number, temp);
delete[] temp;
a = temp;
}
//C语言实现
ChaLenArray *Expand_Capacity(ChaLenArray *old_arr)
{
if (old_arr == NULL)
return NULL;
ChaLenArray *New_arr;
New_arr->cap = (int *)malloc(sizeof(int) * old_arr->cap * 2);
memcpy(New_arr->pAddr, old_arr->pAddr, old_arr->cap * sizeof(int));
New_arr->size = old_arr->size;
free(old_arr->pAddr);
return New_arr;
}
第四章 线性表—— 链表
4.1 单向链表
在链式描述中,数据实例的每一个元素都用一个单元或节点来描述。节点不必是数组成员,因此不是用公式来确定元素的位置。取而代之的是,每一个节点都明确包含另一个相关节点的位置信息,这个信息称为链( link )
或指针( pointer )
。
一般来说,为了找到索引为theIndex的元素,需要从firstNode开始,跟踪theIndex个指针才能找到。一个线 性表的链式的每一个节点只有一个链,这种结构称为单向链表( singly linkedlist)
。链表从左到右,每一个节点(最后一个节点除外)都链接着下一个节点,最后一个节点的链域值为NULL。这样的结构也称为链条( chain )
。
因此,定义数据类型如下:
//定义节点
typedef struct LINKNODE
{
void *data; //指向任何数据指针 (数据域)
struct LINKNODE *next;//指向下一个节点 (指针域)
} LinkNode;
//定义链表
typedef struct LINKLIST
{
LinkNode *head;//定义头结点
int size;//定义链表长度
} LinKList;
初始化
:首先需要初始化链表,对于一个链表而言,只需初始化头结点的指针域和数据域。
LinKList *Init_LinkList()
{
LinKList *list = (LinKList *)malloc(sizeof(LinKList));//申请内存存放链表
list->size = 0;
list->head = (LinkNode *)malloc(sizeof(LinkNode));//申请内存存放头结点
list->head->data = NULL;//头结点的数据域
list->head->next = NULL;//头结点的指针域
}
插入元素
: 将要插入的元素与表List、位置Pos和数据指针Data一起传入。这个 Insert_LinkList()函数将一个元索插人到由 Pos所指示的位置之后。它意味着插入操作如何实现并没有完全确定的规则。很有可能将新元素插人到位置Pos处(即在位置P处当时的元索的前面),所以需要知道位置P前面的元素。
void Insert_LinkList(LinKList *List, int Pos, void *Data)
{
if (list == NULL || data == NULL)
return;
if (pos < 0 || pos > list->size)
pos = list->size;
//创建新节点
LinkNode *newNode = (LinkNode*)malloc(sizeof(LinkNode));
newNode->data = data;
newNode->next = NULL;
//找节点(即找到Pos-1位置)
LinkNode *pCurrent = list->head; //辅助指针
for (int i = 0; i < pos; i++)
{
pCurrent = pCurrent->next;
}
//新节点入链表
newNode->next = pCurrent->next;
pCurrent->next = newNode;
list->size++;
}
删除元素
: 通过位置删除表List中的某个元素X。需要获取元素X的位置,与插入操作相同,也许寻找出前一个元素,并且缓存前一个元素的指针域信息,将指针域信息指向Pos的下一个位置。
void RemoveByBos_linkList(LinKList *list, int pos)
{
if (list == NULL)
return;
if (pos < 0 || pos >= list->size)
return;
//查找删除的前一个节点
LinkNode *pCurrent = list->head;
for (int i = 0; i < pos; i++)
{
pCurrent = pCurrent->next;
}
//缓存待删除的节点
LinkNode *pDel = pCurrent->next;
pCurrent->next = pDel->next->next;
//释放删除节点的内存
free(pDel);
list->size--;
}
查找元素
: 在链表中查找指定数据元素,最常用的方法是:从表头依次遍历表中节点,用被查找元素与各节点数据域中存储的数据元素进行比对。
int Find_LinkList(LinKList *list, void *data)
{
if (list == NULL || data == NULL)
return -1;
int i;
LinkNode *pCurrent = list->head->next;
while (pCurrent != NULL)
{
if (pCurrent->data == data)
{
break;
}
i++;
pCurrent = pCurrent->next;
}
return i;
}
打印输出
:遍历链表中的所有元素,直到最后一个元素为NULL
,这里为了打印任何类型的数据,使用了函数指针,重定义了Print函数。
void MyPrint(void *data)
{
PERSON *p = (PERSON *)data;
printf("Name:%s Age:%d Score:%d\n", p->name, p->age, p->score);
}
void Printf_LinkList(LinKList *list, PRINTFLINKNODE print)
{
if (list == NULL)
return;
LinkNode *pCurrent = list->head->next;
while (pCurrent != NULL)
{
print(pCurrent->data);
pCurrent = pCurrent->next;
}
}
释放链表
: 清空所有元素的内存,包括链表和节点。
void FreeSpace_LinkList(LinKList *list)
{
if (list == NULL)
return;
LinkNode *pCurrent = list->head;
while (pCurrent != NULL)
{
//缓存下一个节点
LinkNode *pNext = pCurrent->next;
free(pCurrent);
pCurrent = pNext;
}
list->size = 0;
free(list);
}
4.2 双向链表
虽然使用单链表能 100% 解决逻辑关系为 “一对一” 数据的存储问题,但在解决某些特殊问题时,单链表并不是效率最优的存储结构。比如说,如果算法中需要大量地找某指定结点的前趋结点,使用单链表无疑是灾难性的,因为单链表更适合 “从前往后” 找,而 “从后往前” 找并不是它的强项。本节来学习双向链表(简称双链表)。
双向,指的是各节点之间的逻辑关系是双向的,但通常头指针只设置一个,除非实际情况需要。
从上图中可以看到,双向链表中各节点包含以下 3 部分信息:
- 指针域:用于指向当前节点的直接前驱节点;
- 数据域:用于存储数据元素。
- 指针域:用于指向当前节点的直接后继节点;
数据类型:
typedef int NodeDataType;
typedef struct _DoublelinkedNode
{
struct _DoublelinkedNOde *prior; //指向直接前趋
NodeDataType data;
struct _DoublelinkedNode *next; //指向直接后继
} DoubLinkNode;
初始化一个双向链表:
DoubLinkNode *DoubListInit()
{
DoubLinkNode *head = (DoubLinkNode *)malloc(sizeof(DoubLinkNode));
head->prior = NULL;
head->next = NULL;
head->data = 1;
//声明一个指向首元节点的指针,方便后期向链表中添加新创建的节点
DoubLinkNode *list = head;
for (int i = 2; i <= 5; i++)
{
//创建新的节点并初始化
DoubLinkNode *body = (DoubLinkNode *)malloc(sizeof(DoubLinkNode));
body->prior = NULL;
body->next = NULL;
body->data = i;
//新节点与链表最后一个节点建立关系
list->next = body;
body->prior = list;
//list永远指向链表中最后一个节点
list = list->next;
}
return head;
}
双向链表添加节点
:有三种情况,分别是头部插入、中间插入和尾部插入。
1、头部插入
:将新数据元素添加到表头,只需要将该元素与表头元素建立双层逻辑关系即可。
换句话说,假设新元素节点为 temp,表头节点为 head,则需要做以下 2 步操作即可:
- temp->next=head; head->prior=temp;
- 将 head 移至 temp,重新指向新的表头;
2、中间位置插入
:同单链表添加数据类似,双向链表中间位置添加数据需要经过以下 2 个步骤,
- 新节点先与其直接后继节点建立双层逻辑关系;
- 新节点的直接前驱节点与之建立双层逻辑关系;
3、尾部插入
:与添加到表头是一个道理,实现过程如下:
-
找到双链表中最后一个节点;
-
让新节点与最后一个节点进行双层逻辑关系;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7y6OSqPT-1622084567131)(D:\小周的部落阁\DataStructAndAlgorithms\双向链表尾插.png)]
DoubLinkNode *InsertList(DoubLinkNode *list, NodeDataType data, int pos)
{
DoubLinkNode *NewNode = (DoubLinkNode *)malloc(sizeof(DoubLinkNode));
NewNode->data = data;
NewNode->next = NULL;
NewNode->prior = NULL;
//插入到链表头,要特殊考虑
if (pos == 1)
{
NewNode->next = list;
list->prior = NewNode;
list = NewNode;
}
//插入链表中
else
{
DoubLinkNode *pCurrent = list; //辅助指针,找到插入位置的前一个节点
for (int i = 0; i < pos - 1; i++)
{
pCurrent = pCurrent->next;
}
if (pCurrent->next == NULL) //说明插入点是尾部
{
pCurrent->next = NewNode;
NewNode->prior = pCurrent;
}
else //说明插入点是中间
{
pCurrent->next->prior = NewNode;
NewNode->next = pCurrent->next;
pCurrent->next = NewNode;
NewNode->prior = pCurrent;
}
}
return list;
}
双向链表删除节点
:双链表删除结点时,只需遍历链表找到要删除的结点,然后将该节点从表中摘除即可。
DoubLinkNode *DeleteNodeByData(DoubLinkNode *list, int data)
{
if (list == NULL)
return -1;
DoubLinkNode *pCurrent = list;
while (pCurrent)
{
if (pCurrent->data == data)
{
pCurrent->next->prior = pCurrent->prior;
//pCurrent->prior->next = pCurrent->next;
free(pCurrent);
return list;
}
pCurrent = pCurrent->next;
}
printf("tThis list doesn't have this element\n");
return list;
}
双向链表查找节点
:通常,双向链表同单链表一样,都仅有一个头指针。因此,双链表查找指定元素的实现同单链表类似,都是从表头依次遍历表中元素。
NodeDataType selectElem(DoubLinkNode *list, NodeDataType elem)
{
//新建一个指针t,初始化为头指针 head
DoubLinkNode *t = list;
int i = 1;
while (t)
{
if (t->data == elem)
{
return i;
}
i++;
t = t->next;
}
//程序执行至此处,表示查找失败
return -1;
}
双向链表更改节点
:更改双链表中指定结点数据域的操作是在查找的基础上完成的。实现过程是:通过遍历找到存储有该数据元素的结点,直接更改其数据域即可。
//更新函数,其中,add 表示更改结点在双链表中的位置,newElem 为新数据的值
DoubLinkNode *amendElem(DoubLinkNode *list, int pos, int newElem)
{
DoubLinkNode *temp = list;
//遍历到被删除结点
for (int i = 1; i < pos; i++)
{
temp = temp->next;
}
temp->data = newElem;
return list;
}
4.3 经典算法题——约瑟夫环
约瑟夫环问题,是一个经典的循环链表问题,题意是:已知 n 个人(分别用编号 1,2,3,…,n 表示)围坐在一张圆桌周围,从编号为 k 的人开始顺时针报数,数到 m 的那个人出列;他的下一个人又从 1 开始,还是顺时针开始报数,数到 m 的那个人又出列;依次重复下去,直到圆桌上剩余一个人。
如图 所示,假设此时圆周周围有 5 个人,要求从编号为 3 的人开始顺时针数数,数到 2 的那个人出列:
出列顺序依次为:
- 编号为 3 的人开始数 1,然后 4 数 2,所以 4 先出列;
- 4 出列后,从 5 开始数 1,1 数 2,所以 1 出列;
- 1 出列后,从 2 开始数 1,3 数 2,所以 3 出列;
- 3 出列后,从 5 开始数 1,2 数 2,所以 2 出列;
- 最后只剩下 5 自己,所以 5 胜出。
可能用链表的方法去模拟这个过程,N个人看作是N个链表节点,节点1指向节点2,节点2指向节点3,……,节点N-1指向节点N,节点N指向节点1,这样就形成了一个环。然后从节点1开始1、2、3……往下报数,每报到M,就把那个节点从环上删除。下一个节点接着从1开始报数。最终链表仅剩一个节点。它就是最终的胜利者。
缺点:要模拟整个游戏过程,时间复杂度高达O(nm),当n,m非常大(例如上百万,上千万)的时候,几乎是没有办法在短时间内出结果的。
#include <stdio.h>
#include <stdlib.h>
typedef struct node{
int number;
struct node * next;
}person;
person * initLink(int n){
person * head=(person*)malloc(sizeof(person));
head->number=1;
head->next=NULL;
person * cyclic=head;
for (int i=2; i<=n; i++) {
person * body=(person*)malloc(sizeof(person));
body->number=i;
body->next=NULL;
cyclic->next=body;
cyclic=cyclic->next;
}
cyclic->next=head;//首尾相连
return head;
}
void findAndKillK(person * head,int k,int m){
person * tail=head;
//找到链表第一个结点的上一个结点,为删除操作做准备
while (tail->next!=head) {
tail=tail->next;
}
person * p=head;
//找到编号为k的人
while (p->number!=k) {
tail=p;
p=p->next;
}
//从编号为k的人开始,只有符合p->next==p时,说明链表中除了p结点,所有编号都出列了,
while (p->next!=p) {
//找到从p报数1开始,报m的人,并且还要知道数m-1de人的位置tail,方便做删除操作。
for (int i=1; i<m; i++) {
tail=p;
p=p->next;
}
tail->next=p->next;//从链表上将p结点摘下来
printf("出列人的编号为:%d\n",p->number);
free(p);
p=tail->next;//继续使用p指针指向出列编号的下一个编号,游戏继续
}
printf("出列人的编号为:%d\n",p->number);
free(p);
}
int main() {
printf("输入圆桌上的人数n:");
int n;
scanf("%d",&n);
person * head=initLink(n);
printf("从第k人开始报数(k>1且k<%d):",n);
int k;
scanf("%d",&k);
printf("数到m的人出列:");
int m;
scanf("%d",&m);
findAndKillK(head, k, m);
return 0;
}
第五章 栈和队列
4.1 栈
同顺序表和链表一样,栈也是用来存储逻辑关系为 “一对一” 数据的线性存储结构,如图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b0e0CuPG-1622084567140)(D:\小周的部落阁\DataStructAndAlgorithms\栈png.png)]
从图看到,栈存储结构与之前所学的线性存储结构有所差异,这缘于栈对数据 “存” 和 “取” 的过程有特殊的要求:
- 栈只能从表的一端存取数据,另一端是封闭的;
- 在栈中,无论是存数据还是取数据,都必须遵循"先进后出"的原则,即最先进栈的元素最后出栈。从图中数据的存储状态可判断出,元素 1 是最先进的栈。因此,当需要从栈中取出元素 1 时,根据"先进后出"的原则,需提前将元素 3 和元素 2 从栈中取出,然后才能成功取出元素 1。
因此,即栈是一种只能从表的一端存取数据且遵循 “先进后出” 原则的线性存储结构。
栈的开口端被称为栈顶;相应地,封口端被称为栈底。因此,栈顶元素指的就是距离栈顶最近的元素,拿图 2 来说,栈顶元素为元素 4;同理,栈底元素指的是位于栈最底部的元素,图中的栈底元素为元素 1。
4.2 进栈和出栈
基于栈结构的特点,在实际应用中,通常只会对栈执行以下两种操作:
- 向栈中添加元素,此过程被称为"进栈"(入栈或压栈);
- 从栈中提取出指定元素,此过程被称为"出栈"(或弹栈);
栈是一种 “特殊” 的线性存储结构,因此栈的具体实现有以下两种方式:
顺序栈:采用顺序存储结构可以模拟栈存储数据的特点,从而实现栈存储结构;
//栈的数据结构
typedef int dataType;
typedef struct _LinearStack
{
dataType arr[MAX_SIZE];
int size;
} LinearStack;
//初始化一个栈:
LinearStack *StackInit()
{
LinearStack *stack = (LinearStack *)malloc(sizeof(LinearStack));
for (int i = 0; i < MAX_SIZE; i++)
stack->arr[i] = 0;
stack->size = 0;
return stack;
}
//入栈
LinearStack *PushStack(LinearStack *stack, dataType data)
{
stack->arr[stack->size] = data;
printf("数据:%d压入栈,位置为:%d \n", stack->arr[stack->size], stack->size);
stack->size++;
return stack;
}
//出栈
LinearStack *PopStack(LinearStack *stack)
{
printf("数据:%d出栈", stack->arr[stack->size]);
stack->arr[stack->size] = 0;
stack->size--;
printf("剩余栈长为:%d \n", stack->size);
return stack;
}
4.3 队列
与栈结构不同的是,队列的两端都"开口",要求数据只能从一端进,从另一端出,如图所示:
通常,称进数据的一端为 “队尾”,出数据的一端为 “队头”,数据元素进队列的过程称为 “入队”,出队列的过程称为 “出队”。
栈和队列不要混淆,栈结构是一端封口,特点是"先进后出";而队列的两端全是开口,特点是"先进先出"。
4.4 入队和出队
由于顺序队列的底层使用的是数组,因此需预先申请一块足够大的内存空间初始化顺序队列。除此之外,为了满足顺序队列中数据从队尾进,队头出且先进先出的要求。
typedef int datatype;
typedef struct _Queue
{
datatype arr[MAX];
int size;
} Queue;
Queue *QueueInit()
{
Queue *queue = (Queue *)malloc(sizeof(queue));
for (int i = 0; i < MAX; i++)
{
queue->arr[i] = NULL;
}
queue->size = 0;
}
void PushQueue(Queue *queue, datatype data)
{
if (queue == NULL || queue->size == MAX)
{
return;
}
queue->arr[queue->size] = data;
queue->size++;
}
void PopQueue(Queue *queue)
{
if (queue == NULL || queue->size == MAX)
{
return;
}
for (int i = 0; i < queue->size - 1; i++)
{
queue->arr[i] = queue->arr[i + 1];
}
queue->size--;
}
datatype BackTopQueue(Queue *queue)
{
if (queue == NULL || queue->size == MAX)
{
return;
}
return queue->arr[queue->size];
}
4.5 栈和队列的应用
基于栈结构对数据存取采用 “先进后出” 原则的特点,它可以用于实现很多功能。
例如,我们经常使用浏览器在各种网站上查找信息。假设先浏览的页面 A,然后关闭了页面 A 跳转到页面 B,随后又关闭页面 B 跳转到了页面 C。而此时,我们如果想重新回到页面 A,有两个选择:
- 重新搜索找到页面 A;
- 使用浏览器的"回退"功能。浏览器会先回退到页面 B,而后再回退到页面 A。
浏览器 “回退” 功能的实现,底层使用的就是栈存储结构。当你关闭页面 A 时,浏览器会将页面 A 入栈;同样,当你关闭页面 B 时,浏览器也会将 B入栈。因此,当你执行回退操作时,才会首先看到的是页面 B,然后是页面 A,这是栈中数据依次出栈的效果。
不仅如此,栈存储结构还可以帮我们检测代码中的括号匹配问题。多数编程语言都会用到括号(小括号、中括号和大括号),括号的错误使用(通常是丢右括号)会导致程序编译错误,而很多开发工具中都有检测代码是否有编辑错误的功能,其中就包含检测代码中的括号匹配问题,此功能的底层实现使用的就是栈结构。
同时,栈结构还可以实现数值的进制转换功能。例如,编写程序实现从十进制数自动转换成二进制数,就可以使用栈存储结构来实现。
以上也仅是栈应用领域的冰山一角,这里不再过多举例。在后续章节的学习中,我们会大量使用到栈结构。接下来,我们学习如何实现顺序栈和链栈,以及对栈中元素进行入栈和出栈的操作。
第六章 串
数据结构中,字符串要单独用一种存储结构来存储,称为串存储结构。这里的串指的就是字符串。严格意义上讲,串存储结构也是一种线性存储结构,因为字符串中的字符之间也具有"一对一"的逻辑关系。只不过,与之前所学的线性存储结构不同,串结构只用于存储字符类型的数据。
无论学习哪种编程语言,操作最多的总是字符串。数据结构中,根据串中存储字符的数量及特点,对一些特殊的串进行了命名,比如说:
- 空串:存储 0 个字符的串,例如 S = “”(双引号紧挨着);
- 空格串:只包含空格字符的串,例如 S = " "(双引号包含 5 个空格);
- 子串和主串:假设有两个串 a 和 b,如果 a 中可以找到几个连续字符组成的串与 b 完全相同,则称 a 是 b 的主串,b 是 a 的子串。例如,若 a = “shujujiegou”,b = “shuju”,由于 a 中也包含 “shuju”,因此串 a 和串 b 是主串和子串的关系;
另外,对于具有主串和子串关系的两个串,通常会让你用算法找到子串在主串的位置。子串在主串中的位置,指的是子串首个字符在主串中的位置。
例如,串 a = “shujujiegou”,串 b = “jiegou”,通过观察,可以判断 a 和 b 是主串和子串的关系,同时子串 b 位于主串 a 中第 6 的位置,因为在串 a 中,串 b 首字符 ‘j’ 的位置是 6。
6.1 串的定长顺序存储
串的定长顺序存储结构,可以简单地理解为采用 “固定长度的顺序存储结构” 来存储字符串,因此限定了其底层实现只能使用静态数组。
使用定长顺序存储结构存储字符串时,需结合目标字符串的长度,预先申请足够大的内存空间。例如,采用定长顺序存储结构存储 “www.codezhou.club”,通过目测得知此字符串长度为 17,因此我们申请的数组空间长度至少为 18(最后一位存储字符串的结束标志 ‘\0’),用 C 语言表示为:
char str[18] = “www.codezhou.club";
6.2 串的堆分配存储
串的堆分配存储其具体实现方式是采用动态数组存储字符串。通常,编程语言会将程序占有的内存空间分成多个不同的区域,程序包含的数据会被分门别类并存储到对应的区域。拿 C 语言来说,程序会将内存分为 4 个区域,分别为堆区、栈区、数据区和代码区。
与其他区域不同,堆区的内存空间需要程序员手动使用 malloc 函数申请,并且在不用后要手动通过 free 函数将其释放。
C 语言中使用 malloc 函数最多的场景是给数组分配空间,这类数组称为动态数组。例如:
char * a = (char*)malloc(5*sizeof(char));
此行代码创建了一个动态数组 a,通过使用 malloc 申请了 5 个 char 类型大小的堆存储空间。
动态数组相比普通数组(静态数组)的优势是长度可变,换句话说,根据需要动态数组可额外申请更多的堆空间(使用 relloc 函数):
a = (char*)realloc(a, 10*sizeof(char));
通过使用这行代码,之前具有 5 个 char 型存储空间的动态数组,其容量扩大为可存储 10 个 char 型数据。
完整展示如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main()
{
char *a1 = NULL;
char *a2 = NULL;
a1 = (char *)malloc(10 * sizeof(char));
strcpy(a1, "http://www"); //将字符串"http://www"复制给a1
a2 = (char *)malloc(10 * sizeof(char));
strcpy(a2, "codezhou.club");
int lengthA1 = strlen(a1); //a1串的长度
int lengthA2 = strlen(a2); //a2串的长度
//尝试将合并的串存储在 a1 中,如果 a1 空间不够,则用realloc动态申请
if (lengthA1 < lengthA1 + lengthA2)
{
a1 = (char *)realloc(a1, (lengthA1 + lengthA2 + 1) * sizeof(char));
}
//合并两个串到 a1 中
for (int i = lengthA1; i < lengthA1 + lengthA2; i++)
{
a1[i] = a2[i - lengthA1];
}
//串的末尾要添加 \0,避免出错
a1[lengthA1 + lengthA2] = '\0';
printf("%s", a1);
//用完动态数组要立即释放
free(a1);
free(a2);
return 0;
}
6.3 BF算法
暴力(Brute Force)算法是普通的模式匹配算法,BF算法的思想就是将目标串S的第一个字符与模式串T的第一个字符进行匹配,若相等,则继续比较S的第二个字符和 T的第二个字符;若不相等,则比较S的第二个字符和T的第一个字符,依次比较下去,直到得出最后的匹配结果。BF算法是一种蛮力算法。
![](https://i-blog.csdnimg.cn/blog_migrate/1e6b5d29644ee3605eb1ef4eecb1c1ec.png)
int index( String S, String T, int pos )
{
int i = pos; // i 表示主串 S 中当前位置下标
int j = 1; // j 表示子串 T 中当前位置下标
while( i <= S[0] && j <= T[0] ){ // i 或 j 其中一个到达尾部则终止搜索
if( S[i] == T[i] ){ // 若相等则继续进行下一个元素匹配
i++;
j++;
}
else { // 若匹配失败则 j 回溯到第一个元素重新匹配
i = i - j + 2; // 将 i 重新回溯到上次匹配首位的下一个元素
j = 1;
}
}
if( j > T[0] ){
return i - T[0];
}
else {
return 0;
}
}
BF算法时间复杂度:该算法最理想的时间复杂度 O(n)
,n 表示串 A 的长度,即第一次匹配就成功。BF 算法最坏情况的时间复杂度为 O(n*m)
,n 为串 A 的长度,m 为串 B 的长度。例如,串 B 为 “0000000001”,而串 A 为 “01”,这种情况下,两个串每次匹配,都必须匹配至串 A 的最末尾才能判断匹配失败,因此运行了 n*m 次。
BF 算法的实现过程很 “无脑”,不包含任何技巧,在对数据量大的串进行模式匹配时,算法的效率很低。
6.4 KMP算法
在计算机科学中,Knuth-Morris-Pratt字符串查找算法(简称为KMP算法)可在一个字符串
S
内查找一个词W
的出现位置。一个词在不匹配时本身就包含足够的信息来确定下一个匹配可能的开始位置,此算法利用这一特性以避免重新检查先前匹配的字符。这个算法由高德纳和沃恩·普拉特在1974年构思,同年詹姆斯·H·莫里斯也独立地设计出该算法,最终三人于1977年联合发表。