链表是一个比较具有争议性的数据结构,很多人都觉得这个数据结构没多大用处,但是这个数据结构却比较受面试官的青睐,和一些基本的数据结构相比,他比较复杂,但是又不至于复杂到像图、哈希表一样,面试时间有限,而考察链表的时间又刚刚好不长不短,所以…很多面试官都会在一个小时内问至少2-3个问题,因此每个问题需要用20-30分钟来回答,链表相比其他数据结构而言,使用的频率比较小,所以很多人对链表并不太熟悉,不太熟悉的人和不懂链表的人其实没太大区别,所以要想显出自己的优势,还是要熟练掌握链表比较好。而说起链表,我们一般指的是单链表,链表由一个个节点组成,在c中每个节点是个结构体,在C++和JAVA里,每个节点是个类,如下是c中链表节点的定义:
typedef struct elementT {
int data;
struct elementT *next;
} element;
一般来说,我们会用头节点来代替一个链表,如果某个函数需要接收一个链表作为参数,那么他会接收一个头指针作为参数值,根据头指针我们会找到后续节点的具体取值。
下面举几个指针的例子巩固一下。
①修改头指针
对链表进行相关操作比如增加、删除一个节点后都需要更新头指针的值,只有这样才能保证对链表所做的相关操作是有效的,看下面的增加节点操作,会发现一些问题:
int BadInsert(element *head)
{
element *newElem;
newElem = (element *) malloc(sizeof(element));
if (!newElem)
return 0;
newElem->next = head;
head = newElem;
return 1;
}
使用上述方式增加一个节点,传递进来的参数是一个头指针,那么程序接收头指针即链表地址之后就会根据该地址复制一个一摸一样的head来指向原链表,按照头插法完成新节点的插入后把head指向第一个节点即新插入的节点,但是由于head是原链表头指针的拷贝,所以原链表头指针并没有更新,它指向的是第二个节点,这个时候需要更新头指针的值才能完成原链表的更新,但是上述代码并没有完成上述步骤,虽然更新了head值,但是head不过是原链表头指针的拷贝,所以上述插入是不成功的,那么怎样才能避免这样的错误呢,请看下面的代码:
int Insert(element **head)
{
element *newElem;
newElem = (element *) malloc(sizeof(element));
if (!newElem)
return 0;
newElem->next = *head;
*head = newElem;
return 1;
}
步骤都是一样的,唯一不同的是传递进来的是二级指针,也就是说,传递进来的是原头指针的地址,那么在程序Insert里,会拷贝一份头指针的地址作为参数传进来,通过这个地址我们能找到头指针,这个时候我们找到的头指针就是原链表的头指针,并不是头指针的拷贝,所以做了节点的插入之后,*head=newItem正是完成了头指针的更新,保证了原链表节点插入的正确性。所以,任何可能导致头指针改变的操作都必须传递二级指针。
②遍历
更多时候我们在意的是节点的值而不是头指针,所以很显然对节点的遍历就是很常见的操作了,但是遍历节点的时候我们需要注意是否已经遍历到了链表末尾,否则引用空指针会导致程序crash掉,比如说:
element *FindSix(element *elem)
{
while (elem->data != 6) {
elem = elem->next;
}
return elem;
}
上述代码如果6在链表里还好,不会报错,但是一旦6不在链表里,那么程序最后就会引用一个空指针,这样是会导致程序崩溃的。
element *FindSix(element *elem)
{
while (elem) {
if (elem->data == 6) {
return elem;
}
elem = elem->next;
}
return NULL;
}
所以一般来说我们会像上面的这段代码一样,增加一个判断节点是否为末尾节点的语句,这样就会使得程序在链表末尾正常终止不会产生意想不到的错误。
③插入和删除
由于链表的每个节点都包含着下一个节点的信息,所以每次执行插入删除之后,都需要对相关节点的next指针进行更新。增加、删除一个节点,需要知道指向该节点的指针以及该节点前面节点、后面节点的指针,如下是一段删除指定节点的代码:
int DeleteElement(element **head, element *deleteMe)
{
element * elem=*head;
if (deleteMe == *head){
*head = elem->next;
free(deleteMe);
return 1;
}
while(elem){
if(deleteMe == elem->next){
elem->next = deleteMe->next;
free(deleteMe);
return 1;
}
elem=elem->next;
}
return 0;
}
如果想删除整个链表的节点,一般来说需要两个指针,一个用来free,一个用来遍历。
Void DeleteList(element *head)
{
element *next, *deleteMe;
deleteMe = head;
while(deleteMe){
next = deleteMe->next;
free(deleteMe);
deleteMe=next;
}
}
下面是exercise:
①使用动态数组或者链表完成栈的建立
这个问题会考察三个方面的内容:对抽象类型stack的理解,对基础类型dynamic array和linked list的理解,对接口连贯性合理性的设计。
栈是一个后进先出的数据结构,也就是说当你从栈中移除一个元素时,正好移去的是最后一次添加进来的元素,栈对于解决不同层次结构的子问题时特别有用,有些栈的实例中会为子问题返回地址、参数以及局部变量,在编译器中,用栈的数据结构来解析语法是很有用的。增加和删除元素的操作分别叫做push和pop.动态数组相比于链表而言,最大的优势在于随机读取,能立即获取任意位置的数据,而栈呢只能操作栈最顶层的数据。最大的缺点在于,一旦有元素的增删,都必须要resize动态数组,这是一个非常耗时的工作。另一方面,如果resize的思路设计地很好的话,动态数组也是很可取的,毕竟链表也要每次为每个元素动态分配内存。而且链表为每个元素还有一个额外的指针开销,如果你往栈里存的只是简单的数据类型如int的话,那么这个开销就是很大的了。在一次面试中,方法的简便和时间效率往往是考虑的重点,一般来说链表还是比较受青睐的。
决定好数据结构以后,就该考虑如何设计接口了,开始写代码之前先想好接口的模式,是一个好的习惯,避免以后重复更改。
typedef struct elementT{
struct elementT *next;
void * data;
}element;
这里data不同于一般链表的数据结构,因为执行pop操作时需要得到pop出来的数据,而且面试官没有指明具体数据结构时,使用void表示你考虑地比较全面。
接下来是pop和push的原型。
void push(element *stack,void *data)
void* pop(element *stack)
上述原型是我们能想到的最简单的形式了,那这样的形式有什么问题没有,需要再仔细考虑。首先栈的操作pop和push都是对第一个元素进行操作,显然需要改变头指针stack的值来反映这种操作,前面提到过,使用一级指针显然是不行的,必须使用二级指针。接下来考虑的是出错后的提示问题,显然程序运行过程中会出现一些异常,比如push操作如果push不了分配不到内存怎么办,应该有个返回值来告诉程序员,pop操作也是一样,栈中如果没有元素也pop不出来,也需要有个返回值告诉程序员,所以设计int类型的返回值,这样的话data就不能通过函数pop的返回值来解决了,可以使用二级指针来记录pop出来的data.如此函数模型设计如下:
int push(element **stack,void *data)
int pop(element **stack,void **data)
接下来还有int CreateStack(element **stack)和int DeleteStack(element **stack)
具体实现如下:
int CreateStack(element **stack){
*stack=null;
return 1;
}
int push(element **stack,void *data){
element *elem;
elem = (element *)malloc(sizeof(element *));
if(!elem)return 0;
elem->data = data;
elem->next = *stack;
*stack = elem;
return 1;
}
int pop(element **stack,void **data){
if(!(*stack))return 0;
*data = *stack->data;
*stack = *stack->next;
return 1;
}
int DeleteStack(element **stack){
element *deleteMe;
deleteMe = (element *)malloc(sizeof(element *));
if(!deleteMe)return 0;
deleteMe = *stack;
while(deleteMe){
*stack = deleteMe->next;
free(deleteMe);
deleteMe = *stack;
}
return 1;
}
②维持链表尾指针
使用头指针和尾指针完成下列函数原型。
int Delete(element *elem)elem是要删除的元素。int InsertAfter(element *elem,int data)把data插入到elem的后面。
int Delete(element *elem){
element *curPos=head;
if(!elem)return 0;//传递进来的elem为空
if(curPos==elem){
head=elem->next;
free(elem);
if(!head)tail=null; //链表只有一个元素
return 1;
}
while(curPos){
if(curPos->next==elem){
curPos->next=elem->next;
free(elem);
if(curPos->next==null)tail=curPos;
return 1;
}
curPos=curPos->next;
}
return 0;
}
int InsertAfter(element *elem,int data){
element *newElem,*curPos=head;
newElem=(element *)malloc(sizeof(element *));
if(!newElem)return 0;
newElem->data=data;
if(!elem){
newElem->next=head;
head=newElem;
if(!tail)tail=newElem;//链表自身为空,只能在null后插入,所以elem必为空
return 1;
}
while(curPos){
if(curPos=elem){
newElem ->next = curPos>next;
curPos->next = newElem;
if(!newElem ->next)tail = newElem;//在链表末尾加入
return 1;
}
curPos = curPos->next;
}
free(newElem);
return 0;
}
③删除头结点的bug
void RemoveHead(node *head){
free(head);
head = head->next;
}
一般来说,判断函数是否有bug,需要从如下4个方面入手:
1 传递进入函数的参数是否合适
2 判断函数的每条语句能否正确执行
3 检查函数返回值是否正确
4 检测函数是否会检测特殊的边界条件
free头结点后无法找到head的next.这种写法显然是不行的,我们需要一个指针指向头结点用于free头结点,同时我们还需要一个指针用来保存头结点的下一个节点,用以更新头结点,与此同时牢记,修改头结点的操作必须传递二级指针。
void RemoveHead(node **head){
node * tmp;
if(head&&(*head)){
tmp = *head->next;
free(*head);
*head = tmp;
}
}
④链表从后往前的m个元素
在一个单链表中,寻找从后往前的m个元素并返回m个元素的起始元素。要求算法保证高效的时间、空间复杂度并且能够处理一些特殊情况如m=0时返回链表的末尾元素。
考虑到链表只能从前往后遍历,所以按照一般思维寻找从后往前的第m个元素至少得遍历链表2便,第一遍知道链表有几个元素,然后计算从后往前的第m个元素会是从前往后的第几个元素。显然这样不够简便特别是链表特别长时就比较耗时间,我们可以换种想法,比如时间窗的概念,既然是m个元素,那这个窗口的大小就固定了,可以先从前往后遍历m个元素作为一个子链表,如果子链表尾部指针的next为空,则找到了题目要求的 m个元素,否则删除子链表的头结点,往子链表的末尾增加下一个节点,如此直至子链表末尾结点的next为空即停止。
element *FindMToLastElement(element *head,int m){
element *current, *mBehind;
int i;
current = head;
for(i=0;i<m;i++){
if(current->next){
current = current->next;
}
else
return null;
}
mBehind = head;
while(current->next){
current = current->next;
mBehind = mBehind->next;
}
return mBehind;
}
⑤展开链表
有如下定义的数据结构:
typedef struct nodeT{
struct nodeT *prev;
struct nodeT *next;
struct nodeT *child;
int value;
}node;
![1a636e680e7b6b75b876d0a230c379f4.png](https://i-blog.csdnimg.cn/blog_migrate/4ca32c750a4f016af4129a3c81a65e12.png)
现在要求将上述情况下的结点展开成如下情形:
![c380b27879ae67b057d9d47f727c8c58.png](https://i-blog.csdnimg.cn/blog_migrate/9700904820fb75c7fa472e3a5e28c62c.png)
void FlattenList(node *head,node **tail){
node *curNode=head;
while(curNode){
if(curNode->child)Append(curNode->child,tail);
curNode=curNode->next;
}
}
void Append(node *child,node **tail){
node *curNode;
(*tail)->next=child;
child->prev=*tail;
for(curNode=child;curNode->next;curNode=curNode->next);
*tail=curNode;
}
反过来如何根据展开的链表还原原来的多层链表结构呢?根据展开的结构可以看出,有child的结点还是有记录的,我们可以遍历一遍链表,遇到有child的结点,还原其child链表(就是两个指针的操作而已).
void UnFlatten(node *start,node **tail){
node *curNode;
ExploreAndSeparate(start);
for(curNode=start;curNode->next;curNode=curNode->next);
*tail=curNode;
}
void ExploreAndSeparate(node *start){
node *curNode=start;
while(curNode){
if(curNode->child){
curNode->child->prev->next=null;
curNode->child->prev=null;
ExploreAndSeparate(curNode->child);
}
curNode=curNode->next;
}
⑥判断链表是单链表还是有环链表
如果链表是单链表,显然遍历一遍最终会以null指针结束,但若是有环链表,则遍历是无法终止的,怎么体现一个链表是循环链表呢,可以使用快慢指针来操作,就像围着操场跑步一样,跑得快的人总会在未来某个时刻与跑得慢的人重新相遇,而能否相遇则是判断链表是否有环的关键。
int DetermineTermination(node *head){
node *fast,*slow;
fast=slow=head;
int first=1;
while(1){
if(!fast||(!fast->next))return 0;//返回0表示无环
else if((!first)&&(fast==slow||fast->next==slow))return 1;//返回1表示有环
else{
slow=slow->next;
fast=fast->next->next;
first=0;
}
}
return 0;
}
注:以上内容翻译自John Mongan, Noah Suojanen, Eric Giguère的Programming Interview Exposed.