Introduction биз колдонуу менен бардык жуптардын эң кыска жол маселесин кантип чечүүнү көрдүк. Биз ошондой эле параллелизмди жана векторизацияны колдонуу менен алгоритмдин иштешин жакшыртуунун бир нече жолдорун изилдедик. Мурунку постто Флойд-Уоршалл алгоритмин Бирок, Флойд-Уоршалл алгоритминин натыйжалары жөнүндө ойлонсоңуз, кызыктуу учурду тез эле түшүнөсүз – биз графиктеги чокулардын ортосундагы аралыктарды (эң кыска жолдордун маанилери) билебиз, бирок “маршруттарды” билбейбиз, б.а. биз ар бир эң кыска жолго кандай чокулар салым кошоорун билбейбиз жана биз бул “маршруттарды” бизде болгон натыйжалардан кайра кура албайбыз. Бул постто мен сизди мага кошулууга жана Флойд-Уоршалл алгоритмин кеңейтүүгө чакырам. Бул жолу биз аралыкты эсептеп, “маршрутту” кайра түзө аларыбызды текшеребиз. Бир аз белгилүү теория… Кодго жана эталондорго кирүүдөн мурун, келгиле, Флойд-Уоршалл алгоритми кандай иштээри жөнүндөгү теорияны кайра карап чыгалы. Бул жерде алгоритмдин тексттик сүрөттөлүшү: Биз өлчөмүндөгү ( ) графигин өлчөмүндөгү ( ) матрица катары көрсөтүүдөн баштайбыз, мында ар бир уячасы чокусунан чокусуна чейинки четтин салмагын камтыйт (1-сүрөттү караңыз). Чокулардын ортосунда чек жок болгон учурларда, уячага атайын мааниси коюлат (1-сүрөттө кара уячалар катары көрсөтүлгөн). N G N x N W W[i,j] i j NO_EDGE Мындан ары биз айтабыз – жана чокуларынын ортосундагы аралыкты камтыйт. W[i,j] i j Андан кийин, биз чокусун алып, чокуларынын бардык жуптары аркылуу кайталап, аралыктын жана ортосундагы азыркы белгилүү аралыктан кичине экенин текшеребиз. k W[i,j] i ⭢ k ⭢ j i j Эң кичине маани ичинде сакталат жана №3 кадам -тин бардык чокулары катары колдонулганга чейин кийинки үчүн кайталанат. W[i,j] G k k Бул жерде псевдокод: algorithm FloydWarshall(W) do for k = 0 to N - 1 do for i = 0 to N - 1 do for j = 0 to N - 1 do W[i,j] = min(W[i,j], W[i,k] + W[k,j]) end for end for end for end algorithm Бүткөндөн кийин, матрицасынын ар бир уячасы же жана чокуларынын ортосундагы эң кыска жолдун аралыгын же алардын ортосунда жол жок болсо, маанисин камтыйт. W W[i,j] i j NO_EDGE Мен башында айтып өткөндөй – ушул гана маалыматка ээ болуу бул чокулардын ортосундагы каттамдарды кайра курууну мүмкүн эмес кылат. Анда... бул каттамдарды калыбына келтирүү үчүн эмне кылышыбыз керек? Ооба… бул ачык угулушу мүмкүн, бирок… биз көбүрөөк маалымат чогултушубуз керек! … Бир аз эле теория, бирок башка бурчтан Флойд-Уоршалл алгоритминин маңызы орто чокусу аркылуу чейинки потенциалдуу жаңы жолдун аралыгы менен белгилүү аралыктагы бөлүм болуп саналат. k i j W[i,j] Мен бул деталга абдан көп көңүл буруп жатам, анткени бул биз маршруттук маалыматты кантип сактай аларыбыздын ачкычы. Мурунку 5 чокудан турган графикти алалы (2-сүрөттү караңыз) жана ага Флойд-Уоршалл алгоритмин аткаралы. Башында, биз графиктин бардык четтери жөнүндө билебиз, бул бизге төмөнкү жолдорду берет: , , , , жана . 0 ⭢ 1 :2 0 ⭢ 4 :10 1 ⭢ 3 :1 2 ⭢ 4 :1 3 ⭢ 2 :1 3 ⭢ 4 :3 Жолдорду жазуу үчүн "ден" ⭢ "чейин" :"дистанция" форматын колдоном. мурунку посттун - Автордун эскертүүсү Биз ошондой эле чокусуна алып баруучу чектер жок экенин билебиз, андыктан үчүн алгоритмди аткаруунун мааниси жок. чокусунан чокусуна алып баруучу бир чети ( ) бар экени да ачык көрүнүп турат, бул да бардык ( бул жерден “бул жерден”) аткарылышын таптакыр маанисиз кылат, анткени чокусу жана менен четтери бар, ошондой эле же эмес ар кандай үчүн алгоритмдерди аткаруунун мааниси жок ( бул жерде “to”). 0 k = 0 0 1 0 ⭢ 1 i != 0 i 1 2 4 2 4 j j Ошондуктан биринчи кадамыбыз , жана үчүн алгоритмди аткаруу болот. k = 1 i = 0 j = 2,4 Кадам Жол Комментарий 1 0 ⭢ 1 ⭢ 2 Жол табылды. Расстояние = 3 (эч нерсе болгон эмес) 2 0 ⭢ 1 ⭢ 4 Жол табылды. Аралык = 8 (10 болгон). Биз эки жолду таптык: жаңы жол ( ) жана жарлык ( ). Экөө тең чокусу аркылуу өтөт. Эгер биз бул маалыматты ( жана менен чейин болгон факты) азыр бир жерде сактабасак, ал биротоло жоголуп кетет (жана бул биз каалаган нерсеге таптакыр карама-каршы). 0 ⭢ 1 ⭢ 2 0 ⭢ 1 ⭢ 4 1 2 4 1 Анда биз эмне кылышыбыз керек? Биз матрицасын жаңы маанилер менен жаңыртабыз (3а сүрөттү караңыз), ошондой эле маанисин (ал ) бирдей өлчөмдөгү жаңы матрицасынын жана уячаларында сактайбыз. матрицасы катары, бирок маанилери менен инициализацияланган (3b сүрөтүн караңыз). W k k = 1 R R[0,2] R[0,4] W NO_EDGE Азыр матрицасы эмне экенине көңүл бурбаңыз. Келгиле, уланталы жана кийинки үчүн алгоритмди аткаралы. R k = 2 Бул жерде биз бирок бул жолу графигинин ордуна матрицасын колдонобуз. матрицасын караңыз, өзгөчө №2 тилкеде жана №2 сапта (4-сүрөт). k = 1, G W W Бул жерден көрө аласыз, чокусуна чокусунан жана чокусунан (№2 мамыча) эки жол жана чокудан чокусуна жана чокусуна (№2 сап) эки жол бар. Муну билип туруп, алгоритмди , жана комбинациялары үчүн гана аткаруунун мааниси бар. 2 0 1 2 3 4 k = 2 i = 0,1 j = 3,4 Кадам Жол Комментарий 1 0 ⭢ 2 ⭢ 3 Жол табылды. Расстояние = 4 (эч нерсе болгон эмес) 2 0 ⭢ 2 ⭢ 4 Жол табылды. Расстояние = 6 (болгон 8) 3 1 ⭢ 2 ⭢ 3 Жол табылды. Аралык = 2 (эч нерсе эмес) 4 1 ⭢ 2 ⭢ 4 Жол табылды. Расстояние = 4 (болгон 6) Буга чейин жасагандай, биз , , , уячаларын жаңы аралыктар жана уячалары менен жаңыртып жатабыз. , жана менен (5-сүрөттү караңыз). W[0,3] W[0,4] W[1,3] W[1,4] R[0,3] R[0,4] R[1,3] R[1,4] k = 2 Иштетүү үчүн болгону калды (анткени графиктин чокусунан башка чокусуна алып баруучу чектер жок). k = 3 4 Дагы бир жолу, матрицасын карап көрөлү (6-сүрөт). W матрицасына ылайык, , жана чокуларынан чокусуна үч жол бар (№3 мамыча), ал эми чокудан чокусуна чейин бир жол бар (№3 катар). Ошондуктан, биз иштетүү үчүн төмөнкү жолдору бар: W 0 1 2 3 3 4 Кадам Жол Комментарий 1 0 ⭢ 3 ⭢ 4 Жол табылды. Расстояние = 5 (болгон 6) 2 1 ⭢ 3 ⭢ 4 Жол табылды. Расстояние = 3 (болду 4) 3 2 ⭢ 3 ⭢ 4 Жол табылды. Расстояние = 2 (болгон 3) Бул алгоритмдин акыркы итерациясы болгон. Болгону , , уячаларын жаңы аралыктар менен жаңыртып, , , уячаларын коюу гана калды. чейин . W[0,4] W[1,4] W[2,4] R[0,4] R[1,4] R[2,4] R[2,4] 3 Алгоритмдин аягында бизде эмне бар (7-сүрөт). билгенибиздей, матрицасы азыр графигиндеги эң кыска жолдордун бардык жуптарын камтыйт. Бирок матрицасында эмне сакталат? Мурунку посттон W G R Үйгө кайтуу Биз жаңы эң кыска жолду тапкан сайын, биз матрицасынын тиешелүү уячасын учурдагы мааниси менен жаңыртып жаттык. Башында бул аракет табышмактуу көрүнүшү мүмкүн, бирок анын абдан жөнөкөй мааниси бар – биз көздөгөн чокуга жеткен чокусун сактап жатканбыз: (бул жерде биз дан түз эле жетип жатабыз). R k i ⭢ k ⭢ j k j Бул учур чечүүчү. Анткени биз келген чокуну билүү чокусунан ("чейин") чокусуна ("ден") артка (омар сыяктуу) жылып, маршрутту кайра курууга мүмкүндүк берет. j i Бул жерде чейинки маршрутту калыбына келтирүү алгоритминин тексттик сүрөттөлүшү: i j Бош динамикалык массив даярдаңыз. X уячасынан маанисин окуу менен баштаңыз. R[i,j] z Эгерде болсо, анда чейинки маршрут табылып, №7 кадамга өтүшүбүз керек. z NO_EDGE i j динамикалык массивинин алдына жазыңыз. X z уячасынын маанисин ичинде оку. R[i,z] z №3, №4 жана №5 кадамдарды №3 чыгуу шартына жеткенге чейин кайталаңыз. X дегенге жаз. i тиркеңиз. X j Эми динамикалык массиви чейинки маршрутту камтыйт. X i j Эскертүү, жогорудагы алгоритм учурдагы жолдор үчүн гана иштейт. Ал ошондой эле аткаруу жагынан идеалдуу эмес жана аны оптималдаштырууга болот. Бирок, бул билдирүүнүн алкагында, ал кагаз бетинде түшүнүү жана кайра чыгарууну жеңилдетүү үчүн атайын сүрөттөлгөн. - Автордун эскертүүсү Псевдокоддо ал дагы жөнөкөй көрүнөт: algorithm RebuildRoute(i, j, R) x = array() z = R[i,j] while (z ne NO_EDGE) do x.prepend(z) z = R[i,z] end while x.prepend(i) x.append(j) return x end algorithm Келгиле, аны графигибизде сынап көрөлү жана чокусунан чокусуна чейин маршрутту кайра түзөлү (8-сүрөттү карагыла). G 0 4 Бул жерде жогорудагы иллюстрациянын тексттик сүрөттөлүшү: Биз маанисин окуу менен баштайбыз, натыйжада . Алгоритмге ылайык, бул маршрут чокусунан чокусуна барат дегенди билдирет (КӨК түстө көрсөтүлгөн). R[0,4] 3 3 4 мааниси эмес болгондуктан, биз улантып, маанисин окуйбуз, натыйжада чыгат (ЖАШЫЛ түстө көрсөтүлгөн). R[0,4] NO_EDGE R[0,3] 2 Дагы, мааниси эмес болгондуктан, биз улантып, маанисин окуйбуз, натыйжада чыгат (КЫЗЫЛ түстө көрсөтүлгөн). R[0,3] NO_EDGE R[0,2] 1 Акыры, биз маанисин окудук, анын натыйжасында мааниси пайда болот, башкача айтканда, маршрутка салым кошкон жана башка чокулар жок. Демек, натыйжада жол: , эгерде сиз графикти карасаңыз, чындыгында чокусунан чокусуна чейинки эң кыска жолго туура келет. R[0,1] NO_EDGE 0 4 0 ⭢ 1 ⭢ 2 ⭢ 3 ⭢ 4 0 4 Ойлуу окурман сурашы мүмкүн: матрицасынан окуган бардык маалыматтар бир жолго таандык экенине кантип ишенсек болот? R - Ойлуу окурман Бул абдан жакшы суроо. Биз ишенебиз, анткени биз матрицасында эң кыска жол маанисин жаңыртканыбызда матрицасын жаңы маани менен жаңыртабыз. Ошентип, акырында, матрицасынын ар бир сабында эң кыска жолдорго тиешелүү маалыматтар камтылган. Артык эмес, кем эмес. W R R Азыр биз теория менен бүттүк, ишке ашыруу мезгили келди. C# тилинде ишке ашыруу , Флойд-Уоршалл алгоритминин оригиналдуу версиясын ишке ашыруудан тышкары, биз ар кандай оптималдаштырууларды да бириктирдик: сейрек графиктерди колдоо, параллелизм жана векторизация, акырында биз алардын баарын бириктирдик. Мурунку постто Мунун баарын бүгүн кайталоого эч кандай негиз жок. Бирок, мен сизге маршруттук жаттоону алгоритмдин оригиналдуу жана векторлоштурулган (сейрек графиктердин колдоосу менен) версияларына кантип интеграциялоону көрсөткүм келет. Оригиналдуу Алгоритмге Интеграция Ишенүү кыйын болушу мүмкүн, бирок маршрутту жаттап алууну алгоритмдердин оригиналдуу версиясына кошуу үчүн, биз эмне кылышыбыз керек: матрицасын өзүнчө параметр катары кошуу үчүн функциянын кол тамгасын кеңейтүү – . R int[] routes Эң кыска жол өзгөргөн сайын k сактаңыз (саптар: 2 жана 14). routes Мына ушундай. Болгону бир жарым сап код: public void BaselineWithRoutes( int[] matrix, int[] routes, int sz) { for (var k = 0; k < sz; ++k) { for (var i = 0; i < sz; ++i) { for (var j = 0; j < sz; ++j) { var distance = matrix[i * sz + k] + matrix[k * sz + j]; if (matrix[i * sz + j] > distance) { matrix[i * sz + j] = distance; routes[i * sz + j] = k; } } } } } Векторизацияланган алгоритмге интеграция Векторлоштурулган (сейрек графиктердин колдоосу менен) версияга интеграциялоо аягына чыгаруу үчүн бир аз көбүрөөк күч-аракетти талап кылат, бирок концептуалдык кадамдар бирдей: матрицасын өзүнчө параметр катары кошуу үчүн функциянын кол тамгасын кеңейтүү – . R int[] routes Ар бир итерацияда маанилердин жаңы векторун инициализациялаңыз (сап: 6). k Эң кыска жол өзгөргөн сайын маанилердин векторун сактаңыз (саптар: 31-32). routes k Алгоритмдин векторлоштурулбаган бөлүгүн биз баштапкы алгоритмди жаңырткандай эле жаңыртыңыз (сап: 41). public void SpartialVectorOptimisationsWithRoutes( int[] matrix, int[] routes, int sz) { for (var k = 0; k < sz; ++k) { var k_vec = new Vector<int>(k); for (var i = 0; i < sz; ++i) { if (matrix[i * sz + k] == Constants.NO_EDGE) { continue; } var ik_vec = new Vector<int>(matrix[i * sz + k]); var j = 0; for (; j < sz - Vector<int>.Count; j += Vector<int>.Count) { var ij_vec = new Vector<int>(matrix, i * sz + j); var ikj_vec = new Vector<int>(matrix, k * sz + j) + ik_vec; var lt_vec = Vector.LessThan(ij_vec, ikj_vec); if (lt_vec == new Vector<int>(-1)) { continue; } var r_vec = Vector.ConditionalSelect(lt_vec, ij_vec, ikj_vec); r_vec.CopyTo(matrix, i * sz + j); var ro_vec = new Vector<int>(routes, i * sz + j); var rk_vec = Vector.ConditionalSelect(lt_vec, ro_vec, k_vec); rk_vec.CopyTo(routes, i * sz + j); } for (; j < sz; ++j) { var distance = matrix[i * sz + k] + matrix[k * sz + j]; if (matrix[i * sz + j] > distance) { matrix[i * sz + j] = distance; routes[i * sz + j] = k; } } } } } Кичинекей эскертүү – операциясы жаңы векторду кайтарат, мында маанилер кириш векторлорунун эки тиешелүү маанилеринин кичирээктерине барабар, башкача айтканда, векторунун мааниси ге барабар болсо, анда операция маани тандайт, антпесе, ичинен маанини тандайт. Vector.ConditionalSelect lt_vec -1 ij_vec k_vec - Автордун эскертүүсү Бенчмаркинг Маршрут маалыматын чогултуу жетиштүү акылга сыярлык, анткени... эмне үчүн? Айрыкча, учурдагы алгоритмдерге интеграциялоо оңой болгондо. Бирок, келгиле, аны демейки боюнча интеграциялоо канчалык практикалык экенин карап көрөлү. Бул жерде эталондордун эки топтому бар: бар жана жок (экөө тең алгоритмдин оригиналдуу жана векторлоштурулган версияларын аткарат). Бардык эталондор v0.13.1 (.NET 6.0.4 x64) тарабынан Intel Core i5-6200U CPU 2.30GHz (Skylake) процессору менен жабдылган жана Windows 10 иштетилген машинада аткарылган. BenchmarkDotNet Бардык эксперименттер колдонулган графиктердин алдын ала аныкталган топтому боюнча аткарылды: 300, 600, 1200, 2400 жана 4800 чокулары. мурунку постто Булак коду жана эксперименталдык графиктер GitHub репозиторийинде жеткиликтүү. Эксперименталдык графиктерди каталогунан тапса болот. Data/Sparse-Graphs.zip Бул посттогу бардык эталондор APSP02.cs файлында ишке ашырылган. Төмөндө эталондук жыйынтыктар келтирилген, анда жана ыкмалары алгоритмдин баштапкы версиясына, ал эми жана ыкмалары алгоритмдин векторлоштурулган (сейрек графиктерди колдоо менен) версиясына туура келет. Baseline BaselineWithRoutes SpartialVectorOptimisations SpartialVectorOptimisationsWithRoutes Метод Өлчөмү Орточо (мс) Ката (мс) StdDev (мс) Базалык 300 40.233 0.0572 0.0477 BaselineWithRoutes 300 40.349 0.1284 0.1201 SpartialVectorOptimizations 300 4.472 0.0168 0.0140 SpartialVectorOptimizationsWithRoutes 300 4.517 0.0218 0.0193 Базалык 600 324.637 5.6161 4.6897 BaselineWithRoutes 600 321.173 1.4339 1.2711 SpartialVectorOptimizations 600 32.133 0.2151 0.1679 SpartialVectorOptimizationsWithRoutes 600 34.646 0.1286 0.1073 Базалык 1200 2,656.024 6.9640 5.8153 BaselineWithRoutes 1200 2,657.883 8.8814 7.4164 SpartialVectorOptimizations 1200 361.435 2.5877 2.2940 SpartialVectorOptimizationsWithRoutes 1200 381.625 3.6975 3.2777 Базалык 2400 21,059.519 38.2291 33.8891 BaselineWithRoutes 2400 20,954.852 56.4719 50.0609 SpartialVectorOptimizations 2400 3,029.488 12.1528 11.3677 SpartialVectorOptimizationsWithRoutes 2400 3,079.006 8.6125 7.1918 Базалык 4800 164,869,803 547.6675 427.5828 BaselineWithRoutes 4800 184,305. 980 210.9535 164.6986 SpartialVectorOptimizations 4800 21,882.379 200.2820 177.5448 SpartialVectorOptimizationsWithRoutes 4800 21,004.612 79.8752 70.8073 Эталондук натыйжаларды чечмелөө оңой эмес. Эгер жакшылап карасаңыз, маршрутту чогултуу алгоритмди тезирээк же такыр эч кандай таасирсиз иштеткен комбинацияны табасыз. Бул абдан чаташкан көрүнөт (жана ошондой болсо – мен сизге жана менен бул угууну катуу кеңеш берем, кандай татаал нерселер өлчөөлөргө таасир эте аларын жакшыраак түшүнүңүз). Бул жерде менин эң жакшы түшүнүгүм "чаташкан" жүрүм-турум CPU кэшинин мыкты иштешинен улам келип чыгат (анткени графиктер кэштерди толтурууга жетиштүү чоң эмес). Жарым-жартылай, бул теория " " үлгүгө негизделген, анда биз эң чоң графикте олуттуу деградацияны көрө алабыз. Бирок, мен аны текшерген жокмун. Денис Бахвалов Андрей Акиншин шоуну калың Жалпысынан, эталон көрсөткөндөй, эгерде биз өтө жогорку деңгээлдеги сценарийлер жана чоң графиктер жөнүндө сөз кылбасак, маршруттук жаттап алууну эки алгоритмге тең демейки боюнча интеграциялоо туура болот (бирок эсиңизде болсун, эстутум керектөө эки эсеге көбөйөт, анткени биз бир матрицанын ордуна жана эки матрицаны бөлүңүз). W R Маршрутту калыбына келтирүү алгоритмин ишке ашыруу гана калды. Маршрутту кайра куруу алгоритмин ишке ашыруу C# тилинде маршрутту калыбына келтирүү алгоритмдерин ишке ашыруу жөнөкөй жана мурда көрсөтүлгөн псевдокодду дээрлик толугу менен чагылдырат (биз динамикалык массивди көрсөтүү үчүн колдонобуз): LinkedList<T> public static IEnumerable<int> RebuildWithLinkedList( int[] routes, int sz, int i, int j) { var x = new LinkedList<int>(); var z = routes[i * sz + j]; while (z != Constants.NO_EDGE) { x.AddFirst(z); z = routes[i * sz + z]; } x.AddFirst(i); x.AddLast(j); return x; } Жогорудагы алгоритм идеалдуу эмес жана аны жакшыртууга болот. Биз жасай ала турган эң жөнөкөй жакшыртуу бул өлчөмүндөгү массивди алдын ала бөлүштүрүү жана аны тескери тартипте толтуруу: sz public static IEnumerable<int> RebuildWithArray( int[] routes, int sz, int i, int j) { var x = new int[sz]; var y = sz - 1; // Fill array from the end x[y--] = j; var z = routes[i * sz + j]; while (z != Constants.NO_EDGE) { x[y--] = z; z = routes[i * sz + z]; } x[y] = i; // Create an array segment from 'y' to the end of the array // // This is required because 'sz' (and therefore length of the array x) // equals to the number of vertices in the graph // // So, in cases when route doesn't span through // all vertices - we need return a segment of the array return new ArraySegment<int>(x, y, sz - y); } Бул “оптималдаштыруу” бөлүштүрүүнүн санын бирге чейин азайтса да, ал алгоритмди мурункуга караганда “тезирээк” же “эстутумду азыраак бөлөт”. Бул жерде кеп, эгер биз чейин буйрутмаланган маршрутка ээ болушубуз керек болсо, биз матрицасынан алган маалыматтарды ар дайым “кайтарым” кылышыбыз керек жана андан кутулууга эч кандай жол жок. i j R Бирок биз маалыматтарды тескери тартипте бере алсак, анда биз LINQ колдонуп, керексиз бөлүштүрүүдөн кача алабыз: public static IEnumerable<int> RebuildWithReverseYield( int[] routes, int sz, int i, int j) { yield return j; var z = routes[i * sz + j]; while (z != Constants.NO_EDGE) { yield return z; z = routes[i * sz + z]; } yield return i; } Бул кодду "өзгөртүү" же "жакшыртуу" боюнча дагы көптөгөн варианттар болушу мүмкүн. Бул жерде маанилүү учур эстен чыгарбоо керек - ар кандай "өзгөрүүлөр" эс тутумунун же CPU циклинин терс жактары бар. Эксперименттен тартынбаңыз! Мүмкүнчүлүктөр дээрлик чексиз! Routes.cs файлында GitHub'тагы бардык маршруттук алгоритмдердин ишке ашырылышын таба аласыз. Корутунду Бул постто биз Флойд-Уоршалл алгоритминин артындагы теорияга терең сүңгүп кирдик жана аны эң кыска жолдордун "маршруттарын" жаттоо мүмкүнчүлүгү менен кеңейттик. Андан кийин биз C# ишке ашырууларын (оригиналдуу жана векторлоштурулган) жаңырттык. Акыр-аягы, биз "маршруттарды" кайра куруу үчүн алгоритмдин бир нече версиясын ишке ашырдык. мурунку посттон Биз көп кайталадык, бирок ошол эле учурда, мен сиз бул жаңы жана кызыктуу бир нерсе таптым деп үмүттөнөм. Бул бардык түгөйлөрдүн эң кыска жолдорунун көйгөйүнүн аягы эмес. Бул башталышы гана. Окуу сизге жакты деп үмүттөнөм жана кийинки жолу көрүшкөнчө!