1. 题目拆解与本质认知
1.1 题目到底在问什么
LeetCode 108这题,我愿称之为“二叉树入门三件套”之一。很多人在准备面试时绕不过它,因为题目本身不长,意思也直白:给你一个按升序排列的整数数组,让你把它转换成一棵高度平衡的二叉搜索树。
什么叫高度平衡?题目里有明确定义:一棵二叉树,每个节点的左右两个子树的高度差的绝对值不超过1。换言之,你构造出来的树不能歪得太离谱。
这题在面试中被问到的频率相当高。我见过不少候选人在白板上写递归写得飞起,但被追问“为什么取中间元素作为根节点”时一下子卡住。所以这篇文章不打算只给你贴代码,而是把思路、代码、边界条件、复杂度、面试变体、易错点一整套都拆开,让你看完之后不仅会写,还能讲得出来。
适合谁来读?如果你正在刷LeetCode准备算法面试,这题是必刷基本盘;如果你已经工作几年,想回头巩固一下二叉树基本功,这题几十行代码就能帮你把“递归分治”和“迭代构建”两种思维重新激活。反正我是建议你把两种实现都写一遍,只背递归版本的话,换一个场景很容易懵。
1.2 为什么“升序数组”这个前提如此关键
二叉搜索树有一个重要性质,用一句话说就是:中序遍历一棵BST,得到的结果一定是递增序列。反过来,如果给你一个递增序列,你其实是在做“逆向工程”——根据中序遍历结果反推出一棵BST。
但这里有个陷阱——中序遍历结果相同的BST并不唯一。举个例子,数组[1, 2, 3]既可以构建成根为2、左右孩子分别为1和3的树,也可以构建成根为1、右孩子为2、2的右孩子为3的树。后者虽然也是BST,但高度是3,不平衡。
题目额外加了一个“高度平衡”的约束,这才让问题变严谨。也就是说,你需要从所有可能的BST中挑出一棵尽量“匀称”的。而“尽量匀称”最直观的做法,就是把数组正中间那个元素拎出来当根,左右两边各自递归。
这里要说清楚一件事:高度平衡BST不是唯一的。偶数长度的数组取中间偏左还是偏右,会产生不同的结果,两种都满足题目要求。这一点很多面试官喜欢拿来延伸,后面我会细讲。
1.3 “高度平衡”到底怎么理解
很多人一听到“高度平衡”就联想到AVL树、红黑树,其实完全不用紧张。LeetCode 108里的高度平衡只是说每个节点的左右子树高度差不超过1,属于一个比较宽松的平衡定义,不需要你搞旋转操作,也不涉及插入删除。
你可以用生活类比来理解:假设你手里有一排从矮到高排列的积木,想搭一个“金字塔”形状的结构。最佳策略肯定是把最高的那块放中间当塔尖,这样左右两边各自堆叠的高度最接近。如果你把最高那块放最边上,整个结构就会严重倾斜。取中点当根,本质上就是让左右两边的数据“数量尽量相等”,而数据数量相等的情况下,左右子树的高度也会尽量接近。
用这个视角去看代码,你就能理解为什么递归函数里要反复left + (right - left) / 2求中点,而不是随便取一个位置。
2. 递归分治:主流解法从思路到代码
2.1 分治思路的设计与建模
递归分治的第一步是定义清楚子问题。对于数组的某个区间[left, right],我们要做三件事:
- 计算中点
mid = left + (right - left) / 2; - 创建根节点
TreeNode(nums[mid]); - 让左子树等于对区间
[left, mid - 1]递归的结果,右子树等于对区间[mid + 1, right]递归的结果。
递归的终止条件就是区间为空:left > right时返回nullptr。
这个模型的精妙之处在于,每一层递归只负责“当前区间”的事情,左右区间是否还有元素、有多少个元素,完全由区间的边界控制。它不关心整个数组有多大,也不用提前计算树的结构,层层递归自然就把树搭出来了。
我在面试候选人的时候发现一个常见问题:很多人会先画图,然后试图在脑子里把整棵树的结构推演出来再写代码。对于这道题完全没必要。你只需要相信递归定义,也就是“对左区间递归返回的结果,恰好就是左子树的根”。这就是分治的自我相似性——大问题拆成两个性质相同的小问题,小问题的解决方案和大问题完全一样。
2.2 递归版本的具体实现
我们先看C++版本,这是面试中最常要求手写的语言之一:
/** * Definition for a binary tree node. * struct TreeNode { * int val; * TreeNode *left; * TreeNode *right; * TreeNode() : val(0), left(nullptr), right(nullptr) {} * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} * }; */ class Solution { public: TreeNode* sortedArrayToBST(vector<int>& nums) { return build(nums, 0, nums.size() - 1); } private: TreeNode* build(vector<int>& nums, int left, int right) { if (left > right) { return nullptr; } int mid = left + (right - left) / 2; TreeNode* root = new TreeNode(nums[mid]); root->left = build(nums, left, mid - 1); root->right = build(nums, mid + 1, right); return root; } };再看Python版本:
class Solution: def sortedArrayToBST(self, nums: List[int]) -> Optional[TreeNode]: def build(left: int, right: int) -> Optional[TreeNode]: if left > right: return None mid = (left + right) // 2 root = TreeNode(nums[mid]) root.left = build(left, mid - 1) root.right = build(mid + 1, right) return root return build(0, len(nums) - 1)代码量确实不多,核心逻辑加起来不到十行。但请千万不要被“短小”欺骗,越短的题目越是细节暗藏。接下来我把几个关键点展开讲,这些都是面试官容易追问的地方。
2.3 中间节点选择与边界处理的细致说明
2.3.1 为什么用left + (right - left) / 2而不是(left + right) / 2
这是一个非常经典的细节。在C++中,left + right有可能超过int的最大表示范围,导致整数溢出。虽然LeetCode的测试数据很少触发这个问题,但面试中主动写出left + (right - left) / 2会显得你很有经验。
这个写法的数学本质是:先求区间长度的一半,再从起点偏移过去。无论区间多大,right - left始终不会超过数组长度,因此是安全的。
Python则不需要担心这个问题,因为Python的整数是不限长度的,所以(left + right) // 2直接写没问题。但在C++和Java中,建议统一使用安全写法。
2.3.2 偶数数组取中点的问题
当区间长度为偶数时,中点有两个候选。mid = (left + right) / 2取到的是偏左那个,也就是数学上的向下取整。比如区间[0, 3],中点是1,不是2。而有些实现会写成mid = (left + right + 1) / 2,取偏右那个,这两种方式构造出来的树都满足高度平衡要求,LeetCode都会判对。
为什么偏左偏右都不影响平衡?因为左右区间长度要么相等,要么差1,那么构造出来的左右子树高度差也最多为1。这一点你可以自己在纸上画一画长度为2、3、4的数组,分别用两种取中方式构建,观察树高差。
2.3.3 终止条件必须是left > right吗
这里有个细节值得注意。有些同学写递归边界时习惯用left == right来判断只剩一个元素,然后手动创建叶子节点。这样做会让代码多一层分支,而且容易漏处理空区间的情况。建议直接统一用left > right作为终止条件,让递归在区间为空时自然返回nullptr。
我见过一种写法,在递归开头判断区间只剩一个节点,然后return new TreeNode(nums[left])作为终止条件,把left > right放在后面。这样写也不是不行,但对于这道题来说会让代码分支更复杂,不利于后续延伸到“有序链表转BST”等变体。
3. 迭代实现:两种值得掌握的替代方案
3.1 用栈模拟递归:核心思想
递归的本质是函数调用栈,那我们自然可以用显式的栈来模拟。在LeetCode上做这题时,很多人觉得递归就够了,但如果你在面试中主动提一句“我还能用迭代实现”,通常会是一个加分项。
栈方案的核心是:把“当前要处理的区间”以及“这个区间对应的父节点”一起入栈。弹出时,创建当前区间的根节点,然后按照递归版本相反的顺序,把左右区间和刚创建的节点关系压回栈中。
需要注意一点:由于栈是后进先出,入栈顺序与递归调用顺序相反。递归是先建根,再递归左子树,再递归右子树;栈模拟如果想要保持同样的处理顺序,就需要先把“右子任务”压栈,再把“左子任务”压栈,这样左子任务会先被弹出处理。
实现的思路可以这样设计:用一个结构体(或pair)保存三个信息:当前构造的树节点指针、区间的左端点、区间的右端点。一开始根节点还没创建,我们可以先压入一个“待创建”的节点和完整区间[0, n-1]。
class Solution { public: TreeNode* sortedArrayToBST(vector<int>& nums) { if (nums.empty()) return nullptr; // 用栈保存三元组:节点指针(可能为空)、区间左边界、区间右边界 stack<tuple<TreeNode*, int, int>> st; TreeNode* root = new TreeNode(0); // 占位节点,后续更新值 st.push({root, 0, (int)nums.size() - 1}); while (!st.empty()) { auto [node, left, right] = st.top(); st.pop(); if (left > right) continue; int mid = left + (right - left) / 2; node->val = nums[mid]; // 先处理左子树,所以右子树先入栈 if (mid + 1 <= right) { node->right = new TreeNode(0); st.push({node->right, mid + 1, right}); } if (left <= mid - 1) { node->left = new TreeNode(0); st.push({node->left, left, mid - 1}); } } return root; } };这个版本的好玩之处在于:我们先用一个占位节点创建了根,之后在循环中每一次弹出栈顶元素,就知道当前这个位置应该填哪个数组值,并且知道它的左右区间边界。整个过程中,树的结构是“自上而下”被填充的,和递归版本的“从根到叶子”构建顺序一致。
3.2 用队列做层次构建:另一种视角
队列做法的思路更贴近BFS。我们把“当前区间”和“需要挂载这个区间对应子树的父节点”以及“该挂载在左侧还是右侧”的状态保存下来。每次从队列头部取出一个任务,创建对应区间的根节点,把它挂到父节点的指定位置,然后把这个节点自身以及它的左右区间作为新的任务继续入队。
这种方式的代码会比栈模拟稍长,但它的好处是结构更接近“逐层构建”,如果你对BFS更熟悉,反而会觉得更好理解。
3.3 迭代方案的代码实现与走查
我用C++写一个队列版本,大家可以对比和栈版本的区别:
class Solution { public: TreeNode* sortedArrayToBST(vector<int>& nums) { if (nums.empty()) return nullptr; queue<tuple<TreeNode*, int, int, bool>> q; TreeNode* root = new TreeNode(0); q.push({root, 0, (int)nums.size() - 1, false}); // bool表示是否是右孩子,false表示根节点本身 while (!q.empty()) { auto [parent, left, right, isRight] = q.front(); q.pop(); if (left > right) continue; int mid = left + (right - left) / 2; TreeNode* node = new TreeNode(nums[mid]); if (isRight) parent->right = node; else if (parent != nullptr && left == 0 && right == (int)nums.size() - 1) { // 处理根节点本身,实际上可以直接判断 left 为0且right为末尾 root = node; } else { parent->left = node; } q.push({node, left, mid - 1, false}); q.push({node, mid + 1, right, true}); } return root; } };这个版本在根节点的挂载上有一点绕。实际写代码时,我一般会把根节点单独创建,然后队列里只放“要挂载的子任务”:
class Solution { public: TreeNode* sortedArrayToBST(vector<int>& nums) { if (nums.empty()) return nullptr; TreeNode* root = new TreeNode(0); struct Task { TreeNode* parent; bool isLeft; int left; int right; }; queue<Task> q; q.push({root, true, 0, (int)nums.size() - 1}); while (!q.empty()) { Task task = q.front(); q.pop(); if (task.left > task.right) continue; int mid = task.left + (task.right - task.left) / 2; TreeNode* node = new TreeNode(nums[mid]); if (task.isLeft) task.parent->left = node; else task.parent->right = node; q.push({node, true, task.left, mid - 1}); q.push({node, false, mid + 1, task.right}); } return root; } };走查一下这个队列版本,以数组[-10, -3, 0, 5, 9]为例:
初始时,根节点占位,任务为{根, true, 0, 4}。弹出后取mid=2,创建值为0的节点,因为是isLeft=true,挂到根节点的左孩子位置——但此时根节点就是我们要返回的根,所以实际上第一次挂载挂到了占位根的左侧,这个行为我们需要特殊处理。
等等,这里其实有个问题:占位根节点并不是最终树的根。这个写法我并不推荐直接用在面试中,因为处理根节点挂载关系容易把自己绕晕。更好的做法是:单独用一个root变量指向真正的根节点,第一次创建节点时把root赋值给它,之后所有节点都挂到父节点的对应位置。
所以在面试里,我更推荐栈模拟递归的方案,或者干脆老老实实写递归。队列方案可以当作思维拓展,知道有这个东西就行。下面给出一个更稳妥的队列版本,先创建根节点,然后把左右区间任务入队:
class Solution { public: TreeNode* sortedArrayToBST(vector<int>& nums) { if (nums.empty()) return nullptr; int mid = (nums.size() - 1) / 2; TreeNode* root = new TreeNode(nums[mid]); queue<tuple<TreeNode*, int, int, bool>> q; q.push({root, 0, mid - 1, true}); q.push({root, mid + 1, (int)nums.size() - 1, false}); while (!q.empty()) { auto [parent, left, right, isLeft] = q.front(); q.pop(); if (left > right) continue; int m = left + (right - left) / 2; TreeNode* node = new TreeNode(nums[m]); if (isLeft) parent->left = node; else parent->right = node; q.push({node, left, m - 1, true}); q.push({node, m + 1, right, false}); } return root; } };这个版本逻辑清晰很多:先手工处理根,再统一处理其余节点。它的构建顺序是层次化的——同一层的任务出队的时候,它们的父节点必然已经创建完毕,所以挂载关系不会出错。
4. 复杂度分析与面试扩展点
4.1 时间与空间复杂度,递归栈与迭代空间的不同
先说时间复杂度。无论是递归还是迭代,每个数组元素都会被选中一次、创建对应的节点,所以总操作次数是O(n)。每一次创建节点所需的工作量是常数级,因此整体时间复杂度是O(n)。
空间复杂度需要分情况讨论:
- 递归版本:每一层递归需要O(1)的栈帧空间,递归深度等于树高。由于我们每次取中点,构建出的是平衡树,高度大约是O(log n),因此递归栈空间是O(log n)。注意,这里没有计算存放树本身节点的空间,因为那部分所有方案都一样,是O(n)。
- 栈模拟迭代版本:最坏情况下栈中同时保存O(n)个三元组,但分析可以更精确一些——栈中保存的是“待处理区间”,这些区间相互不重叠,总数不超过原数组所有可能区间的数量级,通常认为是O(n)。所以迭代版本的空间是O(n)。
- 队列迭代版本:同理,队列中最多同时存在O(n)个任务,空间也是O(n)。
这里有一个有意思的面试点:递归真的比迭代更省空间吗?如果以这道题为例,是的,递归O(log n)优于迭代O(n)。但反过来,递归有函数调用的开销,而且当树退化成链表时递归深度会变成O(n),可能导致栈溢出。在平衡的前提下,递归方案的空间优势是很明显的。
4.2 面试常问的变体:链表转平衡BST、数组区间查询
面试官通常不会止步于这道题本身,常见的变体有:
变体一:把有序数组换成有序链表,如何构造平衡BST?
数组支持O(1)随机访问,所以取中点非常方便。链表没有这个特性,如果每次找中点都从头遍历,整体复杂度会退化为O(n log n)。常见的优化思路有两种:一是把链表转成数组,然后复用本题解法,空间O(n),时间O(n);二是利用中序遍历顺序构建——因为BST的中序遍历就是有序序列,可以在中序遍历过程中“数着节点”来构建,时间O(n),空间O(log n)(递归栈)。第二种方案比较巧妙,也是面试中的高级考点。
变体二:如果数组中有重复元素怎么办?
BST的定义通常不允许重复,但如果你把“左小右大”改成“左小右大等于”,或者“左小等于右大”,其实也能构造出一棵满足条件广义BST的平衡树。取中点时重复元素可能在左也可能在右,不影响平衡性,但要注意这棵树的中序遍历结果仍然是原数组的有序排列(含重复)。你可以和面试官确认一下重复元素的处理规则。
变体三:如果要求返回所有可能的平衡BST呢?
这就从“构造一棵树”变成了“枚举所有树”,难度直接上了一个量级,需要结合卡特兰数和分治枚举来做。面试中很少会让你完整实现,但聊到思路是很有价值的展示机会。
4.3 如何验证自己构造的BST是不是合法且平衡
刷题时多写一个验证函数,既能让你的解题过程更严谨,也能在面试中展示自己的工程意识。我通常在本地写两个辅助函数:
第一个函数验证BST的中序遍历是否严格递增:
void inorder(TreeNode* root, vector<int>& res) { if (!root) return; inorder(root->left, res); res.push_back(root->val); inorder(root->right, res); } bool isValidBST(TreeNode* root) { vector<int> res; inorder(root, res); for (int i = 1; i < res.size(); i++) { if (res[i] <= res[i - 1]) return false; } return true; }第二个函数验证每个节点左右子树高度差不超过1:
int getHeight(TreeNode* root) { if (!root) return 0; return 1 + max(getHeight(root->left), getHeight(root->right)); } bool isBalanced(TreeNode* root) { if (!root) return true; int leftH = getHeight(root->left); int rightH = getHeight(root->right); return abs(leftH - rightH) <= 1 && isBalanced(root->left) && isBalanced(root->right); }你可以在本地把sortedArrayToBST的输出分别传给这两个验证函数,跑几个不同长度的数组,确认每次都能通过。这个习惯帮我抓到过不少边界条件错误,比如递归终止条件写错导致死循环,或者区间划分错误导致漏元素。
4.4 影响范围:这题能辐射哪些知识点
从这道题出发,你能顺藤摸瓜复习一串二叉树基础:
- 树的遍历:中序遍历、前序遍历、后序遍历、层序遍历;
- 树的递归思维:分治是二叉树问题最核心的解法框架;
- 树的形态:平衡树、满二叉树、完全二叉树的判别与特点;
- 有序序列与BST的关系:中序遍历等价性原理;
- 二分思想的延伸:这题的递归建树本质上是对“二分查找”的树形可视化。
很多人在刷题时总是“一道题一道题”地刷,刷完就忘。我的习惯是每做一道树相关的题,就把与之关联的题型拉出来横向对比。这道108题正好可以同时连通“数组下标二分”和“树结构递归”两大基本功,性价比很高。
5. 常见问题与避坑技巧
5.1 取中点惧怕整数溢出?还是担心没见过世面的溢出写法
我前面强调过left + (right - left) / 2的写法。可别小看这个细节,我面试过某候选人,他在白板上写了mid = (left + right) / 2,我随口问了一句“如果left和right都很大的时候会怎样”,他愣了几秒才意识到可能溢出。虽然这道题数组长度很难达到溢出边界,但这种敏锐度在系统设计类的面试里经常能体现出来。
Java和C++都建议养成本能反应式地使用安全写法;Python可以偷懒,但最好讲一句“Python int是无限精度所以可以直接写”。
5.2 递归条件写错导致死循环或栈溢出
最常见的错误是递归区间不能收敛。比如把左区间的右边界写成mid,而不是mid - 1。当区间只剩两个元素时,比如[left=0, right=1],mid=0,如果左区间是[0, 0],右区间是[1, 1],这没问题;但假如你错误地把右区间设置成[mid, right],那右区间是[0, 1],永远不会缩小,就会无限递归。
另一个常见错误是终止条件写成left == right,但当前区间在递归过程中可能出现left > right的情况。如果只处理left == right而不处理left > right,就会导致某些分支返回未定义值。所以请务必统一处理空区间。
5.3 多语言实现时的细节观察
我在面试时允许候选人使用自己熟悉的语言,但一个有意思的现象是:使用Python刷题的人,经常用切片递归,比如写build(nums[:mid])这样。这个写法不是不行,但它有两个隐患:
第一,切片会创建新数组,空间复杂度从O(log n)变成O(n log n),性能变差;第二,面试官追问“怎么不用下标减少空间开销”时,你可能一时反应不过来。所以即使你用Python,我也建议传入左右边界而不是切片。
C++的另一个细节是:如果你的递归函数参数里带vector<int>&引用,递归时不会拷贝数组;如果你忘记写&,每次递归都会拷贝整个数组,空间和时间都会爆炸。面试时面试官会盯着你的函数签名看,这是一个能体现基本功的细节。
Java类似的点在于int[] nums传引用就不存在拷贝问题,但如果你用Arrays.copyOfRange做切片,也会产生不必要的新数组。
5.4 面试表达:不要只背代码,要把思路讲成故事
最后分享一点面试表达上的经验。拿到这题,一个高分答题节奏应该是:
- 先复述题目,确认输入输出,问清楚“高度平衡”的定义;
- 指出关键性质:BST中序遍历有序,所以这个数组就是目标树的中序遍历结果;
- 说明构造策略:每次取中间元素作为当前子树根节点,左右区间递归构建;
- 分析复杂度:时间O(n),递归栈空间O(log n),迭代空间O(n);
- 根据面试官要求给出代码;
- 与面试官讨论边界条件,比如空数组、偶数数组、最小值负数等情况。
把这六步练熟,这道题基本就稳了。
我再分享一个在实际模拟面试中遇到的小场景:有一次某候选人写递归版非常顺利,面试官随口问“递归深度大概是多少”,他答“树的高度”,面试官继续问“那树的高度为什么是log n”,他卡住了。原因是他在建树时没有主动分析“每次取中点会把区间均匀二分”这一点。其实只要反应过来:一个长度为n的数组,每次区间长度减半,直到长度变成0,需要log2(n)次,所以树高就是O(log n)。
这个推导最好自己完整走一遍,别停留在“背结论”的层面。因为把数组换成链表,或者把二叉树换成二叉树搜索树,分析方式又会略有不同,能灵活解释面试官问题的候选人,通常更能拿到正向反馈。
5.5 补充一个关于测试驱动的做题方法
我在本地刷题时习惯写一个简易的测试脚本,传入多组数据,自动验证BST合法性和平衡性。比如:
// 测试用例 vector<int> test1 = {}; vector<int> test2 = {1}; vector<int> test3 = {1, 2, 3}; vector<int> test4 = {-10, -3, 0, 5, 9}; vector<int> test5 = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; for (auto& nums : {test1, test2, test3, test4, test5}) { TreeNode* root = sortedArrayToBST(nums); assert(isValidBST(root) && isBalanced(root)); }尤其是空数组和单元素数组这种边界用例,很多人在LeetCode提交时第一次不过,就挂在空输入上。递归版本其实天然处理了空数组,因为build(nums, 0, -1)会直接返回nullptr,但如果你的代码一开始就访问nums[0],就会越界或者崩溃。把边界情况提前测一遍,能帮你节省大量提交试错的时间。
6. 最后几个实操体会
刷这道题已经很多次了,每一次重做还是会有一点新体会。
我个人最推荐的做题路径是:先完全理解递归分治,达到“闭着眼也能默写”的程度;再写一遍用栈模拟递归的迭代版本,体会“显式栈替代系统调用栈”的感觉;最后有余力再了解队列层次构建的版本。面试中首选递归版本,因为代码最简洁、最容易讲清楚,同时它的空间复杂度也更优;迭代版本可以作为加分项,在答完主体思路后主动提一句“其实还可以用迭代实现”,然后看面试官是否想继续深入。
关于中间节点的选择,做偶数长度数组时我习惯取左边中点,也就是(left + right) / 2或left + (right - left) / 2。这样做的好处是和中序遍历的递归逻辑保持一致——每次左子树的节点数不超过右子树节点数,整体结构会稍微偏向左偏,但不影响平衡性。如果你取右边中点也完全没问题,比如写成mid = left + (right - left + 1) / 2,LeetCode同样能过。关键是你要在面试时能说清楚自己选的哪个,以及为什么选这个。
遇到数组里有重复数字时,这一步尤其需要留意。比如[1, 1, 2],如果你取左边中点,第一个1作为根,左区间是空的,右区间是第2和第3个元素,构建出来的树是根为1、右子树由另一个1和2组成。这样中序遍历结果是[1, 1, 2],严格来说是允许重复的“广义BST”。如果你在面试中被问到这种输入,可以主动和面试官确认重复值的处理约定。
在本地调试时,我还喜欢给TreeNode加一个层序遍历打印函数,每次构建完直接把树打出来,一看就能判断结构是否正确。这个习惯在遇到更复杂的树题目时特别有用,比如层序遍历、之字形遍历,调试效率会高很多。
最后一次建议是:不要贪多,不要把迭代版、队列版、递归版、链表变体一次性全部背下来。先保证递归版完全吃透,再在后续刷题中慢慢接触迭代思路。树的题目很大程度靠手感,而手感来自重复练习和总结,不是背诵量。