Skip to content

big homework for datasturcture, optimize the dijstrak algorithm use different datastructure.

Notifications You must be signed in to change notification settings

MaxtirError/Dijkstra-Algorithm-and-Optimization-by-Datastructure

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Dijkstra Algorithm and Optimization by Datastructure

big homework for datasturcture, optimize the dijstrak algorithm use different datastructure.

项目环境

运行环境:windows10,g++

开发环境:vscode

库依赖:c++标准库

源文件编译指令,在目录下

g++ -O2 file.cpp -o file.exe -Wl,--stack=1073741824

问题描述

经典的最短路问题,经典的Dijstrak算法。

项目框架

Dijkstra:. │ README.md │ ├───feedback │ log.xlsx #一些运行时间数据的对比 │ ├───predata # 预处理的二进制文件,用于优化读入 │ textinfo.bxt │ ├───reference_file # 作业要求文件 │ Lab3_DS_2021Aut.pdf │ ├───results # 结果文件,输出最短路 │ result_textinfo.txt │ ├───source #不同版本的Dijstrak算法 │ EasyVersion.cpp │ FastVersion_Heap.cpp │ FastVersion_zkw.cpp │ FinalVersion.cpp │ FinalVersion_Block.cpp │ SpecialVersion_Block.cpp │ ├───textdata #初始的图文件和源汇文件 │ textinfo.txt │ textinfo_st.txt │ └───tool #一些工具 generate_data.cpp #用于产生数据 gettxt.cpp #用于压缩二进制文件 io.cpp #用于读入 testio.cpp # 用于测试读入效率

开始的开始:数据处理与读入优化

首先,数据的格式是每行三个数据,$u,v,w$

fread读入优化

我们可以采用$fread$处理数据流,并按char读入数据。通过读到一个buf流和指针流动来实现快速的读入优化

struct nios {
    FILE *rfp, *wfp, *sfp;
    char c;
    char FILEPATH[100], NAME[100], n;
    nios() {
        c = -1; rfp = wfp = NULL;
    }
    void Open_R() { //openfile
        ...
    }
    inline char getchar() {
        static const int IN_LEN=1<<18|1;
        static char buf[IN_LEN], *s = buf, *t = buf;
        return (s==t) && (t = (s = buf) + fread(buf, 1, IN_LEN, rfp)),s==t?-1:*s++;
    }

    int read() {
        char ch = getchar();int x = 0;
        if(ch == -1) return -1;
        for(;ch < '0' || ch > '9'; ch = getchar()) 
            if(ch == -1)
                return -1;
        for(;ch >= '0' && ch <= '9'; ch = getchar())
            x = x * 10 + ch - '0';
        return x;
    }
    int GetEdge(int &u, int &v, int &w) {
        u = read();
        if(u == -1) return -1;
        v = read(); w = read();
        return 1;
    }
    void LoadGraph(int &n, int &m, void (*adds)(int, int, int)) {
        n = 0, m = 0; int u, v, w;
        for(;~GetEdge(u, v, w);++m) {
            n = std::max(n, u);
            n = std::max(n, v);
            adds(u, v, w);
        }
    }
    void Close() {
        if(rfp != NULL)
            fclose(rfp);
        if(wfp != NULL)
            fclose(wfp);
    }
}io;
//source:testio

二进制压缩文件

而在此基础上,由于数据量庞大,我们可以尝试使用二进制文件压缩。由于一个char是一个字节,然而事实上,我们只需要0~9和分隔符就可以得到整张图的信息,因此可以用一个字节存储两位的信息。将0到9和用0xa标志的分隔符压缩整个图的文件。

struct ios {
    FILE *rfp, *wfp;
    char num;
    ios() {rfp = wfp = NULL; num = -1;}
    void Open() {...}
    inline char getchar() {...}
    bool rewrite() {
        char c = getchar();
        if(!~c) {
            if(~num)
                num = num << 4 | 0xa,
                fwrite(&num, 1, 1, wfp);
            return false;
        }
        if(c >= '0' && c <= '9')
            c -= '0';
        else c = 10;
        if(!~num)
            num = c;
        else {
            num = num << 4 | c;
            fwrite(&num, 1, 1, wfp);
            num = -1;
        }
        return true;
    }
    void Work() {}
    void Close() { }
}io;
//source:gettxt.cpp

使用方式是:

>gettxt.exe
>INPUTFILE:
textinfo文件名(无后缀)
DATA SUCCESSFULLY LOAD

注意文件的格式必须为txt,并放在textdata目录下,最后会生成一个.bxt文件在predata文件夹下。

而相应地也需要修改读入的方式,每次读取的是两个数据。

struct ios {
    FILE *rfp, *wfp, *sfp;
    char c;
    char FILEPATH[100], NAME[100], n;
    ios() {
        c = -1; rfp = wfp = NULL;
    }
    void Open_R() {...}
    inline char getchar() {...}

    int read() {
        int x = c & 0xf;
        if(x >= 10) x = -1;
        c = getchar();
        if(!~c) return x;
        if(~x && ((c >> 4 & 0xf) == 0xa))
            return x;
        else {
            for(;c == 0xaa;) c = getchar();
            if(!~c)
                return -1;
        }
        if(!~x) x = 0;
        for(;1;) {
            if((c >> 4 & 0xf) == 0xa)
                break;
            x = (x << 1) + (x << 3) + (c >> 4 & 0xf);
            if((c & 0xf) == 0xa)
                break;
            x = (x << 1) + (x << 3) + (c & 0xf);
            c = getchar();
            if(!~c) break;
        }
        return x;
    }
    int GetEdge(int &u, int &v, int &w) {...}
    void LoadGraph(int &n, int &m, void (*adds)(int, int, int)) {...}
    void Get_st(int &s, int &t) {//获取源汇文件
    ...
    }
    void Close() {
        if(rfp != NULL)
            fclose(rfp);
        if(wfp != NULL)
            fclose(wfp);
    }
    void Open_W() {
    ...
    }
    template<typename T>void Print(T x) {
        static char st[20];
        static int tp;
        tp = 0;
        if(x == -1)
            return Print("-1");
        for(;x; x /= 10)
            st[++tp] = x % 10 + '0';
        for(int i = tp; i; --i)
            fwrite(st + i, 1, 1, wfp);
    }
    void Print(const char *s) {
        fwrite(s, 1, strlen(s), wfp);
    }
    void Print(const char c) {
        fwrite(&c, 1, 1, wfp);
    }
};
//soucrce:io.cpp

顺带地,采用了fwrite快速输出答案。全部放在io工具类中,在别的文件中include

读入效率对比

freadio bitstreamio
7081ms 5790ms
SIZEOFN:23947347 SIZEOFM:58333344

可以看到二进制压缩提升了1秒多的读入时长。

最初的起点:朴素的Dijstrak算法

主要的算法流程为,每轮寻找到当前距离原点最近的节点,将这个距离作为这个节点到原点的最短路径,而后用这个节点根据三角不等式更新其他节点的答案。

复杂度分析,每轮都要寻找最短路径的节点,最坏情况下搜索n次,所谓为$O(n^2+m)$

long long Dij(int s, int t) {
    if(s == t) return 0LL;
    D[s] = 0;
    for(int i = 1;i <= n; ++i) {
        int mn = 0; long long d;
        if(D[s] == 0) {
            mn = s;
            d = 0;
            D[s] = -1;
        }
        else {
            for(int j = 1;j <= n; ++j)
                if(D[j] > 0 && (!mn || D[mn] > D[j]))
                    mn = j;
            if(mn == t) return D[t];
            d = D[mn]; D[mn] = -1;
        }
        for(int i = pre[mn]; i; i = nxt[i])
            if(D[to[i]] != -1 && (!D[to[i]] || D[to[i]] > d + w[i]))
                D[to[i]] = d + w[i], pr[to[i]] = mn;
    }
    return -1;
}
//Source: EasyVersion.cpp

数据结构优化:zkw线段树和堆

我们主要的优化着力点在于寻找最小的到原点距离的节点。实际上就是要寻找某种数据结构可以维护某个区间,支持单点修改,删除和全局最小值的查询。

zkw线段树优化

线段树是一种基于分治的数据结构,其基本思想是将某个区间分成均等的两半,当前节点维护当前区间的信息,而左右子节点维护左右子区间的信息,依次递归形成一棵树形结构,查询的时候自顶向下查询,更新的时候自底向上更新。由于树的层数不超过$O(logn)$,更新的复杂度是$O(logn)$,查询的复杂度是$O(1)$,总复杂度为$O(mlogn+n)$

zkw线段树则是线段树的一种基于位操作的实现技巧。基本思想是直接构造一棵完全二叉树,维护$[0,2^n-1]$大小的区间,这样的好处是父子节点的编号关系为2倍或者2倍+1,可以快速寻址并且更新,而不需要递归。

int Be(int x, int y) {
    return D[x] < D[y] ? x : y;
}
void Up(int i, int v) {
    for(T[i += Nt] = v; i >>= 1; T[i] = Be(T[i << 1], T[i << 1 | 1])) ;
}
int Dij(int s, int t) {
    Nt = 1;
    for(;Nt <= n;)
        Nt <<= 1;
    D[0] = 1e9;
    D[s] = 0; Up(s, s);
    for(;T[1];) {
        int u = T[1]; Up(u, 0);
        if(u == t) return D[t];
        static int v, w; Edge *e;
        for(int i = pre[u]; i; i = e->nxt) {
            e = GetEdge(i); v = e->to; w = e->w;
            if(!D[v] || D[v] > D[u] + w)
                D[v] = D[u] + w, Up(v, v), pr[v] = u;
        }
    }
    return -1;
}
//Source:FastVersion_zkw.cpp

堆优化

堆是另一种可以维护上述操作的数据结构。其本质仍是用一棵完全二叉树维护所有的数据,性质在于父亲的序优先于儿子。

插入节点在结尾插入,并且一旦不满足序关系则交换父子。

删除节点弹出堆顶节点将堆最末尾的节点放到堆顶,若不满足序关系,和左右儿子中序优先的那个节点交换并如此递归。

虽然堆是完全二叉树,但是需要注意堆优化中可能有点的重复,所以复杂度和线段树优化略有不同,是$O(mlogm+n)$的。

struct Data {
    int u, d;
    Data(int _u = 0, int _d = 0) : u(_u), d(_d) {}
};
bool operator < (Data a, Data b) {return a.d < b.d;}
struct Heap {
    int sz, curlen;
    Data heap[N];
    bool Empty() {return !sz;}
    Data Pop() {
        Data u = heap[1], l = heap[sz--];
        int ch = 2, cur = 1;
        for(;ch <= sz;) {
            if(ch < sz && heap[ch + 1] < heap[ch])
                ++ch;
            if(l < heap[ch])
                break;
            heap[cur] = heap[ch];
            cur = ch;
            ch <<= 1;
        }
        heap[cur] = l;
        return u;
    }
    void Push(Data x) {
        int u = ++sz;
        for(;u != 1 && x < heap[u >> 1];)
            heap[u] = heap[u >> 1], u >>= 1;
        heap[u] = x;
    }
}Q;
int Dij(int s, int t) {
    D[0] = 1e9;
    D[s] = 0; Q.Push(Data(s, 0));
    for(;!Q.Empty();) {
        Data x = Q.Pop(); 
        if(D[x.u] == -1)
            continue;
        int d = D[x.u];
        if(x.u == t) return D[t];
        D[x.u] = -1;
        static int v, w; Edge *e;
        for(int i = pre[x.u]; i; i = e->nxt) {
            e = GetEdge(i); v = e->to; w = e->w;
            if(!D[v] || D[v] > d + w)
                D[v] = d + w, Q.Push(Data(v, D[v])), pr[v] = x.u;
        }
    }
    return -1;
}
//Source:FastVersion_Heap.cpp

基于点复杂度的优化:分块

首先思考一个问题,线段树和堆优化的复杂度真的完全优于朴素算法吗?

可以想想,当图为完全图的情况下,也就是$O(m)=O(n^2)$时,我们可以发现,线段树和堆优化的复杂度退化为$O(n^2logn)$。

瓶颈在于,线段树和堆优化扩大了边更新的复杂度开销。

事实上,我们可以尝试另一种优化方式,将点分成$\sqrt n$个块,记录每个块到源最小距离的点,这样的好处是更新的复杂度是$O(1)$的,而查询的复杂度是$O(\sqrt n)$的,那么总复杂度就是$O(n\sqrt n+m)$的,在稠密图的情况下理论效率高于堆优化和线段树优化。

struct Data {
    int u, d;
    Data(int _u = 0, int _d = 0) : u(_u), d(_d) {}
};
int bsz, mn[HSZ];
int Be(int x, int y) {return D[x] < D[y] ? x : y;}
void Up(int s) {
    int id = (s - 1) / bsz;
    mn[id] = Be(mn[id], s);
}
int Dij(int s, int t) { 
    bsz = sqrt(n) + 1;
    D[0] = 1e9;
    D[s] = 0; 
    Up(s);
    for(int i = 1;i <= n; ++i) {
        int u = 0;
        for(int j = 0;j * bsz + 1 <= n; ++j)
            u = Be(u, mn[j]);
        if(u == t) return D[t];
        int d = D[u]; D[u] = -1; 
        int id = (u - 1) / bsz, l = id * bsz + 1, r = std::min(n, (id + 1) * bsz);
        mn[id] = 0;
        for(int j = l;j <= r; ++j)
            if(D[j] != -1 && D[j])
                mn[id] = Be(mn[id], j);
        static int v, w; Edge *e;
        for(int i = pre[u]; i; i = e->nxt) {
            e = GetEdge(i); v = e->to; w = e->w;
            if(!D[v] || D[v] > d + w)
                D[v] = d + w, Up(v), pr[v] = u;
        }
    }
    return -1;
}
//source:SpecialVersion_Block.cpp

进阶探索:Dijstrak算法基于连通性的优化

到此为止,Dijstrak的复杂度上限的套论事实上已经基本结束。然而,在一些特殊情况下的图,Dijstrak仍有优化的空间。

分块算法解决了稠密图的情况下Dijstrak算法的缺陷。然而,当图较为稀疏时,例如,图退化为一棵树的时候,logn的复杂度开销其实是不必要的。

点双联通分量算法

事实上,从连通性的角度考虑,树的特殊性在于,所有点都是图的割点。

而我们可以想象,从源点走到汇点,实际上有一些点是必须经过的。我们对图运用Tarjan的点双联通分量算法,所有的点双联通分量事实上形成了一个树形结构。(从广义圆方树的角度理解,实际上就是圆方树上的路径)。那么我们实际上只需要通过s,t所在点双之间的树上路径上的所有点双联通分量上的割点,这样就可以吧最短路径转化为各个点双联通分量内部的问题。

那么在此基础上,我们设点双连通分量的节点为$S_i$,复杂度就可优化为$O(\sum Dijstrak(|S_i|,|E[S_i]|))$,其中$Dijstrak(n,m)$表示应用n个点m条边的点双连通分量算法。

这样,在不改变算法总体复杂度的前提下,我们优化了Dijstrak算法的下限。在树的情况下,每个点双连通分量大小都为2,复杂度退化为线性的。

并且,根据不同图的类型,我们还可以对每个点双连通分量应用不同的算法。也就是选择堆优化或者分块优化。

除了树以外,还可以考虑这样的真实情景:在不同大城市之间的交通链路,不同大城市之间可能只有唯一的一条交通方式,例如飞机。而大城市内部则可能有很多不同的交通方式,也就是好几个稠密图通过几条边连接成一个树-稠密图的结构。这种情况下用分块优化Dijstrak在理论上是较为优秀的。

两种算法的代码如下:

struct Block {
    int bsz, tot, mxb, mn[NN], ps[NN], id[N];
    void init(int s, int t) {
        bsz = sqrt(cursz) + 1;
        id[s] = id[t] = 0;
        tot = 0; mxb = -1;
    }
    int Be(int x, int y) {
        return D[ps[x]] < D[ps[y]] ? x : y;
    }
    void Up(int s) {
        if(!id[s])
            id[s] = ++tot, ps[tot] = s;
        int bid = (id[s] - 1) / bsz;
        if(mxb < bid)
            mxb = bid, mn[bid] = id[s];
        else
            mn[bid] = Be(mn[bid], id[s]);
    }
    int Getmn() {
        int u = 0;
        for(int i = 0;i <= mxb; ++i)
            u = Be(u, mn[i]);
        return ps[u];
    }
    void Pop(int u) {
        if(!id[u])
            printf("%d %d %d %d %d\n", u, cursz, tot, bsz, mxb);
        assert(id[u]);
        int bid = (id[u] - 1) / bsz;
        int l = bid * bsz + 1, r = std::min(tot, (bid + 1) * bsz);
        mn[bid] = 0;
        for(int j = l;j <= r; ++j)
            if(D[ps[j]] != -1 && D[ps[j]])
                mn[bid] = Be(mn[bid], j);
    }
}B;
int dfn[N], st[NN], cnt, be[N], tm, nx, d;
int Dij(int s, int t) {
    if(s == t) return 0;
    assert(be[s] == be[t]);
    static int v, w; Edge *e;
    if(cursz == 2) {
        D[t] = D[s] = 0;
        for(int i = pre[s]; i; i = e->nxt) {
            e = GetEdge(i); v = e->to; w = e->w;
            if(be[v] != be[s])
                continue;
            assert(t == v);
            if(!D[v] || D[v] > w)
                D[v] = w, pr[v] = s;
        }
        return D[t];
    }
    B.init(t, s);
    D[0] = 1e9;
    D[t] = D[s] = 0;
    B.Up(s);
    for(int i = 1;i <= n; ++i) {
        int u = B.Getmn();
        if(u == t) return D[t];
        int d = D[u]; D[u] = -1; 
        B.Pop(u);
        static int v, w; Edge *e;
        for(int i = pre[u]; i; i = e->nxt) {
            e = GetEdge(i); v = e->to; w = e->w;
            if(!D[v] || D[v] > d + w)
                D[v] = d + w, B.Up(v), pr[v] = u;
        }
    }
    return -1;
}
//source:FinalVersion_Block.cpp
int Dij(int s, int t) {
    if(s == t) return 0;
    assert(be[s] == be[t]);
    for(;!Q.Empty();)
        Q.Pop();
    D[0] = 1e9;
    D[t] = D[s] = 0; Q.Push(Data(s, 0));
    for(;!Q.Empty();) {
        Data x = Q.Pop(); 
        if(D[x.u] == -1)
            continue;
        int d = D[x.u];
        if(x.u == t) return D[t];
        D[x.u] = -1;
        static int v, w; Edge *e;
        for(int i = pre[x.u]; i; i = e->nxt) {
            e = GetEdge(i); v = e->to; w = e->w;
            if(be[v] != be[x.u])
                continue;
            if(!D[v] || D[v] > d + w)
                D[v] = d + w, Q.Push(Data(v, D[v])), pr[v] = x.u;
        }
    }
    return -1;
}
bool Dfs(int u, int fa, int &lowu) {
    lowu = dfn[u] = ++tm; 
    st[++tp] = u;
    bool flag = false;
    int v, w, lowv; Edge *e;
    for(int i = pre[u]; i; i = e->nxt) {
        e = GetEdge(i);
        if(i != (fa ^ 1)) {
            v = e->to; w = e->w;
            if(!dfn[v]) {
                if(Dfs(v, i, lowv)) {
                    flag = true;
                    if(lowv >= dfn[u]) {
                        be[u] = ++cnt;
                        for(;st[tp + 1] != v;)
                            be[st[tp--]] = cnt;
                        d += Dij(u, nx);
                        nx = u;
                    }
                }
                if(lowv >= dfn[u]) {
                    for(;st[tp + 1] != v;)
                        be[st[tp--]] = 0;
                }
                assert(lowv != 0);
                lowu = std::min(lowu, lowv);
            }
            else {
                assert(dfn[v] != 0);
                lowu = std::min(lowu, dfn[v]);
            }
        }
    }
    return u == t | flag;
}
//Source:FinalVersion.cpp

效率对比

产生数据

通过generate_data文件,可以产生不同的数据集。使用方法如下

>generate_data.exe
>INPUTDATANAME:
text(文件名)
MODE:
0/1/2(模式)

0/1/2模式分别代表了tree,稠密图以及稠密-树的数据格式。执行完毕后会在textdata文件下产生相应的.txt和_st.txt文件。之后使用gettxt.exe压缩即可产生不同的数据。

运行结果

运行之后的结果如下

堆优化 线段树优化 块优化 点双块 点双堆 图类型
13974ms 20187ms * * 21706ms 超大图
4260ms 19515ms * 370ms 369ms 树大图
31ms 38ms 154ms 200ms 100ms 中图
902ms 200ms 216ms 3966ms 3310ms 稠密图
2012ms 1871ms * 1034ms 1087ms 树稠密图

数据分析

在此环境下,我们得到了一下几点结论

堆优化的优越性

从综合能力上来看,堆优化能适用于在大多数情况。虽然在边数较多的情况下理论上其复杂度高于线段树优化,然而实际环境下其效率比线段树优化高得多。

得到这一结论的分析是,由于堆优化的实现方式是动态加点删点,在随机的情况下堆实际上是不满的,由于只有在更新的条件才会把点插入堆中,只有在边权相差较小的情况下才会跑满$O(logn)$,其余情况下基本是上类线性的。

点双堆下限的有效优化

可以看到,在树和树稠密图类型的图中,点双联通算法较为有效地优化了复杂度的下限。尤其是在树的算法中。

分块优化的缺陷

事实上,除了分块优化,其余的算法基本上都展现出了应用的理论复杂度。然而分块优化的尴尬点在于,在稠密图的情况下,实际上Dijstrak算法的更新次数较少,只有在一些边权差距不大的图中才会有多次更新,而且在点数较小的情况下且更新次数较多的情况下,线段树优化的优越性逐渐展现了出来,常数较小的$logn$超越了分块算法。因此分块算法尽管理论上在稠密图中由于其余数据结构,在实际环境下的效果远不如堆优化和线段树优化,这一点在点双的堆和块中也可以看出来,这两者的复杂度实际上相差不大。

不过当面对更加庞大的图的情况,分块优化在理论上的复杂度优化仍不容忽视,而且也不排除作者本身随机构造数据缺陷,其真正的价值仍然有待研究。

About

big homework for datasturcture, optimize the dijstrak algorithm use different datastructure.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages