ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java与Python双语言刷lintcode:工程化算法训练路径

Java与Python双语言刷lintcode:工程化算法训练路径 简介这份资源是面向算法学习者和备战毕设的学生整理的lintcode题解合集聚焦Java与Python双语言实现帮助读者在对比中理解同一道题的不同编码思路。压缩包共36个文件以md题解笔记、py脚本和java源码为主另含git配置类文件整体约17KB体积轻便便于本地查阅。内容覆盖打劫房屋、超级丑数、中位数、爬楼梯、最小路径和、硬币排成线、摆动排序、字符串查找、第K大元素、二分查找等经典题目每道题均配有Java与Python两套解法及说明文档便于对照分析算法思想、时间与空间复杂度以及数据结构选择。已有46人学习适合初学者打基础也适合有一定经验的开发者查漏补缺、提升实际解题能力。1. 用 Java 和 Python 双语言啃下 lintcode 算法题一个被低估的刷题工程化路径很多人刷算法题的习惯是打开一个在线判题页面选语言、写代码、提交、看结果然后关掉。这个流程本身没问题但当你刷到两三百题之后会发现一个尴尬的事实同一道题用 Java 写过的解法过两周换成 Python 面试时你只记得思路具体边界处理全忘了。更麻烦的是lintcode 上不少题对时间复杂度的卡点非常细Java 能过的写法换成 Python 可能超时反过来 Python 能过的逻辑用 Java 写又因为类型装箱慢了一截。这个标题指向的正是这件事用 Java 和 Python 两套语言对 lintcode 的算法与数据结构题做分析、实现和对照验证把刷题从“一次性提交”变成“可复现、可对比、可归档”的工程化过程。适合已经能写基本语法、但刷题效率低、面试前想系统过一遍数据结构与算法的开发者。下面按我实际搭这套流程的顺序讲。2. 双语言刷题环境怎么搭目录结构、依赖与最小验证2.1 为什么不用在线编辑器直接写在线编辑器最大的问题是不可归档、不可批量回归。你今天调通的一道题明天改了公共工具类没法一键重跑全部用例。我一般会在本地建一个统一仓库Java 和 Python 各占一个源码根目录测试用例和题解说明放在共享的problems/下。这样做的好处是同一道题的两种语言实现放在相邻位置对照阅读成本极低同时可以写一个脚本批量跑所有已完成的题回归验证。目录结构大致如下lintcode-lab/ ├── java/ │ └── src/ │ ├── common/ # 公共数据结构ListNode、TreeNode、并查集等 │ └── problems/ # 每道题一个类如 P0001_TwoSum.java ├── python/ │ ├── common/ # 对应的 Python 数据结构 │ └── problems/ # 每道题一个模块如 p0001_two_sum.py ├── problems/ # 题目描述、用例、复杂度分析markdown │ └── p0001_two_sum.md └── run_all.sh # 批量回归脚本2.2 Java 侧的最小可运行骨架Java 刷题最容易翻车的地方是公共数据结构不统一。lintcode 的链表题给的是ListNode树题给的是TreeNode如果你每道题都重新定义一个后面写工具方法时类型对不上。我一般先在common包里固定下来// java/src/common/ListNode.java package common; public class ListNode { public int val; public ListNode next; public ListNode(int val) { this.val val; } public ListNode(int val, ListNode next) { this.val val; this.next next; } // 从数组构建链表方便本地测试 public static ListNode of(int... arr) { ListNode dummy new ListNode(0); ListNode cur dummy; for (int v : arr) { cur.next new ListNode(v); cur cur.next; } return dummy.next; } // 转回数组方便断言比较 public int[] toArray() { java.util.ListInteger list new java.util.ArrayList(); for (ListNode p this; p ! null; p p.next) list.add(p.val); return list.stream().mapToInt(Integer::intValue).toArray(); } }这段代码的关键点是of和toArray两个静态/实例方法。of用哑结点dummy node构建链表这是链表题里最常用的技巧提前在这里封装好后面每道题就不用重复写。toArray用于测试断言避免手写遍历比较。参数上注意int...可变参数在传空数组时返回null如果你的题可能输入空链表调用处要判空。对应的 Python 侧# python/common/list_node.py class ListNode: def __init__(self, val0, nextNone): self.val val self.next next staticmethod def of(arr): dummy ListNode(0) cur dummy for v in arr: cur.next ListNode(v) cur cur.next return dummy.next def to_list(self): res, p [], self while p: res.append(p.val) p p.next return resPython 这里有个坑next作为参数名会遮蔽内置函数next()在生成器场景里会出问题。我一般改成next_node但为了和 lintcode 题面签名保持一致测试代码里不调用内置next就没事。如果你后面要写迭代器工具建议改名。2.3 用一道题验证双语言环境是否跑通拿最经典的 Two Sum 做冒烟测试。Java 侧// java/src/problems/P0001_TwoSum.java package problems; import java.util.HashMap; import java.util.Map; public class P0001_TwoSum { public int[] twoSum(int[] nums, int target) { MapInteger, Integer idx new HashMap(); for (int i 0; i nums.length; i) { int need target - nums[i]; if (idx.containsKey(need)) { return new int[]{idx.get(need), i}; } idx.put(nums[i], i); } return new int[]{-1, -1}; } public static void main(String[] args) { P0001_TwoSum s new P0001_TwoSum(); int[] r s.twoSum(new int[]{2, 7, 11, 15}, 9); assert r[0] 0 r[1] 1 : case1 failed; System.out.println(P0001 passed); } }Python 侧# python/problems/p0001_two_sum.py def two_sum(nums, target): idx {} for i, v in enumerate(nums): need target - v if need in idx: return [idx[need], i] idx[v] i return [-1, -1] if __name__ __main__: assert two_sum([2, 7, 11, 15], 9) [0, 1] print(p0001 passed)两边的核心逻辑完全一致哈希表存“值到下标”遍历时先查补数再存当前值。这个顺序很重要如果先存再查遇到[3,3], target6会返回[1,1]而不是[0,1]。参数上 Java 用HashMapInteger,Integer会有装箱开销数据量大时可以考虑用数组代替但 lintcode 的 Two Sum 数据规模不大没必要过早优化。跑通这两段环境就算立住了。接下来才是真正花时间的部分按数据结构分类逐类做双语言对照实现。3. 按数据结构分类推进链表、树、图的 Java/Python 对照实现3.1 链表题指针操作在两种语言里的心智差异链表题的核心操作就那几个哑结点、快慢指针、反转、合并。但 Java 和 Python 写起来心智负担完全不同。Java 里每个节点是对象引用p p.next之后原来的p还能通过其他引用访问Python 里变量是名字绑定p p.next只是把名字指向新对象逻辑上一样但如果你同时持有prev和cur赋值顺序写错就会断链。以“反转链表”为例Java 写法public ListNode reverseList(ListNode head) { ListNode prev null, cur head; while (cur ! null) { ListNode nxt cur.next; // 先存后继 cur.next prev; // 反转指针 prev cur; // prev 前移 cur nxt; // cur 前移 } return prev; }Python 写法def reverse_list(head): prev, cur None, head while cur: nxt cur.next cur.next prev prev, cur cur, nxt return prevPython 可以用元组赋值把prev, cur cur, nxt写成一行右边先求值再绑定等价于 Java 的两步。但这里有个血泪经验如果你写成cur, prev nxt, cur顺序反了cur先被改成nxt然后prev拿到的是旧的cur逻辑就错了。元组赋值虽然简洁但变量顺序必须和 Java 的赋值顺序严格对应否则就是玄学 bug。链表题里还有一个高频坑快慢指针找中点时循环条件while fast and fast.next和while fast.next and fast.next.next对应不同的中点定义前者偶数长度取后中点后者取前中点。lintcode 上“排序链表”“重排链表”对中点位置有隐含要求写之前先确认题面要的是哪一个。3.2 树题递归模板与迭代写法的取舍树题我一般先写递归因为递归模板固定不容易错。以“二叉树中序遍历”为例public ListInteger inorder(TreeNode root) { ListInteger res new ArrayList(); dfs(root, res); return res; } private void dfs(TreeNode node, ListInteger res) { if (node null) return; dfs(node.left, res); res.add(node.val); dfs(node.right, res); }Python 对应def inorder(root): res [] def dfs(node): if not node: return dfs(node.left) res.append(node.val) dfs(node.right) dfs(root) return res递归写法在 lintcode 上大部分树题都能过但有两类例外一是题目明确要求迭代考察栈的掌握二是树深度可能达到 10^5 级别递归会栈溢出。Python 默认递归深度约 1000Java 默认栈大小对应约 5000 到 10000 层具体取决于 JVM 参数。遇到深树必须改迭代。迭代中序遍历的 Java 写法public ListInteger inorderIter(TreeNode root) { ListInteger res new ArrayList(); DequeTreeNode stack new ArrayDeque(); TreeNode cur root; while (cur ! null || !stack.isEmpty()) { while (cur ! null) { stack.push(cur); cur cur.left; } cur stack.pop(); res.add(cur.val); cur cur.right; } return res; }Python 用 list 当栈def inorder_iter(root): res, stack, cur [], [], root while cur or stack: while cur: stack.append(cur) cur cur.left cur stack.pop() res.append(cur.val) cur cur.right return res参数上注意 Java 用ArrayDeque而不是Stack后者是同步类单线程下性能差一截。Python 用 list 的append/pop就是栈不要用collections.deque做栈虽然也能用但pop()默认从右端弹出和 list 行为一致没必要多引入一个类型。3.3 图题邻接表构建与 BFS/DFS 的边界处理图题在 lintcode 上主要分三类网格图岛屿数量、迷宫、邻接表图克隆图、拓扑排序、隐式图单词接龙。网格图最简单因为邻居是算出来的邻接表图需要先建图这里 Java 和 Python 的写法差异最大。以“克隆图”为例Java 侧public Node cloneGraph(Node node) { if (node null) return null; MapNode, Node map new HashMap(); return dfs(node, map); } private Node dfs(Node node, MapNode, Node map) { if (map.containsKey(node)) return map.get(node); Node copy new Node(node.val); map.put(node, copy); for (Node nb : node.neighbors) { copy.neighbors.add(dfs(nb, map)); } return copy; }Python 侧def clone_graph(node): if not node: return None old_to_new {} def dfs(n): if n in old_to_new: return old_to_new[n] copy Node(n.val) old_to_new[n] copy for nb in n.neighbors: copy.neighbors.append(dfs(nb)) return copy return dfs(node)关键点是“先注册再递归”。如果先递归邻居再注册自己遇到环就会无限递归。这个坑在 Java 和 Python 里表现一样但 Python 因为字典键是对象引用n in old_to_new用的是__hash__和__eq__默认按身份比较没问题如果你自定义了Node的__eq__却没定义__hash__字典会直接报错。lintcode 的Node类一般不给自定义所以安全。BFS 版本要注意队列的初始化Java 用QueueNode q new LinkedList()或ArrayDequePython 用collections.deque。网格图 BFS 里方向数组[[0,1],[0,-1],[1,0],[-1,0]]是标配但别忘了在入队时标记已访问否则同一节点可能被重复入队在岛屿最大面积这类题上会导致结果偏大。4. 避坑与排查双语言刷题最容易翻车的 5 个点4.1 整数溢出Java 静默回绕Python 不会现象同一道“两数相除”或“反转整数”的题Python 提交通过Java 提交报错或结果不对。原因Java 的int是 32 位Integer.MAX_VALUE 1会回绕成Integer.MIN_VALUE且不抛异常。Python 的int是任意精度不会溢出但这也意味着如果你在 Python 里模拟 32 位溢出需要手动 0xFFFFFFFF再判断符号。解决Java 里涉及加减乘的题先估算中间结果范围超过 2^31-1 就换long。Python 里如果题目要求 32 位行为在返回前做截断def to_int32(x): x 0xFFFFFFFF return x - 0x100000000 if x 0x80000000 else x4.2 哈希表遍历顺序Java HashMap 无序Python dict 有序现象同一道“字母异位词分组”的题Java 和 Python 输出顺序不同本地断言失败。原因Java 的HashMap不保证遍历顺序Python 3.7 的dict保证插入顺序。lintcode 判题一般对顺序不敏感但本地测试如果直接比较列表就会挂。解决本地断言前先排序或者用TreeMapJava和OrderedDictPython统一顺序。我一般是在测试代码里对结果做规范化而不是改题解本身。4.3 递归深度Python 默认 1000Java 看栈大小现象深树或深图递归题Python 报RecursionErrorJava 报StackOverflowError。原因Python 默认递归限制 1000可以用sys.setrecursionlimit调大但调太大可能真的把 C 栈打爆。Java 默认线程栈约 512KB 到 1MB对应递归深度几千层。解决优先改迭代。如果必须递归Python 里sys.setrecursionlimit(100000)配合threading.stack_size开新线程跑Java 里用-Xss参数调大栈。但这些都是权宜之计面试时写迭代更稳。4.4 可变默认参数Python 独有的坑现象Python 写回溯题时结果列表里全是同一个引用最后输出一堆重复。原因def dfs(path, res[])这种写法默认列表在函数定义时创建一次所有调用共享。解决默认参数用None函数体内判空再创建def dfs(path, resNone): if res is None: res []Java 没有这个问题因为 Java 不支持默认参数每次都得显式传。4.5 字符串拼接Java 用 StringBuilderPython 用 join现象字符串拼接题在 Java 里超时Python 里却过了。原因Java 的String不可变循环里s c每次创建新对象O(n^2)。Python 的str也不可变但 CPython 对做了原地优化某些场景下接近 O(n)不过不保证。解决Java 循环拼接一律用StringBuilderPython 用.join(list)。这条在“最长公共前缀”“字符串压缩”这类题上直接决定能不能过。5. 批量回归与复杂度对照把刷题变成可验证的工程5.1 写一个跨语言回归脚本单题跑通不难难的是改了一个公共类之后确认所有已完成的题都没被影响。我一般写一个 shell 脚本分别编译 Java、跑 Python统计通过数#!/bin/bash # run_all.sh set -e echo Java cd java javac -d out $(find src -name *.java) for cls in $(find src/problems -name P*.java | sed s|src/||;s|/|.|g;s|.java||); do java -cp out $cls || echo FAIL: $cls done cd .. echo Python for f in python/problems/p*.py; do python3 $f || echo FAIL: $f done这个脚本的逻辑是Java 侧先全量编译再按类名逐个跑mainPython 侧直接跑每个模块。每个题的main里写断言失败就抛异常脚本捕获后打印 FAIL 但继续跑下一题。参数上注意set -e会让脚本在第一个失败时退出如果你想要“跑完所有再汇总”把set -e去掉改成手动统计退出码。5.2 复杂度对照表怎么记双语言刷题的一个附加价值是你可以直观看到同一算法在两种语言下的常数差异。我一般会在每道题的 markdown 里记一张小表题目算法Java 耗时Python 耗时备注二分查找迭代0.8ms2.1msPython 循环开销大归并排序递归3.2ms8.5ms递归深度相同哈希计数HashMap/dict1.5ms1.2msPython dict 更快这张表不是给面试官看的是给自己看的。当你发现某道题 Python 比 Java 慢 5 倍以上就要警惕是不是写法有问题比如在 Python 里用了低效的列表操作或者在 Java 里频繁装箱。5.3 一个具体技巧用对拍验证边界最后分享一个我常用的验证方法对拍。同一道题Java 写一个暴力解法Python 写一个优化解法随机生成小规模输入跑几百轮比较结果。如果结果不一致说明优化解法有边界漏洞。import random, subprocess def gen(): n random.randint(1, 8) return [random.randint(-5, 5) for _ in range(n)] for i in range(500): arr gen() # 假设 Java 暴力解输出到 stdoutPython 优化解直接调用 expected brute_force(arr) actual optimized(arr) if expected ! actual: print(Mismatch:, arr, expected, actual) break这个方法的成本很低但能抓到很多手写测试漏掉的边界比如空数组、单元素、全负数、有重复值。我一般在对一道题有“感觉不太对”的时候跑一轮对拍十次里有三次能抓到问题。这套流程我断断续续用了两年最大的教训是不要追求刷题数量而是追求每道题在两种语言下都能独立写出来、都能解释清楚复杂度、都能通过随机对拍。一开始会觉得慢但面试前复习时你翻自己的仓库比翻任何题解都快。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进