1.顺序查找
您描述的是顺序查找(Sequential Search),也称为线性查找。
查找过程
从表的一端(如第一个元素)开始,依次将每个元素的关键字与目标关键字 key 进行比较,直到:
- 找到相等的元素 → 返回该元素的位置(查找成功)。
- 查找到表的末尾仍未找到 → 返回失败标志。
查找长度
1. 平均查找长度(ASL)
假设表中有 ( n ) 个元素,每个元素被查找的概率相等(( \frac{1}{n} ))。
查找成功:
- 最好情况:第 1 次就找到,比较次数 = 1
- 最坏情况:最后 1 次才找到(或没找到),比较次数 = ( n )
- 平均比较次数(成功时):
ASL_{\\text{成功}} = \\frac{1+2+ \\cdots + n}{n} = \\frac{n+1}{2}
查找失败 :
必须比较完所有 ( n ) 个元素才能确定失败,比较次数 = ( n )。
2. 时间复杂度
- 最好:( O(1) )
- 最坏:( O(n) )
- 平均:( O(n) )
特点
- 适用于任何顺序存储或链式存储的线性表(无序表也可)。
- 无需事先对数据排序。
- 查找效率低,不适合大数据量查找。
示例
数组 [3, 8, 2, 5, 1],查找 key = 5:
- 比较
3,不等 - 比较
8,不等 - 比较
2,不等 - 比较
5,相等 → 返回索引 3(查找成功,比较 4 次)
查找 key = 7:
比较完所有 5 个元素后仍没找到 → 返回失败(比较 5 次)。
1 + 2 + ... + n 是一个等差数列求和。
公式 :
1 + 2 + \\cdots + n = \\frac{n(n+1)}{2}
推导 (高斯小时候的方法):
令
S = 1 + 2 + 3 + \\cdots + n
倒过来写:
S = n + (n-1) + (n-2) + \\cdots + 1
两式相加:
2S = (1+n) + (2+(n-1)) + (3+(n-2)) + \\cdots + (n+1)
每项都是 ( n+1 ),共有 ( n ) 项:
2S = n(n+1)
所以
S = \\frac{n(n+1)}{2}
2.折半查找
您描述的是 折半查找(二分查找,Binary Search) 的基本思想和步骤。
我帮你整理并补充完整。
一、适用条件
- 必须是有序序列(递增或递减),通常假设递增。
- 存储结构常用一维数组(下标从 1 或 0 开始)。
二、查找过程
设数组 r[1..n] 按关键字递增排列。
- 初始化查找区间:
\\text{low} = 1, \\quad \\text{high} = n
- 当
low <= high时:- 计算中间位置:
\\text{mid} = \\lfloor (\\text{low} + \\text{high}) / 2 \\rfloor
(向下取整,C/Java 中整数除法自动向下取整) - 比较
key与r[mid].key:- 若相等 → 查找成功,返回
mid。 - 若
key > r[mid].key→ 在后半段继续查找:\\text{low} = \\text{mid} + 1
- 若
key < r[mid].key→ 在前半段继续查找:\\text{high} = \\text{mid} - 1
(注意:已经比较过mid,所以下次查找时排除mid)
- 若相等 → 查找成功,返回
- 计算中间位置:
- 当
low > high,说明查找区间为空,查找失败。
三、关键要点
-
中间值位置小数向下取整(不是四舍五入):
- 例如
(4+5)/2 = 4.5→ 取mid = 4。 - 这样做保证查找区间划分均匀,且整数下标合法。
- 例如
-
排除中间值:
- 因为
mid位置已经比较过且不相等,所以下一步区间是:- 前半段:
[low, mid-1] - 后半段:
[mid+1, high]
- 前半段:
- 因为
-
查找结束条件:
- 成功:在循环内找到
key == r[mid].key。 - 失败:
low > high时结束。
- 成功:在循环内找到
四、查找长度与复杂度
-
时间复杂度:( O(\log n) )
-
平均查找长度(成功):
- 假设每个元素等概率被查找,有序表的二分查找 ASL ≈ ( \log_2 (n+1) - 1 )(近似于 ( O(\log n) ))。
- 具体精确公式与查找判定树的深度有关。
-
查找失败:也会在 ( O(\log n) ) 次比较后确定。
五、示例
数组 r[1..6] = {5, 16, 20, 27, 39, 46},查找 key=27。
low=1, high=6,mid=⌊(1+6)/2⌋=3,r[3]=20,27>20→low=4low=4, high=6,mid=⌊(4+6)/2⌋=5,r[5]=39,27<39→high=4low=4, high=4,mid=⌊(4+4)/2⌋=4,r[4]=27,相等 → 查找成功,返回 4。
以下是使用 JavaScript 实现的折半查找(二分查找)代码:
javascript
/**
* 折半查找(二分查找)
* @param {Array} arr - 有序数组(升序)
* @param {number} target - 要查找的目标值
* @return {number} - 找到则返回索引,否则返回 -1
*/
function binarySearch(arr, target) {
let low = 0; // 查找区间左边界
let high = arr.length - 1; // 查找区间右边界
while (low <= high) {
// 计算中间位置(向下取整)
let mid = Math.floor((low + high) / 2);
if (arr[mid] === target) {
// 查找成功
return mid;
} else if (arr[mid] < target) {
// 目标值在右半部分
low = mid + 1;
} else {
// 目标值在左半部分
high = mid - 1;
}
}
// 查找失败
return -1;
}
/**
* 递归版本的折半查找
* @param {Array} arr - 有序数组
* @param {number} target - 要查找的目标值
* @param {number} low - 查找区间左边界
* @param {number} high - 查找区间右边界
* @return {number} - 找到则返回索引,否则返回 -1
*/
function binarySearchRecursive(arr, target, low = 0, high = arr.length - 1) {
if (low > high) {
return -1; // 查找失败
}
let mid = Math.floor((low + high) / 2);
if (arr[mid] === target) {
return mid;
} else if (arr[mid] < target) {
return binarySearchRecursive(arr, target, mid + 1, high);
} else {
return binarySearchRecursive(arr, target, low, mid - 1);
}
}
// 测试示例
const sortedArray = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91];
const targets = [23, 5, 91, 100];
console.log("有序数组:", sortedArray);
console.log("--- 迭代版本 ---");
targets.forEach(target => {
const index = binarySearch(sortedArray, target);
console.log(`查找 ${target}: ${index !== -1 ? `找到,索引为 ${index}` : "未找到"}`);
});
console.log("\n--- 递归版本 ---");
targets.forEach(target => {
const index = binarySearchRecursive(sortedArray, target);
console.log(`查找 ${target}: ${index !== -1 ? `找到,索引为 ${index}` : "未找到"}`);
});
// 性能测试(查找所有元素)
console.log("\n--- 验证所有元素都能找到 ---");
sortedArray.forEach((value, expectedIndex) => {
const foundIndex = binarySearch(sortedArray, value);
console.assert(foundIndex === expectedIndex,
`查找 ${value} 失败: 预期 ${expectedIndex}, 实际 ${foundIndex}`);
});
console.log("所有元素验证通过!");
// 边界情况测试
console.log("\n--- 边界情况测试 ---");
const emptyArray = [];
console.log("空数组查找 5:", binarySearch(emptyArray, 5)); // -1
const singleArray = [10];
console.log("单元素数组查找 10:", binarySearch(singleArray, 10)); // 0
console.log("单元素数组查找 5:", binarySearch(singleArray, 5)); // -1
// 查找过程演示
function binarySearchWithSteps(arr, target) {
console.log(`\n查找 ${target} 的过程:`);
let low = 0;
let high = arr.length - 1;
let steps = 0;
while (low <= high) {
steps++;
let mid = Math.floor((low + high) / 2);
console.log(`步骤 ${steps}: low=${low}, high=${high}, mid=${mid}, arr[mid]=${arr[mid]}`);
if (arr[mid] === target) {
console.log(`找到 ${target},位置: ${mid},共 ${steps} 步`);
return mid;
} else if (arr[mid] < target) {
low = mid + 1;
} else {
high = mid - 1;
}
}
console.log(`未找到 ${target},共 ${steps} 步`);
return -1;
}
// 演示查找过程
binarySearchWithSteps(sortedArray, 23);
代码说明
1. 迭代版本 (binarySearch)
- 使用
while循环实现 - 不断更新查找区间
[low, high] - 中间位置使用
Math.floor()向下取整 - 时间复杂度:O(log n)
- 空间复杂度:O(1)
2. 递归版本 (binarySearchRecursive)
- 递归实现,逻辑更简洁
- 需要传递查找区间边界参数
- 时间复杂度:O(log n)
- 空间复杂度:O(log n)(递归栈空间)
3. 关键点
- 数组必须有序(升序)
- 中间位置向下取整:确保索引为整数
- 排除已比较元素 :
mid+1或mid-1 - 循环条件 :
low <= high(允许相等,此时区间还有一个元素)
4. 测试示例输出
有序数组: [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
--- 迭代版本 ---
查找 23: 找到,索引为 5
查找 5: 找到,索引为 1
查找 91: 找到,索引为 9
查找 100: 未找到
--- 递归版本 ---
查找 23: 找到,索引为 5
查找 5: 找到,索引为 1
查找 91: 找到,索引为 9
查找 100: 未找到
5. 性能特点
- 每次比较将查找范围缩小一半
- 仅适用于顺序存储结构(如数组)
- 不适合链表(因为需要随机访问)
- 比顺序查找快得多,但需要先排序
此实现完整展示了折半查找的核心思想和具体实现方法。
问得非常好!这是一个很关键的理解点。让我详细解释为什么折半查找的时间复杂度是 (O(\log n)),以及 "(\log)" 是怎么来的。
一、直观理解:每次砍掉一半
核心思想 :每次比较后,剩余需要查找的元素数量都减半。
举例:假设数组有 (n=16) 个元素。
第1次比较:剩余 16 个元素 → 比较一次 → 剩余 8 个
第2次比较:剩余 8 个元素 → 比较一次 → 剩余 4 个
第3次比较:剩余 4 个元素 → 比较一次 → 剩余 2 个
第4次比较:剩余 2 个元素 → 比较一次 → 剩余 1 个
第5次比较:剩余 1 个元素 → 比较一次 → 找到或确认不存在
从 16 到 1,只需要 5 次比较。
二、数学推导
1. 设最坏情况下的比较次数为 (k)
每次比较后,剩余元素数变化:
n \\rightarrow \\frac{n}{2} \\rightarrow \\frac{n}{4} \\rightarrow \\cdots \\rightarrow 1
这个过程的次数 (k) 满足:
\\frac{n}{2\^k} = 1
解方程:
2\^k = n
k = \\log_2 n
所以最坏情况下需要比较 (\log_2 n) 次。
2. 为什么是 "(\log_2 n)"?
因为每次除以 2(折半):
- 如果你每次除以 3,复杂度就是 (O(\log_3 n))
- 但计算机科学中,对数底数在渐进记号中不重要((\log_a n = \frac{\log_b n}{\log_b a}),常数倍差异)
- 所以我们通常简写为 (O(\log n))(默认底数为 2)
三、用具体数字感受一下
| 数据规模 (n) | (\log_2 n)(近似) | 比较次数(最坏) |
|---|---|---|
| 10 | 3.32 | 4 |
| 100 | 6.64 | 7 |
| 1,000 | 9.97 | 10 |
| 1,000,000 | 19.93 | 20 |
| 1,000,000,000 | 29.90 | 30 |
惊人的效率:
- 10 亿个元素中查找,最多只需约 30 次比较!
- 而顺序查找需要最多 10 亿次比较。
这就是 (O(\log n)) 的威力:输入规模翻倍,操作次数只增加 1 次。
四、与顺序查找的对比
顺序查找 (线性查找):
\\text{比较次数} \\propto n \\quad (\\text{成正比})
- (n) 增加 10 倍 → 比较次数也增加 10 倍
折半查找 :
\\text{比较次数} \\propto \\log n
- (n) 增加 10 倍 → 比较次数只增加约 3-4 次
- 因为 (\log(10n) = \log n + \log 10 \approx \log n + 3.32)
五、完整公式推导
更精确地说,对于长度为 (n) 的有序表,折半查找的最坏情况比较次数 为:
\\lfloor \\log_2 n \\rfloor + 1
或
\\lceil \\log_2 (n+1) \\rceil
推导 :
设比较次数为 (k),则:
- 查找判定树的高度为 (h = \lceil \log_2 (n+1) \rceil)
- 最坏情况就是沿着树的一条路径走到叶子,比较次数 = 树的高度
六、通俗比喻
想象你在猜数字游戏中猜 1-100 之间的数字:
- 你问:"比 50 大吗?" → 是 → 范围缩小到 51-100
- 你问:"比 75 大吗?" → 否 → 范围缩小到 51-75
- ...
每次提问都能排除一半的可能性,所以最多只需要 (\log_2 100 \approx 7) 次提问。
这就像对数的定义:
- 对数问的是:"2 的多少次方等于 n?"
- 在折半查找中问的是:"需要折半多少次,才能从 n 个元素减少到 1 个?"
总结
| 查找算法 | 时间复杂度 | 增长速率 |
|---|---|---|
| 顺序查找 | (O(n)) | 线性增长 |
| 折半查找 | (O(\log n)) | 对数增长(非常慢的增长) |
对数增长是计算机算法中非常理想的复杂度,因为它意味着即使数据量极大,操作次数也增加得非常缓慢。这正是折半查找高效的核心原因。
3.哈希查找
是的,您描述得非常准确。您这段话精炼地总结了哈希表(Hash Table)的核心原理和查找过程。
让我们将其拆解成几个关键部分,并做一点延伸说明:
1. 核心原理:地址 = 哈希函数(关键字)
这是哈希表最本质的思想。它不通过比较关键字的大小来定位,而是用一个函数直接进行计算。
- 输入 :记录的唯一标识,即 关键字(Key)。
- 处理 :通过一个预设的 哈希函数(Hash Function) 进行计算。
- 输出 :一个整数,通常作为记录在数组中存储的 地址(索引)。
示例: 假设有一个存储员工信息(关键字为工号)的哈希表。
- 哈希函数设计为:
地址 = 工号 % 100(取工号的最后两位)。 - 那么,工号为
12345的员工,其存储地址就是45。
2. 查找操作的过程
您描述的查找过程非常标准:
- 计算哈希地址 :对要查找的 待查关键字(Search Key) ,使用建表时同一个哈希函数计算,得到其理论存储地址。
- 访问存储单元:直接去到哈希表(通常是一个数组)的这个地址位置。
- 获取信息并判定 :
- 情况A:命中 。该位置存储的记录关键字与待查关键字完全匹配。查找成功。
- 情况B:冲突位置不匹配 。该位置有记录,但关键字不匹配。这说明当初插入时发生了哈希冲突,当前记录是通过冲突解决策略(如链地址法、开放定址法)存放在这里的。此时需要根据冲突解决策略,继续"探测"下一个可能的位置,直到找到匹配的记录或确认不存在。
- 情况C:空单元 。该位置为空,说明没有任何记录被哈希到这个地址。查找失败(对于开放定址法,可能需要继续探测直到遇到空单元才能最终确认失败)。
3. 重要的补充概念
您的话引出了哈希表的几个关键特性和需要处理的问题:
- 哈希冲突 :不同的关键字可能被哈希函数映射到同一个地址(例如,工号
12345和22345用%100计算后地址都是45)。这是哈希表设计时必须处理的核心问题。 - 冲突解决策略 :
- 链地址法(开散列法):每个地址位置不是一个记录,而是一个链表(或其他容器)。冲突的记录都放在这个链表里。查找时,在计算出的地址的链表中进行顺序查找。
- 开放定址法(闭散列法):所有记录都放在主数组里。发生冲突时,按照某种探测规则(如线性探测、二次探测)寻找数组中的下一个空位。查找时也需要遵循同样的规则进行探测。
总结流程图
根据您的描述,查找操作的流程图可以概括如下:
开始查找
↓
输入待查关键字 Key
↓
使用哈希函数 H 计算地址: Addr = H(Key)
↓
访问哈希表 Table[Addr]
↓
┌─────────────────┐
│ Table[Addr]状态?│
└────────┬────────┘
│
┌──────────┴──────────┐
▼ ▼ ▼
[为空] [关键字匹配] [关键字不匹配]
│ │ │
│ 查找成功! 发生哈希冲突
│ │ │
▼ ▼ ▼
查找失败 返回记录信息 根据冲突解决策略
(对于开放定址法, (或指针) 寻找下一个可能位置
可能需继续探测) (回到"访问..."步骤循环)
您这段话完美地点明了哈希表之所以能实现 O(1) 平均时间复杂度查找的奥妙:通过一次函数计算直接定位,避免了大量的比较操作。其性能高度依赖于哈希函数的质量和冲突解决策略的效率。
解决冲突的方法
哈希函数产生了冲突的解决方法如下:
-
1.开放定址法: Hi=(H(key)+di) % m i=1, 2, ..., k(k<m一1) 其中,H(key)为哈希函数; m 为哈希表表长;di为增量序列。
常见的增量序列有以下3 种。
-
2.链地址法。它在查找表的每一个记录中增加一个链域,链域中存放下一个具有
相同哈希函数值的记录的存储地址。利用链域,就把若工个发生冲突的记录链
接在一个链表内。当链域的值为NULL 时,表示已没有后继记录了。因此,对于
发生冲突时的查找和插入操作就跟线性表一样了。
-
3.再哈希法: 在同义词发生地址冲突时计算另一个哈希函数地址,直到冲突不再
发生。这种方法不易产生聚集现象,但增加了计算时间。
-
4.建立一个公共溢出区。无论由哈希函数得到的哈希地址是什么,一旦发生冲突
都填入到公共溢出区中。
线性探测法详细介绍
一、基本概念
线性探测法 是开放定址法中最简单 的一种冲突解决策略。当发生哈希冲突时,它按照线性顺序探测哈希表中的下一个位置。
二、基本规则
1. 核心探测序列
当关键字K的哈希地址H(K)发生冲突时,线性探测法依次检查以下位置:
探测序列:H(K), H(K)+1, H(K)+2, ..., H(K)+i (mod m)
其中:
H(K)是初始哈希地址i是探测次数(从0开始)m是哈希表的大小mod m确保探测在表内循环
2. 数学表达式
第i次探测的地址为:
h_i(K) = [H(K) + i] mod m,其中 i = 0, 1, 2, ..., m-1
三、插入操作规则
1. 插入算法步骤
1. 计算初始地址:addr = H(key) mod m
2. 循环探测直到找到空位或已满:
while (table[addr] 不为空 AND table[addr] 不是已删除标记):
if (table[addr].key == key): // 关键字已存在
更新值或报错
return
addr = (addr + 1) mod m // 线性探测到下一位置
if (addr == H(key) mod m): // 已循环一圈
表满,需要扩容
return
3. 将记录插入 table[addr]
2. 插入示例
假设哈希表大小m=10,哈希函数H(key)=key mod 10
插入序列 :47, 67, 43, 87, 91
| 步骤 | 关键字 | H(key) | 冲突处理 | 最终位置 | 表状态 |
|---|---|---|---|---|---|
| 1 | 47 | 7 | 直接插入 | 7 | [...7:47] |
| 2 | 67 | 7 | 冲突,探测8 | 8 | [...7:47, 8:67] |
| 3 | 43 | 3 | 直接插入 | 3 | [...3:43, 7:47, 8:67] |
| 4 | 87 | 7 | 冲突,探测8,再冲突,探测9 | 9 | [...3:43, 7:47, 8:67, 9:87] |
| 5 | 91 | 1 | 直接插入 | 1 | [1:91, 3:43, 7:47, 8:67, 9:87] |
最终哈希表:
索引: 0 1 2 3 4 5 6 7 8 9
内容: [ ] [91][ ] [43][ ] [ ] [ ] [47][67][87]
四、查找操作规则
1. 查找算法步骤
1. 计算初始地址:addr = H(key) mod m
2. 起始地址保存:start = addr
3. 循环探测:
while (table[addr] 不为空):
if (table[addr] 是有效记录 AND table[addr].key == key):
查找成功,返回记录
addr = (addr + 1) mod m // 线性探测到下一位置
if (addr == start): // 已循环一圈
退出循环
4. 查找失败,返回空
2. 查找示例
继续使用上面的哈希表,查找关键字:
情况1:查找91(无冲突)
H(91) = 1 mod 10 = 1
table[1] = 91 → 匹配成功!
查找长度 = 1
情况2:查找87(有冲突)
H(87) = 7 mod 10 = 7
table[7] = 47 → 不匹配,探测下一个
table[8] = 67 → 不匹配,探测下一个
table[9] = 87 → 匹配成功!
查找长度 = 3
情况3:查找52(不存在)
H(52) = 2 mod 10 = 2
table[2] = 空 → 查找失败!
查找长度 = 1
五、删除操作的特殊处理
1. 删除的问题
如果简单地将元素置为空,会破坏查找链,导致后续元素无法被查找到。
2. 解决方案:使用删除标记
删除记录时,不真正清空位置,而是标记为"已删除"(DELETED)
3. 修改后的查找算法
while (table[addr] 不为空 AND table[addr] 不是删除标记):
// ...原有逻辑
// 如果遇到删除标记,需要继续探测
// 查找时需要跳过删除标记继续探测
// 插入时可以将记录插入到删除标记的位置
六、性能分析
1. 平均查找长度(ASL)
查找成功时的平均查找长度:
ASL_success ≈ (1 + 1/(1-α)) / 2
其中α = n/m(装填因子)
查找失败时的平均查找长度:
ASL_unsuccess ≈ (1 + 1/(1-α)²) / 2
2. 性能随装填因子的变化
| 装填因子α | 成功查找ASL | 失败查找ASL |
|---|---|---|
| 0.5 | 1.5 | 2.5 |
| 0.7 | 2.17 | 8.5 |
| 0.8 | 3.0 | 13.0 |
| 0.9 | 5.5 | 50.5 |
七、优缺点
优点:
- 实现简单:算法逻辑简单直观
- 空间局部性好:探测序列连续,缓存友好
- 无需额外空间:所有数据都在主数组中
缺点:
-
聚集现象(主要问题):
- 初级聚集:哈希到同一地址的记录形成长簇
- 次级聚集:不同哈希值的记录互相阻塞,形成更大的簇
-
删除复杂:需要特殊处理删除标记
-
性能下降快:当装填因子较高时,性能急剧下降
八、实际示例演示
完整示例:哈希表大小m=7,哈希函数H(key)=key mod 7
操作序列:
- 插入:8, 15, 22, 29, 36
- 查找:22, 30
- 删除:15
- 插入:43
详细过程:
初始:table[0..6] = [ ] [ ] [ ] [ ] [ ] [ ] [ ]
1. 插入8:H(8)=1 → table[1]=8
[ ] [8] [ ] [ ] [ ] [ ] [ ]
2. 插入15:H(15)=1 → 冲突!探测2 → table[2]=15
[ ] [8] [15] [ ] [ ] [ ] [ ]
3. 插入22:H(22)=1 → 冲突!探测2 → 冲突!探测3 → table[3]=22
[ ] [8] [15] [22] [ ] [ ] [ ]
4. 插入29:H(29)=1 → 探测2,3,4 → table[4]=29
[ ] [8] [15] [22] [29] [ ] [ ]
5. 插入36:H(36)=1 → 探测2,3,4,5 → table[5]=36
[ ] [8] [15] [22] [29] [36] [ ]
6. 查找22:
H(22)=1 → table[1]=8≠22
探测2 → table[2]=15≠22
探测3 → table[3]=22 ✓ 找到!查找长度=3
7. 查找30:
H(30)=2 → table[2]=15≠30
探测3 → table[3]=22≠30
探测4 → table[4]=29≠30
探测5 → table[5]=36≠30
探测6 → table[6]=空 ✗ 失败!查找长度=5
8. 删除15:
H(15)=1 → 探测到table[2]=15 → 标记为DELETED
[ ] [8] [DEL] [22] [29] [36] [ ]
9. 插入43:
H(43)=1 → table[1]=8≠空
探测2 → table[2]=DELETED(可插入!)→ table[2]=43
[ ] [8] [43] [22] [29] [36] [ ]
九、实践建议
- 控制装填因子:通常建议α < 0.7,超过时应考虑扩容
- 表大小选择:最好选择质数作为表大小,减少聚集
- 监控性能:当平均查找长度显著增加时,考虑优化或扩容
- 替代方案考虑:对于高装填因子的场景,可以考虑二次探测或双散列法
线性探测法以其简单性在装填因子较低的场景下表现良好,但在高负载时性能下降明显,这是由其固有的聚集特性决定的。