线性结构
所谓线性结构,用偏数学的观点来看,其内部数据的关系构成一个有序集合,且这个有序集合中不存在自反性。
如果将这种数学表达实例化,可以表述为在这个集合中,所有的元素之间能够与整数的连续子集建立一一对应的关系,这种情况下即可称之为线性数据结构。
例如,数组是一种典型的线性结构,对于一个长度为 n n n的数组,其下标便和 [ 0 , n − 1 ] [0,n-1] [0,n−1]构成了一一映射。树则显然不是一种线性结构,就树结构本身而言,对于父节点 S S S来说,其左节点 L L L和右节点 R R R并不能构成统一的偏序关系,除非在树这个结构上添加一个类似最大堆的预先约定。
按照这种观点,线性结构的顺序关系至少有两种方式可以实现,其一是数组实现,通过下标与整数的子集形成一一对应;其二则是链表实现,通过指向后一个节点的指针来规定节点间的偏序关系。就C语言的特性来说,其固有的数组必须声明长度,然而指针却十分宽松,也就是说对于一个新的数据结构,我们可以从数组和指针两个思路出发。前者即为静态数组,后者则为动态链表,二者结合在一起就是动态数组。
链表
单向链表
所谓链表就是每个节点除了值之外附带一个链接的表,一般这个连接指向下一个节点,从而形成一个有序队列。
如图所示,其中next指向下一个节点的指针,value为当前节点的值,最后一个节点的next
值为NULL
。在这个链表中,只有第一个节点的指针是暴露给我们的入口,其他节点的指针和值都需要通过第一个节点来查找。
这样的一个节点过指针实现为
//ADT.c
typedef struct NODE{
struct NODE *next;
int value;
}Node;
有了第一个节点,我们就可以找到第二个,依次类推,如果想找到第n个节点,也只需循环n次即可
//打印第n个节点
void printNodeN(Node *preNode,int n){
for (int i = 0; i < n+1; i++)
preNode = preNode->next;
printf("the %dth node is",n,preNode->value);
}
不过目前我们只有一个节点,如果希望建立一个新的链表,则需要在现有链表中添加一个元素。由于链表的最后一个节点不指向任何节点,所以我们可以将其设为NULL
,这样以来,只需对现有链表进行循环,next
为NULL
时,将新节点接入即可。
void addNode(Node *preNode, int val){
while (preNode->next != NULL)
preNode = preNode->next;
//建立新节点,开辟新的内存空间
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->value = val;
newNode->next = preNode->next; //此值为NULL
preNode->next = newNode;
}
然后测试一下链表
//打印所有节点
void printNode(Node *preNode){
int i = 0;
while (preNode->next!=NULL){
printf("the %dth node:%d\n",i,preNode->value);
preNode = preNode->next;
i++;
}
printf("the %dth node:%d\n",i,preNode->value);
}
int main(){
Node *firstNode;
firstNode->value=0;
firstNode->next=NULL;
for (int i = 1; i < 6; i++)
addNode(firstNode,i);//添加节点
printNode(firstNode);
return 0;
}
测试
PS E:\Code\AlgC> gcc .\ADT.c
PS E:\Code\AlgC> .\a.exe
the 0th node:0
the 1th node:1
the 2th node:2
the 3th node:3
the 4th node:4
有序链表
这里的有序指的是链表的位置按照其value
的大小进行排序,对于有序链表来说,新插入的节点未必会在原有链表的末尾,所以我们需要实现在原有链表中插入新值的功能。首先,我们可以改造一下此前的addNode
函数。
void addNode(Node *preNode, int val, int n){
int i = 0;
while (preNode->next != NULL && ++i<n)//先比较,再循环
preNode = preNode->next;
//建立新节点,开辟新的内存空间
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->value = val;
newNode->next = preNode->next; //newNode指向preNode后一个元素
preNode->next = newNode; //preNode指向newNode
}
这个函数有一个十分显著的问题,即当n
为0时,会将newNode
插入到1的位置。所以,我们最好在程序的结尾再加一个判断,当传入的序号为0时,交换第0个和第1个节点的值,即在函数开始加上一个判断。
if (n==0){
int temp = val;
val = preNode->value;
preNode->value = temp;
}
若是在有序列表中插入值,则循环过程中的循环条件变为while (preNode->next!=NULL && preNode->value<val)
,判断条件变为if(preNode->value>val)
。
void addLargerNode(Node *preNode, int val){
if (preNode->value>val){
int temp = val;
val = preNode->value;
preNode->value = temp;
}
while (preNode->next!=NULL && preNode->value<val)//先比较,再循环
preNode = preNode->next;
//建立新节点,开辟新的内存空间
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->value = val;
newNode->next = preNode->next; //newNode指向preNode后一个元素
preNode->next = newNode; //preNode指向newNode
}
循环链表
单链表只能从头部进行访问,如果不小心在其他位置进入,则只能遍历此后的节点,而对此前的节点无能为力。若想解决这个问题,只需在节点中加入一个指向前面的指针即可。然而,由于这会改变现有节点的结构,从而影响后面许多其他数据结构的实现,所以从略。
如果不希望增加节点所占用的内存,则可以使得链表的末尾节点指针指向初始节点。只不过这样一来,就无法用空指针来判断循环终止条件,而是需要保存起始节点,判断当前节点的指针是否指向起始节点来进行循环终止判定。
所以,添加节点函数、打印节点函数和主函数改为
void addCircle(Node *preNode, int val){
Node *firstNode = preNode;
//当指针指向初始节点时,说明此节点为尾节点
while (preNode->next != firstNode)
preNode = preNode->next;
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->value = val;
newNode->next = preNode->next;
preNode->next = newNode;
}
//打印所有节点
void printNode(Node *preNode){
Node* firstNode = preNode;
int i = 0;
while (preNode->next!=firstNode){
printf("the %dth node:%d\n",i,preNode->value);
preNode = preNode->next;
i++;
}
printf("the %dth node:%d\n",i,preNode->value);
}
int main(){
Node *firstNode;
firstNode->value=0;
firstNode->next=firstNode; //此时只有一个指向自身的节点
for (int i = 1; i < 5; i++)
addCircle(firstNode,i);//添加节点
printNode(firstNode);
pop(firstNode);
return 0;
}
测试结果为
PS E:\Code\AlgC> gcc .\ADT.c
PS E:\Code\AlgC> .\a.exe
the 0th node:0
the 1th node:1
the 2th node:2
the 3th node:3
the 4th node:4
循环链表在使用的时候显得更加灵活,例如,若将尾节点设为入口节点,那么对于堆栈还有队列的实现来说是大有帮助的。此时,如果希望增加节点,则只需在入口节点后面增加即可。如果希望实现栈式弹出,则只需删除最后一个节点;而若希望实现队列式删除,则只需将指针移动到首节点,然后删除即可。
堆栈
数组实现
栈与数组的区别在于,后者是静态的,即有确定多个元素个数。栈和队列则为动态数组,前者实现一种先入后出的策略,后者则是先入先出。下图即栈的一种直观描述。
所以,除了数组的索引功能之外,栈还要求实现压入和弹出的功能,以实现数组的动态变化。对此,我们只需在数组之外,额外给出一个指针指向当前数组的末尾即可。
#include<stdio.h>
#define MAXSTACK 100
//无脑栈的实现
typedef struct Stack
{
int data[MAXSTACK]; //栈最大长度
int length; //栈长度
}Stack;
void popStack(Stack *s){
if(s->length==0)
printf("overflow"); //栈已空
else
s->length--;
}
void pushStack(Stack *s,int data){
if(s->length==MAXSTACK)
printf("overflow"); //栈已满
else{
s->data[s->length]=data;
s->length++;
}
}
//初始化栈,输入为数组和数组长度
Stack initStack(int *arr, int n){
Stack s;
s.length = 0;
for (int i = 0; i < n; i++)
pushStack(&s,arr[i]);
return s;
}
int main(){
Stack s;
int data[10] = {1,2,3,4,5,6,7,8,9,0};
s = initStack(data,10);
printf("%d\n",s.length);
for (int i = 0; i < 10; i++){
printf("s.data[%d]=%d\n",i,s.data[i]);
}
return 0;
}
其输出结果为
E:\Code\AlgC>gcc test.c
E:\Code\AlgC>a
10
s.data[0]=1
s.data[1]=2
...//雷同省略
s.data[9]=0
实验性的面向对象
上述方式虽然能够实现压栈和弹出的操作,但对于熟悉面向对象编程的人来说显然是难以忍受的,即我们并没有为栈的方法进行良好的封装。这种封装在此前可能是无法想象的,好在gcc编译器已经足够现代,让我们可以在结构体中实现类似于成员方法的功能。
//包含成员函数的栈实现
#include<stdio.h>
typedef struct stack{
int datas[10];
int length;
int (*push)(int data); //成员方法push的函数指针
int (*pop)(); //成员方法pop的函数指针
}stack;
//push创建函数,输出为push函数
int (*createPush(int *x, int *length))(int){
int push(int y){
x[*length]=y;
*length=*length+1;
return 1;
}
return *push;
}
//pop创建函数,输出为pop函数
int (*createPop(int *x, int *length))(int){
int pop(int y){
*length=*length-1;
return 1;
}
return *pop;
}
stack Stack(int *arr, int length){
stack s;
s.length = 0;
s.push = createPush(s.datas,&(s.length));
for (int i = 0; i < length; i++)
s.push(arr[i]);
return s;
}
int main(){
stack s;
s.length = 0;
s.push = createPush(s.datas,&(s.length));
int arr[5] = {1,2,3,4,5};
for (int i = 0; i < 5; i++)
s.push(arr[i]);
printf("pushed successfully\n");
for (int i = 0; i < s.length; i++)
printf("a[%d]=%d\n",i,s.datas[i]);
s.pop = createPop(s.datas,&(s.length));
s.pop();
s.pop();
printf("poped successfully\n");
for (int i = 0; i < s.length; i++)
printf("a[%d]=%d\n",i,s.datas[i]);
}
测试
E:\Code\AlgC>a
pushed successfully
a[0]=1
a[1]=2
a[2]=3
a[3]=4
a[4]=5
poped successfully
a[0]=1
a[1]=2
a[2]=3
当然,类似于int (*push)(int data);
这种写法,看上去就十分危险,让人不寒而栗。在结构体中定义指针,使用时稍有不慎就会报错,在C语言中并不推荐这么做。而且就性能来说,也额外增添了内存的开销,是十分不划算的。
链式堆栈
通过数组实现栈,最终得到的不过是个伪栈,因为我们还是可以对数组进行访问。相比之下,单向链表只暴露给我们一个初始值,和栈只暴露给我们顶部的值十分相似,区别只是在于栈的暴露给我们的是最后压入的值而已。
所以,栈节点的指针需要指向前一个节点,在此前,我们曾经写过单向链表,其指针为next
指向下一个节点,方便起见,我们仍用这个词表示下面的节点。
typedef struct NODE{
struct NODE *next;
int value;
}Node;
相应的push
,pop
以及打印栈的函数为
//删除栈顶
void pop(Node *top){
if (top->next!=NULL)
top->next = top->next->next;
else
printf("empty stack");//此时栈已空
}
//传入栈顶指针和新增值
void push(Node *top, int val){
Node *new;
new = (Node*)malloc(sizeof(Node));
new->value = val;
if (top->next==NULL) //此时栈为空
new->next = NULL; //新增节点为栈底,故指向空处
else
new->next = top->next; //新节点指向原来栈顶指向的节点
top->next = new; //栈顶指向新节点
}
//打印链式栈
void printStack(Node *top){
int i = 0;
while(top->next!=NULL){
top = top->next;
printf("%d\n",top->value);
}
}
测试一下,主函数为
int main(){
Node *topNode; //声明栈顶
topNode->next=NULL; //栈顶指向空处,此时为空栈
//初始化节点
for (int i = 1; i < 6; i++)
push(topNode,i);
printStack(topNode);
pop(topNode);
printf("popped\n");
printStack(topNode);
return 0;
}
输出的值为
PS E:\Code\AlgC> gcc .\ADT.c
PS E:\Code\AlgC> .\a.exe
5
4
3
2
1
popped
4
3
2
1
队列
栈式队列
队列和堆栈的区别在于,后者是采取先入后出的操作原则,前者则是先入先出。就其结构来分析,即栈在删除元素时,只能从栈顶开始删除,而队列则从列首开始,是一种先入先出的模式。
如果希望通过链表的形式实现队列,则至少有两个不同的思路,即前向列表和后向列表。前向列表将初始节点暴露给我们,我们在插入新的值时需要遍历所有节点,但是在删除时比较方便,只需对初始节点执行类似pop
的操作即可;后向链表则相当于堆栈,将栈顶暴露给我们,我们在插入新节点时只需push
即可,但是删除节点时需要遍历到队首。
由于此前已经实现了链表和堆栈的代码,所以下面将只给出链式队列有关的函数和主函数。
对于"栈"式队列,我们只需加入一个删除队首的操作即可。
typedef struct NODE{
struct NODE *next;
int value;
}Node;
void push(Node *top, int val);
void printStack(Node *top);
//删除队首
void delFirst(Node *top){
while (top->next->next!=NULL)
top = top->next;
free(top->next->next);
top->next = NULL;
}
int main(){
Node *topNode;
topNode->next=NULL;
for (int i = 1; i < 5; i++)
push(topNode,i);
printStack(topNode);
delFirst(topNode);
printf("popped\n");
printStack(topNode);
return 0;
}
输出结果为
PS E:\Code\AlgC> gcc .\ADT.c
PS E:\Code\AlgC> .\a.exe
4
3
2
1
popped
4
3
2
即完成了从栈到队列的更改。
链式队列
对于"链"式队列,其修改方式大同小异,只需在单项列表实现函数中添加一个删除首节点的函数即可。甚至我们并不需要再写一个新的函数,只需把pop
直接拿过来用即可。
当然,二者之间还有一个细小的差别,此前我们再定义堆栈的时候,默认栈顶节点的值为空,所以再建立pop
函数的时候并没有关注其value
的变化。那么在建立队列删除操作时,需要补上这一条
//删除队首
void pop(Node *top){
if (top->next!=NULL){
top->value = top->next->value;
top->next = top->next->next;
}
else
printf("empty stack");
}
int main(){
Node *firstNode;
firstNode->value=0;
firstNode->next=NULL;
for (int i = 1; i < 5; i++)
addNode(firstNode,i);//添加节点
printNode(firstNode);
pop(firstNode);
printf("popped\n");
printNode(firstNode);
return 0;
}
结果为
PS E:\Code\AlgC> gcc .\ADT.c
PS E:\Code\AlgC> .\a.exe
the 0th node:0
the 1th node:1
the 2th node:2
the 3th node:3
the 4th node:4
popped
the 0th node:1
the 1th node:2
the 2th node:3
the 3th node:4