☰
二叉树完全解析:核心概念、四种遍历与工程实践
2026/10/14 12:28:39 网站建设 项目流程

1. 二叉树:为什么几乎所有高效的数据结构都绕不开它

如果你正在看《数据结构与算法》第四章,大概率已经过了线性表的“新手村”阶段。数组、链表、栈、队列,这些结构本质上是“一条线”上的操作,无论怎么优化,插入、查找、删除的上限就摆在那里——链表插入再快,想找某个元素还是得从头遍历;数组随机访问再爽,中间插个元素得挪动一串人。这个瓶颈,学完第三章你就会感受得特别明显。

这时候二叉树出现了。它不像数组和链表那样是“平铺”的,而是给数据加了一个“层次”的维度:每个节点最多有两个分支,左右的位置本身就是信息。初看会觉得“这不就是比链表多了个指针吗”,但等你真正把递归、遍历、平衡、搜索这些概念串起来,就会发现二叉树是整个课程里第一道真正需要换思维方式的门槛——它同时用上了“递归”和“分治”这两种最核心的算法思想。后续的堆、并查集、图的最短路、各种平衡树,底层能力全在这一章打底。

这篇文章我按自己当年啃这一章的经验,把二叉树的核心概念、存储方式、遍历实现、工程应用、常见题型、调试技巧全部拆开讲一遍,配合可运行的代码和踩坑记录。不管是正在上课的学生、准备面试的求职者,还是想巩固基础的在职开发者,都能直接用得上。

提示:实际课程中的“第四章”在不同版本教材中可能对应不同的内容范围,但二叉树作为数据结构的核心章节,知识点和重难点基本是稳定不变的,下面的梳理以常见教学大纲为准。

2. 二叉树的核心概念:先搞清楚节点、度、高度这些“地基”

2.1 一棵树是怎么“长”出来的

树结构本质上是“一对多”的关系映射。一个节点可以派生出若干子节点,父节点和子节点之间用边连接。二叉树是其中最特殊也最实用的一种:每个节点最多只有两个子节点,分别叫作左孩子和右孩子。这个“左右”的次序是固定的,不是随便放的——把左孩子和右孩子交换,就变成另一棵不同的树了,这一点在后面的遍历、编码里会反复用上。

实际生活里文件系统的目录结构就是一棵多叉树,你把它限制成“每个文件夹最多两个子文件夹且区分左右”,就得到了二叉树。这样限制有什么好处?好处是结构规整,数学性质清晰,而且所有基于树的操作——查找、插入、删除、遍历——都能用统一的递归逻辑来处理,比如计算树的高度、检查平衡性这类问题,都能用一套规律性的方法,这比处理任意多叉树要简单得多。

2.2 必须一上来就记牢的几个定义

这几个概念一开始不较真,后面做题、写遍历、分析复杂度全都会出错:

  • 节点的度:一个节点拥有的子树个数。二叉树的节点度最高为2。
  • 树的度:整棵树里所有节点的度的最大值。二叉树说白了就是“度≤2”的树。
  • 叶子节点:度为0的节点,也就是没有孩子的末端节点。它们通常是递归的出口,遍历的终点。
  • 树的深度:从根节点到某个节点的最长路径上的节点数,根节点的深度通常记作1。
  • 树的高度:从某个节点到叶子节点的最长路径长度,叶子节点高度为0或1,看你用的教材约定。不同教材对深度和高度有细节差异,做题时注意看题目约定。

我当年考试时栽过一个跟头:题目问“深度为k的二叉树最多有多少个节点”,我下意识写2k,正确答案是2^k - 1。为什么会这样?因为第1层有1个节点(根),第2层最多2个,第3层最多4个……第k层最多2^(k-1)个,全部加起来就是等比数列求和,结果是2^k - 1。这个结论对应的是“满二叉树”,也是二叉树性质里最重要的推导之一。

2.3 满二叉树、完全二叉树、完美二叉树:别混为一谈

这三个名字在中文教材里经常被互相混用,建议先统一口径再往下学:

  • 完美二叉树:除叶子节点外,每个节点都有两个子节点。每一层的节点数都是满的,总节点数为2^k - 1。
  • 完全二叉树:除最后一层外,每一层都是满的,最后一层的节点集中在左侧连续排列,不允许“右边有节点、左边空着”的情况。
  • 满二叉树:国内很多教材里满二叉树和完美二叉树指的是同一个东西,但在部分资料里,“满二叉树”指每个非叶子节点都有两个子节点——两者是等价的。真正操作里更常用的是“完全二叉树”,因为只有它能用数组连续存储而不浪费空间,堆(优先队列)就是典型应用。

这里我举个例子说明为什么完全二叉树特殊:一个深度为3、总共6个节点的完全二叉树,它的节点在数组里的下标是连续的1~6,没有空洞。但如果是一棵普通二叉树,中间某个节点缺了左孩子,数组存储就会出现空洞,必须用特殊值占位,否则无法还原结构。这个特性在堆排序和优先队列里直接决定了代码能不能写得简洁。


3. 二叉树的两大存储方式与操作习惯

3.1 顺序存储:数组里藏着一棵完全二叉树

顺序存储的思路很简单:按从上到下、从左到右的顺序,把二叉树的节点存进数组。对于下标从1开始的数组,某个节点在i的位置,它的左孩子在2i,右孩子在2i+1,父节点在 i/2(整除)。这套公式只对完全二叉树成立,或者说,只有完全二叉树才能让数组下标和树的结构完全对应,不浪费空间。

我建议你现在就拿出一张纸,画一个三层满二叉树,把节点标上1、2、3、4、5、6、7,然后对着公式敲一遍:2的左孩子是4(2×2),右孩子是5(2×2+1),4的父节点是2(4/2)。这个手写过程比任何讲解都有用,它是堆排序的基础,也是你在LeetCode上做二叉堆题时下意识就能列出的映射关系。

顺序存储的优缺点:

  • 优点:内存紧凑,缓存友好,随机访问某个节点的孩子或父节点只需要一次乘除运算,速度极快。
  • 缺点:只适合完全二叉树。普通二叉树想用数组存,必须把缺失的节点用空标记占位,树越稀疏,空间浪费越大。

3.2 链式存储:最常用、最好理解的方式

链式存储是绝大多数教材和算法题的标准写法,每个节点就是一个结构体(或类),包含数据域和左、右两个指针。

typedef struct BiTNode { int data; struct BiTNode *lchild, *rchild; } BiTNode, *BiTree;

C语言版最直接。用Python写的话就是类加两个属性:

class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right

链式存储的优点是完全二叉树、普通二叉树都能存,结构直观,递归遍历好写,不需要预先知道节点数量。缺点是每个节点要额外存两个指针,存储开销大,而且存在大量的指针跳跃,缓存命中率不如数组。

实操中我的建议:算法题刷题和面试手写用链式存储,堆相关题目、按层遍历用顺序存储思维。两种都要能熟练切换,因为面试官经常让你在两种存储方式之间转换,比如“用数组表示一棵[1,2,3,null,4]的树,写出它的中序遍历”。

3.3 二叉树的插入、删除操作,先学做题,再学工程写法

二叉树的插入和删除不像链表的头插尾插那么固定,因为二叉树本身不规定“新节点必须插在哪”。做题时通常的做法是:按照层序遍历的顺序,找到第一个空缺的位置塞进新节点。删除则更复杂,如果是删除任意节点,需要分三种情况处理:无孩子直接删;有一个孩子用孩子替换;有两个孩子一般用左子树最大节点或右子树最小节点顶替,这是二叉搜索树里的经典做法,第四章末或第五章初会讲到。

这里提醒一句:不要花太多时间在“通用二叉树”的删除上,因为通用二叉树不要求有序性,删除谁全看删除策略,而真正值得学透的是“二叉搜索树”的删除——那个才有明确的规则和面试价值。第四章的二叉树基础打好之后,建议无缝衔接二叉搜索树来看删除逻辑,这样效率最高。


4. 四种遍历:二叉树的一切操作都从这儿起步

4.1 遍历的本质是“访问顺序的排列组合”

树的遍历就是按某种规则把每个节点访问一次。二叉树一个节点有左子树、右子树和自己的值,这三个东西的访问顺序不同,就产生了不同的遍历方式。先序、中序、后序都是深度优先遍历(DFS),层序是广度优先遍历(BFS)。

不夸张地说,二叉树90%的算法题都是在这几种遍历的基础上动脑筋:序列化要遍历、求深度要遍历、判断平衡要遍历、翻转二叉树要遍历、求路径和要遍历。你把这四种遍历的递归和迭代写法全部练到条件反射,这章就算是真正拿下了。

4.2 递归遍历:三句话背下来,代码就是答案

递归遍历的实现极其简洁,难点在于“理解递归栈里的状态变化”。先看代码:

# 先序遍历:根 -> 左 -> 右 def preorder(root): if not root: return print(root.val) preorder(root.left) preorder(root.right) # 中序遍历:左 -> 根 -> 右 def inorder(root): if not root: return inorder(root.left) print(root.val) inorder(root.right) # 后序遍历:左 -> 右 -> 根 def postorder(root): if not root: return postorder(root.left) postorder(root.right) print(root.val)

你发现规律了吗?打印语句放在第几行,决定了它是先序、中序还是后序。代码本身的区别就这一行代码的位置,但背后“递归栈每层压入什么、什么时候弹出”的过程,需要真正理解。我推荐一个练习方法:画一棵三层二叉树,模拟递归调用的压栈弹栈过程,每个节点标上“进入时收到的值”和“返回时带出的值”,手写一遍胜过脑补十遍。

有一个面试中常被问到的关联知识点:已知先序遍历和中序遍历,可以唯一确定一棵二叉树;已知中序和后序也可以;但只知道先序和后序不行——当某个节点只有一个孩子时,先序和后序无法区分这个孩子到底是左还是右。这个结论在考研、面试中都很常见,试着用代码实现“由先序和中序重建二叉树”,这是一个非常好的综合训练,也是第四章的经典题目。

4.3 迭代遍历:把递归摊开,看清栈的真实行为

递归遍历好写,但面试官经常要求你写迭代版本,目的是考察你是否真正理解栈。先序和中序的迭代写法差异只在于“访问节点的时机”:

def preorder_iter(root): if not root: return [] res = [] stack = [] node = root while node or stack: # 一路向左,边进栈边访问 while node: res.append(node.val) stack.append(node) node = node.left # 弹出栈顶,转向右子树 node = stack.pop() node = node.right return res

中序迭代几乎一模一样,只是把res.append(node.val)的位置从“入栈前”挪到“出栈后”:

def inorder_iter(root): if not root: return [] res = [] stack = [] node = root while node or stack: while node: stack.append(node) node = node.left node = stack.pop() res.append(node.val) # 访问时机不同 node = node.right return res

后序迭代稍麻烦一点,常见思路是“先序的镜像”:先访问根->右->左,最后整体反转。或者更直接地用两个栈实现。我个人的习惯是记住“倒过来想”这个口诀,面试时能迅速写对。

迭代写法的核心收获:你从代码里能直观看到递归、中、后序的“压栈”和“出栈”时机,这是在数据结构层面理解递归的钥匙。以后看任何递归代码卡壳的时候,先设想一个简单输入,手动画栈帧,绝大多数问题都能解决。

4.4 层序遍历:队列出场,按层挨个访问

层序遍历是广度优先搜索(BFS)在二叉树上的直接体现。它需要借助队列——先把根节点入队,循环弹出队首节点,访问它,再把它的左、右孩子依次入队,直到队列为空。

from collections import deque def level_order(root): if not root: return [] res = [] q = deque([root]) while q: node = q.popleft() res.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) return res

上面代码输出的是“按层顺序”的一维列表。更常见的需求是按层分组,也就是把每一层的节点单独放在一个数组里。实现方式也很简单:在每一轮循环开始时,先记录当前队列的长度,这个长度就是当前层的节点数,然后只弹出这一层的节点,同时把下一层的节点入队。

def level_order_grouped(root): if not root: return [] res = [] q = deque([root]) while q: level_size = len(q) level_vals = [] for _ in range(level_size): node = q.popleft() level_vals.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) res.append(level_vals) return res

这个分组写法是面试的真高频考点,LeetCode 102题“二叉树的层序遍历”就是它。你可以在它基础上做很多扩展:分层从下往上输出(结果反转即可)、锯齿形遍历(单数层反转)、统计每层最大值、找每层最右节点等等,但换汤不换药,都是 BFS + 记录层大小。

4.5 遍历的复杂度与递归栈溢出问题

先序、中序、后序、层序遍历,时间复杂度都是O(n)——因为每个节点恰好被访问一次。空间复杂度分两种:递归遍历的平均空间复杂度是O(h),h是树高,最坏情况(比如一棵退化成链状的树)空间复杂度退化成O(n);层序遍历的空间复杂度是O(w),w是某一层最多节点数,最坏情况下完全二叉树最后一层有约n/2个节点,所以也是O(n)。

工程上要特别留意递归的深度。生产环境里不能无脑用递归遍历一棵深度很大的树,比如一个极端不平衡的二叉搜索树,深度可能等于节点数n,递归n层时Java/Python都会栈溢出。遇到这种情况,要么改用迭代栈版本,要么限制树高(这正是后面平衡树、红黑树的用武之地)。


5. 二叉树到底能干什么:四个方向的工程应用

5.1 二叉搜索树:让“查找”不再是O(n)

二叉树本身不保证什么,但给它加一个约束——左子树上所有节点值都小于根节点,右子树上所有节点值都大于根节点——就得到了二叉搜索树(BST)。这个约束让查找、插入、删除的平均时间复杂度降为O(log n),因为每走一步就能排除掉一半的子树,本质上就是“树版的二分查找”。

BST的劣势在于:如果插入的数据是递增或递减序列,它就会退化成一条链,所有操作退化为O(n)。这就是为什么后面要研究AVL树、红黑树——它们通过旋转来保持树的平衡,让树高始终保持在O(log n)级别。理解BST是理解这些平衡树的前提,而BST本身就是第四章最自然的延伸。

我在工程里实际用过BST的场景是“需要有序且频繁插入删除的需求”,例如排行榜维护、有序集合、区间查询等。不过大多数语言都已经内置了平衡树实现(如Java的TreeMap、C++的map、Python的bisect配合列表),直接使用会更稳妥,自己写BST更多是学习和面试场景。

5.2 堆与优先队列:一颗完全二叉树解决“求最值”问题

堆是一个“值的顺序有约束”的完全二叉树。大顶堆要求每个节点都比它的孩子大,堆顶就是整个数据的最大值。因为堆存储在数组里且满足完全二叉树的下标规律,所以可以很方便地用它实现优先队列——每次弹出堆顶就是弹出当前最值。

堆排序的时间复杂度稳定为O(n log n),而且是不稳定的排序算法。它是“利用二叉树性质解决工程问题”的教科书级例子:插入一个元素O(log n)、取最值O(1)、删除堆顶O(log n)。这比数组找最大值O(n)、链表找最大值O(n)、排序后取最值O(n log n)都高效。

5.3 表达式树、哈夫曼树:一面是编译器,一面是压缩算法

表达式树是一种特殊的二叉树:内部节点是运算符,叶子节点是操作数。它表达了一个算术表达式的结构,中序遍历可以还原出中缀表达式,后续遍历则对应后缀表达式——编译器解析表达式时,底层就是构造一棵表达式树,然后遍历它生成指令。

哈夫曼树则是最优二叉树:给每个叶子节点分配一个权值(出现频率),通过不断合并权值最小的两棵树,得到带权路径长度最小的二叉树。哈夫曼编码就是利用这个性质,为高频字符分配更短的编码,实现无损压缩。这是“用二叉树解决具体工程问题”的又一个经典案例,也是考研和面试的常客。

5.4 日常生活中的树:文件系统、组织架构、路由表

现实里用到树结构的地方比你想象的要多得多:

  • 文件系统:目录与子目录之间天然是一棵多叉树,路径就是树根到节点的路径。
  • HTTP路由器的路由表:前缀树(Trie)是处理IP前缀匹配的高效结构。
  • 组织架构:公司汇报关系就是一棵树,CEO是根节点,层层向下的部门负责人是子节点。
  • JSON/XML解析:文档对象本质上也是一棵树,遍历树的过程就是解析文档的过程。

碰到“有层级、有归属、需要分类”的场景,先想想是不是可以用树模型描述。这种抽象能力,才是数据结构的真正价值——它不止是算法题的敲门砖,更是一套建模方法论。


6. 实操环节:手写一棵二叉树并用四种遍历验证

6.1 搭建一棵测试二叉树

为了演示,我们构造下面这棵二叉树:

1 / \ 2 3 / \ \ 4 5 6

它对应的链式存储创建代码如下(Python):

class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right root = TreeNode(1, TreeNode(2, TreeNode(4), TreeNode(5)), TreeNode(3, None, TreeNode(6)))

6.2 分别运行四种遍历,观察输出差异

把前面的递归遍历跑一遍,输出是:

  • 先序:1, 2, 4, 5, 3, 6
  • 中序:4, 2, 5, 1, 3, 6
  • 后序:4, 5, 2, 6, 3, 1
  • 层序:1, 2, 3, 4, 5, 6

你有没有发现一个规律:先序的第一个节点永远是根,后序的最后一个节点永远是根;中序遍历在二叉搜索树里会得到有序序列,这个特性在验证后面写出的BST是否正确时非常好用。

我强烈建议你亲自跑一遍,并在纸上对照这棵树手动推演一遍每个遍历的访问顺序。因为考试、面试里除了让你写代码,最常见的就是“给一棵树,写出它的先序/中序/后序/层序遍历结果”。这玩意儿光靠看代码是学不来的,必须手推。

6.3 补充:从遍历结果反推二叉树

这里有一个很重要的推导方法。如果题目给了“先序 + 中序”要求恢复二叉树,思路是:先序的第一个节点是根,然后拿这个根去中序序列里定位,中序里根左边的部分就是左子树的中序序列,根右边的部分就是右子树的中序序列;再根据左右子树的长度,去先序序列里切分左右子树的先序序列。之后递归处理即可。

这个题型在笔试、机考里的出现率非常高。我第一次做的时候写了一大堆循环,后来发现直接递归反而最清晰。代码如下:

def build_tree(preorder, inorder): if not preorder: return None root_val = preorder[0] root = TreeNode(root_val) idx = inorder.index(root_val) root.left = build_tree(preorder[1:idx+1], inorder[:idx]) root.right = build_tree(preorder[idx+1:], inorder[idx+1:]) return root

注意inorder.index(root_val)是O(n)操作,因此整体复杂度是O(n^2)。优化方法是先用字典记录中序序列中每个值的下标,把查找降到O(1),整体复杂度变为O(n)。这是很典型的时间复杂度优化操作,面试中属于加分项。

6.4 调试二叉树的实用小技巧

二叉树的问题最难定位,因为你看不到树长什么样。我自己的调试习惯是:先写一个把树转成数组表示的函数,方便肉眼检查:

def tree_to_list(root): # 层序转数组,空缺用None表示 if not root: return [] res = [] q = deque([root]) while q: node = q.popleft() if node: res.append(node.val) q.append(node.left) q.append(node.right) else: res.append(None) # 去掉末尾多余的None while res and res[-1] is None: res.pop() return res

比如上面的测试树,输出就是[1, 2, 3, 4, 5, None, 6]。配合打印中序遍历结果,验证搜索树是否有序、验证重建树的结构,基本能解决90%的调试场景。另一个建议是:递归代码出bug时,不要盯着代码在心里跑,而是手动模拟一个极小的输入(比如3个节点),一步步把递归栈画出来,立刻就能定位问题。


7. 高发问题与避坑指南:二叉树相关的常见坑

7.1 递归出口写错,导致无限递归或空指针

最常见的错误是递归出口判断不完整。比如求树的高度时,这样写就是错的:

def height(root): if root is None: return 0 # 错误的写法:忘记判断空子树

正确写法需要处理空节点的情况:

def height(root): if root is None: return 0 return 1 + max(height(root.left), height(root.right))

这个例子的教训是:写递归先想清楚“什么时候停止”,没有正确的终止条件,递归就会无限压栈直到崩溃。检查递归函数时,第一眼看边界条件,第二眼看递归调用的参数是否朝着边界靠近。

7.2 完全二叉树 vs 完美二叉树的判断题

考试里常出现的坑题是:“完全二叉树一定是完美二叉树吗?”答案是否定的。完全二叉树允许最后一层不满,但必须靠左;完美二叉树要求所有层全满。反过来,“完美二叉树一定是完全二叉树”才是对的。

我记这个的诀窍是看名字:“完全”强调“除最后一层外完全填充”,像“基本填满了但最后一层可以缺一点”;“完美”强调“每一层都完美填满”。做题时先画图,再用定义判断,不要凭感觉。

7.3 递归写法太多,导致栈溢出

在深度较大的树上用递归遍历,可能导致栈溢出。面试里如果树的高度为10万,写递归基本就是死路。自己刷题时看不出来,因为测试用例都比较小,但大厂笔试的后台用例经常会设置边界情况。

应对办法是会用迭代写法。中序和前序的迭代写法本质是用显式栈模拟递归压栈,代码难度不大;实在不行,也可以用Morris遍历——利用叶子节点的空指针建立临时线索,实现O(1)额外空间遍历,这是进阶技巧,理解了原理非常加分。

7.4 层序遍历的“层”概念边界模糊

写层序遍历如果要按层分组,最典型的错误是直接在循环外层level_size = len(q)后,却忘记在内部循环范围之外保存这个值,导致内部循环里队列长度变化、循环次数出错。解决办法就是我在4.4里写的:在每一轮处理前先取level_size = len(q),然后for _ in range(level_size)固定住当前层的节点数。

另一个常见错误是在for循环内部使用while去弹队列,导致把下一层的节点也弹出访问了。我最早遇到的bug就是这种——结果输出的列表顺序完全错了。碰到层序遍历,记住“队列里永远只保留两层节点:当前层 + 下一层”,就好理解了。

7.5 NULL节点占位导致的结构歧义

序列化二叉树时,如果只用层序数组表示,遇到缺失节点必须用null占位。比如[1, null, 2, 3]表示的是根节点1的左孩子为空、右孩子是2、2的左孩子是3。但如果不习惯这个约定,很容易想当然地把数组里的顺序完全和层级对应上,导致构造错误。建议做题时先确认题目序列化的规则,再看示例,别默认它一定是层序。


8. 二叉树学习的路线规划与心态建议

第四章的内容量其实不小,如果只是“看完了”,很容易出现“看懂但不会做”的情况。我建议你按下面的顺序自查一遍:

  1. 能不能不看书手写四种遍历的递归版和迭代版?
  2. 能不能从“先序+中序”恢复一棵二叉树?
  3. 能不能手动计算一棵任意二叉树的节点数、深度、叶子数?
  4. 能不能写出求二叉树最大深度、最小深度、判断对称、翻转二叉树的代码?
  5. 能不能解释“完全二叉树用数组存储为什么没有空洞”?

这些问题如果都能流畅作答,说明这一章的基础就打牢了。

学习路径上,我个人的体会是“先做手工题,再做代码题”。手工题指的是拿到一棵具体的树,手动写出它的各种遍历序列、计算深度和节点数。这一步看似基础,却是构建直觉的最快方式。代码题则从“遍历”这类最依赖递归的数做起,逐步过渡到“求深度”“判断平衡”“公共祖先”这些组合题。题不在多,把每一道典型题背后的遍历方式和递归结构吃透,比机械刷100道更有用。

还有一个心态建议:二叉树这块容易“一听就懂,一写就废”,很正常。递归的思维方式不像循环那么自然,需要刻意练习。我的方法是每道递归题至少手推三轮:输入最小case、输入普通case、输入边界case,推完之后再上手敲代码。坚持两周,你对递归的理解会有质的飞跃。


我个人在实际学习二叉树时最受益的一个小技巧是:把“遍历”看成二叉树的“原语”,任何操作都先问自己一句“这题能不能用某一种遍历来解决”。求深度可以用后序(先处理后序子问题),判断对称可以用先序(同时传入左右子树),求路径和可以用先序(累计状态),序列化可以用层序。万事归一,都是遍历的变形。

第四章是一个承上启下的章节,它承接了线性的数据结构思维,开启了你对“层次”“递归”“分治”这些核心算法思想的理解。真正把二叉树吃透,后面的每一种树结构都会轻松很多。不要急着追求速度,把每种遍历的递归和迭代都写到熟练、把每种题型背后的遍历方式都想明白,这一章就算过关了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询