目录
[问题 1:文件读写时操作系统内核的工作](#问题 1:文件读写时操作系统内核的工作)
[问题 2:在代码中直接操作刷盘动作吗?](#问题 2:在代码中直接操作刷盘动作吗?)
[问题 3:TCP编程中服务端的 bind、listen、accept](#问题 3:TCP编程中服务端的 bind、listen、accept)
[问题 4:TCP编程中客户端的 socket、connect、read/write、close](#问题 4:TCP编程中客户端的 socket、connect、read/write、close)
[问题 5: 服务端死锁或挂起后,客户端再连接能成功吗?](#问题 5: 服务端死锁或挂起后,客户端再连接能成功吗?)
[问题 6:往里面写数据能行吗?](#问题 6:往里面写数据能行吗?)
[问题 7:如果一直写的话,TCP层是直接丢掉吗?](#问题 7:如果一直写的话,TCP层是直接丢掉吗?)
[问题 8:实现一个LRU需要用什么数据结构?](#问题 8:实现一个LRU需要用什么数据结构?)
[问题 9:双向链表是干什么的?](#问题 9:双向链表是干什么的?)
问题 1:文件读写时操作系统内核的工作
当你从磁盘读取一个文件、修改后再写回去时,操作系统内核大致会执行以下步骤:
- 发起系统调用 :你的程序(用户态)通过调用
open()、read()、write()、close()等函数,将控制权转移到操作系统内核(内核态)。 - 读取文件(
read()):
-
- 检查缓存 :内核首先检查页缓存(Page Cache)中是否已经存在该文件的数据。如果有(缓存命中),直接从内存中复制数据到用户缓冲区,
read()调用返回。 - 缓存未命中:如果数据不在缓存中,内核需要真正地从磁盘读取。
- 检查缓存 :内核首先检查页缓存(Page Cache)中是否已经存在该文件的数据。如果有(缓存命中),直接从内存中复制数据到用户缓冲区,
-
-
- IO调度:内核的IO调度器会将这次磁盘读取请求(以及其他可能的请求)进行排序和合并,以优化磁盘IO性能(例如,电梯算法)。
- 与设备驱动交互:内核通过通用块层(General Block Layer)将请求下发到对应的磁盘设备驱动程序。
- 硬件操作:设备驱动程序通过向磁盘控制器发送命令,控制磁盘硬件寻道、旋转、读取数据到控制器的缓冲区,然后再将数据复制到内核的页缓存中。
- 数据拷贝 :最后,内核从页缓存中将数据复制到你的程序提供的用户缓冲区(
buf)。read()调用返回读取的字节数。
-
- 修改数据 :这一步完全在你的程序地址空间(用户态)中进行,内核不参与。你直接操作
read()函数填充的那个缓冲区。 - 写入文件(
write()):
-
- 写入缓存 :
write()系统调用通常不会立即将数据写入磁盘。内核会将你从用户缓冲区(buf)传来的数据复制到页缓存中对应的位置。 - 标记为脏页:被修改的页缓存页面会被标记为"脏页"(Dirty Page)。
- 返回 :
write()调用在数据成功复制到内核缓存后就会返回,而不会等待数据写入物理磁盘。这称为"写回缓存"(Write-Back Caching)策略,极大地提升了写入性能。
- 写入缓存 :
- 后台刷盘:
-
- 内核中有一个专门的内核线程(例如,
pdflush或kswapd的一部分),它会定期地、或者在满足某些条件时(如脏页数量达到阈值、空闲内存不足、或者距离上次刷盘时间过长),将页缓存中的脏页数据批量写入到物理磁盘。这个过程对用户程序是透明的。
- 内核中有一个专门的内核线程(例如,
- 关闭文件(
close()):
-
- 当你调用
close()关闭文件描述符时,内核会做一些清理工作。 - 强制刷盘 :在某些情况下(例如,文件被以
O_SYNC或O_DSYNC标志打开,或者文件系统的sync选项被设置),close()会触发一次对该文件所有脏页的同步写入(即fsync()),确保数据确实已经写入磁盘后才返回。对于普通文件,close()通常不会等待刷盘完成,它只是确保脏页被调度为尽快写入。
- 当你调用
总结:内核在文件读写中扮演了"管家"和"缓存管理者"的角色,它通过页缓存机制减少了对慢速磁盘硬件的直接访问,从而显著提升了整个系统的I/O性能。
问题 2:在代码中直接操作刷盘动作吗?
可以的。操作系统提供了专门的系统调用来让用户程序显式地触发刷盘操作,以保证数据的持久性。
主要有以下几个函数:
fsync(int fd):
-
- 作用 :确保与文件描述符
fd关联的文件的所有脏数据(包括数据和元数据,如修改时间、大小等)都被写入到物理磁盘。 - 特点:这是最常用、最直接的同步函数。它会阻塞调用进程,直到所有数据都安全地写入磁盘。
- 场景 :在数据库、日志系统等需要强数据一致性的应用中,在关键操作(如事务提交、写入一条重要日志)后,必须调用
fsync()来保证即使发生断电等意外,数据也不会丢失。
- 作用 :确保与文件描述符
fdatasync(int fd):
-
- 作用 :与
fsync()类似,但它只确保文件的数据部分被写入磁盘。文件的元数据(metadata)可能不会被同步。 - 特点 :比
fsync()快,因为它减少了一次磁盘写入操作(只写数据,不写元数据)。 - 场景:当你只关心文件内容的持久性,而不太在意文件的最后修改时间等元数据是否及时更新时,可以使用它。
- 作用 :与
sync(void):
-
- 作用:触发整个系统中所有文件系统的所有脏页数据的同步写入。它会强制将所有缓存的文件数据和元数据写入磁盘。
- 特点:是一个非常"重量级"的操作,会严重影响系统性能,因为它会导致大量的磁盘I/O。通常不建议在应用程序中频繁调用。
- 场景:主要用于系统关机前,或者在某些特殊的维护操作前,确保所有数据都已安全落盘。
示例(C语言):
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("data.txt", O_WRONLY | O_CREAT, 0644);
if (fd == -1) { /* error handling */ }
const char* data = "Hello, World!";
write(fd, data, strlen(data));
// 确保数据被写入磁盘
fsync(fd);
close(fd);
return 0;
}
问题 3:TCP编程中服务端的 bind、listen、accept
是的,这些是TCP服务器端编程中最核心的几个系统调用。
bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
-
- 作用 :将一个套接字(由
sockfd指定)与一个特定的网络地址和端口号绑定在一起。 - 场景 :服务器端必须调用
bind。它告诉操作系统:"我这个服务要监听发送到addr这个地址和端口上的所有数据。" 客户端一般不需要调用bind,让操作系统自动选择一个可用的端口即可。 - 参数:
- 作用 :将一个套接字(由
-
-
sockfd:socket()函数返回的套接字文件描述符。addr: 一个指向sockaddr结构体的指针,包含了要绑定的IP地址和端口号。addrlen:addr结构体的大小。
-
listen(int sockfd, int backlog)
-
- 作用 :将一个未连接的套接字(
sockfd)转换为一个被动套接字(监听套接字),使其能够监听客户端的连接请求。 - 场景 :服务器端在
bind之后、accept之前调用。 - 参数:
- 作用 :将一个未连接的套接字(
-
-
sockfd: 已绑定的套接字文件描述符。backlog: 定义了等待接受的连接队列的最大长度。当多个客户端同时连接时,内核会把它们放入这个队列中,等待服务器调用accept来处理。如果队列满了,新的连接请求会被内核拒绝(通常客户端会收到Connection refused错误)。
-
accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)
-
- 作用 :从监听套接字(
sockfd)的连接请求队列中取出一个连接。如果队列中没有连接,accept会阻塞(默认行为),直到有一个客户端连接到达。 - 场景:服务器端的主循环中反复调用,以接受新的客户端连接。
- 参数:
- 作用 :从监听套接字(
-
-
sockfd: 监听套接字的文件描述符。addr: 用于存放客户端地址信息的缓冲区。addrlen: 一个指针,传入时是addr缓冲区的大小,返回时是客户端地址结构体的实际大小。
-
-
- 返回值 :
accept成功后会返回一个新的套接字文件描述符 。这个新的fd代表了与特定客户端的唯一连接。服务器必须使用这个新的fd与客户端进行通信(read/write),而原来的监听套接字(sockfd)则继续用于监听其他新的连接。
- 返回值 :
服务器端典型流程 :
socket() -> bind() -> listen() -> while(1) -> accept() -> read()/write() -> close()
问题 4:TCP编程中客户端的 socket、connect、read/write、close
好的,接下来我们讲解客户端的流程。
相对于服务器端的 bind() -> listen() -> accept() 流程,客户端的流程要简单直接得多,核心目标就是发起一个连接,然后进行数据交互。
客户端的典型流程如下:
socket() -> connect() -> read()/write() -> close()
让我们来详细拆解每一步:
socket(int domain, int type, int protocol)
- 作用:和服务器端一样,客户端首先也需要创建一个套接字(socket)。这个套接字是客户端与服务器通信的端点。
- 场景:客户端程序开始时调用。
- 参数 :与服务器端
socket()调用完全相同。
-
domain: 协议族,如AF_INET(IPv4)。type: 套接字类型,TCP 用SOCK_STREAM。protocol: 通常设为0,让系统根据前两个参数自动选择。
- 返回值 :成功则返回一个套接字文件描述符(
sockfd),失败则返回-1。
示例:
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
if (client_fd == -1) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
- 作用:这是客户端最核心的系统调用。它会主动向服务器端发起一个 TCP 连接请求(即三次握手)。
- 场景 :在
socket()创建成功后调用。 - 参数:
-
sockfd: 由socket()函数返回的客户端套接字文件描述符。addr: 一个指向sockaddr结构体的指针,包含了服务器的 IP 地址和端口号。客户端需要知道它要连接的目标是谁。addrlen:addr结构体的大小。
- 返回值 :成功连接到服务器则返回
0。如果连接失败(例如,服务器地址不存在、端口未监听、连接被拒绝等),则返回-1。
关键点:
- 客户端在调用
connect()时,不需要 提前调用bind()。操作系统会自动为这个客户端套接字选择一个合适的源 IP 地址(通常是出网接口的 IP)和一个随机的、未被使用的源端口号。这简化了客户端的编程。 connect()是一个阻塞调用(默认情况下),它会一直等待,直到与服务器的 TCP 三次握手完成,或者连接尝试超时/失败。
示例:
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080); // 服务器监听的端口
// 将字符串形式的IP地址转换为二进制形式
if (inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr) <= 0) {
perror("invalid server address");
exit(EXIT_FAILURE);
}
// 发起连接
if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("connection failed");
exit(EXIT_FAILURE);
}
printf("Connected to the server successfully.\n");
read()/write()(或send()/recv())
- 作用 :一旦
connect()成功返回,就意味着客户端和服务器之间的 TCP 连接已经建立。此时,客户端就可以使用read()和write()函数(或者recv()和send())通过client_fd这个文件描述符与服务器交换数据了。 - 场景 :
connect()成功之后。 - 与服务器的区别 :客户端只有一个套接字文件描述符(
client_fd)用于所有通信。而服务器则有两个:一个是用于监听的listen_fd,另一个是accept()返回的用于与特定客户端通信的new_client_fd。
示例:
// 发送数据到服务器
const char *message = "Hello, Server!";
write(client_fd, message, strlen(message));
printf("Message sent: %s\n", message);
// 从服务器接收数据
char buffer[1024] = {0};
ssize_t bytes_read = read(client_fd, buffer, sizeof(buffer));
if (bytes_read > 0) {
printf("Message received from server: %s\n", buffer);
} else if (bytes_read == 0) {
printf("Server closed the connection.\n");
} else {
perror("read failed");
}
close(int fd)
- 作用 :当客户端完成所有数据交换后,需要调用
close()来关闭套接字。这会向服务器发送一个 FIN 包,发起 TCP 连接的四次挥手过程,优雅地终止连接。 - 场景 :在
read()/write()操作完成之后,或者在程序退出前。 - 参数:
-
fd: 要关闭的套接字文件描述符,即client_fd。
示例:
close(client_fd);
printf("Connection closed.\n");
总结:客户端与服务器端流程对比
|-------------|---------------------------------------------|---------------------------------------|
| 阶段 | 服务器端 (Server) | 客户端 (Client) |
| 初始化 | socket() | socket() |
| 准备/发起连接 | bind() -> listen() | connect() |
| 建立连接 | accept() (阻塞) | connect() 返回 (阻塞结束) |
| 数据通信 | read() / write() (使用 new_client_fd) | read() / write() (使用 client_fd) |
| 关闭连接 | close(new_client_fd) 和 close(listen_fd) | close(client_fd) |
总而言之,客户端的设计哲学是"发起连接者",而服务器是"被动接受连接者"。这使得客户端的代码通常比服务器端更简洁。
问题 5: 服务端死锁或挂起后,客户端再连接能成功吗?
能,也不能。这取决于服务端"挂起"的具体方式。
我们分两种情况讨论:
- 服务端进程完全阻塞(死锁),但操作系统层面正常:
-
- 情况 :例如,服务端程序进入了一个无限循环,或者因为等待一个永远不会满足的条件而阻塞(死锁)。但它的监听套接字(
sockfd)仍然处于LISTEN状态,内核维护的TCP连接队列也还在。 - 客户端连接 :当一个新的客户端发起
connect()请求时,内核会接收到这个SYN包。由于监听套接字的队列未满,内核会正常地完成TCP三次握手(发送SYN+ACK,等待并确认客户端的ACK)。 - 结果 :从客户端的角度来看,
connect()调用会成功返回 ,客户端认为连接已经建立。然而,服务端的应用程序因为挂起,永远不会调用accept()来从队列中取出这个新建立的连接。这个连接会一直停留在内核的SYN_RECV或ESTABLISHED队列中,直到超时被内核自动关闭。在此期间,客户端向这个"成功"建立的连接写入数据,数据会被内核接收并存入接收缓冲区,但服务端应用永远不会去读取它们。
- 情况 :例如,服务端程序进入了一个无限循环,或者因为等待一个永远不会满足的条件而阻塞(死锁)。但它的监听套接字(
- 服务端进程崩溃或被杀死:
-
- 情况 :服务端进程因为bug、
kill命令等原因退出。 - 内核的处理:当一个进程退出时,操作系统会回收其所有资源,包括它打开的所有文件描述符,其中就包括监听套接字。
- 客户端连接 :当客户端再发起
connect()时,内核在尝试将SYN包递交给对应的监听套接字时,会发现该套接字已经不存在了。 - 结果 :内核会直接向客户端发送一个RST(Reset)包,拒绝这个连接请求。因此,客户端的
connect()调用会失败 ,通常会返回Connection refused(ECONNREFUSED) 错误。
- 情况 :服务端进程因为bug、
总结:
- 如果只是服务端应用程序逻辑挂起 ,内核层面的监听机制还在,那么客户端的
connect()会成功,但后续的通信会因为服务端不处理而失败。 - 如果服务端进程本身崩溃或被终止 ,内核会清理其监听套接字,那么客户端的
connect()会失败,并收到连接被拒绝的错误。
问题 6:往里面写数据能行吗?
这个问题紧接着问题5。
- 如果是问题5中的第一种情况(服务端应用挂起,但连接已建立):
-
- 客户端写入 :能行 。客户端调用
write()或send()时,数据会被成功复制到内核的TCP发送缓冲区(Send Buffer),write()调用会立即返回。 - 数据传输:内核会负责将发送缓冲区中的数据打包成TCP报文段,按照TCP协议的机制(滑动窗口、重传等)发送给服务端。
- 服务端接收:服务端的内核会正确接收这些数据,并将它们放入与该连接对应的TCP接收缓冲区(Receive Buffer)中。
- 问题所在 :服务端的应用程序因为挂起,永远不会调用
read()来读取接收缓冲区中的数据。 - 最终结果:当服务端的接收缓冲区被填满后,内核会根据TCP的流量控制机制,在给客户端的TCP确认报文(ACK)中,将自己的接收窗口大小(Window Size)设置为0。客户端收到后,就知道服务端暂时无法接收更多数据,会停止发送,直到服务端的接收窗口重新打开。如果服务端一直不读取,这个连接就会一直处于这种"卡死"状态,直到一方因为超时(如TCP Keepalive超时)而关闭连接。
- 客户端写入 :能行 。客户端调用
- 如果是问题5中的第二种情况(服务端进程已死):
-
- 客户端写入 :不行 。因为客户端的
connect()已经失败了,它根本没有一个有效的连接来写入数据。如果客户端尝试对一个无效的(已关闭或从未成功建立的)套接字进行write(),会立即返回错误(如EPIPE)。
- 客户端写入 :不行 。因为客户端的
问题 7:如果一直写的话,TCP层是直接丢掉吗?
不会直接丢掉,TCP会尽最大努力确保数据的可靠传输。
当你(客户端)持续调用 write() 向一个对端(服务端)不读取数据的连接发送数据时,发生的情况如下:
- 客户端发送 :你的
write()调用将数据放入客户端内核的发送缓冲区(Send Buffer) 。只要发送缓冲区有空间,write()就会成功返回。 - 服务端接收 :服务端内核正确接收数据,并将其放入该连接的接收缓冲区(Receive Buffer)。
- 接收缓冲区满 :由于服务端应用程序不调用
read(),接收缓冲区会被逐渐填满。 - TCP流量控制(Flow Control):
-
- 当服务端的接收缓冲区快满时,它在给客户端发送的每一个ACK(确认)报文中,会包含一个**接收窗口大小(Receive Window Size)**字段。这个字段会被设置为一个很小的值,甚至是0。
- 客户端收到这个带有小窗口或零窗口的ACK后,会知道服务端暂时"消化不良",需要暂停发送数据。这就是TCP的滑动窗口机制在起作用。
- 客户端阻塞:
-
- 此时,如果你继续调用
write(),客户端内核会尝试将数据放入发送缓冲区。但因为服务端不接收,发送缓冲区的数据也无法被"清空"(只有当数据被对方确认收到后,内核才会从发送缓冲区中移除它)。 - 最终,客户端的发送缓冲区也会被填满。当你再次调用
write()时,write()调用会被阻塞(在默认的阻塞套接字模式下),直到发送缓冲区中有足够的空间容纳新的数据。这个空间只有在服务端开始读取数据,并且其内核发送了带有更大窗口的ACK报文后才会出现。
- 此时,如果你继续调用
- 死锁 :如果服务端永远不读取,客户端的
write()就会永远阻塞下去。
结论 :TCP协议本身不会轻易丢弃数据。它会通过流量控制机制来"节流",阻止发送方淹没接收方。数据丢失通常只发生在更底层(如网络拥塞导致路由器丢包,这会触发TCP的重传机制),或者连接因为超时等原因被重置(RST)。在你描述的场景下,主要问题是阻塞 而非丢包。
问题 8:实现一个LRU需要用什么数据结构?
LRU (Least Recently Used) - 最近最少使用算法。
为了高效实现LRU缓存,需要一种数据结构能够:
- 快速访问:在O(1)时间内获取一个缓存项。
- 快速插入:在O(1)时间内插入一个新的缓存项。
- 快速删除:在O(1)时间内删除一个缓存项(特别是当缓存满时,删除最久未使用的项)。
- 快速更新:在O(1)时间内将一个已存在的缓存项标记为"最近使用"。
标准的实现方式是结合两种数据结构:
- 哈希表 (Hash Table / Dictionary / Map)
-
- 作用 :提供O(1)时间复杂度的查找 操作。哈希表的键是缓存的
key,值是一个指向双向链表节点的指针(或引用)。这个节点存储了key和value。 - 为什么:通过哈希表,我们可以直接定位到对应的缓存项,而不需要遍历。
- 作用 :提供O(1)时间复杂度的查找 操作。哈希表的键是缓存的
- 双向链表 (Doubly Linked List)
-
- 作用 :维护缓存项的访问顺序 ,并提供O(1)时间复杂度的插入 和删除操作。
- 为什么:
-
-
- 每当一个缓存项被访问(无论是
get还是put一个已存在的key),我们可以将它对应的链表节点移动到链表的头部,标记为"最近使用"。 - 当插入一个新的缓存项时,我们创建一个新的链表节点,将它插入到链表的头部。
- 当缓存达到容量上限时,我们需要淘汰最久未使用的项,只需删除链表的尾部节点即可。
- 每当一个缓存项被访问(无论是
-
-
- 为什么是双向链表:因为要从链表中快速地移除一个中间节点(当它被访问或被淘汰时),我们需要知道它的前驱节点和后继节点。双向链表正好提供了这种能力。
工作原理简述:
get(key):
-
- 检查哈希表中是否存在
key。 - 如果不存在,返回-1(或
null)。 - 如果存在,通过哈希表找到对应的链表节点。
- 将该链表节点从其当前位置移除,并重新插入到链表的头部。
- 返回该节点存储的
value。
- 检查哈希表中是否存在
put(key, value):
-
- 检查哈希表中是否已存在
key。 - 如果存在,更新对应链表节点的
value,并将该节点移动到链表头部。 - 如果不存在:
a. 创建一个新的链表节点,存入key和value。
b. 将新节点插入到链表头部。
c. 在哈希表中添加key到新节点的映射。
d. 检查缓存是否超出容量。
e. 如果超出容量,删除链表的尾部节点,并同时从哈希表中删除该key的映射。
- 检查哈希表中是否已存在
在一些编程语言中,可以用更高级的结构来简化实现,例如 Java 的 LinkedHashMap,它内部已经维护了一个双向链表来保证迭代顺序,可以通过重写 removeEldestEntry 方法轻松实现LRU。
问题 9:双向链表是干什么的?
双向链表(Doubly Linked List)是一种常见的线性数据结构。与单向链表不同,双向链表中的每个节点除了包含指向下一个节点的指针(next)外,还包含一个指向前一个节点的指针(prev)。
[prev] <---> [Node A] <---> [Node B] <---> [Node C] <---> [next]
(prev=null) (next=null)
双向链表的主要用途和优势:
- 从任意节点高效地访问前驱和后继:
-
- 这是它最核心的特点。给定一个节点,你不仅可以知道它的下一个节点是什么,还可以立刻知道它的上一个节点是什么。这在需要双向遍历的场景下非常有用。
- 在任意位置高效地插入和删除:
-
- 与单向链表相比,双向链表在已知节点位置的情况下,进行插入和删除操作更高效。
- 删除节点 :假设你有一个节点
X,要删除它,你只需要:
-
-
X.prev.next = X.nextX.next.prev = X.prev
不需要像单向链表那样从头遍历找到前驱节点。
-
-
- 插入节点 :在节点
X后面插入一个新节点Y:
- 插入节点 :在节点
-
-
Y.prev = XY.next = X.nextX.next.prev = YX.next = Y
-
-
- 这些操作的时间复杂度都是O(1),前提是你已经找到了操作的位置。
- 实现更复杂的数据结构:
-
- 双向链表是许多高级数据结构的基础,例如:
-
-
- LRU缓存:如问题8所述,需要快速移动节点到头部和删除尾部节点。
- FIFO队列 和栈:虽然可以用数组实现,但链表实现可以做到动态大小,且插入删除O(1)。
- 双向队列 (Deque - Double-Ended Queue):一种可以在两端都进行插入和删除操作的队列,其高效实现严重依赖双向链表。
- 某些版本的
TreeMap或LinkedHashMap的内部实现。
-
- 更灵活的遍历方式:
-
- 你可以从链表的头节点开始往后遍历,也可以从尾节点开始往前遍历,或者从任意一个中间节点向两个方向扩散遍历。
缺点:
- 额外的内存开销 :每个节点需要额外存储一个
prev指针,相比单向链表,占用了更多的内存空间。 - 稍微复杂的实现 :在进行插入和删除操作时,需要同时维护
prev和next两个指针,代码逻辑比单向链表稍显复杂,更容易出错。
手撕代码:两个升序链表合并成一个降序链表(两种方法)
这里提供两种思路:
- 方法一:先合并成升序,再反转
-
- 这是最直观的思路。利用经典的"合并两个有序链表"算法得到一个升序的新链表,然后再将这个升序链表整个反转,得到降序结果。
- 方法二:直接合并成降序
-
- 在合并的过程中就进行比较,每次选择两个链表当前节点中值较大 的那个节点,将其插入到新链表的头部(或者通过一个 dummy 节点和 prev 指针来构建)。这样可以一步到位得到降序链表。
下面是基于这两种思路的Python代码实现。假设链表节点的定义如下:
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
# 用于打印链表,方便测试
def print_list(head):
current = head
while current:
print(current.val, end=" -> ")
current = current.next
print("None")
# 用于构建链表,方便测试
def build_list(arr):
if not arr:
return None
head = ListNode(arr[0])
current = head
for val in arr[1:]:
current.next = ListNode(val)
current = current.next
return head
方法一:先合并升序,再反转
def merge_two_lists_ascending(l1: ListNode, l2: ListNode) -> ListNode:
"""经典的合并两个升序链表"""
dummy = ListNode(-1)
current = dummy
while l1 and l2:
if l1.val <= l2.val:
current.next = l1
l1 = l1.next
else:
current.next = l2
l2 = l2.next
current = current.next
current.next = l1 if l1 else l2
return dummy.next
def reverse_list(head: ListNode) -> ListNode:
"""反转链表"""
prev = None
current = head
while current:
next_temp = current.next
current.next = prev
prev = current
current = next_temp
return prev
def merge_and_reverse_method(l1: ListNode, l2: ListNode) -> ListNode:
"""方法一:先合并成升序,再反转"""
ascending_head = merge_two_lists_ascending(l1, l2)
descending_head = reverse_list(ascending_head)
return descending_head
方法二:直接合并成降序
def merge_directly_descending(l1: ListNode, l2: ListNode) -> ListNode:
"""方法二:直接合并成降序链表"""
# 使用一个 dummy 节点作为新链表的头,方便操作
dummy = ListNode(-1)
current = dummy
while l1 and l2:
# 每次选择值较大的节点
if l1.val > l2.val:
current.next = l1
l1 = l1.next
else:
current.next = l2
l2 = l2.next
# 将 current 指针向后移动
current = current.next
# 处理剩余的节点
current.next = l1 if l1 else l2
return dummy.next
测试代码
if __name__ == "__main__":
# 测试数据
list1 = build_list([1, 3, 5, 7])
list2 = build_list([2, 4, 6, 8])
print("List 1 (asc):", end=" ")
print_list(list1)
print("List 2 (asc):", end=" ")
print_list(list2)
# 方法一测试
result1 = merge_and_reverse_method(list1, list2)
print("\nResult (desc) - Method 1 (Merge then Reverse):", end=" ")
print_list(result1)
# 重新构建链表,因为上面的操作会破坏原链表
list1 = build_list([1, 3, 5, 7])
list2 = build_list([2, 4, 6, 8])
# 方法二测试
result2 = merge_directly_descending(list1, list2)
print("Result (desc) - Method 2 (Merge directly):", end=" ")
print_list(result2)
# 预期输出 (两种方法结果一致):
# 8 -> 7 -> 6 -> 5 -> 4 -> 3 -> 2 -> 1 -> None
两种方法对比:
- 方法一:代码逻辑清晰,利用了已有的成熟算法,易于理解和实现。但它需要遍历链表两次(一次合并,一次反转)。
- 方法二:更加高效,只需要遍历一次链表即可完成任务。但合并的逻辑需要稍微转个弯,需要习惯"选择较大值"和"构建降序"的思路。
在实际面试中,能够想出并实现方法二,通常会更受青睐,因为它展示了更优的算法设计能力。