链表操作技巧及Linux内核归并排序


前言

本文通过简记链表操作以及指针的实际用法中高效有用的技巧并分析内核链表归并排序来帮助读者在实际操作中变换思考方式,写出更优雅的代码。让大家更好体会到C的魅力。

链表操作技巧

无头节点单链表定义

给定一个简单的单链表定义如下所示:

typedef struct list_entry{
  int value;
  struct list_entry *next;
}list_entry_t;

此时,我们给出最初版本的从给定链表中删除包含指定value节点的函数。
假定该链表中有且仅有一个节点value值为给定value,不考虑value不在链表中或多个节点value都为给定value参数的特殊情况。

参数head:待操作链表
参数value:待移除节点的value值
返回值:返回移除特定节点后的链表

list_entry_t *remove(list_entry_t *head, int value)
{
  if(!head)return NULL;
  if(head->value == value)return head->next;

  list_entry_t *prev=head;
  while(prev->next){
    if(prev->next->value == value)break;
    prev=prev->next;
  }
  prev->next = prev->next->next;
  return head;
}

下文我们会称呼类型list_entry_t *类型为链表,而链表的指针则代表list_entry_t **类型。

自定义一个链表头节点

当链表头部成为特殊情况的一种时,我们可以通过手动添加头部节点的方式来使得链表头部和普通位置节点一样不再特殊。再次给出链表移除给定节点函数如下:

参数head:待操作链表
参数value:待移除节点的value值
返回值:返回移除特定节点后的链表

list_entry_t *remove(list_entry_t *head, int value)
{
  list_entry_t newHead={.value=0,.next=head};
  list_entry_t *prev=&newHead;

  while(prev->next){
    if(prev->next->value == value){
      prev->next=prev->next->next;
      break;
    }
    prev=prev->next;
  }
  return newHead.next;
}

注:返回局部变量的指针会导致未知错误,当函数返回时其所在栈空间里定义的局部变量已经无效,所以一定记得使用此技巧时的返回值为 newHead.next,这很重要

再例如:力扣第21题:合并两个有序链表
图示链表合并结果如下所示:
在这里插入图片描述此时,我们使用自定义头节点方式来解决这个题目代码如下所示:

参数list1:第一个有序链表
参数list2:第二个有序链表
返回值:返回合并后的有序链表

struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
    struct ListNode head;
    struct ListNode *temp=&head;
    while(list1 && list2){
        if(list1->val < list2->val){
            temp->next=list1;
            list1=list1->next;
        }else{
            temp->next=list2;
            list2=list2->next;
        }
        temp=temp->next;
    }
    temp->next=(list1 ? list1 : list2);
    return head.next;
}

善用指针的指针

当指针操作遇到特殊情况需要单独考虑时,善用指针的指针可能也会带来更好的效果。
例如,我们再次给出使用指针的指针来控制链表节点删除操作如下所示:

参数head:指向链表的指针
参数value:待删除的节点value值

void remove(list_entry_t **head, int value)
{
  list_entry_t **indirect=head;
  while(*indirect){
    if((*indirect)->value == value){
      *indirect = (*indirect)->next;
      return;
    }
    indirect=&((*indirect)->next);
  }
}

同时按照指针的指针技巧给出链表节点新增函数

参数head:指向链表的指针
参数value:待新增的节点value值
无返回值,直接通过链表的指针操作链表本身

void append(int value, list_entry_t **head)
{
  list_entry_t *node=(list_entry_t*)malloc(sizeof(list_entry_t));
  node->value=value, node->next=NULL;

  list_entry_t **indirect=head;
  while(*indirect){
    indirect=&(*indirect)->next;
  }
  *indirect=node;
}

上述力扣21题:合并两个有序链表也可以使用指针的指针技巧来简化操作如下:

参数list1:第一个有序链表
参数list2:第二个有序链表
返回值:返回合并后的有序链表

struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
    struct ListNode *head;
    struct ListNode **indirect=&head;
    for(; list1 && list2; indirect=&(*indirect)->next){
        if(list1->val < list2->val){
            *indirect=list1;
            list1=list1->next;
        }else{
            *indirect=list2;
            list2=list2->next;
        }
    }
    *indirect=(list1?list1:list2);
    return head;
}

此时,我们看到该操作在for循环中的操作还是具有一定重复性,那能不能继续优化?当然可以

struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
    struct ListNode *head;
    struct ListNode **indirect=&head, **temp;
    for(; list1 && list2; indirect=&(*indirect)->next){
        temp=(list1->val <list2->val? &list1 : &list2);
        *indirect=*temp;
        *temp=(*temp)->next;
    }
    *indirect=(list1?list1:list2);
    return head;
}

指针的指针操作是一种思维方式的变化。希望大家在适当的时机,例如:当我的代码有重复操作或冗余部分或有特殊情况需要特殊处理时,能够想到有这么一种方法或许能够轻松解决问题。

快慢指针

快慢指针主要思想在于设置两个指针,其每次移动的步伐不一,多数情况下快指针每次两步,慢指针每次一步,用于寻找链表中间节点以及判断是否有环并找出环的起始点等问题。

力扣2095:删除链表中间节点,若链表有偶数个节点则删除中间靠右那个节点。
示例如图:
在这里插入图片描述
给出快慢指针和指针的指针结合的代码如下

参数head:待操作链表
返回值:返回删除中间节点的链表

struct ListNode* deleteMiddle(struct ListNode* head) {
    if(!head)return NULL;
    struct ListNode *fast=head,**indirect=&head;
    while(fast && fast->next){
        fast=fast->next->next;
        indirect=&(*indirect)->next;
    }
    *indirect=(*indirect)->next;
    return head;
}

这里快指针每次两步,慢指针每次一步,但是对于慢指针我们使用指针的指针技巧来避免在单链表中需要使用prev指针来删除特定节点的操作。

指针的指针下空间回收问题思考

指针的指针操作会使得错误排查更加困难,就需要大家更细致准确的理解。
在这里抛出一个问题,当我需要在删除节点的同时使用free函数回收内存,那么按照上面快慢指针的思路给出以下存在某个问题的代码:

struct ListNode* deleteMiddle(struct ListNode* head) {
    if(!head || !head->next)return NULL;
    struct ListNode *fast=head,prev=NULL,**indirect=&head;
    while(fast && fast->next){
        fast=fast->next->next;
        prev=*indirect;
        indirect=&(*indirect)->next;
    }
    prev->next=(*indirect)->next;
    free(*indirect);
    return head;
}

我们知道,参数head和局部变量indirect位于程序运行时的栈帧之中。由于ASLR机制的存在,全称为 Address Space Layout Randomization,地址空间布局随机化,在每次程序运行时的时候,装载的可执行文件和共享库都会被映射到虚拟地址空间的不同地址处;导致每次运行程序head的地址都不同,即indirect初始值不同,其初始值为head的地址。

以一个简单链表的循环过程给出图示来帮助读者真实感知指针的指针indirect在每次循环中的变化,以及其作用。
初始状态下各指针状态以及一个大致的程序栈帧如下图:
在这里插入图片描述

经过一次循环之后,各指针的值和状态如下图所示:
在这里插入图片描述按照prev->next=(*indirect)->next更新链表,则链表状态更新为如下状态:
在这里插入图片描述问题所在:此时indirect依旧指向第一个节点的next指针域,而其中内容已经发生改变,此时调用free函数来释放*indirect也就是0x300的空间就会发生错误。
那么,解决问题只需要临时变量来存储待删除节点的地址,随后释放其空间即可。
修正后的代码如下所示:

struct ListNode* deleteMiddle(struct ListNode* head) {
    if(!head || !head->next)return NULL;
    struct ListNode *fast=head,prev=NULL,node=NULL,**indirect=&head;
    while(fast && fast->next){
        fast=fast->next->next;
        prev=*indirect;
        indirect=&(*indirect)->next;
    }
    node=*indirect;
    prev->next=(*indirect)->next;
    free(node);
    return head;
}

链表归并排序

先抛出一个问题:为什么需要对链表排序?
top命令用于根据对CPU的占用率从高到低显示进程资源占用情况,在我的虚拟机终端中输入top命令显示如下结果:
在这里插入图片描述我们知道Linux内核中使用链表表示进程,只从这里来解释那么对链表的排序就很有必要。
另外引入一个机制:android系统中有一个lmk(Low Memory Killer)机制,简单理解为它会在系统可用内存较低时杀死占用内存空间最大的进程释放资源,此时考虑到android底层基于Linux内核,链表排序依旧重要。

在介绍链表归并排序开始之前先引入一个问题:合并k条有序链表。

合并k条有序链表

力扣23题:合并k个有序链表
题目描述:给你一个链表数组,每个链表都已经按升序排列。请你将所有链表合并到一个升序链表中,返回合并后的链表。
一个可能的链表合并过程如下所示:
在这里插入图片描述上文我们讨论了合并两个有序链表的几种技巧下的操作。下文中将直接调用该函数,不再过多解释。

合并所有剩余链表到第一条链表

第一种思路,我们可以通过把所有链表统一合并到第一条链表上,则代码如下所示:

参数lists:含listsSize个有序链表的链表数组
listsSize:链表数组的长度
返回值:返回合并链表数组中所有有序链表之后的链表

struct ListNode* mergeKLists(struct ListNode** lists, int listsSize) {
    if(listsSize==0)return NULL;
    for(int i=1;i<listsSize;i++){
        lists[0]=mergeTwoList(lists[0],lists[i]);
    }
    return lists[0];
}

这种情况下由于越往后第一条链表越长导致程序总体执行时间很长,但方法本身正确。

以中间为界头尾合并

第二种思路,链表头尾合并,以中间为分界前后两两链表合并,合并结果存储到前面链表所在数组位置,方便下一次合并,合并过程如下图所示:
在这里插入图片描述此时,给出此种情况下代码如下:

struct ListNode* mergeKLists(struct ListNode** lists, int listsSize) {
    if(listsSize==0)return NULL;
    while(listsSize>1){
        for(int i=0,j=listsSize-1;i<j;i++,j--){
            lists[i]=mergeTwoList(lists[i],lists[j]);
        }
        listsSize=((listsSize+1)>>1);
    }
    return lists[0];
}

这里有个特殊情况在于当链表数组个数为奇数,例如为3时,如果直接操作listsSize>>=1,经过一次迭代之后listsSize为1,但此时应该剩下两个链表待合并,结果不再正确。
因此需要加1之后再除以2,也就是右移一位。当链表数组长度为偶数时加1再除以2依旧不影响结果正确,因为listsSizeint类型,除法操作只取整数部分。

从左到右两两合并

第三种思路,链表从头到尾两两合并,合并结果存储到前面链表所在数组位置,方便下一次合并,合并过程如下图所示:
在这里插入图片描述给出index为链表在链表数组中的下标,step为每次合并时的步长。给出数组中的空位方便读者理解,后面step=4时循环停止,不再执行合并操作。

此时,给出此种情况下代码如下:

struct ListNode* mergeKLists(struct ListNode** lists, int listsSize) {
    if(listsSize==0)return NULL;
    int step=1;
    while(step<listsSize){
        for(int i=0;i+step<listsSize;i+=(step<<1)){
            lists[i]=mergeTwoList(lists[i],lists[i+step]);
        }
        step<<=1;
    }
    return lists[0];
}
使用分治策略–归并排序

最后的思路,我们已知k条链表已经有序,同时我们有合并两个有序链表的函数。那么此时我们可以应用普通归并排序的思路,分割数组,递归进行归并排序。
分治合并过程如下图所示:
在这里插入图片描述
按照归并排序实现合并k条有序链表如下所示:

struct ListNode* mergeKLists(struct ListNode** lists, int listsSize) {
    if(listsSize==0)return NULL;
    if(listsSize==1)return lists[0];
    int mid=(listsSize>>1);
    struct ListNode *left=mergeKLists(lists, mid);
    struct ListNode *right=mergeKLists(lists+mid, listsSize-mid);
    return mergeTwoList(left, right);
}

Linux内核归并排序

毋庸置疑,Linux内核归并排序是地球上存在的链表排序工程代码中最优雅最快的归并排序。

list_sort.c 是Linux内核最新链表归并排序源代码,也是少有的注释比代码多的文件了,主要原因在于众多kernel的开发人员对排序做了较多的优化。
list_sort函数用于链表排序,链表类型为Linux内核自定义的侵入式链表list_head,我在内核双向循环链表中已经做过简单介绍。
排序函数原型如下:

参数priv:可选,可为NULL,作为参数传给cmp比较函数
参数head:待排序链表,head为头节点,无实际意义,无外层结构
参数cmp:函数指针,用于比较两节点。

__attribute__((nonnull(2,3)))
void list_sort(void *priv, struct list_head *head, list_cmp_func_t cmp)

__attribute__((nonnull(2,3)))作用在于当传入的第二第三参数为空时,发出警告。
内核注释指出:

1:当a元素应当排在b元素之后时cmp函数必须返回正数,即@a>@b。当a应当排在b之前或需要保持a和b在链表中的原始顺序时,cmp函数返回小于等于0的值。
2:排序算法本身稳定,不需要区分a和b相等和a<b的情况。
3:此版本cmp函数和传统返回>0 =0 <0或只返回0/1的cmp函数兼容。

当链表父指针中需要比较多个元素时,一个较好的比较函数如下所示:

 	if (a->high != b->high)
 		return a->high > b->high;
 	if (a->middle != b->middle)
		return a->middle > b->middle;
	return a->low > b->low;

内核归并排序函数list_sort

我们知道Linux内核链表结构为双向循环链表。首先排除链表无元素或一个元素的情况,并将链表转为单链表。
其中next指针用于指向下一个元素,prev指针用于指向前一个挂起的链表。

__attribute__((nonnull(2,3)))
void list_sort(void *priv, struct list_head *head, list_cmp_func_t cmp)
{
	struct list_head *list = head->next, *pending = NULL;
	size_t count = 0;	/* Count of pending */

	if (list == head->prev)	/* Zero or one elements */
		return;

	/* Convert to a null-terminated singly-linked list. */
	head->prev->next = NULL;

开启循环,开始遍历单链表每一个节点,并根据count的状态决定是否合并,合并哪两条链表。

	do {
		size_t bits;
		struct list_head **tail = &pending;

		/* Find the least-significant clear bit in count */
		for (bits = count; bits & 1; bits >>= 1)
			tail = &(*tail)->prev;
		/* Do the indicated merge */
		if (likely(bits)) {
			struct list_head *a = *tail, *b = a->prev;

			a = merge(priv, cmp, b, a);
			/* Install the merged result in place of the inputs */
			a->prev = b->prev;
			*tail = a;
		}

		/* Move one element from input list to pending */
		list->prev = pending;
		pending = list;
		list = list->next;
		pending->next = NULL;
		count++;
	} while (list);

我们先不看for循环和if判断,此时我们知道函数的作用在于取出每一个list元素,并利用链表的prev属性将每个节点单独挂在pending上,其中,pending自己指向最新加入的节点,可以通过prev属性挨个访问之前的每一个节点。

count是算法的核心所在,每次循环进入时count的值就代表了上一次的状态,我们需要知道它加1之后的状态。
count1之后,若第kbit位被置为1(除了第一次,count= 2 k 2^{k} 2k),那么我们需要将两个大小为 2 k − 1 2^{k-1} 2k1的链合并为一条 2 k 2^{k} 2k的链。
源码注释如下:

  • Each time we increment “count”, we set one bit (bit k) and clear bits k-1 … 0. Each time this happens (except the very first time for each bit, when count increments to 2^k), we merge two lists of size 2^k into one list of size 2^(k+1).

乍一看这很难理解,我会用图示的方式来带大家更好理解这一过程,并给出后续说明。
我们以 5 − 4 − 3 − 2 − 1 − 0 5-4-3-2-1-0 543210 逆序链表逐一描述链表各节点加入过程中各个状态和变量的变化。
初始状态:我们加入节点 5 5 5
在这里插入图片描述随后,加入节点 4 4 4
在这里插入图片描述随后,加入节点 3 3 3
在这里插入图片描述

到这里大家应该理解了prev指向的是前一条链,而next指向的是下一个节点的含义。

我们随后加入节点 2 2 2
在这里插入图片描述再加入节点 1 1 1
在这里插入图片描述最后,加入节点 0 0 0
在这里插入图片描述在上述合并过程中,每次合并都按照大小都为 2 k − 1 2^{k-1} 2k1合并,此时归并排序效率最高。
按照上述过程,如果我们后续还有节点加入,此时我们需要合并两个大小为1的链表到一个大小为2的链中,也就是合并最后加入的节点1和节点0。但是此时list已空,循环结束。

不难想到,我们接下来需要按照从小到大的顺序依次合并所有pending链中所有尚未合并的链表。且看内核代码:

	/* End of input; merge together all the pending lists. */
	list = pending;
	pending = pending->prev;
	for (;;) {
		struct list_head *next = pending->prev;

		if (!next)
			break;
		list = merge(priv, cmp, pending, list);
		pending = next;
	}
	/* The final merge, rebuilding prev links */
	merge_final(priv, cmp, head, pending, list);
}

内核考虑到了需要返回给用户一个双向循环链表,故而最后两条链的合并通过单独的函数merge_final来完成,它还负责修正合并后链表中所有节点的prev属性到双向循环链表。

这种合并方式的优点在于:每次合并的两条链表长度要么相等,要么长的链表是短的链表长度的2倍,当缓存cache的容量容得下3*短链长度大小的节点数时,那么就不存在缓存颠簸极大增加缓存利用效率。

内核链表合并merge函数

相信大家有了上面指针的指针技巧基础之后,不难理解这段代码。用户可以传入比较函数来自定义比较规则使得函数看起来较复杂。

参数priv:用于比较时作为参数传递给cmp函数
参数cmp:比较大小函数指针
参数a:待合并链表
参数b:待合并链表
返回值:返回合并a和b后的链表

__attribute__((nonnull(2,3,4)))
static struct list_head *merge(void *priv, list_cmp_func_t cmp,
				struct list_head *a, struct list_head *b)
{
	struct list_head *head, **tail = &head;

	for (;;) {
		/* if equal, take 'a' -- important for sort stability */
		if (cmp(priv, a, b) <= 0) {
			*tail = a;
			tail = &a->next;
			a = a->next;
			if (!a) {
				*tail = b;
				break;
			}
		} else {
			*tail = b;
			tail = &b->next;
			b = b->next;
			if (!b) {
				*tail = a;
				break;
			}
		}
	}
	return head;
}

大概总体思路就是通过指针的指针tail来串起来一条有序的新链表,考虑到上面已经对这些技巧阐述的很详细了这里就给大家自行理解吧。

内核链表合并merge_final函数

这个函数用于在合并链表的同时修正prev指针。保证循环双链表的特性。

__attribute__((nonnull(2,3,4,5)))
static void merge_final(void *priv, list_cmp_func_t cmp, struct list_head *head,
			struct list_head *a, struct list_head *b)
{
	struct list_head *tail = head;
	u8 count = 0;

	for (;;) {
		/* if equal, take 'a' -- important for sort stability */
		if (cmp(priv, a, b) <= 0) {
			tail->next = a;
			a->prev = tail;
			tail = a;
			a = a->next;
			if (!a)
				break;
		} else {
			tail->next = b;
			b->prev = tail;
			tail = b;
			b = b->next;
			if (!b) {
				b = a;
				break;
			}
		}
	}

	/* Finish linking remainder of list b on to tail */
	tail->next = b;
	do {
	/*
		 * If the merge is highly unbalanced (e.g. the input is
		 * already sorted), this loop may run many iterations.
		 * Continue callbacks to the client even though no
		 * element comparison is needed, so the client's cmp()
		 * routine can invoke cond_resched() periodically.
		 */
		if (unlikely(!++count))
			cmp(priv, b, b);
		b->prev = tail;
		tail = b;
		b = b->next;
	} while (b);

	/* And the final links to make a circular doubly-linked list */
	tail->next = head;
	head->prev = tail;
}

整体思路是先合并两条链表。当一条为空时,跳出for循环。对剩余的不空的链进行prev指针修正。最后,依旧保持head为双向循环链表的头节点。

后面的do{}while循环中的无效比较意义在于当链表过于不平衡时,do{}while循环会执行很多次,当u8仅一个字节且count==255时,则++count会发生无符号数溢出,变为0,此时调用cmp,用户可在cmp函数中可自行加入调度函数等。

retpoline及其对排序的影响

处理器往往会设置一个返回地址缓冲区以便在发生函数调用时能够在函数尚未返回时提前执行,提高效率。例如:Intel的CPU中设置有RSB(return stack buffer),AMD的CPU中设置有RAS(return address stack),ARM中有return stack。然而这个特性却会被攻击者利用,在call发生前修改缓冲中的返回地址利用分支执行执行不同函数,通过上层函数的栈帧获得敏感信息等等。具体内容见:Google工程师retpoline方案

gcc中有-mindirect-branch=选项用于控制编译器在编译C代码时应当如何处理间接跳转/调用,thunk-extern参数将间接跳转/调用指令转换为对外部indirect branch/call thunk的调用。内核中的Retpoline特性就使用了这个参数,内核参数CONFIG_RETPOLINE可以控制是否开启内核对retpoline的支持。

机制简单解释为:在发生call的位置使用宏NOSPEC_CALL来代替。目前最新的内核机制好像已经有所变化。

例如:我们有汇编代码:call cmp则把它替换为NOSPEC_CALL cmp
该宏定义如下所示:

.macro NOSPEC_CALL target
    jmp     1221f            /* jumps to the end of the macro */
1222:
    push    \target          /* pushes ADDR to the stack */
    jmp __x86.indirect_thunk /* executes the indirect jump */
1221:
    call    1222b            /* pushes the return address to the stack */
.endm

这段汇编码会先跳转到call 1222b执行,进而把用户调用NOSPEC_CALL时下一条指令作为返回地址压栈,同时1222会把目标函数地址压栈,转去执行__x86.indirect_thunk

此时栈中先是用户待调用目标函数,其次是目标函数待返回地址,__x86.indirect_thunk定义如下:

ENTRY(__x86.indirect_thunk)
	CFI_STARTPROC
	call	retpoline_call_target
2:
	lfence		/* stop speculation */
	jmp	2b
retpoline_call_target:
#ifdef CONFIG_64BIT
	lea	8(%rsp), %rsp
#else
	lea	4(%esp), %esp
#endif
	ret
	CFI_ENDPROC
ENDPROC(__x86.indirect_thunk)

该函数调用retpoline_call_target修正栈指针,移除调用retpoline_call_target时压栈的返回地址2,此时返回地址为目标函数,则通过ret来执行。RSB(return address buffer)中的地址会为2使得预测执行陷入死循环进而避免攻击。

mergesort补丁提交者说明中第一句指出,内核该机制会极大降低间接函数调用的效率,所以努力降低cmp函数调用次数很有必要:

CONFIG_RETPOLINE has severely degraded indirect function call performance, so it’s worth putting some effort into reducing the number of times cmp() is called.

cmp函数只有当链表merge时总长度为 2 k 2^{k} 2k时比较次数最少,提交者在复杂度和比较次数上等方面证明该排序比任何排序方法都要优秀。为什么是 2 k 2^{k} 2k,详细证明见论文:power of 2 rules

总结

本文尽量细致的向大家介绍了链表操作技巧和内核的链表排序机制。希望读者有所收获。
图画起来也挺费劲的,可能会有一些不正确的地方我没注意到,如有问题欢迎指正。

  • 21
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值