阅读视图

发现新文章,点击刷新页面。

Swift6 @retroactive:Swift 的重复协议遵循陷阱

欢迎大家给我点个 star!Github: RickeyBoy

背景:一个看似简单的 bug

App 内有一个电话号码输入界面,在使用时用户需要从中选择注册电话对应的国家,以获取正确的电话区号前缀(比如中国是 +86,英国是 +44 等)。

Step 1:入口 Step 2:缺少区号 期望结果
image1.png image2.png image3.png

这是一个看似很简单的 bug,无非就是写 UI 的时候漏掉了区号,那么把对应字段拼上去就行了嘛。不过一番调查之后发现事情没有那么简单。

列表是一个公用组件,我们需要在列表中显示国家及其电话区号,格式像这样:"🇬🇧 United Kingdom (+44)"。所以之前在 User 模块中添加了这个extension:

    extension Country: @retroactive DropdownSelectable {
        public var id: String {
            code
        }
    
        public var displayValue: String {
            emoji + "\t(englishName) ((phoneCode))"
        }
    }

原理一看就明白,displayValue 代表的是展示的内容。但是最终结果展示错误了:明明将电话区号 ((phoneCode)) 拼在了上面,为什么只显示了国家名称:"🇬🇧 United Kingdom"?

代码可以编译。测试通过。没有警告。但功能在生产环境中却是坏的。

顺便说一下,什么是 DropdownSelectable?

DropdownSelectable 是我们 DesignSystem 模块中的一个协议,它使任何类型都能与我们的下拉 UI 组件配合使用:

    protocol DropdownSelectable {
        var id: String { get }           // 唯一标识符
        var displayValue: String { get } // 列表中显示的内容
    }

Part 1: extension 不起作用了

发现问题

经过调试后,我们发现了根本原因:Addresses 模块已经有一个类似的 extension

    // 在 Addresses 模块中
    extension Country: @retroactive DropdownSelectable {
        public var displayValue: String {
            emoji + "\t(englishName)"  // 没有电话区号
        }
    }
Step 1 Step 2
image4.png image5.png

Addresses 模块不需要电话区号,只需要国家名称。这对地址列表来说是合理的。

但关键是:Addresses extension 在运行时覆盖了我们 User extension。我们以为在使用 User 模块的extension(带电话区号),但 Swift 随机选择了 Addresses 的 extension(不带电话区号)。

这就是关键问题。

冲突:同时存在两个拓展协议

代码中发现的两处冲突的拓展协议:

在 User 模块中(我们以为在使用的):

    extension Country: @retroactive DropdownSelectable {
        public var id: String {
            code
        }
        public var displayValue: String {
            emoji + "\t(englishName) ((phoneCode))"  // ✅ 带电话区号
        }
    }

在 Addresses 模块中(实际被使用的):

    extension Country: @retroactive DropdownSelectable {
        public var id: String {
            code
        }
        public var displayValue: String {
            emoji + "\t(englishName)"  // ❌ 不带电话区号
        }
    }

两个模块都有各自合理的实现理由:

  • User 模块:电话号码输入界面需要电话区号
  • Addresses 模块:地址表单不需要电话区号,只需要国家名称

每个开发者都在实现需求时添加了他们需要的内容。代码编译没有警告,新需求测试通过,没人预料到会对旧的需求产生影响。

同时,确实 Swift 也是允许在不同模块中使用相同的 extension。那么到底发生了什么,我们又是如何解决的呢?

Part 2: 为什么会发生这种情况 - Swift 模块系统解析

要理解为什么这是一个问题,我们需要理解 Swift 的模块系统是如何工作的。有趣的是:通常情况下,在不同模块中有相同的 extension 是完全没问题的。但协议遵循是一个特殊情况。

正常情况:extension 在模块间通常工作良好

假设你为一个类型添加了一个辅助方法:

    // 在 UserModule 中
    extension Country {
        var displayValue: String {
            return emoji + "\t(englishName) ((phoneCode))"
        }
    }
    // 在 AddressesModule 中
    extension Country {
        var displayValue: String {
            return emoji + "\t(englishName)"
        }
    }

这完全可以!每个模块看到的是它自己的extension:

  • UserModule 中的代码调用 displayValue 会得到带 phoneCode 的结果
  • AddressesModule 中的代码调用 displayValue 会得到不带 phoneCode 的结果

为什么可以: 常规 extension 方法在编译时根据导入的模块来解析。Swift 根据当前模块的导入准确知道要调用哪个方法。

特殊情况:协议遵循是全局的

但协议遵循的工作方式不同。当你写:

    extension Country: DropdownSelectable {
        var displayValue: String { ... }
    }

你不只是在添加一个方法。你在做一个全局声明:"对于整个应用程序,Country 遵循 DropdownSelectable。"

所以当你创建两个相同的遵循时,会导致重复遵循错误

    // 在 UserModule 中
    extension Country: DropdownSelectable {
        var displayValue: String {
            return emoji + "\t(englishName) ((phoneCode))"
        }
    }
    // 在 AddressesModule 中
    extension Country: DropdownSelectable {
        var displayValue: String {
            return emoji + "\t(englishName)"
        }
    }

当你构建链接两个模块的应用时,Swift 编译器或链接器会报错,类似这样:

'Country' declares conformance to protocol 'DropdownSelectable' multiple times

Part 3: 引入 @retroactive 破坏了编译器检查

剩余问题:这怎么能编译通过?

基本上,如果我们遇到重复遵循错误,编译器会阻止我们。但是为什么这段代码可以正常存在?

一切问题都可以被归咎于 @retroactive

什么是 @retroactive?

在 Swift 6 中,Apple 引入了 @retroactive 关键字来让跨模块遵循变得明确:

    extension Country: @retroactive DropdownSelectable {
        // 让一个外部类型
        // 遵循一个外部协议
    }

你需要使用 @retroactive 当:

  • 类型定义在不同的模块中(例如,来自模块 A 的 Country
  • 协议定义在不同的模块中(例如,来自模块 B 的 DropdownSelectable
  • 你在第三个模块中添加遵循(例如,在 UserModuleAddressesModule 中)

为什么 @retroactive 会破坏编译器检查重复编译问题?

没有 @retroactive 的情况下,重复遵循已经是编译时错误。但有了 @retroactive,问题变得更加棘手 —— 因为现在你明确声明了影响整个应用运行时的东西,而不仅仅是你的模块。

当你写 @retroactive 时,你在说:

"我要为一个我不拥有的现有类型添加遵循,作用于整个 App。"

这意味着编译器允许你 追溯地/逆向地(retroactively) 为在其他地方定义的类型添加遵循。这很强大,但也改变了 Swift 检查重复的方式。

关键点:

Swift 在每个模块内强制执行重复遵循规则,但不跨模块。换句话说,编译器只检查它当前正在构建的代码。

  • 每个生产者模块(UserModule、AddressesModule)单独编译时是正常的(它只"看到"自己的遵循)。到目前为止是正常的。
  • 导入两者的消费者(至少你有一个,就是你的 app target!),会构建失败,因为它看到了两个相同的协议遵循

添加 @retroactive 之后:

使用 @retroactive,Swift 将一些检查推迟到链接时,所以两个模块都能成功编译,即使它们都在声明相同的全局遵循。

重复只有在链接之后才会变得可见,当两个模块都被加载到同一个运行时镜像中时 —— 而那时,编译器已经太晚无法阻止它了。

这就是为什么这些重复可以"逃过"编译器的安全检查,导致令人困惑的运行时级别的 bug。

运行时发生了什么

当链接器发现 (Country, DropdownSelectable) 有两个实现时:

  • 选项 A:UserModule 的实现(带电话区号)
  • 选项 B:AddressesModule 的实现(不带电话区号)

它只能注册一个。所以它根据链接顺序选择一个 —— 基本上是链接器首先处理的那个模块。另一个遵循会被静默忽略。

这解释了为什么 UserModule 的实现被忽略了。

Part 4: 解决方案 - 包装结构体来拯救

幸运的是我们有一个非常简单的修复方法:使用包装类型

解决方案模式

不要让 Country 本身遵循协议,而是包装它:

    // UserModule 示例
    struct CountryWithPhoneDropdown: DropdownSelectable {
        let country: Country
        var id: String { country.code }
        var displayValue: String {
            country.emoji + "\t(country.englishName) ((country.phoneCode))"
        }
    }
    // AddressModule 示例
    struct CountryAddressDropdown: DropdownSelectable {
        let country: Country

        var id: String { country.code }
        var displayValue: String {
            country.emoji + "\t(country.englishName)"
        }
    }
    // 使用方式
    countries.map { CountryWithPhoneDropdown(country: $0) }
    countries.map { CountryAddressDropdown(country: $0) }

Part 5: 预防 — 如何防止它再次发生

当然,如果想要不仅是修复这个问题,而是预防这个问题,那么可以通过在工作流程中添加静态分析CI 检查来轻松避免重复的 @retroactive 遵循。

这确保任何重复的 @retroactive 遵循在到达生产环境之前被发现,避免类似的运行时错误。

结语

这个 bug 根本不是简单的 UI 问题,想要彻底解决就需要深度理解 Swift 的运行机制。协议拓展可以跨模块重复,但协议遵循是全局的,@retroactive 叠加 Swift 的这种能力造成了这次的 bug。

一旦我们理解了这一点,修复就很简单了。

iOS App进入后台时会发生什么

根据官方文档(Extending your app’s background execution timeManaging your app’s life cycle)显示

文档查阅时间为2025-11-20

  • 正常情况下,App先进入Background状态,紧接着(时间长短取决于是否通过beginBackgroundTask方法发起任务,但总体都很短)进入Suspended
  • 进入后台时applicationDidEnterBackground会执行,同时进入Background状态,该方法会有5s时间执行其中的任务
  • applicationDidEnterBackground之后,如果没有beginBackgroundTask发起的后台任务,则很快就会进入Suspended状态
  • 进入Suspended状态,App还会在内存中
  • BackgroundSuspended状态的不同点是
    • Background状态下,是可以执行短暂的后台任务
    • Suspended状态下,App代码便不会再执行,收不到任何通知,而且系统可以根据(未其他App腾出内存空间)需要将App终止掉
  • 即使通过beginBackgroundTask开启后台任务也不过最多有30s的执行时间,仅适合执行一些非常重要的任务,比如将一些严重影响用户体验的数据写入磁盘,用作后续状态恢复
  • 如果希望申请更多后台任务执行时间,则需要依赖Background Tasks框架

b0d3522d-c62b-49e2-9f48-01b0a4046b11.png

最近苹果审核效率提高了,周末竟然都在审核。

背景

最近没有怎么更新文章,是因为在迭代之前的老产品。正好把这今天自己迭代的情况,讲一下。

更新了7款产品,其中4款工具类产品,1款生活类,1款娱乐类,1款参考类。

工具类

从迭代周期和迭代时间来看,工具类的迭代时间和之前一样。平均在40分左右出结果,都是一把过。毕竟工具类二进制改动不多,主要是维护一些细节性兼容问题。

777.png

值得关注的是,基本上在16号,(即周末)提交的产品,基本上没有多久就进入正在审核状态。这一点和上一月截然不同,如果是上个月周末提交的产品,普遍在周三晚上左右才会进入审核环节。

其余分类

分类名 等待时间 审核时间 被拒次数
生活 1 天 65 分钟 0
娱乐 2 天半 45 分钟 1
参考 1 天半 90 分钟 0

基本上来说,整体都非常顺利。娱乐被拒的原因是因为文案说明包含了"免费体验"相关的字眼,更新到了市场截图里。 对于苹果来说,不接受在市场图,APP名称以及副标题,体现任何价格相关的元素。

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

Nano Banana Pro 深夜炸场,但最大的亮点不是 AI 生图

奥特曼,迎来至暗时刻。

Google 的 AI 攻势没有半点减弱的迹象。如果说前几天 Gemini 3 Pro 的镰刀伸向了「前端」领域,今天,被颠覆的行业轮到了设计行业,刚刚发布的 Nano Banana Pro(Gemini 3 Pro Image)再次在图像生成能力上重拳出击。

初级设计师的饭碗,怕是要端不稳了。

核心功能如下:

  • 分辨率支持:可输出 1K、2K、4K 分辨率图像
  • 多轮编辑:支持对话式、多轮次的图像编辑工作流
  • 多图像合成:最多可将 14 张输入图像组合为 1 张输出图像
  • 搜索增强:集成 Google 搜索能力,提供更精确、最新的知识支持

不再「瞎猜」,Nano Banana Pro 终于学会了先思考再画画

Nano Banana 的招牌能力是角色一致性强、对话编辑方式,而 Nano Banana Pro 的核心进化在于它把 Gemini 3 的深度思考能力完整接进了图像生成流程。

它生成一张图之前,会先做一轮物理模拟和逻辑推演,而不只是凭视觉模式「胡猜」。

▲提示词:请绘制一张四宫格图片,四张图依次表现同一位戴着斗笠的年轻男子分别发音「我」「上」「早」「八」,人物外貌保持一致,口型准确对应每个字的发音,整体风格统一,16:9,4K

跨模态理解也在 Nano Banana Pro 身上展现得更为彻底。

凭借 Gemini 3 增强的多语言推理能力,你可以直接生成多种语言的文字,或者一键本地化、翻译你的内容。

朋友丢来一页漫画,让模型给漫画上色并把气泡里的英文翻成中文。Nano Banana Pro 上色干净,光影自然,文字识别准确,英文排版也和气泡形状严丝合缝,整个过程从识别到翻译再到重排一气呵成,表现得就像在真正「理解」这张图。

▲提示词:将图片上的文字翻译为中文,并上色,其他不变

又或者,设计师过去需要反复调整的多语言漫画、国际化海报以及宣传物料,现在可以直接让 AI 一步到位。比如让模型将英文海报中的英文翻译成中文。这种从识别、翻译到设计的连贯处理方式,正是原生多模态架构最具威力的一面。

而在文字生成能力上,Nano Banana Pro 更是表现出色,无论是一句短标语还是一整段文字,都能清晰可读,甚至支持多种纹理、字体与书法风格的精细排版。

▲提示词:仿古籍线描插图风,关羽坐于油灯旁,身披宽袖战袍,神态专注沉稳。桌案上摆着《春秋》竹简、鎏金小刀、毛笔等器物,以纤细线条勾画,保留古印刷风格。背景仅以几笔勾勒墙角、屏风与兵器架,简洁却富古雅气息。色彩以浅赭、灰墨、淡青为主,呈现古书插画的文化韵味与历史感,4:3。

64k 的输入 Token 上限意味着它能理解极长的文本提示词。无论是详细的分镜脚本,还是复杂的多语言排版需求,都能更好理解。

▲提示词:生成一幅 4K 古画,画上写着:明月几时有?把酒问青天。不知天上宫阙,今夕是何年。我欲乘风归去,又恐琼楼玉宇,高处不胜寒。起舞弄清影,何似在人间。转朱阁,低绮户,照无眠。不应有恨,何事长向别时圆?人有悲欢离合,月有阴晴圆缺,此事古难全。但愿人长久,千里共婵娟。

针对前代分辨率偏低的老问题,Nano Banana Pro 把画质一步拉到 4K,还允许自由设定任何长宽比。电影海报、宽屏壁纸、纵向分镜,统统能直接生成。

Nano Banana Pro 还支持最多 14 张输入图像的组合编辑,同时保持最多 5 个角色的外貌一致。

配合多轮对话能力,用户可以不断调整、融合多个素材,直到达到理想效果。不论是把草图变成产品,还是将蓝图转换成逼真的 3D 建筑,都能轻松实现概念到成品的跨越。

▲提示词:哆啦A梦和李白在月下对酌。圆月高悬,古代亭台楼阁,哆啦A梦穿着唐朝服饰,李白持酒壶,石桌上摆着酒具,仙气飘飘,中日混合画风,精致细节

更进阶的是专业级创意控制能力。

你可以选择、微调或变换图像中的任何部分,从调整镜头角度、改变焦点到应用高级调色,甚至改变场景光照——把白天变成夜晚,或创造散景效果,这些过去需要在 Photoshop 里精细操作的工作,现在只需要一句话。

▲提示词:Transform the [camera] from the uploaded photo into a bold, colorful cartoon illustration style, while keeping the rest of the photo realistic and unchanged. Cartoon style details: thick black outlines, vibrant flat colors (such as bright cyan, magenta, yellow, pink), dripping paint and splash effects, playful comic-book energy. most drips flow downwards.The cartoon object should look like it is melting or bursting with colors, blending naturally into the real photo. Keep all other elements (background, other objects, environment) photorealistic with no alterations. High resolution, pop-art aesthetic, surreal contrast between realism and cartoon.

搜索 + 生成 = ?Google 给出了终极答案

如果说搜索是 Gemini 3 的「左脑」,那么图像生成就是其「右脑」。

这也是 Nano Banana Pro(Gemini 3 Pro Image)架构中被低估但最具颠覆性的能力。传统搜索是用户搜索、搜索引擎给链接、用户点进网站、网站提供界面。而 Nano Banana Pro 引入了搜索增强功能(Grounding with Search)。

当用户要求生成一张可视化的图片,展示在广州旅游的 2 天行程」时,Nano Banana Pro 生成的图片,包含了详细的行程地图、中英文注释、以及景点图片等。

再比如 Nano Banana Pro 能根据提示词要求,从搜索中获取最新天气状况,再把温度、风力、湿度、天气趋势等关键数据转化为鲜明、富有设计感的视觉内容。

▲提示词:搜索广州实时天气信息,制作一幅中文波普艺术风格的信息图,4:3

这项能力之所以重要,是因为它让创造过程具备了事实基础、实时性和可验证性。只能说,搜索不愧是 Google 的看家本领,无论是技术积攒的厚度,还是在理解上就已经领先一个身位。

在产品定位上,Google 采用了双模型策略:旧版 Nano Banana 用于快速有趣的日常编辑,而 Nano Banana Pro 则专注于复杂构图与顶级画质的专业需求。用户可以根据场景自由选择。

对于消费者与学生,Nano Banana Pro 已在 Gemini 应用中全球开放,只需选择「生成图像」并启用「Thinking(思考)」模式即可使用。免费用户会获得有限额度,超出后将自动切回原版 Nano Banana。

而 Google AI Plus、Pro 和 Ultra 订阅用户则拥有更高额度。在美国地区,Google 搜索的 AI 模式中,Pro 与 Ultra 用户已经可以体验 Nano Banana Pro。NotebookLM 中的 Nano Banana Pro 也面向全球订阅用户开放。

值得注意的是,Google 在 AI 透明度问题上采取了双重策略。

所有 AI 生成的内容都会嵌入不可见的 SynthID 数字水印,用户现在可以在 Gemini 应用中直接上传图像,询问它是否由 Google AI 生成。这项能力将很快扩展到音频与视频。

既然 Nano Banana Pro 已经强大到这个地步,那么问题来了,普通人该如何最大化发挥它的能力?

Google DeepMind 的产品经理 Bea Alessio 给出了一份详细的使用指南,其中透露出不少关键信息。最基本的使用方式当然是随便说一句话,让模型自己猜你想要什么。但如果你想达到专业水准,就需要像导演一样思考。

一个完整的提示词应该包含六个要素:主体(谁或什么)、构图(如何取景)、动作(正在发生什么)、场景(在哪里)、风格(什么审美)、编辑指令(如何修改)。

而如果你想要更精细的控制,还需要进一步明确:画幅比例(9:16 竖版海报还是 21:9 电影宽屏)、镜头参数(低角度、浅景深 f/1.8)、光线细节(逆光的黄金时刻,拉长阴影)、调色方向(电影级调色,偏青绿色调)、以及具体的文字内容和样式。

附上官方博客地址:https://blog.google/products/gemini/prompting-tips-nano-banana-pro/

这种「摄影指导式」的提示词写法,正是 Nano Banana Pro 和传统图像生成模型的分水岭。因为它真的能理解这些专业术语,并把它们准确地转化为视觉输出。

看到这里,再回过头看 Google 这几天连环发布的产品,就不难明白它想传达什么。

无论是前几天发布的 Gemini 3 Pro 预览版,还是今天亮相的 Nano Banana Pro ,Google 试图向世人证明:通往 AGI(通用人工智能)的道路,必须是多模态原生的。

只有一个能看、能听、能理解结构、能处理逻辑的模型,才可能对世界进行完整地「思考」。

从技术层面看,Nano Banana 系列模型让图像生成正式进入了「先理解再表达」的阶段。

当 AI 开始理解迷宫的路径、物体的结构、文字的含义甚至 UI 的交互逻辑时,它就不再只是一个画图工具,而是一个具备视觉思维能力的智能体。

从商业层面看,极低的推理成本和生成式 UI 的出现,将彻底改变内容生产和信息分发的逻辑。过去的互联网由一个个固定网页构成,而未来的互联网更可能是一块块随着你需求即时生长的界面。

设计将不再只是人的手艺,界面也不再是由团队层层打磨的成果。越来越多的视觉内容,会先交给 AI,再由人去补充或微调。Google 显然已经提前看见了那个新世界,并且开始把入口推到所有人面前。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


每日一题-长度为 3 的不同回文子序列🟡

给你一个字符串 s ,返回 s长度为 3 不同回文子序列 的个数。

即便存在多种方法来构建相同的子序列,但相同的子序列只计数一次。

回文 是正着读和反着读一样的字符串。

子序列 是由原字符串删除其中部分字符(也可以不删除)且不改变剩余字符之间相对顺序形成的一个新字符串。

  • 例如,"ace""abcde" 的一个子序列。

 

示例 1:

输入:s = "aabca"
输出:3
解释:长度为 3 的 3 个回文子序列分别是:
- "aba" ("aabca" 的子序列)
- "aaa" ("aabca" 的子序列)
- "aca" ("aabca" 的子序列)

示例 2:

输入:s = "adc"
输出:0
解释:"adc" 不存在长度为 3 的回文子序列。

示例 3:

输入:s = "bbcbaba"
输出:4
解释:长度为 3 的 4 个回文子序列分别是:
- "bbb" ("bbcbaba" 的子序列)
- "bcb" ("bbcbaba" 的子序列)
- "bab" ("bbcbaba" 的子序列)
- "aba" ("bbcbaba" 的子序列)

 

提示:

  • 3 <= s.length <= 105
  • s 仅由小写英文字母组成

播放状态与播放序列的关系(999篇一线博客第107篇)

说到今天这个课题之前,想先同步一个点,就是现在b站上会把一个大视频分为很多段。

这样就导致,

1 学习的获得感很强,能很清楚的确定一个视频的知识点

2 但相对的,也很难快速的整理一个系统性的知识

我今天分享的,和这个不直接相关,而是一个简单视频或者音频里,为了保证连续播放,手动分割的音频段,如何保证播放的序列性以及播放状态的同步。

首先,能确定的事,整体的状态,是基于第一个片段的开始,以及最后最后一个片段的播放结束。

但播放的实际开始,其实取决于audio或者video标签的播放事件,更加准确。

因此,我的设计要点分为两个大的方面。

一 播放状态的整体设计放在播放器。

其 收到第一个音频片段,认为是开始加载 ,状态1;

音频控件收到对应的播放事件,状态2 ;

收到播放序列管理器的调用,执行播放结束,状态3 ;

二 播放序列管理器

其是一个音频分割之后,管理序列的工具。

具体来说,其包括几个生命周期。

2.1 收到一个新的音频,重置,并分割得到新序列,进行初始化(状态1)

2.2 将序列中的第一个放到播放控件中(状态2)

2.3 订阅播放器的播放完成事件,将第二个地址同步给播放控件;直到最后一个;

2.4 当最后一个播放完成,发现没有新的片段需要播放时,调用播放器的状态改变,切换播放器的状态为状态3,自己也进入(状态3)

2.5 任何时候,如果收到新的音频,都将重置,并分割得到新序列(重新进入状态1)

之所以播放器和播放序列分开管理是因为两者对外部的影响不同。

比如对于业务,其只关心整个音频是否加载完。

而对于序列管理器,其只关心自己的序列是否正确,是否将正确的片段传给了播放器,以及是否订阅了节点事件来保证序列顺序和节点的正确性。

拓展:如果哪天,我们需要支持暂停,或者支持指定节点的播放,那么道理也是一样的。需要明确的把播放,和播放列表明确的分开,并通过合适的订阅或者事件机制关联起来。

前端笔记999篇持续更新中,欢迎订阅。 链接

长度为 3 的不同回文子序列

方法一:枚举两侧的字符

思路与算法

我们可以枚举回文序列两侧的字符种类。对于每种字符,如果它在字符串 $s$ 中出现,我们记录它第一次出现的下标 $l$ 与最后一次出现的下标 $r$。那么,以该字符为两侧的回文子序列,它中间的字符只可能在 $s[l+1..r-1]$ 中选取;且以该字符为两侧的回文子序列的种数即为 $s[l+1..r-1]$ 中的字符种数。

我们遍历 $s[l+1..r-1]$ 子串计算该子串中的字符种数。在遍历时,我们可以使用哈希集合来维护该子串中的字符种类;当遍历完成后,哈希集合内元素的数目即为该子串中的字符总数。

在枚举两侧字符种类时,我们维护这些回文子序列种数之和,并最终作为答案返回。

代码

###C++

class Solution {
public:
    int countPalindromicSubsequence(string s) {
        int n = s.size();
        int res = 0;
        // 枚举两侧字符
        for (char ch = 'a'; ch <= 'z'; ++ch){
            int l = 0, r = n - 1;
            // 寻找该字符第一次出现的下标
            while (l < n && s[l] != ch){
                ++l;
            }
            // 寻找该字符最后一次出现的下标
            while (r >= 0 && s[r] != ch){
                --r;
            }
            if (r - l < 2){
                // 该字符未出现,或两下标中间的子串不存在
                continue;
            }
            // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
            unordered_set<char> charset;
            for (int k = l + 1; k < r; ++k){
                charset.insert(s[k]);
            }
            res += charset.size();
        }
        return res;
    }
};

###Python

class Solution:
    def countPalindromicSubsequence(self, s: str) -> int:
        n = len(s)
        res = 0
        # 枚举两侧字符
        for i in range(26):
            l, r = 0, n - 1
            # 寻找该字符第一次出现的下标
            while l < n and ord(s[l]) - ord('a') != i:
                l += 1
            # 寻找该字符最后一次出现的下标
            while r >= 0 and ord(s[r]) - ord('a') != i:
                r -= 1
            if r - l < 2:
                # 该字符未出现,或两下标中间的子串不存在
                continue
            # 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
            charset = set()
            for k in range(l + 1, r):
                charset.add(s[k])
            res += len(charset)
        return res

###Java

class Solution {
    public int countPalindromicSubsequence(String s) {
        int n = s.length();
        int res = 0;
        // 枚举两侧字符
        for (char ch = 'a'; ch <= 'z'; ++ch) {
            int l = 0, r = n - 1;
            // 寻找该字符第一次出现的下标
            while (l < n && s.charAt(l) != ch) {
                ++l;
            }
            // 寻找该字符最后一次出现的下标
            while (r >= 0 && s.charAt(r) != ch) {
                --r;
            }
            if (r - l < 2) {
                // 该字符未出现,或两下标中间的子串不存在
                continue;
            }
            // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
            Set<Character> charset = new HashSet<>();
            for (int k = l + 1; k < r; ++k) {
                charset.add(s.charAt(k));
            }
            res += charset.size();
        }
        return res;
    }
}

###C#

public class Solution {
    public int CountPalindromicSubsequence(string s) {
        int n = s.Length;
        int res = 0;
        // 枚举两侧字符
        for (char ch = 'a'; ch <= 'z'; ++ch) {
            int l = 0, r = n - 1;
            // 寻找该字符第一次出现的下标
            while (l < n && s[l] != ch) {
                ++l;
            }
            // 寻找该字符最后一次出现的下标
            while (r >= 0 && s[r] != ch) {
                --r;
            }
            if (r - l < 2) {
                // 该字符未出现,或两下标中间的子串不存在
                continue;
            }
            // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
            HashSet<char> charset = new HashSet<char>();
            for (int k = l + 1; k < r; ++k) {
                charset.Add(s[k]);
            }
            res += charset.Count;
        }
        return res;
    }
}

###Go

func countPalindromicSubsequence(s string) int {
    n := len(s)
    res := 0
    // 枚举两侧字符
    for ch := 'a'; ch <= 'z'; ch++ {
        l, r := 0, n-1
        // 寻找该字符第一次出现的下标
        for l < n && rune(s[l]) != ch {
            l++
        }
        // 寻找该字符最后一次出现的下标
        for r >= 0 && rune(s[r]) != ch {
            r--
        }
        if r-l < 2 {
            // 该字符未出现,或两下标中间的子串不存在
            continue
        }
        // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
        charset := make(map[rune]bool)
        for _, c := range s[l+1:r] {
            charset[c] = true
        }
        res += len(charset)
    }
    return res
}

###C

int countPalindromicSubsequence(char* s) {
    int n = strlen(s);
    int res = 0;
    // 枚举两侧字符
    for (char ch = 'a'; ch <= 'z'; ++ch) {
        int l = 0, r = n - 1;
        // 寻找该字符第一次出现的下标
        while (l < n && s[l] != ch) {
            ++l;
        }
        // 寻找该字符最后一次出现的下标
        while (r >= 0 && s[r] != ch) {
            --r;
        }
        if (r - l < 2) {
            // 该字符未出现,或两下标中间的子串不存在
            continue;
        }
        // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
        bool charset[26] = {false};
        for (int k = l + 1; k < r; ++k) {
            charset[s[k] - 'a'] = true;
        }
        int count = 0;
        for (int i = 0; i < 26; ++i) {
            if (charset[i]) {
                count++;
            }
        }
        res += count;
    }
    return res;
}

###JavaScript

var countPalindromicSubsequence = function(s) {
    const n = s.length;
    let res = 0;
    // 枚举两侧字符
    for (let ch = 'a'.charCodeAt(0); ch <= 'z'.charCodeAt(0); ch++) {
        const c = String.fromCharCode(ch);
        let l = 0, r = n - 1;
        // 寻找该字符第一次出现的下标
        while (l < n && s[l] !== c) {
            ++l;
        }
        // 寻找该字符最后一次出现的下标
        while (r >= 0 && s[r] !== c) {
            --r;
        }
        if (r - l < 2) {
            // 该字符未出现,或两下标中间的子串不存在
            continue;
        }
        // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
        const charset = new Set();
        for (let k = l + 1; k < r; k++) {
            charset.add(s[k]);
        }
        res += charset.size;
    }
    return res;
};

###TypeScript

function countPalindromicSubsequence(s: string): number {
    const n = s.length;
    let res = 0;
    // 枚举两侧字符
    for (let ch = 'a'.charCodeAt(0); ch <= 'z'.charCodeAt(0); ch++) {
        const c = String.fromCharCode(ch);
        let l = 0, r = n - 1;
        // 寻找该字符第一次出现的下标
        while (l < n && s[l] !== c) {
            ++l;
        }
        // 寻找该字符最后一次出现的下标
        while (r >= 0 && s[r] !== c) {
            --r;
        }
        if (r - l < 2) {
            // 该字符未出现,或两下标中间的子串不存在
            continue;
        }
        // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
        const charset = new Set<string>();
        for (let k = l + 1; k < r; k++) {
            charset.add(s[k]);
        }
        res += charset.size;
    }
    return res;
}

###Rust

use std::collections::HashSet;

impl Solution {
    pub fn count_palindromic_subsequence(s: String) -> i32 {
        let mut res = 0;
        // 枚举两侧字符
        for ch in 'a'..='z' {
            // 使用迭代器找到第一个和最后一个出现位置
            let mut chars = s.chars();
            let l = chars.position(|c| c == ch);
            let r = chars.rev().position(|c| c == ch).map(|pos| s.len() - 1 - pos);
            if let (Some(l), Some(r)) = (l, r) {
                if r > l + 1 {
                    // 收集中间字符
                    let unique_chars: HashSet<_> = s[l+1..r].chars().collect();
                    res += unique_chars.len() as i32;
                }
            }
        }
        res
    }
}

复杂度分析

  • 时间复杂度:$O(n|\Sigma| + |\Sigma|^2)$,其中 $n$ 为 $s$ 的长度,$|\Sigma|$ 为字符集的大小。我们总共需要枚举 $|\Sigma|$ 种字符,每次枚举至多需要遍历一次字符串 $s$ 与哈希集合,时间复杂度分别为 $O(n)$ 与 $O(|\Sigma|)$。

  • 空间复杂度:$O(|\Sigma|)$,即为哈希集合的空间开销。

方法二:枚举中间的字符

思路与算法

我们也可以遍历字符串 $s$ 枚举回文子序列中间的字符。假设 $s$ 的长度为 $n$,当我们遍历到 $s[i]$ 时,以 $s[i]$ 为中间字符的回文子序列种数即为前缀 $s[0..i-1]$ 与后缀 $s[i+1..n-1]$ 的公共字符种数。

对于一个任意的子串,由于其仅由小写英文字母组成,我们可以用一个 $32$ 位整数来表示该子串含有哪些字符。如果该整数从低到高第 $i$ 个二进制位为 $1$,那么代表该子串含有字典序为 $i$ 的小写英文字母。在遍历该子串时,我们需要用按位或来维护该整数。

为了简化计算,我们可以参照前文所述的对应关系,用两个 $32$ 位整数的数组 $\textit{pre}, \textit{suf}$ 分别维护 $s$ 中前缀与后缀包含的字符。其中,$\textit{pre}[i]$ 代表前缀 $s[0..i-1]$ 包含的字符种类,$\textit{suf}[i]$ 代表后缀 $s[i+1..n-1]$ 包含的字符种类。那么,以 $s[i]$ 为中间字符的回文子序列中,两侧字符的种类对应的状态即为 $\textit{pre}[i] & \textit{suf}[i]$,其中 $&$ 为按位与运算符。

为了避免重复计算,我们需要在遍历的同时使用按位或来维护每种字符为中间字符的回文子序列种数。最终,我们将不同种类字符对应的回文子序列总数求和作为答案返回。

代码

###C++

class Solution {
public:
    int countPalindromicSubsequence(string s) {
        int n = s.size();
        int res = 0;
        // 前缀/后缀字符状态数组
        vector<int> pre(n), suf(n);
        for (int i = 0; i < n; ++i) {
            // 前缀 s[0..i-1] 包含的字符种类
            pre[i] = (i ? pre[i - 1] : 0) | (1 << (s[i] - 'a'));
        }
        for (int i = n - 1; i >= 0; --i) {
            // 后缀 s[i+1..n-1] 包含的字符种类
            suf[i] = (i != n - 1 ? suf[i + 1] : 0) | (1 << (s[i] - 'a'));
        }
        // 每种中间字符的回文子序列状态数组
        vector<int> ans(26);
        for (int i = 1; i < n - 1; ++i) {
            ans[s[i]-'a'] |= (pre[i - 1] & suf[i + 1]);
        }
        // 更新答案
        for (int i = 0; i < 26; ++i) {
            res += __builtin_popcount(ans[i]);
        }
        return res;
    }
};

###Python

class Solution:
    def countPalindromicSubsequence(self, s: str) -> int:
        n = len(s)
        res = 0
        # 前缀/后缀字符状态数组
        pre = [0] * n
        suf = [0] * n
        for i in range(n):
            # 前缀 s[0..i-1] 包含的字符种类
            pre[i] = (pre[i - 1] if i else 0) | (1 << (ord(s[i]) - ord('a')))
        for i in range(n - 1, -1, -1):
            # 后缀 s[i+1..n-1] 包含的字符种类
            suf[i] = (suf[i + 1] if i != n - 1 else 0) | (1 << (ord(s[i]) - ord('a')))
        # 每种中间字符的回文子序列状态数组
        ans = [0] * 26
        for i in range(1, n - 1):
            ans[ord(s[i]) - ord('a')] |= pre[i - 1] & suf[i + 1]
        # 更新答案
        for i in range(26):
            res += bin(ans[i]).count("1")
        return res

###Java

class Solution {
    public int countPalindromicSubsequence(String s) {
        int n = s.length();
        int res = 0;
        // 前缀/后缀字符状态数组
        int[] pre = new int[n];
        int[] suf = new int[n];
        for (int i = 0; i < n; ++i) {
            // 前缀 s[0..i-1] 包含的字符种类
            pre[i] = (i > 0 ? pre[i - 1] : 0) | (1 << (s.charAt(i) - 'a'));
        }
        for (int i = n - 1; i >= 0; --i) {
            // 后缀 s[i+1..n-1] 包含的字符种类
            suf[i] = (i != n - 1 ? suf[i + 1] : 0) | (1 << (s.charAt(i) - 'a'));
        }
        // 每种中间字符的回文子序列状态数组
        int[] ans = new int[26];
        for (int i = 1; i < n - 1; ++i) {
            ans[s.charAt(i) - 'a'] |= (pre[i - 1] & suf[i + 1]);
        }
        // 更新答案
        for (int i = 0; i < 26; ++i) {
            res += Integer.bitCount(ans[i]);
        }
        return res;
    }
}

###C#

public class Solution {
    public int CountPalindromicSubsequence(string s) {
        int n = s.Length;
        int res = 0;
        // 前缀/后缀字符状态数组
        int[] pre = new int[n];
        int[] suf = new int[n];
        for (int i = 0; i < n; ++i) {
            // 前缀 s[0..i-1] 包含的字符种类
            pre[i] = (i > 0 ? pre[i - 1] : 0) | (1 << (s[i] - 'a'));
        }
        for (int i = n - 1; i >= 0; --i) {
            // 后缀 s[i+1..n-1] 包含的字符种类
            suf[i] = (i != n - 1 ? suf[i + 1] : 0) | (1 << (s[i] - 'a'));
        }
        // 每种中间字符的回文子序列状态数组
        int[] ans = new int[26];
        for (int i = 1; i < n - 1; ++i) {
            ans[s[i] - 'a'] |= (pre[i - 1] & suf[i + 1]);
        }
        // 更新答案
        for (int i = 0; i < 26; ++i) {
            res += BitOperations.PopCount((uint)ans[i]);
        }
        return res;
    }
}

###Go

func countPalindromicSubsequence(s string) int {
    n := len(s)
    res := 0
    // 前缀/后缀字符状态数组
    pre := make([]int, n)
    suf := make([]int, n)
    for i := 0; i < n; i++ {
        // 前缀 s[0..i-1] 包含的字符种类
        if i > 0 {
            pre[i] = pre[i-1]
        }
        pre[i] |= 1 << (s[i] - 'a')
    }
    for i := n - 1; i >= 0; i-- {
        // 后缀 s[i+1..n-1] 包含的字符种类
        if i != n - 1 {
            suf[i] = suf[i + 1]
        }
        suf[i] |= 1 << (s[i] - 'a')
    }
    // 每种中间字符的回文子序列状态数组
    ans := make([]int, 26)
    for i := 1; i < n - 1; i++ {
        ans[s[i] - 'a'] |= (pre[i - 1] & suf[i + 1])
    }
    // 更新答案
    for i := 0; i < 26; i++ {
        res += bits.OnesCount(uint(ans[i]))
    }
    return res
}

###C

int countPalindromicSubsequence(char* s) {
    int n = strlen(s);
    int res = 0;
    // 前缀/后缀字符状态数组
    int pre[n], suf[n];
    for (int i = 0; i < n; ++i) {
        // 前缀 s[0..i-1] 包含的字符种类
        pre[i] = (i ? pre[i - 1] : 0) | (1 << (s[i] - 'a'));
    }
    for (int i = n - 1; i >= 0; --i) {
        // 后缀 s[i+1..n-1] 包含的字符种类
        suf[i] = (i != n - 1 ? suf[i + 1] : 0) | (1 << (s[i] - 'a'));
    }
    // 每种中间字符的回文子序列状态数组
    int ans[26] = {0};
    for (int i = 1; i < n - 1; ++i) {
        ans[s[i] - 'a'] |= (pre[i - 1] & suf[i + 1]);
    }
    // 更新答案
    for (int i = 0; i < 26; ++i) {
        res += __builtin_popcount(ans[i]);
    }
    return res;
}

###JavaScript

var countPalindromicSubsequence = function(s) {
    const n = s.length;
    let res = 0;
    // 前缀/后缀字符状态数组
    const pre = new Array(n).fill(0);
    const suf = new Array(n).fill(0);
    for (let i = 0; i < n; ++i) {
        // 前缀 s[0..i-1] 包含的字符种类
        pre[i] = (i > 0 ? pre[i - 1] : 0) | (1 << (s.charCodeAt(i) - 97));
    }
    for (let i = n - 1; i >= 0; --i) {
        // 后缀 s[i+1..n-1] 包含的字符种类
        suf[i] = (i !== n - 1 ? suf[i + 1] : 0) | (1 << (s.charCodeAt(i) - 97));
    }
    // 每种中间字符的回文子序列状态数组
    const ans = new Array(26).fill(0);
    for (let i = 1; i < n - 1; ++i) {
        ans[s.charCodeAt(i) - 97] |= (pre[i-1] & suf[i + 1]);
    }
    // 更新答案
    for (let i = 0; i < 26; ++i) {
        res += ans[i].toString(2).split('1').length - 1;
    }
    return res;
};

###TypeScript

function countPalindromicSubsequence(s: string): number {
    const n = s.length;
    let res = 0;
    // 前缀/后缀字符状态数组
    const pre: number[] = new Array(n).fill(0);
    const suf: number[] = new Array(n).fill(0);
    for (let i = 0; i < n; ++i) {
        // 前缀 s[0..i-1] 包含的字符种类
        pre[i] = (i > 0 ? pre[i - 1] : 0) | (1 << (s.charCodeAt(i) - 97));
    }
    for (let i = n - 1; i >= 0; --i) {
        // 后缀 s[i+1..n-1] 包含的字符种类
        suf[i] = (i !== n - 1 ? suf[i + 1] : 0) | (1 << (s.charCodeAt(i) - 97));
    }
    // 每种中间字符的回文子序列状态数组
    const ans: number[] = new Array(26).fill(0);
    for (let i = 1; i < n - 1; ++i) {
        ans[s.charCodeAt(i) - 97] |= (pre[i - 1] & suf[i + 1]);
    }
    // 更新答案
    for (let i = 0; i < 26; ++i) {
        res += ans[i].toString(2).split('1').length - 1;
    }
    return res;
}

###Rust

impl Solution {
    pub fn count_palindromic_subsequence(s: String) -> i32 {
        let n = s.len();
        let mut res = 0;
        // 前缀/后缀字符状态数组
        let mut pre = vec![0u32; n];
        let mut suf = vec![0u32; n];
        
        for (i, c) in s.chars().enumerate() {
            // 前缀 s[0..i-1] 包含的字符种类
            pre[i] = if i > 0 { pre[i-1] } else { 0 } | (1 << (c as u8 - b'a'));
        }
        for (i, c) in s.chars().rev().enumerate() {
            let i = n - 1 - i;
            // 后缀 s[i+1..n-1] 包含的字符种类
            suf[i] = if i != n - 1 { suf[i+1] } else { 0 } | (1 << (c as u8 - b'a'));
        }
        
        // 每种中间字符的回文子序列状态数组
        let mut ans = vec![0u32; 26];
        for (i, c) in s.chars().enumerate() {
            if i > 0 && i < n - 1 {
                ans[(c as u8 - b'a') as usize] |= pre[i-1] & suf[i+1];
            }
        }
        
        // 更新答案
        for &count in &ans {
            res += count.count_ones() as i32;
        } 
        res
    }
}

复杂度分析

  • 时间复杂度:$O(n + |\Sigma|)$,其中 $n$ 为 $s$ 的长度,$|\Sigma|$ 为字符集的大小。预处理前后缀状态数组与遍历 $s$ 更新每种字符状态数组的时间复杂度均为 $O(n)$,初始化每种字符状态数组与更新答案的时间复杂度均为 $O(|\Sigma|)$。

  • 空间复杂度:$O(|\Sigma|)$,即为每种字符状态数组的空间开销。

c++ 寻找回文,关键还是一前一后

解题思路

  1. 一开始还想着dp,可以添加一个新的字符,受到影响的变化规律非常奇怪,然后转变思路
  2. 添加一个新的字符能添加多少呢?首先了解回文字符串,就两种,aba和aaa
  3. 那么添加新的字符需要回去找原来同样的字符,然后看看中间卡了多少种不同字符
  4. 到了这一步我就悟了,真正的核心是前后两个相同字符以及它们之间夹了多少个不同字符
  5. 一开始想用前缀和,计数字母的个数,想想空间开销,就离谱,算了
  6. 然后回到思路,只需要一前一后,总共也就26个字母,只要遍历每个字母的一前一后,最多时间也是O(n)
  7. 于是就有了以下代码,思路理解了,最多遍历26次即可

代码

###cpp

class Solution {
public:
    int countPalindromicSubsequence(string s) {
        //找到一前一后
        map<char, int> first;
        map<char, int> last;
        int size = s.size();
        
        for (int i = 0; i < size; ++i){
            if (first.count(s[i])){
                last[s[i]] = i;
            }else{
                first[s[i]] = i;
            }
        }
        
        int res = 0;
        for (char i = 'a'; i < 'a' + 26; i++){
            if (!first.count(i) || !last.count(i)) continue;
            
            int tL = first[i], tR = last[i];
            vector<int> count(26);
            for (int j = tL + 1; j < tR; ++j){
                count[s[j] - 'a'] = 1;
            }
            
            for (int j = 0; j < 26; ++j){
                if(count[j] == 1) res++;
            }
        }
        
        return res;
    }
};

两种方法:枚举两侧 / 枚举中间+前后缀分解+位运算优化(Python/Java/C++/C/Go/JS/Rust)

前言

本题要找长为 $3$ 的回文子序列,这要求子序列的第一个字母等于第三个字母。

换句话说,确定了子序列的前两个字母,就确定了子序列。

这引出了两类做法:

  • 先枚举两侧的字母,再枚举中间的字母。
  • 先枚举中间的字母,再枚举两侧的字母。

方法一:枚举两侧

枚举子序列的第一、三个字母是 $\texttt{a},\texttt{b},\ldots,\texttt{z}$。

如果第一、三个字母都是 $\texttt{a}$,如何找到尽量多的不同的子序列?

例如 $s = \texttt{abbacad}$。如果选前两个 $\texttt{a}$ 作为子序列的第一、三个字母,我们只能找到子序列 $\texttt{aba}$。而如果选第一个 $\texttt{a}$ 和最后一个 $\texttt{a}$,夹在两个 $\texttt{a}$ 之间的字母都可以是子序列的第二个字母,从而找到第一、三个字母都是 $\texttt{a}$ 的所有子序列,即 $\texttt{aba}$ 和 $\texttt{aca}$。

算法

  1. 枚举 $\alpha = \texttt{a},\texttt{b},\ldots,\texttt{z}$。
  2. 找 $\alpha$ 在 $s$ 中首次出现的下标 $i$ 和最后一次出现的下标 $j$。如果没有这样的下标,回到第一步继续枚举。
  3. 下标在 $[i+1,j-1]$ 中的字母,可以作为回文子序列的中间字母。
  4. 题目要求相同的子序列只计数一次。这可以用哈希集合去重,也可以用长为 $26$ 的布尔数组记录遇到过的中间字母,避免重复统计。
class Solution:
    def countPalindromicSubsequence(self, s: str) -> int:
        ans = 0
        for alpha in ascii_lowercase:  # 枚举两侧字母 alpha
            i = s.find(alpha)  # 最左边的 alpha 的下标
            if i < 0:  # s 中没有 alpha
                continue
            j = s.rfind(alpha)  # 最右边的 alpha 的下标
            ans += len(set(s[i + 1: j]))  # 统计有多少个不同的中间字母
        return ans
class Solution {
    public int countPalindromicSubsequence(String s) {
        int ans = 0;
        for (char alpha = 'a'; alpha <= 'z'; alpha++) { // 枚举两侧字母 alpha
            int i = s.indexOf(alpha); // 最左边的 alpha 的下标
            if (i < 0) { // s 中没有 alpha
                continue;
            }
            int j = s.lastIndexOf(alpha); // 最右边的 alpha 的下标

            boolean[] has = new boolean[26];
            for (int k = i + 1; k < j; k++) { // 枚举中间字母 mid
                int mid = s.charAt(k) - 'a';
                if (!has[mid]) {
                    has[mid] = true; // 避免重复统计
                    ans++;
                }
            }
        }
        return ans;
    }
}
class Solution {
public:
    int countPalindromicSubsequence(string s) {
        int ans = 0;
        for (char alpha = 'a'; alpha <= 'z'; alpha++) { // 枚举两侧字母 alpha
            int i = s.find(alpha); // 最左边的 alpha 的下标
            if (i == string::npos) { // s 中没有 alpha
                continue;
            }
            int j = s.rfind(alpha); // 最右边的 alpha 的下标

            bool has[26]{};
            for (int k = i + 1; k < j; k++) { // 枚举中间字母 s[k]
                if (!has[s[k] - 'a']) {
                    has[s[k] - 'a'] = true; // 避免重复统计
                    ans++;
                }
            }
        }
        return ans;
    }
};
int countPalindromicSubsequence(char* s) {
    int ans = 0;
    for (char alpha = 'a'; alpha <= 'z'; alpha++) { // 枚举两侧字母 alpha
        char* p = strchr(s, alpha); // 找最左边的 alpha
        if (p == NULL) { // s 中没有 alpha
            continue;
        }
        int i = p - s; // 最左边的 alpha 的下标
        int j = strrchr(s, alpha) - s; // 最右边的 alpha 的下标

        bool has[26] = {};
        for (int k = i + 1; k < j; k++) { // 枚举中间字母 s[k]
            if (!has[s[k] - 'a']) {
                has[s[k] - 'a'] = true; // 避免重复统计
                ans++;
            }
        }
    }
    return ans;
}
func countPalindromicSubsequence(s string) (ans int) {
for alpha := byte('a'); alpha <= 'z'; alpha++ { // 枚举两侧字母 alpha
i := strings.IndexByte(s, alpha) // 最左边的 alpha 的下标
if i < 0 { // s 中没有 alpha
continue
}
j := strings.LastIndexByte(s, alpha) // 最右边的 alpha 的下标
if i+1 >= j { // 长度不足 3
continue
}

has := [26]bool{}
for _, mid := range s[i+1 : j] { // 枚举中间字母 mid
if !has[mid-'a'] {
has[mid-'a'] = true // 避免重复统计
ans++
}
}
}
return
}
var countPalindromicSubsequence = function(s) {
    const ordA = 'a'.charCodeAt(0);
    let ans = 0;
    for (let alpha = ordA; alpha <= 'z'.charCodeAt(0); alpha++) { // 枚举两侧字母 alpha
        const ch = String.fromCharCode(alpha);
        const i = s.indexOf(ch); // 最左边的 alpha 的下标
        if (i < 0) { // s 中没有 alpha
            continue;
        }
        const j = s.lastIndexOf(ch); // 最右边的 alpha 的下标

        const has = Array(26).fill(false);
        for (let k = i + 1; k < j; k++) { // 枚举中间字母 mid
            const mid = s.charCodeAt(k) - ordA;
            if (!has[mid]) {
                has[mid] = true; // 避免重复统计
                ans++;
            }
        }
    }
    return ans;
};
impl Solution {
    pub fn count_palindromic_subsequence(s: String) -> i32 {
        let s = s.as_bytes();
        let mut ans = 0;
        for alpha in b'a'..=b'z' { // 枚举两侧字母 alpha
            let i = s.iter().position(|&c| c == alpha); // 找最左边的 alpha
            if i.is_none() { // s 中没有 alpha
                continue;
            }
            let i = i.unwrap(); // 最左边的 alpha 的下标
            let j = s.iter().rposition(|&c| c == alpha).unwrap(); // 最右边的 alpha 的下标

            let mut has = [false; 26];
            for k in i + 1..j { // 枚举中间字母 mid
                let mid = (s[k] - b'a') as usize;
                if !has[mid] {
                    has[mid] = true; // 避免重复统计
                    ans += 1;
                }
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$,其中 $n$ 是 $s$ 的长度,$|\Sigma|=26$ 是字符集合的大小。
  • 空间复杂度:$\mathcal{O}(|\Sigma|)$。

方法二:枚举中间 + 前后缀分解

枚举 $i=1,2,\ldots,n-2$,把 $s[i]$ 当作子序列的第二个字母,那么对于第一、三个字母 $\alpha = \texttt{a},\texttt{b},\ldots,\texttt{z}$,我们需要判断:

  • $s$ 的前缀 $[0,i-1]$ 中有没有 $\alpha$?
  • $s$ 的后缀 $[i+1,n-1]$ 中有没有 $\alpha$?

暴力找是 $\mathcal{O}(n)$ 的,如何加速?

我们可以先遍历 $s$,统计 $s$ 中每个字母的个数,然后再从左到右遍历 $s$,把 $s[i]$ 的个数减一,就得到了后缀 $[i+1,n-1]$ 每个字母的个数。

对于前缀 $[0,i-1]$,在从左到右遍历 $s$ 的同时,记录遇到了哪些字母。

优化前

class Solution:
    def countPalindromicSubsequence(self, s: str) -> int:
        suf_cnt = Counter(s[1:])  # 统计 [1,n-1] 每个字母的个数
        pre_set = set()
        st = set()
        for i in range(1, len(s) - 1):  # 枚举中间字母 mid
            mid = s[i]
            suf_cnt[mid] -= 1  # 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            if suf_cnt[mid] == 0:  # 后缀 [i+1,n-1] 不包含 mid
                del suf_cnt[mid]  # 从 suf_cnt 中去掉 mid
            pre_set.add(s[i - 1])  # 记录前缀 [0,i-1] 有哪些字母
            for alpha in pre_set & suf_cnt.keys():  # mid 的左右两侧都有字母 alpha
                st.add(alpha + mid)
        return len(st)
class Solution {
    public int countPalindromicSubsequence(String S) {
        char[] s = S.toCharArray();
        int n = s.length;

        // 统计 [1,n-1] 每个字母的个数
        int[] sufCnt = new int[26];
        for (int i = 1; i < n; i++) {
            sufCnt[s[i] - 'a']++;
        }

        boolean[] preHas = new boolean[26];
        boolean[][] has = new boolean[26][26];
        int ans = 0;
        for (int i = 1; i < n - 1; i++) { // 枚举中间字母 mid
            int mid = s[i] - 'a';
            sufCnt[mid]--; // 撤销 mid 的计数,sufCnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            preHas[s[i - 1] - 'a'] = true; // 记录前缀 [0,i-1] 有哪些字母
            for (int alpha = 0; alpha < 26; alpha++) { // 枚举两侧字母 alpha
                // 判断 mid 的左右两侧是否都有字母 alpha
                if (preHas[alpha] && sufCnt[alpha] > 0 && !has[mid][alpha]) {
                    has[mid][alpha] = true;
                    ans++;
                }
            }
        }
        return ans;
    }
}
class Solution {
public:
    int countPalindromicSubsequence(string s) {
        int n = s.size();
        // 统计 [1,n-1] 每个字母的个数
        int suf_cnt[26]{};
        for (int i = 1; i < n; i++) {
            suf_cnt[s[i] - 'a']++;
        }

        bool pre_has[26]{};
        bool has[26][26]{};
        int ans = 0;
        for (int i = 1; i < n - 1; i++) { // 枚举中间字母 mid
            int mid = s[i] - 'a';
            suf_cnt[mid]--; // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            pre_has[s[i - 1] - 'a'] = true; // 记录前缀 [0,i-1] 有哪些字母
            for (int alpha = 0; alpha < 26; alpha++) { // 枚举两侧字母 alpha
                // 判断 mid 的左右两侧是否都有字母 alpha
                if (pre_has[alpha] && suf_cnt[alpha] && !has[mid][alpha]) {
                    has[mid][alpha] = true;
                    ans++;
                }
            }
        }
        return ans;
    }
};
int countPalindromicSubsequence(char* s) {
    // 统计 [1,n-1] 每个字母的个数
    int suf_cnt[26] = {}; 
    for (int i = 1; s[i]; i++) {
        suf_cnt[s[i] - 'a']++;
    }

    bool pre_has[26] = {};
    bool has[26][26] = {};
    int ans = 0;
    for (int i = 1; s[i + 1]; i++) { // 枚举中间字母 mid
        int mid = s[i] - 'a';
        suf_cnt[mid]--; // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
        pre_has[s[i - 1] - 'a'] = true; // 记录前缀 [0,i-1] 有哪些字母
        for (int alpha = 0; alpha < 26; alpha++) { // 枚举两侧字母 alpha
            // 判断 mid 的左右两侧是否都有字母 alpha
            if (pre_has[alpha] && suf_cnt[alpha] && !has[mid][alpha]) {
                has[mid][alpha] = true;
                ans++;
            }
        }
    }
    return ans;
}
func countPalindromicSubsequence(s string) (ans int) {
// 统计 s[1:] 每个字母的个数
sufCnt := [26]int{} 
for _, ch := range s[1:] {
sufCnt[ch-'a']++
}

preHas := [26]bool{}
has := [26][26]bool{}
for i := 1; i < len(s)-1; i++ { // 枚举中间字母 mid
mid := s[i] - 'a'
sufCnt[mid]-- // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
preHas[s[i-1]-'a'] = true // 记录前缀 [0,i-1] 有哪些字母
for alpha := range 26 { // 枚举两侧字母 alpha
// 判断 mid 的左右两侧是否都有字母 alpha
if preHas[alpha] && sufCnt[alpha] > 0 && !has[mid][alpha] {
has[mid][alpha] = true
ans++
}
}
}
return
}
var countPalindromicSubsequence = function(s) {
    const n = s.length;
    const ordA = 'a'.charCodeAt(0);

    // 统计 [1,n-1] 每个字母的个数
    const sufCnt = Array(26).fill(0);
    for (let i = 1; i < n; i++) {
        sufCnt[s.charCodeAt(i) - ordA]++;
    }

    const preHas = Array(26).fill(false);
    const has = Array.from({ length: 26 }, () => Array(26).fill(false));
    let ans = 0;
    for (let i = 1; i < n - 1; i++) { // 枚举中间字母 mid
        const mid = s.charCodeAt(i) - ordA;
        sufCnt[mid]--; // 撤销 mid 的计数,sufCnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
        preHas[s.charCodeAt(i - 1) - ordA] = true; // 记录前缀 [0,i-1] 有哪些字母
        for (let alpha = 0; alpha < 26; alpha++) { // 枚举两侧字母 alpha
            // 判断 mid 的左右两侧是否都有字母 alpha
            if (preHas[alpha] && sufCnt[alpha] && !has[mid][alpha]) {
                has[mid][alpha] = true;
                ans++;
            }
        }
    }
    return ans;
};
impl Solution {
    pub fn count_palindromic_subsequence(s: String) -> i32 {
        let s = s.as_bytes();
        let n = s.len();

        // 统计 [1,n-1] 每个字母的个数
        let mut suf_cnt = [0; 26]; 
        for &ch in &s[1..] {
            suf_cnt[(ch - b'a') as usize] += 1;
        }

        let mut pre_has = [false; 26];
        let mut has = [[false; 26]; 26];
        let mut ans = 0;
        for i in 1..n - 1 { // 枚举中间字母 mid
            let mid = (s[i] - b'a') as usize;
            suf_cnt[mid] -= 1; // 撤销 s[i] 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            pre_has[(s[i - 1] - b'a') as usize] = true; // 记录前缀 [0,i-1] 有哪些字母
            for alpha in 0..26 { // 枚举两侧字母 alpha
                // 判断 mid 的左右两侧是否都有字母 alpha
                if pre_has[alpha] && suf_cnt[alpha] > 0 && !has[mid][alpha] {
                    has[mid][alpha] = true;
                    ans += 1;
                }
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$,其中 $n$ 是 $s$ 的长度,$|\Sigma|=26$ 是字符集合的大小。
  • 空间复杂度:$\mathcal{O}(|\Sigma|^2)$。

位运算优化

我们可以把集合或者布尔数组压缩成一个二进制数,二进制数中的 $0$ 表示 $\texttt{false}$,$1$ 表示 $\texttt{true}$。原理请看 从集合论到位运算,常见位运算技巧分类总结!

用与运算可以 $\mathcal{O}(1)$ 求出 $\textit{pre}$ 和 $\textit{suf}$ 的交集。

class Solution:
    def countPalindromicSubsequence(self, s: str) -> int:
        n = len(s)
        ord_a = ord('a')

        # 统计 [1,n-1] 每个字母的个数
        suf_cnt = [0] * 26
        suf = 0
        for ch in s[1:]:
            ch = ord(ch) - ord_a
            suf_cnt[ch] += 1
            suf |= 1 << ch  # 把 ch 记录到二进制数 suf 中,表示后缀有 ch

        pre = 0
        ans = [0] * 26  # ans[mid] = 由 alpha 组成的二进制数
        for i in range(1, n - 1):  # 枚举中间字母 mid
            mid = ord(s[i]) - ord_a
            suf_cnt[mid] -= 1  # 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            if suf_cnt[mid] == 0:  # 后缀 [i+1,n-1] 不包含 mid
                suf ^= 1 << mid  # 从 suf 中去掉 mid
            pre |= 1 << (ord(s[i - 1]) - ord_a)  # 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
            ans[mid] |= pre & suf  # 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 ans[mid] 中

        return sum(mask.bit_count() for mask in ans)  # mask 中的每个 1 对应着一个 alpha
class Solution {
    public int countPalindromicSubsequence(String S) {
        char[] s = S.toCharArray();
        int n = s.length;

        // 统计 [1,n-1] 每个字母的个数
        int[] sufCnt = new int[26];
        int suf = 0;
        for (int i = 1; i < n; i++) {
            int ch = s[i] - 'a';
            sufCnt[ch]++;
            suf |= 1 << ch; // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
        }

        int pre = 0;
        int[] has = new int[26]; // has[mid] = 由 alpha 组成的二进制数
        for (int i = 1; i < n - 1; i++) { // 枚举中间字母 mid
            int mid = s[i] - 'a';
            sufCnt[mid]--; // 撤销 mid 的计数,sufCnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            if (sufCnt[mid] == 0) { // 后缀 [i+1,n-1] 不包含 mid
                suf ^= 1 << mid; // 从 suf 中去掉 mid
            }
            pre |= 1 << (s[i - 1] - 'a'); // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
            has[mid] |= pre & suf; // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
        }

        int ans = 0;
        for (int mask : has) {
            ans += Integer.bitCount(mask); // mask 中的每个 1 对应着一个 alpha
        }
        return ans;
    }
}
class Solution {
public:
    int countPalindromicSubsequence(string s) {
        int n = s.size();
        // 统计 [1,n-1] 每个字母的个数
        int suf_cnt[26]{};
        int suf = 0;
        for (int i = 1; i < n; i++) {
            int ch = s[i] - 'a';
            suf_cnt[ch]++;
            suf |= 1 << ch; // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
        }

        int pre = 0;
        int has[26]{}; // has[mid] = 由 alpha 组成的二进制数
        for (int i = 1; i < n - 1; i++) { // 枚举中间字母 mid
            int mid = s[i] - 'a';
            suf_cnt[mid]--; // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            if (suf_cnt[mid] == 0) { // 后缀 [i+1,n-1] 不包含 mid
                suf ^= 1 << mid; // 从 suf 中去掉 mid
            }
            pre |= 1 << (s[i - 1] - 'a'); // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
            has[mid] |= pre & suf; // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
        }

        int ans = 0;
        for (int mask : has) {
            ans += popcount((uint32_t) mask); // mask 中的每个 1 对应着一个 alpha
        }
        return ans;
    }
};
int countPalindromicSubsequence(char* s) {
    int n = strlen(s);
    // 统计 [1,n-1] 每个字母的个数
    int suf_cnt[26] = {};
    int suf = 0;
    for (int i = 1; s[i]; i++) {
        int ch = s[i] - 'a';
        suf_cnt[ch]++;
        suf |= 1 << ch; // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
    }

    int pre = 0;
    int has[26] = {}; // has[mid] = 由 alpha 组成的二进制数
    for (int i = 1; s[i + 1]; i++) { // 枚举中间字母 mid
        int mid = s[i] - 'a';
        suf_cnt[mid]--; // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
        if (suf_cnt[mid] == 0) { // 后缀 [i+1,n-1] 不包含 mid
            suf ^= 1 << mid; // 从 suf 中去掉 mid
        }
        pre |= 1 << (s[i - 1] - 'a'); // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
        has[mid] |= pre & suf; // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
    }

    int ans = 0;
    for (int i = 0; i < 26; i++) {
        ans += __builtin_popcount(has[i]); // has[i] 中的每个 1 对应着一个 alpha
    }
    return ans;
}
func countPalindromicSubsequence(s string) (ans int) {
// 统计 [1,n-1] 每个字母的个数
sufCnt := [26]int{}
suf := 0
for _, ch := range s[1:] {
ch -= 'a'
sufCnt[ch]++
suf |= 1 << ch // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
}

pre := 0
has := [26]int{} // has[mid] = 由 alpha 组成的二进制数
for i := 1; i < len(s)-1; i++ { // 枚举中间字母 mid
mid := s[i] - 'a'
sufCnt[mid]-- // 撤销 mid 的计数,sufCnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
if sufCnt[mid] == 0 { // 后缀 [i+1,n-1] 不包含 mid
suf ^= 1 << mid // 从 suf 中去掉 mid
}
pre |= 1 << (s[i-1] - 'a') // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
has[mid] |= pre & suf // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
}

for _, mask := range has {
ans += bits.OnesCount(uint(mask)) // mask 中的每个 1 对应着一个 alpha
}
return
}
var countPalindromicSubsequence = function(s) {
    const n = s.length;
    const ordA = 'a'.charCodeAt(0);

    // 统计 [1,n-1] 每个字母的个数
    const sufCnt = Array(26).fill(0);
    let suf = 0;
    for (let i = 1; i < n; i++) {
        const ch = s.charCodeAt(i) - ordA;
        sufCnt[ch]++;
        suf |= 1 << ch; // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
    }

    let pre = 0;
    const has = Array(26).fill(0); // has[mid] = 由 alpha 组成的二进制数
    for (let i = 1; i < n - 1; i++) { // 枚举中间字母 mid
        const mid = s.charCodeAt(i) - ordA;
        sufCnt[mid]--; // 撤销 mid 的计数,sufCnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
        if (sufCnt[mid] === 0) { // 后缀 [i+1,n-1] 不包含 mid
            suf ^= 1 << mid; // 从 suf 中去掉 mid
        }
        pre |= 1 << (s.charCodeAt(i - 1) - ordA); // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
        has[mid] |= pre & suf; // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
    }

    let ans = 0;
    for (const mask of has) {
        ans += bitCount32(mask); // mask 中的每个 1 对应着一个 alpha
    }
    return ans;
};

// 参考 Java 的 Integer.bitCount
function bitCount32(i) {
    i = i - ((i >>> 1) & 0x55555555);
    i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
    i = (i + (i >>> 4)) & 0x0f0f0f0f;
    i = i + (i >>> 8);
    i = i + (i >>> 16);
    return i & 0x3f;
}
impl Solution {
    pub fn count_palindromic_subsequence(s: String) -> i32 {
        let s = s.as_bytes();
        let n = s.len();

        // 统计 [1,n-1] 每个字母的个数
        let mut suf_cnt = [0; 26];
        let mut suf = 0;
        for &ch in &s[1..] {
            let ch = (ch - b'a') as usize;
            suf_cnt[ch] += 1;
            suf |= 1 << ch; // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
        }

        let mut pre = 0;
        let mut has = [0u32; 26]; // has[mid] = 由 alpha 组成的二进制数
        for i in 1..n - 1 { // 枚举中间字母 mid
            let mid = (s[i] - b'a') as usize;
            suf_cnt[mid] -= 1; // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            if suf_cnt[mid] == 0 { // 后缀 [i+1,n-1] 不包含 mid
                suf ^= 1 << mid; // 从 suf 中去掉 mid
            }
            pre |= 1 << (s[i - 1] - b'a'); // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
            has[mid] |= pre & suf; // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
        }

        // mask 中的每个 1 对应着一个 alpha
        has.into_iter().map(|mask| mask.count_ones() as i32).sum()
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n + |\Sigma|)$,其中 $n$ 是 $s$ 的长度,$|\Sigma|=26$ 是字符集合的大小。
  • 空间复杂度:$\mathcal{O}(|\Sigma|)$。

专题训练

  1. 数据结构题单的「§0.2 枚举中间」。
  2. 动态规划题单的「专题:前后缀分解」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

比纯电便宜 4 万!小鹏 X9 增程版 30.98 万起,1602 公里续航告别焦虑

Max 版 30.98 万元,Ultra 版 32.98 万元。

在价格公布前,大家都有预期小鹏 X9 增程版的价格会有下调,但没想到幅度如此之大。如果叠加置换补贴,Max 版本的价格甚至可以做到 30 万元以内。

颇有点「必须成功」的味道。

作为小鹏从「纯电路线」转向更务实能源战略的首款产品,X9 增程版的意义远不仅是一款新车。它的市场表现将直接塑造消费者对小鹏增程技术的信任度,并深刻影响后续增程车型的口碑与接受度。

何小鹏在发布会开场时花了很长的篇幅解释了小鹏为何要转向纯电、增程双车并行,但如果抛开众所周知的增程车拥有的优点外,或许可以将原因总结为一句话——

小鹏需要一个更具想象力的增长空间。

增程、出海、AI、机器人都在为这个目标而服务。

发布会前几天,小鹏公布了其第三季度的财报。今年第三季度,小鹏汽车实现总营收 203.8 亿元,同比增长 101.8%,环比上一季度增长 11.5%,当季净亏损 3.8 亿元。

何小鹏在财报电话会上讲到,「小鹏汽车的各项关键经营指标,包括销量、收入、毛利率、在手现金,再创新高,有望在在第四季度实现盈利」。

但市场其实对此业绩不太满意。虽然汽车销售收入增长,但小鹏的单车售价从 2022 年的 20.57 万元降至 2024 年的 18.85 万元,今年前三季度进一步下降至 15.85 万元,定位中低端的 MONA M03 销量占比长期在 4 成左右。

中高端车型的表现也不尽如人意。新款 P7 10 月仅售出 5600 多台,环比降幅超 30%;小鹏 P7+的月销量也从去年 12 月的万台,降至今年 10 月的 5568 台;高端主力车型,小鹏 G9 和 X9 销量也连续下挫,10 月,G9 销售 974 台,X9 销售 835 台。

小鹏急需一个销量和业绩的新增长点,增程就是目前最能看得见摸得着的那个。

正因如此,小鹏几乎倾注了当前全部的技术积累与造车经验于这款车之上。从三电系统到智能座舱,从空间工程到安全架构,X9 超级增程并非简单地增加一套增程模块,而是试图在能源形式、空间利用与驾乘体验之间,找到一个真正兼顾性能、实用与舒适的最佳平衡点。

告别里程焦虑

最直观的突破是 X9 增程的续航表现。

X9 超级增程 CLTC 综合续航达 1602 公里,纯电续航 452 公里。

这意味着城市通勤用户每周充电一次即可满足日常所需,而在跨省长途出行或高频次商务接待场景下,也能依靠增程系统实现「加油即走」,彻底告别里程焦虑。

支撑这一体验的,是一套深度协同的增程动力系统:小鹏 X9 增程版搭载 63.3kWh 高能量密度电池组与 60 升油箱,整车基于 800V 高压碳化硅电驱平台打造,并采用支持 5C 超快充的高倍率电池。


官方数据显示,在小鹏自营 S4 超快充桩上,车辆充电 10 分钟即可补充 313 公里的 CLTC 纯电续航。

此外,X9 是业内首款全面兼容 2015+ 充电标准的车型,在第三方充电桩上也能实现满功率充电,大幅提升了补能自由度。

在能效表现上,WLTC 综合油耗低至 2.53L/100km,CLTC 电耗仅 16.5kWh/100km,甚至优于部分竞品的纯电版本。它支持 92 号汽油,是目前市场上少数兼容普通标号燃油的高端增程 MPV。

同时,小鹏联合嘉实多共同开发了专用机油,专门针对增程器低负荷运行、频繁启停的工作特性进行优化,进一步提升系统可靠性与使用寿命。车辆保养周期长达 2 万公里,每年可比埃尔法等豪华 MPV 节省超 4 万元。

不侵占一寸空间

增程系统的加入解决了不少问题,但同时也带来了一些新问题。

其中最大的挑战在于,如何在加入整套增程系统的同时,不牺牲 MPV 最核心的空间价值?

传统增程车常因油箱、排气管和增程器的加入,压缩后备厢或第三排空间。

小鹏为此研发了一套名为「鲲鹏超级集成架构」的工程方案。其后桥采用全球首创的「9 合 1」设计,将增程器排气系统、60L 油箱、主动式后轮转向、第三排 4/6 分独立电动座椅收纳机构、双腔空气悬架、智能可变阻尼减振器、同轴电驱总成、H 臂多连杆独立悬架以及传动轴等九大功能模块,高度集成于同一紧凑区域,整体空间利用率达 95.8%,在保障性能的同时,最大限度释放了乘员舱与行李空间。

与此同时,小鹏 X9 在车身结构上大规模应用一体化铝压铸技术,整车集成度高达 167 个零部件合一——远超当前行业主流水平(多数竞品集中在 70 合 1 以内)。

带来的好处也显而易见,小鹏 X9 新增了一套增程系统,但却未侵占乘员舱一寸空间。

七座状态下,三排均拥有超过 1 米的腿部空间,10 岁以下儿童可在车内站立行走;六座模式下,可轻松容纳三个 24 英寸行李箱、一辆轮椅和两台婴儿车;五座时,能装下五个行李箱加两辆儿童自行车;而一键电动收纳第三排后,可形成 2514 升的纯平储物空间——足以放下五辆成人自行车,甚至铺上 1.8×1.6 米的床垫变身露营床。

增程器带来的噪声则是另一大考验。增程器一旦启动,巨大的噪音容易破坏 MPV 的豪华感。

小鹏的解法是让增程「无感化」。通过优化液压悬置结构,使整体振动降低 30%;自研主动停缸技术,使启停振动下降 60%。此外通过 ENC(增程器主动降噪)与 RNC(路噪主动降噪)双重系统,最终增程器介入时车内噪声增量不超过 0.5 分贝,远低于人耳可感知的 1 分贝阈值。配合 6 平方米双层隔音玻璃和 34 处声学包升级,整车静谧性媲美图书馆。

发布会上,小鹏同样用了大量的篇幅来介绍 X9 在安全性能上的优化。

X9 不分版本、不分地区,均满足中国 C-NCAP 2024、中保研 G+、欧洲 E-NCAP 2026 及美标后碰安全标准。

车身 87% 为高强度钢铝混合结构,A 柱内嵌 2000MPa 热气胀膨胀管,扭转刚度达 46000N·m/deg;电池包提前满足 2026 年新国标,单电芯热失控 24 小时不起火,底部可承受 2000 焦耳冲击。

同时,车辆标配 9 个安全气囊,包括同级唯一的前排远端气囊和二排坐垫防下潜气囊。

为了验证车辆可靠性,小鹏历时 800 天,在 20 个国家 330 座城市完成了超 2000 万公里路测。从吐鲁番 70℃高温沙漠,到瑞典北极圈冰雪路面,再到意大利斯泰尔维奥 48 连发卡弯,车辆经受了极端环境考验。

X9 还首发了「冰雪稳行系统」。在冰面或积雪中,dTCS 2.0 扭矩控制系统可使车辆在 2 毫秒内响应打滑,并且悬架自动抬高至 185mm 离地间隙,助力车辆脱困。

而在动态表现上,小鹏 X9 增程全系标配主动式后轮转向,最大转角±5 度,5.3 米车长实现 5.4 米转弯半径,媲美 MINI。在狭窄老城区掉头、停车毫无压力。配合双腔空悬和 6D 防晕车算法,高速稳定性甚至优于雷克萨斯 LM。方向盘振感更低,起步更平稳。

移动起居室

小鹏 X9 的内外饰在增程版上也有些许变化。

小鹏 X9 增程版全新推出了一款「极光青」车漆,采用与劳斯莱斯同源的巴斯夫顶级清漆,结合多层渐变喷涂工艺,可以令车身在不同光线下呈现出流动的光影韵律。

车辆前脸则同步升级 AGS 智能可变进气格栅,可根据车速与温度自动调节开合——高速时闭合以降低风阻,低温时开启以加速电机散热,兼顾效率与性能。

来到车内,新车全车三排座椅均可 180°电动放平,第三排腿部空间超过 1 米,即便是身高 1.88 米的成年人也能轻松跷二郎腿,头部空间同样充裕;配合同级最高的 1360mm 车内净高与 180mm 中央过道,10 岁以下儿童甚至能在车厢内自由行走奔跑,彻底告别 MPV 常见的局促感。

同时第一排标配主副驾 12 向电动调节、坐垫与靠背三挡通风/加热、SPA 级 10 点按摩及 6 种智能场景模式;第二排搭载专属零重力太空座椅,支持 14 向电动调节(含 4 向电动腿托),靠背最大可调至 180°放平,座椅还集成了超宽扶手、50W 无线快充、折叠桌板、隐藏杯架、腿托加热等细节,并配备行业首创的智能「指压」按摩系统,配合冥想模式,10 分钟即可让心率降至静息水平,有效缓解焦虑。

此外,X9 内饰新增「云间玫棕」配色,采用玫瑰棕与静谧白拼色,营造温暖柔和的居家氛围;另提供气宇灰、月影咖两种选择。整体延续了「星舰头等舱」设计语言,仪表台、副仪表台与门板采用一体环抱式布局。

智能化方面,X9 超级增程搭载了全新的电容方向盘、21 英寸 W-HUD 以及一块 21.4 英寸的后排屏。

整车共有 4 颗芯片,包括 3 颗自研图灵 AI 芯片和 1 颗 8295P,有效算力达 2250TOPS。基于 VLA 大模型的智驾系统能处理「盲区鬼探头」等复杂场景,并通过人机共驾持续学习用户习惯。座舱运行本地 VLM 大模型,支持智能迎宾、充电推荐、离车舒享等功能。所有隐私数据不出车端,不同账号相互隔离。

虽然任何技术路线都存在权衡。增程车型在高速巡航时的能效天然低于纯电,且机械复杂度高于单一动力形式。

但在当前 MPV 市场加速电动化、家庭用户对空间与续航双重敏感的背景下,X9 超级增程确实提供了一种折中的可能性——

既保留了纯电车的驾驶质感与智能化体验,又通过增程系统消除了里程焦虑,同时未牺牲 MPV 最核心的空间魔法与舒适配置。

或许从增程 X9 开始,小鹏真能迎来新一轮增长。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


yalc,yyds!

npm link

最近开发一个组件库假设叫rn-skeleton,想着组件库就该有组件库的样子,于是我想着找个宿主项目(假设叫rn-app)通过npm link的方式进行本地调试,谁知道拉了坨大的。。。

事情是这样的:众所周知,npm link的使用就很简单。在rn-skelton执行npm link,在宿主项目rn-app安装npm link rn-skeleton,到这里其实已经完事了,结果引入的时候,一直显示:找不到模块“rn-skeleton”或其相应的类型声明,更过分的是告诉我rn-app中找不到react-native....

于是我进行了好一会的问题解决,我寻思着node_module也是能看到rn-skeleton这个包的,怎么就找不到? 先解决react-native的依赖问题,我把rn-skeleton项目中package.jsonoverrides字段删除:

 "overrides": {
    "react": "18.3.1",
    "react-native": "0.77.2"
  }

因为宿主项目也有这玩意,但是无济于事,尝试好几次,还是无用。于是尝试:

  • npm install /Projects/drn-dialog --legacy-peer-deps
  • ln -s /Projects/rn-skeleton node_modules/rn-skeleton

还是无济于事,于是放弃了,另辟蹊径去。结果发现:

在 React Native 项目中,npm link 和软连接(ln -s)一般无法用于组件库的本地调试,主要因为
Metro bundler(RN 打包器)默认不支持 symlink,所以常规的 npm link 方案不生效。

metroReact Native 使用的 Facebook打包器不支持符号链接,这严重阻碍了本地代码的共享。

wml

WML(Webpack Module Linker)是一款文件同步工具,基于watchman实现文件监听 用于自定义metro打包器配置的实用程序,以解决其不支持符号链接的问题。

该软件包的主要用途是支持使用yarn link或 开发 React Native 依赖项npm link

npm install -g wml

// 命令
wml ls  // 查看当前link
wml add <ProjectA Name> <ProjectA NameB>/node_modules/<ProjectA Package Name>
wml rm <LinkId>
wml rm all
wml start // 启动生效
wml start --verbose
wml stop
  1. ProjectA Name需要引入的包目录
  2. ProjectB Name需要引入包的宿主项目
  3. ProjectA Package NameProjectApackage.json中的name

优点:

  • 完全实时同步
  • 轻量无配置:无需修改包的 package.json,仅需建立一次映射即可长期使用
  • 支持任意文件同步:不限npm包
  • 跨平台:支持mac/windows/linux,依赖watchman

缺点:

  • 仅做文件同步,不处理包的依赖解析
  • 依赖watchman

原以为简简单单,还自带热更新,没想到执行wml start一直卡着不动,也没日志输出,闹麻了,接着换~

yalc

npm install -g yalc

yalc publish  // 将本地包发布到yalc本地仓库
yalc add <Package Name> // 从yalc仓库引入包到当前项目
yalc update <Package Name> // 更新当前项目的本地包到最新版本
yalc push // 将本地包的修改同步到所有引入的项目(热更新)
yalc remove <Package Name> // 从当前项目删除yalc引入的包
yalc clean // 清空yalc本地仓库缓存

执行yalc后可以看到项目中的node_modules出现该包,而且多了一个文件yalc.lock。 如果不是标准的npm包项目,可能还需修改一些内容:

ProjectA

// package.json
{
  "name": "rn-dialog",
  "main": "src/index.tsx", // 入口文件路径
  "types": "src/index.d.ts" // index.d.ts文件路径
  // ...
 }

ProjectB

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
       // 手动映射模块路径,强化 TS 解析
      "rn-dialog": ["node_modules/rn-dialog/src/index.tsx"]
    },
}

缺点:

  • 不支持热更新,需要手动执行yalc update
  • 会有新文件生成,记得添加到.gitignore
  • 主要针对npm 包

优点:

  • 绕开npm linkpeerDependencies/overrides校验冲突,因为yalc是模拟安装而不是软链
  • 支持多项目同步
  • 轻量无侵入

至于@carimus/metro-symlinked-deps,略略略

我宣布,以后yalc是我的首选项~

ESLint报错无具体信息:大型代码合并中的内存与性能问题排查

ESLint报错无具体信息:大型代码合并中的内存与性能问题排查

问题描述

在最近的一次大型代码合并中,遇到了一个令人困惑的ESLint问题:

先是提示内存溢出

img_v3_02s7_537bfaa6-6a75-48d7-a4d2-5bfc5fe092hu.png

然后出现报错,但是没有报错信息,只展示检测的文件路径。

PixPin_2025-11-20_20-38-26.png

  • 现象:ESLint执行失败,但终端只显示 ✖ pnpm eslint --quiet,没有任何具体的错误信息
  • 背景:这次合并涉及1968个文件,其中1744个是TypeScript/JavaScript文件
  • 之前提示:合并前曾出现"git emit超过内存"的警告

这种"静默失败"让问题排查变得异常困难,本文将详细分析这个问题的原因和解决方案。

原因分析

1. 代码量爆炸性增长

通过分析发现,这次合并的规模远超寻常:

# 查看合并涉及的文件数量
$ git diff --name-only HEAD~1 | wc -l
1968

# 其中TypeScript/JavaScript文件数量
$ git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|js|jsx)$' | wc -l
1744

eslint需要检测大量的文件,检测本身是没有问题的,只是检测完的结果展示被遮住了,展示不全。

2. 大型IDL文件的性能瓶颈

进一步分析发现,存在大量大型自动生成的IDL文件:

# 查看超过100KB的文件
$ git diff --name-only HEAD~1 | xargs wc -c 2>/dev/null | awk '$1 > 100000 {print $1/1024 "KB", $2}' | wc -l
26

# 最大的文件达到1.2MB
$ git diff --name-only HEAD~1 | xargs wc -c 2>/dev/null | sort -n | tail -5
1209435 apps/hub/src/idls/app_idl/namespaces/all_req_data.ts
1399027 pnpm-lock.yaml
14093097 total

3. ESLint处理机制的问题

虽然这些IDL文件顶部都有 /* eslint-disable */ 注释,但ESLint仍然需要:

  1. 解析每个文件来确定是否应用规则
  2. 构建AST来理解文件结构
  3. 处理大型文件(如770KB的IDL文件)

单个大型IDL文件的处理时间测试:

$ time NODE_OPTIONS="--max-old-space-size=16384" emox eslint --quiet apps/creative-hub/src/idls/creation_bff/index.ts
# 耗时5.66秒

4. 内存压力

当1744个文件同时处理时,Node.js默认的内存限制(约1.4GB)很容易被突破,导致进程崩溃或异常行为。

解决方案

方案一:增加Node.js内存限制

# 临时解决方案
export NODE_OPTIONS="--max-old-space-size=16384"
emox eslint --quiet your-files

# 或者永久设置
echo "export NODE_OPTIONS=\"--max-old-space-size=16384\"" >> ~/.zshrc
source ~/.zshrc

方案二:使用ESLint缓存机制

# 启用缓存避免重复处理
emox eslint --quiet --cache your-files

方案三:分批处理

创建分批处理脚本,避免同时处理过多文件:

#!/bin/bash
# 分批处理文件,每批50个
batch_size=50
files=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|js|jsx)$' | grep -v idls)

# 分批处理
echo "$files" | split -l $batch_size - /tmp/eslint_batch_
for batch_file in /tmp/eslint_batch/batch_*; do
    echo "处理第 $batch_num 批文件..."
    emox eslint --quiet --cache $(cat $batch_file)
done

方案四:排除IDL文件

IDL文件通常是自动生成的,可以安全排除:

# 排除IDL文件进行检查
emox eslint --quiet $(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|js|jsx)$' | grep -v idls)

最终解决步骤

在尝试了增加node内存和eslint缓存发现无济于事。并且排除idl文件也有一定的安全隐患,虽然idl是自动生成的,但是如果不小心改动了也会引起编译不通过。最终为了快速提交代码,使用最简单粗暴的方法,分段提交。

1. 逐步添加文件到暂存区

# 分批添加文件,避免一次性处理过多
git add apps/edit/src/ -A
git add apps/app-hub/src/ -A
# ... 其他目录分批添加

2. 执行lint-staged检查

# 使用pnpm执行lint-staged
pnpm lint-staged

3. 处理具体报错

根据lint-staged的输出,逐一解决具体的ESLint错误:

# 如果有错误,会显示具体的文件和行号
# 例如:
# apps/your-file.ts
#   45:10  error  Missing semicolon  @typescript-eslint/semi

# 修复后重新检查
pnpm lint-staged

4. 继续合并流程

# 所有问题解决后,继续合并
git merge --continue

总结

这次ESLint"静默失败"问题的根本原因是大型代码合并导致的内存和性能压力。在尝试了增加内存限制和缓存之后也无济于事,那么化繁为简,用最简单的逐步提交就行了。

希望这个经验能帮助你在未来的大型代码合并中避免类似问题。

雅培以210亿美元收购肠癌早筛龙头Exact Sciences

雅培与Exact Sciences公司11月20日宣布达成最终协议,雅培将收购肠癌早筛龙头Exact Sciences。根据协议条款,Exact Sciences股东将获得每股普通股105美元的收购价,总股权价值约达210亿美元。(界面)

中国工程机械工业协会:10月我国工程机械进出口贸易额为48.44亿美元,同比增长0.07%

36氪获悉,据中国工程机械工业协会官微,据海关数据整理,2025年10月我国工程机械进出口贸易额为48.44亿美元,同比增长0.07%,其中:进口额1.76亿美元,同比下降24.2%;出口额46.68亿美元,同比增长1.29%。2025年1至10月我国工程机械进出口贸易额累计为507.18亿美元,同比增长11.5%。其中进口金额21.92亿美元,同比增长0.78%;出口金额485.26亿美元,同比增长12%。按照以人民币计价的出口额计算,10月份出口额332.03亿元,同比增长1.86%。10月份止累计出口额3478.16亿元,同比增长13%。

联合飞机在迪拜航展获1600架重载工业级无人机订单

联合飞机今日宣布在迪拜航展现场获1600架重载工业级无人机订单,这是中国企业在迪拜航展收获的最大一笔订单。航展前夕,联合飞机在中东地区正式落地无人机送餐服务。最新的订单来自阿联酋、韩国等低空物流、医疗配送、农业植保领域。(第一财经)

默克集团拟借助瓦罗健康的药物研发人工智能技术

德国默克集团(Merck KGaA)于周四宣布,已同意采用总部位于美国波士顿的瓦罗健康公司(Valo Health)的药物研究服务,双方将围绕帕金森病及相关疾病开展合作。对这家美国公司(瓦罗健康)而言,该合作的潜在价值可能超过30亿美元。(新浪财经)
❌