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

两种方法对比

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

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

相关推荐
青云交1 小时前
Java 大视界 -- Java 大数据在智能物流无人配送车路径规划与协同调度中的应用
java·spark·路径规划·大数据分析·智能物流·无人配送车·协同调度
d***81722 小时前
解决SpringBoot项目启动错误:找不到或无法加载主类
java·spring boot·后端
p***43482 小时前
Rust网络编程模型
开发语言·网络·rust
NewCarRen2 小时前
汽车网络安全管理系统的需求分析及潜在框架设计
网络·汽车网络安全
捷米研发三部2 小时前
CC-Link转Modbus TCP协议转换网关实现三菱 PLC与传感器通讯在快递分拣中心的应用案例
网络·网络协议
嵌入式-小王2 小时前
每天掌握一个网络协议----ARP协议
网络·网络协议·arp
ᐇ9592 小时前
Java集合框架深度实战:构建智能教育管理与娱乐系统
java·开发语言·娱乐
K***72842 小时前
开源模型应用落地-工具使用篇-Spring AI-Function Call(八)
人工智能·spring·开源
梁正雄2 小时前
1、python基础语法
开发语言·python