#import "template.typ": * // ================================================== // Config Variables // ================================================== #show: base.with( cur_domain: 1, total_domain: 5, domain_name: "Research", domain_detail: "본질을 향한 여정", date: "2026", author: "Seungjun Lee", main-hue: 60deg, ) // ================================================== // Main content // ================================================== #cover() #counter(page).update(1) #table_of_contents() #domain_frame( bg_img_path: "assets/research/bg.jpg", cover_color: rgb("0F0F0F").transparentize(40%), font_color: color.oklch(95%, 0.01, 250deg), chrome_color: color.oklch(80%, 0.01, 250deg), title: [본질을 향한 여정], )[ 나는 세상을 하나의 질문으로 이해하고 싶었다. \ 답을 알수록 새로운 질문이 늘어난다는 것도, \ 그 질문들이 결국 하나로 이어지지 않는다는 것도 나중에 알았다. 생명 수업에서 인체를 배울 때 이 사실을 가장 선명하게 느꼈다. \ 나는 인간이면서도 내 몸 안에서 무슨 일이 일어나는지 인지하지 못한다. \ 신경계가, 세포가 어떻게 상호작용하는지 하나의 그림으로 설명하려 할수록 \ 설명은 흐려졌다. 전체를 통째로 이해하려는 시도는 매번 그렇게 무너졌다. \ 대신 남은 방법은 하나였다 — 쪼개서 이해하고, 그 이해들을 다시 쌓아 올리는 것. \ 전체가 아니라 부분에서, 이론이 아니라 실제 관찰에서 다시 시작하는 것. 이후의 모든 연구는 이 하나의 발견에서 출발한다. \ 그래프 알고리즘의 이론적 시간복잡도가 실제 실행 시간과 어긋나는 지점에서, \ 사회 현상을 하나의 법칙이 아니라 여러 변수의 상호작용으로 다시 볼 때, \ 그리고 인간의 선택이 이론적 모형을 벗어나는 순간에서 — \ 나는 같은 방법을 반복했다. 쪼개고, 어긋난 지점을 찾고, 다시 쌓아 올렸다. ] #activity_cover( name: [Dijkstra Runtime Analysis], hook: [Big-O가 버린 세부 실행비용을 다시 세우고, 실행해야만 알 수 있던 키 감소 횟수를 그래프 특성만으로 구해, 이진 힙 Dijkstra의 런타임 변동 96.9%를 실행 전에 설명한 모델.], )[ 네비게이션, 자율주행, 우주항공 등 많은 현대 기술들에게 최적 경로를 탐색하는 것은 중요한 일이며, Dijkstra 알고리즘은 최적 경로를 도출하는 가장 효율적인 알고리즘이다. \ 그러나 자율주행과 같이 실시간성과 속도가 중요한 문제가 많아지자, 휴리스틱 기반의 A\* 알고리즘들이 적용되었다. 이들은 속도는 빠르지만, 항상 최적해를 보장하지는 않한다는 트레이드오프가 존재한다. \ 상황에 따라 적절한 알고리즘을 선택하면 될 듯 하지만, Big-O 시간복잡도는 노드·간선 수 등 입력 크기에 따른 점근적 증가율만 나타낼 뿐, 상수항이나 그래프의 특성, 기기 성능 같은 요인은 반영하지 않기 때문에 Dijkstra의 실제 실행시간이 충분한지 알기 어렵다. \ 그래서 *"이 트레이드오프를 감이 아닌 숫자로 계산해서 상황에 따른 정확한 알고리즘을 선택할 수는 없을까?"*라는 질문에서 출발하여 입력 그래프와 기기 성능만으로 Dijkstra의 실제 실행시간을 예측하는 모델을 연구했다. IB 소논문 활동에서 시작해 계속 발전시켜 현재는 외부 학술지에도 기제된 연구로, 목표는 그래프의 노드 수, 간선 수, 가중치의 분산, 기기 성능만으로 이집 힙이 적용된 Dijkstra의 실행시간을 예측하는 것이다. ] // #activity_intro( // title: "Big-O가 답하지 못하는 것", // )[ // 네비게이션, 자율주행, 우주항공 등 많은 현대 기술들에게 최적 경로를 탐색하는 것은 중요한 일이며, // Dijkstra 알고리즘은 최적 경로를 도출하는 가장 효율적인 알고리즘이다. \ // 그러나 자율주행과 같이 실시간성과 속도가 중요한 문제가 많아지자, 휴리스틱 기반의 A\* 알고리즘들이 적용되었다. 이들은 속도는 빠르지만, 항상 최적해를 보장하지는 않한다는 트레이드오프가 존재한다. \ // 상황에 따라 적절한 알고리즘을 선택하면 될 듯 하지만, Big-O 시간복잡도는 노드·간선 수 등 입력 크기에 따른 점근적 증가율만 나타낼 뿐, 상수항이나 그래프의 특성, 기기 성능 같은 요인은 반영하지 않기 때문에 Dijkstra의 실제 실행시간이 충분한지 알기 어렵다. \ // 그래서 *"이 트레이드오프를 감이 아닌 숫자로 계산해서 상황에 따른 정확한 알고리즘을 선택할 수는 없을까?"*라는 질문에서 출발하여 입력 그래프와 기기 성능만으로 Dijkstra의 실제 실행시간을 예측하는 모델을 연구했다. // IB 소논문 활동에서 시작해 계속 발전시켜 현재는 외부 학술지에도 기제된 연구로, 목표는 그래프의 노드 수, 간선 수, 가중치의 분산, 기기 성능만으로 이집 힙이 적용된 Dijkstra의 실행시간을 예측하는 것이다. // ] #activity_table() #let RS = math.italic("RS") #let RSR = math.italic("RSR") #activity_process( title: [*통제된 환경*], hook: [통제된 환경에서 벗어나다.], )[ #problem()[ 실행 시간 측정의 신뢰도와 타당도를 높이기 위해서는 일정한 환경을 유지하는 것이 중요하다. 그러나 일반적인 데스크탑이나 노트북 환경에서는 통제된 환경을 구현하기 어렵다. ] #hl[] #solution()[ 운영중인 홈서버의 Proxmox VE에 특정한 스펙의 Debian CT를 만들어 환경을 통제한다. ] #figure( image("assets/research/dijkstra/pve.png"), caption: [CPU: AMD Ryzen 7 7700 8-Core Processor (4cores, Host) \/ RAM: 8GB \/ \ OS: Debian GNU/Linux 13 (trixie) \/ Kernal: Linux 6.8.12-17-pve \/ Python: Python 3.12], ) ] #activity_process( title: "Big-O의 한계를 넘어서다", )[ #problem()[ `Big-O` 시간복잡도는 최고차항을 제외한 모든 항들을 무시하고, 계수마자 없앤다. \ 그렇기 때문에 시간복잡도가 $O(n^2)$인 알고리즘에 $n$을 넣는다고 실행시간을 구할 수 없다. ] #hl[] #solution()[ Dijkstra 알고리즘을 의사코드를 기반으로 세부 작업들로 쪼개어 실행시간 식을 도출한다. ] #block_equation(description: [ $V$ : 노드 수, $E$ : 간선 수, $RS$ : 키 감소 실행 횟수 ])[ $ "Runtime" & = "Heap build" + "Min-node find" + "Vertex Iterate" + "Decrease key" \ & = "V" dot k_1 log(V) + V dot k_2 log(V) + E dot k_3 + RS dot k_4 log(V) \ & = V dot k_12 log(V) + E dot k_3 + RS dot k_4 log(V) $ ] 이진 힙이 적용된 Dijkstra 알고리즘은 크게 위의 4개의 동작으로 나눌 수 있다. \ 각각의 시간 복잡도와 계수 그리고 실행 횟수의 곱을 모두 더하면 전체 실행 시간이 나온다. \ 이때, 첫 두 항의 문자 부분이 같기 때문에 인수분해를 통해 두 식을 합친다. 이때 개별 항의 계수를 구하는 것이 목적이 아니기 때문에 두 계수를 합쳐 $k_12$라고 한다. 이 식을 통해 $V, E, RS, k_12, k_3, k_4$만 알면 Dijkstra의 실행시간을 구할 수 있게 된다. ] #activity_process( title: "존재하지 않는 데이터를 만들다", )[ #problem()[ 식을 학습시키려면 방대한 양의 실제 경로 데이터가 필요했다. 그러나 현실에서는 학습에 필요한 만큼의 실제 경로 데이터를 구할 수 없다. ] #hl[] #solution()[ 실제 경로 데이터의 특성들(크기, 밀도, 가중치 분포 등)을 분석하여, 이를 토대로 가상의 경로 데이터를 제작한다. ] #figure(image("assets/research/dijkstra/hist_original.png", width: 100%), caption: [원데이터 분포]) #grid( columns: (auto, auto), figure( image("assets/research/dijkstra/hist_log.png"), caption: [$log$ 데이터 Q-Q 플롯], ), figure( image("assets/research/dijkstra/Q-Q_log.png"), caption: [$log$ 데이터 Q-Q 플롯], ), ) 원데이터의 분포와 Q-Q 플롯을 통해 데이터가 양의 왜도를 지닌 것을 파악하여, 가중치에 $log$를 취한 후 다시 Q-Q 플롯을 그리니 정규성을 보였다. #figure( table( columns: (auto, auto), [*그래프 밀도*], [$tilde.eq 1.02 dot 10^(-07)$], [*가중치 평균*], [$tilde.eq 2950$], [*표준편차 평균*], [$tilde.eq 4070$], ), caption: [실제 경로 데이터 분석 값], ) 이를 바탕으로 그래프의 밀도와 가중치에 대한 추가적인 특성들을 분석하였다. #figure( table( columns: (auto, auto), [*노드 수*], [${2000 dot 2^k | k = 0, 1, 2, 3}$], [*그래프 밀도*], [${10^k, 3 dot 10^k | k = -7, -6, ..., -1}$], [*표준편차*], [${1000, 1500, 2000, 3000, 4000, 6000, 8000, 12000, 16000}$], [*개수*], [$10$], [*가중치 평균*], [$3000$], [*시작 노드*], [$0$], [*랜덤 시드*], [$42$], ), caption: [데이터 생성 매개변수 조합], ) 분석 값들을 바탕으로 적절한 노드 수, 그래프 밀도, 가중치 분산 3개 변수의 쌍으로 로그 정규분포를 띄는 가상 그래프를 864,000개 생성했다. #figure( image("assets/research/dijkstra/synthetic_data_code.png"), caption: u_link( "https://gitea.seung6lee.com/seung6lee/dijkstra-runtime-analysis/src/branch/main/codes/experiments/synthetic_data/graph_generators/outdegree_graph_generator.py", )[outdegree 기반 랜덤 경로 데이터 생성 프로그램], ) #figure( image("assets/research/dijkstra/synthetic_data.png"), caption: u_link( "https://gitea.seung6lee.com/seung6lee/dijkstra-runtime-analysis/src/branch/main/codes/results/real_data/raw/dimacs_s42.csv", )[가상 경로 데이터 864,000개], ) ] #activity_process( title: "실행해야만 알 수 있는 값", )[ #problem()[ 모델을 학습시켜 가중치 $k$들을 알아낼 수 있다. 그리고 $V$와 $E$는 그래프에서 쉽계 계산할 수 있다. 그러나 $RS$는 Dijkstra를 실행하지 않으면 알 수 없다. 예측을 위해 실행이 선행되어야 하는 모순이 발생하는 것이다. ] #hl[] #solution()[ 그래프의 특성만으로 Dijkstra 실행 없이 $RS$를 예측하는 방법을 찾는다. ] #steps( ( [$bold(RS)$ 재정의], [ $RS$를 그 자체가 아니라, 다른 변수들로 쪼개어서 정의한다. #block_equation()[ $ RS = "Relax Attemps" times "Relax Success Ratio" = E times RSR $ ] 시도 횟수는 $E$라는 것을 알기에, $RSR$만 예측하면 된다. \ $RSR$은 비율이기에 절대량이 아닌 밀도나 분산 등에 영향을 받을 것이라고 판단한다. #fill_break() ], ), ( [$bold(RSR) thin \& thin bold(sigma)$], [ #figure( image("assets/research/dijkstra/sigma_rsr_01.png", width: 60%), caption: [$V = 2000, thin D = 0.01$ 에서의 $sigma "vs" RSR$], ) 각 $(V, D)$ 쌍에 대해 $(sigma, RSR)$ 그래프를 그린 결과, 대부분의 경우 완벽한 로그 함수 관계를 보인다. #grid( columns: (1fr, 1fr), gutter: 1em, figure( image("assets/research/dijkstra/sigma_rsr_02.png", width: 100%), caption: [$V = 2000, thin D = 3 dot 10^(-5)$ 에서의 $sigma "vs" RSR$], ), figure( image("assets/research/dijkstra/sigma_rsr_03.png", width: 100%), caption: [$V = 2000, thin D = 0.0003$ 에서의 $sigma "vs" RSR$], ), ) 그러나 $sigma$값에 관계없이 항상 $RSR=1$이거나, 경향성이 전혀 없는 경우도 나타난다. #block_equation(description: [ $a, b$는 상수 ])[ $ RSR = a dot log(sigma) + b $ ] $log$ 관계를 가정하고 위와 같이 $RSR$과 $sigma$의 관계를 식으로 나타내었다. #fill_break() ], ), ( [$bold(RSR) thin \& thin bold(D)$], [ #figure( image("assets/research/dijkstra/density_rsr.png", width: 60%), caption: [$V = 10000, thin sigma = 0.3246$ 에서의 $D "vs" RSR$], ) 각 $(V, sigma)$ 쌍에 대해 $(D, RSR)$ 그래프를 그린 결과, 초반에 급격하게 감소하다 완만해지고 꼬리가 길게 남는 개형을 보인다. \ 이는 멱법칙의 특성과 부합하여, $(log(D), log(RSR))$ 그래프를 그려본다. #figure( image("assets/research/dijkstra/density_rsr_loglog.png", width: 60%), caption: [$V = 10000, thin sigma = 0.3246$ 에서의 $log(D) "vs" log(RSR)$], ) 특정 임계점을 기준으로 왼쪽은 항상 $RSR = 1$인 구간이고, 오른쪽은 두 변수가 완벽하게 멱법칙을 만족하는 구간이 나온다. \ 이 임계점의 위치와 의미를 설명하려 했으나, 해당 지점의 $D$나 $log(D)$의 유의미한 특징을 발견하지 못하였다. #fill_break() ], ), ( [$bold(D)$ 분석], [ $D$를 다른 변수로 대체해보았다. \ 방향 그래프에서 $D = E / V(V - 1)$로 나타낼 수 있고, 평균 차수는 $macron(k) = E \/ V = (V - 1) D$로 나타낼 수 있다. $D$대신 $macron(k)$를 대입해 그래프를 그려보았으며, 이때 $V$는 통제 변인으로 각 그래프에서 상수로 취급된다. #grid( columns: (1fr, 1fr), figure( image("assets/research/dijkstra/avgdeg_rsr_loglog.png"), caption: [$V = 10000, thin sigma = 0.3246$ 에서의 $log(macron(k)) "vs" log(RSR)$], ), figure( image("assets/research/dijkstra/phase_histogram.png"), caption: [$macron(k)$에 따른 경로 그래프 양상 분포], ), ) 그 결과, 임계점이 항상 $macron(k) = 1$인 구간에서 발생한다. 이 지점은 각 노드에서 출발하는 간선이 최소 1개씩 있는 경우이다. 이 현상은 Erdős-Rényi 모델의 3가지 상과 연결된다는 것을 파악했다. 해당 모델은 $G(n,p)$ 그래프를 $n p$의 값에 따라 아임계, 임계, 초임계 구간으로 분류한다.\ $sigma$ 분석에서의 예외 상황들도 아임계 구간과 임계 구간으로 설명이 가능해졌다. #block_equation(description: [ $c, d$는 상수 ])[ $ RSR = c dot macron(k)^d quad (macron(k) > 1) $ ] $macron(k) > 1$인 조건에서 위와 같이 $RSR$과 $d$의 관계를 식으로 나타내었다. #fill_break() ], ), ( [최종 식 도출], [ $sigma$ 분석 과정에서 발견한 Erdős-Rényi 모델과의 연결성을 통해 $d$ 분석 과정에서의 한계를 극복하여, $sigma$와 $d$ 각각과 $RSR$간의 식을 확정하였다. 이때 $sigma$와 $d$는 그래프에서 서로 독립적이기 때문에 두 관계식을 곱하면 두 변수의 영향을 모두 반영하는 식을 도출할 수 있다. #block_equation(description: [ $alpha, beta, gamma$는 상수 ])[ $ RSR & = {a dot log(sigma) + b} times {c dot macron(k)^d} quad (macron(k) > 1) \ & = macron(k)^d times {a c dot log(sigma) + b c} \ & = macron(k)^alpha times {beta dot log(sigma) + gamma} $ ] ], ), ( [회귀 분석], [ #figure( image("assets/research/dijkstra/rsr_regression.png", width: 50%), caption: [비선형 회귀 결과 ( $alpha = 0.0612 plus.minus 0.000202, thin beta = 0.928 plus.minus 0.000171, thin gamma = -0.711 plus.minus 0.000183$)], ) 제작한 864,000개의 데이터에 대해 비선형 회귀를 진행하였더니 $RSR$에 대해 $R^2 = 0.9947$에 달하는 적합에 성공하였다. 이를 통해 Dijkstra 실행 없이도 $99%$의 정확도로 $RS$를 예측할 수 있게 되었다. ], ), ) ] #activity_process(title: "다중공선성")[ #problem()[ #block_equation(description: [ $V$ : 노드 수, $E$ : 간선 수, $RS$ : 키 감소 실행 횟수 ])[ $ "Runtime" = V dot k_12 log(V) + E dot k_3 + RS dot k_4 log(V) $ ] 이제 회귀 분석을 통해 $k_12, k_3, k_4$만 구하면 된다. 그러나 $k_12$와 $k_4$가 포함된 두 항이 유사하다. 공통적으로 $log(V)$가 있으며, $V$와 $RS$도 양의 상관관계를 가진다. 따라서 회귀분석 시행하면 다중공선성이 발생할 가능성이 높다. ] #hl[] #solution()[ 모든 데이터를 한번에 회귀하는 것이 아니라, 고유한 $V$별로 부분 회귀를 하고 결과를 종합하는 방법을 고안했다. #block_equation()[ $ "UC"_1(V) & = k_12 log(V) \ "UC"_2(V) & = k_4 log(V) \ "Runtime" & = V dot "UC"_1(V) + E dot k_3 + RS dot "UC"_2(V) $ ] $V = V_i$인 데이터로 회귀를 하면 $"UC"_1(V_i), "UC"_2(V_i), k_3$가 도출된다. \ 이때 $"UC"_1(V)$과 $"UC"_2(V)$는 $V$에 대해 $log$ 관계를 가져야하고, $k_3$는 상수여야한다. ] ] #activity_process(title: "이론과 결과의 괴리")[ #callout(title: [$bold("UC"_1(V_i)), bold("UC"_2(V_i)), bold(k_3)$ 적합 결과])[ #grid( columns: (1fr, 1fr), gutter: 1em, figure( image("assets/research/dijkstra/node_uc1.png", width: 100%), caption: [$"UC"_1(V)$], ), figure( image("assets/research/dijkstra/node_uc2.png", width: 100%), caption: [$"UC"_2(V)$], ), ) #figure( image("assets/research/dijkstra/node_k3.png", width: 60%), caption: [$"k"_3(V)$], ) ] #problem()[ $"UC"_2(V)$는 $V$와 정확한 $log$ 관계를 보였다.\ 그러나 $"UC"_1(V)$는 어떠한 경향성도 보이지 않았으며, \ $k_3$는 고정되지 않고 우하향의 추세를 보였다. ] #hl[] #solution()[ #figure( image("assets/research/dijkstra/contribution_vs_N.png", width: 100%), caption: [세부 작업별 전체 실행시간 차지 비율], ) $"UC"_1(V)$의 경향성은 파악이 불가능했으나, 전체 런타임에서 $V dot "UC"_1(V)$항이 차지하는 비율이 $V lt.eq.slant 20000$ 인 지점에서 평균 $4.2 %$ 이하라는 것을 고려해 평균을 대푯값으로 사용하기로 하였다. $k_3$의 우하향 추세는 파이썬의 캐싱 효과로 인한 것이라고 추정하였다. \ 실행시간은 양수여야한다는 점을 고려하여 지수 포화 함수로 모델링하였다. $"UC"_2(V)$는 $log$ 함수 적합을 통해 성공적으로 $k_4$를 구했다. ] ] // #activity_result(title: "예측할 수 있게 된 것")[ // #block_equation( // title: [키 감소 횟수], // description: [$V$ : 노드 수, $E$ : 간선 수, $sigma$ : 가중치 표준편차], // )[ // $ // RSR(macron(k), sigma) & = macron(k)^0.0612 (0.928 log sigma - 0.711), quad // macron(k)(V, E) & = E \/ V \ // RS(V, E, sigma) & = E dot RSR(macron(k)(V, E), sigma) // $ // ] // 실행해야만 알 수 있던 $RS$를 세 변수만으로 계산할 수 있게 되었다. // #block_equation( // title: [단위 실행시간], // description: [단위 : 초], // )[ // $ // "UC"_1 & = 2.15 times 10^(-7) \ // k_3(V) & = 1.59 times 10^(-7) e^(-5.67 times 10^(-5) V) + 4.37 times 10^(-8) \ // "UC"_2(V) & = 1.06 times 10^(-6) log_2 V - 1.09 times 10^(-5) // $ // ] // 세 값은 모두 $V$의 함수이거나 상수가 되어 식에 남아 있던 미지수가 전부 사라진다. // #block_equation( // title: [최종 모델], // description: [입력 : $V$, $E$, $sigma$], // )[ // $ // "Runtime"(V, E, sigma) = V dot "UC"_1 + E dot k_3(V) + RS(V, E, sigma) dot "UC"_2(V) // $ // ] // 최종 식의 세 변수 모두 실행 전에 그래프에서 직접 추출할 수 있는 값이기에, \ // *Dijkstra를 한 번도 실행하지 않고 실행시간을 예측할 수 있다.* // ] // #activity_result(title: "남은 오차")[ // #figure( // image("assets/research/dijkstra/comparison_plot.png"), // caption: [실제 경로 데이터에 대한 실행시간 예측과 $RS$ 예측], // ) // #figure( // image("assets/research/dijkstra/predicted_vs_real.png", width: 60%), // caption: [생성 그래프에 대한 예측값과 실측값], // ) // 내삽에 대해 실행 시간 예측에 대해서 $96.9%$를 설명에 성공했다. \ // 게다가 실제 경로 데이터(외삽)에서도 $RS$예측에 대해 일관된 정확도를 보였다. \ // 그러나 실전 상황에서 그래프가 커질수록 실행시간 예측은 과대평가하는 경항이 보였다.\ // 이 결과들에서 내 연구의 의의와 한계가 뚜렷하게 드러난다. \ // $RS$는 그래프의 구조만으로 결정되므로, 그래프의 근본적인 특성까지 쪼개었더니 정확하게 설명이 가능해졌으나, 실행시간은 파이썬 아래의 캐싱이나 하드웨어적 특성까지 쪼개지 못해 예측과 실측값 차이가 벌어졌다. // #table( // columns: (auto, 1fr), // table.header([*문제 상황*], [*극복 방법*]), // [학습 데이터의 부재], [분포를 분석해 864,000개를 직접 생성], // [실행해야 알 수 있는 값], [$E times RSR$로 쪼개 미지수를 하나로 축소], // [다중공선성], [$V$별 개별 회귀 후 기여도 기준으로 정밀도 배분], // ) // 모든 문제 상황에서는 직면한 큰 문제를 작은 단위들로 쪼개고, 작은 문제들의 해결책들을 종합하여 전체 문제를 해결해나갔다. // 실행시간 측정을 위한 통제된 환경은 직접 구축한 홈서버의 가상화 환경(PVE)를 통해 구현했다. // 이제 남은 오차는 파이썬이라는 언어 아래의 저차원에 위치해 있다. \ // 카이스트에서 언어와 하드웨어 수준의 저차원 요소를 배워 이 모델의 정확도를 끌어올리고, 완성한 모델을 실제 임베디드 시스템에서 알고리즘 선택의 지표로 적용하려 한다. // #context ref_evidence() // ] #add_evidence( activity_ref: ref(label("Dijkstra Runtime Analysis")), ( [소스 코드 및 자료], u_link( "https://gitea.seung6lee.com/seung6lee/dijkstra-runtime-analysis", )[https://gitea.seung6lee.com/seung6lee/dijkstra-runtime-analysis], ), ( [International Journal of STEAM 기재], [ #image("assets/research/dijkstra/short_paper_01.png", height: 70%) #image("assets/research/dijkstra/short_paper_02.png") ], ), ( [정리 노트], { for i in range(1, 15) { let path = "assets/research/dijkstra/notes/note_" + pad_zeros(i, 2) + ".jpg" image(path) } }, ), ( [논문 본문], { for i in range(1, 17) { let path = "assets/research/dijkstra/paper/paper_" + pad_zeros(i, 2) + ".png" image(path) } }, ), ) #evidence()