从一道面试题看编程思维:数组去重的六种解法与AI时代的代码基本功
前七篇文章,我一直在往上走。
OPC(认知)→ Prompt(沟通)→ Agent(工具)→ CLI(工作流)→ Git(版本)→ 模块化(架构)→ FDE(业务)。每一篇都在提升抽象层次,每一篇都在拉高视角。写到 v007 的时候,我甚至觉得已经触摸到了 AI Native 开发者的"终极形态"——连接 AI 和业务,在客户的现场产生真实价值。
然后第八天,我老老实实坐下来,写了一道数组去重题。
[1, 2, 3, 2, 3] → [1, 2, 3]
看起来简单到不值得写一篇博客。但我在学这道题的过程中,发现了一件有意思的事:这六种解法背后藏着的思维模式,和前七篇文章讲的竟然是同一套东西。
抽象和基础,不是两件事。
一、为什么一道数组去重值得写一篇博客
先说说面试官的视角。
笔记里写了一段话:"面试官心态——常见的业务逻辑,手写业务代码题目,多种解法,非常灵活,考的是数组的API,还有优化手段。"
翻译一下:面试官出这道题的时候,心里想的不是"你知不知道 Set",而是"你能不能从多个角度思考同一个问题"。
一个有意思的事实是:几乎每个面试者都能写出一种解法。但能写出三种以上的人,就少很多。能把每种解法的时间复杂度、空间复杂度、适用场景讲清楚的人,更少。能从暴力解法一路推到最优解、并解释每一步"为什么优化"和"牺牲了什么"的人,凤毛麟角。
你看,同一道题,不同的人答出来的深度完全不同。这不是知识量的差距,是思维层次的差距。
这和 v001 里 OPC 的"一人分饰七角"是一回事。OPC 说一个人要能从老板、PM、设计师、前端、后端、测试、运维七个角度看问题。数组去重也一样——你用"能跑就行"的心态看,一行 new Set() 就结束了;你用"我想知道原理"的心态看,六个解法每一个都在教你一种新的思考方式。
v006 把"能跑"和"好维护"做了区分。今天在这个基础上再加一层:"能跑"和"真正理解",隔的也不是一行代码,而是一整套思维路径。
二、六种解法,六层思维
不打算把六种解法的代码全贴一遍。笔记里都有。我想做的是提取每种解法背后的思维模式——因为代码会忘,但思维模式会长在身上。
第一层:双重循环——暴力美学的价值
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
if (arr[i] === arr[j]) {
arr.splice(j, 1)
j--
}
}
}
时间复杂度 O(n²)。笨重、低效、没人会在生产环境这么写。
但它有价值。
暴力解法是你理解问题本质的起点。你在双重循环里亲自跑了一遍"比较每一对元素"的过程,这个过程的直观感受会在你脑子里形成一个基准线——比这更快,快在哪里?比这更省空间,省在哪里?
没有笨办法,就不会有聪明办法。所有优化都是站在暴力解法的肩膀上。
这让我想起 v005 里第一次用 git log 看到 commit 历史时的感觉——那时候才真正理解了"版本"是什么。有些东西,你必须亲手做过最原始的那个版本,才能理解后面的抽象层到底在帮你省什么。
第二层:indexOf——从"手动做"到"借用语言能力"
// 思路一:当前元素在原数组中第一次出现的位置 === 当前位置
if (arr.indexOf(arr[i]) === i) { res.push(arr[i]) }
// 思路二:当前元素在结果数组中不存在,则添加
if (res.indexOf(arr[i]) === -1) { res.push(arr[i]) }
双重循环是你自己写比较逻辑。indexOf 是"我把比较这件事外包给语言内置的方法"。
思维跳跃在于:你从**"我该怎么做"变成了"语言能帮我做什么"。这是一个重要的心理转变——好的程序员不是能写更多代码的人,而是善于找到并利用已有的工具**的人。
一种解法,两种思路(和原数组比、和结果数组比),同一个 API。这又呼应了 OPC 的核心理念:同一件事,换个角度看,解法就不同。
第三层:排序 + 相邻比较——算法思维的引入
arr.sort((a, b) => a - b)
for (let i = 0; i < arr.length; i++) {
if (arr[i] !== arr[i + 1]) { res.push(arr[i]) }
}
O(n log n),比 O(n²) 快了很多。
但代价是:原数组的顺序被改变了。 排序是破坏性操作——如果你的业务需要保留原始顺序,这种解法就不能用。
这是今天遇到的第一个有"tradeoff"味道的解法。之前的解法要么对要么错,但这里开始出现场景依赖——在不在乎顺序?数据量大不大?每引入一个优化,就会带来一个新的取舍。
v006 聊模块化的时候我说过"每一个设计决定都有代价"。今天在算法层面看到了同样的事。架构思维和算法思维,说的是同一种语言。
第四层:Hash 对象——数据结构思维的登场
let obj = {}
for (let i = 0; i < arr.length; i++) {
if (!obj[arr[i]]) {
res.push(arr[i])
obj[arr[i]] = 1
}
}
O(n)。比排序还快。怎么做到的?换了数据结构。
前面三个解法,都是在"数组"这个数据结构里打转。Hash 对象第一次跳出了数组的思维框架——用对象的键来判重,查找时间从 O(n) 变成 O(1)。
这是今天最关键的一次思维跳跃:换一种方式组织数据,就能换一种性能。
这就是"数据结构"四个字的精髓。数据结构不是课本里的名词,而是解决特定问题的数据组织方式。数组适合顺序访问,对象适合快速查找,每个结构有自己的强项和弱项。
"空间换时间"——多占了一些内存,换来了更快的速度。在内存便宜的今天,这通常是一笔划算的买卖。但如果你在内存受限的设备上(比如嵌入式),可能就要反过来——"时间换空间"。
通过这道题,我第一次具体地理解了什么是工程 tradeoff。
第五层:Map——语义化的力量
let map = new Map()
for (let i = 0; i < arr.length; i++) {
if (!map.has(arr[i])) {
map.set(arr[i], 1)
res.push(arr[i])
}
}
Map 和普通对象的逻辑几乎一样,但用了 map.has() / map.set() 替代了 obj[key]。
差别在哪儿?语义。
obj[key] 可以干一万件事——判重、计数、缓存、映射……你只能靠上下文猜它在干什么。map.has() 就是一句话:查询此键是否存在。map.set() 就是一句话:设置此键。
Map 还有普通对象没有的优势:键可以是任意类型(对象、函数、NaN),不会被意外转成字符串 "[object Object]"。
从"能做"到"说清楚在做什么",这是从写代码到写好代码的分界线。v002 说 Prompt 要结构化——Goal、Input、Output 各管各的。代码也一样,语义越清晰,维护成本越低。
第六层:Set——一行代码背后的抽象层次
return [...new Set(arr)]
从双重循环的十几行,到这一行。
这不是技巧的进步,是抽象层次的进步。Set 就是为"集合"而生的数据结构——无序、不重复。它内置了去重的能力,不需要你写任何逻辑。
回头看六种解法的演进,本质上是一条抽象层次不断提升的路:
手动比较 → 借用语言 API → 引入算法 → 转换数据结构 → 语义化表达 → 专用数据结构
每一步都不是在"写更少的代码",而是在更精准地描述你想做什么。写代码的本质,就是把"意图"翻译成"指令"。抽象层越高,翻译成本越低。
这其实也是整个 blog 系列的底层逻辑。v002 的结构化 Prompt 是"更精准地描述意图",v003 的 Agent 是"更高的抽象层次",v006 的模块化是"更清晰的语义边界"。一道数组去重题,折射的是一整个编程世界观。
三、AI 时代,手写这些还有意义吗
每次学到这种基础题,都会有一个问题冒出来:AI 一秒能生成所有解法,我手写还有什么意义?
笔记里给了两个有意思的 AI 用法:
用法一:注释先行,AI 生成。 先把 @func、@param、@returns 写好,把函数的契约定义清楚,然后让 Copilot 根据注释生成代码。注释越精准,生成的代码越靠谱。
用法二:让 AI 写测试。 给 AI 一个指令——"请写测试用例,用于验证数组去重的正确性"。AI 会生成覆盖各种边界情况的测试代码。
这两个用法的共同点是:AI 不是替代你的思考,而是执行你的判断。
你定义"这个函数要做什么"(注释),AI 实现"怎么做"(代码)。你定义"怎么验证对错"(测试要求),AI 生成"验证的具体步骤"(测试用例)。
但这里有一个关键前提:你得知道什么叫"好的实现"、什么叫"充分的测试"。
如果你没手写过双重循环,你可能看不出 Set 解法好在哪——只会觉得"一行和一页代码的区别"。如果你没学过参数校验,你不会想到让 AI 测试 unique(null) 或 unique(undefined)。
手写的意义变了。以前手写是为了记住 API,现在手写是为了培养判断力。 你得先知道"好"是什么样,才能指挥 AI 往那个方向走。v006 结语里写过:"你越懂怎么写好代码,你就越能指挥 AI 写好的代码。" 今天的学习给这句话填上了具体的内容。
这也是注释和 Prompt 的深层联系。v002 的五块分割法(Goal / Input / Output / Layout / Features),和 JSDoc 注释的 @func / @param / @returns,本质上是同一套思维——在动手之前,先说清楚你要什么。 一个是对 AI 说话,一个是在代码里说话,但逻辑一致。
四、从 FDE 回看基础
v007 的结尾我提了一个观点:"AI Native 开发者的终极形态,不是写代码最快的人,而是能连接 AI 和业务的人。" FDE 需要三种能力:领域知识 + AI 工程 + 商业理解。
当时"AI 工程"这四个字,我理解得还挺模糊的。
今天学完数组去重,我对"AI 工程"有了具体的感知。它不是说你会用 Claude Code、会调 API 就够了——它意味着:
- 你遇到一个性能瓶颈时,能想到换数据结构(就像双重循环换成 Hash)
- 你面对一个业务场景时,能判断哪个环节适合 AI 改造(就像判断哪种解法适合当前数据特征)
- 你评估一个方案时,能算出代价和收益(就像 O(n²) vs O(n) 的取舍)
FDE 要的不是"能写 Set",而是在真实的业务现场,面对几十个变量时,能拍板说:这个环节,用这个方案,原因是一二三。
而这种判断力的训练场,就是一道一道的基础题。数据结构、算法复杂度、API 设计、参数校验——这些在"AI 什么都能生成"的时代看起来最不起眼的东西,恰恰是决定你能不能在关键时刻做出正确判断的底层操作系统。
v007 聊得那么高,v008 落地到一行 [...new Set(arr)],它们不是矛盾的。向上抽象和向下扎根,是同一棵树的两个生长方向。
结语
以前七篇,我一直在往上走——从怎么写 Prompt 到怎么理解 FDE。第八篇,往下沉了一步,把一道最简单的数组去重题,翻了六遍。
这六遍翻完,我发现了一个之前没意识到的规律:每一层编程思维的进步,本质上都是"更精准地描述意图"。
直接写双重循环,是在说"帮我逐个比较"。 调 indexOf,是在说"帮我查这个元素在不在"。 用 Hash,是在说"用键来标记存在"。 用 Set,是在说"我就要一个不重复的集合"。
从啰嗦到精准,从具体到抽象——这和 Prompt 的进化(一句话 → 结构化 → Agent 指令)是同一条路,和模块化的进化(大文件 → 拆函数 → 拆模块)也是同一条路。
我还在学。FDE 之后是更基础的算法题,基础之后再往哪儿走,还不确定。但有一件事越来越清晰:
Be AI Native 的路上,没有"高级"和"基础"的区别。你在一道小题里看到的思维深度,就是你在大项目里能走到的认知高度。
下篇见。