PostgreSQL 用WITH伪递归求最短路径的例子

本文涉及的产品
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
云原生数据库 PolarDB 分布式版,标准版 2核8GB
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
简介:

转自

http://www.depesz.com/2012/06/25/how-to-get-shortest-connection-between-two-cities/

Yesterday, on #postgresql on irc some guy asked:

22:28 < rafasc> i am trying to use plpgsql to find the shortest path between two cities, each pair of cities has one or more edges, each edge has a different wheight.
22:28 < rafasc> Is there a easy way to compute the shortest path between two cities?

Well, I was not really in a mood to solve it, so I just told him to try with recursive queries, and went on my way.

But I thought about it. And decided to see if I can write the query.

To get some test data, I created two simple tables:

$ \d cities
   Table "public.cities"
 ColumnType │ Modifiers
────────┼──────┼───────────
 city   │ text │ not null
Indexes:
    "cities_pkey" PRIMARY KEY, btree (city)
Referenced by:
    TABLE "routes" CONSTRAINT "routes_from_city_fkey" FOREIGN KEY (from_city) REFERENCES cities(city)
    TABLE "routes" CONSTRAINT "routes_to_city_fkey" FOREIGN KEY (to_city) REFERENCES cities(city)
 
$ \d routes
      Table "public.routes"
  ColumnType   │ Modifiers
───────────┼─────────┼───────────
 from_city │ text    │ not null
 to_city   │ text    │ not null
 lengthintegernot null
Indexes:
    "routes_pkey" PRIMARY KEY, btree (from_city, to_city)
Check constraints:
    "routes_check" CHECK (from_city < to_city)
Foreign-key constraints:
    "routes_from_city_fkey" FOREIGN KEY (from_city) REFERENCES cities(city)
    "routes_to_city_fkey" FOREIGN KEY (to_city) REFERENCES cities(city)

Data in them is very simple:

$ select * from cities limit 5;
      city
────────────────
 Vancouver
 Calgary
 Winnipeg
 Sault St Marie
 Montreal
(5 rows)
 
$ select * from routes limit 5;
 from_city │  to_city  │ length
───────────┼───────────┼────────
 Calgary   │ Vancouver │      3
 Seattle   │ Vancouver │      1
 Portland  │ Seattle   │      1
 Calgary   │ Seattle   │      4
 Calgary   │ Helena    │      4
(5 rows)

In case you wonder – the data represents base map for “Ticket to Ride" game – awesome thing, and if you haven't played it – get it, and play.

This map was part of review of the game on ars technica.

But anyway. So, I have 36 cities, and 78 unique paths between them, each with length information. So, with this I should be able to find the shortest path.

One word of warning though – the fact that it's possible to do in database, doesn't mean it's good idea. Personally, I think that it should be done in some standalone application, which would use some smarter algorithms, extensive cache, and so on. But – this is just a proof of concept, and the data size that I'm working on is small enough that it shouldn't matter.

Each route is stored only once in routes. So I'll start by duplicating the rows, so I will have them written “in both directions":

CREATE VIEW all_routes AS
    SELECT from_city, to_city, length FROM routes
    UNION ALL
    SELECT to_city, from_city, length FROM routes

This will save me some typing later on.

First, let's start with some small route, but one that will show that it actually works – Duluth-Toronto is great example.

Reason is very simple, We have these 3 routes:

   from_city    │    to_city     │ length
────────────────┼────────────────┼────────
 Duluth         │ Sault St Marie │      3
 Sault St Marie │ Toronto        │      2
 Duluth         │ Toronto        │      6
(3 rows)

There is a direct connection (length 6), but it's actually cheaper to go via Sault St Marie, with total length of 5!

Here is a pause, of ~ 1 hour when I tried to write a query to solve my problem. And I failed. Kind of.

Query that would return the data is relatively simple:

WITH RECURSIVE
    multiroutes AS (
        SELECT
            from_city,
            to_city,
            ARRAY[ from_city, to_city ] as full_route,
            length as total_length
        FROM
            all_routes
        WHERE
            from_city = 'Duluth'
        UNION ALL
        SELECT
            m.from_city,
            n.to_city,
            m.full_route || n.to_city,
            m.total_length + n.length
        FROM
            multiroutes m
            join all_routes n ON m.to_city = n.from_city
        WHERE
            n.to_city <> ALL( m.full_route )
    )
SELECT *
FROM multiroutes
WHERE to_city = 'Toronto'
ORDER BY total_length desc limit 1;

But the problem is – it's extremely slow. And uses a lot of resources, which made OOM killer in my desktop to kill it (yes, stupid OOM killer).

I tried to implement simple pruning of searched paths if they are longer than current shortest on given route, but I couldn't find a way to do it – it seems to require subselect, and subselects referring to recursive queries, are not allowed within the recursive query itself.

(I think that perhaps RhodiumToad (on irc) can do it in a single query, but I'm far away from his level of skills, so I had to pass)

Does that mean it can't be done in database? No.

Luckily, we have functions. And functions can be rather smart.

To make the function simpler to use and write, I defined a type:

CREATE TYPE route_dsc as (
    from_city     TEXT,
    to_city       TEXT,
    full_route    TEXT[],
    total_length  INT4
);

This is a quite easy way to encapsulate all information about a single route as somewhat scalar value.

Now, I can write the function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
CREATE OR REPLACE FUNCTION
    get_shortest_route( p_from TEXT, p_to TEXT )
    RETURNS SETOF route_dsc AS
$$
DECLARE
    sanity_count   INT4;
    final_routes   route_dsc[];
    current_routes route_dsc[];
    r              route_dsc;
BEGIN
    SELECT count(*) INTO sanity_count
        FROM cities
        WHERE city in (p_from, p_to);
    IF sanity_count <> 2 THEN
        raise exception 'These are NOT two, distinct, correct city names.';
    END IF;
 
    current_routes := array(
        SELECT row(from_city, to_city, ARRAY[from_city, to_city], length)
        FROM all_routes
        WHERE from_city = p_from
    );
    final_routes := current_routes;
 
    LOOP
        current_routes := array(
            SELECT row(
                c.from_city,
                a.to_city,
                c.full_route || a.to_city,
                c.total_length + a.length)
            FROM
                unnest( current_routes ) as c
                join all_routes a on c.to_city = a.from_city
            WHERE
                a.to_city <> all( c.full_route )
                AND
                c.total_length + a.length <= least(
                    coalesce(
                        (
                            SELECT min(l.total_length)
                            FROM unnest( final_routes ) as l
                            WHERE ( l.from_city, l.to_city ) = (c.from_city, p_to)
                        ),
                        c.total_length + a.length
                    ),
                    coalesce(
                        (
                            SELECT min(l.total_length)
                            FROM unnest( final_routes ) as l
                            WHERE ( l.from_city, l.to_city ) = (c.from_city, a.to_city)
                        ),
                        c.total_length + a.length
                    )
                )
        );
        EXIT WHEN current_routes = '{}';
        final_routes := final_routes || current_routes;
    END LOOP;
    RETURN query
        WITH rr as (
            SELECT
                from_city,
                to_city,
                full_route,
                total_length,
                dense_rank()
                    over (partition BY from_city, to_city ORDER BY total_length) as rank
            FROM unnest( final_routes )
            WHERE from_city = p_from AND to_city = p_to
        )
        SELECT from_city, to_city, full_route, total_length FROM rr where rank = 1;
    RETURN;
END;
$$ language plpgsql;

Looks huge, but in fact it's only because there are many queries inside. So, let's see what the function does:

  • lines 1-4 – standard preamble with function name, 2 arguments (cities we want to connect), and information that we will be returning set of records based on the type I just defined. In here you might wonder – why set of? We want just the shortest route. Yes, that's correct but it's perfectly possible (and very common) that there are many rows with the same, minimal length. So, instead of picking one randomly – I will return them all.
  • lines 6-9 – variable declarations, not really interesting
  • lines 11-16 – sanity check. Simple verification that both given names are city names, and that they are different.
  • lines 18-22 – I build current_routes based on all routes coming from source city. For example, If I'd call the function to find me route from Duluth to Toronto, the array would get these rows:
    $ SELECT from_city, to_city, ARRAY[from_city, to_city], length
    FROM all_routes
    WHERE from_city = 'Duluth';
     from_city │    to_city     │           array           │ length
    ───────────┼────────────────┼───────────────────────────┼────────
     Duluth    │ Helena         │ {Duluth,Helena}6
     Duluth    │ Winnipeg       │ {Duluth,Winnipeg}4
     Duluth    │ Sault St Marie │ {Duluth,"Sault St Marie"}3
     Duluth    │ Toronto        │ {Duluth,Toronto}6
     Duluth    │ Omaha          │ {Duluth,Omaha}2
     Duluth    │ Chicago        │ {Duluth,Chicago}3
    (6 rows)
  • line 23 – I copy current_routes to “final_routes". current_routes contains only routes that the loop below has to work on, but final routes – is an array of all routes that will be used for finding final solution
  • lines 25-59 – basically infinite loop (of course with proper exit condition), which recursively finds routes:
    • lines 26-56 – core of the function. This query builds new list of routes, based on what's in current_routes, with following criteria:
      • new route must be from a city that is at the end of some route in “current_routes" (i.e. it's next segment for multi-city route
      • added (to route) city cannot be already in full_route (there is no point in revisiting cities when we're looking for shortest path
      • new total length of route (i.e. some route from current_routes + new segment) has to be shorter (or the same) as existing shortest path between these two cities. By “these" I mean original “from" city, and newly added “to" city. So, if we already have a route between cities “a" and “b" that is “10" long, there is no point in adding new route that is “20" long.
      • similar condition as above, but checking against already found requested route – i.e. route between cities user requested in passing arguments
      • above two criteria make sense only if there are matching routes already in final_routes – hence the need for coalesce()

      All such routes are stored in current_routes for future checking

    • line 57 – if the query above didn't return any routes – we're done, can exit the loop
    • line 58 – if there are some routes – add them to final_routes, and repeat the loop
  • lines 60-72 – return of the important data. I take all the routes in final_routes, from there, pick only the ones that match from_city/to_city with parameters given on function call, and then I use dense_rank() to find all records that have minimal total_length. All these records will get returned.

If that's complex, let me show you an example. What is stored, in which variable, at which step, when finding the route from Duluth to Toronto.

  • after line 23 in function, both current_routes and final_routes contain:
    from_city to_city total_length full_route
    Duluth Helena 6 {Duluth,Helena}
    Duluth Winnipeg 4 {Duluth,Winnipeg}
    Duluth Sault St Marie 3 {Duluth,"Sault St Marie"}
    Duluth Toronto 6 {Duluth,Toronto}
    Duluth Omaha 2 {Duluth,Omaha}
    Duluth Chicago 3 {Duluth,Chicago}
  • First run of the main recursive query – at line 57 current_routes are:
    from_city to_city total_length full_route
    Duluth Toronto 5 {Duluth,"Sault St Marie",Toronto}
    Duluth Pittsburg 6 {Duluth,Chicago,Pittsburg}
    Duluth Saint Louis 5 {Duluth,Chicago,"Saint Louis"}
    Duluth Denver 6 {Duluth,Omaha,Denver}
    Duluth Kansas City 3 {Duluth,Omaha,"Kansas City"}

    and since it's obviously not empty set – it continues.
    Please note that it didn't (for example) add route Duluth – Helena – Seattle (which is correct route, as you can see on the image above). Reason is very simple – we already found one route Duluth – Toronto, and its length is 6, so adding new route which is longer than this – doesn't make sense.

  • At line 58 final_routes are set to:
    from_city to_city total_length full_route
    Duluth Helena 6 {Duluth,Helena}
    Duluth Winnipeg 4 {Duluth,Winnipeg}
    Duluth Sault St Marie 3 {Duluth,"Sault St Marie"}
    Duluth Toronto 6 {Duluth,Toronto}
    Duluth Omaha 2 {Duluth,Omaha}
    Duluth Chicago 3 {Duluth,Chicago}
    Duluth Toronto 5 {Duluth,"Sault St Marie",Toronto}
    Duluth Pittsburg 6 {Duluth,Chicago,Pittsburg}
    Duluth Saint Louis 5 {Duluth,Chicago,"Saint Louis"}
    Duluth Denver 6 {Duluth,Omaha,Denver}
    Duluth Kansas City 3 {Duluth,Omaha,"Kansas City"}

    Which is simply previous final_routes with added new 5.

  • After next iteration of the loop, based on 5-element current_routes, we got only two new routes:
    from_city to_city total_length full_route
    Duluth Oklahoma City 5 {Duluth,Omaha,"Kansas City","Oklahoma City"}
    Duluth Saint Louis 5 {Duluth,Omaha,"Kansas City","Saint Louis"}

    And of course they got added to final_routes.

  • another iteration of the loop, based on current_routes with just two elements – didn't return any rows. There simply is no way to extend routes “Duluth-Omaha-Kansas City" or “Duluth-Omaha-Saint Louis" in a way that wouldn't extend already found route “Duluth-Sault St Marie-Toronto" with length 5.
  • Since this iteration of loop didn't find anything, loop exits, and the final_routes contains:
    from_city to_city total_length full_route
    Duluth Helena 6 {Duluth,Helena}
    Duluth Winnipeg 4 {Duluth,Winnipeg}
    Duluth Sault St Marie 3 {Duluth,"Sault St Marie"}
    Duluth Toronto 6 {Duluth,Toronto}
    Duluth Omaha 2 {Duluth,Omaha}
    Duluth Chicago 3 {Duluth,Chicago}
    Duluth Toronto 5 {Duluth,"Sault St Marie",Toronto}
    Duluth Pittsburg 6 {Duluth,Chicago,Pittsburg}
    Duluth Saint Louis 5 {Duluth,Chicago,"Saint Louis"}
    Duluth Denver 6 {Duluth,Omaha,Denver}
    Duluth Kansas City 3 {Duluth,Omaha,"Kansas City"}
    Duluth Oklahoma City 5 {Duluth,Omaha,"Kansas City","Oklahoma City"}
    Duluth Saint Louis 5 {Duluth,Omaha,"Kansas City","Saint Louis"}

Based on the final_routes above, query in lines 61-72 calculates correct answer, and shows it.

OK. So it works. But how slow it is?

First, let's start with very simple example – Atlanta – Nashville. These two cities are connected using a single one-element route. Call to function:

$ SELECT * FROM get_shortest_route('Atlanta', 'Nashville');
 from_city │  to_city  │     full_route      │ total_length
───────────┼───────────┼─────────────────────┼──────────────
 Atlanta   │ Nashville │ {Atlanta,Nashville}1
(1 row)
 
Time: 1.045 ms

What about the Duluth-Toronto?

$ SELECT * FROM get_shortest_route('Duluth', 'Toronto');
 from_city │ to_city │            full_route             │ total_length
───────────┼─────────┼───────────────────────────────────┼──────────────
 Duluth    │ Toronto │ {Duluth,"Sault St Marie",Toronto}5
(1 row)
 
Time: 2.239 ms

Something longer perhaps:

$ SELECT * FROM get_shortest_route('Duluth', 'Los Angeles');
 from_city │   to_city   │                                  full_route                                   │ total_length
───────────┼─────────────┼───────────────────────────────────────────────────────────────────────────────┼──────────────
 Duluth    │ Los Angeles │ {Duluth,Omaha,Denver,Phoenix,"Los Angeles"}14
 Duluth    │ Los Angeles │ {Duluth,Omaha,Denver,"Santa Fe",Phoenix,"Los Angeles"}14
 Duluth    │ Los Angeles │ {Duluth,Omaha,"Kansas City","Oklahoma City","Santa Fe",Phoenix,"Los Angeles"}14
 Duluth    │ Los Angeles │ {Duluth,Helena,"Salt Lake City","Las Vegas","Los Angeles"}14
 Duluth    │ Los Angeles │ {Duluth,Omaha,Denver,"Salt Lake City","Las Vegas","Los Angeles"}14
(5 rows)

And how about a cross country?

$ SELECT * FROM get_shortest_route('Vancouver', 'Miami');
 from_city │ to_city │                                      full_route                                      │ total_length
───────────┼─────────┼──────────────────────────────────────────────────────────────────────────────────────┼──────────────
 Vancouver │ Miami   │ {Vancouver,Calgary,Helena,Omaha,"Kansas City","Saint Louis",Nashville,Atlanta,Miami}23
 Vancouver │ Miami   │ {Vancouver,Seattle,Helena,Omaha,"Kansas City","Saint Louis",Nashville,Atlanta,Miami}23
(2 rows)
 
Time: 62.507 ms

The longer the road the more time it takes to find it. Which is pretty understandable.

So, to wrap it. It can be done in database. It is not as slow as I expected. I wasn't able to find a way to do it without functions, but it might be possible for someone smarter than me.

And I still don't think it's a good idea to put this logic in database.

相关实践学习
使用PolarDB和ECS搭建门户网站
本场景主要介绍基于PolarDB和ECS实现搭建门户网站。
阿里云数据库产品家族及特性
阿里云智能数据库产品团队一直致力于不断健全产品体系,提升产品性能,打磨产品功能,从而帮助客户实现更加极致的弹性能力、具备更强的扩展能力、并利用云设施进一步降低企业成本。以云原生+分布式为核心技术抓手,打造以自研的在线事务型(OLTP)数据库Polar DB和在线分析型(OLAP)数据库Analytic DB为代表的新一代企业级云原生数据库产品体系, 结合NoSQL数据库、数据库生态工具、云原生智能化数据库管控平台,为阿里巴巴经济体以及各个行业的企业客户和开发者提供从公共云到混合云再到私有云的完整解决方案,提供基于云基础设施进行数据从处理、到存储、再到计算与分析的一体化解决方案。本节课带你了解阿里云数据库产品家族及特性。
目录
相关文章
|
7月前
|
SQL 人工智能 Oracle
PostgreSQL 递归查询(含层级和结构)
PostgreSQL 递归查询(含层级和结构)
|
关系型数据库 PostgreSQL
postgresql中关联多表递归查询,并分组计数、求和
postgresql中关联多表递归查询,并分组计数、求和
|
SQL 存储 移动开发
PostgreSQL psql的使用,SQL语法,数据类型,递归SQL用法(四)|学习笔记
快速学习3 PostgreSQL psql的使用,SQL语法,数据类型,递归SQL用法(四)
 PostgreSQL psql的使用,SQL语法,数据类型,递归SQL用法(四)|学习笔记
|
SQL 关系型数据库 数据库
3 PostgreSQL psql的使用,SQL语法,数据类型,递归SQL用法(三)|学习笔记
快速学习3 PostgreSQL psql的使用,SQL语法,数据类型,递归SQL用法(三)
|
SQL 缓存 运维
先入为主的PostgreSQL“递归性能问题”优化
收到运维告警,数据库磁盘容量100%,一段时间后又降了下去,使用该数据库的服务是因为人员变动后流转到我手里维护的,当时听说过因为PostgreSQL问题进行了对应优化,优化前的表现也是类似将数据库临时表空间占满,所以我也想着从这方面入手排查,后续排查确实也验证了我的猜想。
1428 0
先入为主的PostgreSQL“递归性能问题”优化
|
移动开发 关系型数据库 PostgreSQL
PostgreSQL 递归查询简析
### 语法 ```plsql WITH RECURSIVE cte_name AS(     CTE_query_definition -- 非递归项     UNION [ALL]     CTE_query definion -- 递归项 ) SELECT * FROM cte_name; ``` 递归 WITH 查询的一般形式始终是非递归项,然后是 UNION(或 UNION ALL),
|
传感器 SQL 并行计算
【重新发现PostgreSQL之美】 - 6 index链表跳跳糖 (CTE recursive 递归的详细用例)
大家好,这里是重新发现PostgreSQL之美 - 6 index链表跳跳糖 (CTE recursive 递归的详细用例)
|
SQL 分布式计算 并行计算
PostgreSQL 并行计算解说 之29 - parallel 递归查询, 树状查询, 异构查询, CTE, recursive CTE, connect by
标签 PostgreSQL , cpu 并行 , smp 并行 , 并行计算 , gpu 并行 , 并行过程支持 背景 PostgreSQL 11 优化器已经支持了非常多场合的并行。简单估计,已支持27余种场景的并行计算。 parallel seq scan parallel index scan
260 0
|
SQL 存储 Oracle
PostgreSQL 递归应用实践 - 非“传销”的高并发实时藤、树状佣金分配体系
标签 PostgreSQL , 佣金分配 , 树状 , 藤状 , 递归查询 , 传销 背景 早在十年前,PostgreSQL 8点几的版本就支持了递归查询,递归查询的应用非常的广泛,如下: 《PostgreSQL 递归妙用案例 - 分组数据去重与打散》 《PostgreSQL Oracle 兼...
1555 0

相关产品

  • 云原生数据库 PolarDB
  • 云数据库 RDS PostgreSQL 版