1 minute read

“学而时习之,不亦说乎?” —— 孔子
“Simple can be harder than complex: you have to work hard to get your thinking clean to make it simple.” —— Steve Jobs

从LIS到Principal思维:算法不是背答案,而是修炼思考方式的武功秘籍

开篇

在一次面试中遇到了一个看似简单的问题:”给定一个数组,求最长递增子序列的长度。”候选人很快写出了O(n²)的DP解法,然后停下来,满意地看着我。

“很好,”我说,”但如果数组有100万个元素呢?”

候选人愣住了。这一刻,我想起了王阳明的那句话:”知行合一,方为真知。”算法不是背公式,而是一种思考方式的修炼——当数据规模突破现有解法的边界时,真正的挑战不是回忆模板,而是从问题本质里找到新的突破口。

如果你也认为算法就是背模板、刷题库,那么这篇文章可能就是为你准备的。

第一章:普通工程师的算法世界 vs Principal的思维宇宙

故事背景:唐僧师徒的取经之路

让我们用《西游记》来类比这个过程。大多数工程师学算法,就像猪八戒挑担子——只看到眼前的重量(”这题用DP”),却不知道为什么要这样挑(”DP的本质是状态转移”),更不知道如何优化挑担的方式(”什么时候能压缩状态?”)。

而Principal级别的工程师,更像是孙悟空——不仅能七十二变(掌握多种解法),更重要的是,他知道什么时候该用哪一变(根据场景选最优解),为什么这样变最有效(理解解法背后的逻辑)。

普通工程师看LIS:背公式的世界

普通工程师的思路:

  • “LIS?我记得是DP,dp[i]表示以i结尾的最长递增子序列长度”
  • “转移方程是dp[i] = max(dp[j] + 1),其中j < i且nums[j] < nums[i]”
  • “时间复杂度O(n²),空间复杂度O(n),完美!”

这就像是背诵九九乘法表——知其然,不知其所以然。遇到100万级别的数据时,这种”完美解法”会瞬间失效,因为O(n²)的复杂度在n=1e5时会达到1e10次运算,远超计算机的处理能力。

Principal工程师看LIS:第一性原理的解构

Principal的思维过程:

  1. 先拆问题本质:我们要的是”最长递增子序列”,但真的需要关注”每个位置结尾的长度”吗?
    • DP解法保存了每个位置的状态,但这些状态里有大量冗余信息——比如两个长度相同的子序列,一个结尾是3,一个结尾是5,显然结尾为3的子序列未来更可能扩展(因为更小的结尾能容纳更多后续元素)。
    • 核心需求可以抽象为:对于每个可能的子序列长度L,找到结尾值最小的那个序列
  2. 再想优化方向:既然只需要”长度-L对应的最小结尾值”,能不能用一个数据结构专门维护这个映射?
    • 假设用数组tails保存这个映射:tails[k]代表长度为k+1的递增子序列的最小结尾值。比如tails[0]是长度1的最小结尾,tails[1]是长度2的最小结尾。
    • 处理新元素时,只需要判断它能更新哪个长度的最小结尾——这一步能不能用更高效的查找方式?
  3. 最后找数据结构匹配:tails数组有什么特性?
    • 当处理元素时,tails一定是严格递增的(如果tails[k] >= tails[k+1],那长度k+1的最小结尾比长度k的还小,这违背了”最小结尾”的定义)。
    • 有序数组的查找,自然会想到二分查找——这就是从问题本质推导到解法的关键一步。

第二章:Patience算法的直觉建立——扑克牌的智慧

为什么会联想到Patience Sorting?

很多人会疑惑:”怎么会想到用扑克牌游戏来解LIS?”其实不是”想到”,而是”推导过程中自然匹配”——当我们把”维护每个长度的最小结尾”这个逻辑具象化时,Patience Sorting恰好是最直观的载体。

我当时的思考路径是这样的:

  1. 已经明确要维护”长度-L→最小结尾”的映射,且这个映射是有序的。
  2. 处理每个元素时,需要找到第一个比它大(或不小)的”最小结尾”,用它来更新对应长度的结尾值;如果没有,就新增一个长度。
  3. 这个过程像什么?像整理扑克牌——每张牌要放到合适的堆里,让堆顶保持有序,堆的数量就是最长序列的长度。
  4. 突然意识到,这就是Patience Sorting(耐心排序)的核心逻辑!只不过平时接触Patience Sorting是在排序场景,这次是把它的”堆管理逻辑”抽离出来,用到了LIS问题上。

这就是资深工程师的思维特点:不是孤立记算法,而是记算法的核心逻辑,再根据问题本质进行匹配

Patience Sorting的具象化理解

让我们用扑克牌游戏把这个逻辑讲透,你会发现它和我们之前推导的逻辑完全一致:

游戏规则(对应LIS逻辑):

  1. 有一副打乱的牌(对应输入数组nums)。
  2. 从左到右处理每张牌(对应遍历nums)。
  3. 每张牌要么放在现有牌堆的顶部(前提是这张牌≤堆顶牌)——对应”用当前元素更新某个长度的最小结尾”;要么新建一个牌堆——对应”当前元素能构成更长的子序列,新增一个长度”。
  4. 如果能放在现有牌堆上,必须选择最左边满足条件的牌堆——对应”找到第一个≥当前元素的最小结尾,确保更小的结尾不被覆盖,保留更多扩展可能”。

神奇的对应关系:

  • 最终的牌堆数量 = 最长递增子序列的长度(因为每个堆代表一个长度的序列)。
  • 每个牌堆的堆顶 = 对应长度的”最小结尾值”(因为我们总是把牌放在最左边的堆,确保堆顶最小)。
  • 牌堆内部是递减的(因为每次放牌都要求≤堆顶),牌堆之间的堆顶是递增的(符合我们之前推导的tails数组特性)。

为什么这个游戏规则是正确的?

这里需要用第一性原理验证,确保不是”巧合”:

  1. 不变量维护:无论处理多少张牌,”堆顶递增”这个特性始终成立。假设某一步堆顶出现非递增,比如堆1顶是5,堆2顶是4,那堆2的4本可以放在堆1的5下面,这违背了”选最左边堆”的规则——所以堆顶一定递增。
  2. 最优性保证:选择最左边的堆放牌,本质是”用当前元素更新最小的、能被它替代的堆顶”。比如现有堆顶是[3,5,7],处理4时,会替换5为4,得到[3,4,7]——这样长度3的最小结尾从5变成4,未来更可能容纳比4大的元素(比如6)。
  3. 构造性证明:如果想得到具体的LIS序列,从最后一个堆的堆顶开始,向下取一张牌,再向左找比它小的堆顶,依次类推,就能构造出长度等于堆数的递增序列——这证明了堆数就是LIS的长度。

资深开发者的深度思考:为什么是Patience Sorting?

当我第一次遇到LIS优化问题时,我的思考过程远比”想到Patience Sorting”要复杂。让我还原当时的真实思考路径:

第一步:识别问题模式

  • 这是一个”序列优化”问题,需要处理动态变化的数据
  • 当前解法O(n²)在大数据量下不可行,必须找到O(n log n)或更好的方法
  • 问题具有最优子结构,但状态转移有冗余

第二步:分析状态冗余

  • 在O(n²)的DP中,dp[i] = max(dp[j] + 1) for j < i and nums[j] < nums[i]
  • 但仔细思考:对于相同的dp值(相同长度),我们真的需要记住所有可能的前驱吗?
  • 实际上,对于长度k,我们只需要记住结尾最小的那个序列——因为更小的结尾意味着未来更可能扩展

第三步:寻找抽象模型

  • 现在问题变成了:维护一个映射 f: length → min_end_value
  • 这个映射需要支持:
    • 快速查找:对于新元素x,找到第一个长度k使得f(k) >= x
    • 动态更新:用x更新f(k)(如果x < f(k))
    • 可能扩展:如果x > 所有f(k),则新增f(k+1) = x

第四步:匹配已知算法

  • 这个”维护有序映射+二分查找更新”的模式让我想到了几个候选:
    • 平衡二叉树:但实现复杂,且不需要完整的树操作
    • 跳表:同样复杂
    • 有序数组+二分查找:简单高效,正好匹配需求
  • 有序数组的维护模式又让我联想到Patience Sorting——因为Patience Sorting的核心就是维护多个有序堆,用贪心策略管理

第五步:验证直觉

  • 重新审视Patience Sorting:它确实维护了多个堆,堆顶有序
  • 每个新元素要么新建堆,要么放在最左边的合适堆顶
  • 这正好对应我们的”维护长度→最小结尾”的需求
  • 堆的数量就是最长序列长度,堆顶数组就是我们的f映射

这个思考过程的关键不是”记忆Patience Sorting”,而是从问题本质推导出需要的抽象模型,然后寻找匹配的已知算法模式。Patience Sorting只是恰好提供了这个抽象模型的具象化实现。

第三章:哪些场景下会考虑用Patience Sorting的思想?

Patience Sorting的核心逻辑是”维护多个有序子集,通过贪心选择最优子集更新,利用有序性高效查找“。当问题符合以下Pattern时,就可以考虑用它的思想:

Pattern 1:需要”长度-最优值”映射的问题

这类问题的核心是”对于每个可能的长度(或维度),维护一个最优值(如最小、最大),新元素仅需更新对应维度的最优值”。

典型例子:

  • LIS及其变种(如最长非递减子序列、最长递增子序列的个数)。
  • 俄罗斯套娃信封问题(LeetCode 354):先按宽度排序,宽度相同按高度递减排序,然后求高度的LIS——本质是维护”套娃层数→最小高度”的映射。
  • 最长摆动子序列(LeetCode 376):可以维护”长度-最后差值符号→最小结尾值”的映射,用类似思想优化。

我的思考逻辑: 当问题中”长度”是关键维度,且”每个长度对应一个最优结尾”时,Patience Sorting的”堆管理”逻辑就能直接复用。

Pattern 2:有序数组的动态更新与查找

这类问题的核心是”需要维护一个有序集合,动态插入元素并保持有序,同时频繁查询某个元素的位置(如第一个大于x的元素)”。

典型例子:

  • 动态求中位数(用两个堆实现,但如果是求第k小元素,Patience Sorting的有序数组+二分思想也可借鉴)。
  • 数据流中的最长递增子序列(LeetCode 1218):实时处理数据流,需要动态更新tails数组,用二分查找定位插入位置。

我的思考逻辑: Patience Sorting的本质是”用有序数组+二分查找”优化动态更新,当问题需要”动态维护有序结构+高效查找”时,即使不是LIS问题,也能借鉴这个组合。

Pattern 3:贪心策略下的最优子结构问题

这类问题的核心是”贪心选择能带来全局最优,且贪心策略可通过有序结构高效实现”。

典型例子:

  • 最少拦截系统问题(POJ 1028):每个拦截系统可以拦截递减的导弹,求最少需要多少系统——本质是求导弹高度的LIS长度,用Patience Sorting直接解。
  • 任务调度问题(如最少需要多少台机器处理任务,任务有开始和结束时间):将任务按开始时间排序,维护每台机器的结束时间(有序),新任务找最早结束的机器,用二分查找定位——这和Patience Sorting的堆管理逻辑完全一致。

我的思考逻辑: 当贪心策略的关键是”找到最优的前驱(如最早结束的机器、最小的结尾值)”,且前驱集合是有序的,就可以用Patience Sorting的”二分查找定位最优前驱”思想。

第三章:从O(n²)到O(n log n)的思维跃迁

普通工程师的优化思路:”能不能快一点?”

大多数人想到优化,就是:

  • “双重循环太慢了,能不能减少一层循环?”
  • “用哈希表能不能加速查找?”
  • “能不能用空间换时间?”

这些都是战术层面的思考,没有触及问题本质。比如有人会尝试用哈希表存dp值,但LIS的dp值是依赖于”前面所有比当前小的元素”,哈希表无法解决”范围查找”的问题,最终还是绕回O(n²)。

Principal的优化思路:”问题的本质是什么?”

我们之前已经推导过,LIS的本质是”维护每个长度的最小结尾”,而不是”每个位置的最长长度”。这个本质的转变,才是O(n log n)优化的核心——这是战略层面的重构。

我的思考过程可以拆解为三个维度:

1. 信息论视角:压缩冗余信息

  • O(n²)的DP保存了n个状态(每个位置的最长长度),但这些状态里有大量冗余——比如两个位置i和j(i<j),如果nums[i] < nums[j]且dp[i] = dp[j],那么j的状态完全可以替代i的(因为j的结尾更大,未来扩展能力更弱)。
  • 我们需要的核心信息只有”长度-L→最小结尾”,这个映射的状态数最多是LIS的长度(最坏是n,但查找效率从O(n)变成了O(log n))。
  • 这就是信息压缩:从n个状态压缩到O(L)个状态(L是LIS长度),同时用二分查找提升查找效率。

2. 数据结构视角:匹配问题特性

  • 当我们确定要维护”长度-L→最小结尾”的映射后,需要找一个数据结构来支持”快速查找第一个≥当前元素的位置”和”动态更新元素”。
  • 数组的随机访问+二分查找正好满足这两个需求:二分查找的时间复杂度是O(log L)(L≤n),更新元素是O(1)。
  • 为什么不用链表?因为链表的二分查找是O(n);为什么不用平衡二叉树?因为实现复杂,且数组在这种场景下足够高效——这就是根据问题特性选择最简数据结构的思维。

3. 算法设计视角:贪心+二分的组合逻辑

  • 贪心策略:每次用当前元素更新”第一个能更新的长度的最小结尾”,确保每个长度的结尾都是最小的,为未来元素留最大空间。
  • 二分优化:利用贪心策略带来的”tails数组有序”特性,将查找时间从O(n)降到O(log n)。
  • 这不是两个算法的简单叠加,而是”贪心策略决定了数据结构的特性,数据结构的特性又优化了算法的时间复杂度”——这是算法设计的精髓。

代码实现的艺术

from bisect import bisect_left

def lis_patience(nums):
    """Patience算法实现LIS
    
    核心思想:维护每个长度对应的最小结尾值
    时间复杂度:O(n log n)
    空间复杂度:O(n)
    """
    if not nums:
        return 0
    
    tails = []  # tails[k] = 长度为k+1的LIS的最小结尾值
    
    for num in nums:
        # 二分查找第一个 >= num 的位置(严格递增)
        # 若要非严格递增,用bisect_right
        pos = bisect_left(tails, num)
        
        if pos == len(tails):
            # num比所有结尾值都大,扩展LIS长度
            tails.append(num)
        else:
            # 用num更新该长度的最小结尾值,提升未来扩展能力
            tails[pos] = num
    
    return len(tails)

这段代码的关键注释已经标注了每个步骤对应的逻辑——从代码里能看到我们之前推导的所有思考:tails数组的含义、bisect_left的作用、更新逻辑的目的。这就是”理解后写的代码”和”背模板写的代码”的区别:前者能解释每一行的意义,后者只能说”这样写就能过”。

第四章:思维模式的深度对比

普通工程师 vs Principal工程师的思维差异

维度 普通工程师 Principal工程师
问题理解 按题目要求实现(如”求LIS长度”) 抽象问题本质(如”维护每个长度的最小结尾”)
解法选择 记忆模板,套用公式(如”LIS用DP”) 从本质推导解法,匹配核心逻辑(如”有序映射→二分→Patience”)
优化思路 局部优化(如”减少循环次数”) 全局重构(如”压缩信息→换数据结构→降复杂度”)
知识迁移 一题一解(如”Patience只用于排序”) 抽离核心逻辑,跨场景复用(如”Patience的堆管理用于LIS、调度”)
风险预判 只关注当前用例(如”样例过了就行”) 考虑边界场景(如”100万数据时O(n²)会超时”)

博弈论视角:面试中的信号传递

在面试中,你的解题过程就是向面试官传递”你是什么级别的工程师”的信号:

普通工程师的信号:

  • “我刷过这道题”(能写出DP解法)
  • “我记得标准答案”(能写出Patience代码,但说不出tails数组为什么有序)
  • “我能完成任务”(代码能跑,但无法优化边界场景)

Principal工程师的信号:

  • “我理解问题本质”(能解释为什么要维护最小结尾)
  • “我有系统性思维”(能从信息论、数据结构多角度分析)
  • “我能应对变化”(能说清”如果是最长非递减怎么办”,”如果要输出具体序列怎么办”)

第五章:知识的迁移与体系化

从LIS到更广阔的算法世界

Patience Sorting的思想不是孤立的,它可以和其他算法思想结合,解决更复杂的问题:

1. 与动态规划结合:状态压缩

  • 原始LIS的DP是O(n²),但用Patience的思想压缩状态后,变成了O(n log n)。
  • 类似的,最长公共子序列(LCS)如果其中一个字符串是有序的(如另一个字符串的排列),就可以转化为LIS,用Patience优化。

2. 与贪心算法结合:最优子结构

  • 任务调度问题中,”选择最早结束的机器”是贪心策略,用Patience的”有序维护机器结束时间+二分查找”实现。
  • 区间覆盖问题中,”选择覆盖当前位置且最远的区间”是贪心策略,也可以用类似的有序维护思想优化。

3. 与二分查找结合:有序结构的高效利用

  • 所有需要”动态有序集合+快速查找”的问题,都可以借鉴”数组+二分”的组合(如数据流中的第k大元素)。
  • 甚至在工程场景中,比如维护用户的积分排名,需要

Updated: