0.区分结构体和链表,前者是作为后者节点存在且节点结构体中有自引用指针
1.线性1:1(线性表、栈、队列);存储方式:顺序存储(数组)、链式存储(指针)
非线性:1:N树(不会存在回路));N:M图(存在回路);
树是有向无环图;图也可以分解成树来分析。
2.没有数据结构访问数据的效率没有比数组更快的,下标引用
3.线性表中的链式表分为单向链表(单向不循环和单向循环链表)和双向链表(双向不循环和双向循环链表),还分有头无头。单向链表面试常用,双向链表业务常用。
4.链表:单双、有无循环、有无头
5.单向不循环有头链表和单向不循环无头链表的区别:
1)有头时头指针不会变,增删改查都在头指针之后的节点操作,头指针就是链表的代表,因此头指针不变链表不变,传参时链表用一级指针即可;无头指针第一个节点就是有效数据节点,当在首部进行增删改查时(malloc或者free),第一个指针变掉了,由于return返回操作正确与否是个整形,因此无头链表传参时只能用第一个节点的二级指针
2)注意先保存节点,在处理节点,否则处理完会发现找不到被处理节点的情况,从而造成内存泄漏;判断的依据主要是链表的各节点指针是否是不被期望修改但修改了(一般只在自己链上滑动)
3)每创建一个链表节点如果没有立即使用的话先置为空,防止产生野指针被不小心调用
4)创建新链表时只malloc头节点并赋名为me,为了防止出错,对链表的节点数的增加使用另一个指向头节点的指针curr,后面新创建的节点都用newnode来指代。
struct node_st* poly_create(int a[][2], int n)
{
struct node_st* me;//链表指针,用于返回整个链表
sturct node_st* curr;//链表链接指针
struct node_st* *newnode;//接收新创建的节点
//创建头结点
me = malloc(sizeof(*me));
if(NULL == me)
ruturn NULL;
me->next = NULL;
//创建移动链表指针
curr = me;
for(int i = 0; i<n; ++i)
{
//创建新节点,并将传递数据
newnode = malloc(sizeof(*newnode));
if(NULL == newnode)
return NULL;
newnode->coef = a[i][0];
newnode->exp = a[i][1];
//将待操作位置更新
curr->next = newnode;
curr = newnode;
}
//返回链表头结点指针
return me;
}
6.单向循环链表
1)两种创建单向循环链表的方法,一种是一开始创建第一个节点的时候就是一个尾指向头的环链,一种是先按单向不循环链建立尾在节点时再指向头。选择第一种方式,要维持模型的完整性,一开始就是一个单节点的环链。
2)约瑟夫算法,单向无头环链,因为要循环数数,有头的情况比较麻烦:
a)单向环链的每次变化都还是单向环链,因此在创建第一个有效节点时别忘记其next指向自身,否则不满足环链,每接入一个新的节点别忘记重新赋值其next和他是谁的next
b)谁赋值给谁一定不能搞乱了。如果再叠加上上个节点next未赋值,然后直接用了就有段错误
//me->next = me;//第一个有效节点的next指针没有初始化,后面第一个newnode直接用了野指针
strjo* curr = me;
//newnode = curr->next;//赋值方向搞错,应该写为curr->next = newnode;
newnode->next = me;//newnode是个野指针,引用了其next导致段错误
c)创建链表时还是用到三个节点指针:
me作为整条链表代表(如果是传参传入,有头的且传参前头已创建用一级指针传参,无头或者传入前未malloc创建用二级指针传参/或用return返回);
curr作为在该链上滑动链接;
newcode是新创建出的节点
d)约瑟夫算法的节点删除,引入了额外的i来计数,while循环到规则数将节点删除(对应的内存还存在且值也没变,只是逻辑删除不是物理删除,只是和本程序没关系了),再用while循环保证最后只剩一个节点,判断依据是最后一个节点由于是单循环链其next指向自己即while(pCurr->next != pCurr)就停止。最后注意返回删除后只有一个节点的链表,这个节点不一定是原链表的第一个有效节点,因此不能return传参的链表指针而是curr
51 strjo* jose_delete(strjo* sgList, int dlNum)
52 {
53 if(NULL == sgList)
54 {
55 printf("[%s] sgList is NULL\n", __FUNCTION__);
56 return NULL;
57 }
58
59 int i=1;//这个很好,用来计数
60
61 strjo* pPre = sgList;
62 strjo* pCurr = sgList;
63
64 while(pCurr->next != pCurr)
65 {
66 while(i<dlNum)
67 {
68 pPre = pCurr;
69 pCurr = pCurr->next;
70 ++i;
71 }
72 printf("[%s] pCurr->i = %d\n", __FUNCTION__, pCurr->i);
73 pPre->next = pCurr->next;
74 free(pCurr);
75 pCurr = pPre->next;
76 i = 1;
77 }
78 return pCurr;
80 }
e)头文件中:在写判断头文件是否已经被定义过时,H前后分别一个和两个下划线横,结尾不要忘记#endif;头文件不需要有标准库文件,因为函数都是声明没有调用语句不需要库函数替换;结构体类型描述和typedef结构体类型都后跟分号,头文件除了宏定义、头文件外都需要分号
7.双向链表:
1)单向链表(不论是否带头或者是否环链)随机访问性比较差,比如访问前驱节点。
2)双向循环链表也分是否带头。
3)程序要考虑效率和通用性的最佳平衡。
4)双向环链的节点组成:data域(可以是构造数据类型,最好是其指针)、pre自引用指针、next自引用指针。
*NOTE:
a)头结点可以和其他节点不一样,比如头结点带有个表示data数据malloc大小的整型值即多出来的size域与正常的节点head,head由数据域指针、前驱节点指针pre和后继节点指针next组成。但是环链当前驱和后继指针指向头结点时,指向的是head而非整个头结点,因为只有head部分才和后面的节点是一样的。
b)destroy链表时不能free头节点,其他的可以。为何?因为老师用的for循环free节点,且初始值为头结点的head域的next指针,因此头结点只能在最后free。
free时只沿后继节点链路进行就行。free时现将内部成员指针free掉,然后再free外部。
5)双向环链插入:
a)知道前驱节点p和待插入节点q:
先把q的前驱后继搞定,再通过p补齐p的后继和原链p->next的前驱。等号左边都是q的关系:
q->pre = p;//p是带插入节点的前驱
q->next = p->next;//p的后继是待插入节点的后继
q->pre->next = q;
q->next->pre = q;
b)知道后继节点p和待插入节点q:
同a)相比,只需要改变q自身前驱后继节点的指明,q在链中的前驱后继关系不变:
q->next = p;//p是待插入节点的后继
q->pre = p->pre;//p的前驱是待插入节点的前驱
q->pre->next = q;
q->next->pre = q;
6)双向环链的删除
被删除节点指针为node,只需要将node的前驱和后继指向重新指定即可,都只用node表示。实际node节点的prev和next指针还是指向原链的节点,是不是最好置空?
node->next->prev = node->prev;
node->prev->next = node->next;
*NOTE:
a)当把一个不确定数据类型指针里的内容拷贝过来时,要用memcpy,而不能用指针指向。如通过传参给结构体的void*data成员赋值时即使如此。
b)结构体中嵌套结构体,外部结构体是指针,内部结构体要用取地址符&ptr->head,但对于内部结构体的指针的引用既不用取地址符也不用->而是用点ptr->head.pre/ptr->head.next
c)结构体中嵌套结构体内外指针都要初始化再用,要么malloc要么将其指向匹配的指针,否则都是野指针。但是在指向其他指针时参考第一条1)的内容
d)箭头比取地址优先级高:&ptr->head,不用加括号
7)回调函数
main是主管写的,为了程序的通用性,传参的数据类型写分模块的人不知道也不需要知道,用void*接收,当需要将数据返回给main或者其他模块时,就用回调方式实现。
通过typedef声明某个函数类型的方式:typedef 函数返回值类型 函数类型别名 (传参类型表),如 typedef void callback(const void*);
在传参时用该函数类型的指针做形参,call有点类似函数指针,指向callback类型的函数,llist_travel(LLIST* phn, callback* call)
在main中定义回调函数和传参,在子模块中typedef定义函数类型,这样不涉及头文件包含的问题
调用:call(pCurr->data);
1.在main.c的main:
//数据结构体,只在main中出现,不在处理函数文件中出现
struct score_node_st
{
int id;
char name[NAMESIZE];
int math;
int chinese;
};
//回调函数在main中,将成员输出,子模块编写人员不需知道是什么类型数据
void printf_func(const void* pscore)
{
const struct score_node_st* data = pscore;
printf("[%s] id=%d, name = %s, math = %d, chinese =%d\n"
, __FUNCTION__, data->id, data->name, data->math, data->chinese);
}
//通过传函数入口地址也即是函数名完成函数回调
llist_travel(phn, printf_func);
2.在llist.c:
先对回调函数printf_func进行声明以便传参:typedef void callback(const void*);
llist_travel(LLIST* phn, callback* call):
回调使用:call(pCurr->data);
*NOTE:
a)当出现bug时,顺着逻辑链路找,每个环节都不能放弃,从容易到简单。如llist_insert实现一直不对,找了几个小时发现是size没有传进去
headNode->size = intsize;//忘记传大小了,导致insert的memcpy的全是0
memcpy(newnode->data, pstr, phn->size);//phn->size=0
b)链表的操作是以节点为单位进行操作的,因此对于相对来说比较复杂的功能实现,先用个小函数定位到对应的节点,返回节点指针,然后再进行之后的操作。比如在llist_delete和llist_fetch的实现中先根据main中传入的data信息找到对应的节点并返回节点指针,再将该节点信息打印和删除重连。如果一个函数接口里全部实现,就没法和其他诸如打印、对比接口配合,这也是老师表达的意思。
8)重构方式1:变长结构体
当分工实现子模块功能如果不知道数据类型,可以在结构体中用一个void*的data指针(前提是一旦确定传递的data类型,以后都一致),由用户传一个size值来实现。还可以用一个位于结构体尾部的占位的char data[0]或者char data[1]来实现,前者用于支持C99标准,后者通用。虽然数据类型是char*但此处没关系依然可以处理各种数据类型,用char*它只是提供一个地址值(数组名就是个地址)的作用,便于引用。
*NOTE:变长结构体malloc时,别忘记把data真实数据大小算进来。因为char data[0]是定义成了固定大小的数组,只有char大小,用memcpy对data地址覆盖会把剩下的节点覆盖掉,出现越界的问题,访问覆盖掉的节点出现段错误,让我排查了一下午。它不像void* data指针指向别处的起始地址,引用数据有多大给个长度就行;
struct llist_node_st* newnode = NULL;
//忘记+ phn->size导致赋值和访问越界,core dump
newnode = malloc(sizeof(*newnode) + phn->size);
9)重构方式2:C语言实现类似C++面向对象的编程:静态的属性和动态的方法(在重构方式1基础上)
结构体中一般是有各种数据类型的成员,在这里将对结构体进行操作的一些接口方法也作为成员放入结构体中,这样封装之后就更加紧凑简洁清析。
将原本单独调用的接口,根据传参类型和返回值类型抽象为函数指针放入结构体中(有时候create和destroy接口放不进去),在create创建头结点时(create只创建头结点),为函数指针赋值(函数赋值和函数调用的区别,赋值只传函数名,赋值函数名+括号)。调用时就用头节点成员引用加括号传参的方式实现。
//头结点结构体
typedef struct LLIST
{
int size;
struct llist_node_st head;
//接口放在头结点中,因为对链表操作时肯定要有传参头结点的,放在正常节点中每个节点都要有这些接口没必要
//函数指针
void (*travel) (struct LLIST*, callback*);
int (*insert) (struct LLIST*, const void*, int);
int (*find) (struct LLIST*, const void*, cmp*, callback*);
int (*fetch) (struct LLIST*, const void*, cmp*, callback*);
}LLIST;
//接口赋值
headNode->insert = llist_insert;
headNode->travel = llist_travel;
headNode->fetch = llist_fetch;
headNode->find = llist_find;
//调用
ret = phn->insert(phn, stscore, mod);
ret = phn->find(phn, strkey, func_cmp, printf_func);
ret = phn->fetch(phn, strkey, func_cmp, printf_func);
*NOTE:
a)接口封装放在头结点中,因为对链表操作时肯定要有传参头结点的,放在正常节点中每个节点都要有这些接口没必要
b)函数名也只是个符号,当用函数指针指向某个函数后,函数名和函数指针名就一样了,用函数指针调用也是将括号放在函数指针名后
c)typedef指向某个函数和结构体函数指针成员的区别:
i)前者直接用一个名字指代返回值类型、传参类型的函数而非指针,形式是
typedef 返回值类型 别名 (传参类型1, 传参类型2)
作为形参时是指针,实参是函数名;
//main.c中函数实现,
int func_cmp(struct score_node_st* cur, const void* key);
//llist.c中函数别名定义,不是指针类型, (不被别的文件调用,不必放在.h中)
typedef int cmp(void*, const void*);
//llist.c中别名作为形参,指针类型
int llist_fetch(L_LIST* phn, const void* key, cmp* fun_cmp, callback* call);
//main.c中被调用
ret = phn->fetch(phn, strkey, func_cmp, printf_func);
ii)后者定义是函数指针形式
返回值类型 (*别名) (传参类型1, 传参类型2)
先赋值:别名 = 实际实现了的函数名
在调用 :别名(实参1, 实参2);
//llist.c中函数实现
int llist_insert(L_LIST* phn, const void* pdata, int mode)
//llist.h中C语言实现面向对象封装到结构体成员
typedef struct LLIST
{
int size;
struct llist_node_st head;
//函数指针
int (*insert) (struct LLIST*, const void*, int);
...
}L_LIST;
//llist.c的llist_create()中初始化
headNode->insert = llist_insert;
//main.c中调用
ret = phn->insert(phn, stscore, mod);
10)重构方式3:将llist.c做成二进制方式、对外提供llist.h和二进制文件(在重构方式1基础上)
score_node_st是main可见,本模块不可见
llist_node_st是本模块可见,main不可见,故将其从llist.h移入llist.c,那么就要对llist.h中的llist_node_st*用void*代替,并封装为LLIST即:·
typedef void LLIST;
由于.h指导.c,因此定义函数接口时也要是LLIST*即void*类型,再在函数里面用struct llist_node_st*接收LLIST*转换一波,而不能直接函数名传参类型直接写成llist_node_st*,这样编译不过