Java复习 操作系统原理 计算机网络相关 2025年11月23日

目录

[问题 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:文件读写时操作系统内核的工作

当你从磁盘读取一个文件、修改后再写回去时,操作系统内核大致会执行以下步骤:

  1. 发起系统调用 :你的程序(用户态)通过调用 open()read()write()close() 等函数,将控制权转移到操作系统内核(内核态)。
  2. 读取文件( read()
    • 检查缓存 :内核首先检查页缓存(Page Cache)中是否已经存在该文件的数据。如果有(缓存命中),直接从内存中复制数据到用户缓冲区,read() 调用返回。
    • 缓存未命中:如果数据不在缓存中,内核需要真正地从磁盘读取。
      • IO调度:内核的IO调度器会将这次磁盘读取请求(以及其他可能的请求)进行排序和合并,以优化磁盘IO性能(例如,电梯算法)。
      • 与设备驱动交互:内核通过通用块层(General Block Layer)将请求下发到对应的磁盘设备驱动程序。
      • 硬件操作:设备驱动程序通过向磁盘控制器发送命令,控制磁盘硬件寻道、旋转、读取数据到控制器的缓冲区,然后再将数据复制到内核的页缓存中。
      • 数据拷贝 :最后,内核从页缓存中将数据复制到你的程序提供的用户缓冲区(buf)。read() 调用返回读取的字节数。
  1. 修改数据 :这一步完全在你的程序地址空间(用户态)中进行,内核不参与。你直接操作 read() 函数填充的那个缓冲区。
  2. 写入文件( write()
    • 写入缓存write() 系统调用通常不会立即将数据写入磁盘。内核会将你从用户缓冲区(buf)传来的数据复制到页缓存中对应的位置。
    • 标记为脏页:被修改的页缓存页面会被标记为"脏页"(Dirty Page)。
    • 返回write() 调用在数据成功复制到内核缓存后就会返回,而不会等待数据写入物理磁盘。这称为"写回缓存"(Write-Back Caching)策略,极大地提升了写入性能。
  1. 后台刷盘
    • 内核中有一个专门的内核线程(例如,pdflushkswapd 的一部分),它会定期地、或者在满足某些条件时(如脏页数量达到阈值、空闲内存不足、或者距离上次刷盘时间过长),将页缓存中的脏页数据批量写入到物理磁盘。这个过程对用户程序是透明的。
  1. 关闭文件( close()
    • 当你调用 close() 关闭文件描述符时,内核会做一些清理工作。
    • 强制刷盘 :在某些情况下(例如,文件被以 O_SYNCO_DSYNC 标志打开,或者文件系统的 sync 选项被设置),close() 会触发一次对该文件所有脏页的同步写入(即 fsync()),确保数据确实已经写入磁盘后才返回。对于普通文件,close() 通常不会等待刷盘完成,它只是确保脏页被调度为尽快写入。

总结:内核在文件读写中扮演了"管家"和"缓存管理者"的角色,它通过页缓存机制减少了对慢速磁盘硬件的直接访问,从而显著提升了整个系统的I/O性能。


问题 2:在代码中直接操作刷盘动作吗?

可以的。操作系统提供了专门的系统调用来让用户程序显式地触发刷盘操作,以保证数据的持久性。

主要有以下几个函数:

  1. fsync(int fd)
    • 作用 :确保与文件描述符 fd 关联的文件的所有脏数据(包括数据和元数据,如修改时间、大小等)都被写入到物理磁盘。
    • 特点:这是最常用、最直接的同步函数。它会阻塞调用进程,直到所有数据都安全地写入磁盘。
    • 场景 :在数据库、日志系统等需要强数据一致性的应用中,在关键操作(如事务提交、写入一条重要日志)后,必须调用 fsync() 来保证即使发生断电等意外,数据也不会丢失。
  1. fdatasync(int fd)
    • 作用 :与 fsync() 类似,但它只确保文件的数据部分被写入磁盘。文件的元数据(metadata)可能不会被同步。
    • 特点 :比 fsync() 快,因为它减少了一次磁盘写入操作(只写数据,不写元数据)。
    • 场景:当你只关心文件内容的持久性,而不太在意文件的最后修改时间等元数据是否及时更新时,可以使用它。
  1. 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编程中服务端的 bindlistenaccept

是的,这些是TCP服务器端编程中最核心的几个系统调用。

  1. bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
    • 作用 :将一个套接字(由 sockfd 指定)与一个特定的网络地址和端口号绑定在一起。
    • 场景 :服务器端必须调用 bind。它告诉操作系统:"我这个服务要监听发送到 addr 这个地址和端口上的所有数据。" 客户端一般不需要调用 bind,让操作系统自动选择一个可用的端口即可。
    • 参数
      • sockfd: socket() 函数返回的套接字文件描述符。
      • addr: 一个指向 sockaddr 结构体的指针,包含了要绑定的IP地址和端口号。
      • addrlen: addr 结构体的大小。
  1. listen(int sockfd, int backlog)
    • 作用 :将一个未连接的套接字(sockfd)转换为一个被动套接字(监听套接字),使其能够监听客户端的连接请求。
    • 场景 :服务器端在 bind 之后、accept 之前调用。
    • 参数
      • sockfd: 已绑定的套接字文件描述符。
      • backlog: 定义了等待接受的连接队列的最大长度。当多个客户端同时连接时,内核会把它们放入这个队列中,等待服务器调用 accept 来处理。如果队列满了,新的连接请求会被内核拒绝(通常客户端会收到 Connection refused 错误)。
  1. 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编程中客户端的 socketconnectread/writeclose

好的,接下来我们讲解客户端的流程。

相对于服务器端的 bind() -> listen() -> accept() 流程,客户端的流程要简单直接得多,核心目标就是发起一个连接,然后进行数据交互。

客户端的典型流程如下:

socket() -> connect() -> read()/write() -> close()

让我们来详细拆解每一步:


  1. 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);
}

  1. 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");

  1. 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");
}

  1. 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: 服务端死锁或挂起后,客户端再连接能成功吗?

能,也不能。这取决于服务端"挂起"的具体方式。

我们分两种情况讨论:

  1. 服务端进程完全阻塞(死锁),但操作系统层面正常
    • 情况 :例如,服务端程序进入了一个无限循环,或者因为等待一个永远不会满足的条件而阻塞(死锁)。但它的监听套接字(sockfd)仍然处于 LISTEN 状态,内核维护的TCP连接队列也还在。
    • 客户端连接 :当一个新的客户端发起 connect() 请求时,内核会接收到这个SYN包。由于监听套接字的队列未满,内核会正常地完成TCP三次握手(发送SYN+ACK,等待并确认客户端的ACK)。
    • 结果从客户端的角度来看, connect()调用会成功返回 ,客户端认为连接已经建立。然而,服务端的应用程序因为挂起,永远不会调用 accept() 来从队列中取出这个新建立的连接。这个连接会一直停留在内核的 SYN_RECVESTABLISHED 队列中,直到超时被内核自动关闭。在此期间,客户端向这个"成功"建立的连接写入数据,数据会被内核接收并存入接收缓冲区,但服务端应用永远不会去读取它们。
  1. 服务端进程崩溃或被杀死
    • 情况 :服务端进程因为bug、kill 命令等原因退出。
    • 内核的处理:当一个进程退出时,操作系统会回收其所有资源,包括它打开的所有文件描述符,其中就包括监听套接字。
    • 客户端连接 :当客户端再发起 connect() 时,内核在尝试将SYN包递交给对应的监听套接字时,会发现该套接字已经不存在了。
    • 结果 :内核会直接向客户端发送一个RST(Reset)包,拒绝这个连接请求。因此,客户端的 connect()调用会失败 ,通常会返回 Connection refused (ECONNREFUSED) 错误。

总结

  • 如果只是服务端应用程序逻辑挂起 ,内核层面的监听机制还在,那么客户端的 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() 向一个对端(服务端)不读取数据的连接发送数据时,发生的情况如下:

  1. 客户端发送 :你的 write() 调用将数据放入客户端内核的发送缓冲区(Send Buffer) 。只要发送缓冲区有空间,write() 就会成功返回。
  2. 服务端接收 :服务端内核正确接收数据,并将其放入该连接的接收缓冲区(Receive Buffer)
  3. 接收缓冲区满 :由于服务端应用程序不调用 read(),接收缓冲区会被逐渐填满。
  4. TCP流量控制(Flow Control)
    • 当服务端的接收缓冲区快满时,它在给客户端发送的每一个ACK(确认)报文中,会包含一个**接收窗口大小(Receive Window Size)**字段。这个字段会被设置为一个很小的值,甚至是0。
    • 客户端收到这个带有小窗口或零窗口的ACK后,会知道服务端暂时"消化不良",需要暂停发送数据。这就是TCP的滑动窗口机制在起作用。
  1. 客户端阻塞
    • 此时,如果你继续调用 write(),客户端内核会尝试将数据放入发送缓冲区。但因为服务端不接收,发送缓冲区的数据也无法被"清空"(只有当数据被对方确认收到后,内核才会从发送缓冲区中移除它)。
    • 最终,客户端的发送缓冲区也会被填满。当你再次调用 write() 时,write()调用会被阻塞(在默认的阻塞套接字模式下),直到发送缓冲区中有足够的空间容纳新的数据。这个空间只有在服务端开始读取数据,并且其内核发送了带有更大窗口的ACK报文后才会出现。
  1. 死锁 :如果服务端永远不读取,客户端的 write() 就会永远阻塞下去。

结论 :TCP协议本身不会轻易丢弃数据。它会通过流量控制机制来"节流",阻止发送方淹没接收方。数据丢失通常只发生在更底层(如网络拥塞导致路由器丢包,这会触发TCP的重传机制),或者连接因为超时等原因被重置(RST)。在你描述的场景下,主要问题是阻塞 而非丢包


问题 8:实现一个LRU需要用什么数据结构?

LRU (Least Recently Used) - 最近最少使用算法。

为了高效实现LRU缓存,需要一种数据结构能够:

  1. 快速访问:在O(1)时间内获取一个缓存项。
  2. 快速插入:在O(1)时间内插入一个新的缓存项。
  3. 快速删除:在O(1)时间内删除一个缓存项(特别是当缓存满时,删除最久未使用的项)。
  4. 快速更新:在O(1)时间内将一个已存在的缓存项标记为"最近使用"。

标准的实现方式是结合两种数据结构:

  1. 哈希表 (Hash Table / Dictionary / Map)
    • 作用 :提供O(1)时间复杂度的查找 操作。哈希表的键是缓存的key,值是一个指向双向链表节点的指针(或引用)。这个节点存储了keyvalue
    • 为什么:通过哈希表,我们可以直接定位到对应的缓存项,而不需要遍历。
  1. 双向链表 (Doubly Linked List)
    • 作用 :维护缓存项的访问顺序 ,并提供O(1)时间复杂度的插入删除操作。
    • 为什么
      • 每当一个缓存项被访问(无论是get还是put一个已存在的key),我们可以将它对应的链表节点移动到链表的头部,标记为"最近使用"。
      • 当插入一个新的缓存项时,我们创建一个新的链表节点,将它插入到链表的头部
      • 当缓存达到容量上限时,我们需要淘汰最久未使用的项,只需删除链表的尾部节点即可。
    • 为什么是双向链表:因为要从链表中快速地移除一个中间节点(当它被访问或被淘汰时),我们需要知道它的前驱节点和后继节点。双向链表正好提供了这种能力。

工作原理简述:

  • get(key):
    1. 检查哈希表中是否存在key
    2. 如果不存在,返回-1(或null)。
    3. 如果存在,通过哈希表找到对应的链表节点。
    4. 将该链表节点从其当前位置移除,并重新插入到链表的头部。
    5. 返回该节点存储的value
  • put(key, value):
    1. 检查哈希表中是否已存在key
    2. 如果存在,更新对应链表节点的value,并将该节点移动到链表头部。
    3. 如果不存在:
      a. 创建一个新的链表节点,存入keyvalue
      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)

双向链表的主要用途和优势:

  1. 从任意节点高效地访问前驱和后继
    • 这是它最核心的特点。给定一个节点,你不仅可以知道它的下一个节点是什么,还可以立刻知道它的上一个节点是什么。这在需要双向遍历的场景下非常有用。
  1. 在任意位置高效地插入和删除
    • 与单向链表相比,双向链表在已知节点位置的情况下,进行插入和删除操作更高效。
    • 删除节点 :假设你有一个节点 X,要删除它,你只需要:
      • X.prev.next = X.next
      • X.next.prev = X.prev
        不需要像单向链表那样从头遍历找到前驱节点。
    • 插入节点 :在节点 X 后面插入一个新节点 Y
      • Y.prev = X
      • Y.next = X.next
      • X.next.prev = Y
      • X.next = Y
    • 这些操作的时间复杂度都是O(1),前提是你已经找到了操作的位置。
  1. 实现更复杂的数据结构
    • 双向链表是许多高级数据结构的基础,例如:
      • LRU缓存:如问题8所述,需要快速移动节点到头部和删除尾部节点。
      • FIFO队列:虽然可以用数组实现,但链表实现可以做到动态大小,且插入删除O(1)。
      • 双向队列 (Deque - Double-Ended Queue):一种可以在两端都进行插入和删除操作的队列,其高效实现严重依赖双向链表。
      • 某些版本的TreeMapLinkedHashMap的内部实现。
  1. 更灵活的遍历方式
    • 你可以从链表的头节点开始往后遍历,也可以从尾节点开始往前遍历,或者从任意一个中间节点向两个方向扩散遍历。

缺点

  • 额外的内存开销 :每个节点需要额外存储一个prev指针,相比单向链表,占用了更多的内存空间。
  • 稍微复杂的实现 :在进行插入和删除操作时,需要同时维护prevnext两个指针,代码逻辑比单向链表稍显复杂,更容易出错。

手撕代码:两个升序链表合并成一个降序链表(两种方法)

这里提供两种思路:

  1. 方法一:先合并成升序,再反转
    • 这是最直观的思路。利用经典的"合并两个有序链表"算法得到一个升序的新链表,然后再将这个升序链表整个反转,得到降序结果。
  1. 方法二:直接合并成降序
    • 在合并的过程中就进行比较,每次选择两个链表当前节点中值较大 的那个节点,将其插入到新链表的头部(或者通过一个 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

两种方法对比

  • 方法一:代码逻辑清晰,利用了已有的成熟算法,易于理解和实现。但它需要遍历链表两次(一次合并,一次反转)。
  • 方法二:更加高效,只需要遍历一次链表即可完成任务。但合并的逻辑需要稍微转个弯,需要习惯"选择较大值"和"构建降序"的思路。

在实际面试中,能够想出并实现方法二,通常会更受青睐,因为它展示了更优的算法设计能力。

相关推荐
猷咪6 分钟前
C++基础
开发语言·c++
IT·小灰灰7 分钟前
30行PHP,利用硅基流动API,网页客服瞬间上线
开发语言·人工智能·aigc·php
快点好好学习吧9 分钟前
phpize 依赖 php-config 获取 PHP 信息的庖丁解牛
android·开发语言·php
秦老师Q9 分钟前
php入门教程(超详细,一篇就够了!!!)
开发语言·mysql·php·db
烟锁池塘柳09 分钟前
解决Google Scholar “We‘re sorry... but your computer or network may be sending automated queries.”的问题
开发语言
是誰萆微了承諾9 分钟前
php 对接deepseek
android·开发语言·php
24zhgjx-lxq11 分钟前
华为ensp:MSTP
网络·安全·华为·hcip·ensp
vx_BS8133013 分钟前
【直接可用源码免费送】计算机毕业设计精选项目03574基于Python的网上商城管理系统设计与实现:Java/PHP/Python/C#小程序、单片机、成品+文档源码支持定制
java·python·课程设计
2601_9498683613 分钟前
Flutter for OpenHarmony 电子合同签署App实战 - 已签合同实现
java·开发语言·flutter
ling___xi14 分钟前
《计算机网络》计网3小时期末速成课各版本教程都可用谢稀仁湖科大版都可用_哔哩哔哩_bilibili(笔记)
网络·笔记·计算机网络