Python集合与字典:从哈希原理到去重与查询实战

发布时间:2026/10/16 18:18:33

Python集合与字典:从哈希原理到去重与查询实战 1. 集合与字典为什么学完列表还会遇到这两个新容器很多刚开始学Python的朋友在掌握列表和元组之后会很快碰到一道坎乱序、去重、建索引、按照某个key查值——这些需求用列表写起来总是差点意思。这时候就该请出Python里真正靠哈希吃饭的两个容器了集合set和字典dict。先说结论集合的本质是一个装着不重复元素的无序袋子字典的本质是一张键值对应表。前者解决的是去重与快速判断某样东西是否存在后者解决的是按名字找东西而不是按位置数格子。作为day05的下半场这一篇把两者一次性讲明白从创建、增删改查到去重顺序变化、键不可变、嵌套结构以及性能对比全都会覆盖。适合刚学完列表、正在做基础练习的初学者也适合想把自己对Python容器的理解再理一遍的读者。我写这篇内容的思路是每一个知识点都配上为什么。学容器不能光记方法名你得能回答三件事——这个容器解决什么问题、它内部怎么工作、什么时候该用它而不是用别的容器。只要把这三个问题吃透后面的爬虫解析、数据处理、接口开发才不会看着字典发懵。2. 集合容器的整体设计与核心操作2.1 无序、唯一、可变的不重复袋子集合最容易理解也最容易被低估。它的定义只有三句话无序、元素唯一、元素必须可哈希。无序是什么意思就是你在集合里存了三个值遍历时不保证按插入顺序输出。唯一是什么意思重复元素不会保留第二份这个特性直接催生了最高频的用法去重。可哈希是什么意思就是集合只能存数字、字符串、元组这类不可变对象不能存列表、字典。创建方式很简单直接看代码# 方式一直接用花括号 s1 {3, 1, 2, 2, 3} print(s1) # 输出{1, 2, 3}重复的被自动丢弃 # 方式二用set()构造函数可接收列表、元组、字符串 s2 set([1, 2, 3, 4, 4, 5]) print(s2) # 输出{1, 2, 3, 4, 5} s3 set(hello) print(s3) # 输出{h, e, l, o}注意l只保留一份这里有一个新手必踩的坑空集合不能写成{}因为Python把{}留给了空字典。想要空集合必须用set()。我课堂上经常看到有人写s {}然后调用add方法报错一看异常信息才发现是个字典。基本操作也很有规律s {1, 2, 3} s.add(4) # 添加元素 s.discard(10) # 删除元素不存在不报错 s.remove(2) # 删除元素不存在会报KeyError s.pop() # 随机弹出一个元素因为无序弹出的位置不固定 s.clear() # 清空集合从实际教学和项目经验来看remove和discard的取舍最常被忽略。当你删除一个不确定是否存在的元素时如果希望代码继续跑下去就用discard如果希望快速发现逻辑错误就用remove。这个习惯直接决定了你的脚本是天崩地裂地报错还是安静地吞掉问题。2.2 交并差运算集合的真正杀手锏单独用集合当不重复列表多少有点大材小用。它真正的威力在于数学集合运算这几个运算符在日常数据处理里几乎天天遇到a {1, 2, 3, 4} b {3, 4, 5, 6} # 交集两个集合都有的元素 print(a b) # {3, 4} print(a.intersection(b)) # 并集合并所有元素自动去重 print(a | b) # {1, 2, 3, 4, 5, 6} print(a.union(b)) # 差集a里有、b里没有 print(a - b) # {1, 2} print(a.difference(b)) # 对称差集只出现在一边的元素 print(a ^ b) # {1, 2, 5, 6} print(a.symmetric_difference(b))你可能会问这些运算除了做题还有什么用我举几个非常实际的场景第一统计两个订单列表中有哪些相同的客户。把客户ID分别转成集合再取交集即可代码只有一行比嵌套for循环快了不止一个数量级。第二统计日志中的异常IP。把昨天和今天的IP集合做差集就能快速得到新增的IP或消失的IP。第三检查两批配置项是否一致。对称差集不为空说明两边配置有差异直接打印差异项。还有一个类比能帮你记住区别和|就像你我都认识的人与咱们认识的所有人-是只属于我的^则是咱俩谁也不重复的那部分社交圈。这么一想运算符的记忆负担就小很多。2.3 集合推导式与去重的顺序陷阱集合同样支持推导式这和列表推导式长得几乎一样只是外层括号换成了花括号# 生成1到10中偶数的平方集合 s {x**2 for x in range(1, 11) if x % 2 0} print(s) # {64, 4, 36, 16, 100} # 去重并清洗数据 words [apple, banana, apple, cherry, banana] unique_words {w.lower() for w in words} print(unique_words) # {cherry, banana, apple}这里必须提醒大家一件事使用集合去重后原列表的顺序大概率不会保留。原因是集合内部基于哈希表组织数据存储位置由哈希值决定和元素先后顺序无关。字符串哈希值的计算结果看起来就是乱序的。我踩过的坑是这样的早期写爬虫去重URL用set存了所有需要请求的URL结果运行日志里请求顺序肉眼可见地乱了导致某个依赖顺序访问的接口统计出了偏差。当时的解决方案是改用字典的key来去重因为Python 3.7之后字典保证插入顺序具体见下一节这样既去重又保序。如果你必须在去重后保持原顺序还有一种经典方案用列表推导遍历原列表同时用集合记录是否已经出现过如下items [3, 1, 3, 2, 1, 5] seen set() result [] for item in items: if item not in seen: seen.add(item) result.append(item) print(result) # [3, 1, 2, 5]顺序保持了这种方式在很多数据清洗脚本里非常常用值得记下来。3. 字典容器键值对应的查询表思想3.1 字典的创建方式与访问逻辑如果只能保留一个Python容器我会毫不犹豫选择字典。因为它把数据是什么和数据叫什么名字绑在了一起读写代码的直观程度远超列表。字典的创建方式非常灵活# 最常用的字面量方式 student {name: Alice, age: 20, major: Computer Science} # dict() 关键字参数注意键只能是字符串 student dict(nameAlice, age20, majorComputer Science) # 从键值对列表转换 pairs [(name, Alice), (age, 20)] student dict(pairs) # 使用zip合并两个列表 keys [name, age] values [Alice, 20] student dict(zip(keys, values))获取值的核心是方括号加键。但新手会在这里遇到一个让人困惑的报错KeyError。比如你访问student[score]键不存在程序直接崩溃。解决方式有两个# 方式一get方法找不到返回None或返回指定默认值 score student.get(score) print(score) # None score student.get(score, 0) print(score) # 0 # 方式二先判断键是否存在 if score in student: print(student[score])两个方式的区别不只是写法不同。get的语义是让我试着查一下查不到就给个兜底值in判断的语义是我明确想知道这个字典里有没有这个键。实际操作中统计词频、计数、缓存场景几乎离不开get因为你能省掉一整个if判断text apple banana apple cherry apple banana counter {} for word in text.split(): counter[word] counter.get(word, 0) 1 print(counter) # {apple: 3, banana: 2, cherry: 1}这段代码已经是面试题级别的经典写法一个get把查旧值补默认值写回新值压缩到了一行。后面讲词频统计时还会再展开。3.2 键的唯一性、不可变性与哈希底层字典的核心规则只有一条键必须唯一键必须是不可变类型。这两点都源于字典的底层实现——哈希表。当你往字典里存一个键值对时Python会先对键做哈希运算得到一串数值并依据这个数值决定数据存放的位置。查找时同样先哈希再定位因此无论字典里有多少条记录一次查找的时间都是O(1)和字典规模无关。这也是为什么前面说按key找值比在列表里一个一个比对快得多。但哈希有个前提键的值在存储后绝对不能变。如果键是列表某次运行中列表的元素被改了那它的哈希值随之改变后继查找就永远找不到原来的位置了。所以Python规定列表、字典、集合这些可变对象不能当字典的键。什么能当键字符串、数字、元组含内部不可变元组。实际工程里字符串当键占了绝大多数。关于唯一性还有个小坑如果重复给同一个键赋值旧值会被静默覆盖不报错。写配置合并代码时如果不小心用了同名键最终值会悄悄变成后写入的那一个。排查起来往往很隐蔽建议在写入前先打印一下或者用collections模块里的工具来管理重复情况。3.3 字典的增删改查与遍历姿势字典的操作口诀是查用方括号或get增就是直接赋值改也是直接赋新值删用pop或del。student {name: Alice, age: 20} # 增 student[score] 90 # 改 student[age] 21 # 删 age student.pop(age) # 返回被删的值第二个参数可以传默认值 del student[score] # 不返回任何值键不存在会报错 # 清空 student.clear()遍历则有三种姿态需要根据目标选择student {name: Alice, age: 20, major: CS} # 只遍历键 for k in student: print(k) # 同时遍历键和值items()最常用 for k, v in student.items(): print(f{k}: {v}) # 只遍历值 for v in student.values(): print(v) # 只遍历键但显式写出 for k in student.keys(): print(k)如果你需要在遍历字典的同时修改它直接在上面循环里删除元素运行时会报RuntimeError。安全做法是先把要删的键收集到列表里循环结束后再删除或者用字典推导式生成一个新字典。这个点下面单独说因为它是面试问答里的高频考点。3.4 字典推导式与键值翻转花括号推导式对字典同样适用格式稍微多一个冒号# 生成数字平方表 squares {x: x**2 for x in range(1, 6)} print(squares) # {1: 1, 2: 4, 3: 9, 4: 16, 5: 25} # 过滤后再生成 fruits {apple: 5, banana: 3, cherry: 8} expensive {name: count for name, count in fruits.items() if count 5} print(expensive) # {apple: 5, cherry: 8}最常见的实用技巧是键值翻转。当你拿到一个名字映射id的字典之后却需要id反查名字直接用推导式一气呵成id_map {Alice: 1001, Bob: 1002} name_map {v: k for k, v in id_map.items()} print(name_map) # {1001: Alice, 1002: Bob}不过翻转之前要确认原字典的值是唯一且不可变的。如果两个不同键对应同一个值翻转后就会丢失其中一条信息因为它俩会占用同一个键。4. 集合与字典的联合实操三个典型场景4.1 场景一用集合字典完成词频统计与去重这个场景几乎每个Python学习者都会遇到也是我认为最该先动手练的综合题。目标给定一段英文文本统计每个单词出现次数同时列出所有出现过的不同单词。我的实现思路是这样from collections import Counter text the quick brown fox jumps over the lazy dog and the quick dog # 方案一纯手写适合理解原理 words text.split() freq {} for word in words: freq[word] freq.get(word, 0) 1 print(freq) # 方案二集合去重后再计算次数 unique_words set(words) unique_freq {word: words.count(word) for word in unique_words} print(unique_freq) # 方案三直接用Counter工程上最推荐 freq_counter Counter(words) print(freq_counter.most_common(2)) # 出现次数最多的两个单词方案一最直观方案二能同时练到集合和字典推导式方案三则是线上代码会用的写法。方案二有个性能隐患words.count()每次都要遍历整个列表如果单词很多整体会偏慢。相比之下方案一的自增逻辑只是扫描一遍是更优的做法。这里对你的建议是三个方案都亲手敲一遍重点体会方案一为什么比方案二高效这也是一种算法意识训练。4.2 场景二用嵌套字典构建用户信息表字典的值可以是任意类型也就是说字典里可以再放字典。这种嵌套结构在真实项目里非常常见比如一个接口返回的用户数据可能就是多层嵌套。假设我们要维护一个极简用户系统users { u001: {name: Alice, age: 20, tags: {python, java}}, u002: {name: Bob, age: 22, tags: {go, python}}, } # 查询某个用户的年龄 print(users[u001][age]) # 20 # 判断某个用户是否拥有java技能 print(java in users[u001][tags]) # True # 新增一个用户 users[u003] {name: Cathy, age: 21, tags: set()} users[u003][tags].add(sql) # 统计会用python的人数 python_users [] for uid, info in users.items(): if python in info[tags]: python_users.append(uid) print(python_users) # [u001, u002]这里最容易出的三个问题已经看得很清楚。第一个直接拿users[u001]之前最好先判断u001是否存在否则会KeyError。多用户系统里一个id不存在是常态最好用get加上默认值。第二个tags如果是空的时候想给新用户添加技能必须先初始化成set()否则None.add()直接报AttributeError。有人习惯写成tagsNone再后面加那样就掉坑里了。第三个遍历修改users结构时比如在循环里删除用户记得先记录要删的id否则会在迭代过程中报错或跳过元素。4.3 场景三购物车价格汇总与库存校验最后一个场景用字典来模拟电商购物车结算。核心需求有三个汇总总价、检查库存、去除重复商品并合并数量。cart {apple: 3, banana: 2, cherry: 1} prices {apple: 5, banana: 3, cherry: 10, durian: 20} stock {apple: 10, banana: 5, cherry: 0} # 结算总价 total 0 error_items [] for name, count in cart.items(): if name not in prices: error_items.append(f{name}不存在于价格表) continue if name in stock and stock[name] count: error_items.append(f{name}库存不足) continue total prices[name] * count print(f总价: {total}) print(问题商品:, error_items if error_items else 无)这段代码把几个核心点串起来了遍历字典items、用in判断键是否存在、按条件跳过、累加价格。有些情况下你还要考虑购物车里同一商品出现两次这时候最好在加入购物车时就做合并或者结算前先手动合并逻辑类似词频统计。if name not in prices这种检查非常值得保留真实接口里数据缺失和脏数据实在太常见了宁可多写一个判断不要等到最后price[name]那一刻再被KeyError打断。5. 常见问题与排查技巧集合与字典的实战避坑5.1 集合去重后顺序变乱怎么办前面提到过这个坑在批量URL去重、去重后还要保持原始展示顺序的场景里反复出现。最稳妥的解法是列表集合双结构顺序靠列表保存查重靠集合作O(1)判断。另一个思路是改用dict.fromkeys()因为字典的key天然去重且Python 3.7的字典保持插入顺序data [3, 1, 4, 1, 5, 9, 2, 6, 5] deduped list(dict.fromkeys(data)) print(deduped) # [3, 1, 4, 5, 9, 2, 6]这个写法看起来有点trick但确实项目中经常见到。如果你很在意代码可读性就写显式的for循环双结构版本两种选一种即可。5.2 字典键总被判定为不存在的排查有一种看起来很诡异的情况明明字典里打印显示有某个键但就是用key in dict判断返回False。绝大多数原因可以归为两类第一键类型不一致。比如字典里存的是整数1你在外部拿到的是字符串1判断时自然为False。解决方法是统一类型int和str不要混用。日志场景里从文件读出来的id往往是字符串从接口拿到的是整数不做转换就会出现这个问题。第二肉眼不可见字符。键名里包含了空格、换行或\u200b之类的零宽字符打印出来与预期不同。排查方法很简单print(repr(dict.keys()))把键的原样表示打出来眼见的假象就消失了。我排查过一次长达一小时的问题最后发现是URL解析时多了个末尾空格repr一眼暴露。5.3 可变对象导致集合操作报错集合添加列表、字典里用列表当key都会遇到同一类异常TypeError: unhashable type。很多人学完哈希后仍然忍不住写{[a, b]}这种代码。解决办法是先问自己这个键在存续期间会不会变如果会变那它就不该当键如果不会变且需要整体作为键那就把列表转成元组再用data_list [1, 2, 3] s {tuple(data_list)} # 合法 d {tuple(data_list): value} # 合法反过来如果你需要在字典里存可变值那是完全没问题的。因为只有键才要求不可变值可以是列表、字典、集合随意嵌套。5.4 set和dict的查找效率为什么比list快这可能是全篇最值得认真理解的部分。列表查找一个元素是否存在默认是从头到尾逐个比较的复杂度是O(n)。集合和字典则是把元素先换算成哈希值直接跳到可能存放它的格子上平均复杂度O(1)。我用一个例子来演示一个包含100万个元素的列表查找最后一个元素要经过大量比较同样的数据放到集合里查找几乎瞬间完成不管数据多大表现都稳定。所以如果你要频繁判断某个值在不在里面毫不犹豫用集合。如果还要顺带保存这个值对应什么那就用字典。不过也别把哈希想成万能药。哈希表的缺点是不能按顺序连续访问所以要遍历就切片要排序就转回list再sorted。工程上最常见的姿势是数据入库或用列表展示时转成list数据反复检索时放set或dict。知道每个容器的边界比背下一堆API更有价值。5.5 修改容器时要养成的三个习惯从踩坑经验来看有一组习惯能让你少写很多debug代码习惯一从字典取值默认用get不要裸用方括号。减少KeyError更能在代码里直接表达允许缺失的含义。习惯二迭代字典时不要直接增删元素。要删就先收集键要增就构建新字典或等循环结束再操作。习惯三检查集合元素存在用in不要试图通过遍历list去判断那会把O(1)退化成O(n)。这三个习惯都属于投入极小、回报极大的类型尤其在稍大一点的脚本或面试手写题里会直接影响代码给人留下的专业印象。6. 一套顺手的小技巧从实用角度补几个细节6.1 字典合并的多种写法合并字典在Python 3.9之后变得优雅了config1 {host: localhost, port: 8080} config2 {port: 9090, debug: True} # 新语法| 运算符 merged config1 | config2 print(merged) # {host: localhost, port: 9090, debug: True} # 老写法update方法会直接修改原字典 config1.update(config2) print(config1)注意|运算符返回新字典不影响原对象update则是原地修改。如果生产环境的Python版本是3.9以下只能用update或解包写法{**config1, **config2}。版本不同语法习惯不同但意图都一样后面的字典会覆盖前面字典的同名键。6.2 动态给字典补默认值setdefaultsetdefault这个方法的语义是有就取现成的没有就创建默认值并返回比get多了一个自动写入的动作d {} for city, population in [(A, 100), (B, 200), (A, 120)]: d.setdefault(city, []).append(population) print(d) # {A: [100, 120], B: [200]}如果没有setdefault你会写出两三行if逻辑。它对字典的值是列表这种结构特别友好花括号初始化加append的写法在统计分组时很常见。6.3 当集合和字典都嫌麻烦时引入标准库工程代码里我很少手写裸的set和dict来做复杂统计更多会用collections里的defaultdict和Counter。它们本质上仍是字典只是把get加默认值这个动作内置了from collections import defaultdict, Counter # defaultdict传入一个工厂函数缺键时自动补默认值 counter defaultdict(int) for word in [a, b, a]: counter[word] 1 print(counter) # defaultdict(class int, {a: 2, b: 1}) # Counter词频统计和最高频元素一步到位 c Counter(aabbbcd) print(c.most_common(2)) # [(b, 3), (a, 2)]我给你的建议是初学阶段先用原生的dict把底层逻辑写明白理解缺键手动补、存在则累加的过程等写过几个统计脚本后再替换成defaultdict和Counter。这样既稳又省事也不会出现看不懂库在干什么的尴尬。7. 个人经验体会容器思维的建立比背方法更重要讲到这里集合容器和字典容器的主要知识点基本覆盖完了。最后说说我自己的感受。刚开始接触set和dict时我总把它们当成另类的列表把操作列表的习惯原样搬过来结果频繁踩无序、KeyError、TypeError这些坑。后来才意识到学Python容器不能只记方法列表更重要的是建立内存组织方式不同的意识。集合和字典的底层是哈希表所以快也因为哈希本身的性质它们必须约束键的可变性也决定了顺序不再受插入位置控制。理解了这个本质你就能推理出它们所有的脾气为什么去重后乱序、为什么键不能是列表、为什么查找快得惊人。一天之内把这些内容全部消化并不现实。我建议你接下来的练习路径是先把本篇的每个代码片段亲手敲一遍观察输出和你预期是否一致然后做三个小任务用集合完成两个列表的去重与交集运算、用字典统计一篇文章的词频、用一个嵌套字典模拟学生选课系统。做这些任务时不必追求速度但一定要在每一步问自己这个容器为什么能这样做换个容器行不行。把这个问题想清楚你后面写任何涉及数据组织的代码都会顺畅得多。
延伸阅读

更多相关文章

2026/10/16 18:18:33

投资组合管理流程:从资产配置到仓位与再平衡的完整框架

1. 为什么大多数人的账户缺的是一套"管理流程"而非"选品能力"1.1 一个典型账户的真实状态我最早接触投资组合管理这个概念,是在自己账户亏得乱七八糟的时候。当时手里同时拿着七八只股票和两只基金,每一只都是听了不同人的分析后买的…

2026/10/16 18:13:30

Spark MLlib实战避坑指南:从数据加载到模型部署的7大关键环节

1. 为什么“史上最详细”不是营销话术,而是真实需求倒逼出的必然结果我第一次在某高校实验室带学生跑Spark MLlib的逻辑回归时,发现一个现象:三个人用同一份UCI乳腺癌数据集,配置完全相同的参数,训练出来的AUC值却分别…

2026/10/16 18:13:30

语义分割与医学图像分割论文解读:从FCN到Transformer的实战路线

做语义分割方向研究或者项目开发的朋友,多半都有过面对一堆论文不知道从哪下手的感觉。方向看起来不算难懂,不就是给每个像素分类吗,可真读起文献来,从FCN到U-Net,再到DeepLab、Transformer,再到各种医学影…

2026/10/17 0:04:32

NetSpot 实战:用信道分析与频宽优化解决 Wi-Fi 卡顿

简介:这份资源是 NetSpot 这款专业 Wi-Fi 分析工具的完整使用详解文档,面向家庭用户、网络运维初学者及小型企业 IT 人员,帮助解决无线信号干扰、覆盖盲点与频道规划混乱等常见问题。压缩包内共 1 个 docx 文件,约 1.15MB&#xf…

2026/10/17 0:04:32

TDH Workflow调度实战:从DAG依赖驱动到任务编排与故障排查

1. 从一个调度脚本说起:TDH Workflow到底在解决什么问题第一次接触TDH的Workflow,是在一个数据清洗项目里。当时团队每天要跑几十个ETL任务,任务之间有严格的先后依赖——A表清洗完才能跑B表的关联,B表跑完才能触发C表的聚合。最开…

2026/10/17 0:04:32

PlanarSLAM:面向曼哈顿世界的点线面联合SLAM系统

简介:本资源是面向机器人视觉与SLAM研究者的C开源RGB-D SLAM系统实现,专为结构化室内场景(如办公室、走廊)设计,通过融合点、线、平面三类几何特征并引入“曼哈顿世界”假设,显著提升位姿跟踪鲁棒性与稠密地…

2026/10/16 23:59:30

CentOS 6.5 虚拟机安装与 Oracle 10g 部署完整指南

简介:这份文档面向需要在虚拟机中搭建Linux开发或测试环境的技术人员,聚焦CentOS 6.5-x86_64系统的完整部署流程。内容从基础操作系统安装讲起,涵盖静态IP网络配置、VMware虚拟工具安装、mpiag与oracle用户创建,以及SSH、FTP、tel…

2026/10/16 5:33:43

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/16 4:03:48

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/15 13:42:16

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/17 0:04:32

PlanarSLAM:面向曼哈顿世界的点线面联合SLAM系统

简介:本资源是面向机器人视觉与SLAM研究者的C开源RGB-D SLAM系统实现,专为结构化室内场景(如办公室、走廊)设计,通过融合点、线、平面三类几何特征并引入“曼哈顿世界”假设,显著提升位姿跟踪鲁棒性与稠密地…

2026/10/17 0:04:32

TDH Workflow调度实战:从DAG依赖驱动到任务编排与故障排查

1. 从一个调度脚本说起:TDH Workflow到底在解决什么问题第一次接触TDH的Workflow,是在一个数据清洗项目里。当时团队每天要跑几十个ETL任务,任务之间有严格的先后依赖——A表清洗完才能跑B表的关联,B表跑完才能触发C表的聚合。最开…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑