1.1 为什么练内功:数据结构与算法在解决什么问题


1.1 为什么练内功:数据结构与算法在解决什么问题

本节摘要:数据结构决定"数据怎么摆",算法决定"数据怎么处理",两者共同决定每个基本操作要付多少内力。本节用两个可以亲手复现的对照实验说明:同样任务换个组织方式,操作次数可以相差千倍;并给出全书统一的"招式—心法—走火入魔"三层读法。

从一场惨败说起

一位做日志审计的朋友接到需求:黑名单里有十万条 IP,每来一条访问记录就查一次是否命中,一天要查百万次。他的直觉写法是把黑名单放进列表,每条记录来了就在列表里从头扫到尾。上线之后服务迟迟响应不过来,CPU 被打满。整改只改了一行:把列表换成集合。操作次数从"每次最多扫十万次"降到"每次近似一次",服务立刻恢复轻快。

同样的查询任务,同样的机器,只换了数据的组织方式,代价相差五个数量级——这就是数据结构在解决的问题:数据的摆法决定了每个操作的底价。算法则决定处理流程本身要付多少步。两者合起来,才是"编程内功"。

这场惨败可以在你的电脑上复现。下面用计数器代替掐表,数一数"查一条记录到底比较了多少次":

# 实验:列表扫描与哈希集合的查找代价对照(数操作次数,不掐表) import random random.seed(7) n = 10_000 # 黑名单规模(缩小版,便于复现) blacklist = [random.randint(1, 1_000_000) for _ in range(n)] probe = random.choice(blacklist) # 一定命中的目标 # 写法一:列表扫描,逐个比较 steps = 0 for ip in blacklist: steps += 1 if ip == probe: break print("列表命中:比较次数 =", steps) # 输出:列表命中:比较次数 = 6144(命中位置由随机种子决定,此处约为列表长度的六成) # 写法二:哈希集合,先建后查 build_steps = 0 lookup = set() for ip in blacklist: build_steps += 1 # 每条做一次哈希定位后放入 lookup.add(ip) hit = probe in lookup # 哈希查找:近似一次哈希定位 print("集合建表:操作次数 =", build_steps, ";集合命中:操作次数 ≈ 1") # 典型输出:集合建表:操作次数 = 10000 ;集合命中:操作次数 ≈ 1

一万条黑名单,列表平均要比较约五千次,集合只做一次定位;规模翻倍,列表的比较次数跟着翻倍,集合依然是一次。趋势上的差距,比固定常数重要得多——这正是下一节复杂度分析要量化的东西。

数据结构到底"结构"了什么

剥掉术语,数据结构只回答一个问题:数据元素之间是什么关系,这种关系让哪些操作变便宜、让哪些操作变贵

你的需求 便宜的结构 代价在哪里
按位置快速取第 i 个元素 数组:一步直达 中间插入要整体挪动
频繁在两头增删 链表、双端队列 按位置找元素要顺链走
后来者先处理(撤销、配对) 想取中间元素很难
先来者先处理(排队、消息) 队列 同上
按关键字秒查 哈希表 最坏情况退化、占内存
有序地查、按范围查 平衡树、跳表 维护平衡有额外开销
关系网络、路径规划 遍历与最短路都是专门功课

算法则是处理数据的"套路":同样是把一堆数变有序,冒泡、快排、堆排的步数差出数量级;同样是找最优解,穷举与动态规划的差距可以是"算到宇宙尽头"与"毫秒出解"。

全书的读法:招式、心法、走火入魔

从下一节起,每引入一门结构,都按三层拆解:

  • 招式:它对外提供哪些操作、接口长什么样。招式是显性的,看文档就会;
  • 心法:结构内部始终成立的不变量,以及由不变量推出的复杂度。比如二叉搜索树的心法是"左小右大",它保证查找路径沿高度下降;哈希表的心法是"键定位只取决于哈希值",它保证不冲突时一步命中。心法是威力的来源,也是推导复杂度的依据;
  • 走火入魔:违反心法前提的典型误用。比如对有序输入用首元素做轴的快排、拿可变对象当哈希键。每个走火入魔场景都附可复现的症状与解法。

只背招式的人,题面一变就束手无策;懂心法的人能推出"为什么必须这样";见过走火入魔现场的人,才敢说自己在生产环境用过它。

第二个实验:摆法不同,重复劳动差多少

再看一个更能体现"算法"价值的实验:数组给定,要反复询问"区间 [l, r] 内的和"。摆法一:每次询问现场累加;摆法二:先花一次预处理建前缀和,之后每次询问做一次减法。

# 实验:现场累加 vs 前缀和,数一数加法各做多少次 data = [3, 1, 4, 1, 5, 9, 2, 6] queries = [(0, 3), (2, 5), (1, 7), (0, 7), (4, 4)] # 5 次询问 # 写法一:每次现场累加 total_adds = 0 for l, r in queries: s = 0 for x in data[l:r+1]: s += x total_adds += 1 print("现场累加:加法次数 =", total_adds) # 输出:现场累加:加法次数 = 24(4 + 4 + 7 + 8 + 1 次,逐次可验证) # 写法二:前缀和,先建表再查询 prefix = [0] for x in data: prefix.append(prefix[-1] + x) # n 次加法建表 q_adds = 0 for l, r in queries: _ = prefix[r+1] - prefix[l] # 每次询问 0 次加法、1 次减法 q_adds += 1 print("前缀和:建表加法 =", len(data), ";查询减法 =", q_adds) # 输出:前缀和:建表加法 = 8 ;查询减法 = 5

规模小的时候两者差距不明显;一旦数组百万、询问十万次,写法一要做上百亿次加法,写法二始终是"百万加十万"量级。预处理换查询是算法设计里最常用的心法之一,第七章的树状数组与线段树会把它推广到"边改边查"的场景。

走火入魔:两种典型的心态事故

**走火入魔之一:背招式不修心法。**把每种结构的操作表背得滚瓜烂熟,一旦被问"为什么哈希表不能保证顺序""为什么快排会退化"就哑火。检验办法很简单:能不能对任意一段代码数出操作次数并说出增长趋势。数不出来,说明内力还没练到。

**走火入魔之二:过早优化。**反过来,有人学会复杂度后看不上一切简单写法,几十条数据的配置脚本也要硬上红黑树。复杂度刻画的是趋势,小规模下常数与可读性常常更重要。工程上的判断顺序应当是:先测、再数、后优化;数据规模撑不起优化收益时,选最直白的写法。

本节要点回顾

  • 数据的摆法决定操作的底价:同一查询任务,列表与集合的操作次数可差五个数量级,且差距随规模放大;
  • 数据结构 = 关系 + 操作 + 代价,选型时先问"哪个操作最高频",再看该操作在候选结构里的价格;
  • 算法是处理数据的套路,同一目标不同套路的步数差距同样巨大,前缀和实验就是"预处理换查询"的最小样例;
  • 三层读法贯穿全书:招式(会用)、心法(不变量与复杂度)、走火入魔(误用与修复);
  • 内力测量不靠掐表:数操作次数、看增长趋势,这是下一节复杂度分析的主题。

下一节就把"数操作"变成一门严谨的度量术:大 O、大 Ω、大 Θ。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U