秋招八股准备
不得不向八股文低头了喵。本文应该会持续更新一段时间。
Python
Python 如果函数没有使用 return 语句,则返回的是
如果一个 Python 函数没有显式地使用 return 语句,或者执行到了函数的末尾也没有遇到 return 语句,那么函数将默认返回 None。
Python 的 pass
pass 不做任何事情,一般用做占位语句。pass 的唯一作用就是作为占位符。当你定义一个函数、类、循环或条件语句的结构,但暂时还没想好具体实现时,可以使用 pass 来保证程序语法正确,可以正常运行。
Python 的 yield
yield 关键字的主要作用是将一个普通的函数转变为一个“生成器”(Generator)。一个包含 yield 的函数在被调用时,不会立即执行,而是返回一个生成器对象。这个生成器是一个可以迭代的对象,它“懒惰地”计算值,只有在你请求时才生成下一个值。
当函数执行到 yield 时,它也会返回一个值,但函数会在此处暂停,并保存当前所有的状态(包括局部变量、执行位置等)。下次从这个生成器请求值时,函数会从它上次暂停的地方(上次 yield 语句的下一行代码处)无缝地继续执行。
示例:
1 | def countdown_generator(n): |
上面代码的输出结果:
1 | 创建了生成器对象: <generator object countdown_generator at 0x...> |
为什么使用 yield (它的优点)?
- 极高的内存效率:这是 yield 最重要的优点。生成器是“懒加载”的,它不会一次性在内存中创建所有元素。它只在你需要的时候才生成下一个元素。对于处理大数据集、读取大文件或者生成无限序列等场景,这可以极大地节省内存。
1
2
3
4
5# 如果用列表,会立即在内存中创建10亿个整数,可能导致内存崩溃
# my_list = [i for i in range(1_000_000_000)]
# 如果用生成器,它只占用极小的内存,因为它是一个对象,记录着如何生成下一个值
my_generator = (i for i in range(1_000_000_000)) - 能够处理无限序列:因为值是动态生成的,所以你可以创建一个永不停止的生成器。例如,一个生成所有自然数的生成器。
- 代码更简洁、可读性更高:相比于自己编写一个包含
__iter__()和__next__()方法的迭代器类,使用yield的生成器函数要简洁得多,逻辑也更清晰。状态管理由 Python 自动完成,无需手动维护。
Python 的 GIL锁
Python 中的 GIL 指什么?谈一下对 GIL 的理解。
简介
GIL 的全称是 Global Interpreter Lock,即全局解释器锁。
简单来说,GIL 是 CPython 解释器(我们最常使用的官方 Python 解释器)中的一把“大锁”。它的核心作用是:在同一时刻,只允许一个线程执行 Python 的字节码。
这意味着,即使你的计算机有多个 CPU 核心,一个 CPython 进程中的多个线程也无法真正地并行执行 Python 代码。它们可以在不同线程间快速切换,实现并发(Concurrency),但无法在同一瞬间利用多核优势实现并行(Parallelism)。
对 GIL 的理解可以分为三个方面:它为什么存在,它带来了什么问题,以及应该如何应对它。
为什么会有 GIL?为了简化和安全
GIL 的存在主要是历史原因,其核心目的是为了简化 CPython 的内存管理,使其线程安全。
CPython 的垃圾回收机制主要基于“引用计数”(Reference Counting)。每个 Python 对象都有一个计数器,记录着有多少个变量指向它。当计数器变为0时,对象的内存就会被回收。
想象一下,如果没有 GIL,两个线程同时尝试修改同一个对象的引用计数(一个增加,一个减少),就可能会发生“竞态条件”(Race Condition),导致计数错误。这可能会让不该被回收的对象被回收(导致程序崩溃),或者该被回收的对象永远不被回收(导致内存泄漏)。
解决这个问题,要么使用大量精细的、复杂的锁来保护每一个对象,要么就用一把简单粗暴的“全局锁”——GIL。当年的设计者选择了后者,因为它极大地简化了 CPython 本身以及 C 语言扩展库的编写,保证了内存管理的线程安全。
GIL 带来了什么问题?多线程无法利用多核
GIL 最大的负面影响就是它成为了 CPU密集型(CPU-bound) 任务的性能瓶颈。
对于 CPU 密集型任务:比如大量的数学计算、图像处理、数据分析等,程序需要持续占用 CPU。在这种情况下,即使你创建了多个线程,GIL 也会确保只有一个线程在执行 Python 代码。其他线程只能等待。这就像一条有8车道的高速公路,但只有一个收费站窗口在工作,所有车都得排队通过这一个窗口,多车道的优势完全无法发挥。
对于 I/O 密集型任务:比如网络请求、文件读写、数据库操作等,情况则完全不同。当一个 Python 线程在执行 I/O 操作时,它会因为等待数据返回而被“阻塞”。CPython 在遇到 I/O 阻塞时,会主动释放 GIL,让其他线程有机会执行。对于 I/O 密集型应用,使用多线程依然能通过并发显著提升效率。
如何应对 GIL?选择正确的工具
理解了 GIL 的影响后,我们就可以在不同的场景下选择合适的并发策略:
多线程(
threading模块):- 适用场景:I/O 密集型任务。这是利用多线程提升效率的最佳场景。
- 不适用场景:CPU 密集型任务。用了也不会比单线程快,甚至可能因为线程切换的开销而更慢。
多进程(
multiprocessing模块):- 适用场景:CPU 密集型任务。这是绕过 GIL 实现真并行计算的标准方法。
- 工作原理:
multiprocessing会创建多个独立的 Python 解释器进程。每个进程都有自己独立的内存空间和独立的 GIL。这样,操作系统就可以将这些进程调度到不同的 CPU 核心上,实现真正的并行计算。 - 缺点:进程间通信(IPC)比线程间共享内存更复杂,且创建和管理进程的开销也比线程大。
异步编程(
asyncio库):- 适用场景:高并发的 I/O 密集型任务,尤其适合需要同时处理成千上万个网络连接的场景(如 Web 服务器、爬虫)。
- 工作原理:它在单线程内通过事件循环(Event Loop)来切换任务,避免了多线程的上下文切换开销,是一种更高效的并发模型。
使用其他 Python 解释器:
- 像 Jython(运行在 JVM 上)和 IronPython(运行在 .NET CLR 上)就没有 GIL,可以实现真正的多线程并行。但它们的生态和 CPython 有差异。
| 任务类型 | 瓶颈 | GIL 影响 | 推荐方案 |
|---|---|---|---|
| CPU 密集型 | CPU 运算速度 | 大 (无法利用多核) | multiprocessing (多进程) |
| I/O 密集型 | I/O 等待时间 | 小 (I/O 等待时会释放 GIL) | threading (多线程) 或 asyncio (异步) |
RabbitMQ
简单讲讲 RabbitMQ
https://kucw.io/blog/rabbitmq1/
https://kucw.io/blog/2020/11/rabbitmq/
(主要根据上面这两个文章的内容,用口语化的表达去说)
应用解耦: 上游核心服务只需要将数据投递到 MQ,无需关心下游有哪些子系统。即使下游服务宕机,也不会阻塞上游的主流程。
异步处理: 针对耗时的非核心链路操作(例如发送邮件、生成历史报表),可以直接丢入队列异步执行,从而大幅降低业务接口的响应延迟。
流量削峰: 在高并发场景下,突发的巨量请求先进入队列排队暂存,后端的消费服务则根据自身的负载能力匀速拉取处理,保护底层数据库不被瞬间打穿。”
怎么保证 RabbitMQ 不丢消息
按消息流转的生命周期,构建三道防线。消息丢失可能发生在三个阶段:发送时、存储时、消费时。
第一道防线:生产者 到 Broker(保证消息发到服务器)
消息在通过网络发往 RabbitMQ 的过程中,可能因为网络闪断而丢失。为了防止这种情况,我们在生产端主要依赖发布确认机制(Publisher Confirms)。
- 具体来说,我们会开启 confirm 模式。当消息成功到达 Exchange 时,RabbitMQ 会异步回调我们的 ack 接口;如果因为某些原因(比如内部磁盘满)被拒绝,会回调 nack 接口。
- 同时,我们还会开启 return 回调。如果消息到了 Exchange,但由于路由键错误没有匹配到任何 Queue,消息会被退回给生产者。
- 对于回调失败或超时的消息,我们会将它们落入本地数据库的消息重试表,由定时任务进行补偿发送,确保第一跳绝对不丢。
第二道防线:Broker 内部存储(保证服务器宕机不丢数据)
消息到达 RabbitMQ 后,如果它仅仅停留在内存里,一旦服务器停电或宕机,消息就全没了。所以第二步是做持久化(Persistence)。这里需要做到三位一体的持久化:
- Exchange 持久化: 创建交换机时设置
durable=true。 - Queue 持久化: 创建队列时设置
durable=true。 - Message 持久化: 生产者发送消息时,将
delivery_mode设置为2(表示持久化)。 - 此外,为了防范单台机器磁盘损坏的物理故障,在生产环境中我们会部署高可用集群,并使用 仲裁队列(Quorum Queues),基于 Raft 协议在多个节点间同步消息副本。
第三道防线:Broker 到 消费者(保证业务处理完才算成功)
哪怕消息安全存进磁盘了,如果消费者刚把消息拉取过来,还没来得及执行业务逻辑代码,消费者的机器就 OOM(内存溢出)崩溃了,由于 RabbitMQ 默认是自动确认(Auto-ACK),它会认为消息已经消费完毕并在服务端将其删除,这就导致了消息丢失。
- 解决方案是关闭自动 ACK,开启手动 ACK(Manual ACK)。
- 只有当我们的业务代码(比如落库、调用外部接口)全部且成功执行完毕后,我们才在
finally块中手动调用basic.ack告诉 RabbitMQ 可以删数据了。 - 如果业务执行抛出异常,我们就捕获它并调用
basic.nack,可以选择将消息重新放回队列(requeue),或者丢进死信队列(DLX)让人工介入排查。
如果消费失败,可以让消息重试(Requeue)。但如果在高并发场景下,一条消息因为代码 bug 一直消费失败,一直疯狂重试,就会导致整个队列卡死。如何处理这种一直消费失败的“毒药消息”?
RabbitMQ 其实有一个小缺陷:当使用 requeue=true 时,它不会自动记录这条消息被重试了多少次。如果代码里有 Bug,或者遇到了脏数据(比如字段格式错乱导致解析一直抛异常),无脑 requeue 就会造成无限死循环,也就是‘毒药消息’。这会彻底卡死当前队列。
解决方案是:业务代码计步 + 死信队列(DLX)兜底。
- 本地或 Redis 计数: 消费端在捕获异常时,不要立刻让 MQ 重新投递,而是先在本地缓存(或者 Redis 里,以 Message ID 为 Key)记录重试次数。
- 重发带 Header 标记: 更好的做法是,捕获异常后,消费者主动把这条消息作为一个新消息重新发送回目标队列,但在 Header 里加上一个
x-retry-count属性。每次失败就+1。 - 死信队列兜底: 当判断重试次数达到阈值(比如 3 次),我们就对其调用
basic.reject(requeue=false)。因为我们在声明业务队列时,提前绑定了死信交换机(Dead Letter Exchange, DLX)。被拒绝的消息会自动流入死信队列(DLQ),让业务队列继续处理后续的正常消息。 - 人工介入: 运维或开发人员会配置针对死信队列的监控报警,收到报警后去后台捞出这条‘毒药消息’,排查究竟是代码 Bug 还是脏数据。
然而在很多情况下,消费失败不是因为代码 Bug,而是下游依赖的服务暂时抖动了(比如数据库发生死锁、第三方接口限流)。此时‘立即重试’毫无意义,依然会失败。
所以,我们在高并发架构中会引入退避策略(Exponential Backoff)。结合 RabbitMQ 的延迟队列插件(或 TTL+DLX 机制),让第一次失败的消息延迟 1 秒重试,第二次延迟 5 秒,第三次延迟 10 秒。如果 10 秒后还是失败,再扔进最终的死信队列。这样既保护了下游系统,又最大程度保全了消息的处理率。
ref: https://www.cnblogs.com/auguse/articles/17715114.html
如何保障 RabbitMQ 的消息顺序性
比如说在订单的场景下,订单从创建到支付到发货的这些消息。同一订单的这些消息需要顺序处理,这个可以怎么实现?有这些消费者手速很快。他创建之后,他马上支付了。
RabbitMQ 的单个队列(Queue)内部是严格遵循 FIFO(先进先出)原则的,但如果多消费者并发的话,可能会破坏顺序性。
生产者端不要将所有订单消息无差别地扔进一个队列。根据订单 ID 进行哈希取模。
- 做法: 准备多个队列(例如
queue_0,queue_1,queue_2)。 - 逻辑: 发送消息时,计算
hash(order_id) % 队列数。这样,同一个订单的所有状态变更消息(创建、支付、发货)都会进入同一个队列。
RabbitMQ 保证了单个队列内的消息是有序的。为了维持这个顺序,每个队列必须只能有一个消费者实例,且该消费者内部必须是单线程处理。如果为了提速想开多个消费者,增加队列数量,而不是增加单个队列的消费者数。
RabbitMQ 是否不保证只投一次?怎么去针对他这个特性去做防范,去做应对设设计呢?(消息去重方案)
在分布式系统的理论中,没有任何一款主流的 MQ(包括 RabbitMQ、Kafka、RocketMQ)能在纯粹的底层网络层面做到严格的‘Exactly-Once(只投递一次)’。
只要配置了可靠性机制,RabbitMQ 能保证的上限是 At-Least-Once(至少投递一次)。这意味着,消息重复是必然会发生的现象。
在真实的网络环境中,重复通常发生在两个关键节点:
- 发送端重复: 生产者发送消息到 MQ,MQ 收到了并落盘,但在返回 Confirm 确认包给生产者时,网络闪断了。生产者以为没发成功,触发重试,导致 MQ 里存了两条一模一样的消息。
- 消费端重复(最常见): 消费者拉取了消息,业务逻辑也执行成功了(比如扣了款),但在最后发送
basic.ack告诉 MQ 删数据时,消费者所在的机器突然宕机或者网络超时。MQ 等不到 ACK,就会认为消费失败,把消息重新放回队列,交给下一个消费者处理,导致重复消费。
既然底层框架无法避免重发,我们的架构设计原则就是:把防重的任务交给业务侧,也就是在消费端实现幂等性。
所谓幂等性,就是一条相同的消息,无论被消费 1 次还是 100 次,对系统最终造成的影响都和只消费 1 次完全一样。通常我们有三种工业级的防范方案:
方案 A:数据库唯一索引(最坚固的底线防线)
最简单粗暴且绝对安全的方法。我们在消费消息前,提取消息中的唯一标识(比如 OrderID 或 MessageID),插入到数据库的一张“消费去重表”中。
因为这个表在 MessageID 上建了唯一索引(Unique Key),第一次插入成功后,执行真正的业务逻辑。如果重复的消息过来,插入去重表时会直接报 DuplicateKeyException 主键冲突异常。我们在代码里捕获这个异常,直接按成功处理并 ack 掉即可。
方案 B:Redis 分布式锁/防重表(高并发优选)
如果单靠数据库抗并发压力太大,我们会在前面加一层 Redis。
消费者拿到消息后,先用 MessageID 去 Redis 里执行 SETNX(Set if Not eXists)命令。
- 如果返回成功,说明是第一次消费,放行业务。
- 如果返回失败,说明之前已经处理过了,直接把消息丢弃并
ack。(注意:为了防止极端情况下的死锁或中断,我们会给这个 Key 设置一个合理的过期时间,比如 24 小时)。
方案 C:业务状态机乐观锁(最优雅的无锁设计)
在订单流转等有明确状态的场景下,我们可以直接利用业务本身的状态机来实现天然幂等,甚至不需要额外的防重表。
比如执行扣款时,我们的 SQL 语句带上状态前置条件:UPDATE order SET status = 'PAID' WHERE order_id = 123 AND status = 'UNPAID';
如果是重复的扣款消息过来,因为第一次执行后 status 已经变成了 PAID,第二次执行这条 SQL 时受影响的行数为 0。代码里判断如果更新行数为 0,就知道是重复消息,直接忽略。
刚才你提到了用 Redis SETNX 做防重。但是如果 Redis SETNX 成功了,紧接着执行下面数据库扣款业务逻辑的时候,数据库宕机抛出异常了。这时候业务没执行,但 Redis 里已经有记录了。等消息重试的时候,不就被 Redis 错误地拦截掉,导致这笔订单永远没法处理了吗?
揭示本质:不要用 Redis 做绝对的业务状态存储。
导致这个死局的根本原因在于,我们错误地把 Redis 既当成了防重屏障,又当成了业务状态的“最终真理”。在强一致性要求高的场景(如资金、订单)下,唯一能代表业务真实状态的,只有底层的关系型数据库(如 MySQL)。
方案一:Redis 作为并发锁 + DB 作为状态机(工业界黄金标准)
为了彻底解决一致性问题,我们需要重新定义 Redis 的角色——它只是一个分布式锁,用来挡住瞬间的高并发,真正的幂等性校验交还给数据库。
- 第一步(挡并发): 消费者进来,先用
SETNX尝试获取当前订单的分布式锁(带短暂 TTL)。如果没有获取到锁,说明有另一个线程正在处理,当前线程直接抛出异常,让 MQ 稍后重试。 - 第二步(查真理): 获取到锁之后,第一件事不是直接扣款,而是去查一遍数据库! 查一下这个订单在数据库里的状态是不是已经被处理过了。
- 第三步(做业务):
- 如果 DB 显示“已处理”,说明是重复消息,直接
ack掉。 - 如果 DB 显示“未处理”,开始执行扣款等业务逻辑。
- 如果 DB 显示“已处理”,说明是重复消息,直接
- 破局点: 哪怕这个时候数据库扣款抛异常崩溃了,业务事物回滚,DB 里的状态依然是“未处理”。由于此时抛出了异常,MQ 会在稍后发起重试。等下次重试时,拿到锁再去查 DB,发现还是“未处理”,就会继续正确地执行下去。完全不会被死锁!
方案二:退璞归真,纯 DB 本地事务防重(终极一致性)
如果系统的并发量没有达到极其夸张的地步(比如秒杀),我们完全可以抛弃 Redis,直接利用 MySQL 的 ACID 本地事务。
新建一张 消息去重表,把 MessageID 设为唯一索引。我们将“插入去重表”和“业务扣款”放在同一个 @Transactional 本地事务里。
- 如果扣款异常,事务回滚,去重表里的记录也会被一起撤销。
- 只有当业务彻底成功提交,去重记录才真正生效。
杂
对 git workflow 的理解,并举例协作流程
Git Workflow 是一套团队协作的规范和策略。其核心目的是为了让多人高效、安全地并行开发,同时保持代码库历史的清晰和可追溯性。一个好的工作流可以极大地减少代码冲突,简化 Code Review,并且能保证主分支的稳定性。
它主要围绕几个关键概念:
- 分支管理:如何以及何时创建分支,分支的命名规范,以及分支的生命周期。这是工作流的核心。
- 代码集成:如何将不同分支的代码合并到主干,是通过
merge还是rebase? - 代码审查:在代码正式合入之前,通过 Pull Request (或者叫 Merge Request) 的方式让同事评审代码,确保质量。
- 版本发布:如何管理线上版本,以及如何在出现紧急 Bug 时进行修复。
我认为 “功能分支工作流 (Feature Branch Workflow)” 结合 “代码审查 (Pull Request)” 是目前最流行也最高效的协作流程。如果项目再复杂一点,可以借鉴 GitFlow 的思想,引入一个 develop 分支。
下面我来描述一下这个流程的具体样貌:
核心分支:
main(或master):这个分支的代码永远是稳定、可随时部署到生产环境的。任何人都不能直接向main分支推送代码。我们会设置分支保护规则来强制执行。develop:这是一个集成分支。所有完成开发的功能分支都会合并到这里。我们可以把它看作是“下一个版本”的预备状态。日常的开发都是基于这个分支进行的。
协作步骤:
任务开始 - 创建功能分支:
- 当我接到一个新的开发任务时(比如 “开发用户登录功能”),我首先会确保我的本地
develop分支是最新版本 (git pull origin develop)。 - 然后,我会从
develop分支创建一个新的功能分支。分支命名会清晰地描述它的用途,比如feature/user-login或者feature/TICKET-123-user-login(如果用了Jira等工具)。 - 命令:
git checkout -b feature/user-login develop
- 当我接到一个新的开发任务时(比如 “开发用户登录功能”),我首先会确保我的本地
本地开发 - 提交代码:
- 在这个
feature/user-login分支上,我进行所有的编码、修改和测试工作。 - 我会遵循“小步快跑”的原则,频繁地进行
git commit。每次提交都有一个清晰的、原子性的目的。Commit message 我会写得很规范,比如feat: add email and password validation,遵循团队的约定(比如 Conventional Commits)。 - 这保证了我的开发过程是可以被追溯的。
- 在这个
保持同步 - 更新分支:
- 为了避免我的分支和主开发分支 (
develop) 偏离太远,导致最后合并时出现大量冲突,我会定期地将develop的最新代码同步到我的功能分支。 - 我个人更倾向于使用
rebase来保持一个线性的、整洁的提交历史。 - 命令:
git pull --rebase origin develop
- 为了避免我的分支和主开发分支 (
发起审查 - 创建 Pull Request (PR):
- 当功能开发完成并且在本地测试通过后,我将功能分支推送到远程仓库:
git push origin feature/user-login。 - 然后,我会在代码托管平台(如 GitHub, GitLab)上创建一个 Pull Request,请求将我的
feature/user-login分支合并到develop分支。 - 在 PR 的描述里,我会清晰地说明:这个 PR 做了什么,为什么这么做,以及如何测试。如果有关闭某个 issue,也会在这里关联。
- 当功能开发完成并且在本地测试通过后,我将功能分支推送到远程仓库:
代码审查与持续集成 (CI):
- PR 创建后,会自动触发 CI 工具(比如 Jenkins, GitHub Actions)运行自动化检查,包括代码风格检查 (Linter)、单元测试、构建等。如果任何一项检查失败,PR 会被标记为不合格,不能合并。
- 同时,我会邀请一到两位同事来审查我的代码。他们会提出修改建议。
- 我根据反馈进行修改,提交新的 commit,然后再次推送到我的功能分支。PR 会自动更新这些改动。这个过程会一直持续到代码被批准为止。
完成合并 - 清理分支:
- 当 PR 通过了所有自动化检查和同事的审查后,项目维护者或我自己会将它合并到
develop分支。 - 这里我特别喜欢用 “Squash and Merge” 的方式。它能将我这个功能分支上所有的零散提交(比如 “fix typo”, “add comments”)合并成一个有意义的 commit,再合入
develop分支。这让develop的历史记录非常干净,每个 commit 都对应一个完整的功能或修复。 - 合并后,这个功能分支的历史使命就完成了,我会删除远程和本地的功能分支,保持仓库的整洁。
- 当 PR 通过了所有自动化检查和同事的审查后,项目维护者或我自己会将它合并到
发布和热修复 (Hotfix):
- 当
develop分支上积累了足够多的功能,准备发布新版本时,我们会将develop分支合并到main分支,并打上版本标签(如v1.2.0)。 - 如果线上版本 (
main) 出现紧急 Bug,我们会从main分支直接创建一个hotfix/fix-login-bug分支,修复后,必须同时合并回main(立即发布修复)和develop(保证后续开发也包含此修复)。
- 当
我认为这个流程最大的优点是结构清晰、责任明确、自动化程度高。它通过分支隔离了开发,通过 PR 保证了代码质量,通过 CI 保证了基本的功能正确性,最终确保了 main 分支的绝对稳定。对于一个实习生来说,这套流程能让我快速融入团队,并且我的每一行代码都会经过审查,是一个非常好的学习和成长的过程。
如果由你来主动推进项⽬怎么做?如何与相关⼈⼠讨论什么内容
如果由我来主动推进项目,我会首先确保我对项目的核心目标和价值有清晰的理解,然后将其拆解为可执行的小任务和明确的时间节点。接着,我会主动发起沟通:和产品经理讨论确认需求的优先级和验收标准;和资深同事或导师讨论技术方案的可行性和潜在风险;和前端或后端等协作方明确接口定义和联调排期。在整个过程中,我会通过简短的站会或文档保持信息透明,确保大家对进度和遇到的问题有共同的认知,从而保证项目能高效、顺畅地向前推进。
智力题
烧绳子
你有两条绳⼦,每条都需要烧⼀个⼩时才能烧完。但是每条绳⼦在每个点都有不同的密度,所以不能保证它在不同的部分燃烧时间,所以你怎么使⽤这两条绳⼦衡量45分钟?
解决方法关键在于利用“同时从两端点燃”这个操作。
这是一个衡量45分钟的具体步骤:
开始计时 (0分钟):
- 拿起第一根绳子 (绳子A),从两端同时点燃。
- 同时,拿起第二根绳子 (绳子B),只从一端点燃。
30分钟时刻:
- 绳子A因为是从两端燃烧,所以会在30分钟时正好完全烧完。
- 在绳子A烧完的那一瞬间,立即将绳子B的另一端也点燃。
45分钟时刻:
- 在30分钟时,绳子B已经燃烧了30分钟,所以它剩下的燃烧时间也是30分钟。
- 当你把绳子B的另一端也点燃后,这根“剩下30分钟燃烧时间的绳子”就会在15分钟后烧完(因为30分钟的路程由两个火头分担)。
因此,从开始到绳子B最终烧完,总时间就是 $30分钟 + 15分钟 = 45分钟$。
缺陷球
你有12个单位球,其中⼀个球是⽐其它轻或者⽐其它重的,给你⼀个天平,你如何⽤三次测量就判断哪个球是有缺陷的?并确定它是偏重还是偏轻。
将球编号为 1 到 12,然后按以下步骤操作:
第一次称量将球分成三组:
- 左盘: 球 1, 2, 3, 4
- 右盘: 球 5, 6, 7, 8
- 桌上: 球 9, 10, 11, 12
这次称量会有三种结果:
情况A:天平平衡
- 推断: 缺陷球一定在桌上的四颗球(9, 10, 11, 12)中,而且1到8号球都是标准球。
- 进入第二次称量(情况A)
情况B:左盘下沉 (左重右轻)
- 推断: 缺陷球在参与称量的八颗球中。可能性是:(1, 2, 3, 4) 中有一个是重球,或者 (5, 6, 7, 8) 中有一个是轻球。
- 进入第二次称量(情况B)
情况C:右盘下沉 (左轻右重)
- 推断: 这和情况B完全对称。可能性是:(1, 2, 3, 4) 中有一个是轻球,或者 (5, 6, 7, 8) 中有一个是重球。
- 进入第二次称量(情况C)
第二次称量,现在根据第一次的结果进行操作:
第二次称量(情况A下)
- 我们知道缺陷球在 (9, 10, 11, 12) 中,且(1, 2, 3)是标准球。
- 左盘: 球 9, 10, 11
- 右盘: 球 1, 2, 3 (已知标准球)
- 结果1:天平平衡。说明 (9, 10, 11) 都是好球,那么缺陷球就是12号。为了判断轻重,进行第三次称量:将12号球与任意一个标准球(如1号)称量,即可知道12号是重是轻。
- 结果2:左盘下沉。说明缺陷球在 (9, 10, 11) 中,并且是重球。进行第三次称量:左盘放9号,右盘放10号。如果左盘下沉,则9号是重球;右盘下沉,则10号是重球;如果平衡,则11号是重球。
- 结果3:右盘下沉。说明缺陷球在 (9, 10, 11) 中,并且是轻球。进行第三次称量:左盘放9号,右盘放10号。如果左盘下沉,则10号是轻球;右盘下沉,则9号是轻球;如果平衡,则11号是轻球。
第二次称量(情况B下 - 左重右轻)
- 我们知道可能性是:(1,2,3,4)中一个重 或 (5,6,7,8)中一个轻。同时,9,10,11是标准球。
- 我们将这些可能性混合重组:
- 左盘: 球 1, 2, 5
- 右盘: 球 3, 6, 9
- 结果1:天平平衡。说明缺陷球在剩下的 (4, 7, 8) 之中。根据第一次称量,我们知道4只可能是重球,7和8只可能是轻球。进行第三次称量:左盘放7号,右盘放8号。如果平衡,则4号是重球;如果左盘下沉,8号更轻,则8号是轻球;如果右盘下沉,7号更轻,则7号是轻球。
- 结果2:左盘下沉。说明问题出在 (1, 2, 5, 3, 6, 9) 中。结合两次称量结果分析:不可能是3号(因为它在右盘),不可能是5号(它如果是轻球,右盘会下沉)。所以只可能是1号或2号是重球,或者6号是轻球。进行第三次称量:左盘放1号,右盘放2号。如果左盘下沉,1号是重球;右盘下沉,2号是重球;如果平衡,则6号是轻球。
- 结果3:右盘下沉。同样分析:不可能是1,2,6号。所以只可能是5号是轻球,或者3号是重球。进行第三次称量:左盘放3号,右盘放一个标准球(如9号)。如果左盘下沉,3号是重球;如果天平平衡,则5号是轻球。
第二次称量(情况C下 - 左轻右重)
- 这个情况和B完全相反,所有的推理逻辑镜像对称即可。
- 左盘: 球 1, 2, 5
- 右盘: 球 3, 6, 9
- 结果1:天平平衡。缺陷球在 (4, 7, 8) 中。4只可能是轻球,7和8只可能是重球。第三次称量7号和8号,重的胜出,平衡则是4号轻。
- 结果2:左盘下沉。5号是重球或3号是轻球。第三次称量3号和标准球,平衡则5号重,不平衡则3号轻。
- 结果3:右盘下沉。1号或2号是轻球或6号是重球。第三次称量1号和2号,轻的胜出,平衡则是6号重。
通过这个流程,无论出现哪种情况,我都能在第三次称量时准确地找到那个缺陷球,并确定它的重量是偏重还是偏轻。
100!里有多少个0
100! (100的阶乘) 的末尾有 24 个零。
一个数的末尾有零,是因为这个数是10的倍数。而10可以分解为质因数 $2 \times 5$。
在 100! 这个庞大的乘积($1 \times 2 \times 3 \times \dots \times 100$)中,我们需要找出有多少对 (2, 5)。
分析因子:在1到100的数字中,因子
2的数量远远多于因子5的数量(例如,所有偶数都包含因子2)。因此,(2, 5)对的数量实际上取决于有多少个因子5,因为5是这里的“短板”。计算因子5的数量:问题就简化为计算从1到100的所有数字总共能分解出多少个因子
5。- 首先,能被5整除的数会提供一个因子
5。这些数是 5, 10, 15, …, 100。
数量为:$100 \div 5 = 20$ 个。 - 其次,需要特别注意的是,能被25(即 $5^2$)整除的数会提供两个因子
5。在上面的计算中,我们只算了一个,所以需要把额外的这一个补上。这些数是 25, 50, 75, 100。
数量为:$100 \div 25 = 4$ 个。 - 再往上,能被125(即 $5^3$)整除的数会提供三个因子
5。但在1到100的范围内没有能被125整除的数。
- 首先,能被5整除的数会提供一个因子
得出结论:
将上面计算出的数量相加,就得到了因子5的总数:$20 + 4 = 24$ 个。
因为有24个因子5,我们就可以组成24对(2, 5),所以100!的末尾有 24 个零。
计算平均工资
员工们对工资好奇,但员工都有和公司签保密协议,每个人都不能告诉对方自己的工资,那你怎么知道你们的平均工资呢?
这是一个非常经典的关于“安全多方计算”(Secure Multi-Party Computation)问题的趣味版本。这个问题的核心在于,如何在不泄露个体数据的前提下,完成对群体数据的计算。
假设有 N 个员工,按顺序坐成一圈。
第一位员工(发起者):
- 想一个非常大的随机数(比如一个10位数,我们称之为“随机噪声R”)。
- 将 [自己的工资 + 随机噪声R] 的结果,告诉他/她旁边的第二位员工。
第二位员工:
- 拿到第一个员工传来的数字。
- 将 [这个数字 + 自己的工资] 的结果,传给第三位员工。
后续员工:
- 每一位员工都重复第二步的操作:将上一个人传来的数字加上自己的工资,再传给下一个人。
流程回到发起者:
- 当链条传了一圈后,最后一位员工会将最终的数字传回到第一位员工(发起者)手中。
- 此时,第一位员工拿到的数字是 [(所有人的工资总和)+ 他自己最初想的那个随机噪声R]。
计算结果:
- 第一位员工用他/她收到的最终数字,减去他/她自己最初加上的那个随机噪声R,就得到了所有人真实的总工资。
- 再用这个总工资除以员工人数 N,就得到了所有人的平均工资。
- 最后,由第一位员工向所有人公布这个计算出的平均工资。
这个方案如何保证保密性?
- 对于中间的员工:每个人拿到的都只是一个巨大的、无意义的中间数字,因为其中包含了那个神秘的“随机噪声R”,所以他们无法反推出任何人的工资信息。
- 对于发起者:他/她只知道最终的总工资,但无法知道除自己以外任何一个人的具体工资是多少。
这个方法巧妙地通过增加一个只有发起者知道的“噪声数据”,保护了所有人的隐私,同时又得出了准确的聚合结果。这是密码学和数据安全领域一个非常基础和重要的思想。
蒙提霍尔问题
参赛者会看见三扇门,其中一扇门的里面有一辆汽车,选中里面是汽车的那扇门,就可以赢得该辆汽车,另外两扇门里面则都是一只山羊。当参赛者选定了一扇门,主持人会开启另一扇是山羊的门。问:要不要换一扇门?
关于这个问题的仿真实验: https://education.ti.com/-/media/ti/files/china/downloads/pdf/montyhall_problem_simulation.pdf
答案是:一定要换。
换门之后,赢得汽车的概率会从 1/3 变为 2/3。
情况1:你第一次就选对了车门(概率为 1/3)
- 在这种情况下,主持人会在另外两个“山羊门”中随便打开一扇。
- 如果你换门,你必然会选中另一扇“山羊门”,你会输。
情况2:你第一次选的是一扇山羊门(概率为 2/3)
- 在这种情况下,剩下的一扇门是车,一扇门是羊。
- 主持人必须打开另一扇有山羊的门(他不能打开车门,也不能打开你选的门)。
- 所以,剩下的那扇未被打开的门必然是车门。
- 如果你换门,你就必然会选中车门,你会赢。
结论:
- 只有在你一开始就蒙对(1/3的概率)的情况下,不换才能赢。
- 只要你一开始选错了(2/3的概率),换门就一定会赢。
所以,选择换门,你获胜的概率是 2/3,远高于不换门的 1/3。
如果还是觉得有点绕,可以想象一个更极端的版本:
假设有100扇门,1扇门后是汽车,剩下99扇门后都是山羊。
- 你选了一扇门(比如7号门)。你选对的概率是
1/100,选错的概率是99/100。 - 这时,知道车在哪里的主持人,在剩下的99扇门中,打开了98扇有山羊的门。现在场上只剩下你最初选的7号门,和另一扇未被打开的门(比如54号门)。
- 主持人问你:“你要不要从7号门换到54号门?”
现在感觉是不是非常清晰了?
- 你最初选的7号门,它有车的概率依然是渺茫的
1/100。 - 而主持人帮你排除了其他98个错误答案,相当于把那
99/100的巨大获胜概率,全都集中到了剩下的54号门上!
在这种情况下,肯定会毫不犹豫地选择换门。
三扇门的逻辑和一百扇门是完全一样的,只是因为数字小,所以迷惑性更强。主持人的行为不是随机的,他利用自己掌握的“内部信息”(车在哪扇门后)帮你排除了一个错误选项,这个行为本身极大地改变了剩下那扇门的获胜概率。
最少赛马问题
有25只马,每个马的速度都和其它马不⼀样。因为赛场只有5个赛道,所以一次比赛最多五只马,你需要找出最快的三只马,需⽤最少的比赛场次是多少?
将25匹马分成5个小组(A, B, C, D, E),每组5匹马。对每个小组进行一次比赛。
这需要5场比赛。比赛结束后,我们得到每个小组内马匹的排名。例如,A组的排名是 A1 > A2 > A3 > A4 > A5 (A1最快)。
现在我们有了5个小组的冠军(A1, B1, C1, D1, E1)。但这并不意味着他们就是总排名的前五,我们只知道他们是各自小组中最快的。
让这5匹小组冠军进行一场比赛。这是第6场比赛。假设这场比赛的结果是:A1 > B1 > C1 > D1 > E1。
根据这个结果,我们可以得出几个关键结论:
- A1 是毫无疑问的总冠军(第1名)。
- D1和E1被淘汰了。
- D组和E组的所有其他马(D2-D5, E2-E5)也都被淘汰了。
我们已经找到了第1名(A1),现在需要从剩下的马中找出第2名和第3名。
谁有可能是第2名或第3名呢?
- C组的马:C1在冠军赛中是第3名,在它前面已经有A1和B1。所以C1有可能是第3名。但C组更慢的马(C2, C3等)不可能是前三,所以它们被淘汰。
- B组的马:B1在冠军赛中是第2名,它有可能是总排名的第2或第3。B2在小组赛中仅次于B1,所以B2也有可能是总排名的第3名。B3及更慢的马则不可能,因为至少A1, B1, B2都比它快。
- A组的马:A2和A3在小组赛中仅次于总冠军A1,所以它们都有可能是总排名的第2或第3名。
综上,有资格争夺第2名和第3名的候选马匹只剩下 5 匹:
- B1 和 C1 (冠军赛的亚军和季军)
- A2 和 A3 (总冠军所在小组的亚军和季军)
- B2 (冠军赛亚军所在小组的亚军)
让这5匹马(B1, C1, A2, A3, B2)进行最后一场比赛。这是第7场比赛。这场比赛的前两名,就是我们最终要找的总排名第2和第3的马。











