Hive窗口函数:从原理到三大面试题

Hive 从入门到实战(七):窗口函数------从"为什么需要"到三大面试题全解

这是《Hive 从入门到实战》系列第 7 篇,也是含金量最高的一篇:窗口函数 + 行列转换 + 连续登录 + TopN,全是大厂 SQL 面试高频题。
本篇学习路线图:先从一道"算占比"需求引出为什么要窗口函数 → 与 group by 划清界限 → 四类函数逐个上手(聚合 → 累积 → 排序 → 取值)→ 三大面试题逐个击破(行列转换、TopN、连续登录)→ 工程变式与文献印证。

目录

  • 一、从一道需求说起:为什么需要窗口函数
  • 二、什么是窗口函数:和 group by 的本质区别
  • 三、聚合类窗口函数:sum / count / max / avg
  • 四、加上 order by:窗口变成了"累积窗口"
  • 五、排序类:row_number / rank / dense_rank
  • 六、取值类:lag / lead / first_value / last_value
  • 七、面试题一:行列转换
  • 八、面试题二:连续登录 N 天(含实战变式追问)
  • 九、总结

一、从一道需求说起:为什么需要窗口函数

需求:计算每个学生每科成绩占总分的比例。

总分要先按学生聚合出来,再拿单科成绩去除。最直觉的写法是"子查询聚合 + join":

sql 复制代码
SELECT
    a.id,
    a.cid,
    a.score,
    b.sum_score,
    round(a.score / b.sum_score,2) as p
FROM
    scores as a 
JOIN(
    SELECT
        id,
        sum(score) as sum_score
    FROM
        scores
    GROUP BY
        id
) as b 
on a.id = b.id;

能出结果,但有两个问题:

  1. 同一张表扫了两遍 (一遍算明细,一遍算总分),scores 数据量大时非常浪费
  2. 子查询聚合 + join 会产生两个 MapReduce 阶段,效率低------"减少重复扫描、减少执行阶段"正是查询优化研究的核心课题3

我们要的其实很朴素:在每一行后面"贴"一个该学生总分的批注,行数不变、不用 join。这就是窗口函数诞生的原因。

二、什么是窗口函数:和 group by 的本质区别

窗口函数:对每一行数据,以它所在的"窗口"(一组相关行)为范围做计算,结果附加在这一行上,行数不变。

和 group by 的本质区别(必须先想明白):

group by 窗口函数
行数变化 多行压缩成一行 行数不变,只是加了一列计算结果
类比 把 3 科成绩"合计成"一行总分 在 3 行成绩旁边各"贴上"总分的批注

图1:group by 把三科成绩"压扁"成一行总分;窗口函数行数不变,每行"贴上"总分批注

通用语法(完整规范见官方 WindowingAndAnalytics 文档4):

sql 复制代码
函数名(...) over(partition by 分组字段 order by 排序字段)
  • partition by:把数据分成多个窗口(相当于 group by 的分组,但不压缩行)
  • order by:窗口内排序(部分函数需要)
  • over(...) 里的两个子句都可省略,省略 = 整张表一个大窗口

本文统一用成绩表演示:

sql 复制代码
CREATE TABLE scores(
    id string ,      -- 学号
    cid string ,     -- 科目编号:1=语文 2=数学 3=英语
    score double  
) 
ROW FORMAT DELIMITED FIELDS TERMINATED BY ',' 
location '/data/scores';

数据(10 行,特意造了 1002 和 1004 同分,后面讲 rank 要用):

复制代码
1001,1,90
1001,2,85
1001,3,88
1002,1,75
1002,2,92
1002,3,80
1003,1,68
1003,2,78
1003,3,70
1004,1,75
1004,2,80
1004,3,92

三、聚合类窗口函数:sum / count / max / avg

3.1 sum() over():重写开头的比例需求

sql 复制代码
SELECT
    id,
    cid,
    score,
    sum_score,
    round(score / sum_score,2) as p
FROM
(
    SELECT
        id,
        cid,
        score,
        sum(score) over(partition by id) as sum_score   -- 每行的"窗口总分"
    FROM
        scores
) as a;

输出:

复制代码
OK
id    cid  score  sum_score  p
1001  1    90.0   263.0      0.34
1001  2    85.0   263.0      0.32
1001  3    88.0   263.0      0.33
1002  1    75.0   247.0      0.3
1002  2    92.0   247.0      0.37
1002  3    80.0   247.0      0.32
1003  1    68.0   216.0      0.31
1003  2    78.0   216.0      0.36
1003  3    70.0   216.0      0.32
1004  1    75.0   247.0      0.3
1004  2    80.0   247.0      0.32
1004  3    92.0   247.0      0.37

对比第 1 节:一遍扫描、无 join、无 group by 压缩,原 12 行原样保留,每行多了总分列。这就是窗口函数最经典的用法。

图2:PARTITION BY 画墙不搬家,各算各的总分、互不掺和

3.2 count / max / avg 同理

sql 复制代码
-- 每个学生一共考了几科
SELECT id, cid, score,
       count(1) over(partition by id) as cnt
FROM scores;

-- 每个学生的最高分
SELECT id, cid, score,
       max(score) over(partition by id) as max_score
FROM scores;

max 输出(1001 的最高分 90 贴在每行后面):

复制代码
OK
id    cid  score  max_score
1001  1    90.0   90.0
1001  2    85.0   90.0
1001  3    88.0   90.0
...

四、加上 order by:窗口变成了"累积窗口"

over(partition by id **order by cid**) 之后,语义变了:计算范围不再是整个窗口,而是"窗口起点 → 当前行"。

4.1 累积求和

sql 复制代码
SELECT
    id, cid, score,
    sum(score) over(partition by id order by cid asc) as sum_score
from scores;

输出:

复制代码
OK
id    cid  score  sum_score
1001  1    90.0   90.0     ← 只加第1科
1001  2    85.0   175.0    ← 前2科累加
1001  3    88.0   263.0    ← 前3科累加
...

典型用途:累计销售额------按天 order by,每行就是"截至当天的累计值"。

图3:帧随当前行逐行"长大",90 → 175 → 263 逐行累积

4.2 逐个比较求最大值 / 逐个求平均

sql 复制代码
-- 逐个比较:每行是"到目前为止"的最大值
SELECT id, cid, score,
       max(score) over(partition by id order by cid) as max_score
from scores;

-- 逐个求平均:每行是"到目前为止"的平均值
SELECT id, cid, score,
       avg(score) over(partition by id order by cid) as num
from scores;

max 输出:

复制代码
OK
id    cid  score  max_score
1001  1    90.0   90.0
1001  2    85.0   90.0
1001  3    88.0   90.0

记忆口诀:over 里加 order by,聚合就带"累积"属性;不加 order by,算的是整个窗口。

五、排序类:row_number / rank / dense_rank

三个函数都是"窗口内编排名号",区别在于遇到并列名次怎么处理。

5.1 组内排序

sql 复制代码
SELECT
    id, cid, score,
    row_number() over(partition by id order by score DESC) as rn
from scores;

输出(每个学生内部按分数从高到低):

复制代码
OK
id    cid  score  rn
1001  1    90.0   1
1001  3    88.0   2
1001  2    85.0   3
...

5.2 面试题三:TopN 排名------三兄弟同台对比

需求:按总分排名,看三者的区别:

sql 复制代码
SELECT 
    id,
    sum(score) as sum_score,
    row_number() over(order by sum(score) DESC) as rn,
    rank()      over(order by sum(score) DESC) as rk,
    dense_rank()over(order by sum(score) DESC) as dr
from scores
GROUP BY id;

输出:

复制代码
OK
id    sum_score  rn  rk  dr
1001  263.0      1   1   1
1002  247.0      2   2   2
1004  247.0      3   2   2
1003  216.0      4   4   3

三者区别一目了然:

函数 1001 1002 1004 1003 规则
row_number 1 2 3 4 并列也强行顺排:1,2,3,4
rank 1 2 2 4 并列同号,跳号:1,1,3
dense_rank 1 2 2 3 并列同号,不跳号:1,1,2

记忆口诀:rank 并列跳号,dense_rank 并列不跳号,row_number 根本不认并列。

TopN 标准套路:先算排名,再套一层 where rn <= N:

sql 复制代码
-- 每个学生成绩最高的前2科
select * from (
    SELECT id, cid, score,
           row_number() over(partition by id order by score DESC) as rn
    from scores
) t
where rn <= 2;

两个坑:

  1. 窗口函数里不能使用 select 里定义的别名 (上面 order by sum(score) 不能写成 order by sum_score),因为窗口函数和 select 同层求值
  2. TopN 的 where 过滤必须套在外层 ,不能直接在原查询里 where rn <= 2(where 先于窗口函数执行,此时 rn 还不存在)

六、取值类:lag / lead / first_value / last_value

6.1 lag / lead:取前一行 / 后一行

sql 复制代码
SELECT
    id,
    sum(score) as sum_score,
    lag(sum_score, 1, 0)  over(order by sum(score) DESC) as lag_score,
    lead(sum_score, 1, 0) over(order by sum(score) DESC) as lead_score
from scores
GROUP BY id;

输出:

复制代码
OK
id    sum_score  lag_score  lead_score
1001  263.0      0          247.0      ← 第一行,前面没有了,补默认值0
1002  247.0      263.0      247.0
1004  247.0      247.0      216.0
1003  216.0      247.0      0          ← 最后一行,后面没有了,补0

参数解读(都是可调的):

  • lag(字段, n, 默认值):取当前行前面第 n 行的值
  • n:偏移量,默认 1
  • 默认值:越界时补什么,可省略 ,省略时补 NULL(上面的 0 是我们手动指定的)
  • lead 同理,取后面的行

典型用途:环比/同比------本月销售额 - lag(上月销售额)。

6.2 first_value / last_value:窗口内第一个 / 最后一个值

sql 复制代码
SELECT
    id, score,
    first_value(score) over(partition by id order by score desc) as fv_score
from scores;
复制代码
OK
id    score  fv_score
1001  90.0   90.0
1001  88.0   90.0
1001  85.0   90.0
...

first_value 稳定输出每个学生的最高分。

last_value 有个大坑:

sql 复制代码
SELECT
    id, score,
    last_value(score) over(partition by id order by score desc) as lv_score
from scores;

输出并不是你想象的"每行都是最低分 85",而是:

复制代码
OK
id    score  lv_score
1001  90.0   90.0     ← 等于当前行自己
1001  88.0   88.0     ← 还是当前行自己
1001  85.0   85.0

原因 :加了 order by 后,窗口的默认边界是"从起点到当前行"(不是整个分组),所以 last_value 永远指向当前行。想取"真正的最后一个值",要手动把边界扩到整个窗口:

sql 复制代码
SELECT
    id, score,
    last_value(score) over(
        partition by id order by score desc
        rows between unbounded preceding and unbounded following   -- 边界=整个窗口
    ) as lv_score
from scores;

这样 lv_score 全部输出 85(每个学生的最低分)。记住这个框架写法,面试加分项。

6.3 窗口帧 rows between:累积与滑动的底层机制(面试加分项)

第 4 节的"累积"和 last_value 的坑,其实是同一个机制在起作用------窗口帧(window frame),它规定"参与计算的行到底从哪到哪";对窗口函数执行原理与优化想深挖的话,学界有专门研究可读1:

sql 复制代码
函数(...) over(
    partition by ... order by ...
    rows between <起点> and <终点>     -- 帧边界,可调
)
帧写法 计算范围 典型用途
rows between unbounded preceding and current row 起点 → 当前行(order by 时默认) 累计销售额(第 4 节)
rows between unbounded preceding and unbounded following 整个窗口 last_value 取真正的末值(6.2 的解法)
rows between 6 preceding and current row 前 6 行 → 当前行 近 7 天滑动求和 / 移动平均

近 7 日滑动求和示例(daily_login 是"日期 + 当日登录数"的汇总表):

sql 复制代码
select
    login_date,
    cnt,
    sum(cnt) over(order by login_date rows between 6 preceding and current row) as last7_sum
from daily_login;

口诀:不写帧是累积(到当前行),写 n preceding 是滑动(近 n+1 天),unbounded following 是全窗口。

图4:彩色格子是聚合"看得见"的行,黑框是当前行

严谨备注:SQL 标准里默认帧其实是 range(按值界定,并列值同帧),本文场景与 rows 等价,写 rows 更直观;面试能说出 rows / range 的区别更是加分。

七、面试题一:行列转换

行列转换分两类:行转列(多行聚合成列)、列转行(一列炸成多行)。第 6 篇的 collect_set / lateral view 在这里合体实战。

7.1 需求

表1(长表)转表2(宽表),再转回来:

复制代码
表1:name,item,score          表2:name,math,english
张三,数学,33                   张三,33,77
张三,英语,77                   李四,66,78
李四,数学,66
李四,英语,78

建表灌数:

sql 复制代码
create table table1(
    name STRING,
    item STRING,
    score DOUBLE
)ROW FORMAT DELIMITED FIELDS TERMINATED BY ',' 
STORED AS TEXTFILE;

LOAD DATA LOCAL INPATH '/root/sh_code/table1.csv' OVERWRITE INTO TABLE table1;

7.2 行转列:case when + sum

思路:case when 把"数学行"和"英语行"拆成两个通道,再按人求和:

sql 复制代码
create table table2 as
SELECT
    name,
    -- 数学通道:是数学就取分数,不是就取0
    sum(case when item = '数学' then score else 0 end) as math,
    -- 英语通道:同理
    sum(case when item = '英语' then score else 0 end) as english
from table1
GROUP BY name;

输出:

复制代码
OK
name  math  english
张三  33.0  77.0
李四  66.0  78.0

多几门课就多几个 case when 通道,套路不变。

7.3 列转行:lateral view explode(map)

思路:把每人的 (数学,英语) 打包成 map,用 explode 炸开,key 变科目、value 变分数:

sql 复制代码
SELECT
    name,
    item,
    score
FROM
    table2,
lateral view explode(
    map(
        '数学',math,      -- key=数学, value=33.0/66.0
        '英语',english    -- key=英语, value=77.0/78.0
    )
) T as item,score;

输出(完美还原表1):

复制代码
OK
name  item  score
张三  数学  33.0
张三  英语  77.0
李四  数学  66.0
李四  英语  78.0

一图总结:行转列 = case when 聚合;列转行 = lateral view + explode。

八、面试题二:连续登录 N 天

题目:登录日志如下,找出连续登录天数 >= 3 天的用户。

复制代码
user_id,login_date
01,2021-02-28
01,2021-03-01
01,2021-03-02
01,2021-03-04
01,2021-03-05
01,2021-03-06
01,2021-03-08
02,2021-03-01
02,2021-03-02
02,2021-03-03
02,2021-03-04
02,2021-03-06
03,2021-03-06
sql 复制代码
create table user_login_log(
    user_id STRING,
    login_date STRING
)ROW FORMAT DELIMITED FIELDS TERMINATED BY ',' 
STORED AS TEXTFILE;

LOAD DATA LOCAL INPATH '/root/sh_code/user_login_log.csv' OVERWRITE INTO TABLE user_login_log;

8.1 核心思路:日期减序号 = 连续标记(差值法)

先观察一个规律------连续的日期,减去它排第几,差值是不变的:

日期 序号 rn date_sub(日期, rn)
03-01 1 02-28
03-02 2 02-28 ← 和上一行相同!
03-04 3 03-01 ← 断了,差值变了
03-05 4 03-01 ← 又相同了

"日期 - 序号"相同 = 这一段日期连续。把相同差值分成一组,组内 count 就是连续登录天数。这就是面试官想听到的核心思路。这类"从登录日志里挖行为规律"的分析,也正是用户行为分析系统的典型场景2。

图5:差值相同分一组,组内 count 就是连续天数

8.2 三步实现

第一步:row_number 编序号

sql 复制代码
SELECT
    user_id,
    login_date,
    row_number() over(partition by user_id order by login_date) as rank
FROM
    user_login_log;

输出(节选用户01):

复制代码
OK
user_id  login_date  rank
01       2021-02-28  1
01       2021-03-01  2
01       2021-03-02  3
01       2021-03-04  4
01       2021-03-05  5
01       2021-03-06  6
01       2021-03-08  7

第二步:算差值,按差值分组计数

sql 复制代码
SELECT
    user_id,
    min(login_date) as min_login_date,     -- 连续段开始
    max(login_date) as max_login_date,     -- 连续段结束
    date_sub(login_date, rank) as sub_date,-- 差值(分组依据)
    count(1) as num                        -- 连续天数
FROM(
    SELECT
        user_id,
        login_date,
        row_number() over(partition by user_id order by login_date) as rank
    FROM
        user_login_log
) as a
GROUP BY user_id, date_sub(login_date, rank);

输出:

复制代码
OK
user_id  min_login_date  max_login_date  sub_date    num
01       2021-02-28      2021-02-28      2021-02-27  1
01       2021-03-01      2021-03-02      2021-02-28  2
01       2021-03-04      2021-03-06      2021-03-01  3   ← 连续3天!
01       2021-03-08      2021-03-08      2021-03-02  1
02       2021-03-01      2021-03-03      2021-02-28  3   ← 连续3天!
02       2021-03-04      2021-03-04      2021-03-01  1
02       2021-03-06      2021-03-06      2021-03-02  1
03       2021-03-06      2021-03-06      2021-03-05  1

第三步:having 过滤 >= 3 天

sql 复制代码
SELECT
    user_id,
    min(login_date) as min_login_date,
    max(login_date) as max_login_date,
    count(1) as num
FROM(
    SELECT
        user_id,
        login_date,
        row_number() over(partition by user_id order by login_date) as rank
    FROM
        user_login_log
) as a
GROUP BY user_id, date_sub(login_date, rank)
HAVING num >= 3;

最终输出:

复制代码
OK
user_id  min_login_date  max_login_date  num
01       2021-03-04      2021-03-06      3
02       2021-03-01      2021-03-03      3

口诀:连续问题先排序,日期减序号,差值同组即连续。 这套"差值分组"框架换个壳就能解"连续 N 天活跃""连续 N 月消费"等一大家子题。

面试官追问(实战变式):如果日志里同一用户同一天有多条登录记录怎么办?

答:先去重、再编号 。差值法的前提是"每个日期只出现一次"------若同一天有两条记录,row_number 会给它们编上不同序号,date_sub 就会算出两个不同差值,同一天的记录被硬拆进不同"连续段",天数直接算错。解法:内层先按 user_id, login_date 去重(group by 或 select distinct),再套三步框架。这一问考察的是你知不知道框架的适用边界。

九、总结

  1. 窗口函数 = 在不压缩行数的前提下,给每行附加"所在窗口"的计算结果;与 group by 的区别是"贴批注 vs 压扁"
  2. 语法:函数() over(partition by 分组 order by 排序),两个子句都可省
  3. 四类函数 + 一个机制:
    • 聚合类:sum/count/max/avg(+order by 变累积)
    • 排序类:row_number 不认并列、rank 跳号、dense_rank 不跳号
    • 取值类:lag/lead(环比)、first_value/last_value(注意默认窗口边界坑)
    • 底层机制:窗口帧 rows between------不写帧=累积,写 6 preceding=近 7 天滑动,unbounded following=全窗口
  4. 面试三件套 :
    • 行列转换:case when 聚合 ↔ lateral view explode
    • 连续登录:row_number + date_sub 差值分组(实战变式:先去重再编号)
    • TopN:row_number 排名后外层 where rn <= N
  5. 至此 SQL 核心武器全部集齐。实战中再配合分区表(少扫数据)+ ORC(读得少压得小),就是一套完整的 Hive 数仓基本功

相关推荐
一个热爱拳击的玩家42 分钟前
本地实体如何构建面向 AI 检索的信任链?以杭州拳击行业案例分析
大数据·人工智能·信任链
byte轻骑兵1 小时前
时序数据库选型指南:面向工业物联网大数据场景,Apache IoTDB落地实践
大数据·人工智能·物联网·时序数据库·人大金仓
墨神谕1 小时前
Elasticsearch 倒排索引底层原理详解:从 Term Dictionary、FST 到海量数据性能优化
大数据·elasticsearch·搜索引擎
程序员梅雨1 小时前
Hive面试总结
hive·hadoop·面试
Navicat中国7 小时前
Navicat 18 内置 AI 助手让数据库开发从繁琐操作迈向智能协作
大数据·人工智能·数据库开发
知了学历孙老师9 小时前
小自考的校考和统考,出题方和难度差在哪
java·大数据·前端
XCDoris10 小时前
郴州logo设计机构
大数据·人工智能·python
物联网软硬件开发-轨物科技10 小时前
【技术白皮书】光伏清检一体化机器人系统解决方案技术白皮书
大数据·运维·网络·人工智能·机器人
zyseo811 小时前
谷歌SEO 准首页词挖掘脚本:找出排在第 8-20 名的隐藏流量
大数据·数据库·人工智能