跳到主要内容

4.3 非数值计算

知识点​

1.什么是"非数值计算"​

非数值计算:处理对象不是数值(或主要不是数值运算),而是查找、排序、比对、匹配、路径搜索、逻辑推理等问题。

数值计算非数值计算
处理对象数数据元素(记录、字符串、结点)
核心操作算术运算比较与移动
典型问题求根、求和、求 π查找、排序、字符串处理、汉诺塔
结果一个数值位置、顺序、新序列、步骤

为什么重要:现实中大部分数据处理都是非数值的——在 10 万条记录里找一个人、把成绩从高到低排、判断两个文本是否相同。

本小节的四类问题:

  1. 查找——东西在哪?
  2. 排序——怎么排队?
  3. 字符串处理——文字怎么比对与变换?
  4. 递归类——汉诺塔怎么搬?

2.查找:顺序查找​

思路:从第一个元素开始,逐个比较,找到就返回位置,找完没有就报告"不存在"。

# 4-10 顺序查找
def seq_search(a, key):
for i in range(len(a)):
if a[i] == key:
return i # 返回下标
return -1 # 未找到

效率:

  • 最好情况:第 1 个就是 → 1 次比较;
  • 最坏情况:最后 1 个或不存在 → n 次;
  • 平均:约 n/2 次;
  • 时间复杂度:O(n)。

优点:对数据没有要求(无序也能查),实现简单; 缺点:数据量大时慢。

Python 里的等价写法:key in a(判断存在)、a.index(key)(取下标)。

3.查找:二分查找(折半查找)​

(1)前提与思路​

前提(必须满足):数据已经按顺序排好(有序)。

思路:每次取中间元素与目标比较:

  • 相等 → 找到;
  • 目标小于中间值 → 只在左半部分继续找;
  • 目标大于中间值 → 只在右半部分继续找。

每比较一次,查找范围减半——这是它快的根本原因。

(2)代码实现​

# 4-11 二分查找(a 必须已升序排列)
def binary_search(a, key):
low, high = 0, len(a) - 1
while low <= high:
mid = (low + high) // 2
if a[mid] == key:
return mid # 找到
elif a[mid] < key:
low = mid + 1 # 目标在右半区
else:
high = mid - 1 # 目标在左半区
return -1 # 未找到

三个易错点:

  1. 循环条件是 low <= high(不是 <),否则会漏掉最后一个元素;
  2. mid 用整除 //,否则得到小数下标;
  3. low = mid + 1、high = mid - 1,不能写成 mid(会死循环)。

(3)效率分析(必考)​

查找过程示例:在 11 个有序数中找某个值,最多比较 4 次(11 → 5 → 2 → 1)。

数据规模 n顺序查找最多比较二分查找最多比较
10104
1001007
1 0001 00010
1 000 0001 000 00020

规律:二分查找的比较次数 ≈ log₂n,时间复杂度 O(log n)。

结论(答题落点):数据量大且有序时,二分查找远优于顺序查找;但二分查找要求先排序,如果只查一次,排序的成本可能超过查找节省的时间。

生活类比:查字典——不会从第一页翻,而是先翻中间,看拼音在哪一半,再继续折半。

4.排序(三种基本排序,必会)​

排序:把一组数据按关键字的升序或降序重新排列。

比较维度:比较次数、交换次数、是否稳定、时间复杂度。

(1)冒泡排序(Bubble Sort)​

思想:相邻两个比较,逆序就交换。每一轮走完,最大的元素"冒"到末尾。

# 4-12 冒泡排序(升序)
def bubble_sort(a):
n = len(a)
for i in range(n - 1): # 共 n-1 轮
swapped = False
for j in range(n - 1 - i): # 每轮比较范围逐渐缩小
if a[j] > a[j + 1]: # 逆序则交换
a[j], a[j + 1] = a[j + 1], a[j]
swapped = True
if not swapped: # 本轮无交换 → 已有序,提前结束
break
return a

要点:

  • 外层控制轮数(n-1 轮),内层控制比较(n-1-i 次,每轮少一个);
  • swapped 优化:某一轮没发生交换说明已经有序,可提前结束;
  • 时间复杂度 O(n²),最好情况(已有序)O(n);
  • 空间 O(1)(原地排序)。

手算示例:[5, 3, 8, 1]

第 1 轮:3 5 1 8 (8 冒到最后)
第 2 轮:3 1 5 8 (5 到位)
第 3 轮:1 3 5 8 (3 到位,完成)

(2)选择排序(Selection Sort)​

思想:每一轮在未排序部分找出最小(或最大) 的元素,与未排序部分的第一个位置交换。

# 4-13 选择排序(升序)
def selection_sort(a):
n = len(a)
for i in range(n - 1):
min_idx = i # 假设当前位置最小
for j in range(i + 1, n):
if a[j] < a[min_idx]:
min_idx = j # 记录更小的位置
if min_idx != i: # 交换到前面
a[i], a[min_idx] = a[min_idx], a[i]
return a

特点:

  • 每轮只交换一次(最多 n-1 次交换),交换次数最少;
  • 比较次数固定为 n(n-1)/2,与初始顺序无关(不能提前结束);
  • 时间复杂度 O(n²)。

与冒泡的核心差别:冒泡"边比边换",选择"先找最小再换一次"。

(3)插入排序(Insertion Sort)​

思想:像整理扑克牌——把每个新元素插入到前面已排好序的部分中的正确位置。

# 4-14 插入排序(升序)
def insertion_sort(a):
for i in range(1, len(a)):
key = a[i] # 待插入的元素
j = i - 1
while j >= 0 and a[j] > key: # 比 key 大的往后挪
a[j + 1] = a[j]
j -= 1
a[j + 1] = key # 插入正确位置
return a

特点:

  • 对基本有序的数据效率很高(接近 O(n));
  • 稳定排序;
  • 时间复杂度 O(n²),最好 O(n)。

(4)三种排序对比表(必背)​

对比项冒泡排序选择排序插入排序
核心动作相邻比较,逆序交换每轮选最小,放到前面取一个,插入已排好的部分
比较次数约 n²/2(可提前结束)固定 n(n-1)/2约 n²/4(有序时 n-1)
交换次数多最少中等(移动多)
能否提前结束能(无交换即有序)不能部分能(已就位即停)
时间复杂度O(n²),最好 O(n)O(n²),始终O(n²),最好 O(n)
稳定性稳定不稳定(长距离交换)稳定
适用场景教学演示、小数据交换成本高的场合数据基本有序

记忆口诀:冒泡"换相邻"、选择"选最小"、插入"打扑克"。

重要提醒:这三种排序都是 O(n²);实际工程中用快速排序/归并排序(O(n log n)) 或直接用 sorted()。

5.字符串处理(非数值计算的另一大类)​

(1)回文判断​

回文:正着读和倒着读相同的字符串(如 "level"、"上海自来水来自海上")。

# 4-15 判断回文(两种写法)
def is_palindrome1(s):
return s == s[::-1] # 切片反转后比较,最简洁

def is_palindrome2(s):
i, j = 0, len(s) - 1
while i < j: # 首尾向中间夹逼
if s[i] != s[j]:
return False
i += 1
j -= 1
return True

两种思路对比:

  • 切片法:代码最短,但额外占 O(n) 空间(生成了新字符串);
  • 双指针法:不用额外空间(O(1)),比较次数最多 n/2。

若要忽略大小写与空格:先 s = s.lower().replace(" ", "") 再判断。

(2)字符统计​

# 4-16 统计字符串中各类字符的个数
s = input("请输入一行字符:")
letters = digits = spaces = others = 0
for ch in s:
if ch.isalpha():
letters += 1
elif ch.isdigit():
digits += 1
elif ch.isspace():
spaces += 1
else:
others += 1
print(f"字母{letters} 数字{digits} 空格{spaces} 其他{others}")

要点:初始化四个计数器 → 遍历一次 → 用 if-elif 分类累加。这是"计数模式"的典型应用。

(3)大小写转换与反转​

s = "Hello World"
print(s.upper()) # HELLO WORLD
print(s.lower()) # hello world
print(s.swapcase()) # hELLO wORLD
print(s[::-1]) # dlroW olleH(反转)

注意:字符串不可变,这些方法都返回新字符串,必须赋值接收。

(4)子串统计与替换​

text = "abcabcabc"
print(text.count("abc")) # 3(出现次数)
print(text.find("b")) # 1(首次出现的下标)
print(text.replace("a", "X")) # XbcXbcXbc
print("abc" in text) # True(成员判断)

易错:find 找不到返回 -1(不是 0,也不是报错);而 index 找不到会报错。

(5)编码转换类问题(与 3.4 呼应)​

# 统计字母出现频率(可用于破解替换密码)
freq = {}
for ch in "hello":
freq[ch] = freq.get(ch, 0) + 1
print(freq) # {'h': 1, 'e': 1, 'l': 2, 'o': 1}

要点:freq.get(ch, 0) + 1 是"字典计数"的标准写法——键不存在时取 0,避免 KeyError。

6.递归经典:汉诺塔​

(1)问题描述​

有 A、B、C 三根柱子,A 上有 n 个大小不同的圆盘(小在上)。要求把全部圆盘移到 C,规则:

  1. 每次只能移动一个圆盘;
  2. 大盘不能压在小盘上。

(2)递归思路(三步分解)​

要把 n 个盘从 A 移到 C(借助 B):

  1. 先把上面 n-1 个盘从 A 移到 B(借助 C);
  2. 把最大的第 n 个盘从 A 移到 C(一步);
  3. 再把 n-1 个盘从 B 移到 C(借助 A)。

终止条件:n == 1 时,直接从 A 移到 C。

(3)代码​

# 4-17 汉诺塔
def hanoi(n, a, b, c): # n 个盘从 a 借助 b 移到 c
if n == 1:
print(f"{a} -> {c}") # 只剩一个,直接搬
else:
hanoi(n - 1, a, c, b) # ① n-1 个从 a 借助 c 移到 b
print(f"{a} -> {c}") # ② 最大的从 a 移到 c
hanoi(n - 1, b, a, c) # ③ n-1 个从 b 借助 a 移到 c

hanoi(3, "A", "B", "C")

3 个盘的输出(7 步):

A -> C
A -> B
C -> B
A -> C
B -> A
B -> C
A -> C

(4)复杂度:为什么 n=64 搬不完​

移动次数递推式:T(n) = 2T(n-1) + 1,解得 T(n) = 2ⁿ - 1。

n移动次数
37
101023
201 048 575
64约 1.8 × 10¹⁹

传说:僧侣搬 64 个盘,每秒一步不停,需要约 5845 亿年——远超宇宙年龄。这是"指数级增长"最著名的例子。

教学价值:汉诺塔完美体现"分解"(把 n 化成 n-1)与"递归"(自己调用自己),用循环极难写清,用递归只需 6 行。

7.综合效率对比表(必背)​

算法时间复杂度空间复杂度前提/特点
顺序查找O(n)O(1)无需有序
二分查找O(log n)O(1)必须有序
冒泡排序O(n²)O(1)稳定,可提前结束
选择排序O(n²)O(1)交换次数最少,不稳定
插入排序O(n²),最好 O(n)O(1)稳定,适合基本有序
汉诺塔O(2ⁿ)O(n)(递归深度)指数级,规模稍大即不可行

一句话总结:"查找看有没有序,排序看数据乱不乱,递归看规模涨得多快"。

8.易错点清单​

序号易错点说明正确做法
1对无序数据用二分查找结果可能错误先排序,或改用顺序查找
2二分查找 while low < high漏掉最后一个元素low <= high
3low = mid(不加 1)死循环low = mid + 1
4冒泡内层 range(n-1) 每轮不变多做了无效比较range(n - 1 - i)
5选择排序以为能提前结束比较次数固定它不能提前结束
6认为三种排序都是 O(n log n)都是 O(n²)快排/归并才是 O(n log n)
7字符串 replace 不赋值原串不可变,没变化s = s.replace(...)
8find 找不到以为返回 0返回 -1用 if idx != -1 判断
9字典计数直接 d[ch] += 1首次会 KeyError用 d.get(ch, 0) + 1
10汉诺塔递归参数顺序写错结果错记住"借助谁、移到谁"三个参数的含义
11回文判断忘记统一大小写大小写不同判成 False先 lower()
12交换写成 a = b; b = a两个变量变成同值a, b = b, a 或借助临时变量

9.速查表​

序号考点结论
1非数值计算的核心操作比较与移动
2顺序查找复杂度O(n)
3顺序查找前提无(无序也可以)
4二分查找前提数据有序
5二分查找复杂度O(log n)
6二分循环条件low <= high
7二分 mid 计算(low + high) // 2
8二分更新low = mid + 1/high = mid - 1
9冒泡排序思想相邻比较,逆序交换
10冒泡每轮效果最大(小)值到位
11冒泡优化swapped 标志提前结束
12选择排序思想每轮选最小,交换一次
13选择排序比较次数固定 n(n-1)/2
14插入排序思想打扑克,插入已排好的部分
15插入排序优势基本有序时接近 O(n)
16三种排序复杂度都是 O(n²)
17交换两个变量a, b = b, a
18回文最简写法s == s[::-1]
19双指针法空间O(1)
20find 找不到返回 -1
21字典计数写法d.get(ch, 0) + 1
22汉诺塔递推式T(n) = 2ⁿ − 1
23汉诺塔复杂度O(2ⁿ)(指数级)
24汉诺塔递归三步骤移 n−1 到辅助柱 → 移最大盘 → 移 n−1 到目标柱

10.课堂提示(讲课参考)​

  • 二分查找用"猜数字"复现:1~100 猜数字,让学生体会"每次砍一半"的威力,再引出 O(log n)。
  • 三种排序用同一组数据手算:[5, 3, 8, 1, 9, 2],让学生用三种方法各排一遍,比较次数写在黑板上对比,比看代码直观。
  • 冒泡用"气泡上浮"动画、选择用"选班长"、插入用"整理扑克"——三个生活比喻分别对应三种排序,学生不易混。
  • 汉诺塔先让学生用手搬 3 个盘,记录步数 7 步,再问"4 个盘要几步"(15 步),最后推出 2ⁿ−1,指数增长的震撼比公式更有说服力。
  • 二分查找的边界 bug 让学生踩一次(写 low < high 或 low = mid),在 10 个元素的数组上找最后一个元素会失败——错误记忆最牢。