3.1.2 题目的要求
← 内容总览 11 / 24 下一节 →
强化讲义 · 原始转写 · 原书检索 · 文字用于定位,公式与图表请回原页核验 · 本次导入快照
## 3.1.2 题目的要求
如果题目要求空间复杂度是O(1),这就意味着不能额外设置数组,不能将链表先用数组保存然后再进行处理。 人
如果题目要求不可修改链表结构,意思是我们不能随意的对链表元素顺序进行修改以及随意的插入和删除。 Y
<!-- page 101 -->
## 3.1.3 链表定义
题目中经常会有如下描述:
(2) 使用 C 或C++语言,给出单链表结点的数据类型定义。
对这类问题,我们可以提前记忆模板,然后考试中根据题目要求进行改动(仔细审题):
单链表(data 是该结点的权值,一般可以定义成int;next 是指向下一个结点的指针,保存的是下一个结点的位置): 1
<!-- verified-by-hand -->
> [!success] 已结合原文第 101 页及 Markdown 上下文人工校对
> 中文注释缺少注释符,next 字段后的说明同样混入代码。
typedef struct LNode
{
ElemType data ; // 该结点的权值
struct LNode * next ; // 指向下一个结点
} LNode ;
双链表(不需要特别记忆,只是比单链表多一个 prior 指针而已):
<!-- verified-by-hand -->
> [!success] 已结合原文第 101 页及 Markdown 上下文人工校对
> 双链表结构体左花括号丢失。
typedef struct LNode
{
ElemType data ;
struct LNode * prior , * next ;
} LNode ;
<!-- page 102 -->
pre -& gt ; next = p -& gt ; next ; // ① 将pre的next域指向p的下一个结点
Node * temp = p ;
p = p -& gt ; next ; //②将p指向p的下一个结点
free ( temp );
## 3.2 基本操作
链表的基本操作是实现所有算法的基础。
## 3.2.1 删除 p
原资料配图 · 转写内容请回源核验
在考试中删除p结点之后一般还需要将p移动到下一个结点,所以删除时需要注意两个问题: K 尊
删除操作不要导致断链。
要能够找到下一次待处理的结点,即p要更新所指结点。
pre -& gt ; next = p -& gt ; next ; //①将pre的next域指向p的下一个结点
p = p -& gt ; next ; //②将p指向p的下一个结点
释放内存:在删除结点时,除了修改前后结点的指针,还需要考虑释放结点所占的内存。
如果是C 语言,需要调用f ree(p)。但在考试中为了方便不写也没关系。
这是加上了free的写法,仅供参考:
<!-- page 103 -->
## 3.2.2 p 插入到 pre 后面
p 插入 pre 后面的结点:
p -& gt ; next = pre -& gt ; next ; // ① 将p的next域指向pre的下一个结点
pre -& gt ; next = p ; // ②将pre的next域指向p对应结点
将p 插入到 pre 后面要注意先处理p 的 next,后处理 pre 的 next,否则可能会因为先修改了pre 的 next 而导致找不到 pre 的下一个结点。
原资料配图 · 转写内容请回源核验
<!-- page 104 -->
## 3.2.3 尾插法和头插法
当我需要插入很多个元素到空链表中,而且要保证插入元素的顺序就是最终链表中元素的顺序,可以使用尾插法,即每次都是在链表末尾插入p,需要设置一个指针tail指向链表末尾的元素:
原资料配图 · 转写内容请回源核验
而如果想让插入顺序和链表最终顺序相反,则不应使用尾插法而使用头插法,是将一个结点p插入到头结点 和其原来的第一个数据结点之间。这种方法常用于实现链表的逆置,具体使用请看3.5.3。
原资料配图 · 转写内容请回源核验
所有的单链表插入都应该是先连后,再连前,这样一定不会断链。
假如插入顺序是1、2、3、x,如果使用尾插法插入则链表最终顺序为1、2、3、x(和插入顺序相同),如果使用头插法插入则链表最终顺序为x、3、2、1(和插入顺序相反)。
<!-- page 105 -->
## 3.2.4 遍历链表
对于线性表(数组+链表)的遍历,如果表中元素个数确定就用 for 循环,否则用 while 循环。在遍历过程中访问指针p每次都需要后移即p=p->next,不要忘记。链表一般是带头结点的,所以遍历时应从L->next 开始。
## 元素个数 n 已知
<!-- verified-by-hand -->
> [!success] 已结合原文第 105 页及 Markdown 上下文人工校对
> visit 后的中文说明缺少注释符;其余循环结构完整。
Node * p = L -& gt ; next ;
int n = 0 ;
while ( p != NULL )
{
n ++ ;
visit ( p ); // 访问 p 结点
p = p -& gt ; next ;
}
## 元素个数未知
Node * p = L -& gt ; next ;
while ( p != NULL )
{
visit ( p ); // 访问p结点
p = p -& gt ; next ;
}
在此基础上,还可以统计元素个数n:
<!-- suspect-code -->
> [!warning] 这段代码需要核对(原文第 105 页)
> OCR 体检未通过:中文出现在代码行但缺注释符。扫描件的两栏排版(代码 + 右侧注释)有时会被交错读取,产生看起来合理但语义错误的代码。背之前请对照原文。
Node * p = L -& gt ; next ;
int n = 0 ;
while ( p != NULL )
{
n ++ ;
visit ( p ); 访问p结点
p = p -& gt ; next ;
}
其实所有的循环都可以写成 for 循环,这种写法和 while 循环是等价的:
for ( Node * p = L -& gt ; next ; p != NULL ; p = p -& gt ; next ) visit ( p );
<!-- page 106 -->
<table><tr><td>X data</td><td>next</td></tr><tr><td></td><td></td></tr></table>
## 3.3 暴力解法
练习:请在2分钟内想出这道例题做法(不考虑复杂度)
例题3-1:请将链表 L 按照升序重新排序。结点结构为
## 3.3.1 枚举法,直接处理
链表的一些题目可以先采用最直接易想的方法,比如要改变链表元素顺序的话,可以将需要重排的元素一个一个拆下来重新插入。这类方法看起来比较憨,但是容易想到 早
例题 3-1 解法如下:
// 查找并插入p
void Find_Insert ( ListNode * pre , ListNode * p )
{
while ( pre -& gt ; next != NULL & amp ; & amp ; pre -& gt ; next -& gt ; data & lt ; p -& gt ; data ) //查找插入位置
pre = pre -& gt ; next ;
p -& gt ; next = pre -& gt ; next ;
pre -& gt ; next = p ; //将p插入到pre之后
Node * ans ( Node * L )
{
Node * p = L -& gt ; next ;
L -& gt ; next = NULL ;
while ( p != NULL )
{
Find_Insert ( L , p );
p = temp ;
}
return L ; // 十算机
}
}
<!-- page 107 -->
## 3.3.2 链表转数组,再处理
可以使用数组保存链表中的结点地址或者直接保存数据,然后再按照数组的做题方法操作即可。
以下情况不能使用数组保存链表的结点:
题目要求空间复杂度为O(1);
## 例题 3-1 解法如下:
//快速排序
void Qsort ( int A [], int L , int R )
{
if ( L & gt ; = R )
return ;
int pivot , i = L , j = R ;
pivot = A [ L ];
while ( i & lt ; j )
{
while ( i & lt ; j & amp ; & amp ; A [ j ] & gt ; = pivot ) j -- ;
while ( i & lt ; j & amp ; & amp ; A [ i ] & lt ; = pivot ) i ++ ;
if ( i & lt ; j )
swap ( A [ i ], A [ j ]); // A[j]<pivot<A[i],交换
}
swap ( A [ L ], A [ i ]); //基准归位
Qsort ( A , L , i -1 );
Qsort ( A , i + 1 , R );
}
Node * ans ( Node * L )
{
Node * p = L -& gt ; next ;
// 链表转数组
while ( p != NULL )
{
//
visit ( p );
a [ n ] = p -& gt ; data ;
n ++ ;
p = p -& gt ; next ;
}
Qsort ( a , 0 , n -1 );
// 数组转链表
p = L -& gt ; next ;
for ( int i = 0 ; i & lt ; n ; i ++ )
{
p -& gt ; data = a [ i ];
p = p -& gt ; next ;
}
return L ;
}
<!-- page 108 -->
## 3.4 判断能否优化
判断方法见 2.3。对于链表来说,如果算法的时间复杂度是O(nlogn)或以上,则可以优化,否则就不需要。
<!-- page 109 -->
## 3.5 适用于链表的优化算法
## 3.5.1 多指针法
回忆上一章内容,多指针法可以用于顺序表和链表,顺序表的多指针法分为三种:多个有序顺序表、单个有序顺序表、单个无序顺序表,后两种都会涉及从后往前访问,而在单链表中是无法实现从后往前访问的,因此只考虑第一种(多个有序链表)。
除此之外,链表自身有一些特殊用法:快慢指针、前后指针。
## 类型1:多个有序链表
请大家回顾顺序表的多指针法,对于多个有序顺序表的情况,需要对每一个顺序表都设置一个指针(从前往后或从后往前)。
以下是顺序表的写法,它和链表写法的思路、每个模块的功能都是一样的,只是每个模块具体代码不一样,请大家尝试根据顺序表写法改写成链表写法。
void Merge ( int A [], int n , int B [], int m )
{
int C [ n + m ];
int i = 0 , j = 0 , k = 0 ;
//直到有一个数组遍历完退出循环
while ( i & lt ; n & amp ; & amp ; j & lt ; m )
if ( A [ i ] & lt ; B [ j ])
C [ k ++ ] = A [ i ++ ];
else
C [ k ++ ] = B [ j ++ ];
// 复制A 中剩余的元素
while ( i & lt ; n )
C [ k ++ ] = A [ i ++ ];
// 复制 B 中剩余的元素
while ( j & lt ; m )
C [ k ++ ] = B [ j ++ ];
从左到右输出数组C中的元素
}
以下是链表写法。因为单链表只允许从前往后,每个有序链表都设置一个指针从头结点开始从后往前进行访问。在数组中每次后移指针下标i是i++,而链表中指针p则是p=p->next,本质一样。
归并两个带头结点的升序链表(理解+应用),代码如下:
<!-- page 110 -->
//归并操作
void Merge ( LNode * L1 , LNode * L2 )
{
// p 和 q 分别指向两个链表的第一个数据结点
LNode * p = L1 -& gt ; next ;
LNode * q = L2 -& gt ; next ;
//创建一个新的头结点L3,并用tail指向它
LNode * tail = L3 ;
//遍历两个链表,直到其中一个为空
while ( p != NULL & amp ; & amp ; q != NULL )
{
if ( p -& gt ; data & lt ; = q -& gt ; data )
{
tail -& gt ; next = p ;
p = p -& gt ; next ;
else
tail -& gt ; next = q ;
q = q -& gt ; next ;
tail = tail -& gt ; next ; //移动 tail 指针到新链表的末尾
}
//将未处理完的链表剩余部分直接链接到tail 之后
if ( p != NULL ) tail -& gt ; next = p ;
else tail -& gt ; next = q ;
//释放原始链表的头结点,可以省略
free ( L1 );
free ( L2 );
return L3 ;
}
}
请大家思考:多指针法插入的时候使用头插法,会发生什么事情?
<!-- page 111 -->
## 类型2:快慢指针
如果需要判断一个单链表是否有环,可以通过设置两个指针p和q,初始时都指向第1 个结点,然后每次p往后移动 1 次,而 q往后移动 2次,保证 q 往后移动的次数是p的两倍,如果q能从p后面追上 q即p== q,说明该单链表有环。
Node * p , * q ;
p = q = L -& gt ; next ;
while ( p != NULL & amp ; & amp ; q != NULL )
{
p = p -& gt ; next ;
q = q -& gt ; next ;
if ( q == NULL )
break ;
if ( p == q )
有环 √ / p和q相遇
}
## 而如果我们需要返回中点,可以这样写
Node * p , * q ;
p = q = L -& gt ; next ;
while ( p != NULL & amp ; & amp ; q != NULL )
{
q = q -& gt ; next ;
if ( q == NULL || q -& gt ; next == NULL )
break ;
q = q -& gt ; next ;
p = p -& gt ; next ;
if ( p == q )
有环 // p和q相遇
}
<!-- page 112 -->
## 类型3:前后指针
【2009年真题,15分】已知一个带有表头结点的单链表,结点结构为
<table><tr><td>data link</td></tr></table>
假设该链表只给出了头指针list。在不改变链表的前提下,请设计一个尽可能高效的算法,查找链表中倒数第k个位置上的结点(k为正整数)。若查找成功,算法输出该结点的data 域的值,并返回1;否则,只返回0。
因为单链表只能按照一个方向处理,无法从后往前回头。所以设置两个指针p和q,初始时p指向L,q指向第 k结点,然后p和 q 每次同时后移,这样p和 q 之间一直保持 k的距离,当q指向NULL时,此时p指向倒数第k个元素。 X R
Node * p , * q ;
p = q = L ;
for ( int i = 0 ; i & lt ; k ; i ++ )
q = q -& gt ; next ; //假设链表长度>=k
while ( q != NULL )
{
p = p -& gt ; next ;
q = q -& gt ; next ;
}
输出p
<!-- page 113 -->
## 3.5.2 散列表
和数组中一样,都是额外定义数组保存中间过程,比如count[n]表示链表中权值为n的元素出现的次数。 尊
例题2-3:数组 A 中有 n 个非负元素,每个元素绝对值< n,其中只有一个数重复出现过,请输出这个数。如:n=6,A={1, 2, 5,2,4, 3}
暴力解:枚举优化解:散列表解析:关键信息“绝对值< n”,可使用散列表
//链表做法
{
Node * p = L -& gt ; next ;
while ( p != NULL )
{
//visit(p);
if ( count [ p -& gt ; data ] & gt ; 0 )
cout & lt ; & lt ; p -& gt ; data ;
count [ p -& gt ; data ] ++ ; // 要注意p->data是否可能超过数组下标
p = p -& gt ; next ;
}
}
//顺序表做法
void Hash_Sequence ( int a [], int n )
{
int count [ n ] = { 0 };
for ( int i = 0 ; i & lt ; n ; i ++ )
{
if ( count [ a [ i ]] & gt ; 0 )
cout & lt ; & lt ; a [ i ];
count [ a [ i ]] ++ ; // 要注意a[i]是否可能超过数组下标
}
}
在使用时一定要考虑 a[i] 的大小是否会超出 count 下标的范围
<!-- page 114 -->
## 3.5.3 逆置
逆置就是要将线性表反转,顺序完全反过来。接下来我们会依次讨论顺序表的逆置、链表的逆置。
## 顺序表的逆置
顺序表的逆置很简单,依次将左侧第1个元素(A[0])和右侧第1个元素(A[n-1])交换、左侧第2个元素(A[1])和右侧第2个元素(A[n-2])交换...... AX
for ( int i = 0 ; i & lt ; n / 2 ; i ++ )
swap ( A [ i ], A [ n - i -1 ]);
## 链表的逆置
链表因为无法实现随机访问,所以逆置问题无法采用顺序表相同的方式,但可以借助头插法重新插入所有结点。头插法的特点是后插入的结点先访问,这样重新插入一遍就实现了逆置的功能。
设置两个指针:p指向当前正在处理的结点,temp 指向下一个处理的结点,每次把p头插法插入到头结点L后面,然后处理下一个结点,更新p、temp。具体的写法有以下两种,我更推荐大家使用第二种。 算
## 第一种写法:
<!-- verified-by-hand -->
> [!success] 已结合原文第 114 页及 Markdown 上下文人工校对
> 对照原文第114页校正:while (p != NULL) 丢了花括号,三条语句全落在循环外(原代码是死循环);末尾多一个 }
// 逆置操作
void Head_Insert ( ListNode * L , ListNode * p )
{
p -& gt ; next = L -& gt ; next ;
L -& gt ; next = p ;
}
void Reverse ( ListNode * L )
{
ListNode * p = L -& gt ; next ;
L -& gt ; next = NULL ;
while ( p != NULL )
{
ListNode * temp = p -& gt ; next ;
Head_Insert ( L , p );
p = temp ; // 更新p,指向下一个待处理结点
}
}
如果让 Head\_Insert() 过程能够返回下一个待处理结点地址,就有了第二种写法:
<!-- page 115 -->
//逆置操作
ListNode * Head_Insert ( ListNode * L , ListNode * p )
{
ListNode * temp = p -& gt ; next ;
p -& gt ; next = L -& gt ; next ;
L -& gt ; next = p ;
return temp ; // 返回原来p的下一个元素
void Reverse ( ListNode * L ) //主代码段
{
ListNode * p = L -& gt ; next ;
L -& gt ; next = NULL ;
while ( p != NULL )
更新p , 指向下一个待处理结点
}
}
<!-- page 116 -->
← 内容总览 11 / 24 下一节 →