本文记录了衣陈在学习链表数据结构并尝试仿制它的一个int型容器时遇到的问题与思考,本markdown与原码储存衣陈的gitee仓库(文件名intlist.cpp),建议本文搭配原码食用,希望对你能有所启发。
//本文是复制markdown文本,就代码块做的不是很好Q.Q
本文包含:实现一个链表类的所有知识,思想,心路历程,疑惑与解答。
链表概念
实现链表类应备成员属性和方法及选择它们的原因。
链表的push_back与头结点新建问题
insert 与 earse的实现及他们的边界:头插尾插头删尾删
基于递归的反转链表练笔日志
核心素养:
数据结构思想
边界分析思想
变量作用域与值地址传递的衡量
此处建议观看翁恺的c语言课堂入门,这是个人遇到的讲解链表最清晰的视频。
对于已经到实战实现的我们应该不必展开。
核心思想是:我们为什么需要链表?因为链表的插入删除操作比数组更加高效。
需要重点理解的是:链表与动态数组的数据储存思想的辨析。
此处一图胜千言
一个链表类,它封装所有成员属性和方法
一个成员属性结点类,它是链表的单元
头结点,它是链表的起点。
一个长度记录器,便于 我们的增删改查。
一个尾结点,它是尾插操作的起点
class intList { public: class node { public: node() { this->data = 0; this->next = NULL; } /*node(int elem) { this->data = elem; this->next = NULL; }*///并没有用到有参构造,因为我们维护的是节点数据类型的指针 int data; node* next; }; node* head; int len; };
思考:指针域指向的对象,是下一节点这个数据类型还是下一个节点储存的数据呢?
头结点应该如何实现?
我们需要的是节点这个实例对象还是它的指针?
答案都是指针。链表类list
维护节点类node
, 节点类node
维护数据data
和指针域next
。
如果实现过动态数组,我们应该知道,维护的方法是指针
我们将head和next设计为node的指针,cout<\
这样的嵌套寻址就可以实现了。
head.next=1st;
思考:尾结点的声明时机?(变量作用域的衡量)
保留此问。
intList() { this->head = new node; this->head->next = NULL; this->len = -1; } void push_back(int elem) { if (this->head->next==NULL) { //因为链表类可以先被创建,再删除干净,在push。 //链表的初始化:为指针分配空间后写入数据NULL。 this->head = new node; //需注意,在无参构造中应先初始化一次头结点,否则报错野指针 this->head->next = NULL; this->len = -1; //而是创建指针对象并写入匿名数据 node* pos = new node; pos->data = elem; this->head->next = pos; this->len++; // cout << this->head->next->data<<" "; } //正常尾插,查找方法searchNo方法此后讲解 else { //寻尾 node* pos = this->searchNo(this->len); node* come = new node; come->data = elem; pos->next = come; pos = come; this->len++; // cout << this->pos->data << " "; } }
首先是链表的初始化时机,起初我写在类的无参构造中,但在实战中发现了如上先建立,再删为空再pushback的情况,故改为了在每次pushback时判断是否在新建链表。
Q:一大难点是,为什么要在这里用new操作呢?
A:因为head初始为空指针,而我们想对一个空指针进行操作的话,应先为指针解引的地址分配空间。
所以head是new出来的,为了保持风格一致,后面的pos和come(见下文)也应该是指针类型。
这样对于单元节点,我们维护它的指针,用堆区new风格的操作维护节点内的数据。
优雅。这与动态二维数组的思想也是互通的
我们再对比以下代码
node pos; pos.data=elem; this->head->next = &pos;
Q:为了连接链表,我们用了一个取址操作,所有尾插都使用这样风格的新节点,不妨思考this->head最后指向什么 ?(涉及前文发文尾结点声明时机)
A:答:如果pos是在push_back方法内的局部变量,它在方法用完时就会销毁,交出它的地址并不能连接上链表;如果pos是一个list类的全局成员属性,它在正常尾插中被come不断更新,最终它的地址保存的是尾结点,head指向了尾结点
Q:是否可以强求这样node pos?
A: 可以的,我们把node pos;设置为list类的成员属性,在push_back等涉及尾结点更新的方法改造引用传入传出pos。
BUT ,翁恺老师说“全局变量是有害的”,为了强求设计实例对象,增强了类内的耦合度,并不合理。
无论如何这样的操作都不够优雅,next指向的是下一个这个节点实例对象的地址吗?
不是的,next应该指向下一个节点类指针,这是指针与指针的赋值操作。
小结:这实际是一个“变量作用域”和“值与地址”的问题,而且这个问题藏得非常深,蛮难察觉就会像笔者这样陷入纠结而且没纠结到点上而且难以通过追踪程序运行发觉。笔者是在梳理这篇markdown时才发觉这个问题,才真正解答了为什么head,pos一定是“指针”。
同时,回答上文,pos应该是一个局部变量,用new风格在尾插时声明。
这里直接给出从翁恺老师视频中学会的控制结构
for (node* it = this->head->next; it; it = it->next)
关于真假问题: c++中0为假,非0为真,我们可以尝试输出一个空指针,结果为0x000000000,这是十六进制位模式的0。
优雅。循环条件是it为真(即指针有指向,不是空指针),迭代语句为it自身更新。
void ptfme() { for (node* it = this->head->next; it; it = it->next) { cout << it->data << " "; } }
对于链表由于它的非连续性,我们是不可能像数组那样及其简单地获得某某节点的地址的。
解决方法为,记录一次遍历中迭代器it(它的功能不正是如此吗)的更新次数,直到这个次数等于我们的预期,返回这个节点(指针)
即,searchNo(int no)方法,得到No.x号元素的地址。
node* searchNo(int no) { int ans = 0; int is_found = 1; for (node* it = this->head->next; it; it = it->next, ans++) { if (ans == no) { is_found = 0; // cout << "found\n"; return it; } } if (!is_found) { // cout << "no this No.x\n"; return NULL; } }
现在我们可以轻松获得链表中任意位置的对象指针了,(溢出部分的代价由操作者承担),插删操作就有了起点
首先是插删的基本参数,位置和插入对象。
然后是链表插删的思想:断链,重新成链。
//这里仅实现中间插入:用serachNo方法获得插入点左右节点,插入come的左侧comel.next改为链接come,come。next链接come右侧come1; //事实上,当情况为头插尾插时,又有不同。应该加上if语句判断边界处理 void insert(int no,int elem) { node* comel = this->searchNo(no); node* comer = this->searchNo(no)->next; node* come = new node; come->data = elem; comel->next = come; come->next = comer; this->len++; cout << "is_insert,new inlist:\n"; this->ptfme(); } //这里先实现普通删除,用searchNo方法获得删除节点,左侧链接右侧,重点delete掉。 void earse(int no) { if (no == this->len) { this->pop_back(); } else { if (no == 0) this->pop_head(); else { node* earsing = this->searchNo(no); node* earl = this->searchNo(no - 1); node* earr = this->searchNo(no)->next; earl->next = earr; delete earsing; earsing = NULL; this->len--; cout << "is_earse,newlint:\n"; this->ptfme(); } }
尾插已实现。
以下给出头尾删
void pop_back() { //一般尾删需要找到倒数第二个元素即链表长度大于等于二 //获得尾结点指针,删除尾结点内容,更新尾结点为倒二节点。 if (this->head->next->next) { node* pos = this->searchNo(this->len); node* newpos = this->searchNo(--this->len); // cout << "popelem:" << newpos->next->data; delete newpos->next; newpos->next = NULL; pos = newpos; //if(newpos->next)cout << "popelem:" << newpos->next->data; this->ptfme(); return; } else { //一个元素的尾删,即链表置空 //获得节点指针,delete,头结点置空 if (this->head->next) { node* temp = this->searchNo(0); delete temp; temp = NULL; this->head->next = NULL; this->ptfme(); return; } else { cout << "NULL_inilist,wrong_pop"; return; } } } //头删同理 void pop_head() { //2+ if (this->head->next->next) { node* newhead = new node; newhead->next = this->searchNo(1); delete this->head; this->head = newhead; cout << "is head_back,newlist:\n"; this->ptfme(); } else { if (this->head->next) { node* temp = this->searchNo(0); delete temp; temp = NULL; this->head->next = NULL; this->ptfme(); return; } else { cout << "NULL_inilist,wrong_pop"; return; } } }
头插略(忘了写QAQ)
思想都是想通的,当做您的小练笔吧(:> ,<:)
是力扣挺经典的一道题,我就顺带拿来练笔了
不同于算法题只需要一种返回翻转完毕后的链表的头结点的算法,我希望的是获得一种把this对象翻转的方法
所以最终我设计了如下框架
//对于实节点数少于两个(0或1)的链表,做空实现,对于合法的链表,做算法得到newhead,用newhead更新this->head void reverse() { if (this->head->next->next) { node* newhead= _reverse(this->head); this->head= newhead; } this->ptfme();//打印列表函数 return; } node* _reverse(node* _head){}
解决完这些前置的小问题,来到重难点递归算法部分
推荐观看@bilibili_筱可爱233 的算法讲解视频
先上算法
//蛮短但蛮难 node* _reverse(node* _head) { //递归结构 if (_head->next==NULL) return _head; node* newhead = _reverse(_head->next); // //反转算法 _head->next->next = _head; _head->next = NULL; return newhead;// }
如果你和笔者一样初识递归或者一直难以搞定递归,建议多上草稿本,画画递归闭环和出口,写写程序运行流程,以下是笔者对这个算法的个人理解,仅供参考。
如上分隔,这个算法分为两部分,且看我一图讲完
第一部分为递归结构,用以进入最底层递归和获取尾节点指针
经过如图,经紫框的控制结构,我们获得最底层递归的result 5th,退出5th递归据需4th递归,做了与value第二部分的算法后返回value,退出4th进入3rd,……最终把value(
newhead
)传递给target(intlist::node* newhead
)
第二部分为算法内容,用以修改链表指向。
赋值语句是要从右往左看的,不难看出算法的起点是倒数第二层递归,算法主体head是func4的参(4th节点),所以尾结点表达式为head.next
所以是吧head.next
左边的指针的指向(指向head.next)赋给了head.next
的右边。
这样head.next.next不就指向了它左边的节点吗。
注意这里的head.next.next并不是6th节点,我们并没有为他new空间也没有声明6th节点,它的正确读法应该为(head.next).next,
这里的next并非时如它的标识符那样指下一个节点,而是对象指针(head->next)的解引的成员属性——它的指针域,现在指向了4th。
做完这些后,4th递归结束,返回与上述算法无关的新链表(is_building)的new_head,然后返回值传给了func3的value,依次上传。而fun3的参是3th节点指针,重复上述算法对(3th.next)这个节点指针修改,结果是4th的next(在上一层递归中被擦除)被修改为了3th,3th.next被擦除。依次进行算法,新链表最终建立完成。
小结,核心素养是对“next”这个概念的辨析,他只是一个指针而不是“下一个”的意思,可以说是很有误导性了。
HOWEVER, 结果:
原因很显然啊,我们的算法起点是4th,结束点就并非是1st而是原来的空头结点(默认值为0),所以会带0;又因为我们处理这个算法返回的头结点的语句是this->head= newhead;
而在我们的遍历结构中起点是this.head.next;所以会发生“左截断,右延长”的灵异现象
思考:上述语句改为
this->head->next= newhead;
呢?
结果:
很有意思,确实成功包含了10th元素
9
,也有完整的9到1的翻转列表。却陷入了死循环
因为没有管理head的指向
解决方法:将this->head new一下
node* newhead= _reverse(this->head); this->head = new node; this->head->next= newhead;
思考,覆盖指针head而并未delete回收head,堆区数据是否丢失:否,经我们的操作head又重新链回了这段数据。
输出结果显然是“9 8 7 6 5 4 3 2 1 0”
So how to free the fucking 0?
尾删也不太对,因为“0”是head指针来的,这个head指针被是尾结点.next连上,形成闭环链表,而尾删方法作用对象是尾结点。所以我们要删除尾结点.next
void reverse() { if (this->head->next->next) { node* newhead= _reverse(this->head); this->head->next= newhead; } //错误的,尾删 this.pop_back(); this->ptfme();//打印列表函数 return; }
:
//正确的,尾.next删 node* tail = this->searchNo(this->len); tail->next = NULL;
结果符合预期
至此,基于递归的翻转链表实现完成,特别有意思真的,自己捣鼓试出了好多东西,可能这就是计算机的魅力吧。
Q:最后的最后是链表类的析构问题,如何回收我们再堆区写入的每个节点的数据?
A: 必须分清楚delete的目标,删除我们声明的每个与堆区有关的标识符吗?
不是的,是写在堆区那块物理空间的内的数据。我们仅需要一种算法,得到每个节点的地址(即通过searchNo方法得到这个节点的指针),delete指针即可。
核心问题是要free掉节点的同时不丢失next指针,所以,先创建q维护p.next节点,再delete掉p,以下为《大话数据结构》中的实现
后记:此次对链表容器的实现,较好地锻炼了数据结构思维与边界处理思维,变量选取的权衡。
数据结构与与算法真的真的太核心了,我是用c++实现的嘛,可是在参阅资料时那些基于java,c的课程其实都能听的,因为底层的数据结构和算法都是共通的(其中感触最深的莫过于翁凯的c语言给出的链表遍历结构)
不妨将我们的cpp拆分为头文件和cpp,就获得了专属的int类型list容器。
最后贴上成品原码(懒得git的话)
#includeusing namespace std; class intList { public: class node { public: node() { this->data = 0; this->next = NULL; } /*node(int elem) { this->data = elem; this->next = NULL; }*///并没有用到有参构造,因为我们维护的是节点数据类型的指针 int data; node* next; }; node* head; int len; intList() { this->head = new node; this->head->next = NULL; this->len = -1; } void push_back(int elem) { if (this->head->next==NULL) { //结点的新建应该包含pos的初始化,因为链表类可以先被创建,再删除干净,在push。 this->head = new node; this->head->next = NULL; this->len = -1; //不是深拷贝,而是创建指针对象并写入匿名数据 node* pos = new node; pos->data = elem; this->head->next = pos; this->len++; // cout << this->head->next->data<<" "; } else { //寻尾 node* pos = this->searchNo(this->len); node* come = new node; come->data = elem; pos->next = come; pos = come; this->len++; // cout << this->pos->data << " "; } } //遍历 void ptfme() { cout << endl; for (node* it = this->head->next; it; it = it->next) { cout << it->data << " "; } cout << "\nptfover\n"; return; } node* searchNo(int no) { int ans = 0; int is_found = 1; for (node* it = this->head->next; it; it = it->next, ans++) { if (ans == no) { is_found = 0; // cout << "found\n"; return it; } } if (!is_found) { // cout << "no this no\n"; return NULL; } } void ptffrom(int no) { for (node* it=this->searchNo(no); it; it = it->next) { cout << it->data << " "; } } void insert(int no,int elem) { node* comel = this->searchNo(no); node* comer = this->searchNo(no)->next; node* come = new node; come->data = elem; comel->next = come; come->next = comer; this->len++; cout << "is_insert,new inlist:\n"; this->ptfme(); } void earse(int no) { if (no == this->len) { this->pop_back(); } else { if (no == 0) this->pop_head(); else { node* earsing = this->searchNo(no); node* earl = this->searchNo(no - 1); node* earr = this->searchNo(no)->next; earl->next = earr; delete earsing; earsing = NULL; this->len--; cout << "is_earse,newlint:\n"; this->ptfme(); } } } //头尾插删有边界问题 void pop_back() { //一般尾删需要找到倒数第二个元素即链表长度大于等于二 if (this->head->next->next) { node* pos = this->searchNo(this->len); node* newpos = this->searchNo(--this->len); // cout << "popelem:" << newpos->next->data; delete newpos->next; newpos->next = NULL; pos = newpos; //if(newpos->next)cout << "popelem:" << newpos->next->data; this->ptfme(); return; } else { //一个元素的尾删 if (this->head->next) { node* temp = this->searchNo(0); delete temp; temp = NULL; this->head->next = NULL; this->ptfme(); return; } else { cout << "NULL_inilist,wrong_pop"; return; } } } void pop_head() { //2+ if (this->head->next->next) { node* newhead = new node; newhead->next = this->searchNo(1); delete this->head; this->head = newhead; cout << "is head_back,newlist:\n"; this->ptfme(); } else { if (this->head->next) { node* temp = this->searchNo(0); delete temp; temp = NULL; this->head->next = NULL; this->ptfme(); return; } else { cout << "NULL_inilist,wrong_pop"; return; } } } //递归结构实现链表翻转 返回新链表头结点 //我们这种头结点结构不太适用,因为 void reverse() { if (this->head->next->next) { node* newhead= _reverse(this->head); this->head = new node; this->head->next= newhead; } //this->pop_back(); //正确的尾.next删 node* tail = this->searchNo(this->len); tail->next = NULL; this->ptfme(); return; } node* _reverse(node* _head) { if (_head->next==NULL) return _head; node* newhead = _reverse(_head->next); // _head->next->next = _head; _head->next = NULL; return newhead;// } }; int main() { intList intlist; //intlist.push_back(1); // intlist.push_back(0); // intlist.pop_head(); for (int i = 1; i < 10; i++)intlist.push_back(i); intlist.ptfme(); intlist.reverse(); intlist.reverse(); return 0; //intlist.earse(intlist.len); //cout << intlist.head->next->data << " " << intlist.head->next->next->data << " "; 结点链接测试案例 //cout << intlist.pos->data;尾结点测试案例 //intlist.ptfme(); }