Android 面试算法完整手册(64 题)
定位:面向 Android/客户端面试的算法速查与复习手册。
特点:每题包含「易记方式、思路、步骤、复杂度、可抄写代码骨架」。
适用:面试前突击、平时刷题复盘、口述思路训练。
1 链表类
1.1 反转链表
易记方式:三指针反转|触发词:prev/cur/next|警报:先存 next 再改指针。
思想:迭代反转整条链表。用三个引用:prev(已反转部分的头)、cur(当前结点)、nxt(暂存后继,避免断链后丢失)。
步骤:
- 初始化
prev=null,cur=head。 - 当
cur非空:nxt=cur.next,cur.next=prev,然后prev=cur,cur=nxt。 - 结束时
prev即为新头结点。
时间复杂度:O(n),n 为链表长度
空间复杂度:O(1),仅用常数个引用。
fun reverseList(head: ListNode?): ListNode? {
var prev: ListNode? = null
var cur = head
while (cur != null) {
val nxt = cur.next
cur.next = prev
prev = cur
cur = nxt
}
return prev
}
1.2 反转链表 II(区间反转)
易记方式:dummy 头插|触发词:pre/start/then|警报:left=1 必须有 dummy。
思想:在「虚拟头结点 dummy」后定位到第 left-1 个结点作为 pre,对 [left,right] 区间做多次「头插」,把区间内结点逐个插到 pre 之后。
步骤:
dummy.next=head,pre从dummy走left-1步。start为区间首结点,then为待头插的下一个结点,循环right-left次:把then移到pre.next之前。
时间复杂度:O(n)
空间复杂度:O(1)。
fun reverseBetween(head: ListNode?, left: Int, right: Int): ListNode? {
val dummy = ListNode(0).apply { next = head }
var pre: ListNode? = dummy
repeat(left - 1) { pre = pre!!.next }
val start = pre!!.next
var then = start?.next
repeat(right - left) {
start!!.next = then!!.next
then.next = pre!!.next
pre.next = then
then = start.next
}
return dummy.next
}
1.3 环形链表 II(入环点)
易记方式:相遇再重逢|触发词:快慢指针|警报:先判 fast/fast.next。
思想:Floyd 判环——快指针每次走 2 步、慢指针走 1 步,若有环必相遇;无环则快指针先到达链表尾。求环入口:相遇后,一指针从 head 出发、一指针从相遇点出发,同速前进,相遇处即为入环点(数学可证路程相等)。
步骤:
slow=fast=head,循环直到fast或fast.next为空(无环)或slow==fast(有环)。- 若无环返回
null;若有环,p=head,p与slow同步前移直到p===slow,返回p。
时间复杂度:O(n)
空间复杂度:O(1)。
fun detectCycle(head: ListNode?): ListNode? {
var slow = head
var fast = head
while (fast?.next != null) {
slow = slow?.next
fast = fast.next?.next
if (slow === fast) break
}
if (fast?.next == null) return null
var p = head
while (p !== slow) {
p = p?.next
slow = slow?.next
}
return p
}
1.4 链表的中间结点
易记方式:快二慢一|触发词:fast/slow|警报:偶数长度题意取哪一个中点。
思想:快慢指针:fast 每次走两步、slow 走一步,当 fast 到尾时,slow 在链表中点(偶数个结点时取第二个中点)。
步骤:
slow=fast=headwhile (fast?.next!=null):slow=slow.next,fast=fast.next.next- 返回
slow。
时间复杂度:O(n)
空间复杂度:O(1)。
fun middleNode(head: ListNode?): ListNode? {
var slow = head
var fast = head
while (fast?.next != null) {
slow = slow?.next
fast = fast.next?.next
}
return slow
}
1.5 合并两个有序链表
易记方式:归并接小|触发词:a/b/tail|警报:最后别忘接剩余链。
思想:两链表元素均已有序,用双指针比较当前头结点值,较小者接到结果链尾(merge,归并的一个子过程,不是完整排序算法)。
步骤:
- 建
dummy a、b指向两链头while两者非空,选较小者接尾- 最后接上剩余一段。
时间复杂度:O(n+m)
空间复杂度:O(1)。
fun mergeTwoLists(l1: ListNode?, l2: ListNode?): ListNode? {
val dummy = ListNode(0)
var t = dummy
var a = l1
var b = l2
while (a != null && b != null) {
if (a.`val` <= b.`val`) {
t.next = a
a = a.next
} else {
t.next = b
b = b.next
}
t = t.next!!
}
t.next = a ?: b
return dummy.next
}
1.6 相交链表
易记方式:A+B 等于 B+A|触发词:换头双指针|警报:比较引用不是值。
思想:两链表相交则尾部共用。指针 a 从 A 出发走到尾后从 B 头继续,b 对称;总路程相同,若有交点必在交点相遇(否则同到 null)。
步骤:
a=headA,b=headBwhile (a!==b):a = a?.next ?: headB,b = b?.next ?: headA- 返回
a。
时间复杂度:O(n+m)
空间复杂度:O(1)。
fun getIntersectionNode(headA: ListNode?, headB: ListNode?): ListNode? {
var a = headA
var b = headB
while (a !== b) {
a = if (a == null) headB else a.next
b = if (b == null) headA else b.next
}
return a
}
1.7 奇偶链表
易记方式:奇偶分链再接|触发词:odd/even/evenHead|警报:处理空链和单节点。
思想:保持相对顺序:奇数位结点串成一条链,偶数位串成另一条,再把奇链尾接偶链头。下标从 1 开始计「奇」「偶」。
步骤:
odd指向第 1 个结点,evenHead为第 2 个结点;若只有 0/1 个结点直接返回。- 反复:
odd.next = even.next,odd前进;even.next = odd.next,even前进,直到偶链无后继。 odd.next = evenHead,返回原头。
时间复杂度:O(n)
空间复杂度:O(1)。
fun oddEvenList(head: ListNode?): ListNode? {
if (head == null || head.next == null) return head
var odd: ListNode = head
val evenHead = head.next!!
var even: ListNode = evenHead
while (even.next != null) {
odd.next = even.next
odd = odd.next!!
even.next = odd.next
even = even.next!!
}
odd.next = evenHead
return head
}
2 栈与队列类
2.1 有效的括号
易记方式:左进右配|触发词:括号栈|警报:右括号前先判空栈。
思想:左括号入栈;遇到右括号必须与栈顶左括号类型匹配,否则非法。
步骤:
- 遍历字符
([{入栈)]}时若栈空或栈顶不匹配则失败- 结束栈须为空。
时间复杂度:O(n)
空间复杂度:O(n)。
import java.util.ArrayDeque
fun isValidParentheses(s: String): Boolean {
val st = ArrayDeque<Char>()
val map = mapOf(')' to '(', ']' to '[', '}' to '{')
for (c in s) {
when (c) {
'(', '[', '{' -> st.addLast(c)
else -> {
if (st.isEmpty() || st.removeLast() != map[c]) return false
}
}
}
return st.isEmpty()
}
2.2 每日温度
易记方式:单调栈找右大|触发词:从右向左|警报:栈里存下标不存值。
思想:对每个位置找「右侧第一个更大温度」的距离。从右往左扫,单调栈存下标,栈底到栈顶温度递减;当前温度会弹出栈里所有不大于它的下标。
步骤:
i从n-1到0:弹出<=T[i]的栈顶- 答案为栈顶与
i的距离(无则为 0) i入栈。
时间复杂度:O(n)
空间复杂度:O(n)。
import java.util.ArrayDeque
fun dailyTemperatures(temperatures: IntArray): IntArray {
val n = temperatures.size
val ans = IntArray(n)
val st = ArrayDeque<Int>()
for (i in n - 1 downTo 0) {
while (st.isNotEmpty() && temperatures[st.last()] <= temperatures[i]) {
st.removeLast()
}
ans[i] = if (st.isEmpty()) 0 else st.last() - i
st.addLast(i)
}
return ans
}
2.3 接雨水
易记方式:短板先算水|触发词:leftMax/rightMax|警报:更新最大值与结算顺序别反。
思想:每个位置存水量 = min(左侧最高, 右侧最高) - 当前高度。双指针从两端向中间,维护左右最大高度,先处理较短一侧(短板效应)。
步骤:
l=0,r=n-1,维护leftMax、rightMax- 每次较短边更新对应侧贡献或更新最大高度。
时间复杂度:O(n)
空间复杂度:O(1)。
fun trap(height: IntArray): Int {
var l = 0
var r = height.size - 1
var leftMax = 0
var rightMax = 0
var water = 0
while (l < r) {
if (height[l] < height[r]) {
if (height[l] >= leftMax) leftMax = height[l] else water += leftMax - height[l]
l++
} else {
if (height[r] >= rightMax) rightMax = height[r] else water += rightMax - height[r]
r--
}
}
return water
}
2.4 用栈实现队列
易记方式:in 进 out 出|触发词:双栈模拟队列|警报:仅在 out 为空时倒栈。
思想:两个栈:inSt 负责入队,outSt 负责出队;出队时若 outSt 空,把 inSt 全部倒入 outSt(顺序反转后满足 FIFO)。
步骤:
push进inStpop/peek前调用pour()- 从
outSt弹出。
时间复杂度:均摊 O(1)
空间复杂度:O(n)。
import java.util.ArrayDeque
class MyQueue {
private val inSt = ArrayDeque<Int>()
private val outSt = ArrayDeque<Int>()
private fun pour() {
if (outSt.isEmpty()) while (inSt.isNotEmpty()) outSt.addLast(inSt.removeLast())
}
fun push(x: Int) = inSt.addLast(x)
fun pop(): Int {
pour()
return outSt.removeLast()
}
fun peek(): Int {
pour()
return outSt.last()
}
fun empty(): Boolean = inSt.isEmpty() && outSt.isEmpty()
}
2.5 用队列实现栈
易记方式:新元素旋到首|触发词:单队列模拟栈|警报:push 后旋转 size-1 次。
思想:一个队列模拟栈:栈顶应在队首;每次 push 新元素后,把前面所有元素依次从队尾重新入队,使新元素到队首。
步骤:addLast(x) 后执行 size-1 次「队首出队并 addLast」。
时间复杂度:push 为 O(n),pop/peek 为 O(1)
空间复杂度:O(n)。
import java.util.ArrayDeque
class MyStack {
private val q = ArrayDeque<Int>()
fun push(x: Int) {
q.addLast(x)
repeat(q.size - 1) { q.addLast(q.removeFirst()) }
}
fun pop(): Int = q.removeFirst()
fun top(): Int = q.first()
fun empty(): Boolean = q.isEmpty()
}
3 二叉树类
3.1 二叉树的最大深度
易记方式:高度递归式|触发词:1+max(左,右)|警报:空节点返回 0。
思想:树高 = 1 + max(左子树高, 右子树高);空结点高度为 0。
步骤:
- 若
root==null返回 0 - 否则返回
1 + max(maxDepth(left), maxDepth(right))。
时间复杂度:O(n)
空间复杂度:O(h),h 为树高(递归栈)。
fun maxDepth(root: TreeNode?): Int {
if (root == null) return 0
return 1 + maxOf(maxDepth(root.left), maxDepth(root.right))
}
3.2 二叉树前序遍历(迭代)
易记方式:前序入栈即访|触发词:左链入栈|警报:访问时机在入栈前后别混。
思想:前序顺序为「根–左–右」。迭代:沿左链一路访问并入栈,弹栈后处理右子树。
步骤:
cur=rootwhile cur!=null 或栈非空:沿左走并访问- 弹栈取结点并转向其右孩子。
时间复杂度:O(n)
空间复杂度:O(h)。
import java.util.ArrayDeque
fun preorderTraversal(root: TreeNode?): List<Int> {
val ans = mutableListOf<Int>()
val st = ArrayDeque<TreeNode>()
var cur = root
while (cur != null || st.isNotEmpty()) {
while (cur != null) {
ans.add(cur.`val`)
st.addLast(cur)
cur = cur.left
}
cur = st.removeLast().right
}
return ans
}
3.3 二叉树中序遍历(迭代)
易记方式:中序出栈再访|触发词:左压右转|警报:访问在弹栈后。
思想:中序为「左–根–右」。对 BST 而言,中序遍历得到升序序列。
步骤:
- 左链入栈直到空
- 弹栈访问结点
- 转右子树,重复。
时间复杂度:O(n)
空间复杂度:O(h)。
import java.util.ArrayDeque
fun inorderTraversal(root: TreeNode?): List<Int> {
val ans = mutableListOf<Int>()
val st = ArrayDeque<TreeNode>()
var cur = root
while (cur != null || st.isNotEmpty()) {
while (cur != null) {
st.addLast(cur)
cur = cur.left
}
cur = st.removeLast()
ans.add(cur.`val`)
cur = cur.right
}
return ans
}
3.4 二叉树层序遍历
易记方式:层序看 size|触发词:队列分层|警报:每层循环次数固定为当层 size。
思想:BFS:队列中依次存放当前层的结点;每层处理前记录队列长度,即该层结点个数。
步骤:
- 根入队
while队列非空:弹出当前层size个结点,子结点入队。
时间复杂度:O(n)
空间复杂度:O(n)(队列最多约 n/2 个结点)。
import java.util.ArrayDeque
fun levelOrder(root: TreeNode?): List<List<Int>> {
val res = mutableListOf<List<Int>>()
if (root == null) return res
val q = ArrayDeque<TreeNode>()
q.add(root)
while (q.isNotEmpty()) {
val level = mutableListOf<Int>()
repeat(q.size) {
val n = q.removeFirst()
level.add(n.`val`)
n.left?.let { q.addLast(it) }
n.right?.let { q.addLast(it) }
}
res.add(level)
}
return res
}
3.5 对称二叉树
易记方式:镜像交叉比|触发词:a.left 对 b.right|警报:一空一非空立即 false。
思想:对称 = 左子树与右子树镜像:左左与右右比、左右与右左比。
步骤:
- 递归
mirror(a,b):皆空则真 - 一空则假
- 值相等且
mirror(a.left,b.right)且mirror(a.right,b.left)。
时间复杂度:O(n)
空间复杂度:O(h)。
fun isSymmetric(root: TreeNode?): Boolean {
fun mirror(a: TreeNode?, b: TreeNode?): Boolean {
if (a == null && b == null) return true
if (a == null || b == null) return false
return a.`val` == b.`val` && mirror(a.left, b.right) && mirror(a.right, b.left)
}
return root == null || mirror(root.left, root.right)
}
3.6 路径总和
易记方式:目标一路减|触发词:递归传剩余和|警报:必须在叶子节点判等。
思想:从根到叶的路径和需等于 targetSum;递归时减去当前结点值,到叶子判断是否为 0。
步骤:
- 空结点返回 false
- 叶子结点判断
val==targetSum - 否则递归左右子树,目标和减去当前值。
时间复杂度:O(n)
空间复杂度:O(h)。
fun hasPathSum(root: TreeNode?, targetSum: Int): Boolean {
if (root == null) return false
if (root.left == null && root.right == null) return root.`val` == targetSum
return hasPathSum(root.left, targetSum - root.`val`) ||
hasPathSum(root.right, targetSum - root.`val`)
}
3.7 二叉树的最近公共祖先
易记方式:LCA 后序汇合|触发词:左右子树结果|警报:当前命中 p/q 直接返回。
思想:LCA:后序遍历。若 p、q 分别在左右子树,则当前根为 LCA;若只在一边,则向上返回那一边找到的结点。本题实现适用于一般二叉树(非 BST 专用写法)。
步骤:
- 若当前为
null或等于p/q则返回当前 - 左、右递归
- 若左右均非空则返回根,否则返回非空一侧。
时间复杂度:O(n)
空间复杂度:O(h)。
说明:若题目限定为 BST 的 LCA,可利用大小关系从根向下走,时间可写为 O(h) 且实现更短。
fun lowestCommonAncestor(root: TreeNode?, p: TreeNode, q: TreeNode): TreeNode? {
if (root == null || root === p || root === q) return root
val left = lowestCommonAncestor(root.left, p, q)
val right = lowestCommonAncestor(root.right, p, q)
return when {
left != null && right != null -> root
else -> left ?: right
}
}
3.8 验证二叉搜索树
易记方式:中序严格增|触发词:prev 前驱值|警报:必须是严格大于。
思想:BST 的中序遍历为严格递增序列;用 prev 保存中序前一个值,当前值须大于 prev。
步骤:
- 中序 DFS:先左子树
- 再比较
prev与当前值 - 更新
prev - 再右子树。
时间复杂度:O(n)
空间复杂度:O(h)。
fun isValidBST(root: TreeNode?): Boolean {
var prev: Int? = null
fun dfs(n: TreeNode?): Boolean {
if (n == null) return true
if (!dfs(n.left)) return false
if (prev != null && n.`val` <= prev!!) return false
prev = n.`val`
return dfs(n.right)
}
return dfs(root)
}
3.9 二叉树中的最大路径和
易记方式:全局看拐点|触发词:best 与 maxGain|警报:向上只返单边贡献。
思想:路径可起止于任意结点,但沿父子边。递归函数返回「从该结点向下延伸的最大链和(可为负则取 0 不接)」;全局维护「经过当前结点」的折线路径 = 左链 + 右链 + 结点值。
步骤:
maxGain(n):空返回 0;左、右子贡献为max(0, maxGain(子))。- 用
n.val + left + right更新全局答案。 - 向上返回
n.val + max(left, right)。
时间复杂度:O(n)
空间复杂度:O(h)。
fun maxPathSum(root: TreeNode?): Int {
var best = Int.MIN_VALUE
fun maxGain(n: TreeNode?): Int {
if (n == null) return 0
val left = maxOf(0, maxGain(n.left))
val right = maxOf(0, maxGain(n.right))
best = maxOf(best, n.`val` + left + right)
return n.`val` + maxOf(left, right)
}
maxGain(root)
return best
}
3.10 二叉树的右视图
易记方式:每层取最后|触发词:层序 idx==sz-1|警报:先记录层大小再遍历。
思想:二叉树右视图:从右侧看每层只露出最右结点。层次遍历(BFS)每一层从左到右出队时,该层最后一个出队的结点值即为右视图在该层的一个值。
步骤:
- 根入队;当队列非空时,记本层大小
sz。 - 出队
sz次:第sz-1个结点的值加入结果;子结点入队。 - 重复直到队列为空。
时间复杂度:O(n)
空间复杂度:O(n)。
import java.util.ArrayDeque
fun rightSideView(root: TreeNode?): List<Int> {
val res = mutableListOf<Int>()
if (root == null) return res
val q = ArrayDeque<TreeNode>()
q.add(root)
while (q.isNotEmpty()) {
val sz = q.size
repeat(sz) { idx ->
val n = q.removeFirst()
if (idx == sz - 1) res.add(n.`val`)
n.left?.let { q.addLast(it) }
n.right?.let { q.addLast(it) }
}
}
return res
}
3.11 二叉树展开为链表(前序)
易记方式:左最右接原右|触发词:pred.right=cur.right|警报:记得清空 cur.left。
思想:将树按前序拉成单链表(顺序:根–左–右),原地用指针完成(类似 Morris:有左子树时把左子树最右结点接到原右子树前)。
步骤:
- 当前结点有左子树:找左子树最右结点
pred。 pred.right = 原右子树;当前.right = 左子树;清空左子树。- 当前沿
right走到下一个结点,直到空。
时间复杂度:O(n)
空间复杂度:O(1) 额外空间。
fun flattenBinaryTreeToLinkedList(root: TreeNode?) {
var cur = root
while (cur != null) {
if (cur.left != null) {
var pred = cur.left
while (pred!!.right != null) pred = pred.right
pred.right = cur.right
cur.right = cur.left
cur.left = null
}
cur = cur.right
}
}
4 图论与并查集
4.1 岛屿数量
易记方式:见1就淹没|触发词:DFS 染色|警报:会原地修改 grid。
思想:遇到陆地 '1' 就从此格 DFS 四向扩展,把连通陆地标成水,避免重复计数;连通块个数即答案。
步骤:
- 二重循环枚举格
- 若为
'1',count++并dfs将连通'1'置'0'。
时间复杂度:O(mn)
空间复杂度:O(mn) 为递归栈最坏情况。
说明:本实现会原地修改输入
grid(把访问过的'1'置为'0')。若不允许改输入,可改为visited数组。
fun numIslands(grid: Array<CharArray>): Int {
if (grid.isEmpty()) return 0
var count = 0
val m = grid.size
val n = grid[0].size
fun dfs(i: Int, j: Int) {
if (i !in 0 until m || j !in 0 until n || grid[i][j] != '1') return
grid[i][j] = '0'
dfs(i + 1, j)
dfs(i - 1, j)
dfs(i, j + 1)
dfs(i, j - 1)
}
for (i in 0 until m) for (j in 0 until n) {
if (grid[i][j] == '1') {
count++
dfs(i, j)
}
}
return count
}
4.2 课程表(拓扑排序)
易记方式:入度0开修|触发词:拓扑队列|警报:最终检查 taken==numCourses。
思想:课程 a 依赖 b 表示边 b→a。若图为 DAG,可拓扑排序修完所有课;用入度:入度为 0 的课可先修,修完后删边,新入度为 0 的继续入队。
步骤:
- 建邻接表与入度数组
- 入度 0 入队
taken计数- BFS 减少后继入度
- 若
taken==课程数则无环。
时间复杂度:O(V+E)
空间复杂度:O(V+E)。
import java.util.ArrayDeque
fun canFinish(numCourses: Int, prerequisites: Array<IntArray>): Boolean {
val g = Array(numCourses) { mutableListOf<Int>() }
val indeg = IntArray(numCourses)
for (e in prerequisites) {
g[e[1]].add(e[0])
indeg[e[0]]++
}
val q = ArrayDeque<Int>()
indeg.forEachIndexed { i, d -> if (d == 0) q.add(i) }
var taken = 0
while (q.isNotEmpty()) {
val u = q.removeFirst()
taken++
for (v in g[u]) {
indeg[v]--
if (indeg[v] == 0) q.add(v)
}
}
return taken == numCourses
}
4.3 无权图最短路径(边数,BFS)
易记方式:首到即最短|触发词:无权图 BFS|警报:用 dist=-1 防重复。
思想:边权均为 1 时,BFS 第一次到达终点的层数即最短边数。
步骤:
dist[start]=0,起点入队- 弹出
u时遍历邻居,未访问则dist[v]=dist[u]+1并入队 - 到达
target返回dist。
时间复杂度:O(V+E)
空间复杂度:O(V)。
import java.util.ArrayDeque
fun shortestPathEdges(adj: List<List<Int>>, start: Int, target: Int): Int {
val n = adj.size
val dist = IntArray(n) { -1 }
val q = ArrayDeque<Int>()
dist[start] = 0
q.add(start)
while (q.isNotEmpty()) {
val u = q.removeFirst()
if (u == target) return dist[u]
for (v in adj[u]) {
if (dist[v] == -1) {
dist[v] = dist[u] + 1
q.add(v)
}
}
}
return -1
}
4.4 并查集
易记方式:认祖宗再并|触发词:路径压缩+按秩|警报:先判根是否相同。
思想:用数组 parent 表示森林,Find 查集合代表元(根),Union 合并两集合。
步骤:
find(x):若父不是自己则递归并路径压缩union:两树根不同则按秩把矮树挂高树下。
时间复杂度:find/union 均摊约 O(α(n)),α 为反阿克曼函数,可视为很小常数
空间复杂度:O(n)。
class UnionFind(n: Int) {
private val parent = IntArray(n) { it }
private val rank = IntArray(n)
fun find(x: Int): Int {
if (parent[x] != x) parent[x] = find(parent[x])
return parent[x]
}
fun union(a: Int, b: Int): Boolean {
var ra = find(a)
var rb = find(b)
if (ra == rb) return false
if (rank[ra] < rank[rb]) ra = rb.also { rb = ra }
parent[rb] = ra
if (rank[ra] == rank[rb]) rank[ra]++
return true
}
}
5 双指针与滑动窗口
5.1 盛最多水的容器
易记方式:只动短板|触发词:双指针对撞|警报:移动长板通常无收益。
思想:对撞双指针,短板决定盛水高度;向内移动较短一侧,才有可能增大面积。
步骤:
l=0,r=n-1,best=0。- 当
l<r:面积 =min(height[l],height[r]) * (r-l),更新best。 - 若
height[l] < height[r]则l++,否则r--(移动较短边)。
时间复杂度:O(n)
空间复杂度:O(1)。
fun maxArea(height: IntArray): Int {
var l = 0
var r = height.size - 1
var best = 0
while (l < r) {
val h = minOf(height[l], height[r])
best = maxOf(best, h * (r - l))
if (height[l] < height[r]) l++ else r--
}
return best
}
5.2 无重复字符的最长子串
易记方式:重复就缩左|触发词:窗口频次|警报:数组计数版本默认 ASCII。
思想:滑动窗口内每种字符至多出现一次;右端扩窗,若出现重复则左端收缩直到合法,维护最大窗口长度。
步骤:
left=0,用数组或Map统计窗口内字符频次。right遍历:加入s[right];while当前字符重复则左移并减少计数。- 用
right-left+1更新答案。
时间复杂度:O(n)
空间复杂度:O(字符集大小),本实现用 IntArray(128)(按 ASCII);若要支持完整 Unicode,建议改为 HashMap<Char, Int>。
fun lengthOfLongestSubstring(s: String): Int {
val cnt = IntArray(128)
var left = 0
var best = 0
for (right in s.indices) {
cnt[s[right].code]++
while (cnt[s[right].code] > 1) {
cnt[s[left].code]--
left++
}
best = maxOf(best, right - left + 1)
}
return best
}
5.3 移动零
易记方式:快找慢写|触发词:fast/slow|警报:保持相对顺序。
思想:非零元素前移,保持相对顺序;fast 扫描,slow 指向下一个应写入非 0 的位置。
步骤:
slow=0for fast in indices:若nums[fast]!=0则与nums[slow]交换,slow++。
时间复杂度:O(n)
空间复杂度:O(1)。
fun moveZeroes(nums: IntArray) {
var slow = 0
for (fast in nums.indices) {
if (nums[fast] != 0) {
val t = nums[slow]
nums[slow] = nums[fast]
nums[fast] = t
slow++
}
}
}
5.4 最小覆盖子串
易记方式:先满足后压缩|触发词:valid==requiredKinds|警报:缩窗时别忘回退计数。
思想:need 表示字符串 t 中各字符需求量,window 表示当前窗口内计数;当窗口覆盖 t 的全部字符种类及数量时,尝试收缩左边界以得到最短子串。
步骤:
- 统计
t的字符需求,得到需满足的「种类数」requiredKinds。 right右扩,更新window与valid(已满足需求的种类数)。- 当
valid == requiredKinds时循环:更新最短窗口起点与长度;左移left并更新状态。
时间复杂度:O(|s|+|t|)
空间复杂度:O(字符集)。
说明:本实现同样基于 ASCII 计数数组(
IntArray(128));若字符集更大可改HashMap版本。
fun minWindow(s: String, t: String): String {
if (t.isEmpty()) return ""
val need = IntArray(128)
for (c in t) need[c.code]++
val requiredKinds = need.count { it > 0 }
val window = IntArray(128)
var valid = 0
var left = 0
var start = 0
var len = Int.MAX_VALUE
for (right in s.indices) {
val c = s[right].code
if (need[c] > 0) {
window[c]++
if (window[c] == need[c]) valid++
}
while (valid == requiredKinds) {
if (right - left + 1 < len) {
len = right - left + 1
start = left
}
val lc = s[left].code
if (need[lc] > 0) {
if (window[lc] == need[lc]) valid--
window[lc]--
}
left++
}
}
return if (len == Int.MAX_VALUE) "" else s.substring(start, start + len)
}
5.5 滑动窗口最大值
易记方式:队列三件事|触发词:弹尾加尾踢头|警报:先踢过期再取队首。
思想:长度为 k 的窗口在数组上滑动,求每个窗口的最大值。用**双端队列(Deque)**维护「候选下标」,队列中对应值从队首到队尾单调递减;队首即为当前窗口最大值下标。窗口滑动时,移除队尾不大于新元素的值(它们不可能成为最大值)。
步骤:
- 遍历下标
i:队尾弹出所有nums[队尾] <= nums[i]。 i入队;若队首下标已超出窗口左端则出队。- 从
i >= k-1起,每个窗口取队首对应值为答案。
时间复杂度:O(n),每个下标最多入队、出队一次
空间复杂度:O(k),队列最多存 k 个下标。
import java.util.ArrayDeque
fun maxSlidingWindow(nums: IntArray, k: Int): IntArray {
if (nums.isEmpty()) return intArrayOf()
require(k in 1..nums.size) { "k must be in 1..${nums.size}" }
val dq = ArrayDeque<Int>()
val n = nums.size
val ans = IntArray(n - k + 1)
var ai = 0
for (i in nums.indices) {
while (dq.isNotEmpty() && nums[dq.last()] <= nums[i]) dq.removeLast()
dq.addLast(i)
while (dq.first() <= i - k) dq.removeFirst()
if (i >= k - 1) ans[ai++] = nums[dq.first()]
}
return ans
}
5.6 除自身以外数组的乘积
易记方式:前缀后缀各一遍|触发词:ans*= 后缀积|警报:禁止除法题设。
思想:结果第 i 位 = 左侧所有数乘积 × 右侧所有数乘积。先从左到右扫一遍得「左侧前缀积」,再从右到左乘上「右侧后缀积」,无需除法。
步骤:
ans[i]先存prefix[i](nums[0]到nums[i-1]的积)。- 用变量
suffix从右向左累乘,并ans[i] *= suffix。
时间复杂度:O(n)
空间复杂度:O(1) 除输出数组外。
fun productExceptSelf(nums: IntArray): IntArray {
val n = nums.size
if (n == 0) return intArrayOf()
val ans = IntArray(n)
ans[0] = 1
for (i in 1 until n) ans[i] = ans[i - 1] * nums[i - 1]
var suffix = 1
for (i in n - 1 downTo 0) {
ans[i] *= suffix
suffix *= nums[i]
}
return ans
}
6 矩阵与螺旋遍历
6.1 螺旋矩阵
易记方式:四边走收边界|触发词:top/bottom/left/right|警报:每收一边都要判交叉。
思想:从外向内一圈一圈走:顶行从左到右、右列上到下、底行右到左、左列下到上;每走完一边收缩边界 t,b,l,r,直到交叉。
步骤:
- 初始化上
t=0、下b=m-1、左l=0、右r=n-1。 - 顶行
j in l..r收集;t++;若t>b结束。 - 右列
i in t..b收集;r--;若l>r结束。 - 底行
j从r到l;b--;若t>b结束。 - 左列
i从b到t;l++。
时间复杂度:O(mn)
空间复杂度:O(1) 除结果列表外。
fun spiralOrder(matrix: Array<IntArray>): List<Int> {
val res = mutableListOf<Int>()
if (matrix.isEmpty() || matrix[0].isEmpty()) return res
var t = 0
var b = matrix.lastIndex
var l = 0
var r = matrix[0].lastIndex
while (t <= b && l <= r) {
for (j in l..r) res.add(matrix[t][j])
t++
if (t > b) break
for (i in t..b) res.add(matrix[i][r])
r--
if (l > r) break
for (j in r downTo l) res.add(matrix[b][j])
b--
if (t > b) break
for (i in b downTo t) res.add(matrix[i][l])
l++
}
return res
}
7 动态规划
7.1 爬楼梯
易记方式:斐波那契同款|触发词:f(i-1)+f(i-2)|警报:n<=2 先返回。
思想:到达第 i 级台阶的方法数 = 从 i-1 跨一步 + 从 i-2 跨两步(斐波那契递推)。
步骤:
dp[i]=dp[i-1]+dp[i-2]- 边界
dp[1]=1,dp[2]=2 - 用两变量滚动。
时间复杂度:O(n)
空间复杂度:O(1)。
fun climbStairs(n: Int): Int {
if (n <= 2) return n
var a = 1
var b = 2
repeat(n - 2) {
val c = a + b
a = b
b = c
}
return b
}
7.2 打家劫舍
易记方式:偷或不偷|触发词:max(prev1, prev2+x)|警报:滚动变量更新顺序。
思想:相邻房屋不能同时偷;偷第 i 家则收益 = nums[i] + 前 i-2 家最优;不偷则继承 i-1 家最优。
步骤:
- 滚动变量
prev2、prev1表示前两家最优 cur = max(prev1, prev2+nums[i])。
时间复杂度:O(n)
空间复杂度:O(1)。
fun rob(nums: IntArray): Int {
var prev2 = 0
var prev1 = 0
for (x in nums) {
val cur = maxOf(prev1, prev2 + x)
prev2 = prev1
prev1 = cur
}
return prev1
}
7.3 最长公共子序列
易记方式:等看左上+1|触发词:LCS 二维表|警报:dp 下标是前缀长度。
思想:LCS:若两串末尾字符相同,长度 = 两串各去掉末尾后的 LCS +1;否则取「删 A 尾」与「删 B 尾」两种情况的较大值。
步骤:
- 二维表
dp[i][j]表示text1[0..i)与text2[0..j)的 LCS 长度 - 填表。
时间复杂度:O(mn)
空间复杂度:O(mn)。
fun longestCommonSubsequence(text1: String, text2: String): Int {
val m = text1.length
val n = text2.length
val dp = Array(m + 1) { IntArray(n + 1) }
for (i in 1..m) {
for (j in 1..n) {
dp[i][j] = if (text1[i - 1] == text2[j - 1]) {
dp[i - 1][j - 1] + 1
} else {
maxOf(dp[i - 1][j], dp[i][j - 1])
}
}
}
return dp[m][n]
}
7.4 0-1 背包
易记方式:01背包倒序j|触发词:容量倒序|警报:正序会重复选同物品。
思想:0-1 背包:每件物品最多用一次。dp[j] 表示容量为 j 时能达到的最大价值;内层容量逆序更新,保证每件只用一次。
步骤:对每个物品 i,j 从 capacity 降到 weights[i],尝试 dp[j] = max(dp[j], dp[j-w]+v)。
时间复杂度:O(n·capacity)
空间复杂度:O(capacity)。
fun knapsack01(weights: IntArray, values: IntArray, capacity: Int): Int {
val dp = IntArray(capacity + 1)
for (i in weights.indices) {
for (j in capacity downTo weights[i]) {
dp[j] = maxOf(dp[j], dp[j - weights[i]] + values[i])
}
}
return dp[capacity]
}
7.5 编辑距离
易记方式:删插替三选一|触发词:编辑距离 dp[i][j]|警报:首行首列初始化。
思想:三种操作:删、插、替换;dp[i][j] 表示 word1[0..i) 变成 word2[0..j) 的最少操作数。
步骤:
- 初始化首行首列
- 若
word1[i-1]==word2[j-1]则dp[i][j]=dp[i-1][j-1] - 否则
1+min(删,插,替)。
时间复杂度:O(mn)
空间复杂度:O(mn)。
fun minDistance(word1: String, word2: String): Int {
val m = word1.length
val n = word2.length
val dp = Array(m + 1) { IntArray(n + 1) }
for (i in 0..m) dp[i][0] = i
for (j in 0..n) dp[0][j] = j
for (i in 1..m) {
for (j in 1..n) {
dp[i][j] = if (word1[i - 1] == word2[j - 1]) {
dp[i - 1][j - 1]
} else {
1 + minOf(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
}
}
}
return dp[m][n]
}
7.6 零钱兑换
易记方式:完全背包正序x|触发词:零钱兑换|警报:无解返回 -1。
思想:完全背包(每种硬币无限枚):凑成金额 amount 的最少硬币数。dp[x] = min(dp[x], dp[x-c]+1)。
步骤:
dp[0]=0,其余初值为无穷大- 外层遍历硬币,内层
x从小到大(完全背包顺序)。
时间复杂度:O(amount·硬币种类数)
空间复杂度:O(amount)。
fun coinChange(coins: IntArray, amount: Int): Int {
val dp = IntArray(amount + 1) { amount + 1 }
dp[0] = 0
for (c in coins) {
for (x in c..amount) {
dp[x] = minOf(dp[x], dp[x - c] + 1)
}
}
return if (dp[amount] > amount) -1 else dp[amount]
}
7.7 最长递增子序列
易记方式:tails 二分替换|触发词:首个 >=x|警报:替换不是拼接原序列。
思想:LIS(Longest Increasing Subsequence,最长递增子序列)——维护数组 tails:tails[k] 表示长度为 k+1 的递增子序列的最小末尾值;tails 必递增,对新元素二分插入位置,长度即 tails.size。
步骤:
- 遍历
x:在tails中二分第一个>=x的位置替换 - 若比末尾都大则追加。
时间复杂度:O(n log n)
空间复杂度:O(n)。
fun lengthOfLIS(nums: IntArray): Int {
val tails = mutableListOf<Int>()
for (x in nums) {
var lo = 0
var hi = tails.size
while (lo < hi) {
val mid = (lo + hi) / 2
if (tails[mid] < x) lo = mid + 1 else hi = mid
}
if (lo == tails.size) tails.add(x) else tails[lo] = x
}
return tails.size
}
7.8 乘积最大子数组
易记方式:双状态同维护|触发词:maxP/minP|警报:负数会交换角色。
思想:子数组乘积可能因负数变号,同时维护「以当前结尾的最大积」和「最小积」(负数×负数可能变最大)。
步骤:
- 遍历
nums[i]:用maxP、minP表示以i-1结尾的最大/最小积 - 更新
maxP = max(x, maxP*x, minP*x),对称更新minP。
时间复杂度:O(n)
空间复杂度:O(1)。
fun maxProduct(nums: IntArray): Int {
var maxP = nums[0]
var minP = nums[0]
var ans = nums[0]
for (i in 1 until nums.size) {
val x = nums[i]
val t = maxP
maxP = maxOf(x, maxP * x, minP * x)
minP = minOf(x, t * x, minP * x)
ans = maxOf(ans, maxP)
}
return ans
}
8 贪心
8.1 无重叠区间(最少删除区间数)
易记方式:结束早优先|触发词:按 end 排序|警报:别把“删除数”与“保留数”混。
思想:结束越早越给后面留空位,按 end 排序后贪心选不重叠区间。
步骤:
- 排序
- lastEnd 初值极小
- 若 start>=lastEnd 则选该区间并更新 lastEnd。
时间复杂度:O(n log n)
空间复杂度:O(1) 不计排序额外空间。
fun eraseOverlapIntervals(intervals: Array<Interval>): Int {
if (intervals.isEmpty()) return 0
intervals.sortBy { it.end }
var lastEnd = Int.MIN_VALUE
var keep = 0
for (iv in intervals) {
if (iv.start >= lastEnd) {
keep++
lastEnd = iv.end
}
}
return intervals.size - keep
}
8.2 跳跃游戏
易记方式:维护最远 reach|触发词:贪心覆盖范围|警报:i>reach 立即 false。
思想:维护最远能跳到的位置,遍历下标若超出则失败。
步骤:
- reach=0
- i in 0..n-1,若 i>reach 返回 false
- reach=max(reach,i+nums[i])。
时间复杂度:O(n)
空间复杂度:O(1)。
fun canJump(nums: IntArray): Boolean {
var reach = 0
for (i in nums.indices) {
if (i > reach) return false
reach = maxOf(reach, i + nums[i])
if (reach >= nums.lastIndex) return true
}
return true
}
8.3 跳跃游戏 II
易记方式:按层扩边界|触发词:end/farthest|警报:默认题目可达终点。
思想:同 canJump,在可达前提下求最少步数:层序 BFS 思想,每步扩展当前最远边界。
步骤:
- end 为当前步最远
- next 为下一步最远
- 到达 end 时步数+1 并换边界。
时间复杂度:O(n)
空间复杂度:O(1)。
说明:该写法默认题目保证「一定能到达最后一个位置」(与 LeetCode 45 设定一致)。
fun jump(nums: IntArray): Int {
if (nums.size <= 1) return 0
var steps = 0
var end = 0
var farthest = 0
for (i in 0 until nums.lastIndex) {
farthest = maxOf(farthest, i + nums[i])
if (i == end) {
steps++
end = farthest
}
}
return steps
}
8.4 合并区间
易记方式:先排后并|触发词:与结果尾比较|警报:sortBy 会原地改输入。
思想:区间按 start 排序后,若当前区间与结果中上一段重叠(当前 start ≤ 上一段 end),则合并为一段;否则新开一段。
步骤:
- 按
start升序排序。 - 结果列表先放入第一个区间。
- 从第二个开始:若与最后一项重叠则合并
end,否则追加。
时间复杂度:O(n log n)
空间复杂度:O(n)。
说明:实现中
intervals.sortBy为原地排序,会改变输入数组顺序;若业务不允许改输入,请先拷贝再排序。
fun mergeIntervals(intervals: Array<Interval>): Array<Interval> {
if (intervals.isEmpty()) return intervals
intervals.sortBy { it.start }
val out = mutableListOf(intervals[0])
for (i in 1 until intervals.size) {
val cur = intervals[i]
val last = out.last()
if (cur.start <= last.end) {
out[out.lastIndex] = Interval(last.start, maxOf(last.end, cur.end))
} else {
out.add(cur)
}
}
return out.toTypedArray()
}
9 回溯
9.1 子集
易记方式:选或不选|触发词:子集 DFS 树|警报:收集路径用拷贝。
思想:每个元素选或不选,生成所有子集。
步骤:
- dfs(start):若 start==n 收集路径
- dfs(start+1) 不选
- 选 nums[start] 再 dfs(start+1) 再移除。
时间复杂度:O(n·2^n)
空间复杂度:O(n) 递归栈。
fun subsets(nums: IntArray): List<List<Int>> {
val ans = mutableListOf<List<Int>>()
val path = mutableListOf<Int>()
fun dfs(start: Int) {
if (start == nums.size) {
ans.add(path.toList())
return
}
dfs(start + 1)
path.add(nums[start])
dfs(start + 1)
path.removeAt(path.lastIndex)
}
dfs(0)
return ans
}
9.2 全排列
易记方式:路径+used|触发词:全排列回溯|警报:回溯要成对撤销。
思想:全排列,用布尔数组标记已用元素。
步骤:
- path 长度 n 时入答案
- 否则枚举 i,未用则标记、加入、dfs、撤销。
时间复杂度:O(n!·n)
空间复杂度:O(n)。
fun permute(nums: IntArray): List<List<Int>> {
val ans = mutableListOf<List<Int>>()
val used = BooleanArray(nums.size)
val path = mutableListOf<Int>()
fun dfs() {
if (path.size == nums.size) {
ans.add(path.toList())
return
}
for (i in nums.indices) {
if (used[i]) continue
used[i] = true
path.add(nums[i])
dfs()
path.removeAt(path.lastIndex)
used[i] = false
}
}
dfs()
return ans
}
9.3 组合
易记方式:start防重剪枝|触发词:组合回溯|警报:上界剪枝避免无效分支。
思想:组合无顺序,从 start 开始选,避免 1,2 与 2,1 重复。
步骤:
- path.size==k 收集
- 从 index 到 n-1 尝试加入并 dfs(i+1)。
时间复杂度:O(C(n,k)·k)
空间复杂度:O(k)。
fun combine(n: Int, k: Int): List<List<Int>> {
val ans = mutableListOf<List<Int>>()
val path = mutableListOf<Int>()
fun dfs(start: Int) {
if (path.size == k) {
ans.add(path.toList())
return
}
// 剪枝:若剩余可选数字不足以补满 k,后续分支无需继续。
val need = k - path.size
val end = n - need + 1
for (i in start..end) {
path.add(i)
dfs(i + 1)
path.removeAt(path.lastIndex)
}
}
dfs(1)
return ans
}
9.4 N 皇后
易记方式:行列对角约束|触发词:cols/diag1/diag2|警报:回溯要清理标记。
思想:每行放一个皇后,列与两对角线用集合剪枝。
步骤:
- row 从 0 到 n-1,尝试每列
- 合法则记录并 dfs(row+1)
- 回溯清除标记。
时间复杂度:近似 O(n!)
空间复杂度:O(n)。
fun solveNQueens(n: Int): List<List<String>> {
val ans = mutableListOf<List<String>>()
val cols = mutableSetOf<Int>()
val diag1 = mutableSetOf<Int>()
val diag2 = mutableSetOf<Int>()
val board = Array(n) { CharArray(n) { '.' } }
fun dfs(row: Int) {
if (row == n) {
ans.add(board.map { it.concatToString("") })
return
}
for (col in 0 until n) {
if (col in cols || row - col in diag1 || row + col in diag2) continue
cols.add(col)
diag1.add(row - col)
diag2.add(row + col)
board[row][col] = 'Q'
dfs(row + 1)
board[row][col] = '.'
cols.remove(col)
diag1.remove(row - col)
diag2.remove(row + col)
}
}
dfs(0)
return ans
}
10 位运算与数学
10.1 最大公约数(辗转相除)
易记方式:辗转取余到0|触发词:gcd(a,b)=gcd(b,a%b)|警报:先取绝对值。
思想:GCD(Greatest Common Divisor,最大公约数)——辗转相除:gcd(a,b)=gcd(b, a mod b),直到除数为 0。
步骤:
while b!=0:(a,b)=(b, a%b)- 返回
a。
时间复杂度:O(log min(a,b))
空间复杂度:O(1)。
fun gcd(a: Int, b: Int): Int {
var x = kotlin.math.abs(a)
var y = kotlin.math.abs(b)
while (y != 0) {
val t = x % y
x = y
y = t
}
return x
}
10.2 快速幂取模
易记方式:指数按位拆|触发词:快速幂|警报:每步都要取模防溢出。
思想:快速幂:将指数按二进制展开,exp 最低位为 1 时把当前 base 乘入答案;base 每步平方并对 mod 取余。
步骤:
res=1while exp>0:若exp为奇数res=res*base%modbase=base*base%modexp右移一位。
时间复杂度:O(log exp)
空间复杂度:O(1)。
fun powMod(base: Long, exp: Long, mod: Long): Long {
var b = base % mod
var e = exp
var res = 1L
while (e > 0) {
if (e and 1L == 1L) res = (res * b) % mod
b = (b * b) % mod
e = e shr 1
}
return res
}
10.3 位 1 的个数(汉明重量)
易记方式:每次消最低1|触发词:n&(n-1)|警报:注意无符号统计语义。
思想:n & (n-1) 会消掉 n 的二进制最低位的 1;循环直到 n=0,次数即 1 的个数(汉明重量)。
步骤:while n!=0:n &= n-1,计数加一。
时间复杂度:O(1) 的位数(32 次以内)
空间复杂度:O(1)。
fun hammingWeight(n: Int): Int {
var x = n.toLong() and 0xFFFFFFFFL
var c = 0
while (x != 0L) {
x = x and (x - 1)
c++
}
return c
}
10.4 2 的幂
易记方式:正数且单1位|触发词:n&(n-1)==0|警报:先判 n>0。
思想:2 的幂在二进制中只有一位为 1,且 n>0;即 n & (n-1) == 0。
步骤:判断 n>0 && (n and (n-1))==0。
时间复杂度:O(1)
空间复杂度:O(1)。
fun isPowerOfTwo(n: Int): Boolean = n > 0 && n and (n - 1) == 0
}
11 Trie(前缀树)
11.1 Trie 插入单词
易记方式:边走边建|触发词:Trie insert|警报:本实现仅支持 a-z。
思想:从根沿字符向下走,无则新建子结点;单词末尾标记 isEnd = true。
步骤:
cur=root- 对每个字符
c:索引c-'a',移动或创建 - 最后
isEnd=true。
时间复杂度:O(L),L 为单词长
空间复杂度:O(L) 新增结点。
fun insert(word: String) {
var cur = root
for (c in word) {
val i = idx(c)
if (cur.next[i] == null) cur.next[i] = Node()
cur = cur.next[i]!!
}
cur.isEnd = true
}
11.2 Trie 搜索单词
易记方式:路通且终点旗|触发词:Trie search|警报:非 a-z 直接 false。
思想:完整匹配单词须走到对应结点且 isEnd 为真。
步骤:
- 沿字符走子结点
- 缺失则 false
- 结束看
isEnd。
时间复杂度:O(L)
空间复杂度:O(1) 辅助空间。
fun search(word: String): Boolean {
var cur: Node? = root
for (c in word) {
val i = if (c in 'a'..'z') c - 'a' else return false
cur = cur?.next?.get(i) ?: return false
}
return cur.isEnd
}
11.3 Trie 前缀查询
易记方式:只看路通|触发词:Trie prefix|警报:前缀不要求 isEnd。
思想:只需是某单词前缀,不要求 isEnd。
步骤:沿前缀走,缺失则 false。
时间复杂度:O(L)
空间复杂度:O(1)。
fun startsWith(prefix: String): Boolean {
var cur: Node? = root
for (c in prefix) {
val i = if (c in 'a'..'z') c - 'a' else return false
cur = cur?.next?.get(i) ?: return false
}
return true
}
12 字符串与哈希
12.1 字母异位词分组
易记方式:同字母同签名|触发词:排序串作 key|警报:更快可用 26 计数签名。
思想:异位词排序后字母序相同。用「排序后的字符串」作 HashMap 的键,值为该组所有原串。
步骤:
- 遍历每个字符串,转为字符数组排序后拼接为键
key。 map[key]追加当前串。- 返回
map所有 value 组成的列表。
时间复杂度:O(n · k log k),k 为最长串长
空间复杂度:O(nk)。
可选优化:若字符集固定为 26 个小写字母,可用「26 维计数签名」做键,常见写法时间可降为 O(n·k)。
fun groupAnagrams(strs: Array<String>): List<List<String>> {
val map = mutableMapOf<String, MutableList<String>>()
for (s in strs) {
val key = s.toCharArray().sorted().joinToString("")
map.getOrPut(key) { mutableListOf() }.add(s)
}
return map.values.toList()
}
12.2 最长连续序列
易记方式:只从起点扩展|触发词:x-1 不在集合|警报:避免重复扩展。
思想:最长连续序列长度只与「每个数能否作为序列起点」有关:若 x-1 在集合中,则 x 不是起点。对每个起点 x 不断 x+1 直到不在集合。
步骤:
- 所有数放入 HashSet。
- 对每个
x,若x-1在集合则跳过。 - 否则从
x开始连续向后计数,更新全局最大长度。
时间复杂度:O(n)
空间复杂度:O(n)。
fun longestConsecutive(nums: IntArray): Int {
val set = nums.toHashSet()
var best = 0
for (x in set) {
if (x - 1 in set) continue
var y = x
var len = 0
while (y in set) {
len++
y++
}
best = maxOf(best, len)
}
return best
}
13 堆与 Top K
13.1 数组中的第 K 个最大元素
易记方式:小顶堆保K大|触发词:堆顶最弱候选|警报:k 需在合法范围内。
思想:求第 K 大元素:用小顶堆维护当前「最大的 K 个」里的最小值(堆顶);堆大小超过 K 就弹出堆顶。
步骤:
- 遍历
nums:堆未满则加入 - 否则若当前值大于堆顶则弹出堆顶再入堆。
时间复杂度:O(n log K)
空间复杂度:O(K)。
说明:当前实现对非法参数做了显式校验:
k必须在1..nums.size。
import java.util.PriorityQueue
fun findKthLargest(nums: IntArray, k: Int): Int {
require(k in 1..nums.size) { "k must be in 1..${nums.size}" }
val pq = PriorityQueue<Int>()
for (x in nums) {
if (pq.size < k) {
pq.offer(x)
} else if (x > pq.peek()) {
pq.poll()
pq.offer(x)
}
}
return pq.peek()
}
13.2 前 K 个高频元素
易记方式:频次表+小顶堆|触发词:按频率比较|警报:结果顺序通常不保证。
思想:统计每个数出现次数,按频率建大小为 K 的小顶堆(堆顶为 K 个里频率最低的);超过 K 就弹出。
步骤:
groupingBy得频次表- 遍历 distinct 数值入堆
- 最后堆中即为前 K 个高频(次序任意)。
时间复杂度:O(n log K)
空间复杂度:O(U),U 为不同元素个数。
import java.util.PriorityQueue
fun topKFrequent(nums: IntArray, k: Int): IntArray {
val freq = nums.groupingBy { it }.eachCount()
val pq = PriorityQueue<Int>(compareBy { freq[it]!! })
for (num in freq.keys) {
pq.offer(num)
if (pq.size > k) pq.poll()
}
return IntArray(pq.size) { pq.poll() }
}
13.3 合并 K 个升序链表
易记方式:K路归并|触发词:最小堆弹头补 next|警报:先过滤空链表头。
思想:K 路归并:每次从 K 个链表头里取最小值,用小顶堆按结点值排序;弹出最小后将其 next 入堆。
步骤:
- 非空头指针入堆
while堆非空:弹最小接在结果尾,若该结点有后继则后继入堆。
时间复杂度:O(N log K),N 为总结点数
空间复杂度:O(K)。
import java.util.PriorityQueue
fun mergeKLists(lists: Array<LinkedListAlgorithms.ListNode?>): LinkedListAlgorithms.ListNode? {
val dummy = LinkedListAlgorithms.ListNode(0)
var tail: LinkedListAlgorithms.ListNode = dummy
val pq = PriorityQueue<LinkedListAlgorithms.ListNode>(compareBy { it.`val` })
for (h in lists) {
if (h != null) pq.offer(h)
}
while (pq.isNotEmpty()) {
val n = pq.poll()!!
tail.next = n
tail = n
n.next?.let { pq.offer(it) }
}
tail.next = null
return dummy.next
}
14 LRU 缓存
LRU(Least Recently Used,最近最少使用) 是一种缓存淘汰策略(eviction policy):容量有上限时,若还要写入新数据且空间已满,必须淘汰一条旧数据;LRU 淘汰的是「距离最近一次被访问(读或写)最久」的那条。
直观理解:为每个键维护「最后使用时间」;时间最旧者优先被踢出。本实现用双向链表表示从「最近使用」到「最久未使用」的顺序:靠近哨兵头结点 head 一侧为最近使用(MRU,Most Recently Used),靠近哨兵尾 tail 一侧为最久未使用;tail.prev 即为下次要淘汰的 LRU 结点。
实现上:HashMap<Int, Node> 按键 O(1) 找到链表结点;双向链表支持 O(1) 删除与插入(把结点移到头部表示「刚用过」)。get 与 put 命中或更新后都要把结点移到头部;put 新增导致超出容量时,删除 tail 前驱结点并从 map 中移除对应键。面试中也可答 LinkedHashMap 按访问顺序 + removeEldestEntry 的思路,本质相同。
14.1 LRU get
易记方式:读命中就提头|触发词:LRU get|警报:未命中返回 -1。
思想:命中则把结点移到头侧(标记为最近使用 MRU);未命中返回 -1。
步骤:
map取结点removeNode后addToHead- 返回
val。
时间复杂度:O(1)
空间复杂度:O(capacity)。
fun get(key: Int): Int {
val n = map[key] ?: return -1
removeNode(n)
addToHead(n)
return n.`val`
}
14.2 LRU put
易记方式:写命中提头,超容删尾|触发词:tail.prev|警报:删链后同步删 map。
思想:键已存在则更新值并移到头侧;否则新建结点插头。若 map.size 超过 capacity,删除 tail.prev(LRU) 并 map.remove。
步骤:
- 若
key在map中则更新并调整位置 - 否则新建
- 超容量删 LRU 结点。
时间复杂度:O(1)
空间复杂度:O(capacity)。
fun put(key: Int, value: Int) {
map[key]?.let { n ->
n.`val` = value
removeNode(n)
addToHead(n)
return
}
val node = Node(key, value)
map[key] = node
addToHead(node)
if (map.size > capacity) {
val lru = tail.prev!!
removeNode(lru)
map.remove(lru.key)
}
}
复习建议(面试前)
- 每天抽 8~10 题,只做「30 秒口述 + 2 分钟骨架默写」。
- 重点记录错因:边界漏判、指针顺序错误、复杂度说错。
- 优先复盘错题,不重复刷已经稳定掌握的题。
说明
- 本文用于算法学习与面试训练,题解与代码均为整理后的教学表达。
- 若你要“单文件直接运行”,建议额外补齐
ListNode、TreeNode、LRU完整类定义与测试入口。