腾讯面试题总结(1)
腾讯面试题
一个服务器,有多个用户,每次用户发起请求,给服务器一个整数,服务器判断有没有其他用户已经用同样的数字发起过请求了?
服务器要高效判断用户提交的整数是否已被其他用户提交过,这是一个典型的 重复数据检测 问题,在多用户并发环境下会变得复杂。其核心在于 如何快速存储和查询所有已提交的数字,并在多用户同时请求时保持数据的一致性。
基础检测方法
内存数据结构(适用于单机或数据量可预估)
如果数字范围可控或数据量不大,优先考虑内存方案,速度最快。
-
使用
set
(集合):set
基于哈希实现,其查找和插入的平均时间复杂度为 O(1),效率极高,且自动去重。
优点:实现简单,速度快。
缺点:数据在内存中,服务器重启则数据丢失;若数据量极大(例如上亿个),可能耗尽内存。 -
使用
dict
(字典)扩展:如果你不仅想知道是否重复,还想知道是哪个用户提交的或提交了几次,可以用字典,键为数字,值为相关信息(如用户ID列表或计数)。
数据库(适用于数据需要持久化或规模很大)
当数据量巨大或需要持久化时,数据库是更可靠的选择。
-
关系型数据库 (如 MySQL, PostgreSQL):
- 创建一张表,表中有且仅有一个字段(例如
number
)设置为主键或唯一索引。 - 每次收到新数字,尝试插入该表。
- 如果插入成功,说明是首次提交。
- 如果捕获到主键或唯一索引冲突的错误,说明数字已存在。
优点:数据持久化,不易丢失;利用数据库的强大能力,可处理非常大容量的数据。
缺点:每次检查都需要数据库的 I/O 操作,速度远慢于内存操作,高并发下数据库可能成为瓶颈。
- 创建一张表,表中有且仅有一个字段(例如
-
缓存数据库 (如 Redis):
Redis 的所有数据存储在内存中,速度接近原生数据结构,同时支持数据持久化到硬盘,非常适合这种高频读写的场景。可以使用 Redis 的Set
或String
类型。
优点:性能极高;支持持久化;可以搭建集群扩展能力;天然适合分布式场景。
缺点:需要引入和维护额外的中间件。
应对多用户并发
当多个用户同时提交相同或不同的数字时,必须考虑并发问题,防止数据错乱。
-
加锁(Locking):在执行“检查-写入”这个关键流程时加锁,确保同一时间只有一个请求在进行此操作,从而保证操作的原子性。
- 线程锁:如果服务器是单机多进程/多线程模型,可以使用线程锁(如
threading.Lock
)。 - 分布式锁:如果服务器是多机分布式部署,内存锁失效,需要使用分布式锁(例如通过 Redis 或 ZooKeeper 实现)。
- 线程锁:如果服务器是单机多进程/多线程模型,可以使用线程锁(如
-
数据库原子操作:如果使用数据库,可以利用其原子性操作避免竞态条件。
INSERT
语句:如前所述,利用数据库的唯一约束,插入成功即为新数字,插入失败即为重复。这条语句本身是原子的,无需外部锁。- Redis 的
SETNX
命令:SETNX
(SET if Not eXists)是专为此场景设计的原子命令。只有当键不存在时才会设置值并返回 1;键存在则什么都不做并返回 0。
扩展考量
- 数字的范围和类型:数字是整数,但范围多大?是 32 位还是 64 位?这会影响内存和存储的规划。
- 时间窗口:是否需要永远记录所有数字?通常不需要。可以给记录设置一个过期时间(TTL)。例如,只检查一分钟内是否有重复提交。这在 Redis 中非常容易实现(
SETNX
后调用EXPIRE
,或使用一条语句set(key, 1, nx=True, ex=60)
),能有效控制存储空间。 - 性能与内存的权衡:
- 内存 (
set
) 方案最快,但容量有限。 - Redis 是性能和容量之间的一个优秀折中,支持分布式和高并发。
- 关系型数据库最可靠,但并发读写性能有瓶颈,需要优化。
- 内存 (
- 是否仅需判断存在:如果不需要后续查询具体信息,
set
或 RedisSETNX
足够了。如果需要关联用户信息等,则需dict
或数据库表。
选型建议
场景特征 | 推荐方案 | 原因 |
---|---|---|
快速原型、数据量小、无需持久化 | 内存 set / dict | 实现最简单,速度极快 |
需要持久化、数据量较大、高并发 | Redis | 高性能、支持持久化、原子操作、易设过期时间 |
数据关系复杂、需复杂查询、强一致性 | 关系型数据库 | 利用其完整的事务和查询能力 |
超大规模数据(如数十亿) | 数据库分片或布隆过滤器 (Bloom Filter) | 分库分表应对容量;布隆过滤器以极小空间概略判断是否存在(可能有误判) |
当需要处理超大范围的64位整数并且面临内存不足的问题时。64位整数的范围极其巨大(从 -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807),直接存储所有值会消耗大量内存。
1. 数据压缩与编码
如果数据本身存在一定的模式或重复,压缩是减少内存占用的直接方法。
- 差值编码与位压缩:对于有序且相对密集的64位整数序列(例如自增ID、时间戳),存储相邻数字的差值(Delta Encoding)通常比存储原始数字所需位数少很多。这些差值可以用更少的位(如32位、16位甚至8位)来存储,或者使用变长整数编码(如VInt)。优点是实现相对简单,对有序数据压缩效果好。缺点是如果数据稀疏或无序,效果会打折扣,且需要先解码才能使用。
- 使用更高效的序列化格式:相比纯文本格式(如CSV),使用二进制格式(如Apache Parquet、Apache Avro)通常能提供更高的压缩率和更快的读写速度。这些格式通常列式存储并支持压缩(如Snappy、GZIP),在存储和读取时都能节省空间和I/O。
2. 外部存储与内存映射
当数据量远超物理内存容量时,必须借助磁盘,但可以通过内存映射技术让访问感觉像是在内存中。
- 内存映射文件 (Memory-mapped File):这项技术允许你将一个文件直接映射到进程的虚拟地址空间。操作系统会负责将当前需要的部分数据从磁盘调入物理内存,并将不再使用的部分换出。这样,你可以像操作内存数组一样操作非常大的文件,而无需一次性将其全部加载到内存中。优点是几乎可以处理任意大小的文件,简化了编程模型。缺点是访问速度受磁盘I/O(尤其是随机访问)和系统虚拟内存管理性能的限制,需要SSD硬盘来提升性能。
- 使用数据库或数据格式:对于结构化或半结构化的海量数据,使用专门的数据库或数据格式是更省心的选择。
- HDF5 (h5py):这是一种非常适合存储和组织大规模数值数据的文件格式。它支持分块存储(Chunking)、压缩和部分I/O(只读取需要的部分),非常适合无法全部装入内存的大型数组,尤其是多维数组。 许多科学计算和数据分析领域都在使用它。
- 数据库系统:即使是SQLite这样的轻量级数据库,也能很好地管理超出内存的数据,并提供了方便的查询能力。对于更大规模的数据,可以考虑专业的时序数据库(如果数据与时间相关)或列式存储数据库。
3. 概率型数据结构
当你只需要进行存在性检查(如判断某个数是否在集合中)并且可以接受低概率的误判(False Positive)时,概率型数据结构是极佳选择。
- 布隆过滤器 (Bloom Filter):布隆过滤器是一种空间效率极高的概率型数据结构。 它可能会告诉你“某个元素可能在集合中”(有很小的误判率),但能准确告诉你“某个元素肯定不在集合中”。优点是空间占用非常小,查询时间是常数级 O(k)。缺点是有误判率,不能存储原始数据,也无法删除元素(除非使用计数布隆过滤器变种)。它非常适合用作缓存穿透保护或预检过滤器,避免对不存在的数据进行昂贵的查询。
4. 算法与数据遍历优化
改进数据访问模式和算法本身也能有效降低内存压力。
- 流式处理与分块处理:不要试图一次性将所有数据加载到内存中再处理。而是将数据划分为较小的块(Chunks),逐块地读取、处理和写出结果。优点是大大降低了单次内存消耗,适合顺序处理。缺点是通常不适合需要频繁随机访问全部数据的场景。
- 使用位图 (Bitmap/Bitset):如果你的64位整数集是稠密的(即存在很多连续或接近连续的值),并且主要进行存在性查询,位图是最高效的结构之一。它的基本原理是使用一个比特位(bit)来表示一个整数是否存在。例如,要表示所有可能的32位整数,需要 2³² bit = 512 MB 的内存。对于64位整数,理论上需要 2⁶³ bit(这是一个巨大的数字),但可以通过分段位图、压缩位图(如Roaring Bitmap)或仅存储实际存在的数值范围来优化。 优点是查询速度极快。缺点是对于稀疏数据,基础位图的空间浪费严重,需要压缩等优化手段。
5. 分布式计算框架
当单机无论如何优化都无法处理时,就需要借助多台机器的力量。
- 将数据和计算任务分布到多台机器上并行处理。优点是能处理PB级数据,突破了单机硬件限制。缺点是系统复杂度和运维成本显著增加。
为了帮助你根据场景快速选择,可以参考以下表格:
场景特征 | 推荐方案 | 关键考量 |
---|---|---|
数据有序或可排序,需压缩存储 | 差值编码与位压缩 | 排序是关键预处理步骤,对有序数据压缩率极高。 |
数据远超内存,需像数组一样随机访问 | 内存映射文件 (np.memmap) | 访问性能依赖磁盘IO(推荐SSD),使用方便。 |
海量数值数据,需分块、压缩存储和读取 | HDF5 (h5py) | 非常适合科学计算场景,支持分块和压缩。 |
仅需判断元素是否存在,可接受低概率误判 | 布隆过滤器 (Bloom Filter) | 空间效率极高,绝不漏判,但可能误判。 |
数据密集,存在大量整数,需快速存在性查询 | (压缩)位图 (Bitmap) | 查询极快,但稀疏数据需用Roaring Bitmap等压缩技术避免浪费。 |
需对海量数据进行复杂分析、聚合,单机无法完成 | 分布式计算框架 (Spark, Dask) | 处理能力最强,但系统复杂度和成本最高。 |
综合建议
- 首先分析数据和访问模式:数据是稠密还是稀疏?有序还是无序?主要进行随机访问还是顺序处理?需要精确查询还是可以接受概率性结果?回答这些问题是指南针。
- 从单机优化开始:优先考虑数据压缩、内存映射或分块处理。这些方法通常能解决大部分问题,且复杂度可控。
- 针对性选择高级数据结构:对于存在性检查,布隆过滤器(省空间)和位图(速度快)是利器。
- 考虑分布式:只有当数据规模确实巨大,且单机优化后仍无法满足需求时,再考虑引入分布式系统。
使用什么指令查看端口传输的具体包信息?
查看端口传输的具体数据包信息,主要是为了深入分析网络通信的细节,常用于故障排查、安全分析或性能优化。我来介绍几个常用的方法:
使用 tcpdump
抓取分析(最常用)
tcpdump
是 Linux 系统中强大的命令行网络抓包分析工具,可以详细显示或保存流经指定端口的数据包。
-
基本命令:
sudo tcpdump -i <网卡名> port <端口号> -nn -v
-i <网卡名>
:指定网络接口,如eth0
、ens33
。用any
可捕获所有接口。port <端口号>
:指定要监听的端口。
-nn
:直接以数字形式显示 IP 和端口(不进行 DNS 解析和端口号到服务名的转换)。
-v
(或-vv
、-vvv
):显示更详细的数据包信息。
-
示例:捕获
eth0
网卡上80
端口的流量并显示详细信息sudo tcpdump -i eth0 port 80 -nn -vv
-
高级用法:
- 捕获特定主机和端口:
sudo tcpdump -i any 'host 192.168.1.100 and port 80'
- 将捕获结果保存到文件(便于后续分析):
之后可用sudo tcpdump -i any port 80 -w http_traffic.pcap
tcpdump -r http_traffic.pcap
读取分析,或使用 Wireshark 等图形化工具分析。 - 捕获特定协议:如只捕获 UDP 53 端口(DNS):
sudo tcpdump -i any udp port 53 -nn -v
- 捕获特定主机和端口:
使用 wireshark
或 tshark
(图形化/更强大)
对于更复杂或需要深度分析的情况,Wireshark
(带图形界面)或其命令行版本 tshark
功能更强大。
-
使用
tshark
捕获:sudo tshark -i <网卡名> -f "tcp port 80" -V
-f "过滤表达式"
:指定捕获过滤器。
-V
:显示数据包的完整详情(包括各层报文头)。 -
分析已保存的抓包文件:
tshark -r http_traffic.pcap -Y "tcp.port == 80" -V
-Y <显示过滤器>
:在读取时应用更灵活的显示过滤器。
使用 netstat
或 ss
查看连接状态(基础)
netstat
和 ss
命令主要用于查看端口的连接状态、监听情况及关联的进程,无法提供数据包的具体内容,但可帮助快速识别哪些端口有活动连接。
-
查看所有 TCP/UDP 连接和监听端口(常用组合):
netstat -tulnp
或使用更快的
ss
:ss -tulnp
-t
:TCP;-u
:UDP;-l
:监听中的端口;-n
:数字形式显示;-p
:显示关联的进程信息。 -
筛选特定端口(例如 80):
netstat -an | grep :80
或
ss -an | grep :80
使用 lsof
查看进程打开的端口
lsof
可列出被进程打开的文件,也包括网络连接。可查特定端口被哪个进程使用。
- 查看指定端口(如 80)被哪个进程使用:
sudo lsof -i :80
常用命令对比与选择
命令/工具 | 主要用途 | 优点 | 缺点 |
---|---|---|---|
tcpdump | 详细抓取和分析数据包 | 功能强大、灵活、系统通常自带 | 命令行操作 |
wireshark | 图形化深度包分析 | 界面友好、分析功能极强、支持协议多 | 需图形环境或学会tshark |
tshark | 命令行版的 Wireshark | 兼具强大功能与命令行灵活性 | 参数较多,需学习 |
netstat /ss | 查看连接状态和监听端口 | 简单快速、系统自带 | 无法查看包具体内容 |
lsof | 查看端口与进程关系 | 方便定位占用端口的进程 | 不显示包内容 |
简单建议
- 想快速看哪个进程用了端口,用
ss -tulnp | grep :端口号
或sudo lsof -i :端口号
。 - 需要详细分析流经端口的数据包内容、排查网络故障或进行安全分析,用
sudo tcpdump -i any port 端口号 -nn -v
。 - 需要对抓取的数据包进行深度解析、统计、可视化或长期分析,请将
tcpdump
输出保存为.pcap
文件后用 Wireshark 分析。
注意:
- 执行抓包命令通常需要
sudo
或root
权限。- 抓包可能会捕获到敏感信息,请在获得授权的情况下对相关网络进行操作。
- 生产环境抓包时注意控制数据量,避免磁盘空间被快速占满。
假设现在系统网络流量被某几个进程大量占用了,应该怎样解决?
参考下面的路线图:
flowchart TDA[系统网络流量异常升高] --> B[第一步:诊断与识别<br>使用工具定位高流量进程与IP]B --> C{第二步:评估与决策}C -- 异常/恶意进程 --> D[立即终止进程<br>(kill 命令)]C -- 正常业务进程 --> E[考虑升级网络带宽]C -- 非关键正常进程 --> F[第三步:实施限制<br>使用工具限制带宽]D --> G[第四步:安全扫描<br>检查病毒与恶意软件]E --> H[长期监控与优化]F --> HG --> HH --> I[问题解决]subgraph B [诊断工具选择]B1[nethogs<br>按进程查看流量]B2[iftop<br>按连接/IP查看流量]endsubgraph F [限制工具选择]F1[trickle<br>(单进程限速)]F2[tc<br>(基于接口的流量整形)]F3[防火墙规则<br>(屏蔽或限速特定IP)]end
下面是各步骤的详细操作方法:
第一步:诊断与识别高流量进程
-
使用
nethogs
:按进程查看实时流量
nethogs
能直接显示每个进程的网络带宽占用情况,非常直观。- 安装 (Ubuntu/Debian):
sudo apt install nethogs
- 使用:
sudo nethogs
- 查看结果:运行后,你会看到一个动态更新的表格。重点关注
SENT
(发送) 和RECEIVED
(接收) 列,它们显示了每个进程实时的上行和下行带宽(默认单位 KB/s)。按s
可按发送流量排序,按r
可按接收流量排序,方便你快速找到占用最高的进程。
- 安装 (Ubuntu/Debian):
-
使用
iftop
:按 连接/IP 查看流量明细
如果nethogs
告诉你某个进程流量大,iftop
则可以进一步查看这个进程具体是和哪些 IP 地址在进行通信。- 安装 (Ubuntu/Debian):
sudo apt install iftop
- 使用:
(sudo iftop -n -P
-n
不解析主机名,直接显示IP,加快速度;-P
显示端口号) - 查看结果:
iftop
界面会显示多个连接,清楚地列出每一条连接的源IP、目标IP以及实时带宽占用。这对于判断是内网同步、对外下载还是异常外连非常有帮助。
- 安装 (Ubuntu/Debian):
第二步:应急处理与长期控制
识别出高流量进程后,根据其性质决定处理方式:
-
终止异常或恶意进程:
如果确认某个进程是异常、未知或恶意的(例如通过iftop
发现它连接到一个可疑的外网IP),最直接的方法是终止它。kill -9 <PID> # <PID> 是 nethogs 或 iftop 中显示的进程ID
-
限制非关键正常进程:
对于一些非关键但又需要运行的进程(如个人云盘同步、大文件下载),你可以限制其带宽,而不是完全禁止它。- 使用
trickle
:
trickle
是一个轻量级工具,可以在命令启动时直接限制其网络带宽。trickle -d 100 -u 20 wget http://example.com/large.file # -d 100: 限制下载速度不超过 100 KB/s # -u 20: 限制上传速度不超过 20 KB/s
- 使用
tc
(Traffic Control):
tc
是 Linux 内核自带的强大流量控制工具,可以进行更复杂和系统级的限速配置(例如限制整个网卡或特定IP段的速率),但配置相对复杂。
- 使用
-
应对正常业务流量或网络攻击:
- 如果高流量是由正常的业务增长或访问量增加引起的(例如通过
iftop
发现大量来自真实客户端的连接),那么最根本的解决方案是联系服务器提供商或网络管理员,升级服务器的网络带宽。 - 如果通过
iftop
发现流量来自少量特定IP,且怀疑是恶意攻击或爬虫,可以使用iptables
防火墙规则来临时屏蔽或限制这些IP的访问。
- 如果高流量是由正常的业务增长或访问量增加引起的(例如通过
第三步:安全检查
系统流量异常有时也可能是安全问题的征兆。
- 在处理完高流量进程后,建议运行安全扫描,检查系统是否存在病毒或木马。
sudo apt install clamav # 安装ClamAV杀毒软件 sudo freshclam # 更新病毒库 sudo clamscan -r / # 全盘扫描(耗时较长)
- 检查并确保防火墙(如
ufw
或iptables
)已启用且配置正确,关闭不必要的网络端口。
第四步:长期监控与优化
- 定期检查:可以定期使用
nethogs
或iftop
检查系统网络状态,将其作为一种习惯。 - 配置告警:对于服务器,可以配置系统监控工具(如
netdata
,zabbix
),当网络流量持续超过阈值时自动发送告警通知。 - 优化应用程序:对于自己部署的服务,检查其配置,例如调整下载/上传速率限制、并发连接数等,从源头减少带宽压力。
stl中有几种map?multimap和map的区别在什么地方?map是有序的吗?multimap呢?
C++ STL 提供了四种主要的 map 容器,它们在键的唯一性和元素有序性上有所不同。
STL 中的四种 map 容器
容器类型 | 是否有序 | 键是否唯一 | 底层实现 | 时间复杂度 (查找/插入/删除) | 适用场景举例 |
---|---|---|---|---|---|
std::map | 是 | 是 | 红黑树 | O(log n) | 字典、配置项、需要有序遍历的键值对 |
std::multimap | 是 | 否 | 红黑树 | O(log n) | 事件调度(同一时间多个事件)、一对多关系 |
std::unordered_map | 否 | 是 | 哈希表 | O(1)平均, O(n)最坏 | 缓存、快速查找、词频统计 |
std::unordered_multimap | 否 | 否 | 哈希表 | O(1)平均, O(n)最坏 | 分类商品列表、图的邻接表(允许重复) |
multimap 与 map 的核心区别
multimap
和 map
最本质的区别在于 是否允许重复的键(Key),由此衍生出一些操作上的不同。
特性 | std::map | std::multimap |
---|---|---|
键的唯一性 | 键是唯一的,插入相同键会覆盖原有值。 | 允许重复的键,可以存储多个相同键的键值对。 |
下标操作符 [] | 支持,可用于访问或插入元素。 | 不支持,因为无法确定返回多个相同键值对中的哪一个。 |
插入操作返回值 | 返回 pair<iterator, bool> ,bool 表示插入是否成功。 | 返回指向新插入元素的迭代器(总是成功)。 |
查找单一键的结果 | 使用 find 返回唯一元素的迭代器或 end() 。 | 使用 find 返回第一个匹配键的迭代器。 |
获取键的所有值 | 不需要,一个键只对应一个值。 | 需使用 equal_range(k) 获取匹配键的范围迭代器对,然后遍历。 |
有序性说明
std::map
是有序的:它基于红黑树实现,元素会按照键(Key)的升序(默认)或自定义的比较规则进行自动排序和存储。std::multimap
也是有序的:同样基于红黑树,元素也按照键的排序规则存储。重复的键会相邻存储,但它们的插入顺序可能并不被保留。std::unordered_map
和std::unordered_multimap
是无序的:它们基于哈希表实现,元素顺序由哈希函数决定,不保证任何特定的顺序。
volatile关键字是什么意思?如果现在有5个线程,和一个变量,一个线程修改变量,另外4个线程只读取不修改,那么加不加volatile有什么区别?
volatile关键字的主要作用是确保变量的可见性和防止指令重排序。
下面是一个表格,帮你快速了解核心差异:
特性 | 不加 volatile | 加 volatile |
---|---|---|
可见性 | 无法保证。读线程可能一直读取自己CPU缓存中的旧值,看不到写线程的修改。 | 有保证。写操作立即刷新到主内存,并使其他CPU核心中的缓存副本失效,强制读线程下次访问时从主内存重新加载最新值。 |
指令重排序 | 无法阻止。编译器或处理器可能为了优化而重排指令顺序,可能导致意想不到的行为。 | 有效防止。通过插入内存屏障(Memory Barrier),确保指令不会被重排序到该变量的读写操作之外。 |
性能影响 | 理论上更高(利用了缓存) | 理论上稍低(每次读写都直接操作主内存,阻止了某些优化) |
适用性 | 不适用于此场景,会导致数据不一致 | 非常适合此场景 |
重要的补充说明
- 不保证原子性 (Atomicity):
volatile
能保证单次读或写操作是原子的,但对于某些操作(如count++
,它本质是读-改-写三个步骤),它不能保证原子性。 - 其他应用场景:
volatile
的另一个经典用途是在双重检查锁定(Double-Checked Locking) 单例模式中,以防止指令重排序导致其他线程获取到未完全初始化的对象。
100万个user_key,user_score键值对,找到user_score最大的1万个?优先队列如何实现的?
使用优先队列(通常基于堆实现)是一种非常高效且经典的方法。
1. 为什么使用优先队列(堆)?
处理TopK问题(例如从海量数据中找出最大或最小的K个元素)时,完全排序(时间复杂度为O(nlogn))在数据量巨大时效率低下。而优先队列(堆)可以将时间复杂度优化到O(nlogK),这通常要优越得多,尤其是当K远小于n时(例如,从100万中找1万,K=10000, n=1000000)。
其核心思想是维护一个大小为K的堆,仅保存当前检测到的TopK元素,避免处理全部数据。
2. 选择大顶堆还是小顶堆?
你需要找出最大的K个值,应使用小顶堆(Min-Heap)。
- 原因:小顶堆的堆顶是堆中最小的元素。遍历数据时,若当前元素的分数大于堆顶元素(当前TopK集合中的最小值),则替换掉堆顶元素并调整堆结构。这样,堆中始终保留的是截至目前遇到的最大的K个元素。
- 若寻找最小的K个元素,则需使用大顶堆(Max-Heap)。
3. 算法步骤与实现
以下是使用小顶堆求 user_score
最大的1万个 user_key
的基本步骤:
- 初始化:创建一个最小堆(优先队列),并指定其容量为 K(10000)。
- 遍历键值对:逐个处理100万个
user_key
和user_score
键值对。- 如果堆的大小小于 K,直接将当前键值对加入堆中。
- 如果堆已满(即已有 K 个元素),则比较当前元素的
user_score
与堆顶元素的user_score
。- 如果当前
user_score
大于堆顶元素的user_score
,则弹出堆顶元素(当前堆中最小的分数),并将当前键值对插入堆中。 - 如果当前
user_score
小于或等于堆顶元素的user_score
,则跳过该元素。
- 如果当前
- 获取结果:遍历完所有数据后,堆中剩余的 K 个键值对就是最大的 K 个
user_score
及其对应的user_key
。
复杂度分析
- 时间复杂度:O(n log K)。你需要遍历 n (100万) 个元素,每次堆操作(插入或删除)的时间复杂度为 O(log K),因为堆的大小最大为 K (1万)。
- 空间复杂度:O(K)。只需在内存中维护一个大小为 K 的堆。
4. 注意事项
- 内存使用:虽然堆本身只存储K个元素,但如果你需要最终输出完整的键值对,而不仅仅是键或值,请确保堆中存储的是必要的信息。
- 重复分数:如果有多个相同的
user_score
,上述算法会保留最先遇到的一部分(取决于具体的比较和替换逻辑)。如果需要对相同分数的元素有特定的处理规则(例如,按user_key
的字典序 break tie),你需要在比较时相应地修改比较器。 - 结果顺序:堆输出的顺序可能不是排序后的。通常,堆内部只保证堆序性质,不保证整个堆的有序性。如果你需要最终结果按
user_score
降序排列,可能需要对堆中剩余的元素进行排序(这通常代价很小,因为只有K=1万个元素)。 - 边界情况:注意处理 K=0 或输入数据量小于 K 的情况。
总结
使用优先队列(小顶堆)是解决此类TopK问题的高效且标准的方法。其优势在于无需将全部数据加载到内存并排序,只需维护一个较小的堆,非常适合处理海量数据。
给定N个数组,每次挑若干个数组合并,求合并完数组的第k小的数
寻找合并N个有序数组后所得数组的第k小元素,是一个涉及多路归并和高效选择算法的问题。由于每个数组本身有序,我们可以利用这一特性来设计算法,避免完全合并所有数组再排序的低效做法。
方法一:多指针比较法(模拟归并)
这种方法模拟多路归并过程,无需真正合并全部数据。
- 核心思想:为每个数组维护一个指针,指向当前尚未被处理的最小元素。每次循环都在所有数组的当前指针所指元素中找出最小值,然后将该最小值所属数组的指针后移一位。重复此过程 k 次,第 k 次选出的最小值即为所求的第 k 小元素。
- 时间复杂度:O(k * N)。每次选择最小值需要比较 N 个数组的当前元素(共 N 次比较),进行 k 轮。
- 空间复杂度:O(N)。主要用于存储每个数组的当前指针位置。
- 优点:思路直观,无需额外空间。
- 缺点:当 k 非常大时,效率较低。
🗃️ 方法二:最小堆(优先队列)优化
此方法通过最小堆优化每次寻找最小值的操作。
- 核心思想:使用一个最小堆(优先队列)来动态维护每个数组的当前最小元素。堆中元素通常需要记录三部分信息:元素值、所属数组的索引、以及该元素在所属数组中的位置索引(以便获取下一个元素)。
- 算法步骤:
- 初始化一个最小堆
min_heap
。 - 将每个数组的第一个元素(及其所属数组索引和在数组中的索引信息)加入最小堆。
- 循环 k 次:
- 弹出堆顶元素(当前最小值)。
- 如果这是第 k 次弹出,则该元素即为第 k 小元素,算法结束。
- 否则,从被弹出元素所在的数组中,取出下一个元素(索引加一),并将其值及相关信息加入最小堆。如果该数组已无下一个元素,则跳过。
- 第 k 次弹出的元素即为答案。
- 初始化一个最小堆
- 时间复杂度:O(k * log N)。每次堆的插入和删除操作时间复杂度为 O(log N),共进行 k 次。
- 空间复杂度:O(N)。堆中最多同时存储 N 个元素(每个数组一个当前元素)。
- 优点:相比指针比较法,在 N 较大时能显著提高效率。
- 缺点:需要维护堆结构。
方法对比与选择
方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
---|---|---|---|
多指针比较法 | O(k * N) | O(N) | k 较小或 N 不大的情况,实现简单 |
最小堆法 | O(k * log N) | O(N) | 最通用、高效的选择,尤其适合N较大的情况 |
二分查找法 | O(N * log(range)) | O(1) | 元素范围已知且范围不大,适用于海量数据场景 |
二分查找法在解决TopK问题(尤其是在有序矩阵或有序数组中寻找第K小的元素)时,提供了一种非常高效且空间复杂度低的解决方案。下面我们来详细了解一下它的核心思想、实现细节以及相关考虑。
核心思想:对值域进行二分
二分查找法用于TopK问题时,其独特之处在于它并不直接对数据本身进行排序或遍历,而是对数据的值域范围进行二分搜索。
- 确定搜索范围:首先,找到整个数据集中的最小值(
low
)和最大值(high
)。第K小的元素一定位于[low, high]
这个区间内。 - 猜测中间值:计算中间值
mid
。这个mid
是我们对第K小元素的一个“猜测”。 - 统计验证:关键的一步是统计数据集中有多少个元素小于等于这个猜测值
mid
,记为count
。 - 调整搜索区间:
- 如果
count < K
,说明我们猜小了,第K小的元素实际比mid
大。于是我们将搜索区间的下限low
调整为mid + 1
。 - 如果
count >= K
,说明我们猜大了,或者mid
可能就是第K小的元素(或者等于它的元素之一)。这时我们将搜索区间的上限high
调整为mid - 1
,并记录当前的mid
为一个潜在的答案。
- 如果
- 循环收敛:重复步骤2-4,直到
low
大于high
。最后一次使count >= K
的mid
就是我们要找的第K小的元素。
关键实现细节
1. 统计小于等于mid的元素个数(Count函数)
高效实现 count
函数是优化整个算法的关键。对于有序矩阵,可以利用其行和列的有序性(每行递增,每列递增)进行高效统计,而无需遍历所有元素。
一个巧妙的方法是从矩阵的右上角(或左下角)开始扫描:
- 以右上角为例(假设矩阵为
n x n
):- 初始化位置为
(0, n-1)
,计数器count = 0
。 - 如果当前元素
matrix[i][j] <= mid
:- 由于这一行是递增的,当前元素以及它左边所有的元素肯定都小于等于
mid
。因此,这一行本次有效的元素个数是j + 1
。 - 将这些数量
(j + 1)
加入count
。 - 然后向下移动一行(
i++
),检查下一行。
- 由于这一行是递增的,当前元素以及它左边所有的元素肯定都小于等于
- 如果当前元素
matrix[i][j] > mid
:- 由于这一列是递增的,当前元素以及它下方所有的元素肯定都大于
mid
。因此,这一列都可以排除。 - 于是我们向左移动一列(
j--
),继续检查。
- 由于这一列是递增的,当前元素以及它下方所有的元素肯定都大于
- 重复上述过程,直到行或列索引超出边界。
- 初始化位置为
这个过程的时间复杂度是 O(n),因为每行或每列最多被排除一次,总共最多扫描 2n
步。
2. 二分查找的结束条件
标准的二分查找采用循环 while (low <= high)
。当循环结束时(low > high
),第K小的元素就是上一次循环中记录的潜在答案(即 count >= K
时的那个 mid
),或者就是此时的 low
。
时间复杂度分析
- 二分查找的迭代次数取决于值域的范围
range = high - low
,每次迭代将范围减半,故迭代次数为 O(log(range))。 - 每次迭代中,
count
函数的复杂度为 O(n)(针对有序矩阵)。 - 因此,总的时间复杂度为 O(n * log(range))。
与其他方法对比
方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
---|---|---|---|---|
二分查找法 | O(n * log(range)) | O(1) | 空间效率极高,无需额外数据结构 | 思路相对巧妙,需要对值域二分有理解 |
堆(优先队列)法 | O(K log n) | O(n) | 思路直观,易于理解 | 需要维护一个大小为n的堆,空间占用较高 |
直接排序法 | O(n² log n²) | O(n²) | 实现简单 | 时间和空间复杂度最高,通常不可取 |
注意事项和边界条件
- 重复元素:二分查找法能够很好地处理重复元素。
count
函数统计的是“小于等于”,重复值会被正确计数。 - K 的有效性:需要确保 K 在有效范围内(1 ≤ K ≤ 元素总数)。
- 值域范围:
range
的大小会影响二分查找的迭代次数。如果range
非常大(例如元素值非常分散),log(range)
可能会比较大。 - 计数函数:实现高效的
count
函数是核心,务必保证其正确性。对于有序矩阵,从右上角或左下角开始的线性扫描是最佳选择。
一个公司有W元给n个员工发年终奖,给定每个员工的奖金下限x[i],上限y[i],求最大的奖金中位数能有多少
中位数是排序后奖金序列的中间值,对于偶数个员工,中位数是中间两个值的平均值;对于奇数个员工,中位数是中间值。为了最大化中位数,我们需要确保在满足总奖金约束的前提下,尽可能使中间位置的奖金值最大。
方法思路
- 问题分析:我们需要分配奖金使得总奖金不超过W,同时每个员工的奖金在其给定范围内。中位数的最大值取决于如何分配奖金,使得中间位置的奖金尽可能大,同时其他员工的奖金尽可能小以节省总奖金。
- 关键 insight:中位数至少为M的条件是至少有
floor(n/2) + 1
个员工的奖金至少为M。这是因为对于奇数n,中位数是第(n+1)/2
个值;对于偶数n,中位数是第n/2
和n/2+1
个值的平均值,确保至少floor(n/2) + 1
个员工奖金≥M保证了中位数≥M。 - 二分搜索:我们使用二分搜索来找到最大的中位数。搜索范围从
2 * min_x
到2 * max_y
,其中min_x
和max_y
分别是所有员工奖金下限的最小值和上限的最大值。对于每个中间值a
,我们计算M = ceil(a/2)
,并检查是否可能至少有k = floor(n/2) + 1
个员工奖金≥M,且总奖金≤W。 - 可行性检查:对于给定的M,检查是否存在至少k个员工其奖金上限≥M。对于这些员工,如果奖金下限≥M,则无需额外成本;否则需要额外支付
M - x[i]
。选择额外成本最小的员工以最小化总奖金。
有n个商品,每个价值w[i],再给一个整数m,挑选若干个商品,求这些商品价值总和是m的倍数时的最大值
您的问题是一个经典的动态规划问题,类似于背包问题的变体,目标是选择若干商品,使得它们的总价值是给定整数 mmm 的倍数,并且总价值最大。下面我将基于动态规划的原理为您提供解决方案,并参考了搜索结果中的背包问题实现思路。
方法思路
- 问题分析:需要从 nnn 个商品中选择一个子集,使得子集的总和是 mmm 的倍数,且总和最大。这可以转化为一个模 mmm 下的背包问题,其中状态记录的是余数而非绝对容量。
- 关键 Insight:使用动态规划,定义状态
dp[r]
表示当前考虑商品后,总价值模 mmm 余数为 rrr 时能达到的最大总价值。初始化时,dp[0] = 0
(余数为0时总和为0),其他状态初始化为一个极小值(表示不可达)。 - 状态转移:对于每个商品,遍历所有可能的余数 rrr(0 到 (m-1))。如果当前余数 rrr 可达,则考虑选择该商品:新余数
new_r = (r + w[i]) % m
,新总价值为dp[r] + w[i]
,并更新dp[new_r]
为最大值。 - 最终结果:处理完所有商品后,
dp[0]
即为总和是 mmm 的倍数时的最大价值。
这种方法的时间复杂度为 O(n×m)O(n \times m)O(n×m),空间复杂度为 O(m)O(m)O(m)。如果 mmm 很大,可能需要优化,但对于一般情况可行。
注意事项
- 负数处理:如果商品价值有负数,需要调整模运算。
- 大 mmm 情况:如果 mmm 非常大(例如超过 10610^6106),而 nnn 也很大,算法可能较慢。但对于一般问题,此方法有效。
当商品价值允许为负数时,动态规划的状态转移过程需要一些特殊的调整来处理负值带来的影响,主要是为了避免数组下标越界并确保状态转移的正确性。核心的调整策略、注意事项以及一个示例性修改方案如下。
调整方面 | 关键点 | 经典做法 (非负价值) |
---|---|---|
状态表示 | 引入偏移量 (Offset) P 将实际可能为负的“总和”映射到非负的数组索引上 | 直接使用目标值作为下标 |
遍历顺序 | 根据商品重量的正负调整内层循环的遍历方向 | 内层循环固定为逆序 |
初始化 | 通常需要将 dp 数组初始化为一个非常小的数 (负无穷),并将起点状态 dp[P + 0] 设为 0 | 通常初始化为0或负无穷,但下标从0开始 |
最终答案提取 | 在平移后的数组范围内寻找最优值 | 在 dp[0..V] 中寻找最大值 |
核心调整策略
-
状态重新定义与偏移量 (Offset):
由于价值可能为负,传统的dp[j]
(表示容量为j
时的最大价值)中的j
可能变为负数,这会导致数组下标越界。解决方法是为所有可能的总重量(或总价值,取决于你的状态定义)引入一个偏移量 (Offset)P
,将一个原本可能为负的值x
映射到数组索引x + P
上,确保索引非负。这个P
需要足够大,以覆盖所有可能的负总和。 -
遍历顺序的调整:
在标准的01背包问题中,为了确保每个物品只被使用一次,内层循环(遍历容量)是从大到小(逆序)进行的。但当物品的重量为负数时,状态转移方程中的j - w[i]
会变得比j
更大。如果仍然逆序遍历,在计算dp[j]
时,dp[j - w[i]]
可能已经被当前物品更新过,这违反了每个物品只使用一次的原则。因此,对于重量为负的物品,内层循环需要改为从小到大(正序)遍历。 -
初始化调整:
由于存在负价值,最大价值可能是负数,因此通常需要将dp
数组初始化为一个极小的数值(例如负无穷),以表示某些状态是不可达或无效的。只有基准状态(例如什么都不选,总重量为0,总价值为0)应初始化为0,并对应到偏移后的位置dp[P + 0]
。
调整示例
假设有一个背包问题,物品数量为 n
,背包容量为 V
,每个物品的重量为 w[i]
(可能为负),价值为 v[i]
(可能为负)。我们定义 P
为一个足够大的偏移量,例如所有负重量的绝对值之和可能达到的最小负值。
# 假设的最小可能总和(负方向)和最大可能总和(正方向)
min_sum = -100000 # 你需要根据实际数据估算这个值
max_sum = 100000 # 你需要根据实际数据估算这个值
P = -min_sum # 偏移量,使得 min_sum + P = 0
N = max_sum - min_sum + 1 # dp数组所需的总长度# 初始化dp数组,全部设置为一个非常小的数(负无穷),表示不可达
dp = [-10**18] * (N)
dp[P + 0] = 0 # 基准状态:总重量为0时,价值为0# 遍历所有物品
for i in range(n):if w[i] >= 0:# 对于非负重量的物品,采用逆序遍历for j in range(max_sum, min_sum - 1, -1): # 实际遍历的是索引范围,注意转换idx_j = j + Pidx_j_minus_w = j - w[i] + Pif min_sum <= j - w[i] <= max_sum: # 检查边界if dp[idx_j_minus_w] != -10**18: # 检查状态是否可达dp[idx_j] = max(dp[idx_j], dp[idx_j_minus_w] + v[i])else:# 对于负重量的物品,采用正序遍历for j in range(min_sum, max_sum + 1): # 实际遍历的是索引范围,注意转换idx_j = j + Pidx_j_minus_w = j - w[i] + Pif min_sum <= j - w[i] <= max_sum: # 检查边界if dp[idx_j_minus_w] != -10**18: # 检查状态是否可达dp[idx_j] = max(dp[idx_j], dp[idx_j_minus_w] + v[i])# 最终,在合理的总重量范围内(例如0到V)寻找最大价值
# 注意:总重量j需要满足 0 <= j <= V,对应的数组索引是 P + j
max_value = -10**18
for j in range(0, V + 1):idx = P + jif dp[idx] > max_value:max_value = dp[idx]
print(max_value)
注意事项
- 偏移量
P
的选取:P
必须足够大,使得j + P
永远不会是负数。通常P
可以取所有负重量绝对值之和的可能最大值,或者根据问题给定的数据范围确定。 - 循环边界:在循环中,需要确保计算的索引
j - w[i] + P
在dp
数组的有效范围内(0
到N-1
),避免越界。 - 状态可达性检查:在更新状态前,务必检查前驱状态
dp[j - w[i] + P]
是否是可达的(即其值不为初始的负无穷),否则会导致错误的状态转移。 - 最终答案的解读:由于引入了偏移量,最终答案是在
dp[P + j]
(j
为实际总重量)中寻找。你需要根据问题约束(比如背包容量限制V
)来确定需要检查的j
的范围。 - 性能影响:引入偏移量会显著增加
dp
数组的大小(从V+1
增加到(max_sum - min_sum + 1)
),可能会增加空间和时间复杂度。需要确保计算资源足够。
总结
处理价值为负的背包问题,关键在于引入偏移量处理负下标,并根据重量正负调整遍历顺序。虽然调整稍显复杂,但通过谨慎处理边界和状态转移,动态规划依然能够有效求解。
某一个大文件被拆成了N个小文件,相应大小分别记为S[i]。给定磁盘空间为C,从N个文件中选出若干个文件拷贝到磁盘中,使得磁盘剩余空间最小
这道题一题两用,分选出的文件必须连续以及可以不连续两种情况讨论,文件连续时用滑动窗口遍历一遍,不连续时则是01背包问题.
“背包问题”的变种:给定一个固定容量的磁盘(容量 C)和 N 个文件(每个文件大小 S[i]),需要选择一部分文件拷贝到磁盘中,使得这些文件的总大小不超过 C,并且尽可能接近 C(即剩余空间最小)。这使得剩余空间最小化。
方法选择:动态规划(0-1 背包问题变种)
- 背包容量:磁盘空间 C。
- 物品:每个文件,其“重量”为 S[i],“价值”也视为 S[i](因为我们的目标是最大化总大小,从而最小化剩余空间)。
- 目标:选择文件子集,使得总大小尽可能接近 C(即最大化总大小,但不超过 C)。
动态规划是解决此问题的最直接方法,它能找到精确解,但时间复杂度为 O(N×C)O(N \times C)O(N×C),适用于 C 不是特别大的情况(例如,C 在几万以内)。如果 N 或 C 非常大,可能需要考虑贪心或近似算法。
解决步骤:动态规划详解
1. 定义状态
- 创建一个布尔数组
dp
,长度为C+1
。dp[j]
表示是否存在一个文件子集,使得总大小恰好等于j
(true
表示可达)。 - 初始化:
dp[0] = true
(总大小为 0 总是可达),其他为false
。
2. 状态转移
- 对于每个文件大小
S[i]
(从第一个文件到最后一个文件),从后往前更新dp
数组(避免重复选择同一文件):- 对于
j
从C
向下到S[i]
:- 如果
dp[j - S[i]]
为true
,则设置dp[j] = true
(表示选择当前文件后,总大小j
可达)。
- 如果
- 对于
3. 寻找最优解
- 遍历结束后,从
C
向下遍历dp
数组,找到最大的j
(≤ C)使得dp[j] = true
。 - 此时,最小剩余空间为
C - j
,所选文件的总大小为j
。
4. 记录具体文件选择(可选)
如果需要知道具体选择了哪些文件,可以维护一个额外的数组 path
,记录每个状态 j
是由哪个文件添加而来的(通常通过记录决策路径实现)。
伪代码示例
def minimize_remaining_space(S, C):n = len(S)dp = [False] * (C + 1)dp[0] = True # 初始状态:总大小为0可达# 可选:记录路径,用于回溯文件选择path = [-1] * (C + 1) # 记录达到j时添加的文件索引# 动态规划处理每个文件for i in range(n):for j in range(C, S[i] - 1, -1):if dp[j - S[i]] and not dp[j]:dp[j] = Truepath[j] = i # 记录:状态j是通过添加文件i达到的# 寻找最大的j使得dp[j]为Truemax_total = 0for j in range(C, -1, -1):if dp[j]:max_total = jbreakremaining_space = C - max_totalreturn remaining_space, max_total# 如果需要回溯文件组合,可以根据path数组反向查找
实用建议和注意事项
-
复杂度分析:
- 时间复杂度:O(N*C),空间复杂度:O©。
- 如果 C 很大(例如上百万),动态规划可能消耗大量内存和时间。此时可考虑:
- 贪心算法(快速但可能非最优):将文件按大小降序排序,优先放入大文件,直到无法放入。这不一定得到最优解,但适合快速估计。
- ** meet-in-the-middle**:如果 N 较小(≤40),可以将文件分成两半,分别枚举子集和,再合并结果。
- 记忆化搜索:使用递归与记忆化,避免全部状态枚举。
-
大数据集处理:
- 如果文件数量 N 极大,且磁盘空间 C 也很大,动态规划可能不适用。可以考虑使用 优化后的背包算法(如使用 bitset 压缩状态)或 近似算法(如贪心选择)。
-
实际应用场景:
- 在文件系统或磁盘管理中,这种问题常见于备份、数据迁移或存储优化中(例如,选择日志文件或数据块以最大化磁盘利用率)。
- 如果文件是分割后的部分(如使用
split
命令生成),确保文件顺序正确,避免数据丢失。
-
工具和实现:
- 示例命令获取文件大小列表(假设文件在当前目录):
ls -l | awk '{print $5}' > sizes.txt # 将文件大小输出到sizes.txt
- 然后使用上述算法处理
sizes.txt
中的大小。
- 示例命令获取文件大小列表(假设文件在当前目录):
总结
- 核心方法:使用动态规划解决“最小化剩余空间”问题,找到文件子集使总大小最接近 C。
- 适用场景:精确求解,当 N 和 C 不太大时。
- 替代方案:对于大规模数据,使用贪心算法或分治策略近似求解。
- 实践提示:在真实环境中,考虑文件系统约束(如块大小、碎片化),并验证文件完整性(例如使用 MD5 校验)。
如果要求选择的文件是连续的(即索引连续的子数组),如何实现同样的目标。我将基于搜索结果中的相关技术背景和算法思想,为您提供详细的解决方案。
选择连续文件使剩余空间最小
这个问题要求选择一个连续子数组(索引连续的文件),其总和尽可能大但不超过C。这类似于最大子数组和问题,但有一个上限约束(和不超过C)。剩余空间最小化即为C减去这个最大连续子数组和。
解决方法:
- 前缀和 + 二分查找:由于文件大小非负(S[i] ≥ 0),前缀和数组P是单调递增的(P[0]=0, P[i]=P[i-1]+S[i] for i=1…N)。对于每个结尾索引j,我们需要找到一个起始索引i(i < j),使得P[j] - P[i] ≤ C且尽可能大。这可以通过对每个j,二分查找前缀和数组来找到最小的P[i] ≥ P[j] - C(从而最大化P[j] - P[i])。
- 算法步骤:
- 计算前缀和数组P,长度为N+1,其中P[0]=0, P[i]=P[i-1]+S[i] for i=1…N。
- 初始化最佳和best_sum = 0。
- 维护一个数据结构(如列表)来存储前缀和,但为了二分查找,我们可以直接使用P数组的单调性。
- 对于每个j从1到N:
- 计算target = P[j] - C。
- 在P[0…j-1]中二分查找最小的索引i,使得P[i] ≥ target(由于P单调,第一个≥target的索引即为最小P[i])。
- 如果找到这样的i,则当前和current_sum = P[j] - P[i](≤C)。
- 更新best_sum = max(best_sum, current_sum)。
- 剩余空间为C - best_sum。
- 时间复杂度:O(N log N),由于二分查找。
- 替代方法:如果C很小,可以使用滑动窗口或动态规划,但前缀和方法是标准做法。
代码示例(Python):
import bisectdef min_remaining_continuous(S, C):n = len(S)P = [0] * (n + 1)for i in range(1, n + 1):P[i] = P[i-1] + S[i-1] # P[i] 是前i个文件的总和best_sum = 0# 维护一个列表来存储前缀和,用于二分查找prefix_list = [P[0]] # 初始包含P[0]for j in range(1, n + 1):target = P[j] - C# 在prefix_list中二分查找第一个≥target的索引idx = bisect.bisect_left(prefix_list, target)if idx < len(prefix_list):i_val = prefix_list[idx]current_sum = P[j] - i_valif current_sum <= C:best_sum = max(best_sum, current_sum)# 将当前P[j]插入prefix_list并保持排序(单调递增)bisect.insort(prefix_list, P[j])return C - best_sum# 示例用法:
S = [5, 10, 15, 20] # 文件大小
C = 30 # 磁盘容量
remaining = min_remaining_continuous(S, C)
print(f"最小剩余空间(连续): {remaining}")
讨论与建议
- 非连续选择:动态规划方法能保证最优解,但可能计算昂贵如果C很大。如果N和C都很大,可以考虑使用贪心近似(例如按文件大小降序选择文件),但这可能不是最优。
- 连续选择:前缀和方法高效且准确。如果文件大小有负数(但您的场景中文件大小应为非负),算法需要调整(但您的问题中文件大小应为非负)。
- 实际应用:在磁盘空间管理中,类似问题常见于清理或选择文件时。如果您需要自动化,可以编写脚本并定期执行。
- 性能注意:如果N很大(例如N>10^5),连续选择的O(N log N)方法可行;非连续选择的O(N*C)方法可能需优化(如使用位集或剪枝)。
了解网络流问题吗,说说解决这个问题的方法(增广路算法)
网络流问题是图论中的一个经典问题,它研究的是如何在一个有向图中找到从源点(Source)到汇点(Sink)的最大流量,同时满足每条边的容量限制和流量守恒条件。这个问题在现实生活中有着广泛的应用,例如模拟交通系统的车流量、管道系统中的液体流动或计算机网络中的数据流。
解决此问题的核心方法——增广路算法。
核心概念
- 容量 (Capacity):每条边允许通过的最大流量,记为 c(u,v)c(u, v)c(u,v)。
- 流量 (Flow):实际通过一条边的流量,记为 f(u,v)f(u, v)f(u,v),必须满足 0≤f(u,v)≤c(u,v)0 \leq f(u, v) \leq c(u, v)0≤f(u,v)≤c(u,v)。
- 源点 (Source) 和 汇点 (Sink):流量的起点和终点。
- 流量守恒:除源点和汇点外,任意中间节点的流入总量等于流出总量。
- 残留网络 (Residual Network):这是算法中的关键概念。它表示当前流量分配下,网络还能容纳多少流量。对于边 (u,v)(u, v)(u,v):
- 其残留容量 cf(u,v)=c(u,v)−f(u,v)c_f(u, v) = c(u, v) - f(u, v)cf(u,v)=c(u,v)−f(u,v),即剩余的可用的正向容量。
- 同时,算法会引入反向边 (v,u)(v, u)(v,u),其容量 cf(v,u)=f(u,v)c_f(v, u) = f(u, v)cf(v,u)=f(u,v),表示可以“回收”或“反向推送”的流量,这是算法实现“反悔”机制的基础。
- 增广路径 (Augmenting Path):在残留网络中,从源点 sss 到汇点 ttt 的一条简单路径。这条路径上的最小残留容量决定了该路径能增加多少流量。
增广路算法 (Ford-Fulkerson 方法)
增广路算法由 Ford 和 Fulkerson 提出,其核心思想是:不断在残留网络中寻找增广路径,并沿该路径增加流量,直到不存在增广路径为止。此时得到的流就是最大流。
其核心流程如下图所示:
在实际应用中,寻找增广路径通常使用 BFS(广度优先搜索)来实现,这种实现也称为 Edmonds-Karp 算法,它能确保找到最短的增广路径,时间复杂度为 O(VE2)O(VE^2)O(VE2)(V为节点数,E为边数)。
算法精髓:残留网络与反向边
反向边的引入是增广路算法精妙之处。它允许算法“反悔”之前做出的流量分配决策。如果后续发现一条更优的路径,可以通过反向边将之前分配的流量“退回”,从而重新调整流量的分布,逐步逼近全局最优解——最大流。
算法变种与改进
基础的 Ford-Fulkerson 算法在某些情况下可能效率不高(如容量为无理数时)。除了上面提到的 Edmonds-Karp 算法,还有一些更高效的改进算法:
- Dinic 算法:采用 BFS 构建分层图 + DFS 进行多路增广的策略,并常结合当前弧优化跳过已满的边。其时间复杂度为 O(V2E)O(V^2E)O(V2E),在效率上显著优于基础的 Ford-Fulkerson 方法。
- ISAP 算法:通过动态更新节点到汇点的距离标号,并结合 Gap 优化(当某距离值节点数为0时提前终止),效率与 Dinic 算法相近但常数更优。
- Push-Relabel 算法:采用了不同于增广路思想的压入与重标记策略。该算法从源点开始尽可能多地推送流,并通过调整节点的高度标签来辅助流的推送。这类算法(如最高标号预流推进算法,HLPP)在实践中有可能获得更好的性能。
实际应用
网络流算法在实际中有广泛应用:
- IP网络中的QoS保障与拥塞控制:网络流算法是IP网络优化的数学基石。例如,通过流量分类与标记(如DSCP、MPLS EXP)、队列调度(如严格优先级队列SPQ、加权公平队列WFQ)和拥塞避免机制(如RED/WRED)来保障服务质量和控制网络拥塞。
- 软件定义网络(SDN)中的流表优化:在SDN中,利用集中控制器的全局视图,网络流算法可以计算最优的流表。同时,为了应对TCAM资源受限的场景,会采用流表压缩算法(如通配符压缩、编码压缩)来减少流表项的数量。
网络五层的内容
五层的核心职责:
层次 | 核心功能 | 关键协议与技术 | 数据单元 |
---|---|---|---|
应用层 | 为应用程序提供网络服务接口,处理特定的应用逻辑 | HTTP, HTTPS, FTP, SMTP, POP3, IMAP, DNS | 数据(Data) |
传输层 | 提供端到端的可靠或不可靠传输,负责流量控制、差错恢复 | TCP, UDP | 段(Segment) / 数据报(Datagram) |
网络层 | 在不同网络间进行逻辑寻址、路由选择和分组转发 | IP, ICMP, ARP, 路由器 | 包(Packet) |
数据链路层 | 在相邻节点间无差错地传输数据帧,进行介质访问控制 | Ethernet, WiFi, PPP, 交换机, 网桥 | 帧(Frame) |
物理层 | 在物理介质上传输原始的比特流,定义电气和物理特性 | RJ45, 光纤, 同轴电缆, 集线器, 中继器 | 比特(Bit) |
以下是各层的详细说明:
1. 物理层
物理层是网络通信的物理基础。它负责在物理介质(如网线、光纤、无线电波)上传输原始的比特流(0和1)。该层不关心数据的含义,只关注如何表示“0”和“1”、信号的同步、调制和解调,以及物理接口的电气和机械特性(如网线的水晶头、光纤的接口类型)。集线器(Hub)是工作在这一层的典型设备。
2. 数据链路层
数据链路层确保在同一个局域网(LAN)内相邻设备之间的可靠数据传输。它将来自网络层的“包”封装成帧,并添加头部和尾部(包含MAC地址和差错校验信息)。该层通过MAC地址识别物理设备,并处理帧的差错检测(如CRC校验),但不负责纠错(发现错误就直接丢弃)。交换机(Switch)是这一层的核心设备,它根据MAC地址表智能地转发帧。
3. 网络层
网络层负责在不同网络之间进行通信。其核心任务是逻辑寻址(分配IP地址)和路由选择(为数据包选择最佳路径)。该层将传输层的“段”封装成包,并添加IP头部(包含源IP和目标IP地址)。路由器(Router)是工作在这一层的关键设备,它根据路由表在不同网络间转发数据包。
4. 传输层
传输层为运行在不同主机上的应用程序提供端到端的通信服务。它通过端口号来标识和区分不同的应用程序(如80端口用于HTTP,443用于HTTPS)。这一层有两个核心协议:
- TCP:提供面向连接的、可靠的数据传输。通过三次握手建立连接,并通过确认、重传、流量控制等机制确保数据正确、按序到达。
- UDP:提供无连接的、不可靠的数据传输。不保证交付,但开销小、延迟低,适用于实时性要求高的应用(如视频通话、在线游戏)。
5. 应用层
应用层是用户与网络的直接接口,包含了各种面向用户的应用程序协议。它负责为特定应用(如网页浏览、电子邮件、文件传输)提供服务和规则。当你使用浏览器、邮箱或微信时,就是在和应用层打交道。
给两个字符串a和b,删除字符串b中所有a的子字符串
实际上这只是个文字游戏,把所有在a中出现过的字符删了就行了
判断一个链表是否是回文链表
判断一个链表是否是回文链表,本质上就是判断它的顺序遍历和逆序遍历得到的序列是否一致。
三种方法对比
方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|---|
数组+双指针 | O(n) | O(n) | 思路简单,易于实现 | 需要额外O(n)空间 | 对空间复杂度无要求,快速实现 |
递归 | O(n) | O(n) | 代码简洁,体现递归思想 | 栈空间开销大,可能栈溢出 | 理解递归,链表长度不大 |
快慢指针+部分反转 | O(n) | O(1) | 满足常数空间复杂度 | 会修改原链表结构 | 空间敏感,允许临时修改链表 |
快慢指针与部分反转(最优)
这是空间复杂度为 O(1) 的解法,也是面试官通常期望的答案。它结合了快慢指针找中点和反转链表两个经典技巧。
步骤:
- 找到中间节点:使用快慢指针。慢指针每次走一步,快指针每次走两步。当快指针到达末尾时,慢指针正好在链表中间。
- 反转后半部分链表:从慢指针所在的位置开始,反转后半部分链表。
- 比较前后两半:同时遍历原链表的头部分和反转后的后半部分,逐个比较节点的值。如果所有值都相同,则为回文链表。
- 恢复链表(可选):如果要求不修改原链表,可以在比较后将反转的后半部分再次反转恢复。
复杂度分析:
- 时间复杂度:O(n)。查找中点、反转后半部分、比较操作都是 O(n) 时间。
- 空间复杂度:O(1)。只需要几个指针变量,是常数级的额外空间。
总结与建议
- 理解本质:回文链表的关键在于无法直接获取前驱节点,因此需要巧妙地利用快慢指针和链表反转来解决问题。
- 面试首选:务必掌握快慢指针与部分反转的方法(方法三),因为它是最优解。
- 注意细节:
- 使用快慢指针时,注意链表节点数为奇数和偶数的情况。奇数时,中间节点可以归入前半部分。
- 如果题目要求不能修改原链表,记得在比较后恢复链表(将后半部分再次反转)。
- 总是考虑边界情况,如空链表或只有一个节点的链表,它们通常是回文的。
dfs和bfs,dfs和bfs用的数据结构
特性 | 深度优先搜索 (DFS) | 广度优先搜索 (BFS) |
---|---|---|
核心策略 | 深度优先,一条路走到黑,再回溯 | 广度优先,按层扩散,由近及远 |
数据结构 | 栈 (递归调用栈或显式栈) | 队列 |
遍历顺序 | 无固定顺序,取决于实现和选择策略 | 按距离起点层级递增的顺序访问 |
空间复杂度 | 通常较低 O(h) (h为树高或递归深度) | 通常较高 O(w) (w为树最宽层的节点数) |
最短路径 | 不保证找到最短路径(除非额外记录) | 天然保证找到无权图的最短路径 |
常见应用 | 拓扑排序、连通性检测、回溯问题(如N皇后)、图的环路检测 | 无权图最短路径、层次遍历、广播网络、社交网络好友推荐 |
如何选择?
选择DFS还是BFS,主要取决于你的具体需求:
- 如果你的问题是寻找最短路径、最少步数,或者需要按层次遍历,BFS通常是更好的选择。
- 如果你的问题是检查连通性、判断是否存在路径、需要遍历所有可能情况(如排列组合),或者空间复杂度是一个主要考虑因素时,DFS可能更合适。
- 有时,对于大规模状态空间问题,还可以考虑双向BFS或迭代加深DFS等优化策略。
进程,线程,协程
进程、线程和协程是现代计算中并发编程的三大核心概念。下面这张表格汇总了它们的主要区别,方便你快速建立整体认识。
特性维度 | 进程 (Process) | 线程 (Thread) | 协程 (Coroutine) |
---|---|---|---|
基本定义 | 资源分配的基本单位 | CPU调度的基本单位,属于同一进程 | 用户态的轻量级线程,由程序自行调度 |
资源开销 | 大,涉及独立资源分配 | 较小,共享进程资源 | 极低,在用户态完成,不涉及系统调用 |
切换开销 | 高,需内核介入,保存/恢复资源多 | 中,需内核调度 | 极低,仅保存寄存器等少量上下文 |
内存共享 | 独立内存空间,不共享 | 共享同一进程的内存空间 | 共享线程内存,通常在单线程内运行 |
数据安全/同步 | 高,进程间相互隔离 | 低,需通过锁、信号量等同步机制防止数据竞争 | 高,单线程内串行执行,无需锁机制 |
调度主体 | 操作系统内核 | 操作系统内核 | 程序员或应用程序(用户态) |
并发数量 | 数百 | 数千 | 数十万级别 |
通信方式 | 管道、消息队列、共享内存、信号量、套接字等IPC方式 | 可直接读写进程数据段(如全局变量) | 共享内存、消息队列 |
关于Python的GIL
值得注意的是,在Python解释器中,由于存在全局解释器锁(GIL),同一时刻只能有一个线程执行Python字节码。这使得Python的多线程难以充分利用多核CPU进行并行计算,更适合I/O密集型任务。对于CPU密集型任务,通常需借助多进程来突破GIL限制。
如何选择了三种模型
- 需要高稳定性和强隔离性
当某个任务的崩溃不应影响其他任务时(如浏览器不同标签页、安全沙箱),进程是首选,因为它能提供独立的地址空间和资源。 - 处理CPU密集型任务
任务需要大量计算,消耗CPU资源。若需利用多核CPU并行计算(如图像处理、数据分析),可选用多进程(尤其在Python中,可规避GIL)或多线程(在无GIL限制的语言中,如Java、C++)。 - 处理I/O密集型任务
任务涉及大量等待(如网络请求、文件读写、数据库操作)。协程是理想选择,它能以极低开销管理大量并发连接,最大化单核资源利用率。多线程也可行,但线程创建和切换开销高于协程。 - 任务间需要频繁共享数据
若多个任务需频繁访问和操作共享数据,多线程(共享进程内存)或协程(单线程内共享)更简便高效。进程间共享数据则需通过进程间通信(IPC),相对复杂。
深入理解进程
- 进程的状态:进程在其生命周期中会经历多种状态转换,主要包括就绪(Ready)、运行(Running) 和阻塞(Blocked)(等待I/O等事件)。此外还有创建(New)和终止(Terminated)状态。
- 进程的组成:一个进程实体(Process Image)不仅是程序代码,还包括程序段、数据段和至关重要的进程控制块(PCB)。PCB是进程存在的唯一标志,记录了进程状态、编号、寄存器、资源分配等所有控制信息。
- 进程的调度:操作系统通过调度算法(如先来先服务、短作业优先、时间片轮转、优先级调度、多级反馈队列等)来决定就绪队列中的哪个进程获得CPU使用权。
用户态和内核态定义,切换过程,通信
用户态和内核态是操作系统的两种核心运行模式,它们确保了系统的安全性、稳定性和资源管理的有效性。
用户态与内核态的定义
特性维度 | 用户态 (User Mode) | 内核态 (Kernel Mode) |
---|---|---|
权限级别 | 较低(如x86架构的Ring 3) | 最高(如x86架构的Ring 0) |
执行程序 | 应用程序、用户级库函数(如浏览器、文本编辑器) | 操作系统内核、驱动程序、中断处理程序 |
硬件访问 | 不能直接访问硬件,需通过系统调用请求内核 | 可直接访问和操作所有硬件资源 |
内存访问 | 仅能访问进程的私有虚拟地址空间 | 可访问所有物理内存和内核空间 |
执行指令 | 只能执行非特权指令 | 可执行所有指令(包括特权指令) |
主要目的 | 运行用户应用程序,隔离错误保障系统安全 | 执行操作系统核心任务,管理硬件和系统资源 |
用户态与内核态的切换
应用程序运行时,CPU会在用户态和内核态之间转换。切换的触发方式主要有三种:
- 系统调用(System Call):这是用户态进程主动发起的切换。当应用程序需要操作系统提供服务时(如文件读写
read
、write
,创建进程fork
),会执行一条特殊的指令(如int 0x80
或syscall
)陷入内核。这是编程中最常见的一种方式。 - 中断(Interrupt):由外部硬件设备发起,是被动的切换。例如,键盘输入、鼠标移动或网络数据到达时,硬件会向CPU发送中断信号。CPU无论正在执行什么,都会暂停当前指令,转而去执行对应的中断处理程序。
- 异常(Exception):由CPU内部执行指令时检测到错误或特殊条件引发,也是被动的切换。常见的异常包括除零错误、页面失效(缺页异常)、访问非法内存等。
切换过程的简要步骤如下:
- 从用户态到内核态:
- 触发事件(如执行系统调用指令或发生中断/异常)。
- CPU自动切换到内核栈,保存当前用户态的上下文信息(如寄存器、程序计数器等)。
- 跳转到操作系统内核中预设的处理函数(如系统调用处理程序或中断服务例程)执行。
- 从内核态返回用户态:
- 内核处理程序执行完毕。
- 恢复之前保存的用户态上下文。
- 执行特定的返回指令(如
iret
或sysret
),切换回用户态,用户程序继续执行。
这种切换需要保存和恢复现场,因此存在一定的性能开销。
用户态与内核态的通信
由于存在权限和隔离,用户态和内核态不能直接访问对方的数据,通信需要通过以下几种方式:
- 系统调用(System Call):最基础、最核心的通信方式。正如前面切换中提到的,它提供了用户程序访问内核服务的标准化接口。
- Procfs (/proc) 和 Sysfs (/sys):这是两个由内核提供的虚拟文件系统。它们将内核的一些内部数据(如系统信息、硬件配置、设备状态、进程详情等)以文件的形式暴露给用户态。用户程序通过读写这些文件来与内核交换信息。例如,
/proc/cpuinfo
可以查看CPU信息,/sys/class/
目录下可以操作设备。 - Netlink Socket:这是一种基于Socket的通信机制,允许内核与用户态进程进行双向异步通信。它比系统调用更灵活,能够传输更多数据,也支持内核主动向用户态发送消息(例如udev通知设备事件)。
iproute2
等现代网络工具就使用Netlink。 - 共享内存(Shared Memory):一种高效的通信方式。内核和用户进程共同映射同一块物理内存,数据可以直接读写,避免了在用户态和内核态之间拷贝数据的开销。但需要自行处理同步问题(如使用信号量)。
Netlink Socket 是 Linux 系统特有的一种高级进程间通信(IPC)机制,主要用于内核空间与用户空间进程之间、以及用户进程之间的双向数据传输。它提供了一种比传统机制(如 ioctl、系统调用或 proc 文件系统)更灵活、高效的通信方式。
以下是关于 Netlink Socket 的关键介绍:
一、基本工作原理
Netlink Socket 基于标准的 BSD Socket API 构建,使用了特定的 AF_NETLINK
地址族。其通信是异步的:
- 用户空间进程:通过标准的 Socket API(
socket()
,bind()
,sendmsg()
,recvmsg()
,close()
)来创建和使用 Netlink Socket。 - 内核空间模块:使用一套专用的内核 API(如
netlink_kernel_create()
,netlink_unicast()
,netlink_broadcast()
)来参与通信。
每个 Netlink 消息都包含一个固定的消息头 (struct nlmsghdr
) 和紧随其后的消息体(载荷数据)。消息头记录了消息长度、类型、序列号、发送者 PID 等信息,确保了消息的可解析性和完整性。
二、主要特性与优势
Netlink Socket 相较于其他 IPC 机制具有显著优点:
- 双向异步通信:不仅用户程序可以主动向内核发送请求,内核也能主动发起会话,向用户空间发送消息(例如事件通知),这是 ioctl 等机制难以实现的。
- 支持多播(Multicast):允许将消息发送到一个“多播组”,多个监听该组的进程都能同时接收到消息,非常适合实现事件通知机制。
- 标准化接口:基于熟悉的 Socket API,降低了开发者的学习成本。
- 协议可扩展:内核预定义了多种协议类型(如
NETLINK_ROUTE
用于路由信息、NETLINK_KOBJECT_UEVENT
用于内核热插拔事件等),同时也支持添加自定义的协议类型。 - 减少系统开销:避免了同步通信的阻塞等待,有助于提高系统响应和吞吐量。
三、常用 Netlink 协议类型
Linux 内核预定义了多种 Netlink 协议,用于不同的子系统:
协议常量 | 用途描述 |
---|---|
NETLINK_ROUTE | 路由表、网络接口、邻居表(ARP)、地址配置等网络栈信息获取与设置。 |
NETLINK_KOBJECT_UEVENT | 内核热插拔事件通知(如 U 盘插拔)。 |
NETLINK_FIREWALL / NETLINK_NETFILTER | 接收防火墙数据包或与 Netfilter 子系统交互。 |
NETLINK_SELINUX | SELinux 事件通知。 |
NETLINK_GENERIC | 一种通用的 Netlink 协议,简化用户自定义协议的实现。 |
四、编程模型简述
在用户空间中编写 Netlink 程序通常包含以下步骤:
-
创建 Socket:
int fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE);
SOCK_RAW
或SOCK_DGRAM
均可,NETLINK_ROUTE
可根据需要替换为其他协议。 -
绑定地址:
填充sockaddr_nl
结构体,指定地址族和本进程的 PID(通常用getpid()
),有时也会绑定到特定的多播组。struct sockaddr_nl addr; memset(&addr, 0, sizeof(addr)); addr.nl_family = AF_NETLINK; addr.nl_pid = getpid(); // 设置端口ID,通常是进程ID // addr.nl_groups = RTMGRP_LINK; // 可选:绑定到多播组 bind(fd, (struct sockaddr*)&addr, sizeof(addr));
-
发送与接收消息:
- 发送:构建消息(
nlmsghdr
+ 载荷),通过sendmsg()
发送。需要指定目标地址(发给内核则 PID 设为 0)。 - 接收:通常循环调用
recvmsg()
来读取消息,需要解析消息头和处理载荷。
- 发送:构建消息(
-
关闭 Socket:
使用close(fd)
释放资源。
五、典型应用场景
Netlink Socket 广泛应用于:
- 网络配置:
iproute2
工具集(如ip link
,ip addr
,ip route
)大量使用 Netlink 与内核通信来配置网络接口、路由和策略。 - 防火墙与策略管理:如
iptables
、nftables
的配套工具通过 Netlink 获取和设置规则。 - 系统状态监控:用户态程序通过监听 Netlink 多播消息(如链路状态变化、路由更新)来实时感知系统网络状态的变化。
- 设备热插拔事件处理:udev 依赖
NETLINK_KOBJECT_UEVENT
来接收内核发出的设备事件,从而动态管理/dev
下的设备节点。
六、注意事项
- 内核内存使用:不节制的消息发送可能导致内核内存耗尽。
- 消息顺序与可靠性:Netlink 不保证消息的绝对顺序和可靠送达,应用层需根据需要实现序列号(
nlmsg_seq
)和确认机制。 - 权限检查:向内核发送某些控制消息可能需要
CAP_NET_ADMIN
等特权能力。